修改提交
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8301b99c61e7b7e44befcf784c72a226
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 333f972789d338c4aafe2236cb5ac3cf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a919bae4d47922248a206faa7ba67ed7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5ef1416a06c8bce4caaff3a2b88aaafe
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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 ... }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 896e8045986eb0d449ee68395479f1d6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
2537
Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageAsset.cs
Normal file
2537
Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageAsset.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8d58d03981d97594b80e043a0ba78f55
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
740
Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageBake.cs
Normal file
740
Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageBake.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 16bc91f90f3df674486759e40dffb088
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
1330
Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageEditor.cs
Normal file
1330
Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageEditor.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: da88809c82aa8214eaa168ec9ce090af
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
4592
Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageGameObject.cs
Normal file
4592
Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageGameObject.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9c6230a18f5554a41aaac2188776332e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
271
Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManagePackage.cs
Normal file
271
Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManagePackage.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1d06adc1d5b654a428a23760d94d5079
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
722
Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScene.cs
Normal file
722
Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScene.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2446b8cefbf129d40abfa286e9e3d137
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5fd45617bc3cd52489b0ad49fe49e55b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
2636
Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScript.cs
Normal file
2636
Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScript.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 146e77f78721f9a4494cc01662cc082c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
545
Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageShader.cs
Normal file
545
Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageShader.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c4894079ca02cc34ab668aa21146f161
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 42651d1308102f848b379ba3ba6e6f27
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
699
Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageWorkflow.cs
Normal file
699
Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageWorkflow.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c3f5f5eaf4972c94ca0d613330e6768a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
879
Packages/cn.tuanjie.codely.bridge/Editor/Tools/ReadConsole.cs
Normal file
879
Packages/cn.tuanjie.codely.bridge/Editor/Tools/ReadConsole.cs
Normal 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)
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5cb98ba361543ca42a0f2e9c239657ca
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3e7e2027a02d4d442ac58f11596fc99a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user