修改提交

This commit is contained in:
Bob.Song
2026-03-09 17:50:20 +08:00
parent 68beeb3417
commit 27b85fd875
228 changed files with 30829 additions and 1509 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8301b99c61e7b7e44befcf784c72a226
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using Codely.Newtonsoft.Json.Linq;
namespace UnityTcp.Editor.Tools
{
/// <summary>
/// Registry for all Unity Tool command handlers (Upgraded Version)
/// </summary>
public static class CommandRegistry
{
// Maps command names to the corresponding static HandleCommand method in tool classes
private static readonly Dictionary<string, Func<JObject, object>> _handlers = new()
{
// Core tools
{ "HandleManageScript", ManageScript.HandleCommand },
{ "HandleManageScene", ManageScene.HandleCommand },
{ "HandleManageEditor", ManageEditor.HandleCommand },
{ "HandleManageGameObject", ManageGameObject.HandleCommand },
{ "HandleManageAsset", ManageAsset.HandleCommand },
{ "HandleReadConsole", ReadConsole.HandleCommand },
{ "HandleExecuteMenuItem", ExecuteMenuItem.HandleCommand },
{ "HandleManageShader", ManageShader.HandleCommand},
{ "HandleManageScreenshot", ManageScreenshot.HandleCommand },
// [EXPERIMENTAL] Phase 3 tools
{ "HandleManagePackage", ManagePackage.HandleCommand },
{ "HandleManageBake", ManageBake.HandleCommand },
{ "HandleManageUIToolkit", ManageUIToolkit.HandleCommand },
// Custom tool execution (API Spec aligned)
{ "HandleExecuteCustomTool", ExecuteCustomTool.HandleCommand },
{ "HandleExecuteCSharpScript", ExecuteCSharpScript.HandleCommand },
// [INTERNAL] Not exposed to LLM - for agent execution layer only
{ "Handle_InternalStateDirty", _InternalStateDirtyNotifier.HandleCommand },
};
/// <summary>
/// Gets a command handler by name.
/// </summary>
/// <param name="commandName">Name of the command handler (e.g., "HandleManageAsset").</param>
/// <returns>The command handler function if found, null otherwise.</returns>
public static Func<JObject, object> GetHandler(string commandName)
{
// Use case-insensitive comparison for flexibility, although Python side should be consistent
return _handlers.TryGetValue(commandName, out var handler) ? handler : null;
// Consider adding logging here if a handler is not found
/*
if (_handlers.TryGetValue(commandName, out var handler)) {
return handler;
} else {
UnityEngine.Debug.LogError($\"[CommandRegistry] No handler found for command: {commandName}\");
return null;
}
*/
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 333f972789d338c4aafe2236cb5ac3cf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,204 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Codely.Newtonsoft.Json.Linq;
using UnityEngine;
using UnityTcp.Editor.Helpers;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
namespace UnityTcp.Editor.Tools
{
/// <summary>
/// Executes C# scripts using Microsoft.CodeAnalysis.CSharp.Scripting (Roslyn).
/// Captures and returns logs generated during script execution.
/// Ensures execution happens on the main thread.
/// </summary>
public static class ExecuteCSharpScript
{
private static List<string> _capturedLogs = new List<string>();
private static bool _isCapturingLogs = false;
/// <summary>
/// Main handler for executing C# scripts.
/// </summary>
public static object HandleCommand(JObject @params)
{
string script = @params["script"]?.ToString();
if (string.IsNullOrEmpty(script))
{
return Response.Error("'script' parameter is required.");
}
bool captureLogs = @params["capture_logs"]?.ToObject<bool>() ?? true;
string[] imports = @params["imports"]?.ToObject<string[]>() ?? new string[]
{
"System",
"System.Linq",
"System.Collections.Generic",
"UnityEngine",
"UnityEditor",
"UnityEditor.SceneManagement",
"UnityEngine.SceneManagement"
};
try
{
Debug.Log($"[ExecuteCSharpScript] Executing C# script (length: {script.Length} chars)");
StartLogCapture(captureLogs);
object result;
try
{
result = ExecuteScriptInternal(script, imports);
}
finally
{
// Always stop log capture, even on error
}
var logs = captureLogs ? StopLogCapture() : new List<string>();
return Response.Success(
"C# script executed successfully.",
new
{
result = result?.ToString(),
logs = logs,
log_count = logs.Count
}
);
}
catch (Exception e)
{
var logs = captureLogs ? StopLogCapture() : new List<string>();
Debug.LogError($"[ExecuteCSharpScript] Failed to execute script: {e}");
return Response.Error(
$"C# script execution failed: {e.Message}",
new
{
logs = logs,
exception = e.ToString()
}
);
}
}
/// <summary>
/// Internal method to execute the script using Roslyn.
/// </summary>
private static object ExecuteScriptInternal(string script, string[] imports)
{
try
{
// Collect assembly references
var references = new List<System.Reflection.Assembly>
{
typeof(UnityEngine.Debug).Assembly,
typeof(UnityEditor.EditorApplication).Assembly
};
// Add Assembly-CSharp if it exists (runtime user code)
var assemblyCSharp = System.AppDomain.CurrentDomain.GetAssemblies()
.FirstOrDefault(a => a.GetName().Name == "Assembly-CSharp");
if (assemblyCSharp != null)
{
references.Add(assemblyCSharp);
}
// Add Assembly-CSharp-Editor if it exists (editor user code)
var assemblyCSharpEditor = System.AppDomain.CurrentDomain.GetAssemblies()
.FirstOrDefault(a => a.GetName().Name == "Assembly-CSharp-Editor");
if (assemblyCSharpEditor != null)
{
references.Add(assemblyCSharpEditor);
}
// Add Unity.InputSystem if it exists (Input System package)
var inputSystemAssembly = System.AppDomain.CurrentDomain.GetAssemblies()
.FirstOrDefault(a => a.GetName().Name == "Unity.InputSystem");
if (inputSystemAssembly != null)
{
references.Add(inputSystemAssembly);
}
// Create script options with imports
var options = ScriptOptions.Default
.WithReferences(references)
.WithImports(imports);
// Execute the script synchronously
var scriptTask = CSharpScript.EvaluateAsync(script, options);
// Wait for the task to complete
scriptTask.Wait();
return scriptTask.Result;
}
catch (AggregateException ae)
{
// Unwrap AggregateException to get the actual exception
if (ae.InnerException != null)
{
throw ae.InnerException;
}
throw;
}
}
/// <summary>
/// Starts capturing Unity logs.
/// </summary>
private static void StartLogCapture(bool enabled)
{
if (!enabled)
{
_isCapturingLogs = false;
return;
}
_capturedLogs.Clear();
_isCapturingLogs = true;
Application.logMessageReceived += OnLogMessageReceived;
}
/// <summary>
/// Stops capturing logs and returns the captured log list.
/// </summary>
private static List<string> StopLogCapture()
{
Application.logMessageReceived -= OnLogMessageReceived;
_isCapturingLogs = false;
var logs = new List<string>(_capturedLogs);
_capturedLogs.Clear();
return logs;
}
/// <summary>
/// Log message callback handler.
/// </summary>
private static void OnLogMessageReceived(string logString, string stackTrace, LogType type)
{
if (!_isCapturingLogs)
return;
var logEntry = new StringBuilder();
logEntry.Append($"[{type}] {logString}");
// Include stack trace for errors and exceptions
if ((type == LogType.Error || type == LogType.Exception) && !string.IsNullOrEmpty(stackTrace))
{
logEntry.Append($"\n{stackTrace}");
}
_capturedLogs.Add(logEntry.ToString());
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a919bae4d47922248a206faa7ba67ed7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,217 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using Codely.Newtonsoft.Json.Linq;
using UnityEngine;
using UnityTcp.Editor.Helpers;
namespace UnityTcp.Editor.Tools
{
/// <summary>
/// Executes custom tools registered in the Unity project.
/// Custom tools must be static methods with a specific signature:
/// public static object ToolName(JObject parameters)
///
/// Tools can be registered via the [CustomTool] attribute or
/// by convention in the CustomToolsRegistry static class.
/// </summary>
public static class ExecuteCustomTool
{
// Registry of custom tools: tool_name -> method info
private static readonly Dictionary<string, MethodInfo> _registeredTools = new Dictionary<string, MethodInfo>();
private static bool _initialized = false;
private static readonly object _initLock = new object();
/// <summary>
/// Attribute to mark a method as a custom tool.
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class CustomToolAttribute : Attribute
{
public string Name { get; }
public string Description { get; }
public CustomToolAttribute(string name, string description = null)
{
Name = name;
Description = description;
}
}
/// <summary>
/// Main handler for executing custom tools.
/// </summary>
public static object HandleCommand(JObject @params)
{
// Ensure tools are discovered
EnsureInitialized();
string toolName = @params["tool_name"]?.ToString();
if (string.IsNullOrEmpty(toolName))
{
return Response.Error("'tool_name' parameter is required.");
}
JObject toolParams = @params["parameters"] as JObject ?? new JObject();
try
{
// Check if tool exists
if (!_registeredTools.TryGetValue(toolName, out MethodInfo method))
{
// Try case-insensitive lookup
method = FindToolCaseInsensitive(toolName);
if (method == null)
{
return Response.Error($"Custom tool '{toolName}' not found. Available tools: {string.Join(", ", _registeredTools.Keys)}");
}
}
// Execute the tool
Debug.Log($"[ExecuteCustomTool] Executing custom tool: {toolName}");
var result = method.Invoke(null, new object[] { toolParams });
// Wrap result in standard response format if not already
if (result is Dictionary<string, object> dictResult && dictResult.ContainsKey("success"))
{
// Already in standard format
return result;
}
// Wrap in success response
return new
{
success = true,
message = $"Custom tool '{toolName}' executed successfully.",
data = result,
state = StateComposer.BuildFullState()
};
}
catch (TargetInvocationException tie)
{
// Unwrap the inner exception
var innerEx = tie.InnerException ?? tie;
Debug.LogError($"[ExecuteCustomTool] Tool '{toolName}' failed: {innerEx}");
return Response.Error($"Custom tool '{toolName}' execution failed: {innerEx.Message}");
}
catch (Exception e)
{
Debug.LogError($"[ExecuteCustomTool] Error executing tool '{toolName}': {e}");
return Response.Error($"Error executing custom tool '{toolName}': {e.Message}");
}
}
/// <summary>
/// Registers a custom tool manually.
/// </summary>
public static void RegisterTool(string name, MethodInfo method)
{
lock (_initLock)
{
if (_registeredTools.ContainsKey(name))
{
Debug.LogWarning($"[ExecuteCustomTool] Tool '{name}' is already registered. Overwriting.");
}
_registeredTools[name] = method;
Debug.Log($"[ExecuteCustomTool] Registered custom tool: {name}");
}
}
/// <summary>
/// Lists all registered custom tools.
/// </summary>
public static IEnumerable<string> GetRegisteredTools()
{
EnsureInitialized();
return _registeredTools.Keys;
}
/// <summary>
/// Discovers and registers all custom tools in the project.
/// </summary>
private static void EnsureInitialized()
{
lock (_initLock)
{
if (_initialized)
return;
try
{
// Scan all assemblies for methods with [CustomTool] attribute
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
// Skip system assemblies for performance, but keep UnityTcp assemblies
var assemblyName = assembly.GetName().Name;
if (assemblyName.StartsWith("UnityTcp"))
{
// Always scan our own assemblies
}
else if (assemblyName.StartsWith("System") ||
assemblyName.StartsWith("mscorlib") ||
assemblyName.StartsWith("Unity") ||
assemblyName.StartsWith("Newtonsoft") ||
assemblyName.StartsWith("netstandard") ||
assemblyName.StartsWith("Microsoft"))
continue;
try
{
foreach (var type in assembly.GetTypes())
{
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
var attr = method.GetCustomAttribute<CustomToolAttribute>();
if (attr != null)
{
// Validate signature
var parameters = method.GetParameters();
if (parameters.Length == 1 && parameters[0].ParameterType == typeof(JObject))
{
_registeredTools[attr.Name] = method;
Debug.Log($"[ExecuteCustomTool] Discovered custom tool: {attr.Name} ({type.FullName}.{method.Name})");
}
else
{
Debug.LogWarning($"[ExecuteCustomTool] Invalid signature for tool '{attr.Name}'. Expected: public static object ToolName(JObject parameters)");
}
}
}
}
}
catch (ReflectionTypeLoadException)
{
// Ignore assembly load errors
}
}
Debug.Log($"[ExecuteCustomTool] Initialization complete. {_registeredTools.Count} custom tools registered.");
}
catch (Exception e)
{
Debug.LogError($"[ExecuteCustomTool] Failed to initialize: {e}");
}
finally
{
_initialized = true;
}
}
}
/// <summary>
/// Finds a tool by name (case-insensitive).
/// </summary>
private static MethodInfo FindToolCaseInsensitive(string toolName)
{
foreach (var kvp in _registeredTools)
{
if (string.Equals(kvp.Key, toolName, StringComparison.OrdinalIgnoreCase))
{
return kvp.Value;
}
}
return null;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5ef1416a06c8bce4caaff3a2b88aaafe
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,130 @@
using System;
using System.Collections.Generic; // Added for HashSet
using Codely.Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityTcp.Editor.Helpers; // For Response class
namespace UnityTcp.Editor.Tools
{
/// <summary>
/// Handles executing Unity Editor menu items by path.
/// </summary>
public static class ExecuteMenuItem
{
// Basic blacklist to prevent accidental execution of potentially disruptive menu items.
// This can be expanded based on needs.
private static readonly HashSet<string> _menuPathBlacklist = new HashSet<string>(
StringComparer.OrdinalIgnoreCase
)
{
"File/Quit",
// Add other potentially dangerous items like "Edit/Preferences...", "File/Build Settings..." if needed
};
/// <summary>
/// Main handler for executing menu items or getting available ones.
/// </summary>
public static object HandleCommand(JObject @params)
{
string action = (@params["action"]?.ToString())?.ToLowerInvariant() ?? "execute"; // Default action
try
{
switch (action)
{
case "execute":
return ExecuteItem(@params);
case "get_available_menus":
// Getting a comprehensive list of *all* menu items dynamically is very difficult
// and often requires complex reflection or maintaining a manual list.
// Returning a placeholder/acknowledgement for now.
Debug.LogWarning(
"[ExecuteMenuItem] 'get_available_menus' action is not fully implemented. Dynamically listing all menu items is complex."
);
// Returning an empty list as per the refactor plan's requirements.
return Response.Success(
"'get_available_menus' action is not fully implemented. Returning empty list.",
new List<string>()
);
// TODO: Consider implementing a basic list of common/known menu items or exploring reflection techniques if this feature becomes critical.
default:
return Response.Error(
$"Unknown action: '{action}'. Valid actions are 'execute', 'get_available_menus'."
);
}
}
catch (Exception e)
{
Debug.LogError($"[ExecuteMenuItem] Action '{action}' failed: {e}");
return Response.Error($"Internal error processing action '{action}': {e.Message}");
}
}
/// <summary>
/// Executes a specific menu item.
/// </summary>
private static object ExecuteItem(JObject @params)
{
// Try both naming conventions: snake_case and camelCase
string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString();
// Optional future param retained for API compatibility; not used in synchronous mode
// int timeoutMs = Math.Max(0, (@params["timeout_ms"]?.ToObject<int>() ?? 2000));
// string alias = @params["alias"]?.ToString(); // TODO: Implement alias mapping based on refactor plan requirements.
// JObject parameters = @params["parameters"] as JObject; // TODO: Investigate parameter passing (often not directly supported by ExecuteMenuItem).
if (string.IsNullOrWhiteSpace(menuPath))
{
return Response.Error("Required parameter 'menu_path' or 'menuPath' is missing or empty.");
}
// Validate against blacklist
if (_menuPathBlacklist.Contains(menuPath))
{
return Response.Error(
$"Execution of menu item '{menuPath}' is blocked for safety reasons."
);
}
// TODO: Implement alias lookup here if needed (Map alias to actual menuPath).
// if (!string.IsNullOrEmpty(alias)) { menuPath = LookupAlias(alias); if(menuPath == null) return Response.Error(...); }
// TODO: Handle parameters ('parameters' object) if a viable method is found.
// This is complex as EditorApplication.ExecuteMenuItem doesn't take arguments directly.
// It might require finding the underlying EditorWindow or command if parameters are needed.
try
{
// Trace incoming execute requests (debug-gated)
TcpLog.Info($"[ExecuteMenuItem] Request to execute menu: '{menuPath}'", always: false);
// Execute synchronously. This code runs on the Editor main thread in our bridge path.
bool executed = EditorApplication.ExecuteMenuItem(menuPath);
if (executed)
{
// Success trace (debug-gated)
TcpLog.Info($"[ExecuteMenuItem] Executed successfully: '{menuPath}'", always: false);
return Response.Success(
$"Executed menu item: '{menuPath}'",
new { executed = true, menuPath }
);
}
Debug.LogWarning($"[ExecuteMenuItem] Failed (not found/disabled): '{menuPath}'");
return Response.Error(
$"Failed to execute menu item (not found or disabled): '{menuPath}'",
new { executed = false, menuPath }
);
}
catch (Exception e)
{
Debug.LogError($"[ExecuteMenuItem] Error executing '{menuPath}': {e}");
return Response.Error($"Error executing menu item '{menuPath}': {e.Message}");
}
}
// TODO: Add helper for alias lookup if implementing aliases.
// private static string LookupAlias(string alias) { ... return actualMenuPath or null ... }
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 896e8045986eb0d449ee68395479f1d6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8d58d03981d97594b80e043a0ba78f55
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,740 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Codely.Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.PackageManager;
using UnityEngine;
using UnityTcp.Editor.Helpers;
namespace UnityTcp.Editor.Tools
{
/// <summary>
/// [EXPERIMENTAL] Handles baking operations (NavMesh, Lighting, etc.).
/// Compatible with Unity 2022.3 LTS.
/// </summary>
public static class ManageBake
{
// Store callbacks for proper unsubscription
private static readonly Dictionary<string, EditorApplication.CallbackFunction> _updateCallbacks = new Dictionary<string, EditorApplication.CallbackFunction>();
private static readonly object _callbackLock = new object();
// Store async operations for NavMesh baking
private static readonly Dictionary<string, List<AsyncOperation>> _navMeshBakeOperations = new Dictionary<string, List<AsyncOperation>>();
// Runtime check for AI Navigation package availability
private static bool? _hasAINavigation = null;
private static Type _navMeshSurfaceType = null;
private static MethodInfo _buildNavMeshMethod = null;
private static MethodInfo _updateNavMeshMethod = null;
private static PropertyInfo _activeSurfacesProperty = null;
private static Type _navMeshType = null;
private static MethodInfo _calculateTriangulationMethod = null;
private static MethodInfo _removeAllNavMeshDataMethod = null;
/// <summary>
/// Reset the AI Navigation package cache. Call this after installing the package
/// to force re-checking for available types.
/// </summary>
private static void ResetAINavigationCache()
{
_hasAINavigation = null;
_navMeshSurfaceType = null;
_buildNavMeshMethod = null;
_updateNavMeshMethod = null;
_activeSurfacesProperty = null;
_navMeshType = null;
_calculateTriangulationMethod = null;
_removeAllNavMeshDataMethod = null;
}
private static bool HasAINavigation()
{
if (_hasAINavigation.HasValue)
return _hasAINavigation.Value;
try
{
// First, check if the package is installed via PackageManager
bool packageInstalled = false;
try
{
#if UNITY_2021_2_OR_NEWER
// Use GetAllRegisteredPackages for Unity 2021.2+
var packages = UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages();
packageInstalled = packages.Any(p => p.name == "com.unity.ai.navigation");
#else
// Fallback for older Unity versions
var listRequest = Client.List(true, false);
while (!listRequest.IsCompleted)
{
System.Threading.Thread.Sleep(50);
}
if (listRequest.Status == StatusCode.Success)
{
packageInstalled = listRequest.Result.Any(p => p.name == "com.unity.ai.navigation");
}
#endif
}
catch (Exception ex)
{
Debug.LogWarning($"[ManageBake] Error checking package installation: {ex.Message}");
// Continue with type checking as fallback
}
// Try to find NavMeshSurface type (Unity.AI.Navigation namespace from com.unity.ai.navigation package)
// Try multiple methods to find the type
_navMeshSurfaceType = Type.GetType("Unity.AI.Navigation.NavMeshSurface, Unity.AI.Navigation");
if (_navMeshSurfaceType == null)
{
// Try with full assembly qualified name variations
_navMeshSurfaceType = Type.GetType("Unity.AI.Navigation.NavMeshSurface, Unity.AI.Navigation, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null");
}
if (_navMeshSurfaceType == null)
{
// Fallback: search in loaded assemblies by name first
System.Reflection.Assembly targetAssembly = null;
foreach (var assembly in System.AppDomain.CurrentDomain.GetAssemblies())
{
var assemblyName = assembly.GetName().Name;
if (assemblyName == "Unity.AI.Navigation" || assemblyName.Contains("Unity.AI.Navigation"))
{
targetAssembly = assembly;
break;
}
}
if (targetAssembly != null)
{
_navMeshSurfaceType = targetAssembly.GetType("Unity.AI.Navigation.NavMeshSurface");
}
}
if (_navMeshSurfaceType == null)
{
// Last resort: search all assemblies
foreach (var assembly in System.AppDomain.CurrentDomain.GetAssemblies())
{
_navMeshSurfaceType = assembly.GetType("Unity.AI.Navigation.NavMeshSurface");
if (_navMeshSurfaceType != null) break;
}
}
if (_navMeshSurfaceType != null)
{
_buildNavMeshMethod = _navMeshSurfaceType.GetMethod("BuildNavMesh", BindingFlags.Public | BindingFlags.Instance);
_updateNavMeshMethod = _navMeshSurfaceType.GetMethod("UpdateNavMesh", BindingFlags.Public | BindingFlags.Instance);
_activeSurfacesProperty = _navMeshSurfaceType.GetProperty("activeSurfaces", BindingFlags.Public | BindingFlags.Static);
}
// Try to find NavMesh type (UnityEngine.AI namespace - still used by the package)
_navMeshType = Type.GetType("UnityEngine.AI.NavMesh, UnityEngine.AIModule");
if (_navMeshType == null)
{
foreach (var assembly in System.AppDomain.CurrentDomain.GetAssemblies())
{
_navMeshType = assembly.GetType("UnityEngine.AI.NavMesh");
if (_navMeshType != null) break;
}
}
if (_navMeshType != null)
{
_calculateTriangulationMethod = _navMeshType.GetMethod("CalculateTriangulation", BindingFlags.Public | BindingFlags.Static);
_removeAllNavMeshDataMethod = _navMeshType.GetMethod("RemoveAllNavMeshData", BindingFlags.Public | BindingFlags.Static);
}
// Check both package installation and required types/methods
bool hasRequiredTypes = _navMeshSurfaceType != null && _buildNavMeshMethod != null && _navMeshType != null;
// If package is installed but types are missing, check compilation status
if (packageInstalled && !hasRequiredTypes)
{
bool isCompiling = EditorApplication.isCompiling;
string compilationStatus = isCompiling ? "compiling" : "idle";
// Collect diagnostic information
var loadedAssemblies = System.AppDomain.CurrentDomain.GetAssemblies()
.Where(a => a.GetName().Name.Contains("AI") || a.GetName().Name.Contains("Navigation"))
.Select(a => a.GetName().Name)
.ToList();
string diagnosticInfo = "";
if (loadedAssemblies.Count > 0)
{
diagnosticInfo = $" Found related assemblies: {string.Join(", ", loadedAssemblies)}.";
}
else
{
diagnosticInfo = " No AI/Navigation assemblies found in loaded assemblies.";
}
string typeStatus = "";
if (_navMeshSurfaceType == null)
{
typeStatus += " NavMeshSurface type not found.";
}
else
{
typeStatus += $" NavMeshSurface found, but methods missing: BuildNavMesh={_buildNavMeshMethod != null}, UpdateNavMesh={_updateNavMeshMethod != null}, activeSurfaces={_activeSurfacesProperty != null}.";
}
if (_navMeshType == null)
{
typeStatus += " NavMesh type not found.";
}
Debug.LogWarning(
$"[ManageBake] com.unity.ai.navigation package is installed but required types/methods are not available. " +
$"Editor is currently {compilationStatus}.{diagnosticInfo}{typeStatus} " +
(isCompiling
? "Please wait for compilation to complete, then call 'unity_editor { \"action\": \"wait_for_idle\" }' before retrying."
: "The package may need to be reloaded. Try restarting Unity or wait a moment and retry.")
);
}
// Package installation check is primary, but we also need the types to be available
// If package is installed but types are missing and we're not compiling, return false
// If we're compiling, also return false (types won't be available until compilation completes)
_hasAINavigation = packageInstalled && hasRequiredTypes && !EditorApplication.isCompiling;
}
catch (Exception ex)
{
Debug.LogWarning($"[ManageBake] Error checking for AI Navigation package: {ex.Message}");
_hasAINavigation = false;
}
return _hasAINavigation.Value;
}
public static object HandleCommand(JObject @params)
{
string action = @params["action"]?.ToString().ToLower();
if (string.IsNullOrEmpty(action))
{
return Response.Error("Action parameter is required.");
}
try
{
switch (action)
{
case "bake_navmesh":
return BakeNavMesh(@params);
case "bake_lighting":
return BakeLighting(@params);
case "wait_for_bake":
return WaitForBake(@params);
case "clear_navmesh":
return ClearNavMesh();
case "clear_baked_data":
return ClearBakedData();
default:
return Response.Error(
$"Unknown action: '{action}'. Valid actions: bake_navmesh, bake_lighting, wait_for_bake, clear_navmesh, clear_baked_data."
);
}
}
catch (Exception e)
{
Debug.LogError($"[ManageBake] Action '{action}' failed: {e}");
return Response.Error($"[EXPERIMENTAL] Bake operation failed: {e.Message}");
}
}
private static object BakeNavMesh(JObject @params)
{
try
{
// Reset cache and re-check if first check fails (in case package was just installed)
if (!HasAINavigation())
{
ResetAINavigationCache();
if (!HasAINavigation())
{
// Check if package is installed but types are not available
bool packageInstalled = false;
try
{
#if UNITY_2021_2_OR_NEWER
var packages = UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages();
packageInstalled = packages.Any(p => p.name == "com.unity.ai.navigation");
#else
var listRequest = Client.List(true, false);
while (!listRequest.IsCompleted)
{
System.Threading.Thread.Sleep(50);
}
if (listRequest.Status == StatusCode.Success)
{
packageInstalled = listRequest.Result.Any(p => p.name == "com.unity.ai.navigation");
}
#endif
}
catch { }
bool isCompiling = EditorApplication.isCompiling;
string errorMessage;
if (packageInstalled && isCompiling)
{
errorMessage =
"[EXPERIMENTAL] NavMesh baking requires AI Navigation package types to be loaded. " +
"The package is installed but Unity is currently compiling. " +
"Please wait for compilation to complete by calling 'unity_editor { \"action\": \"wait_for_idle\" }', then retry.";
}
else if (packageInstalled)
{
errorMessage =
"[EXPERIMENTAL] NavMesh baking requires AI Navigation package types to be loaded. " +
"The package 'com.unity.ai.navigation' is installed but required types are not available. " +
"This may happen if: (1) compilation is in progress, (2) the package needs to be reloaded, or (3) Unity needs to be restarted. " +
"Try: (1) Call 'unity_editor { \"action\": \"wait_for_idle\" }' to ensure compilation is complete, " +
"(2) Wait a few seconds and retry, or (3) Restart Unity.";
}
else
{
errorMessage =
"[EXPERIMENTAL] NavMesh baking requires AI Navigation package. " +
"Install 'com.unity.ai.navigation' via Package Manager using: " +
"'unity_package { \"action\": \"install_package\", \"id_or_url\": \"com.unity.ai.navigation\" }', " +
"then wait for installation and compilation to complete using 'unity_editor { \"action\": \"wait_for_idle\" }'.";
}
return Response.Error(errorMessage);
}
}
var writeCheck = WriteGuard.CheckWriteAllowed("bake_navmesh");
if (writeCheck != null) return writeCheck;
var job = AsyncOperationTracker.CreateJob(
AsyncOperationTracker.JobType.NavMeshBake,
"Baking NavMesh..."
);
// Get all active NavMeshSurface components in the scene
List<object> surfaces = new List<object>();
if (_activeSurfacesProperty != null)
{
var activeSurfaces = _activeSurfacesProperty.GetValue(null);
if (activeSurfaces is System.Collections.IList surfaceList)
{
foreach (var surface in surfaceList)
{
surfaces.Add(surface);
}
}
}
if (surfaces.Count == 0)
{
// Fallback: find all NavMeshSurface components using Resources.FindObjectsOfTypeAll
if (_navMeshSurfaceType != null)
{
var allObjects = Resources.FindObjectsOfTypeAll(_navMeshSurfaceType);
foreach (var obj in allObjects)
{
if (obj != null)
{
surfaces.Add(obj);
}
}
}
}
if (surfaces.Count == 0)
{
return Response.Error("[EXPERIMENTAL] No NavMeshSurface components found in the scene. Add a NavMeshSurface component to a GameObject to bake NavMesh.");
}
// Check if we should use async baking (UpdateNavMesh) or sync baking (BuildNavMesh)
bool useAsync = @params["async"]?.ToObject<bool?>() ?? false;
List<AsyncOperation> asyncOps = new List<AsyncOperation>();
if (useAsync && _updateNavMeshMethod != null)
{
// Use async UpdateNavMesh for each surface that has existing data
foreach (var surface in surfaces)
{
try
{
var navMeshDataProperty = _navMeshSurfaceType.GetProperty("navMeshData");
if (navMeshDataProperty != null)
{
var navMeshData = navMeshDataProperty.GetValue(surface);
if (navMeshData != null)
{
var asyncOp = _updateNavMeshMethod.Invoke(surface, new object[] { navMeshData }) as AsyncOperation;
if (asyncOp != null)
{
asyncOps.Add(asyncOp);
}
}
}
}
catch
{
// If UpdateNavMesh fails, fall back to BuildNavMesh
}
}
}
// If no async operations were started, use synchronous BuildNavMesh
if (asyncOps.Count == 0)
{
foreach (var surface in surfaces)
{
_buildNavMeshMethod?.Invoke(surface, null);
}
// For synchronous baking, complete immediately
bool hasNavMeshData = false;
try
{
// First check NavMeshSurface components for navMeshData
if (_navMeshSurfaceType != null)
{
var navMeshDataProperty = _navMeshSurfaceType.GetProperty("navMeshData");
if (navMeshDataProperty != null)
{
foreach (var surface in surfaces)
{
if (surface != null)
{
var navMeshData = navMeshDataProperty.GetValue(surface);
if (navMeshData != null)
{
hasNavMeshData = true;
break;
}
}
}
}
}
// Fallback: check global NavMesh if NavMeshSurface check didn't find anything
if (!hasNavMeshData && _calculateTriangulationMethod != null)
{
var triangulation = _calculateTriangulationMethod.Invoke(null, null);
if (triangulation != null)
{
var verticesProperty = triangulation.GetType().GetProperty("vertices");
if (verticesProperty != null)
{
var vertices = verticesProperty.GetValue(triangulation) as Array;
hasNavMeshData = vertices != null && vertices.Length > 0;
}
}
}
}
catch { }
AsyncOperationTracker.CompleteJob(job.OpId, "NavMesh baking completed", new
{
hasNavMeshData = hasNavMeshData,
surfacesBaked = surfaces.Count
});
StateComposer.IncrementRevision();
return AsyncOperationTracker.CreateCompleteResponse(job);
}
else
{
// Store async operations for tracking
lock (_callbackLock)
{
_navMeshBakeOperations[job.OpId] = asyncOps;
}
// Create and store callback delegate for proper unsubscription
EditorApplication.CallbackFunction callback = () => CheckNavMeshBake(job.OpId);
lock (_callbackLock)
{
_updateCallbacks[job.OpId] = callback;
}
EditorApplication.update += callback;
// Return standardized pending response
var response = AsyncOperationTracker.CreatePendingResponse(job) as Dictionary<string, object>;
response["poll_interval"] = 2.0;
response["message"] = "[EXPERIMENTAL] NavMesh baking started (async)";
response["data"] = new { type = "navmesh", surfacesCount = surfaces.Count };
return response;
}
}
catch (Exception e)
{
return Response.Error($"[EXPERIMENTAL] Failed to start NavMesh baking: {e.Message}");
}
}
private static void CheckNavMeshBake(string opId)
{
if (!HasAINavigation())
return;
try
{
// Check if async operations are still running
List<AsyncOperation> asyncOps = null;
lock (_callbackLock)
{
if (_navMeshBakeOperations.TryGetValue(opId, out asyncOps))
{
// Check if all operations are done
bool allDone = asyncOps.All(op => op != null && op.isDone);
if (allDone)
{
// Remove from tracking
_navMeshBakeOperations.Remove(opId);
// Properly unsubscribe using stored delegate
if (_updateCallbacks.TryGetValue(opId, out var callback))
{
EditorApplication.update -= callback;
_updateCallbacks.Remove(opId);
}
// Check if NavMesh data exists using reflection
bool hasNavMeshData = false;
try
{
// First check NavMeshSurface components for navMeshData
if (_navMeshSurfaceType != null)
{
var navMeshDataProperty = _navMeshSurfaceType.GetProperty("navMeshData");
if (navMeshDataProperty != null && _activeSurfacesProperty != null)
{
var activeSurfaces = _activeSurfacesProperty.GetValue(null);
if (activeSurfaces is System.Collections.IList surfaceList)
{
foreach (var surface in surfaceList)
{
if (surface != null)
{
var navMeshData = navMeshDataProperty.GetValue(surface);
if (navMeshData != null)
{
hasNavMeshData = true;
break;
}
}
}
}
}
}
// Fallback: check global NavMesh if NavMeshSurface check didn't find anything
if (!hasNavMeshData && _calculateTriangulationMethod != null)
{
var triangulation = _calculateTriangulationMethod.Invoke(null, null);
if (triangulation != null)
{
var verticesProperty = triangulation.GetType().GetProperty("vertices");
if (verticesProperty != null)
{
var vertices = verticesProperty.GetValue(triangulation) as Array;
hasNavMeshData = vertices != null && vertices.Length > 0;
}
}
}
}
catch
{
// If we can't check, assume no data
hasNavMeshData = false;
}
AsyncOperationTracker.CompleteJob(opId, "NavMesh baking completed", new
{
hasNavMeshData = hasNavMeshData,
surfacesBaked = asyncOps.Count
});
StateComposer.IncrementRevision();
}
}
}
}
catch (Exception ex)
{
Debug.LogError($"[ManageBake] Error in CheckNavMeshBake: {ex.Message}");
}
}
private static object BakeLighting(JObject @params)
{
try
{
var writeCheck = WriteGuard.CheckWriteAllowed("bake_lighting");
if (writeCheck != null) return writeCheck;
var job = AsyncOperationTracker.CreateJob(
AsyncOperationTracker.JobType.LightingBake,
"Baking lighting..."
);
// Start async bake
Lightmapping.BakeAsync();
// Create and store callback delegate for proper unsubscription
EditorApplication.CallbackFunction callback = () => CheckLightingBake(job.OpId);
lock (_callbackLock)
{
_updateCallbacks[job.OpId] = callback;
}
EditorApplication.update += callback;
// Return standardized pending response
var response = AsyncOperationTracker.CreatePendingResponse(job) as Dictionary<string, object>;
response["poll_interval"] = 2.0;
response["message"] = "[EXPERIMENTAL] Lighting baking started";
response["data"] = new { type = "lighting" };
return response;
}
catch (Exception e)
{
return Response.Error($"[EXPERIMENTAL] Failed to start lighting baking: {e.Message}");
}
}
private static void CheckLightingBake(string opId)
{
if (!Lightmapping.isRunning)
{
// Properly unsubscribe using stored delegate
EditorApplication.CallbackFunction callback;
lock (_callbackLock)
{
if (_updateCallbacks.TryGetValue(opId, out callback))
{
EditorApplication.update -= callback;
_updateCallbacks.Remove(opId);
}
}
AsyncOperationTracker.CompleteJob(opId, "Lighting baking completed", new
{
hasLightingData = Lightmapping.lightingDataAsset != null
});
StateComposer.IncrementRevision();
}
}
private static object WaitForBake(JObject @params)
{
try
{
string opId = @params["op_id"]?.ToString();
int timeoutSeconds = @params["timeoutSeconds"]?.ToObject<int?>() ?? 600;
if (string.IsNullOrEmpty(opId))
return Response.Error("'op_id' parameter required for wait_for_bake.");
var job = AsyncOperationTracker.GetJob(opId);
if (job == null)
return Response.Error($"Operation {opId} not found.");
if (job.Type != AsyncOperationTracker.JobType.NavMeshBake &&
job.Type != AsyncOperationTracker.JobType.LightingBake)
return Response.Error($"Operation {opId} is not a bake operation.");
if (AsyncOperationTracker.IsJobTimedOut(opId, timeoutSeconds))
{
AsyncOperationTracker.FailJob(opId, $"Bake operation timed out after {timeoutSeconds} seconds");
return AsyncOperationTracker.CreateErrorResponse(job);
}
switch (job.Status)
{
case AsyncOperationTracker.JobStatus.Complete:
var response = AsyncOperationTracker.CreateCompleteResponse(job);
return response;
case AsyncOperationTracker.JobStatus.Error:
return AsyncOperationTracker.CreateErrorResponse(job);
case AsyncOperationTracker.JobStatus.Pending:
return AsyncOperationTracker.CreatePendingResponse(job);
default:
return Response.Error($"Unknown job status: {job.Status}");
}
}
catch (Exception e)
{
return Response.Error($"[EXPERIMENTAL] Failed to wait for bake: {e.Message}");
}
}
private static object ClearNavMesh()
{
try
{
if (!HasAINavigation())
{
return Response.Error("[EXPERIMENTAL] NavMesh operations require AI Navigation package.");
}
var writeCheck = WriteGuard.CheckWriteAllowed("clear_navmesh");
if (writeCheck != null) return writeCheck;
// Clear NavMesh using reflection - try RemoveAllNavMeshData first
int clearedCount = 0;
if (_removeAllNavMeshDataMethod != null)
{
_removeAllNavMeshDataMethod.Invoke(null, null);
clearedCount++;
}
// Also clear all NavMeshSurface components
if (_activeSurfacesProperty != null)
{
var activeSurfaces = _activeSurfacesProperty.GetValue(null);
if (activeSurfaces is System.Collections.IList surfaceList)
{
var removeDataMethod = _navMeshSurfaceType.GetMethod("RemoveData", BindingFlags.Public | BindingFlags.Instance);
foreach (var surface in surfaceList)
{
try
{
removeDataMethod?.Invoke(surface, null);
clearedCount++;
}
catch { }
}
}
}
StateComposer.IncrementRevision();
return Response.Success($"[EXPERIMENTAL] NavMesh data cleared ({clearedCount} surfaces).");
}
catch (Exception e)
{
return Response.Error($"[EXPERIMENTAL] Failed to clear NavMesh: {e.Message}");
}
}
private static object ClearBakedData()
{
try
{
var writeCheck = WriteGuard.CheckWriteAllowed("clear_baked_data");
if (writeCheck != null) return writeCheck;
Lightmapping.Clear();
StateComposer.IncrementRevision();
return Response.Success("[EXPERIMENTAL] Baked lighting data cleared.");
}
catch (Exception e)
{
return Response.Error($"[EXPERIMENTAL] Failed to clear baked data: {e.Message}");
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 16bc91f90f3df674486759e40dffb088
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: da88809c82aa8214eaa168ec9ce090af
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9c6230a18f5554a41aaac2188776332e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,271 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Codely.Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.PackageManager;
using UnityEditor.PackageManager.Requests;
using UnityEngine;
using UnityTcp.Editor.Helpers;
namespace UnityTcp.Editor.Tools
{
/// <summary>
/// [EXPERIMENTAL] Handles Unity Package Manager (UPM) operations.
/// Supports installing, removing, and querying packages.
/// Compatible with Unity 2022.3 LTS.
/// </summary>
public static class ManagePackage
{
private static readonly Dictionary<string, Request> _activeRequests = new Dictionary<string, Request>();
private static readonly Dictionary<string, EditorApplication.CallbackFunction> _updateCallbacks = new Dictionary<string, EditorApplication.CallbackFunction>();
private static readonly object _requestLock = new object();
public static object HandleCommand(JObject @params)
{
string action = @params["action"]?.ToString().ToLower();
if (string.IsNullOrEmpty(action))
{
return Response.Error("Action parameter is required.");
}
try
{
switch (action)
{
case "install_package":
return InstallPackage(@params);
case "remove_package":
return RemovePackage(@params);
case "wait_for_upm":
return WaitForUpm(@params);
case "list_packages":
return ListPackages();
default:
return Response.Error(
$"Unknown action: '{action}'. Valid actions: install_package, remove_package, wait_for_upm, list_packages."
);
}
}
catch (Exception e)
{
Debug.LogError($"[ManagePackage] Action '{action}' failed: {e}");
return Response.Error($"[EXPERIMENTAL] Package operation failed: {e.Message}");
}
}
private static object InstallPackage(JObject @params)
{
try
{
string idOrUrl = @params["id_or_url"]?.ToString();
if (string.IsNullOrEmpty(idOrUrl))
return Response.Error("'id_or_url' parameter required for install_package.");
// Handle optional version parameter
string version = @params["version"]?.ToString();
// Append version to package identifier if provided and not already in format
// e.g., "com.unity.package" + "1.2.3" -> "com.unity.package@1.2.3"
if (!string.IsNullOrEmpty(version) &&
!idOrUrl.Contains("@") &&
!idOrUrl.StartsWith("http"))
{
idOrUrl = $"{idOrUrl}@{version}";
}
// Create job
var job = AsyncOperationTracker.CreateJob(
AsyncOperationTracker.JobType.UpmPackage,
$"Installing package: {idOrUrl}"
);
// Start UPM request
AddRequest addRequest = Client.Add(idOrUrl);
lock (_requestLock)
{
_activeRequests[job.OpId] = addRequest;
}
// Create and store callback delegate for proper unsubscription
EditorApplication.CallbackFunction callback = () => CheckUpmRequest(job.OpId);
lock (_requestLock)
{
_updateCallbacks[job.OpId] = callback;
}
EditorApplication.update += callback;
// Return standardized pending response
var response = AsyncOperationTracker.CreatePendingResponse(job) as Dictionary<string, object>;
response["poll_interval"] = 2.0; // UPM needs longer poll interval
response["message"] = $"[EXPERIMENTAL] Package installation started: {idOrUrl}";
response["data"] = new { package = idOrUrl, type = "install" };
return response;
}
catch (Exception e)
{
return Response.Error($"[EXPERIMENTAL] Failed to start package installation: {e.Message}");
}
}
private static object RemovePackage(JObject @params)
{
try
{
string packageName = @params["package_name"]?.ToString();
if (string.IsNullOrEmpty(packageName))
return Response.Error("'package_name' parameter required for remove_package.");
var job = AsyncOperationTracker.CreateJob(
AsyncOperationTracker.JobType.UpmPackage,
$"Removing package: {packageName}"
);
RemoveRequest removeRequest = Client.Remove(packageName);
lock (_requestLock)
{
_activeRequests[job.OpId] = removeRequest;
}
// Create and store callback delegate for proper unsubscription
EditorApplication.CallbackFunction callback = () => CheckUpmRequest(job.OpId);
lock (_requestLock)
{
_updateCallbacks[job.OpId] = callback;
}
EditorApplication.update += callback;
// Return standardized pending response
var response = AsyncOperationTracker.CreatePendingResponse(job) as Dictionary<string, object>;
response["poll_interval"] = 2.0; // UPM needs longer poll interval
response["message"] = $"[EXPERIMENTAL] Package removal started: {packageName}";
response["data"] = new { package = packageName, type = "remove" };
return response;
}
catch (Exception e)
{
return Response.Error($"[EXPERIMENTAL] Failed to start package removal: {e.Message}");
}
}
private static void CheckUpmRequest(string opId)
{
Request request;
EditorApplication.CallbackFunction callback;
lock (_requestLock)
{
if (!_activeRequests.TryGetValue(opId, out request))
{
return;
}
_updateCallbacks.TryGetValue(opId, out callback);
}
if (request.IsCompleted)
{
// Properly unsubscribe using stored delegate
if (callback != null)
{
EditorApplication.update -= callback;
}
lock (_requestLock)
{
_activeRequests.Remove(opId);
_updateCallbacks.Remove(opId);
}
if (request.Status == StatusCode.Success)
{
AsyncOperationTracker.CompleteJob(opId, "Package operation completed successfully");
StateComposer.IncrementRevision();
}
else
{
AsyncOperationTracker.FailJob(opId, $"Package operation failed: {request.Error?.message}");
}
}
}
private static object WaitForUpm(JObject @params)
{
try
{
string opId = @params["op_id"]?.ToString();
int timeoutSeconds = @params["timeoutSeconds"]?.ToObject<int?>() ?? 300;
if (string.IsNullOrEmpty(opId))
return Response.Error("'op_id' parameter required for wait_for_upm.");
var job = AsyncOperationTracker.GetJob(opId);
if (job == null)
return Response.Error($"Operation {opId} not found.");
if (job.Type != AsyncOperationTracker.JobType.UpmPackage)
return Response.Error($"Operation {opId} is not a UPM operation.");
if (AsyncOperationTracker.IsJobTimedOut(opId, timeoutSeconds))
{
AsyncOperationTracker.FailJob(opId, $"UPM operation timed out after {timeoutSeconds} seconds");
return AsyncOperationTracker.CreateErrorResponse(job);
}
switch (job.Status)
{
case AsyncOperationTracker.JobStatus.Complete:
return AsyncOperationTracker.CreateCompleteResponse(job);
case AsyncOperationTracker.JobStatus.Error:
return AsyncOperationTracker.CreateErrorResponse(job);
case AsyncOperationTracker.JobStatus.Pending:
return AsyncOperationTracker.CreatePendingResponse(job);
default:
return Response.Error($"Unknown job status: {job.Status}");
}
}
catch (Exception e)
{
return Response.Error($"[EXPERIMENTAL] Failed to wait for UPM: {e.Message}");
}
}
private static object ListPackages()
{
try
{
ListRequest listRequest = Client.List(true, false);
// Wait for request to complete (synchronous for simplicity)
while (!listRequest.IsCompleted)
{
System.Threading.Thread.Sleep(100);
}
if (listRequest.Status == StatusCode.Success)
{
var packages = listRequest.Result.Select(p => new
{
name = p.name,
version = p.version,
displayName = p.displayName,
description = p.description,
source = p.source.ToString()
}).ToList();
return Response.Success(
$"[EXPERIMENTAL] Retrieved {packages.Count} packages.",
packages
);
}
else
{
return Response.Error($"[EXPERIMENTAL] Failed to list packages: {listRequest.Error?.message}");
}
}
catch (Exception e)
{
return Response.Error($"[EXPERIMENTAL] Failed to list packages: {e.Message}");
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1d06adc1d5b654a428a23760d94d5079
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,722 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Codely.Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityTcp.Editor.Helpers; // For Response class
namespace UnityTcp.Editor.Tools
{
/// <summary>
/// Handles scene management operations like loading, saving, creating, and querying hierarchy.
/// </summary>
public static class ManageScene
{
private static readonly string[] AllowedSceneExtensions = new[] { ".unity", ".scene" };
#if TUANJIE_1_OR_NEWER || TUANJIE_1
// Tuanjie Editor defines TUANJIE_* version macros (e.g. TUANJIE_1, TUANJIE_1_1, TUANJIE_1_1_2, TUANJIE_X_Y_OR_NEWER).
private const bool IsTuanjieEditor = true;
#else
// Unity Editor builds do NOT define TUANJIE_* macros.
private const bool IsTuanjieEditor = false;
#endif
private static bool HasAllowedSceneExtension(string path)
{
if (string.IsNullOrEmpty(path)) return false;
return AllowedSceneExtensions.Any(ext =>
path.EndsWith(ext, StringComparison.OrdinalIgnoreCase)
);
}
private static string GetSceneExtensionFromPathOrName(string path, string name)
{
// If caller provided an explicit scene file path, respect its extension.
if (!string.IsNullOrEmpty(path) && HasAllowedSceneExtension(path))
{
return Path.GetExtension(path).ToLowerInvariant();
}
// If caller (incorrectly) included an extension in name, respect it as a hint.
if (!string.IsNullOrEmpty(name) && HasAllowedSceneExtension(name))
{
return Path.GetExtension(name).ToLowerInvariant();
}
// Default depends on editor:
// - Unity: .unity
// - Tuanjie: .scene
return IsTuanjieEditor ? ".scene" : ".unity";
}
private static string StripSceneExtension(string name)
{
if (string.IsNullOrEmpty(name)) return name;
foreach (var ext in AllowedSceneExtensions)
{
if (name.EndsWith(ext, StringComparison.OrdinalIgnoreCase))
{
return name.Substring(0, name.Length - ext.Length);
}
}
return name;
}
private sealed class SceneCommand
{
public string action { get; set; } = string.Empty;
public string name { get; set; } = string.Empty;
public string path { get; set; } = string.Empty;
public int? buildIndex { get; set; }
}
private static SceneCommand ToSceneCommand(JObject p)
{
if (p == null) return new SceneCommand();
int? BI(JToken t)
{
if (t == null || t.Type == JTokenType.Null) return null;
var s = t.ToString().Trim();
if (s.Length == 0) return null;
if (int.TryParse(s, out var i)) return i;
if (double.TryParse(s, out var d)) return (int)d;
return t.Type == JTokenType.Integer ? t.Value<int>() : (int?)null;
}
return new SceneCommand
{
action = (p["action"]?.ToString() ?? string.Empty).Trim().ToLowerInvariant(),
name = p["name"]?.ToString() ?? string.Empty,
path = p["path"]?.ToString() ?? string.Empty,
buildIndex = BI(p["buildIndex"] ?? p["build_index"])
};
}
/// <summary>
/// Main handler for scene management actions.
/// </summary>
public static object HandleCommand(JObject @params)
{
try { TcpLog.Info("[ManageScene] HandleCommand: start", always: false); } catch { }
var cmd = ToSceneCommand(@params);
string action = cmd.action;
string name = string.IsNullOrEmpty(cmd.name) ? null : cmd.name;
string path = string.IsNullOrEmpty(cmd.path) ? null : cmd.path; // Relative to Assets/
int? buildIndex = cmd.buildIndex;
// bool loadAdditive = @params["loadAdditive"]?.ToObject<bool>() ?? false; // Example for future extension
// --- Validate client_state_rev for write operations ---
var writeActions = new[] { "ensure_scene_open", "ensure_scene_saved", "create", "load", "save" };
if (Array.Exists(writeActions, a => a == action))
{
var revConflict = StateComposer.ValidateClientRevisionFromParams(@params);
if (revConflict != null) return revConflict;
}
// Ensure path is relative to Assets/, removing any leading "Assets/"
string relativeDir = path ?? string.Empty;
if (!string.IsNullOrEmpty(relativeDir))
{
relativeDir = relativeDir.Replace('\\', '/').Trim('/');
if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/');
}
}
// Apply default *after* sanitizing, using the original path variable for the check
if (string.IsNullOrEmpty(path) && action == "create") // Check original path for emptiness
{
relativeDir = "Scenes"; // Default relative directory
}
if (string.IsNullOrEmpty(action))
{
return Response.Error("Action parameter is required.");
}
// Normalize name (strip extension if provided)
name = StripSceneExtension(name);
// Determine extension for scene file operations (Unity: .unity, Tuanjie: .scene)
string sceneExt = GetSceneExtensionFromPathOrName(path, cmd.name);
string sceneFileName = string.IsNullOrEmpty(name) ? null : $"{name}{sceneExt}";
// Construct full system path correctly: ProjectRoot/Assets/relativeDir/sceneFileName
string fullPathDir = Path.Combine(Application.dataPath, relativeDir); // Combine with Assets path (Application.dataPath ends in Assets)
string fullPath = string.IsNullOrEmpty(sceneFileName)
? null
: Path.Combine(fullPathDir, sceneFileName);
// Ensure relativePath always starts with "Assets/" and uses forward slashes
string relativePath = string.IsNullOrEmpty(sceneFileName)
? null
: Path.Combine("Assets", relativeDir, sceneFileName).Replace('\\', '/');
// Ensure directory exists for 'create'
if (action == "create" && !string.IsNullOrEmpty(fullPathDir))
{
try
{
Directory.CreateDirectory(fullPathDir);
}
catch (Exception e)
{
return Response.Error(
$"Could not create directory '{fullPathDir}': {e.Message}"
);
}
}
// Route action
try { TcpLog.Info($"[ManageScene] Route action='{action}' name='{name}' path='{path}' buildIndex={(buildIndex.HasValue ? buildIndex.Value.ToString() : "null")}", always: false); } catch { }
switch (action)
{
// Ensure operations (idempotent)
case "ensure_scene_open":
// For ensure_scene_open, the path parameter is the full scene path (e.g., "Assets/Scenes/MyScene.unity")
// NOT the directory path like other actions
if (string.IsNullOrEmpty(path))
return Response.Error("'path' parameter is required for 'ensure_scene_open' action.");
// Normalize the path - ensure it starts with Assets/ and uses forward slashes
string ensureScenePath = path.Replace('\\', '/');
if (!ensureScenePath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
ensureScenePath = "Assets/" + ensureScenePath.TrimStart('/');
if (!HasAllowedSceneExtension(ensureScenePath))
return Response.Error("'path' must end with '.unity' or '.scene' for scene files.");
return EnsureSceneOpen(ensureScenePath);
case "ensure_scene_saved":
return EnsureSceneSaved();
// Regular operations
case "create":
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(relativePath))
return Response.Error(
"'name' and 'path' parameters are required for 'create' action."
);
return CreateScene(fullPath, relativePath);
case "load":
// Loading can be done by path/name or build index
if (!string.IsNullOrEmpty(relativePath))
return LoadScene(relativePath);
else if (buildIndex.HasValue)
return LoadScene(buildIndex.Value);
else
return Response.Error(
"Either 'name'/'path' or 'buildIndex' must be provided for 'load' action."
);
case "save":
// Save current scene, optionally to a new path
return SaveScene(fullPath, relativePath);
case "get_hierarchy":
try { TcpLog.Info("[ManageScene] get_hierarchy: entering", always: false); } catch { }
var gh = GetSceneHierarchy();
try { TcpLog.Info("[ManageScene] get_hierarchy: exiting", always: false); } catch { }
return gh;
case "get_active":
try { TcpLog.Info("[ManageScene] get_active: entering", always: false); } catch { }
var ga = GetActiveSceneInfo();
try { TcpLog.Info("[ManageScene] get_active: exiting", always: false); } catch { }
return ga;
case "get_build_settings":
return GetBuildSettingsScenes();
// Add cases for modifying build settings, additive loading, unloading etc.
default:
return Response.Error(
$"Unknown action: '{action}'. Valid actions: ensure_scene_open, ensure_scene_saved, create, load, save, get_hierarchy, get_active, get_build_settings."
);
}
}
private static object CreateScene(string fullPath, string relativePath)
{
if (File.Exists(fullPath))
{
return Response.Error($"Scene already exists at '{relativePath}'.");
}
try
{
// Create a new empty scene
Scene newScene = EditorSceneManager.NewScene(
NewSceneSetup.EmptyScene,
NewSceneMode.Single
);
// Save it to the specified path (creation is an authoring operation)
bool saved = EditorSceneManager.SaveScene(newScene, relativePath);
if (saved)
{
AssetDatabase.Refresh(); // Ensure Unity sees the new scene file
return Response.Success(
$"Scene '{Path.GetFileName(relativePath)}' created successfully at '{relativePath}'.",
new { path = relativePath }
);
}
else
{
// If SaveScene fails, it might leave an untitled scene open.
// Optionally try to close it, but be cautious.
return Response.Error($"Failed to save new scene to '{relativePath}'.");
}
}
catch (Exception e)
{
return Response.Error($"Error creating scene '{relativePath}': {e.Message}");
}
}
private static object LoadScene(string relativePath)
{
if (
!File.Exists(
Path.Combine(
Application.dataPath.Substring(
0,
Application.dataPath.Length - "Assets".Length
),
relativePath
)
)
)
{
return Response.Error($"Scene file not found at '{relativePath}'.");
}
// Check for unsaved changes in the current scene
if (EditorSceneManager.GetActiveScene().isDirty)
{
// Optionally prompt the user or save automatically before loading
return Response.Error(
"Current scene has unsaved changes. Please save or discard changes before loading a new scene."
);
// Example: bool saveOK = EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo();
// if (!saveOK) return Response.Error("Load cancelled by user.");
}
try
{
EditorSceneManager.OpenScene(relativePath, OpenSceneMode.Single);
return Response.Success(
$"Scene '{relativePath}' loaded successfully.",
new
{
path = relativePath,
name = Path.GetFileNameWithoutExtension(relativePath),
}
);
}
catch (Exception e)
{
return Response.Error($"Error loading scene '{relativePath}': {e.Message}");
}
}
private static object LoadScene(int buildIndex)
{
if (buildIndex < 0 || buildIndex >= SceneManager.sceneCountInBuildSettings)
{
return Response.Error(
$"Invalid build index: {buildIndex}. Must be between 0 and {SceneManager.sceneCountInBuildSettings - 1}."
);
}
// Check for unsaved changes
if (EditorSceneManager.GetActiveScene().isDirty)
{
return Response.Error(
"Current scene has unsaved changes. Please save or discard changes before loading a new scene."
);
}
try
{
string scenePath = SceneUtility.GetScenePathByBuildIndex(buildIndex);
EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single);
return Response.Success(
$"Scene at build index {buildIndex} ('{scenePath}') loaded successfully.",
new
{
path = scenePath,
name = Path.GetFileNameWithoutExtension(scenePath),
buildIndex = buildIndex,
}
);
}
catch (Exception e)
{
return Response.Error(
$"Error loading scene with build index {buildIndex}: {e.Message}"
);
}
}
private static object SaveScene(string fullPath, string relativePath)
{
try
{
Scene currentScene = EditorSceneManager.GetActiveScene();
if (!currentScene.IsValid())
{
return Response.Error("No valid scene is currently active to save.");
}
bool saved;
string finalPath = currentScene.path; // Path where it was last saved or will be saved
if (!string.IsNullOrEmpty(relativePath) && currentScene.path != relativePath)
{
// Save As...
// Ensure directory exists
string dir = Path.GetDirectoryName(fullPath);
if (!Directory.Exists(dir))
Directory.CreateDirectory(dir);
saved = EditorSceneManager.SaveScene(currentScene, relativePath);
finalPath = relativePath;
}
else
{
// Save (overwrite existing or save untitled)
if (string.IsNullOrEmpty(currentScene.path))
{
// Scene is untitled, needs a path
return Response.Error(
"Cannot save an untitled scene without providing a 'name' and 'path'. Use Save As functionality."
);
}
saved = EditorSceneManager.SaveScene(currentScene);
}
if (saved)
{
AssetDatabase.Refresh();
return Response.Success(
$"Scene '{currentScene.name}' saved successfully to '{finalPath}'.",
new { path = finalPath, name = currentScene.name }
);
}
else
{
return Response.Error($"Failed to save scene '{currentScene.name}'.");
}
}
catch (Exception e)
{
return Response.Error($"Error saving scene: {e.Message}");
}
}
private static object GetActiveSceneInfo()
{
try
{
try { TcpLog.Info("[ManageScene] get_active: querying EditorSceneManager.GetActiveScene", always: false); } catch { }
Scene activeScene = EditorSceneManager.GetActiveScene();
try { TcpLog.Info($"[ManageScene] get_active: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { }
if (!activeScene.IsValid())
{
return Response.Error("No active scene found.");
}
var sceneInfo = new
{
name = activeScene.name,
path = activeScene.path,
buildIndex = activeScene.buildIndex, // -1 if not in build settings
isDirty = activeScene.isDirty,
isLoaded = activeScene.isLoaded,
rootCount = activeScene.rootCount,
};
return Response.Success("Retrieved active scene information.", sceneInfo);
}
catch (Exception e)
{
try { TcpLog.Error($"[ManageScene] get_active: exception {e.Message}"); } catch { }
return Response.Error($"Error getting active scene info: {e.Message}");
}
}
private static object GetBuildSettingsScenes()
{
try
{
var scenes = new List<object>();
for (int i = 0; i < EditorBuildSettings.scenes.Length; i++)
{
var scene = EditorBuildSettings.scenes[i];
scenes.Add(
new
{
path = scene.path,
guid = scene.guid.ToString(),
enabled = scene.enabled,
buildIndex = i, // Actual build index considering only enabled scenes might differ
}
);
}
return Response.Success("Retrieved scenes from Build Settings.", scenes);
}
catch (Exception e)
{
return Response.Error($"Error getting scenes from Build Settings: {e.Message}");
}
}
private static object GetSceneHierarchy()
{
try
{
try { TcpLog.Info("[ManageScene] get_hierarchy: querying EditorSceneManager.GetActiveScene", always: false); } catch { }
Scene activeScene = EditorSceneManager.GetActiveScene();
try { TcpLog.Info($"[ManageScene] get_hierarchy: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { }
if (!activeScene.IsValid() || !activeScene.isLoaded)
{
return Response.Error(
"No valid and loaded scene is active to get hierarchy from."
);
}
try { TcpLog.Info("[ManageScene] get_hierarchy: fetching root objects", always: false); } catch { }
GameObject[] rootObjects = activeScene.GetRootGameObjects();
try { TcpLog.Info($"[ManageScene] get_hierarchy: rootCount={rootObjects?.Length ?? 0}", always: false); } catch { }
// Count total GameObjects to avoid massive responses
int totalObjectCount = 0;
foreach (var rootObj in rootObjects)
{
totalObjectCount += CountGameObjectsRecursive(rootObj);
}
// If too many objects would be serialized, return a shallow root-only tree with hints.
if (totalObjectCount > 500)
{
var roots = rootObjects
.Where(go => go != null)
.Select(go => new Dictionary<string, object>
{
{ "name", go.name },
{ "instanceID", go.GetInstanceID() },
{ "activeSelf", go.activeSelf },
{ "activeInHierarchy", go.activeInHierarchy },
{ "tag", go.tag },
{ "layer", go.layer },
{ "isStatic", go.isStatic },
{ "childCount", go.transform != null ? go.transform.childCount : 0 },
{ "children", new List<object>() }, // Keep tree shape (shallow)
})
.ToList();
return Response.Success(
$"Scene hierarchy is large ({totalObjectCount} GameObjects). Returned root objects only (children omitted).",
new
{
partial = true,
mode = "roots_only",
totalObjectCount = totalObjectCount,
rootCount = roots.Count,
roots = roots,
hints = new[]
{
"Use unity_gameobject action='list_children' on a specific root GameObject to expand its subtree with a depth limit.",
"Use unity_gameobject action='find' with searchMethod='by_path' to locate a specific object (e.g. 'Root/Child') before listing children.",
"If you only need a specific area, pick a root from this list and then drill down incrementally (depth=1..N)."
}
}
);
}
var hierarchy = rootObjects.Select(go => GetGameObjectDataRecursive(go)).ToList();
var resp = Response.Success(
$"Retrieved hierarchy for scene '{activeScene.name}'.",
hierarchy
);
try { TcpLog.Info("[ManageScene] get_hierarchy: success", always: false); } catch { }
return resp;
}
catch (Exception e)
{
try { TcpLog.Error($"[ManageScene] get_hierarchy: exception {e.Message}"); } catch { }
return Response.Error($"Error getting scene hierarchy: {e.Message}");
}
}
// --- Ensure Methods (Idempotent Operations) ---
/// <summary>
/// Ensures a scene is open. Idempotent.
/// </summary>
private static object EnsureSceneOpen(string scenePath)
{
try
{
var writeCheck = WriteGuard.CheckWriteAllowed($"ensure_scene_open({scenePath})");
if (writeCheck != null) return writeCheck;
if (!File.Exists(scenePath))
return Response.Error($"Scene file not found: {scenePath}");
Scene activeScene = SceneManager.GetActiveScene();
if (activeScene.path.Equals(scenePath, StringComparison.OrdinalIgnoreCase))
{
return new
{
success = true,
message = $"Scene is already active.",
data = new { scenePath = activeScene.path, dirty = activeScene.isDirty },
state_delta = StateComposer.CreateSceneDelta(activeScene.path, activeScene.isDirty)
};
}
Scene loadedScene = EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single);
if (!loadedScene.IsValid())
return Response.Error($"Failed to open scene: {scenePath}");
StateComposer.IncrementRevision();
return new
{
success = true,
message = $"Scene opened.",
data = new { scenePath = loadedScene.path, dirty = loadedScene.isDirty },
state_delta = StateComposer.CreateSceneDelta(loadedScene.path, loadedScene.isDirty)
};
}
catch (Exception e)
{
return Response.Error($"Failed to ensure scene open: {e.Message}");
}
}
/// <summary>
/// Ensures the active scene is saved. Idempotent.
/// </summary>
private static object EnsureSceneSaved()
{
try
{
Scene activeScene = SceneManager.GetActiveScene();
if (!activeScene.IsValid())
return Response.Error("No active scene.");
if (!activeScene.isDirty)
{
return new
{
success = true,
message = "Scene already saved.",
data = new { scenePath = activeScene.path, dirty = false },
state_delta = StateComposer.CreateSceneDelta(activeScene.path, false)
};
}
var writeCheck = WriteGuard.CheckWriteAllowed($"ensure_scene_saved");
if (writeCheck != null) return writeCheck;
bool saved = EditorSceneManager.SaveScene(activeScene);
if (!saved)
return Response.Error($"Failed to save scene.");
StateComposer.IncrementRevision();
return new
{
success = true,
message = "Scene saved.",
data = new { scenePath = activeScene.path, dirty = false },
state_delta = StateComposer.CreateSceneDelta(activeScene.path, false)
};
}
catch (Exception e)
{
return Response.Error($"Failed to ensure scene saved: {e.Message}");
}
}
/// <summary>
/// Counts total GameObjects in a hierarchy. Uses an iterative traversal to avoid stack overflows on deep hierarchies.
/// </summary>
private static int CountGameObjectsRecursive(GameObject go)
{
if (go == null) return 0;
int count = 0;
var stack = new Stack<Transform>();
if (go.transform != null)
{
stack.Push(go.transform);
}
while (stack.Count > 0)
{
var tr = stack.Pop();
if (tr == null) continue;
count++;
int cc = tr.childCount;
for (int i = 0; i < cc; i++)
{
var child = tr.GetChild(i);
if (child != null) stack.Push(child);
}
}
return count;
}
/// <summary>
/// Recursively builds a data representation of a GameObject and its children.
/// </summary>
private static object GetGameObjectDataRecursive(GameObject go)
{
if (go == null)
return null;
var childrenData = new List<object>();
foreach (Transform child in go.transform)
{
childrenData.Add(GetGameObjectDataRecursive(child.gameObject));
}
var gameObjectData = new Dictionary<string, object>
{
{ "name", go.name },
{ "activeSelf", go.activeSelf },
{ "activeInHierarchy", go.activeInHierarchy },
{ "tag", go.tag },
{ "layer", go.layer },
{ "isStatic", go.isStatic },
{ "instanceID", go.GetInstanceID() }, // Useful unique identifier
{
"transform",
new
{
position = new
{
x = go.transform.localPosition.x,
y = go.transform.localPosition.y,
z = go.transform.localPosition.z,
},
rotation = new
{
x = go.transform.localRotation.eulerAngles.x,
y = go.transform.localRotation.eulerAngles.y,
z = go.transform.localRotation.eulerAngles.z,
}, // Euler for simplicity
scale = new
{
x = go.transform.localScale.x,
y = go.transform.localScale.y,
z = go.transform.localScale.z,
},
}
},
{ "children", childrenData },
};
return gameObjectData;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2446b8cefbf129d40abfa286e9e3d137
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,662 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using Codely.Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityTcp.Editor.Helpers; // For Response class
namespace UnityTcp.Editor.Tools
{
/// <summary>
/// Handles screenshot capture operations within Unity Editor.
/// </summary>
public static class ManageScreenshot
{
// --- Main Handler ---
// Define the list of valid actions
private static readonly List<string> ValidActions = new List<string>
{
"capture",
"capture_main_camera",
"capture_specific_camera",
"capture_scene_camera"
};
public static object HandleCommand(JObject @params)
{
string action = @params["action"]?.ToString().ToLower();
if (string.IsNullOrEmpty(action))
{
return Response.Error("Action parameter is required.");
}
// Check if the action is valid before switching
if (!ValidActions.Contains(action))
{
string validActionsList = string.Join(", ", ValidActions);
return Response.Error(
$"Unknown action: '{action}'. Valid actions are: {validActionsList}"
);
}
try
{
switch (action)
{
case "capture":
return CaptureGameView(@params);
case "capture_main_camera":
return CaptureMainCamera(@params);
case "capture_specific_camera":
return CaptureSpecificCamera(@params);
case "capture_scene_camera":
return CaptureSceneCamera(@params);
default:
string validActionsListDefault = string.Join(", ", ValidActions);
return Response.Error(
$"Unknown action: '{action}'. Valid actions are: {validActionsListDefault}"
);
}
}
catch (Exception e)
{
Debug.LogError($"[ManageScreenshot] Action '{action}' failed: {e}");
return Response.Error(
$"Internal error processing action '{action}': {e.Message}"
);
}
}
// --- Action Implementations ---
/// <summary>
/// Menu item to capture screenshot from game view
/// </summary>
public static void CaptureScreenshotMenuItem()
{
CaptureGameViewAndSave();
}
private static object CaptureGameView(JObject @params)
{
string customPath = @params["path"]?.ToString();
string customFilename = @params["filename"]?.ToString();
int? width = @params["width"]?.ToObject<int?>();
int? height = @params["height"]?.ToObject<int?>();
try
{
string savedPath = CaptureGameViewAndSave(customPath, customFilename, width, height);
if (savedPath != null)
{
return Response.Success(
"Screenshot captured successfully from Game view.",
new { path = savedPath, camera = "Game View" }
);
}
else
{
return Response.Error("Failed to capture screenshot from Game view.");
}
}
catch (Exception e)
{
return Response.Error($"Failed to capture screenshot from Game view: {e.Message}");
}
}
private static object CaptureMainCamera(JObject @params)
{
string customPath = @params["path"]?.ToString();
string customFilename = @params["filename"]?.ToString();
int? width = @params["width"]?.ToObject<int?>();
int? height = @params["height"]?.ToObject<int?>();
try
{
string savedPath = CaptureAndSave(customPath, customFilename, width, height);
if (savedPath != null)
{
return Response.Success(
"Screenshot captured successfully from main camera.",
new { path = savedPath, camera = "Main Camera" }
);
}
else
{
return Response.Error("Failed to capture screenshot from main camera.");
}
}
catch (Exception e)
{
return Response.Error($"Failed to capture screenshot: {e.Message}");
}
}
private static object CaptureSpecificCamera(JObject @params)
{
string cameraName = @params["cameraName"]?.ToString();
string customPath = @params["path"]?.ToString();
string customFilename = @params["filename"]?.ToString();
int? width = @params["width"]?.ToObject<int?>();
int? height = @params["height"]?.ToObject<int?>();
if (string.IsNullOrEmpty(cameraName))
{
return Response.Error("'cameraName' parameter is required for capture_specific_camera action.");
}
try
{
Camera targetCamera = GameObject.Find(cameraName)?.GetComponent<Camera>();
if (targetCamera == null)
{
// Try finding by name in all cameras
Camera[] cameras = UnityEngine.Object.FindObjectsOfType<Camera>();
foreach (Camera cam in cameras)
{
if (cam.name.Equals(cameraName, StringComparison.OrdinalIgnoreCase))
{
targetCamera = cam;
break;
}
}
}
if (targetCamera == null)
{
return Response.Error($"Camera '{cameraName}' not found in the scene.");
}
string savedPath = CaptureAndSave(customPath, customFilename, width, height, targetCamera);
if (savedPath != null)
{
return Response.Success(
$"Screenshot captured successfully from camera '{cameraName}'.",
new { path = savedPath, camera = cameraName }
);
}
else
{
return Response.Error($"Failed to capture screenshot from camera '{cameraName}'.");
}
}
catch (Exception e)
{
return Response.Error($"Failed to capture screenshot from camera '{cameraName}': {e.Message}");
}
}
private static object CaptureSceneCamera(JObject @params)
{
string customPath = @params["path"]?.ToString();
string customFilename = @params["filename"]?.ToString();
int? width = @params["width"]?.ToObject<int?>();
int? height = @params["height"]?.ToObject<int?>();
try
{
// Get the active scene view camera
SceneView sceneView = SceneView.lastActiveSceneView;
if (sceneView == null)
{
return Response.Error("No active Scene view found. Please ensure a Scene view is open.");
}
Camera sceneCamera = sceneView.camera;
if (sceneCamera == null)
{
return Response.Error("Scene view camera not found.");
}
string savedPath = CaptureAndSave(customPath, customFilename, width, height, sceneCamera, "SceneCamera");
if (savedPath != null)
{
return Response.Success(
"Screenshot captured successfully from Scene camera.",
new { path = savedPath, camera = "Scene Camera" }
);
}
else
{
return Response.Error("Failed to capture screenshot from Scene camera.");
}
}
catch (Exception e)
{
return Response.Error($"Failed to capture screenshot from Scene camera: {e.Message}");
}
}
/// <summary>
/// Function to capture Game view screenshot and save to file
/// </summary>
public static string CaptureGameViewAndSave(string customPath = null, string customFilename = null, int? width = null, int? height = null)
{
try
{
// Generate filename with GameView prefix
string filename;
if (!string.IsNullOrEmpty(customFilename))
{
filename = customFilename.EndsWith(".png") ? customFilename : customFilename + ".png";
}
else
{
filename = $"GameView-{System.DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss")}.png";
}
// Determine save path
string savePath;
if (!string.IsNullOrEmpty(customPath))
{
// Ensure directory exists
Directory.CreateDirectory(customPath);
savePath = Path.Combine(customPath, filename);
}
else
{
// Use project path with Screenshots folder
string projectPath = Path.GetDirectoryName(Application.dataPath); // Get project root (parent of Assets)
string screenshotsFolder = Path.Combine(projectPath, "Screenshots");
// Create Screenshots folder if it doesn't exist
if (!Directory.Exists(screenshotsFolder))
{
Directory.CreateDirectory(screenshotsFolder);
Debug.Log($"Created Screenshots folder at: {screenshotsFolder}");
}
savePath = Path.Combine(screenshotsFolder, filename);
}
// Try different capture methods based on mode and availability
Texture2D screenshot = null;
// Always prioritize GameView reflection to capture what user actually sees
// This ensures screenshots match the GameView dimensions, multi-camera setups, and post-processing
screenshot = CaptureGameViewInEditMode(width, height);
if (screenshot == null)
{
// Fallback to main camera rendering if GameView is not accessible
Camera mainCamera = Camera.main;
if (mainCamera != null)
{
screenshot = CaptureScreenByRenderTexture(mainCamera, width, height);
}
}
if (screenshot == null)
{
Debug.LogError("Failed to capture screenshot using all available methods!");
return null;
}
// ReadPixels from RenderTexture often returns bottom-up (OpenGL origin); flip so saved PNG is top-down
FlipTextureVertically(screenshot);
// Encode to PNG and save
byte[] bytes = screenshot.EncodeToPNG();
UnityEngine.Object.DestroyImmediate(screenshot); // Free GPU memory (must use DestroyImmediate in edit mode)
File.WriteAllBytes(savePath, bytes);
Debug.Log("Game view screenshot saved to: " + savePath);
return savePath;
}
catch (System.Exception e)
{
Debug.LogError($"Failed to capture game view screenshot: {e.Message}");
return null;
}
}
/// <summary>
/// Capture screenshot from camera using RenderTexture approach
/// </summary>
private static Texture2D CaptureScreenByRenderTexture(Camera camera, int? width = null, int? height = null)
{
if (camera == null)
{
Debug.LogError("Camera is null!");
return null;
}
try
{
// Use custom dimensions or default to reasonable size
int captureWidth = width ?? 1920;
int captureHeight = height ?? 1080;
Rect rect = new Rect(0, 0, captureWidth, captureHeight);
// Create a RenderTexture object
RenderTexture rt = new RenderTexture((int)rect.width, (int)rect.height, 24);
// Store original target texture to restore later
RenderTexture originalTarget = camera.targetTexture;
// Temporarily set the camera's targetTexture to rt, and manually render the camera
camera.targetTexture = rt;
camera.Render();
// Activate this rt, and read pixels from it.
RenderTexture previousActive = RenderTexture.active;
RenderTexture.active = rt;
Texture2D screenShot = new Texture2D((int)rect.width, (int)rect.height, TextureFormat.RGB24, false);
// Note: At this time, it reads pixels from RenderTexture.active
screenShot.ReadPixels(rect, 0, 0);
screenShot.Apply();
// Reset related parameters so the camera continues to display on screen
camera.targetTexture = originalTarget;
RenderTexture.active = previousActive;
UnityEngine.Object.DestroyImmediate(rt);
return screenShot;
}
catch (System.Exception e)
{
Debug.LogError($"Failed to capture screenshot using RenderTexture: {e.Message}");
return null;
}
}
/// <summary>
/// Flips the texture vertically in-place. Use before saving when the image is upside-down.
/// ReadPixels from RenderTexture uses bottom-left origin (e.g. OpenGL); PNG expects top-left,
/// so screenshots from GameView/camera RT can appear flipped. Call this before EncodeToPNG to correct.
/// </summary>
private static void FlipTextureVertically(Texture2D tex)
{
if (tex == null || tex.height <= 1) return;
int w = tex.width;
int h = tex.height;
Color[] pixels = tex.GetPixels();
for (int y = 0; y < h / 2; y++)
{
int top = y * w;
int bottom = (h - 1 - y) * w;
for (int x = 0; x < w; x++)
{
Color tmp = pixels[top + x];
pixels[top + x] = pixels[bottom + x];
pixels[bottom + x] = tmp;
}
}
tex.SetPixels(pixels);
tex.Apply();
}
/// <summary>
/// Capture Game view using GameView's internal render texture via reflection
/// Works in both Edit and Play modes to capture what the user actually sees.
/// Note: ReadPixels from RT may yield bottom-up image (OpenGL); save path flips vertically before PNG.
/// </summary>
private static Texture2D CaptureGameViewInEditMode(int? width = null, int? height = null)
{
try
{
// Get active GameView
var gameView = GetActiveGameView();
if (gameView == null)
{
Debug.LogWarning("Game View not found. Falling back to camera rendering.");
return CaptureWithCameraRenderingFallback(width, height);
}
// Try to get GameView's internal render texture via reflection
// Different Unity versions may use different property/field names
RenderTexture gameViewRT = null;
var gameViewType = gameView.GetType();
// Try property "targetTexture" (some Unity versions)
var textureProperty = gameViewType.GetProperty("targetTexture",
BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance);
if (textureProperty != null)
{
gameViewRT = textureProperty.GetValue(gameView, null) as RenderTexture;
}
// Try field "m_TargetTexture" (some Unity versions)
if (gameViewRT == null)
{
var textureField = gameViewType.GetField("m_TargetTexture",
BindingFlags.NonPublic | BindingFlags.Instance);
if (textureField != null)
{
gameViewRT = textureField.GetValue(gameView) as RenderTexture;
}
}
// Try method "GetMainPlayModeView" or similar approaches
if (gameViewRT == null)
{
var renderDocField = gameViewType.GetField("m_RenderTexture",
BindingFlags.NonPublic | BindingFlags.Instance);
if (renderDocField != null)
{
gameViewRT = renderDocField.GetValue(gameView) as RenderTexture;
}
}
if (gameViewRT == null)
{
Debug.LogWarning("GameView render texture not accessible. Falling back to camera rendering.");
return CaptureWithCameraRenderingFallback(width, height);
}
RenderTexture activeRT = RenderTexture.active;
// Determine final dimensions
int finalWidth = width ?? gameViewRT.width;
int finalHeight = height ?? gameViewRT.height;
// Create temporary RenderTexture to read pixels
RenderTexture tempRT;
// If custom dimensions are specified and different from game view, resize
if ((width.HasValue || height.HasValue) && (finalWidth != gameViewRT.width || finalHeight != gameViewRT.height))
{
tempRT = RenderTexture.GetTemporary(finalWidth, finalHeight, 0, gameViewRT.format);
// Scale the game view texture to the desired size
Graphics.Blit(gameViewRT, tempRT);
}
else
{
// Use game view dimensions
tempRT = RenderTexture.GetTemporary(gameViewRT.width, gameViewRT.height, 0, gameViewRT.format);
Graphics.Blit(gameViewRT, tempRT);
}
RenderTexture.active = tempRT;
Texture2D screenshot = new Texture2D(tempRT.width, tempRT.height, TextureFormat.RGB24, false);
screenshot.ReadPixels(new Rect(0, 0, tempRT.width, tempRT.height), 0, 0);
screenshot.Apply();
RenderTexture.active = activeRT;
RenderTexture.ReleaseTemporary(tempRT);
return screenshot;
}
catch (System.Exception e)
{
Debug.LogError($"Failed to capture game view using reflection: {e.Message}. Falling back to camera rendering.");
return CaptureWithCameraRenderingFallback(width, height);
}
}
/// <summary>
/// Get the active Game View window using reflection
/// </summary>
private static EditorWindow GetActiveGameView()
{
try
{
System.Type gameViewType = System.Type.GetType("UnityEditor.GameView,UnityEditor");
if (gameViewType == null)
return null;
// Try to get the focused game view first
EditorWindow focusedWindow = EditorWindow.focusedWindow;
if (focusedWindow != null && focusedWindow.GetType() == gameViewType)
return focusedWindow;
// If no focused game view, get any game view that exists
EditorWindow[] gameViews = Resources.FindObjectsOfTypeAll(gameViewType) as EditorWindow[];
if (gameViews != null && gameViews.Length > 0)
return gameViews[0];
return null;
}
catch (System.Exception e)
{
Debug.LogWarning($"Could not get Game View: {e.Message}");
return null;
}
}
/// <summary>
/// Fallback method using camera rendering when GameView access fails
/// </summary>
private static Texture2D CaptureWithCameraRenderingFallback(int? width = null, int? height = null)
{
try
{
int captureWidth = width ?? Screen.width;
int captureHeight = height ?? Screen.height;
// Create render texture
RenderTexture rt = new RenderTexture(captureWidth, captureHeight, 24);
// Get all cameras and render them to the render texture
Camera[] cameras = Camera.allCameras;
RenderTexture previousActive = RenderTexture.active;
RenderTexture.active = rt;
// Clear the render texture
GL.Clear(true, true, Color.black);
// Render all active cameras to the render texture in order
foreach (Camera cam in cameras)
{
if (cam != null && cam.enabled && cam.gameObject.activeInHierarchy)
{
RenderTexture prevTarget = cam.targetTexture;
cam.targetTexture = rt;
cam.Render();
cam.targetTexture = prevTarget;
}
}
// Read pixels from render texture
Texture2D screenshot = new Texture2D(captureWidth, captureHeight, TextureFormat.RGB24, false);
screenshot.ReadPixels(new Rect(0, 0, captureWidth, captureHeight), 0, 0);
screenshot.Apply();
// Cleanup
RenderTexture.active = previousActive;
UnityEngine.Object.DestroyImmediate(rt);
return screenshot;
}
catch (System.Exception e)
{
Debug.LogError($"Camera rendering fallback failed: {e.Message}");
return null;
}
}
/// <summary>
/// Helper method to resize a texture
/// </summary>
private static Texture2D ResizeTexture(Texture2D source, int targetWidth, int targetHeight)
{
RenderTexture rt = RenderTexture.GetTemporary(targetWidth, targetHeight);
RenderTexture.active = rt;
Graphics.Blit(source, rt);
Texture2D result = new Texture2D(targetWidth, targetHeight);
result.ReadPixels(new Rect(0, 0, targetWidth, targetHeight), 0, 0);
result.Apply();
RenderTexture.active = null;
RenderTexture.ReleaseTemporary(rt);
return result;
}
/// <summary>
/// Function to capture screenshot and save to file
/// </summary>
public static string CaptureAndSave(string customPath = null, string customFilename = null, int? width = null, int? height = null, Camera specificCamera = null, string cameraPrefix = null)
{
// Get the camera to use
Camera cam = specificCamera ?? Camera.main;
if (cam == null)
{
Debug.LogError("No main camera found!");
return null;
}
// Use the new RenderTexture approach
Texture2D screenshot = CaptureScreenByRenderTexture(cam, width, height);
if (screenshot == null)
{
Debug.LogError("Failed to capture screenshot using RenderTexture!");
return null;
}
// Encode to PNG
byte[] bytes = screenshot.EncodeToPNG();
UnityEngine.Object.DestroyImmediate(screenshot);
// Generate filename with camera prefix
string filename;
if (!string.IsNullOrEmpty(customFilename))
{
filename = customFilename.EndsWith(".png") ? customFilename : customFilename + ".png";
}
else
{
string prefix = cameraPrefix ?? (specificCamera != null ? specificCamera.name : "MainCamera");
filename = $"{prefix}-{System.DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss")}.png";
}
// Determine save path
string savePath;
if (!string.IsNullOrEmpty(customPath))
{
// Ensure directory exists
Directory.CreateDirectory(customPath);
savePath = Path.Combine(customPath, filename);
}
else
{
// Use project path with Screenshots folder
string projectPath = Path.GetDirectoryName(Application.dataPath); // Get project root (parent of Assets)
string screenshotsFolder = Path.Combine(projectPath, "Screenshots");
// Create Screenshots folder if it doesn't exist
if (!Directory.Exists(screenshotsFolder))
{
Directory.CreateDirectory(screenshotsFolder);
Debug.Log($"Created Screenshots folder at: {screenshotsFolder}");
}
savePath = Path.Combine(screenshotsFolder, filename);
}
// Save to file
File.WriteAllBytes(savePath, bytes);
Debug.Log("Screenshot saved to: " + savePath);
return savePath;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5fd45617bc3cd52489b0ad49fe49e55b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 146e77f78721f9a4494cc01662cc082c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,545 @@
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Codely.Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityTcp.Editor.Helpers;
namespace UnityTcp.Editor.Tools
{
/// <summary>
/// Handles CRUD operations for shader files within the Unity project.
/// </summary>
public static class ManageShader
{
/// <summary>
/// Main handler for shader management actions.
/// </summary>
public static object HandleCommand(JObject @params)
{
// Extract parameters
string action = @params["action"]?.ToString().ToLower();
string name = @params["name"]?.ToString();
string path = @params["path"]?.ToString(); // Relative to Assets/
string contents = null;
// --- Validate client_state_rev for write operations ---
var writeActions = new[] { "ensure_material_shader_for_srp", "create", "update", "delete" };
if (writeActions.Contains(action))
{
var revConflict = StateComposer.ValidateClientRevisionFromParams(@params);
if (revConflict != null) return revConflict;
}
// Check if we have base64 encoded contents
bool contentsEncoded = @params["contentsEncoded"]?.ToObject<bool>() ?? false;
if (contentsEncoded && @params["encodedContents"] != null)
{
try
{
contents = DecodeBase64(@params["encodedContents"].ToString());
}
catch (Exception e)
{
return Response.Error($"Failed to decode shader contents: {e.Message}");
}
}
else
{
contents = @params["contents"]?.ToString();
}
// Validate required parameters
if (string.IsNullOrEmpty(action))
{
return Response.Error("Action parameter is required.");
}
// Skip name validation for SRP operations
if (action != "detect_render_pipeline" && action != "ensure_material_shader_for_srp")
{
if (string.IsNullOrEmpty(name))
{
return Response.Error("Name parameter is required.");
}
// Basic name validation (alphanumeric, underscores, cannot start with number)
if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$"))
{
return Response.Error(
$"Invalid shader name: '{name}'. Use only letters, numbers, underscores, and don't start with a number."
);
}
}
// Ensure path is relative to Assets/, removing any leading "Assets/"
// Set default directory to "Shaders" if path is not provided
string relativeDir = path ?? "Shaders"; // Default to "Shaders" if path is null
if (!string.IsNullOrEmpty(relativeDir))
{
relativeDir = relativeDir.Replace('\\', '/').Trim('/');
if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/');
}
}
// Handle empty string case explicitly after processing
if (string.IsNullOrEmpty(relativeDir))
{
relativeDir = "Shaders"; // Ensure default if path was provided as "" or only "/" or "Assets/"
}
// Construct paths
string shaderFileName = $"{name}.shader";
string fullPathDir = Path.Combine(Application.dataPath, relativeDir);
string fullPath = Path.Combine(fullPathDir, shaderFileName);
string relativePath = Path.Combine("Assets", relativeDir, shaderFileName)
.Replace('\\', '/'); // Ensure "Assets/" prefix and forward slashes
// Ensure the target directory exists for create/update
if (action == "create" || action == "update")
{
try
{
if (!Directory.Exists(fullPathDir))
{
Directory.CreateDirectory(fullPathDir);
// Refresh AssetDatabase to recognize new folders
AssetDatabase.Refresh();
}
}
catch (Exception e)
{
return Response.Error(
$"Could not create directory '{fullPathDir}': {e.Message}"
);
}
}
// Route to specific action handlers
switch (action)
{
// SRP operations (don't require name validation)
case "detect_render_pipeline":
return DetectRenderPipeline();
case "ensure_material_shader_for_srp":
return EnsureMaterialShaderForSRP(@params);
// Regular shader file operations
case "create":
return CreateShader(fullPath, relativePath, name, contents);
case "read":
return ReadShader(fullPath, relativePath);
case "update":
return UpdateShader(fullPath, relativePath, name, contents);
case "delete":
return DeleteShader(fullPath, relativePath);
default:
return Response.Error(
$"Unknown action: '{action}'. Valid actions are: detect_render_pipeline, ensure_material_shader_for_srp, create, read, update, delete."
);
}
}
/// <summary>
/// Decode base64 string to normal text
/// </summary>
private static string DecodeBase64(string encoded)
{
byte[] data = Convert.FromBase64String(encoded);
return System.Text.Encoding.UTF8.GetString(data);
}
/// <summary>
/// Encode text to base64 string
/// </summary>
private static string EncodeBase64(string text)
{
byte[] data = System.Text.Encoding.UTF8.GetBytes(text);
return Convert.ToBase64String(data);
}
private static object CreateShader(
string fullPath,
string relativePath,
string name,
string contents
)
{
// Check if shader already exists
if (File.Exists(fullPath))
{
return Response.Error(
$"Shader already exists at '{relativePath}'. Use 'update' action to modify."
);
}
// Add validation for shader name conflicts in Unity
if (Shader.Find(name) != null)
{
return Response.Error(
$"A shader with name '{name}' already exists in the project. Choose a different name."
);
}
// Generate default content if none provided
if (string.IsNullOrEmpty(contents))
{
contents = GenerateDefaultShaderContent(name);
}
try
{
File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false));
AssetDatabase.ImportAsset(relativePath);
AssetDatabase.Refresh(); // Ensure Unity recognizes the new shader
return Response.Success(
$"Shader '{name}.shader' created successfully at '{relativePath}'.",
new { path = relativePath }
);
}
catch (Exception e)
{
return Response.Error($"Failed to create shader '{relativePath}': {e.Message}");
}
}
private static object ReadShader(string fullPath, string relativePath)
{
if (!File.Exists(fullPath))
{
return Response.Error($"Shader not found at '{relativePath}'.");
}
try
{
string contents = File.ReadAllText(fullPath);
// Return both normal and encoded contents for larger files
//TODO: Consider a threshold for large files
bool isLarge = contents.Length > 10000; // If content is large, include encoded version
var responseData = new
{
path = relativePath,
contents = contents,
// For large files, also include base64-encoded version
encodedContents = isLarge ? EncodeBase64(contents) : null,
contentsEncoded = isLarge,
};
return Response.Success(
$"Shader '{Path.GetFileName(relativePath)}' read successfully.",
responseData
);
}
catch (Exception e)
{
return Response.Error($"Failed to read shader '{relativePath}': {e.Message}");
}
}
private static object UpdateShader(
string fullPath,
string relativePath,
string name,
string contents
)
{
if (!File.Exists(fullPath))
{
return Response.Error(
$"Shader not found at '{relativePath}'. Use 'create' action to add a new shader."
);
}
if (string.IsNullOrEmpty(contents))
{
return Response.Error("Content is required for the 'update' action.");
}
try
{
File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false));
AssetDatabase.ImportAsset(relativePath);
AssetDatabase.Refresh();
return Response.Success(
$"Shader '{Path.GetFileName(relativePath)}' updated successfully.",
new { path = relativePath }
);
}
catch (Exception e)
{
return Response.Error($"Failed to update shader '{relativePath}': {e.Message}");
}
}
private static object DeleteShader(string fullPath, string relativePath)
{
if (!File.Exists(fullPath))
{
return Response.Error($"Shader not found at '{relativePath}'.");
}
try
{
// Delete the asset through Unity's AssetDatabase first
bool success = AssetDatabase.DeleteAsset(relativePath);
if (!success)
{
return Response.Error($"Failed to delete shader through Unity's AssetDatabase: '{relativePath}'");
}
// If the file still exists (rare case), try direct deletion
if (File.Exists(fullPath))
{
File.Delete(fullPath);
}
return Response.Success($"Shader '{Path.GetFileName(relativePath)}' deleted successfully.");
}
catch (Exception e)
{
return Response.Error($"Failed to delete shader '{relativePath}': {e.Message}");
}
}
//This is a CGProgram template
//TODO: making a HLSL template as well?
private static string GenerateDefaultShaderContent(string name)
{
return @"Shader """ + name + @"""
{
Properties
{
_MainTex (""Texture"", 2D) = ""white"" {}
}
SubShader
{
Tags { ""RenderType""=""Opaque"" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include ""UnityCG.cginc""
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv);
return col;
}
ENDCG
}
}
}";
}
// --- SRP/Shader Safety Methods ---
/// <summary>
/// Detects the current render pipeline in use.
/// </summary>
private static object DetectRenderPipeline()
{
try
{
string srp = "builtin";
var currentRP = UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline;
if (currentRP != null)
{
string rpName = currentRP.GetType().Name.ToLowerInvariant();
string rpFullName = currentRP.GetType().FullName.ToLowerInvariant();
if (rpName.Contains("urp") || rpName.Contains("universal") ||
rpFullName.Contains("universal"))
{
srp = "urp";
}
else if (rpName.Contains("hdrp") || rpName.Contains("highdefinition") ||
rpFullName.Contains("highdefinition"))
{
srp = "hdrp";
}
}
return new
{
success = true,
message = $"Current render pipeline: {srp}",
data = new
{
srp = srp,
rpAssetName = currentRP?.name,
rpTypeName = currentRP?.GetType().FullName
},
state_delta = StateComposer.CreateEditorDelta(isUpdating: false)
};
}
catch (Exception e)
{
return Response.Error($"Failed to detect render pipeline: {e.Message}");
}
}
/// <summary>
/// Ensures a material uses the appropriate shader for the current SRP. Idempotent.
/// Supports both "material" (legacy) and "material_path"/"material_guid"
/// as described in the Unity-Tools-Spec.
/// </summary>
private static object EnsureMaterialShaderForSRP(JObject @params)
{
try
{
var writeCheck = WriteGuard.CheckWriteAllowed("ensure_material_shader_for_srp");
if (writeCheck != null) return writeCheck;
// Accept multiple parameter shapes.
// Primary spec uses "material_path" / "material_guid",
// but we still accept legacy "material" for backwards compatibility.
string materialPath = @params["material_path"]?.ToString();
// Legacy fallback: allow "material" if material_path is not provided
if (string.IsNullOrEmpty(materialPath))
{
materialPath = @params["material"]?.ToString();
}
// Resolve from GUID if path not provided
if (string.IsNullOrEmpty(materialPath))
{
var guid = @params["material_guid"]?.ToString();
if (!string.IsNullOrEmpty(guid))
{
materialPath = AssetDatabase.GUIDToAssetPath(guid);
}
}
if (string.IsNullOrEmpty(materialPath))
return Response.Error("Either material_path or material_guid is required for ensure_material_shader_for_srp action");
// Validate path format (mirror TS-side validation)
if (!materialPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
return Response.Error("material_path must start with \"Assets/\"");
JObject shaderMapping = @params["shader_for_srp"] as JObject;
if (shaderMapping == null)
return Response.Error("shader_for_srp is required for ensure_material_shader_for_srp action");
if (!shaderMapping.ContainsKey("builtin") ||
string.IsNullOrWhiteSpace(shaderMapping["builtin"]?.ToString()))
{
return Response.Error("shader_for_srp.builtin is required as fallback shader");
}
// Load material
Material material = AssetDatabase.LoadAssetAtPath<Material>(materialPath);
if (material == null)
return Response.Error($"Material not found at: {materialPath}");
// Detect current SRP
string currentSrp = "builtin";
var currentRP = UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline;
if (currentRP != null)
{
string rpName = currentRP.GetType().Name.ToLowerInvariant();
if (rpName.Contains("urp") || rpName.Contains("universal"))
currentSrp = "urp";
else if (rpName.Contains("hdrp") || rpName.Contains("highdefinition"))
currentSrp = "hdrp";
}
// Get appropriate shader name
string targetShaderName = null;
if (currentSrp == "urp" && shaderMapping.ContainsKey("urp"))
targetShaderName = shaderMapping["urp"]?.ToString();
else if (currentSrp == "hdrp" && shaderMapping.ContainsKey("hdrp"))
targetShaderName = shaderMapping["hdrp"]?.ToString();
else if (shaderMapping.ContainsKey("builtin"))
targetShaderName = shaderMapping["builtin"]?.ToString(); // Fallback
if (string.IsNullOrEmpty(targetShaderName))
return Response.Error($"No shader mapping provided for current SRP: {currentSrp}");
// Find shader
Shader targetShader = Shader.Find(targetShaderName);
if (targetShader == null)
return Response.Error($"Shader not found: {targetShaderName}");
// Check if material already uses this shader (idempotent)
if (material.shader == targetShader)
{
return new
{
success = true,
message = $"Material already uses appropriate shader for {currentSrp}.",
data = new
{
material = materialPath,
currentSrp = currentSrp,
shader = targetShaderName,
alreadyCorrect = true
},
state_delta = StateComposer.CreateAssetDelta(new[] {
new { path = materialPath, imported = false, hasMeta = true }
})
};
}
// Cache old shader name BEFORE switching
string oldShaderName = material.shader?.name ?? "None";
// Switch shader
material.shader = targetShader;
EditorUtility.SetDirty(material);
AssetDatabase.SaveAssets();
StateComposer.IncrementRevision();
return new
{
success = true,
message = $"Material shader switched for {currentSrp}.",
data = new
{
material = materialPath,
currentSrp = currentSrp,
oldShader = oldShaderName,
newShader = targetShaderName,
alreadyCorrect = false
},
state_delta = StateComposer.CreateAssetDelta(new[] {
new { path = materialPath, imported = false, hasMeta = true }
})
};
}
catch (Exception e)
{
return Response.Error($"Failed to ensure material shader for SRP: {e.Message}");
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c4894079ca02cc34ab668aa21146f161
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,383 @@
using System;
using System.IO;
using Codely.Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
using UnityTcp.Editor.Helpers;
namespace UnityTcp.Editor.Tools
{
/// <summary>
/// [EXPERIMENTAL] Handles UI Toolkit operations (UXML, USS, PanelSettings).
/// </summary>
public static class ManageUIToolkit
{
public static object HandleCommand(JObject @params)
{
string action = @params["action"]?.ToString().ToLower();
if (string.IsNullOrEmpty(action))
{
return Response.Error("Action parameter is required.");
}
try
{
switch (action)
{
case "ensure_panel_settings_asset":
return EnsurePanelSettingsAsset(@params);
case "link_uss_to_uxml":
return LinkUssToUxml(@params);
case "create_uxml":
return CreateUxml(@params);
case "create_uss":
return CreateUss(@params);
default:
return Response.Error(
$"Unknown action: '{action}'. Valid actions: ensure_panel_settings_asset, link_uss_to_uxml, create_uxml, create_uss."
);
}
}
catch (Exception e)
{
Debug.LogError($"[ManageUIToolkit] Action '{action}' failed: {e}");
return Response.Error($"[EXPERIMENTAL] UI Toolkit operation failed: {e.Message}");
}
}
private static object EnsurePanelSettingsAsset(JObject @params)
{
try
{
var writeCheck = WriteGuard.CheckWriteAllowed("ensure_panel_settings_asset");
if (writeCheck != null) return writeCheck;
string path = @params["path"]?.ToString();
if (string.IsNullOrEmpty(path))
return Response.Error("'path' parameter required.");
if (!path.EndsWith(".asset"))
path += ".asset";
// Check if already exists
var existingAsset = AssetDatabase.LoadAssetAtPath<PanelSettings>(path);
if (existingAsset != null)
{
return new
{
success = true,
message = "[EXPERIMENTAL] PanelSettings asset already exists.",
data = new { path = path, alreadyExists = true },
state_delta = StateComposer.CreateAssetDelta(new[] {
new { path = path, imported = false, hasMeta = true }
})
};
}
// Create directory if needed
string dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
// Create PanelSettings asset
var panelSettings = ScriptableObject.CreateInstance<PanelSettings>();
AssetDatabase.CreateAsset(panelSettings, path);
AssetDatabase.SaveAssets();
StateComposer.IncrementRevision();
return new
{
success = true,
message = "[EXPERIMENTAL] PanelSettings asset created.",
data = new { path = path, alreadyExists = false },
state_delta = StateComposer.CreateAssetDelta(new[] {
new { path = path, imported = true, hasMeta = true }
})
};
}
catch (Exception e)
{
return Response.Error($"[EXPERIMENTAL] Failed to ensure PanelSettings asset: {e.Message}");
}
}
private static object LinkUssToUxml(JObject @params)
{
try
{
var writeCheck = WriteGuard.CheckWriteAllowed("link_uss_to_uxml");
if (writeCheck != null) return writeCheck;
// Support both parameter naming conventions: uxml/uss and uxml_path/uss_path,
// as well as GUID-based references via uxml_guid/uss_guid.
string uxmlShorthand = @params["uxml"]?.ToString();
string uxmlPathParam = @params["uxml_path"]?.ToString();
string uxmlGuidParam = @params["uxml_guid"]?.ToString();
string ussShorthand = @params["uss"]?.ToString();
string ussPathParam = @params["uss_path"]?.ToString();
string ussGuidParam = @params["uss_guid"]?.ToString();
bool hasUxmlIdentifier =
!string.IsNullOrEmpty(uxmlShorthand)
|| !string.IsNullOrEmpty(uxmlPathParam)
|| !string.IsNullOrEmpty(uxmlGuidParam);
bool hasUssIdentifier =
!string.IsNullOrEmpty(ussShorthand)
|| !string.IsNullOrEmpty(ussPathParam)
|| !string.IsNullOrEmpty(ussGuidParam);
if (!hasUxmlIdentifier)
return Response.Error("Either 'uxml', 'uxml_path', or 'uxml_guid' parameter is required.");
if (!hasUssIdentifier)
return Response.Error("Either 'uss', 'uss_path', or 'uss_guid' parameter is required.");
string uxmlGuidUsed;
string ussGuidUsed;
string uxmlPath = ResolveAssetPath(uxmlShorthand, uxmlPathParam, uxmlGuidParam, out uxmlGuidUsed);
string ussPath = ResolveAssetPath(ussShorthand, ussPathParam, ussGuidParam, out ussGuidUsed);
if (string.IsNullOrEmpty(uxmlPath))
{
if (!string.IsNullOrEmpty(uxmlGuidUsed))
{
return Response.Error($"UXML asset not found for GUID: {uxmlGuidUsed}");
}
return Response.Error("UXML path could not be resolved.");
}
if (string.IsNullOrEmpty(ussPath))
{
if (!string.IsNullOrEmpty(ussGuidUsed))
{
return Response.Error($"USS asset not found for GUID: {ussGuidUsed}");
}
return Response.Error("USS path could not be resolved.");
}
var uxmlAsset = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(uxmlPath);
if (uxmlAsset == null)
return Response.Error($"UXML not found at: {uxmlPath}");
var ussAsset = AssetDatabase.LoadAssetAtPath<StyleSheet>(ussPath);
if (ussAsset == null)
return Response.Error($"USS not found at: {ussPath}");
// Read UXML file content
string uxmlContent = File.ReadAllText(uxmlPath);
// Check if USS is already linked
string ussFileName = Path.GetFileName(ussPath);
if (uxmlContent.Contains($"src=\"{ussFileName}\""))
{
return new
{
success = true,
message = "[EXPERIMENTAL] USS already linked to UXML.",
data = new { uxml = uxmlPath, uss = ussPath, alreadyLinked = true },
state_delta = StateComposer.CreateAssetDelta(new[] {
new { path = uxmlPath, imported = false, hasMeta = true }
})
};
}
// Add USS reference to UXML
// Insert after <ui:UXML> tag
int insertPos = uxmlContent.IndexOf("<ui:UXML");
if (insertPos >= 0)
{
insertPos = uxmlContent.IndexOf('>', insertPos) + 1;
string styleTag = $"\n <Style src=\"{ussFileName}\" />";
uxmlContent = uxmlContent.Insert(insertPos, styleTag);
File.WriteAllText(uxmlPath, uxmlContent);
AssetDatabase.ImportAsset(uxmlPath);
StateComposer.IncrementRevision();
return new
{
success = true,
message = "[EXPERIMENTAL] USS linked to UXML.",
data = new { uxml = uxmlPath, uss = ussPath, alreadyLinked = false },
state_delta = StateComposer.CreateAssetDelta(new[] {
new { path = uxmlPath, imported = true, hasMeta = true }
})
};
}
else
{
return Response.Error("Failed to parse UXML file.");
}
}
catch (Exception e)
{
return Response.Error($"[EXPERIMENTAL] Failed to link USS to UXML: {e.Message}");
}
}
/// <summary>
/// Resolves an asset path from either a shorthand (path or GUID), an explicit path, or a GUID.
/// - If explicit path is provided, it is returned as-is.
/// - If shorthand contains '/', it is treated as a path.
/// - Otherwise shorthand / guid are treated as GUIDs and resolved via AssetDatabase.GUIDToAssetPath.
/// Returns null if the asset path cannot be resolved.
/// </summary>
private static string ResolveAssetPath(string shorthand, string pathParam, string guidParam, out string guidUsed)
{
guidUsed = null;
// Prefer explicit path if provided
if (!string.IsNullOrEmpty(pathParam))
{
return pathParam;
}
// Handle shorthand: path or GUID
if (!string.IsNullOrEmpty(shorthand))
{
if (shorthand.Contains("/"))
{
// Looks like a path (ValidateToolParams already enforced "Assets/" prefix when appropriate)
return shorthand;
}
// Treat shorthand as GUID
guidUsed = shorthand;
string resolvedFromShorthand = AssetDatabase.GUIDToAssetPath(shorthand);
if (!string.IsNullOrEmpty(resolvedFromShorthand))
{
return resolvedFromShorthand;
}
// Fall through and let guidParam try (could be different)
}
if (!string.IsNullOrEmpty(guidParam))
{
guidUsed = guidParam;
string resolvedFromGuid = AssetDatabase.GUIDToAssetPath(guidParam);
if (!string.IsNullOrEmpty(resolvedFromGuid))
{
return resolvedFromGuid;
}
}
return null;
}
private static object CreateUxml(JObject @params)
{
try
{
var writeCheck = WriteGuard.CheckWriteAllowed("create_uxml");
if (writeCheck != null) return writeCheck;
string path = @params["path"]?.ToString();
if (string.IsNullOrEmpty(path))
return Response.Error("'path' parameter required.");
if (!path.EndsWith(".uxml"))
path += ".uxml";
if (File.Exists(path))
return Response.Error($"UXML file already exists at: {path}");
string dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
string template = @"<?xml version=""1.0"" encoding=""utf-8""?>
<ui:UXML
xmlns:ui=""UnityEngine.UIElements""
xmlns:uie=""UnityEditor.UIElements"">
<ui:VisualElement name=""root"" style=""flex-grow: 1;"">
<ui:Label text=""Hello UI Toolkit"" name=""title-label"" />
</ui:VisualElement>
</ui:UXML>";
File.WriteAllText(path, template);
AssetDatabase.ImportAsset(path);
StateComposer.IncrementRevision();
return new
{
success = true,
message = "[EXPERIMENTAL] UXML file created.",
data = new { path = path },
state_delta = StateComposer.CreateAssetDelta(new[] {
new { path = path, imported = true, hasMeta = true }
})
};
}
catch (Exception e)
{
return Response.Error($"[EXPERIMENTAL] Failed to create UXML: {e.Message}");
}
}
private static object CreateUss(JObject @params)
{
try
{
var writeCheck = WriteGuard.CheckWriteAllowed("create_uss");
if (writeCheck != null) return writeCheck;
string path = @params["path"]?.ToString();
if (string.IsNullOrEmpty(path))
return Response.Error("'path' parameter required.");
if (!path.EndsWith(".uss"))
path += ".uss";
if (File.Exists(path))
return Response.Error($"USS file already exists at: {path}");
string dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
string template = @"/* UI Toolkit Style Sheet */
.root {
flex-grow: 1;
background-color: rgb(56, 56, 56);
}
#title-label {
font-size: 20px;
-unity-font-style: bold;
color: rgb(255, 255, 255);
padding: 10px;
}
";
File.WriteAllText(path, template);
AssetDatabase.ImportAsset(path);
StateComposer.IncrementRevision();
return new
{
success = true,
message = "[EXPERIMENTAL] USS file created.",
data = new { path = path },
state_delta = StateComposer.CreateAssetDelta(new[] {
new { path = path, imported = true, hasMeta = true }
})
};
}
catch (Exception e)
{
return Response.Error($"[EXPERIMENTAL] Failed to create USS: {e.Message}");
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 42651d1308102f848b379ba3ba6e6f27
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,699 @@
using System;
using System.Collections.Generic;
using Codely.Newtonsoft.Json;
using Codely.Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityTcp.Editor.Helpers;
namespace UnityTcp.Editor.Tools
{
/// <summary>
/// High-level Unity workflows (init_session, compile_and_validate, checkpoint).
///
/// Important:
/// - Workflows are advanced incrementally on each call to avoid blocking the editor thread.
/// - Minimal state is persisted via SessionState so that the workflow can survive domain reloads.
/// - Clients can poll by calling the same action again with op_id.
/// </summary>
public static class ManageWorkflow
{
private static readonly List<string> ValidActions = new List<string>
{
"init_session",
"compile_and_validate",
"checkpoint",
};
private const string KeyPrefix = "ManageWorkflow_";
private static string CtxKey(string opId) => $"{KeyPrefix}Ctx_{opId}";
public static object HandleCommand(JObject @params)
{
if (@params == null)
{
return Response.Error("Parameters cannot be null.");
}
string action = @params["action"]?.ToString()?.ToLowerInvariant();
if (string.IsNullOrEmpty(action))
{
return Response.Error("Action parameter is required.");
}
if (!ValidActions.Contains(action))
{
string valid = string.Join(", ", ValidActions);
return Response.Error($"Unknown action: '{action}'. Valid actions are: {valid}");
}
// Optional explicit op_id for polling
string requestedOpId = @params["op_id"]?.ToString();
string opId = null;
JObject ctx = null;
if (!string.IsNullOrEmpty(requestedOpId))
{
opId = requestedOpId;
ctx = LoadContext(opId);
if (ctx == null)
{
return BuildErrorWithOpId(opId, "unknown_op_id", $"Unknown workflow op_id: {opId}");
}
// Guard: prevent action/op_id mismatches from accidentally advancing the wrong workflow.
string ctxAction = ctx["action"]?.ToString()?.ToLowerInvariant();
if (!string.IsNullOrEmpty(ctxAction) && !string.Equals(ctxAction, action, StringComparison.OrdinalIgnoreCase))
{
return BuildErrorWithOpId(
opId,
"action_mismatch",
$"Workflow op_id '{opId}' belongs to action '{ctxAction}', not '{action}'."
);
}
}
if (ctx == null)
{
// Start a new workflow
opId = Guid.NewGuid().ToString("N");
ctx = new JObject
{
["op_id"] = opId,
["action"] = action,
["stage"] = "start",
["createdAtUtcTicks"] = DateTime.UtcNow.Ticks,
};
// Per-action defaults (match TypeScript tool behavior)
int timeoutSeconds =
@params["timeoutSeconds"]?.ToObject<int?>()
?? (action == "init_session" ? 600 : 180);
if (timeoutSeconds < 1) timeoutSeconds = 1;
ctx["timeoutSeconds"] = timeoutSeconds;
// Persist checkpoint parameters so polling doesn't require resending options
if (action == "checkpoint")
{
bool screenshot = @params["screenshot"]?.ToObject<bool?>() ?? true;
ctx["screenshot"] = screenshot;
string screenshotAction = @params["screenshotAction"]?.ToString();
if (string.IsNullOrEmpty(screenshotAction)) screenshotAction = "capture";
ctx["screenshotAction"] = screenshotAction;
ctx["screenshotPath"] = @params["screenshotPath"]?.ToString();
ctx["screenshotFilename"] = @params["screenshotFilename"]?.ToString();
}
SaveContext(opId, ctx);
}
// Overall workflow timeout guard
if (IsTimedOut(ctx, out double elapsedSec))
{
DeleteContext(opId);
return BuildErrorWithOpId(
opId,
"timeout",
$"unity_workflow '{action}' timed out after {Math.Round(elapsedSec)}s"
);
}
try
{
switch (action)
{
case "init_session":
return AdvanceInitSession(opId, ctx);
case "compile_and_validate":
return AdvanceCompileAndValidate(opId, ctx);
case "checkpoint":
return ExecuteCheckpoint(opId, ctx);
default:
return Response.Error($"Unknown action: '{action}'");
}
}
catch (Exception e)
{
Debug.LogError($"[ManageWorkflow] Action '{action}' failed: {e}");
DeleteContext(opId);
return BuildErrorWithOpId(opId, "exception", e.Message);
}
}
private static object AdvanceInitSession(string opId, JObject ctx)
{
string stage = ctx["stage"]?.ToString() ?? "start";
var deltas = new List<object>();
if (stage != "start" && stage != "waiting_idle")
{
DeleteContext(opId);
return BuildErrorWithOpId(
opId,
"invalid_stage",
$"Invalid init_session stage: '{stage}'. Please retry init_session."
);
}
if (stage == "start")
{
// 1) get_current_state
var editorState = ManageEditor.HandleCommand(new JObject
{
["action"] = "get_current_state",
});
var editorJ = ToJObject(editorState);
if (editorJ?["success"]?.ToObject<bool?>() == false)
{
DeleteContext(opId);
return editorState;
}
// 2) clear console
var clear = ReadConsole.HandleCommand(new JObject
{
["action"] = "clear",
["scope"] = "all",
});
var clearJ = ToJObject(clear);
if (clearJ?["success"]?.ToObject<bool?>() == false)
{
DeleteContext(opId);
return clear;
}
string sinceToken =
clearJ?["data"]?["sinceToken"]?.ToString()
?? StateComposer.GetCurrentConsoleToken();
ctx["editor_state"] = editorJ;
ctx["console_clear"] = clearJ;
ctx["since_token"] = sinceToken;
ctx["stage"] = "waiting_idle";
SaveContext(opId, ctx);
deltas.Add(ExtractStateDelta(editorJ));
deltas.Add(ExtractStateDelta(clearJ));
}
// 3) wait_for_idle (poll)
int timeoutSeconds = ctx["timeoutSeconds"]?.ToObject<int?>() ?? 600;
var idle = ManageEditor.HandleCommand(new JObject
{
["action"] = "wait_for_idle",
["timeoutSeconds"] = timeoutSeconds,
});
var idleJ = ToJObject(idle);
if (idleJ?["success"]?.ToObject<bool?>() == false)
{
DeleteContext(opId);
return idle;
}
ctx["idle"] = idleJ;
SaveContext(opId, ctx);
deltas.Add(ExtractStateDelta(idleJ));
var mergedDelta = StateComposer.MergeStateDeltas(deltas.ToArray());
var stateObj = ctx["editor_state"]?["state"];
var data = new JObject
{
["editor_state"] = ctx["editor_state"],
["console_clear"] = ctx["console_clear"],
["idle"] = idleJ,
};
string status = idleJ?["status"]?.ToString();
if (string.Equals(status, "pending", StringComparison.OrdinalIgnoreCase))
{
double poll = idleJ?["poll_interval"]?.ToObject<double?>() ?? 1.0;
return BuildPending(
opId,
"Unity workflow init_session pending (waiting for idle)...",
poll,
data,
stateObj,
mergedDelta
);
}
DeleteContext(opId);
return BuildComplete(
opId,
"Unity workflow init_session completed",
data,
stateObj,
mergedDelta
);
}
private static object AdvanceCompileAndValidate(string opId, JObject ctx)
{
// Server-side guard: do not compile while playing/paused
if (EditorApplication.isPlaying || EditorApplication.isPaused)
{
DeleteContext(opId);
return Response.Error(
"compile_blocked_in_play_mode",
new
{
code = "compile_blocked_in_play_mode",
message = "Compilation is not allowed while the editor is in Play/Paused mode. Stop Play mode (unity_editor.stop) before requesting compilation.",
playMode = EditorApplication.isPlaying ? "playing" : "paused",
}
);
}
string stage = ctx["stage"]?.ToString() ?? "start";
var deltas = new List<object>();
if (stage != "start" && stage != "waiting_compile")
{
DeleteContext(opId);
return BuildErrorWithOpId(
opId,
"invalid_stage",
$"Invalid compile_and_validate stage: '{stage}'. Please retry compile_and_validate."
);
}
if (stage == "start")
{
// Start compilation pipeline: clear console → request compile → return op_id + since_token
var start = ManageEditor.HandleCommand(new JObject
{
["action"] = "start_compilation_pipeline",
});
var startJ = ToJObject(start);
if (startJ?["success"]?.ToObject<bool?>() == false)
{
DeleteContext(opId);
return start;
}
string compileOpId =
startJ?["op_id"]?.ToString() ?? startJ?["opId"]?.ToString();
string sinceToken = startJ?["since_token"]?.ToString();
if (string.IsNullOrEmpty(compileOpId))
{
DeleteContext(opId);
return BuildErrorWithOpId(opId, "missing_op_id", "Compilation pipeline did not return op_id");
}
ctx["start"] = startJ;
ctx["compile_op_id"] = compileOpId;
if (!string.IsNullOrEmpty(sinceToken)) ctx["since_token"] = sinceToken;
ctx["stage"] = "waiting_compile";
SaveContext(opId, ctx);
deltas.Add(ExtractStateDelta(startJ));
}
string compileOpId2 = ctx["compile_op_id"]?.ToString();
string sinceToken2 = ctx["since_token"]?.ToString();
int timeoutSeconds = ctx["timeoutSeconds"]?.ToObject<int?>() ?? 180;
if (string.IsNullOrEmpty(compileOpId2))
{
DeleteContext(opId);
return BuildErrorWithOpId(
opId,
"missing_op_id",
"Workflow context missing compile_op_id. Please retry compile_and_validate."
);
}
if (string.IsNullOrEmpty(sinceToken2))
{
sinceToken2 = StateComposer.GetCurrentConsoleToken();
if (!string.IsNullOrEmpty(sinceToken2))
{
ctx["since_token"] = sinceToken2;
SaveContext(opId, ctx);
}
}
// Wait for compile (poll)
var wait = ManageEditor.HandleCommand(new JObject
{
["action"] = "wait_for_compile",
["op_id"] = compileOpId2,
["timeoutSeconds"] = timeoutSeconds,
["since_token"] = sinceToken2,
});
var waitJ = ToJObject(wait);
ctx["wait"] = waitJ;
SaveContext(opId, ctx);
deltas.Add(ExtractStateDelta(waitJ));
var status = waitJ?["status"]?.ToString();
bool waitFailed = waitJ?["success"]?.ToObject<bool?>() == false;
if (string.Equals(status, "pending", StringComparison.OrdinalIgnoreCase) && !waitFailed)
{
double poll = waitJ?["poll_interval"]?.ToObject<double?>() ?? 1.0;
var pendingData = new JObject
{
["stage"] = "waiting_compile",
["start"] = ctx["start"],
["wait"] = waitJ,
["op_id"] = compileOpId2,
["since_token"] = sinceToken2,
};
var mergedPendingDelta = StateComposer.MergeStateDeltas(deltas.ToArray());
return BuildPending(
opId,
"Unity workflow compile_and_validate pending (waiting for compile)...",
poll,
pendingData,
null,
mergedPendingDelta
);
}
// Read console since token (even if wait failed)
var consoleRead = ReadConsole.HandleCommand(new JObject
{
["action"] = "get",
["since_token"] = sinceToken2,
});
var consoleJ = ToJObject(consoleRead);
if (consoleJ?["success"]?.ToObject<bool?>() == false)
{
DeleteContext(opId);
return consoleRead;
}
ctx["console"] = consoleJ;
SaveContext(opId, ctx);
// Compute hasErrors / hasWarnings
var entriesToken = consoleJ?["data"]?["entries"];
var entriesArray = entriesToken as JArray;
int errCount = 0;
int warnCount = 0;
if (entriesArray != null)
{
foreach (var entry in entriesArray)
{
var t = entry?["type"]?.ToString();
if (string.IsNullOrEmpty(t)) continue;
var lower = t.ToLowerInvariant();
if (lower == "error" || lower == "exception") errCount++;
else if (lower == "warning") warnCount++;
}
}
bool hasErrors = errCount > 0;
bool hasWarnings = warnCount > 0;
bool success = !waitFailed && !hasErrors;
var data = new JObject
{
["start"] = ctx["start"],
["wait"] = waitJ,
["wait_failed"] = waitFailed,
["console"] = consoleJ,
["since_token"] = sinceToken2,
["op_id"] = compileOpId2,
["hasErrors"] = hasErrors,
["hasWarnings"] = hasWarnings,
["consoleErrorCount"] = errCount,
["consoleWarningCount"] = warnCount,
};
var mergedDelta = StateComposer.MergeStateDeltas(deltas.ToArray());
DeleteContext(opId);
if (success)
{
return BuildComplete(
opId,
"Unity workflow compile_and_validate completed",
data,
null,
mergedDelta
);
}
// Treat compilation errors as tool failure, but keep the structured data
string errorMessage = waitFailed
? "Unity workflow compile_and_validate failed (wait_for_compile failed)"
: "Unity workflow compile_and_validate failed (console has errors)";
return new Dictionary<string, object>
{
["success"] = false,
["status"] = "complete",
["op_id"] = opId,
["code"] = waitFailed ? "compile_wait_failed" : "compilation_errors",
["error"] = errorMessage,
["message"] = errorMessage,
["data"] = data,
["state_delta"] = mergedDelta,
};
}
private static object ExecuteCheckpoint(string opId, JObject ctx)
{
// checkpoint should finish in one call; no multi-step polling required.
bool screenshot = ctx["screenshot"]?.ToObject<bool?>() ?? true;
string screenshotAction = ctx["screenshotAction"]?.ToString() ?? "capture";
string screenshotPath = ctx["screenshotPath"]?.ToString();
string screenshotFilename = ctx["screenshotFilename"]?.ToString();
var deltas = new List<object>();
var save = ManageScene.HandleCommand(new JObject
{
["action"] = "ensure_scene_saved",
});
var saveJ = ToJObject(save);
if (saveJ?["success"]?.ToObject<bool?>() == false)
{
DeleteContext(opId);
return save;
}
deltas.Add(ExtractStateDelta(saveJ));
JObject screenshotJ = null;
if (screenshot)
{
var shotParams = new JObject
{
["action"] = screenshotAction,
};
if (!string.IsNullOrEmpty(screenshotPath)) shotParams["path"] = screenshotPath;
if (!string.IsNullOrEmpty(screenshotFilename)) shotParams["filename"] = screenshotFilename;
var shot = ManageScreenshot.HandleCommand(shotParams);
screenshotJ = ToJObject(shot);
if (screenshotJ?["success"]?.ToObject<bool?>() == false)
{
DeleteContext(opId);
return shot;
}
deltas.Add(ExtractStateDelta(screenshotJ));
}
var mergedDelta = StateComposer.MergeStateDeltas(deltas.ToArray());
var data = new JObject
{
["scene_saved"] = saveJ,
["screenshot"] = screenshot ? screenshotJ : null,
};
DeleteContext(opId);
return BuildComplete(
opId,
"Unity workflow checkpoint completed",
data,
null,
mergedDelta
);
}
private static bool IsTimedOut(JObject ctx, out double elapsedSeconds)
{
elapsedSeconds = 0;
try
{
long createdTicks = ctx["createdAtUtcTicks"]?.ToObject<long?>() ?? 0;
if (createdTicks <= 0) return false;
int timeoutSeconds = ctx["timeoutSeconds"]?.ToObject<int?>() ?? 0;
if (timeoutSeconds <= 0) return false;
elapsedSeconds =
(DateTime.UtcNow.Ticks - createdTicks) / (double)TimeSpan.TicksPerSecond;
return elapsedSeconds > timeoutSeconds;
}
catch
{
return false;
}
}
private static JObject LoadContext(string opId)
{
string json = GetSessionString(CtxKey(opId));
if (string.IsNullOrEmpty(json)) return null;
try
{
return JObject.Parse(json);
}
catch
{
return null;
}
}
private static void SaveContext(string opId, JObject ctx)
{
if (string.IsNullOrEmpty(opId) || ctx == null) return;
try
{
SessionState.SetString(CtxKey(opId), ctx.ToString(Formatting.None));
}
catch { }
}
private static void DeleteContext(string opId)
{
if (string.IsNullOrEmpty(opId)) return;
try
{
// Use empty string instead of EraseString to maximize Unity version compatibility
SessionState.SetString(CtxKey(opId), string.Empty);
}
catch { }
}
private static string GetSessionString(string key)
{
try
{
var v = SessionState.GetString(key, null);
return string.IsNullOrEmpty(v) ? null : v;
}
catch
{
return null;
}
}
private static JObject ToJObject(object obj)
{
if (obj == null) return null;
try
{
if (obj is JObject j) return j;
return JObject.FromObject(obj);
}
catch
{
return null;
}
}
private static object ExtractStateDelta(JObject result)
{
if (result == null) return null;
return result["state_delta"];
}
private static object BuildPending(
string opId,
string message,
double pollInterval,
JObject data,
JToken state,
object stateDelta
)
{
var resp = new Dictionary<string, object>
{
["success"] = true,
["status"] = "pending",
["op_id"] = opId,
["poll_interval"] = pollInterval,
["message"] = message,
["data"] = data,
};
if (state != null) resp["state"] = state;
// Also surface operations delta for this workflow
try
{
var opDelta = StateComposer.CreateOperationsDelta(
new object[]
{
new { id = opId, type = "workflow", progress = 0.0f, message = message },
}
);
resp["state_delta"] =
stateDelta != null
? StateComposer.MergeStateDeltas(opDelta, stateDelta)
: opDelta;
}
catch
{
if (stateDelta != null) resp["state_delta"] = stateDelta;
}
return resp;
}
private static object BuildComplete(
string opId,
string message,
JObject data,
JToken state,
object stateDelta
)
{
var resp = new Dictionary<string, object>
{
["success"] = true,
["status"] = "complete",
["op_id"] = opId,
["message"] = message,
["data"] = data,
};
if (state != null) resp["state"] = state;
if (stateDelta != null) resp["state_delta"] = stateDelta;
return resp;
}
private static object BuildErrorWithOpId(string opId, string code, string message)
{
return new Dictionary<string, object>
{
["success"] = false,
["status"] = "error",
["op_id"] = opId,
["code"] = code,
["error"] = message,
["message"] = message,
};
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c3f5f5eaf4972c94ca0d613330e6768a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,879 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Codely.Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEditorInternal;
using UnityEngine;
using UnityTcp.Editor.Helpers; // For Response class
namespace UnityTcp.Editor.Tools
{
/// <summary>
/// Handles reading and clearing Unity Editor console log entries.
/// Uses reflection to access internal LogEntry methods/properties.
/// Supports incremental reading via since_token mechanism.
/// </summary>
public static class ReadConsole
{
// SessionState keys for persisting token state across domain reloads
private const string SessionKeyTokenCounter = "ReadConsole_TokenCounter";
private const string SessionKeySinceToken = "ReadConsole_SinceToken";
private const string SessionKeyClearTimeTicks = "ReadConsole_ClearTimeTicks";
private const string SessionKeyEntryCountAtClear = "ReadConsole_EntryCountAtClear";
private const string SessionKeyTokenMap = "ReadConsole_TokenMap";
// Token generation state - backed by SessionState for persistence across domain reloads
private static readonly object _tokenLock = new object();
// Properties that persist via SessionState
private static long TokenCounter
{
get => SessionState.GetInt(SessionKeyTokenCounter, 0);
set => SessionState.SetInt(SessionKeyTokenCounter, (int)value);
}
private static string CurrentSinceToken
{
get => SessionState.GetString(SessionKeySinceToken, null);
set => SessionState.SetString(SessionKeySinceToken, value ?? string.Empty);
}
private static long ClearTimeTicks
{
get
{
var str = SessionState.GetString(SessionKeyClearTimeTicks, "0");
return long.TryParse(str, out var val) ? val : 0;
}
set => SessionState.SetString(SessionKeyClearTimeTicks, value.ToString());
}
private static int EntryCountAtClear
{
get => SessionState.GetInt(SessionKeyEntryCountAtClear, 0);
set => SessionState.SetInt(SessionKeyEntryCountAtClear, value);
}
// Token entry count map - serialized as JSON in SessionState
private static Dictionary<string, int> GetTokenEntryCountMap()
{
var json = SessionState.GetString(SessionKeyTokenMap, "{}");
try
{
var jobj = Codely.Newtonsoft.Json.Linq.JObject.Parse(json);
var dict = new Dictionary<string, int>();
foreach (var prop in jobj.Properties())
{
if (int.TryParse(prop.Value.ToString(), out var count))
{
dict[prop.Name] = count;
}
}
return dict;
}
catch
{
return new Dictionary<string, int>();
}
}
private static void SaveTokenEntryCountMap(Dictionary<string, int> map)
{
var jobj = new Codely.Newtonsoft.Json.Linq.JObject();
foreach (var kvp in map)
{
jobj[kvp.Key] = kvp.Value;
}
SessionState.SetString(SessionKeyTokenMap, jobj.ToString());
}
// Reflection members for accessing internal LogEntry data
// private static MethodInfo _getEntriesMethod; // Removed as it's unused and fails reflection
private static MethodInfo _startGettingEntriesMethod;
private static MethodInfo _endGettingEntriesMethod; // Renamed from _stopGettingEntriesMethod, trying End...
private static MethodInfo _clearMethod;
private static MethodInfo _getCountMethod;
private static MethodInfo _getEntryMethod;
private static FieldInfo _modeField;
private static FieldInfo _messageField;
private static FieldInfo _fileField;
private static FieldInfo _lineField;
private static FieldInfo _instanceIdField;
// Note: Timestamp is not directly available in LogEntry; need to parse message or find alternative?
// Static constructor for reflection setup
static ReadConsole()
{
try
{
Type logEntriesType = typeof(EditorApplication).Assembly.GetType(
"UnityEditor.LogEntries"
);
if (logEntriesType == null)
throw new Exception("Could not find internal type UnityEditor.LogEntries");
// Include NonPublic binding flags as internal APIs might change accessibility
BindingFlags staticFlags =
BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
BindingFlags instanceFlags =
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
_startGettingEntriesMethod = logEntriesType.GetMethod(
"StartGettingEntries",
staticFlags
);
if (_startGettingEntriesMethod == null)
throw new Exception("Failed to reflect LogEntries.StartGettingEntries");
// Try reflecting EndGettingEntries based on warning message
_endGettingEntriesMethod = logEntriesType.GetMethod(
"EndGettingEntries",
staticFlags
);
if (_endGettingEntriesMethod == null)
throw new Exception("Failed to reflect LogEntries.EndGettingEntries");
_clearMethod = logEntriesType.GetMethod("Clear", staticFlags);
if (_clearMethod == null)
throw new Exception("Failed to reflect LogEntries.Clear");
_getCountMethod = logEntriesType.GetMethod("GetCount", staticFlags);
if (_getCountMethod == null)
throw new Exception("Failed to reflect LogEntries.GetCount");
_getEntryMethod = logEntriesType.GetMethod("GetEntryInternal", staticFlags);
if (_getEntryMethod == null)
throw new Exception("Failed to reflect LogEntries.GetEntryInternal");
Type logEntryType = typeof(EditorApplication).Assembly.GetType(
"UnityEditor.LogEntry"
);
if (logEntryType == null)
throw new Exception("Could not find internal type UnityEditor.LogEntry");
_modeField = logEntryType.GetField("mode", instanceFlags);
if (_modeField == null)
throw new Exception("Failed to reflect LogEntry.mode");
_messageField = logEntryType.GetField("message", instanceFlags);
if (_messageField == null)
throw new Exception("Failed to reflect LogEntry.message");
_fileField = logEntryType.GetField("file", instanceFlags);
if (_fileField == null)
throw new Exception("Failed to reflect LogEntry.file");
_lineField = logEntryType.GetField("line", instanceFlags);
if (_lineField == null)
throw new Exception("Failed to reflect LogEntry.line");
_instanceIdField = logEntryType.GetField("instanceID", instanceFlags);
if (_instanceIdField == null)
throw new Exception("Failed to reflect LogEntry.instanceID");
// (Calibration removed)
}
catch (Exception e)
{
Debug.LogError(
$"[ReadConsole] Static Initialization Failed: Could not setup reflection for LogEntries/LogEntry. Console reading/clearing will likely fail. Specific Error: {e.Message}"
);
// Set members to null to prevent NullReferenceExceptions later, HandleCommand should check this.
_startGettingEntriesMethod =
_endGettingEntriesMethod =
_clearMethod =
_getCountMethod =
_getEntryMethod =
null;
_modeField = _messageField = _fileField = _lineField = _instanceIdField = null;
}
}
// --- Main Handler ---
public static object HandleCommand(JObject @params)
{
// Check if ALL required reflection members were successfully initialized.
if (
_startGettingEntriesMethod == null
|| _endGettingEntriesMethod == null
|| _clearMethod == null
|| _getCountMethod == null
|| _getEntryMethod == null
|| _modeField == null
|| _messageField == null
|| _fileField == null
|| _lineField == null
|| _instanceIdField == null
)
{
// Log the error here as well for easier debugging in Unity Console
Debug.LogError(
"[ReadConsole] HandleCommand called but reflection members are not initialized. Static constructor might have failed silently or there's an issue."
);
return Response.Error(
"ReadConsole handler failed to initialize due to reflection errors. Cannot access console logs."
);
}
string action = @params["action"]?.ToString().ToLower() ?? "get";
try
{
if (action == "clear")
{
// Extract scope parameter: "all" (default) or "errors_only"
string scope = (@params["scope"]?.ToString() ?? "all").ToLower();
return ClearConsole(scope);
}
else if (action == "get")
{
// Extract parameters for 'get'
var types =
(@params["types"] as JArray)?.Select(t => t.ToString().ToLower()).ToList()
?? new List<string> { "error", "warning", "log" };
int? count = @params["count"]?.ToObject<int?>();
string filterText = @params["filterText"]?.ToString();
string sinceToken = @params["since_token"]?.ToString(); // NEW: since_token parameter
string sinceTimestampStr = @params["sinceTimestamp"]?.ToString(); // Legacy: timestamp filtering
string format = (@params["format"]?.ToString() ?? "detailed").ToLower();
bool includeStacktrace =
@params["includeStacktrace"]?.ToObject<bool?>() ?? true;
if (types.Contains("all"))
{
types = new List<string> { "error", "warning", "log" }; // Expand 'all'
}
// Prioritize since_token over sinceTimestamp
if (!string.IsNullOrEmpty(sinceToken))
{
return GetConsoleEntriesSinceToken(sinceToken, types, count, filterText, format, includeStacktrace);
}
else if (!string.IsNullOrEmpty(sinceTimestampStr))
{
Debug.LogWarning(
"[ReadConsole] Filtering by 'since_timestamp' is not currently implemented. Use 'since_token' instead."
);
}
return GetConsoleEntries(types, count, filterText, format, includeStacktrace);
}
else
{
return Response.Error(
$"Unknown action: '{action}'. Valid actions are 'get' or 'clear'."
);
}
}
catch (Exception e)
{
Debug.LogError($"[ReadConsole] Action '{action}' failed: {e}");
return Response.Error($"Internal error processing action '{action}': {e.Message}");
}
}
// --- Action Implementations ---
/// <summary>
/// Clears the console with optional scope.
/// </summary>
/// <param name="scope">"all" clears everything (default), "errors_only" clears only error messages</param>
private static object ClearConsole(string scope = "all")
{
try
{
if (scope == "errors_only")
{
// Note: Unity's LogEntries.Clear() clears everything.
// For errors_only, we would need to iterate and selectively remove,
// which is not directly supported by Unity's API.
// For now, we log a warning and clear all (future enhancement could filter).
Debug.LogWarning("[ReadConsole] 'errors_only' scope is not fully supported by Unity's internal API. Clearing all messages.");
}
_clearMethod.Invoke(null, null); // Static method, no instance, no parameters
// Generate a new since_token
string sinceToken;
lock (_tokenLock)
{
var clearTimeTicks = DateTime.UtcNow.Ticks;
ClearTimeTicks = clearTimeTicks;
var counter = TokenCounter + 1;
TokenCounter = counter;
sinceToken = $"{clearTimeTicks}-{counter}";
CurrentSinceToken = sinceToken;
// Record that at this token, there were 0 entries (console was just cleared)
EntryCountAtClear = 0;
// Update the token entry count map
var tokenMap = GetTokenEntryCountMap();
tokenMap[sinceToken] = 0;
// Clean up old tokens (keep only last 10)
if (tokenMap.Count > 10)
{
var oldTokens = tokenMap.Keys
.Where(k => k != sinceToken)
.OrderBy(k => k)
.Take(tokenMap.Count - 10)
.ToList();
foreach (var oldToken in oldTokens)
{
tokenMap.Remove(oldToken);
}
}
SaveTokenEntryCountMap(tokenMap);
}
// Update state revision and console state tracking
StateComposer.IncrementRevision();
StateComposer.UpdateConsoleState(sinceToken, 0, new object[0]);
// Return success with the since_token in data and state_delta
return new
{
success = true,
message = scope == "errors_only"
? "Console cleared (errors_only scope limited by Unity API - all messages cleared)."
: "Console cleared successfully.",
data = new
{
sinceToken = sinceToken,
scope = scope
},
state_delta = StateComposer.CreateConsoleDelta(sinceToken, 0, new object[0])
};
}
catch (Exception e)
{
Debug.LogError($"[ReadConsole] Failed to clear console: {e}");
return Response.Error($"Failed to clear console: {e.Message}");
}
}
private static object GetConsoleEntriesSinceToken(
string sinceToken,
List<string> types,
int? count,
string filterText,
string format,
bool includeStacktrace
)
{
int startIndex = 0;
bool tokenValid = false;
string currentToken;
// Validate the since_token and determine start index
lock (_tokenLock)
{
currentToken = CurrentSinceToken;
if (string.IsNullOrEmpty(currentToken))
{
return Response.Error(
"No valid since_token available. Please call 'clear' first to obtain a since_token.",
new { sinceToken = (string)null }
);
}
if (sinceToken == currentToken)
{
// Token matches current - read entries after the clear point
startIndex = EntryCountAtClear;
tokenValid = true;
}
else
{
// Check if it's an older but still tracked token
var tokenMap = GetTokenEntryCountMap();
if (tokenMap.TryGetValue(sinceToken, out int recordedCount))
{
// Token is older but still tracked - use its recorded count
startIndex = recordedCount;
tokenValid = true;
Debug.LogWarning(
$"[ReadConsole] Using older token. Provided: {sinceToken}, Current: {currentToken}. " +
"Reading from recorded position."
);
}
else
{
// Token is unknown/expired - return all entries with warning
Debug.LogWarning(
$"[ReadConsole] since_token unknown or expired. Provided: {sinceToken}, Current: {currentToken}. " +
"Returning all entries. Please clear console and use the new token."
);
startIndex = 0;
tokenValid = false;
}
}
}
// Get entries starting from the determined index
var result = GetConsoleEntriesFromIndex(startIndex, types, count, filterText, format, includeStacktrace);
// Enhance the response with since_token information
if (result is System.Collections.IDictionary dict && dict.Contains("data"))
{
var dataObj = dict["data"];
int entryCount = 0;
if (dataObj is System.Collections.IList list)
{
entryCount = list.Count;
}
// Update console state with current unread count
var errors = ExtractErrorsFromResult(result);
StateComposer.UpdateConsoleState(currentToken, entryCount, errors);
// Add metadata about the token
var enhancedData = new
{
entries = dataObj,
sinceToken = currentToken,
tokenValidated = tokenValid,
providedToken = sinceToken,
startIndex = startIndex,
summary = new
{
total = entryCount,
filtered = !string.IsNullOrEmpty(filterText),
newSinceToken = tokenValid
}
};
return Response.Success(
tokenValid
? $"Retrieved {entryCount} new log entries since token."
: $"Retrieved {entryCount} log entries (token was invalid/expired).",
enhancedData
);
}
return result;
}
/// <summary>
/// Extracts error entries from a result for state tracking.
/// </summary>
private static object[] ExtractErrorsFromResult(object result)
{
var errors = new List<object>();
if (result is System.Collections.IDictionary dict && dict.Contains("data"))
{
var dataObj = dict["data"];
if (dataObj is System.Collections.IList list)
{
foreach (var entry in list)
{
// Check if entry has type = Error or Exception
var entryType = entry?.GetType();
if (entryType != null)
{
var typeProp = entryType.GetProperty("type");
var typeValue = typeProp?.GetValue(entry)?.ToString();
if (typeValue == "Error" || typeValue == "Exception")
{
var messageProp = entryType.GetProperty("message");
var fileProp = entryType.GetProperty("file");
var lineProp = entryType.GetProperty("line");
errors.Add(new
{
message = messageProp?.GetValue(entry)?.ToString(),
file = fileProp?.GetValue(entry)?.ToString(),
line = lineProp?.GetValue(entry)
});
}
}
}
}
}
return errors.Take(10).ToArray(); // Keep only last 10 errors
}
private static object GetConsoleEntries(
List<string> types,
int? count,
string filterText,
string format,
bool includeStacktrace
)
{
return GetConsoleEntriesFromIndex(0, types, count, filterText, format, includeStacktrace);
}
/// <summary>
/// Gets console entries starting from a specific index.
/// Used for since_token filtering.
/// </summary>
private static object GetConsoleEntriesFromIndex(
int startIndex,
List<string> types,
int? count,
string filterText,
string format,
bool includeStacktrace
)
{
List<object> formattedEntries = new List<object>();
int retrievedCount = 0;
try
{
// LogEntries requires calling Start/Stop around GetEntries/GetEntryInternal
_startGettingEntriesMethod.Invoke(null, null);
int totalEntries = (int)_getCountMethod.Invoke(null, null);
// Create instance to pass to GetEntryInternal - Ensure the type is correct
Type logEntryType = typeof(EditorApplication).Assembly.GetType(
"UnityEditor.LogEntry"
);
if (logEntryType == null)
throw new Exception(
"Could not find internal type UnityEditor.LogEntry during GetConsoleEntries."
);
object logEntryInstance = Activator.CreateInstance(logEntryType);
// Ensure startIndex is valid
if (startIndex < 0) startIndex = 0;
if (startIndex > totalEntries) startIndex = totalEntries;
for (int i = startIndex; i < totalEntries; i++)
{
// Get the entry data into our instance using reflection
_getEntryMethod.Invoke(null, new object[] { i, logEntryInstance });
// Extract data using reflection
int mode = (int)_modeField.GetValue(logEntryInstance);
string message = (string)_messageField.GetValue(logEntryInstance);
string file = (string)_fileField.GetValue(logEntryInstance);
int line = (int)_lineField.GetValue(logEntryInstance);
// int instanceId = (int)_instanceIdField.GetValue(logEntryInstance);
if (string.IsNullOrEmpty(message))
{
continue; // Skip empty messages
}
// (Calibration removed)
// --- Filtering ---
// Prefer classifying severity from message/stacktrace; fallback to mode bits if needed
LogType unityType = InferTypeFromMessage(message);
bool isExplicitDebug = IsExplicitDebugLog(message);
if (!isExplicitDebug && unityType == LogType.Log)
{
unityType = GetLogTypeFromMode(mode);
}
bool want;
// Treat Exception/Assert as errors for filtering convenience
if (unityType == LogType.Exception)
{
want = types.Contains("error") || types.Contains("exception");
}
else if (unityType == LogType.Assert)
{
want = types.Contains("error") || types.Contains("assert");
}
else
{
want = types.Contains(unityType.ToString().ToLowerInvariant());
}
if (!want) continue;
// Filter by text (case-insensitive)
if (
!string.IsNullOrEmpty(filterText)
&& message.IndexOf(filterText, StringComparison.OrdinalIgnoreCase) < 0
)
{
continue;
}
// TODO: Filter by timestamp (requires timestamp data)
// --- Formatting ---
string stackTrace = includeStacktrace ? ExtractStackTrace(message) : null;
// Get first line if stack is present and requested, otherwise use full message
string messageOnly =
(includeStacktrace && !string.IsNullOrEmpty(stackTrace))
? message.Split(
new[] { '\n', '\r' },
StringSplitOptions.RemoveEmptyEntries
)[0]
: message;
object formattedEntry = null;
switch (format)
{
case "plain":
formattedEntry = messageOnly;
break;
case "json":
case "detailed": // Treat detailed as json for structured return
default:
formattedEntry = new
{
type = unityType.ToString(),
message = messageOnly,
file = file,
line = line,
// timestamp = "", // TODO
stackTrace = stackTrace, // Will be null if includeStacktrace is false or no stack found
};
break;
}
formattedEntries.Add(formattedEntry);
retrievedCount++;
// Apply count limit (after filtering)
if (count.HasValue && retrievedCount >= count.Value)
{
break;
}
}
}
catch (Exception e)
{
Debug.LogError($"[ReadConsole] Error while retrieving log entries: {e}");
// Ensure EndGettingEntries is called even if there's an error during iteration
try
{
_endGettingEntriesMethod.Invoke(null, null);
}
catch
{ /* Ignore nested exception */
}
return Response.Error($"Error retrieving log entries: {e.Message}");
}
finally
{
// Ensure we always call EndGettingEntries
try
{
_endGettingEntriesMethod.Invoke(null, null);
}
catch (Exception e)
{
Debug.LogError($"[ReadConsole] Failed to call EndGettingEntries: {e}");
// Don't return error here as we might have valid data, but log it.
}
}
// Return the filtered and formatted list (might be empty)
return Response.Success(
$"Retrieved {formattedEntries.Count} log entries.",
formattedEntries
);
}
// --- Internal Helpers ---
// Mapping bits from LogEntry.mode. These may vary by Unity version.
private const int ModeBitError = 1 << 0;
private const int ModeBitAssert = 1 << 1;
private const int ModeBitWarning = 1 << 2;
private const int ModeBitLog = 1 << 3;
private const int ModeBitException = 1 << 4; // often combined with Error bits
private const int ModeBitScriptingError = 1 << 9;
private const int ModeBitScriptingWarning = 1 << 10;
private const int ModeBitScriptingLog = 1 << 11;
private const int ModeBitScriptingException = 1 << 18;
private const int ModeBitScriptingAssertion = 1 << 22;
private static LogType GetLogTypeFromMode(int mode)
{
// Preserve Unity's real type (no remapping); bits may vary by version
if ((mode & (ModeBitException | ModeBitScriptingException)) != 0) return LogType.Exception;
if ((mode & (ModeBitError | ModeBitScriptingError)) != 0) return LogType.Error;
if ((mode & (ModeBitAssert | ModeBitScriptingAssertion)) != 0) return LogType.Assert;
if ((mode & (ModeBitWarning | ModeBitScriptingWarning)) != 0) return LogType.Warning;
return LogType.Log;
}
// (Calibration helpers removed)
/// <summary>
/// Classifies severity using message/stacktrace content. Works across Unity versions.
/// </summary>
private static LogType InferTypeFromMessage(string fullMessage)
{
if (string.IsNullOrEmpty(fullMessage)) return LogType.Log;
// Fast path: look for explicit Debug API names in the appended stack trace
// e.g., "UnityEngine.Debug:LogError (object)" or "LogWarning"
if (fullMessage.IndexOf("LogError", StringComparison.OrdinalIgnoreCase) >= 0)
return LogType.Error;
if (fullMessage.IndexOf("LogWarning", StringComparison.OrdinalIgnoreCase) >= 0)
return LogType.Warning;
// Compiler diagnostics (C#): "warning CSxxxx" / "error CSxxxx"
if (fullMessage.IndexOf(" warning CS", StringComparison.OrdinalIgnoreCase) >= 0
|| fullMessage.IndexOf(": warning CS", StringComparison.OrdinalIgnoreCase) >= 0)
return LogType.Warning;
if (fullMessage.IndexOf(" error CS", StringComparison.OrdinalIgnoreCase) >= 0
|| fullMessage.IndexOf(": error CS", StringComparison.OrdinalIgnoreCase) >= 0)
return LogType.Error;
// Exceptions (avoid misclassifying compiler diagnostics)
if (fullMessage.IndexOf("Exception", StringComparison.OrdinalIgnoreCase) >= 0)
return LogType.Exception;
// Unity assertions
if (fullMessage.IndexOf("Assertion", StringComparison.OrdinalIgnoreCase) >= 0)
return LogType.Assert;
return LogType.Log;
}
private static bool IsExplicitDebugLog(string fullMessage)
{
if (string.IsNullOrEmpty(fullMessage)) return false;
if (fullMessage.IndexOf("Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true;
if (fullMessage.IndexOf("UnityEngine.Debug:Log (", StringComparison.OrdinalIgnoreCase) >= 0) return true;
return false;
}
/// <summary>
/// Applies the "one level lower" remapping for filtering, like the old version.
/// This ensures compatibility with the filtering logic that expects remapped types.
/// </summary>
private static LogType GetRemappedTypeForFiltering(LogType unityType)
{
switch (unityType)
{
case LogType.Error:
return LogType.Warning; // Error becomes Warning
case LogType.Warning:
return LogType.Log; // Warning becomes Log
case LogType.Assert:
return LogType.Assert; // Assert remains Assert
case LogType.Log:
return LogType.Log; // Log remains Log
case LogType.Exception:
return LogType.Warning; // Exception becomes Warning
default:
return LogType.Log; // Default fallback
}
}
/// <summary>
/// Attempts to extract the stack trace part from a log message.
/// Unity log messages often have the stack trace appended after the main message,
/// starting on a new line and typically indented or beginning with "at ".
/// </summary>
/// <param name="fullMessage">The complete log message including potential stack trace.</param>
/// <returns>The extracted stack trace string, or null if none is found.</returns>
private static string ExtractStackTrace(string fullMessage)
{
if (string.IsNullOrEmpty(fullMessage))
return null;
// Split into lines, removing empty ones to handle different line endings gracefully.
// Using StringSplitOptions.None might be better if empty lines matter within stack trace, but RemoveEmptyEntries is usually safer here.
string[] lines = fullMessage.Split(
new[] { '\r', '\n' },
StringSplitOptions.RemoveEmptyEntries
);
// If there's only one line or less, there's no separate stack trace.
if (lines.Length <= 1)
return null;
int stackStartIndex = -1;
// Start checking from the second line onwards.
for (int i = 1; i < lines.Length; ++i)
{
// Performance: TrimStart creates a new string. Consider using IsWhiteSpace check if performance critical.
string trimmedLine = lines[i].TrimStart();
// Check for common stack trace patterns.
if (
trimmedLine.StartsWith("at ")
|| trimmedLine.StartsWith("UnityEngine.")
|| trimmedLine.StartsWith("UnityEditor.")
|| trimmedLine.Contains("(at ")
|| // Covers "(at Assets/..." pattern
// Heuristic: Check if line starts with likely namespace/class pattern (Uppercase.Something)
(
trimmedLine.Length > 0
&& char.IsUpper(trimmedLine[0])
&& trimmedLine.Contains('.')
)
)
{
stackStartIndex = i;
break; // Found the likely start of the stack trace
}
}
// If a potential start index was found...
if (stackStartIndex > 0)
{
// Join the lines from the stack start index onwards using standard newline characters.
// This reconstructs the stack trace part of the message.
return string.Join("\n", lines.Skip(stackStartIndex));
}
// No clear stack trace found based on the patterns.
return null;
}
/* LogEntry.mode bits exploration (based on Unity decompilation/observation):
May change between versions.
Basic Types:
kError = 1 << 0 (1)
kAssert = 1 << 1 (2)
kWarning = 1 << 2 (4)
kLog = 1 << 3 (8)
kFatal = 1 << 4 (16) - Often treated as Exception/Error
Modifiers/Context:
kAssetImportError = 1 << 7 (128)
kAssetImportWarning = 1 << 8 (256)
kScriptingError = 1 << 9 (512)
kScriptingWarning = 1 << 10 (1024)
kScriptingLog = 1 << 11 (2048)
kScriptCompileError = 1 << 12 (4096)
kScriptCompileWarning = 1 << 13 (8192)
kStickyError = 1 << 14 (16384) - Stays visible even after Clear On Play
kMayIgnoreLineNumber = 1 << 15 (32768)
kReportBug = 1 << 16 (65536) - Shows the "Report Bug" button
kDisplayPreviousErrorInStatusBar = 1 << 17 (131072)
kScriptingException = 1 << 18 (262144)
kDontExtractStacktrace = 1 << 19 (524288) - Hint to the console UI
kShouldClearOnPlay = 1 << 20 (1048576) - Default behavior
kGraphCompileError = 1 << 21 (2097152)
kScriptingAssertion = 1 << 22 (4194304)
kVisualScriptingError = 1 << 23 (8388608)
Example observed values:
Log: 2048 (ScriptingLog) or 8 (Log)
Warning: 1028 (ScriptingWarning | Warning) or 4 (Warning)
Error: 513 (ScriptingError | Error) or 1 (Error)
Exception: 262161 (ScriptingException | Error | kFatal?) - Complex combination
Assertion: 4194306 (ScriptingAssertion | Assert) or 2 (Assert)
*/
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5cb98ba361543ca42a0f2e9c239657ca
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,124 @@
using System;
using Codely.Newtonsoft.Json.Linq;
using UnityEngine;
using UnityTcp.Editor.Helpers;
namespace UnityTcp.Editor.Tools
{
/// <summary>
/// INTERNAL TOOL - NOT EXPOSED TO LLM.
/// Receives notifications from external agentic tools when they modify files
/// that may affect Unity's state.
///
/// This tool should only be called by the agent execution layer,
/// not directly by LLM tool invocations.
/// </summary>
public static class _InternalStateDirtyNotifier
{
/// <summary>
/// Handle dirty state notification from external tools.
/// </summary>
public static object HandleCommand(JObject @params)
{
try
{
string action = @params["action"]?.ToString().ToLower();
switch (action)
{
case "notify_file_changed":
return NotifyFileChanged(@params);
case "notify_batch_changes":
return NotifyBatchChanges(@params);
case "get_statistics":
return GetStatistics();
default:
return Response.Error($"[INTERNAL] Unknown action: '{action}'");
}
}
catch (Exception e)
{
Debug.LogError($"[_InternalStateDirtyNotifier] Failed: {e}");
return Response.Error($"[INTERNAL] Dirty notification failed: {e.Message}");
}
}
/// <summary>
/// Notify Unity that a single file was changed by an external tool.
/// </summary>
private static object NotifyFileChanged(JObject @params)
{
try
{
string filePath = @params["file_path"]?.ToString();
string toolName = @params["tool_name"]?.ToString() ?? "unknown";
if (string.IsNullOrEmpty(filePath))
return Response.Error("[INTERNAL] 'file_path' parameter required");
UnityStateDirtyHook.NotifyFileChanged(filePath, toolName);
return Response.Success(
$"[INTERNAL] File change notification recorded: {filePath}",
new { filePath = filePath, toolName = toolName }
);
}
catch (Exception e)
{
return Response.Error($"[INTERNAL] Failed to notify file change: {e.Message}");
}
}
/// <summary>
/// Notify Unity of multiple file changes in a batch (more efficient).
/// </summary>
private static object NotifyBatchChanges(JObject @params)
{
try
{
var filesArray = @params["files"] as JArray;
string toolName = @params["tool_name"]?.ToString() ?? "unknown";
if (filesArray == null || filesArray.Count == 0)
return Response.Error("[INTERNAL] 'files' array parameter required");
int notified = 0;
foreach (var fileToken in filesArray)
{
string filePath = fileToken.ToString();
if (!string.IsNullOrEmpty(filePath))
{
UnityStateDirtyHook.NotifyFileChanged(filePath, toolName);
notified++;
}
}
return Response.Success(
$"[INTERNAL] Batch notification recorded: {notified} files",
new { count = notified, toolName = toolName }
);
}
catch (Exception e)
{
return Response.Error($"[INTERNAL] Failed to notify batch changes: {e.Message}");
}
}
/// <summary>
/// Get statistics about dirty notifications (for debugging).
/// </summary>
private static object GetStatistics()
{
try
{
var stats = UnityStateDirtyHook.GetStatistics();
return Response.Success("[INTERNAL] Dirty hook statistics", stats);
}
catch (Exception e)
{
return Response.Error($"[INTERNAL] Failed to get statistics: {e.Message}");
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3e7e2027a02d4d442ac58f11596fc99a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: