using System; using System.Net.WebSockets; using System.Text; using System.Threading; using System.Threading.Tasks; using UnityEngine; using UnityEditor; using Newtonsoft.Json; using System.Collections.Generic; using SynapticAIPro; namespace SynapticPro { /// /// WebSocket client for editor mode /// Manages communication with MCP server /// public class NexusWebSocketClient { private ClientWebSocket webSocket; private CancellationTokenSource cancellationTokenSource; private bool isConnected = false; private Queue messageQueue = new Queue(); private bool shouldReconnect = true; private int reconnectAttempts = 0; private const int maxReconnectAttempts = 30; private const int reconnectDelay = 2000; // 2 seconds between attempts private string serverUrl = "ws://127.0.0.1:8090"; private const int CONNECT_TIMEOUT_SECONDS = 5; private readonly List backgroundTasks = new List(); public bool IsConnected => isConnected; public event Action OnMessageReceived; public event Action OnConnected; public event Action OnDisconnected; private static NexusWebSocketClient instance; public static NexusWebSocketClient Instance { get { if (instance == null) { instance = new NexusWebSocketClient(); } return instance; } } private static void LogTaskFault(Task t, string label) { t.ContinueWith( x => SynLog.Warn($"[Nexus WebSocket] {label} faulted: {x.Exception?.GetBaseException().Message}"), TaskContinuationOptions.OnlyOnFaulted); } public async Task Connect(string url = "ws://127.0.0.1:8090") { shouldReconnect = true; reconnectAttempts = 0; while (shouldReconnect && reconnectAttempts < maxReconnectAttempts) { try { SynLog.Info($"[Nexus WebSocket] Connecting to {url}... (Attempt {reconnectAttempts + 1})"); webSocket = new ClientWebSocket(); cancellationTokenSource = new CancellationTokenSource(); using (var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationTokenSource.Token)) { connectCts.CancelAfter(TimeSpan.FromSeconds(CONNECT_TIMEOUT_SECONDS)); await webSocket.ConnectAsync(new Uri(url), connectCts.Token); } isConnected = true; reconnectAttempts = 0; // Reset on success SynLog.Info("[Nexus WebSocket] Connected successfully!"); OnConnected?.Invoke(); // Start message receive loop (tracked so Disconnect can await) var receiveTask = Task.Run(async () => await ReceiveLoop()); LogTaskFault(receiveTask, "ReceiveLoop"); backgroundTasks.Add(receiveTask); // Start heartbeat var heartbeatTask = Task.Run(async () => await HeartbeatLoop()); LogTaskFault(heartbeatTask, "HeartbeatLoop"); backgroundTasks.Add(heartbeatTask); // Notify Unity is ready await SendMessage(new { type = "unity_ready", version = NexusVersion.Current }); return true; } catch (Exception e) { Debug.LogError($"[Nexus WebSocket] Connection failed: {e.Message}"); isConnected = false; reconnectAttempts++; if (reconnectAttempts < maxReconnectAttempts && shouldReconnect) { SynLog.Info($"[Nexus WebSocket] Retrying in {reconnectDelay / 1000} seconds..."); await Task.Delay(reconnectDelay); } } } return false; } private async Task ReceiveLoop() { var buffer = new byte[4096]; var messageBuilder = new StringBuilder(); try { while (webSocket.State == WebSocketState.Open) { var result = await webSocket.ReceiveAsync( new ArraySegment(buffer), cancellationTokenSource.Token ); if (result.MessageType == WebSocketMessageType.Text) { // Accumulate fragments until EndOfMessage. Without this, // any message larger than 4096B (e.g. tool args / scene // dumps) gets truncated mid-chunk and fails JSON parse — // root cause of ESC-0102 (Win + Unity 6.3 MCP receive // appears to "drop" large messages). messageBuilder.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); if (result.EndOfMessage) { var message = messageBuilder.ToString(); messageBuilder.Clear(); SynLog.Info($"[Nexus WebSocket] Received ({message.Length} chars): {message.Substring(0, System.Math.Min(message.Length, 200))}"); lock (messageQueue) { messageQueue.Enqueue(message); } } } else if (result.MessageType == WebSocketMessageType.Close) { break; } } } catch (Exception e) { Debug.LogError($"[Nexus WebSocket] Receive error: {e.Message}"); } finally { isConnected = false; OnDisconnected?.Invoke(); } } public void ProcessMessages() { lock (messageQueue) { while (messageQueue.Count > 0) { var message = messageQueue.Dequeue(); OnMessageReceived?.Invoke(message); try { var data = JsonConvert.DeserializeObject>(message); if (data != null && data.ContainsKey("type")) { ProcessUnityCommand(data); } } catch (Exception e) { Debug.LogError($"[Nexus WebSocket] Message processing error: {e.Message}"); } } } } public event Action OnClaudeResponse; // sessionId, message public event Action OnChatStatusUpdate; // status message private void ProcessUnityCommand(Dictionary data) { var type = data["type"].ToString(); if (type == "unity_operation") { var command = data.ContainsKey("command") ? data["command"].ToString() : ""; var parameters = data.ContainsKey("parameters") ? data["parameters"] as Newtonsoft.Json.Linq.JObject : null; SynLog.Info($"[Nexus WebSocket] Executing Unity command: {command}"); // Execute Unity operation in editor mode EditorApplication.delayCall += () => { ExecuteUnityOperation(command, parameters); }; } else if (type == "claude_response") { // Real-time response from Claude Desktop var responseData = data.ContainsKey("data") ? data["data"] as Newtonsoft.Json.Linq.JObject : null; if (responseData != null) { var message = responseData.Value("message") ?? ""; var sessionId = responseData.Value("sessionId") ?? ""; var responseType = responseData.Value("responseType") ?? "response"; SynLog.Info($"[Nexus WebSocket] Claude response received: {message}"); // Fire event on main thread EditorApplication.delayCall += () => { OnClaudeResponse?.Invoke(sessionId, message); }; } } else if (type == "chat_initiated") { // Chat initiated notification var chatData = data.ContainsKey("data") ? data["data"] as Newtonsoft.Json.Linq.JObject : null; if (chatData != null) { var status = chatData.Value("status") ?? "Processing..."; SynLog.Info($"[Nexus WebSocket] Chat initiated: {status}"); EditorApplication.delayCall += () => { OnChatStatusUpdate?.Invoke(status); }; } } } private async void ExecuteUnityOperation(string command, Newtonsoft.Json.Linq.JObject parameters) { SynLog.Info($"[Nexus WebSocket] Executing Unity operation: {command}"); SynLog.Info($"[Nexus WebSocket] Parameters: {parameters?.ToString()}"); var operationId = parameters?.Value("operationId") ?? Guid.NewGuid().ToString(); try { var operation = new NexusUnityOperation { type = ConvertCommandToOperationType(command), parameters = new Dictionary() }; // Convert parameters if (parameters != null) { foreach (var prop in parameters.Properties()) { if (prop.Name == "operationId") continue; var value = prop.Value; if (value is Newtonsoft.Json.Linq.JObject jObj) { // Process nested objects (Vector3, etc.) if (jObj.ContainsKey("x") && jObj.ContainsKey("y") && jObj.ContainsKey("z")) { operation.parameters[prop.Name] = $"{jObj["x"]},{jObj["y"]},{jObj["z"]}"; } else if (jObj.ContainsKey("x") && jObj.ContainsKey("y")) { operation.parameters[prop.Name] = $"{jObj["x"]},{jObj["y"]}"; } else { operation.parameters[prop.Name] = value.ToString(); } } else { operation.parameters[prop.Name] = value.ToString(); } } } // Execute string result = ""; bool success = true; // Process information retrieval commands switch (operation.type) { case "GET_SCENE_INFO": result = NexusStateInspector.GetSceneInformation(); break; case "GET_CAMERA_INFO": result = NexusStateInspector.GetCameraInformation(); break; case "GET_TERRAIN_INFO": result = NexusStateInspector.GetTerrainInformation(); break; case "GET_LIGHTING_INFO": result = NexusStateInspector.GetLightingInformation(); break; case "GET_MATERIAL_INFO": result = NexusStateInspector.GetMaterialInformation(); break; case "GET_UI_INFO": result = NexusStateInspector.GetUIInformation(); break; case "GET_PHYSICS_INFO": result = NexusStateInspector.GetPhysicsInformation(); break; case "GET_GAMEOBJECT_DETAILS": var name = operation.parameters.GetValueOrDefault("name", ""); result = NexusStateInspector.GetGameObjectDetails(name); break; case "GET_PROJECT_STATS": result = NexusStateInspector.GetProjectStatistics(); break; default: // Normal operation. // ESC-0107 fix: previously used `.Result` which blocks the // main thread (delayCall) on a Task whose continuation may // need to repost via UnitySynchronizationContext → classic // SyncContext deadlock. ConfigureAwait(false) drops the // captured context so the continuation can run on any // thread; the body of ExecuteOperation is itself sync // for most cases (e.g. RUN_CSHARP returns immediately). var executor = new NexusUnityExecutor(); result = await executor.ExecuteOperation(operation).ConfigureAwait(false); // Error check if (result.StartsWith("Error") || result.Contains("not found") || result.Contains("failed")) { success = false; } break; } // 既存のMCP通信と同じフォーマットを使用 var response = new Dictionary { ["type"] = "operation_result", ["id"] = operationId, ["content"] = result, ["data"] = new Dictionary { ["success"] = success } }; SynLog.Info($"[Nexus WebSocket] Operation result: {result}"); // Send result to MCP server await SendMessage(response); } catch (Exception e) { var errorResponse = new Dictionary { ["type"] = "operation_result", ["id"] = operationId, ["content"] = e.Message, ["data"] = new Dictionary { ["success"] = false } }; Debug.LogError($"[Nexus WebSocket] Operation execution error: {e.Message}\n{e.StackTrace}"); // Send error response to MCP server await SendMessage(errorResponse); } } private string ConvertCommandToOperationType(string command) { switch (command) { case "create_ui": return "CREATE_UI"; case "create_gameobject": return "CREATE_GAMEOBJECT"; case "instantiate_prefab": return "INSTANTIATE_PREFAB"; case "set_transform": return "SET_PROPERTY"; case "setup_camera": return "SETUP_CAMERA"; case "create_particle_system": return "CREATE_PARTICLE_SYSTEM"; case "setup_navmesh": return "SETUP_NAVMESH"; case "create_audio_mixer": return "CREATE_AUDIO_MIXER"; case "undo": return "UNDO"; case "redo": return "REDO"; case "get_history": return "GET_HISTORY"; // Information retrieval case "get_scene_info": return "GET_SCENE_INFO"; case "get_camera_info": return "GET_CAMERA_INFO"; case "get_terrain_info": return "GET_TERRAIN_INFO"; case "get_lighting_info": return "GET_LIGHTING_INFO"; case "get_material_info": return "GET_MATERIAL_INFO"; case "get_ui_info": return "GET_UI_INFO"; case "get_physics_info": return "GET_PHYSICS_INFO"; case "get_gameobject_details": return "GET_GAMEOBJECT_DETAILS"; case "list_assets": return "LIST_ASSETS"; case "get_project_stats": return "GET_PROJECT_STATS"; default: return command.ToUpper(); } } public async Task SendMessage(object data) { if (!isConnected || webSocket.State != WebSocketState.Open) { SynLog.Warn("[Nexus WebSocket] Cannot send message: not connected"); return; } try { var json = JsonConvert.SerializeObject(data); var bytes = Encoding.UTF8.GetBytes(json); await webSocket.SendAsync( new ArraySegment(bytes), WebSocketMessageType.Text, true, cancellationTokenSource.Token ); } catch (Exception e) { Debug.LogError($"[Nexus WebSocket] Send error: {e.Message}"); } } private async Task HeartbeatLoop() { while (isConnected && !cancellationTokenSource.Token.IsCancellationRequested) { try { // Send heartbeat every 30 seconds await Task.Delay(30000, cancellationTokenSource.Token); if (webSocket.State == WebSocketState.Open) { await SendMessage(new { type = "heartbeat", timestamp = DateTime.Now.Ticks }); } else { SynLog.Warn("[Nexus WebSocket] Connection lost during heartbeat"); isConnected = false; OnDisconnected?.Invoke(); // Attempt reconnection if (shouldReconnect) { var reconnectTask = Task.Run(async () => await Connect()); LogTaskFault(reconnectTask, "HeartbeatReconnect"); } break; } } catch (Exception e) { if (!cancellationTokenSource.Token.IsCancellationRequested) { Debug.LogError($"[Nexus WebSocket] Heartbeat error: {e.Message}"); } break; } } } public async Task Disconnect() { shouldReconnect = false; // Disable auto-reconnect if (webSocket != null && webSocket.State == WebSocketState.Open) { try { await webSocket.CloseAsync( WebSocketCloseStatus.NormalClosure, "Closing", cancellationTokenSource.Token ); } catch (Exception e) { Debug.LogError($"[Nexus WebSocket] Disconnect error: {e.Message}"); } } cancellationTokenSource?.Cancel(); cancellationTokenSource?.Dispose(); isConnected = false; // Give background tasks a bounded window to unwind before disposing resources. if (backgroundTasks.Count > 0) { try { Task.WhenAll(backgroundTasks).Wait(TimeSpan.FromSeconds(2)); } catch { } backgroundTasks.Clear(); } } /// /// Set server URL /// public void SetServerUrl(string url) { serverUrl = url; SynLog.Info($"[Nexus WebSocket] Server URL changed to: {url}"); } } /// /// Manages WebSocket client updates /// [InitializeOnLoad] public static class NexusWebSocketUpdater { static NexusWebSocketUpdater() { EditorApplication.update += Update; } private static void Update() { NexusWebSocketClient.Instance?.ProcessMessages(); NexusHTTPWebSocketClient.Instance?.ProcessMessages(); } } /// /// WebSocket client for HTTP Server connection /// Separate from MCP WebSocket to allow both to run simultaneously /// public class NexusHTTPWebSocketClient { private ClientWebSocket webSocket; private CancellationTokenSource cancellationTokenSource; private bool isConnected = false; private Queue messageQueue = new Queue(); private bool shouldReconnect = true; private int reconnectAttempts = 0; private const int maxReconnectAttempts = 10; private const int reconnectDelay = 1000; private const int CONNECT_TIMEOUT_SECONDS = 5; private readonly List backgroundTasks = new List(); // Reentrancy guard. Connect() can be invoked from three paths (UI button, // NexusEditorMCPService HTTP auto-connect, and ReceiveLoop's finally auto-reconnect); // without this gate, concurrent calls clobber `webSocket`/`cancellationTokenSource` // and produce duplicate "Connecting/Connected/Disconnected" log spam. private volatile bool connectInFlight = false; private string serverUrl = "ws://127.0.0.1:8086"; private int port = 8086; public bool IsConnected => isConnected; public int Port => port; private static NexusHTTPWebSocketClient instance; public static NexusHTTPWebSocketClient Instance { get { if (instance == null) { instance = new NexusHTTPWebSocketClient(); } return instance; } } private static void LogTaskFault(Task t, string label) { t.ContinueWith( x => SynLog.Warn($"[HTTP WebSocket] {label} faulted: {x.Exception?.GetBaseException().Message}"), TaskContinuationOptions.OnlyOnFaulted); } public async Task Connect(int httpPort) { // Reentrancy guard — silently drop concurrent Connect calls. if (connectInFlight) { return isConnected; } if (isConnected && port == httpPort) { return true; } connectInFlight = true; try { port = httpPort; serverUrl = $"ws://127.0.0.1:{port}"; shouldReconnect = true; reconnectAttempts = 0; while (shouldReconnect && reconnectAttempts < maxReconnectAttempts) { try { SynLog.Info($"[HTTP WebSocket] Connecting to {serverUrl}... (Attempt {reconnectAttempts + 1})"); var ws = new ClientWebSocket(); ws.Options.SetRequestHeader("X-Client-Type", "unity"); var cts = new CancellationTokenSource(); webSocket = ws; cancellationTokenSource = cts; using (var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token)) { connectCts.CancelAfter(TimeSpan.FromSeconds(CONNECT_TIMEOUT_SECONDS)); await ws.ConnectAsync(new Uri(serverUrl), connectCts.Token); } isConnected = true; reconnectAttempts = 0; SynLog.Info($"[HTTP WebSocket] Connected to HTTP Server on port {port}!"); // Start message receive loop — snapshot ws+cts so a stale loop whose // fields were overwritten by a subsequent Connect bails out cleanly. var receiveTask = Task.Run(async () => await ReceiveLoop(ws, cts)); LogTaskFault(receiveTask, "ReceiveLoop"); backgroundTasks.Add(receiveTask); // Notify Unity is ready await SendMessage(new { type = "unity_ready", version = NexusVersion.Current }); return true; } catch (Exception e) { SynLog.Warn($"[HTTP WebSocket] Connection failed: {e.Message}"); isConnected = false; reconnectAttempts++; if (reconnectAttempts < maxReconnectAttempts && shouldReconnect) { await Task.Delay(reconnectDelay); } } } SynLog.Warn($"[HTTP WebSocket] Could not connect after {maxReconnectAttempts} attempts"); return false; } finally { connectInFlight = false; } } private async Task ReceiveLoop(ClientWebSocket ws, CancellationTokenSource cts) { var buffer = new byte[8192]; var messageBuilder = new StringBuilder(); try { while (ws.State == WebSocketState.Open && !cts.IsCancellationRequested) { var result = await ws.ReceiveAsync( new ArraySegment(buffer), cts.Token ); if (result.MessageType == WebSocketMessageType.Text) { messageBuilder.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); if (result.EndOfMessage) { var message = messageBuilder.ToString(); messageBuilder.Clear(); lock (messageQueue) { messageQueue.Enqueue(message); } } } else if (result.MessageType == WebSocketMessageType.Close) { break; } } } catch (Exception e) { if (!cts.IsCancellationRequested) { SynLog.Warn($"[HTTP WebSocket] Receive error: {e.Message}"); } } finally { // Only mutate shared state + auto-reconnect if this loop is STILL the active // session. A later Connect() overwrites webSocket/cancellationTokenSource, so // reference-equality on the snapshots tells us whether we're stale. bool stillActive = ReferenceEquals(webSocket, ws) && ReferenceEquals(cancellationTokenSource, cts); if (stillActive) { isConnected = false; } if (shouldReconnect && stillActive && !cts.IsCancellationRequested && !connectInFlight) { var reconnectTask = Task.Run(async () => { await Task.Delay(reconnectDelay); if (shouldReconnect && !connectInFlight) await Connect(port); }); LogTaskFault(reconnectTask, "AutoReconnect"); } } } public void ProcessMessages() { if (!isConnected) return; lock (messageQueue) { while (messageQueue.Count > 0) { var message = messageQueue.Dequeue(); try { var data = JsonConvert.DeserializeObject>(message); if (data != null && data.ContainsKey("type")) { ProcessOperation(data); } } catch (Exception e) { Debug.LogError($"[HTTP WebSocket] Message processing error: {e.Message}"); } } } } private void ProcessOperation(Dictionary data) { var type = data["type"].ToString(); if (type == "operation") { var operationType = data.ContainsKey("operationType") ? data["operationType"].ToString() : ""; var operationId = data.ContainsKey("operationId") ? data["operationId"].ToString() : ""; var parameters = data.ContainsKey("parameters") ? data["parameters"] as Newtonsoft.Json.Linq.JObject : null; // Execute immediately (ProcessMessages is called from main thread via EditorApplication.update) _ = ExecuteOperation(operationType, operationId, parameters); } } private async Task ExecuteOperation(string operationType, string operationId, Newtonsoft.Json.Linq.JObject parameters) { try { var operation = new NexusUnityOperation { type = operationType, parameters = new Dictionary() }; // Convert parameters if (parameters != null) { foreach (var prop in parameters.Properties()) { var value = prop.Value; if (value is Newtonsoft.Json.Linq.JObject jObj) { if (jObj.ContainsKey("x") && jObj.ContainsKey("y") && jObj.ContainsKey("z")) { operation.parameters[prop.Name] = $"{jObj["x"]},{jObj["y"]},{jObj["z"]}"; } else { operation.parameters[prop.Name] = value.ToString(); } } else { operation.parameters[prop.Name] = value?.ToString() ?? ""; } } } // Execute var executor = new NexusUnityExecutor(); var result = await executor.ExecuteOperation(operation); var success = !result.StartsWith("Error") && !result.Contains("failed"); // Send response var response = new Dictionary { ["operationId"] = operationId, ["success"] = success, ["result"] = result }; await SendMessage(response); } catch (Exception e) { var errorResponse = new Dictionary { ["operationId"] = operationId, ["success"] = false, ["result"] = $"Error: {e.Message}" }; await SendMessage(errorResponse); } } public async Task SendMessage(object data) { if (!isConnected || webSocket?.State != WebSocketState.Open) { return; } try { var json = JsonConvert.SerializeObject(data); var bytes = Encoding.UTF8.GetBytes(json); await webSocket.SendAsync( new ArraySegment(bytes), WebSocketMessageType.Text, true, cancellationTokenSource.Token ); } catch (Exception e) { Debug.LogError($"[HTTP WebSocket] Send error: {e.Message}"); } } public async Task Disconnect() { shouldReconnect = false; isConnected = false; try { if (webSocket != null && webSocket.State == WebSocketState.Open) { using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2))) { await webSocket.CloseAsync( WebSocketCloseStatus.NormalClosure, "Closing", cts.Token ); } } } catch { } try { cancellationTokenSource?.Cancel(); cancellationTokenSource?.Dispose(); } catch { } cancellationTokenSource = null; webSocket = null; if (backgroundTasks.Count > 0) { try { Task.WhenAll(backgroundTasks).Wait(TimeSpan.FromSeconds(2)); } catch { } backgroundTasks.Clear(); } SynLog.Info("[HTTP WebSocket] Disconnected"); } } }