Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:19:28 +08:00
commit 1da7b24c8e
254 changed files with 43797 additions and 0 deletions

View File

@@ -0,0 +1,474 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEditor;
using UnityEditorToolkit.Protocol;
using UnityEditorToolkit.Handlers;
using UnityEditorToolkit.Editor.Utils;
using Newtonsoft.Json;
using WebSocketSharp;
using WebSocketSharp.Server;
using LogLevel = UnityEditorToolkit.Editor.Utils.ToolkitLogger.LogLevel;
namespace UnityEditorToolkit.Editor.Server
{
/// <summary>
/// Editor WebSocket Server (Scene-independent)
/// Runs automatically in the background using EditorApplication.update
/// No GameObject required
/// </summary>
[InitializeOnLoad]
public class EditorWebSocketServer
{
// LogLevel is now imported from Logger class
// Singleton instance
private static EditorWebSocketServer instance;
public static EditorWebSocketServer Instance
{
get
{
if (instance == null)
{
instance = new EditorWebSocketServer();
}
return instance;
}
}
// Server settings (stored in EditorPrefs)
private const string PREF_PORT = "UnityEditorToolkit.Server.Port";
private const string PREF_AUTO_START = "UnityEditorToolkit.Server.AutoStart";
private const string PREF_MAX_CONNECTIONS = "UnityEditorToolkit.Server.MaxConnections";
private const string PREF_COMMAND_TIMEOUT = "UnityEditorToolkit.Server.CommandTimeout";
private const string PREF_LOG_LEVEL = "UnityEditorToolkit.Server.LogLevel";
public int Port
{
get => EditorPrefs.GetInt(PREF_PORT, 9500);
set => EditorPrefs.SetInt(PREF_PORT, value);
}
public bool AutoStart
{
get => EditorPrefs.GetBool(PREF_AUTO_START, true);
set => EditorPrefs.SetBool(PREF_AUTO_START, value);
}
public int MaxConnections
{
get => EditorPrefs.GetInt(PREF_MAX_CONNECTIONS, 5);
set => EditorPrefs.SetInt(PREF_MAX_CONNECTIONS, value);
}
public float CommandTimeout
{
get => EditorPrefs.GetFloat(PREF_COMMAND_TIMEOUT, 30f);
set => EditorPrefs.SetFloat(PREF_COMMAND_TIMEOUT, value);
}
public LogLevel CurrentLogLevel
{
get => ToolkitLogger.CurrentLogLevel;
set => ToolkitLogger.CurrentLogLevel = value;
}
// Server state
public bool IsRunning { get; private set; }
public int ConnectedClients => activeConnections.Count;
// Server state change events
public event Action OnServerStarted;
public event Action OnServerStopped;
private WebSocketServer server;
private Dictionary<string, BaseHandler> handlers;
private HashSet<string> activeConnections = new HashSet<string>();
private double serverStartTime = 0;
private double lastHeartbeatTime = 0;
private const double HeartbeatInterval = 5.0; // seconds
/// <summary>
/// Static constructor - automatically called when Unity Editor starts
/// </summary>
static EditorWebSocketServer()
{
// Initialize instance
var instance = Instance;
// Auto-start if enabled
if (instance.AutoStart)
{
instance.StartServer();
}
// Register update callback
EditorApplication.update += instance.OnUpdate;
// Handle domain reload
AssemblyReloadEvents.beforeAssemblyReload += instance.OnBeforeAssemblyReload;
}
/// <summary>
/// Private constructor
/// </summary>
private EditorWebSocketServer()
{
// Initialize handlers
handlers = new Dictionary<string, BaseHandler>
{
{ "GameObject", new GameObjectHandler() },
{ "Transform", new TransformHandler() },
{ "Component", new ComponentHandler() },
{ "Scene", new SceneHandler() },
{ "Console", new ConsoleHandler() },
{ "Hierarchy", new HierarchyHandler() },
{ "Editor", new EditorHandler() },
{ "Prefs", new PrefsHandler() },
{ "Wait", new WaitHandler() },
{ "Database", new DatabaseHandler() },
{ "Snapshot", new SnapshotHandler() },
{ "TransformHistory", new TransformHistoryHandler() },
{ "Sync", new SyncHandler() },
{ "Analytics", new AnalyticsHandler() },
{ "Menu", new MenuHandler() },
{ "Asset", new AssetHandler() },
{ "Prefab", new PrefabHandler() },
{ "Material", new MaterialHandler() },
{ "Animation", new AnimationHandler() }
};
// Initialize ChainHandler with access to all handlers
var chainHandler = new ChainHandler();
chainHandler.SetHandlers(handlers);
handlers.Add("Chain", chainHandler);
// Start console logging
ConsoleHandler.StartListening();
}
/// <summary>
/// Called every editor frame
/// </summary>
private void OnUpdate()
{
// Process pending delayed responses
ResponseQueue.Instance.Update();
// Periodic heartbeat update
if (IsRunning && EditorApplication.timeSinceStartup - lastHeartbeatTime > HeartbeatInterval)
{
lastHeartbeatTime = EditorApplication.timeSinceStartup;
try
{
string projectRoot = Path.GetDirectoryName(Application.dataPath);
ServerStatus status = ServerStatus.Load(projectRoot);
if (status != null)
{
status.UpdateHeartbeat();
bool saved = ServerStatus.Save(status, projectRoot);
if (!saved)
{
Log("Failed to save heartbeat update", LogLevel.Warning);
}
}
else
{
Log("Failed to load server status for heartbeat update", LogLevel.Warning);
}
}
catch (Exception e)
{
Log($"Error updating heartbeat: {e.Message}", LogLevel.Error);
}
}
}
/// <summary>
/// Handle assembly reload (script compilation)
/// </summary>
private void OnBeforeAssemblyReload()
{
// Cancel all pending responses before domain reload
// This notifies clients that their requests are cancelled due to compilation
ResponseQueue.Instance.CancelAllPending("Script compilation started, request cancelled");
StopServer();
}
/// <summary>
/// Start WebSocket server
/// </summary>
public void StartServer()
{
if (IsRunning)
{
Log($"Server already running on port {Port}", LogLevel.Warning);
return;
}
try
{
server = new WebSocketServer(Port);
server.AddWebSocketService<EditorService>("/", () => new EditorService(this));
server.Start();
IsRunning = true;
serverStartTime = EditorApplication.timeSinceStartup;
lastHeartbeatTime = EditorApplication.timeSinceStartup;
// Save server status
string projectRoot = Path.GetDirectoryName(Application.dataPath);
ServerStatus status = ServerStatus.Create(Port);
ServerStatus.Save(status, projectRoot);
Log($"✓ Unity Editor Server started on ws://127.0.0.1:{Port}", LogLevel.Info);
// Notify subscribers
OnServerStarted?.Invoke();
}
catch (Exception ex)
{
Log($"Failed to start server: {ex.Message}", LogLevel.Error);
IsRunning = false;
}
}
/// <summary>
/// Stop WebSocket server
/// </summary>
public void StopServer()
{
if (!IsRunning || server == null)
{
return;
}
try
{
// Cancel all pending responses before stopping server
ResponseQueue.Instance.CancelAllPending("Server stopping");
// Mark server as stopped
string projectRoot = Path.GetDirectoryName(Application.dataPath);
ServerStatus.MarkStopped(projectRoot);
server.Stop();
server = null;
IsRunning = false;
activeConnections.Clear();
ConsoleHandler.StopListening();
Log("Unity Editor Server stopped", LogLevel.Info);
// Notify subscribers
OnServerStopped?.Invoke();
}
catch (Exception ex)
{
Log($"Error stopping server: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Handle JSON-RPC request (called from main thread)
/// </summary>
internal string HandleRequest(string message, Action<string> sendCallback)
{
JsonRpcRequest request = null;
double startTime = EditorApplication.timeSinceStartup;
try
{
// Parse JSON-RPC request
request = JsonConvert.DeserializeObject<JsonRpcRequest>(message);
if (request == null || !request.IsValid())
{
return new JsonRpcErrorResponse(null, JsonRpcError.InvalidRequest()).ToJson();
}
// Set send callback in request context for delayed responses
request.SetContext("sendCallback", sendCallback);
Log($"Request: {request.Method}", LogLevel.Debug);
// Health check (ping)
if (request.Method == "ping")
{
return new JsonRpcResponse(request.Id, new
{
status = "ok",
version = "1.0.0",
uptime = EditorApplication.timeSinceStartup - serverStartTime,
handlers = new List<string>(handlers.Keys),
connectedClients = ConnectedClients
}).ToJson();
}
// Extract category from method (e.g., "GameObject.Find" -> "GameObject")
string category = GetCategory(request.Method);
if (!handlers.ContainsKey(category))
{
return new JsonRpcErrorResponse(request.Id, JsonRpcError.MethodNotFound(request.Method)).ToJson();
}
// Handle request with appropriate handler
var handler = handlers[category];
var result = handler.Handle(request);
// If result is null, it's a delayed response (handled by ResponseQueue)
if (result == null)
{
Log($"Delayed response registered for: {request.Method}", LogLevel.Debug);
return null;
}
// Check timeout
double elapsed = EditorApplication.timeSinceStartup - startTime;
if (elapsed > CommandTimeout)
{
Log($"Command timeout: {request.Method} took {elapsed:F2}s", LogLevel.Warning);
}
else
{
Log($"Success: {request.Method} ({elapsed:F3}s)", LogLevel.Info);
}
// Return success response
return new JsonRpcResponse(request.Id, result).ToJson();
}
catch (JsonException ex)
{
Log($"JSON Parse Error: {ex.Message}", LogLevel.Error);
return new JsonRpcErrorResponse(null, JsonRpcError.ParseError(ex.Message)).ToJson();
}
catch (Exception ex)
{
Log($"Request Handler Error: {ex.Message}\n{ex.StackTrace}", LogLevel.Error);
return new JsonRpcErrorResponse(request?.Id, JsonRpcError.InternalError(ex.Message)).ToJson();
}
}
private string GetCategory(string method)
{
int dotIndex = method.IndexOf('.');
if (dotIndex < 0)
{
throw new Exception($"Invalid method format: {method}");
}
return method.Substring(0, dotIndex);
}
internal bool OnClientConnected(string connectionId)
{
if (activeConnections.Count >= MaxConnections)
{
Log($"Max connections reached ({MaxConnections}), rejecting connection", LogLevel.Warning);
return false;
}
activeConnections.Add(connectionId);
Log($"Client connected: {connectionId} (total: {ConnectedClients})", LogLevel.Info);
return true;
}
internal void OnClientDisconnected(string connectionId)
{
if (activeConnections.Remove(connectionId))
{
Log($"Client disconnected: {connectionId} (remaining: {ConnectedClients})", LogLevel.Info);
}
}
/// <summary>
/// Logging method (delegates to centralized Logger)
/// </summary>
private void Log(string message, LogLevel level)
{
switch (level)
{
case LogLevel.Error:
ToolkitLogger.LogError("Server", message);
break;
case LogLevel.Warning:
ToolkitLogger.LogWarning("Server", message);
break;
case LogLevel.Info:
ToolkitLogger.Log("Server", message);
break;
case LogLevel.Debug:
ToolkitLogger.LogDebug("Server", message);
break;
}
}
/// <summary>
/// WebSocket service behavior
/// </summary>
private class EditorService : WebSocketBehavior
{
private EditorWebSocketServer server;
public EditorService(EditorWebSocketServer server)
{
this.server = server;
}
protected override void OnOpen()
{
// Check max connections
if (!server.OnClientConnected(ID))
{
Context.WebSocket.Close(CloseStatusCode.PolicyViolation, "Max connections reached");
}
}
protected override void OnClose(CloseEventArgs e)
{
server.OnClientDisconnected(ID);
}
protected override void OnMessage(MessageEventArgs e)
{
// WebSocket thread → Main thread
string message = e.Data;
// Unity API must be called from main thread
EditorMainThreadDispatcher.Enqueue(() =>
{
try
{
// Handle request on main thread with send callback for delayed responses
string response = server.HandleRequest(message, Send);
// Send response (Send is thread-safe)
// Note: response can be null for delayed responses handled by ResponseQueue
if (!string.IsNullOrEmpty(response))
{
Send(response);
}
}
catch (Exception ex)
{
server.Log($"Error processing message: {ex.Message}", LogLevel.Error);
// Send error response
var errorResponse = new JsonRpcErrorResponse(null,
JsonRpcError.InternalError(ex.Message)).ToJson();
Send(errorResponse);
}
});
}
protected override void OnError(WebSocketSharp.ErrorEventArgs e)
{
server.Log($"WebSocket Error: {e.Message}", LogLevel.Error);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5e047287374f78b4387d060f934e0eab

View File

@@ -0,0 +1,199 @@
using System;
using System.IO;
using System.Text;
using UnityEngine;
using Newtonsoft.Json;
using UnityEditorToolkit.Editor.Utils;
using static UnityEditorToolkit.Editor.Utils.ToolkitLogger;
namespace UnityEditorToolkit.Editor.Server
{
/// <summary>
/// Manages server status file for Unity WebSocket Server
///
/// Stores current server state in .unity-websocket/server-status.json
/// allowing CLI tools to discover the correct port and server state.
/// </summary>
[Serializable]
public class ServerStatus
{
// Constants
private const int HeartbeatStaleSeconds = 30;
public string version = "1.0";
public int port;
public bool isRunning;
public int pid;
public string editorVersion;
public string startedAt;
public string lastHeartbeat;
/// <summary>
/// Get server status file path
/// </summary>
public static string GetStatusFilePath(string projectRoot)
{
string statusDir = Path.Combine(projectRoot, ".unity-websocket");
return Path.Combine(statusDir, "server-status.json");
}
/// <summary>
/// Create new server status
/// </summary>
public static ServerStatus Create(int port)
{
return new ServerStatus
{
version = "1.0",
port = port,
isRunning = true,
pid = System.Diagnostics.Process.GetCurrentProcess().Id,
editorVersion = Application.unityVersion,
startedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
lastHeartbeat = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")
};
}
/// <summary>
/// Save server status to file (atomic write)
/// </summary>
public static bool Save(ServerStatus status, string projectRoot)
{
string tempPath = null;
try
{
string statusDir = Path.Combine(projectRoot, ".unity-websocket");
if (!Directory.Exists(statusDir))
{
Directory.CreateDirectory(statusDir);
}
string statusPath = GetStatusFilePath(projectRoot);
tempPath = statusPath + ".tmp";
// Serialize to JSON
string json = JsonConvert.SerializeObject(status, Formatting.Indented);
// Write to temp file first
// Use UTF8 without BOM for Node.js compatibility
var utf8NoBom = new System.Text.UTF8Encoding(false);
File.WriteAllText(tempPath, json, utf8NoBom);
// Atomic replace using File.Replace (crash-safe)
if (File.Exists(statusPath))
{
// File.Replace is atomic - no data loss even if crash occurs
File.Replace(tempPath, statusPath, null);
}
else
{
// First time, just move
File.Move(tempPath, statusPath);
}
tempPath = null; // Successfully handled, no cleanup needed
return true;
}
catch (Exception e)
{
LogError("ServerStatus", $"Failed to save server status: {e.Message}");
return false;
}
finally
{
// Cleanup temp file if it still exists (error case)
if (tempPath != null && File.Exists(tempPath))
{
try
{
File.Delete(tempPath);
}
catch (Exception cleanupEx)
{
LogWarning("ServerStatus", $"Failed to cleanup temp file: {cleanupEx.Message}");
}
}
}
}
/// <summary>
/// Load server status from file
/// </summary>
public static ServerStatus Load(string projectRoot)
{
try
{
string statusPath = GetStatusFilePath(projectRoot);
if (!File.Exists(statusPath))
{
return null;
}
string json = File.ReadAllText(statusPath, Encoding.UTF8);
return JsonConvert.DeserializeObject<ServerStatus>(json);
}
catch (Exception e)
{
LogWarning("ServerStatus", $"Failed to load server status: {e.Message}");
return null;
}
}
/// <summary>
/// Update heartbeat timestamp
/// </summary>
public void UpdateHeartbeat()
{
lastHeartbeat = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ");
}
/// <summary>
/// Mark server as stopped
/// </summary>
public static bool MarkStopped(string projectRoot)
{
try
{
ServerStatus status = Load(projectRoot);
if (status == null)
{
return true; // Already no status file
}
status.isRunning = false;
status.UpdateHeartbeat();
return Save(status, projectRoot);
}
catch (Exception e)
{
LogError("ServerStatus", $"Failed to mark server as stopped: {e.Message}");
return false;
}
}
/// <summary>
/// Check if status is stale (heartbeat > configured seconds old)
/// </summary>
public bool IsStale()
{
try
{
if (string.IsNullOrEmpty(lastHeartbeat))
{
return true;
}
DateTime lastBeat = DateTime.Parse(lastHeartbeat);
double secondsSinceLastBeat = (DateTime.UtcNow - lastBeat).TotalSeconds;
return secondsSinceLastBeat > HeartbeatStaleSeconds;
}
catch
{
return true; // If we can't parse, assume stale
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6465f1a7f87d9854ab0406e3ef9fd414