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,195 @@
using System;
using System.IO;
using System.Text;
using UnityEngine;
using Newtonsoft.Json;
namespace UnityEditorToolkit.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
File.WriteAllText(tempPath, json, Encoding.UTF8);
// 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)
{
Debug.LogError($"Unity Editor Toolkit: 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)
{
Debug.LogWarning($"Unity Editor Toolkit: 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)
{
Debug.LogWarning($"Unity Editor Toolkit: 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)
{
Debug.LogError($"Unity Editor Toolkit: 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: 86b5800f8a79d5e4e92cb3aefe90b39d

View File

@@ -0,0 +1,380 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEditorToolkit.Protocol;
using UnityEditorToolkit.Handlers;
using UnityEditorToolkit.Utils;
using Newtonsoft.Json;
// Note: websocket-sharp requires adding the DLL to ThirdParty folder
// Download from: https://github.com/sta/websocket-sharp
using WebSocketSharp;
using WebSocketSharp.Server;
namespace UnityEditorToolkit.Server
{
/// <summary>
/// Unity Editor WebSocket Server
/// Provides JSON-RPC 2.0 API for controlling Unity Editor via WebSocket
/// </summary>
[ExecuteAlways]
public class UnityEditorServer : MonoBehaviour
{
public enum LogLevel
{
None = 0,
Error = 1,
Warning = 2,
Info = 3,
Debug = 4
}
[Header("Server Settings")]
[Tooltip("WebSocket server port (default: 9500)")]
public int port = 9500;
[Tooltip("Auto-start server on scene load")]
public bool autoStart = true;
[Tooltip("Maximum number of concurrent connections")]
public int maxConnections = 5;
[Tooltip("Command execution timeout in seconds")]
public float commandTimeout = 30f;
[Header("Logging")]
[Tooltip("Logging level for debugging")]
public LogLevel logLevel = LogLevel.Info;
[Header("Status")]
[SerializeField] private bool isRunning = false;
[SerializeField] private int connectedClients = 0;
private WebSocketServer server;
private Dictionary<string, BaseHandler> handlers;
private HashSet<string> activeConnections = new HashSet<string>();
private float serverStartTime = 0f;
private float lastHeartbeatTime = 0f;
private const float HeartbeatInterval = 5f; // Update heartbeat every 5 seconds
private void Awake()
{
// Ensure Main Thread Dispatcher exists
UnityMainThreadDispatcher.Instance();
// Initialize handlers
handlers = new Dictionary<string, BaseHandler>
{
{ "GameObject", new GameObjectHandler() },
{ "Transform", new TransformHandler() },
{ "Scene", new SceneHandler() },
{ "Console", new ConsoleHandler() },
{ "Hierarchy", new HierarchyHandler() }
};
// Start console logging
ConsoleHandler.StartListening();
}
private void Start()
{
if (autoStart)
{
StartServer();
}
}
private void OnDestroy()
{
StopServer();
ConsoleHandler.StopListening();
}
private void Update()
{
// Periodic heartbeat update
if (isRunning && Time.realtimeSinceStartup - lastHeartbeatTime > HeartbeatInterval)
{
lastHeartbeatTime = Time.realtimeSinceStartup;
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>
/// 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 = Time.realtimeSinceStartup;
lastHeartbeatTime = Time.realtimeSinceStartup;
// 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);
}
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
{
// Mark server as stopped
string projectRoot = Path.GetDirectoryName(Application.dataPath);
ServerStatus.MarkStopped(projectRoot);
server.Stop();
server = null;
isRunning = false;
connectedClients = 0;
activeConnections.Clear();
Log("Unity Editor Server stopped", LogLevel.Info);
}
catch (Exception ex)
{
Log($"Error stopping server: {ex.Message}", LogLevel.Error);
}
}
/// <summary>
/// Handle JSON-RPC request (메인 스레드에서 실행됨)
/// </summary>
internal string HandleRequest(string message)
{
JsonRpcRequest request = null;
float startTime = Time.realtimeSinceStartup;
try
{
// Parse JSON-RPC request
request = JsonConvert.DeserializeObject<JsonRpcRequest>(message);
if (request == null || !request.IsValid())
{
return new JsonRpcErrorResponse(null, JsonRpcError.InvalidRequest()).ToJson();
}
Log($"Request: {request.Method}", LogLevel.Debug);
// Health check (ping)
if (request.Method == "ping")
{
return new JsonRpcResponse(request.Id, new
{
status = "ok",
version = "0.1.0",
uptime = Time.realtimeSinceStartup - 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);
// Check timeout
float elapsed = Time.realtimeSinceStartup - startTime;
if (elapsed > commandTimeout)
{
Log($"Command timeout: {request.Method} took {elapsed:F2}s", LogLevel.Warning);
}
// 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);
// ✅ request?.Id 사용 (High 이슈 해결)
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);
connectedClients++;
Log($"Client connected: {connectionId} (total: {connectedClients})", LogLevel.Info);
return true;
}
internal void OnClientDisconnected(string connectionId)
{
if (activeConnections.Remove(connectionId))
{
connectedClients--;
Log($"Client disconnected: {connectionId} (remaining: {connectedClients})", LogLevel.Info);
}
}
/// <summary>
/// 로깅 메서드
/// </summary>
private void Log(string message, LogLevel level)
{
if (level <= logLevel)
{
string prefix = "[UnityEditorToolkit]";
switch (level)
{
case LogLevel.Error:
Debug.LogError($"{prefix} {message}");
break;
case LogLevel.Warning:
Debug.LogWarning($"{prefix} {message}");
break;
case LogLevel.Info:
case LogLevel.Debug:
Debug.Log($"{prefix} {message}");
break;
}
}
}
/// <summary>
/// WebSocket service behavior
/// </summary>
private class EditorService : WebSocketBehavior
{
private UnityEditorServer server;
public EditorService(UnityEditorServer 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)
{
// ✅ Critical 이슈 해결: WebSocket 스레드에서 메인 스레드로 작업 전달
string message = e.Data;
// Unity API는 반드시 메인 스레드에서만 호출
UnityMainThreadDispatcher.Instance().Enqueue(() =>
{
try
{
// 메인 스레드에서 요청 처리
string response = server.HandleRequest(message);
// 응답 전송 (Send는 스레드 안전)
if (!string.IsNullOrEmpty(response))
{
Send(response);
}
}
catch (Exception ex)
{
server.Log($"Error processing message: {ex.Message}", LogLevel.Error);
// 에러 응답 전송
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: 28fbab6e6d8088744b0598d5505cc79d