using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Net; using System.Net.Sockets; using System.Text; using System.Text.RegularExpressions; using System.Threading; using UnityEditor; using UnityEngine; using UnityEngine.SceneManagement; namespace UniRun.EditorTools { [InitializeOnLoad] internal static class UnityCodexBridgeServer { private const int PreferredPort = 19744; private const int MaxPortAttempts = 5; private const string AutoStartPrefKey = "UniRun.CodexBridge.AutoStart"; private const int MaxLogs = 250; private static readonly ConcurrentQueue Jobs = new ConcurrentQueue(); private static readonly List Logs = new List(); private static readonly object LogLock = new object(); private static TcpListener _listener; private static Thread _serverThread; private static bool _running; private static int _port = PreferredPort; private static int _logIndex; static UnityCodexBridgeServer() { if (IsBackgroundEditorProcess()) return; Application.logMessageReceived -= OnLogMessageReceived; Application.logMessageReceived += OnLogMessageReceived; EditorApplication.update -= ProcessJobs; EditorApplication.update += ProcessJobs; EditorApplication.quitting -= StopServer; EditorApplication.quitting += StopServer; AssemblyReloadEvents.beforeAssemblyReload -= StopServer; AssemblyReloadEvents.beforeAssemblyReload += StopServer; if (EditorPrefs.GetBool(AutoStartPrefKey, true)) StartServer(); } [MenuItem("Tools/Codex Bridge/Start Server")] private static void StartServerMenu() { StartServer(); } [MenuItem("Tools/Codex Bridge/Stop Server")] private static void StopServerMenu() { StopServer(); } [MenuItem("Tools/Codex Bridge/Auto Start")] private static void ToggleAutoStart() { bool enabled = !EditorPrefs.GetBool(AutoStartPrefKey, true); EditorPrefs.SetBool(AutoStartPrefKey, enabled); if (enabled) StartServer(); } [MenuItem("Tools/Codex Bridge/Auto Start", true)] private static bool ValidateToggleAutoStart() { Menu.SetChecked("Tools/Codex Bridge/Auto Start", EditorPrefs.GetBool(AutoStartPrefKey, true)); return true; } [MenuItem("Tools/Codex Bridge/Capture Game View")] private static void CaptureGameViewMenu() { BridgeResponse response = CaptureGameView(new Dictionary()); Debug.Log("[CodexBridge] Capture result: " + response.Body); } private static void StartServer() { if (IsBackgroundEditorProcess()) return; if (_running) return; try { _listener = CreateListener(); _running = true; _serverThread = new Thread(ServerLoop) { IsBackground = true, Name = "UnityCodexBridgeServer" }; _serverThread.Start(); Debug.Log("[CodexBridge] Listening on http://127.0.0.1:" + _port); } catch (Exception ex) { _running = false; try { if (_listener != null) _listener.Stop(); } catch { // Ignore cleanup failures after a failed bind. } finally { _listener = null; } Debug.LogWarning("[CodexBridge] Failed to start: " + ex.Message); } } private static void StopServer() { _running = false; try { if (_listener != null) _listener.Stop(); } catch { // Ignore shutdown races. } _listener = null; if (_serverThread != null && _serverThread.IsAlive) _serverThread.Join(200); _serverThread = null; } private static TcpListener CreateListener() { Exception lastException = null; for (int i = 0; i < MaxPortAttempts; i++) { int port = PreferredPort + i; TcpListener listener = null; try { listener = new TcpListener(IPAddress.Loopback, port); listener.Server.ExclusiveAddressUse = true; listener.Start(); _port = port; return listener; } catch (Exception ex) { lastException = ex; try { if (listener != null) listener.Stop(); } catch { // Ignore cleanup failures while trying fallback ports. } } } throw lastException ?? new SocketException(); } private static bool IsBackgroundEditorProcess() { string commandLine = Environment.CommandLine; return Application.isBatchMode || commandLine.IndexOf("AssetImportWorker", StringComparison.OrdinalIgnoreCase) >= 0 || commandLine.IndexOf("-batchMode", StringComparison.OrdinalIgnoreCase) >= 0; } private static void ServerLoop() { while (_running) { try { TcpClient client = _listener.AcceptTcpClient(); ThreadPool.QueueUserWorkItem(_ => HandleClient(client)); } catch { if (_running) Thread.Sleep(100); } } } private static void HandleClient(TcpClient client) { using (client) { try { client.ReceiveTimeout = 5000; client.SendTimeout = 5000; BridgeRequest request = ReadRequest(client.GetStream()); BridgeResponse response; if (request == null) { response = BridgeResponse.Json(400, "{\"ok\":false,\"error\":\"invalid_request\"}"); } else if (request.Method == "OPTIONS") { response = BridgeResponse.Json(204, string.Empty); } else { BridgeJob job = new BridgeJob(request); Jobs.Enqueue(job); if (!job.Done.Wait(TimeSpan.FromSeconds(10))) response = BridgeResponse.Json(504, "{\"ok\":false,\"error\":\"unity_main_thread_timeout\"}"); else response = job.Response; } WriteResponse(client.GetStream(), response); } catch (Exception ex) { try { WriteResponse(client.GetStream(), BridgeResponse.Json(500, "{\"ok\":false,\"error\":" + JsonString(ex.Message) + "}")); } catch { // Client has already gone away. } } } } private static BridgeRequest ReadRequest(Stream stream) { StreamReader reader = new StreamReader(stream, Encoding.UTF8, false, 4096, true); string requestLine = reader.ReadLine(); if (string.IsNullOrEmpty(requestLine)) return null; string[] requestParts = requestLine.Split(' '); if (requestParts.Length < 2) return null; int contentLength = 0; string line; while (!string.IsNullOrEmpty(line = reader.ReadLine())) { int separatorIndex = line.IndexOf(':'); if (separatorIndex <= 0) continue; string headerName = line.Substring(0, separatorIndex).Trim(); string headerValue = line.Substring(separatorIndex + 1).Trim(); if (string.Equals(headerName, "Content-Length", StringComparison.OrdinalIgnoreCase)) int.TryParse(headerValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out contentLength); } string body = string.Empty; if (contentLength > 0) { char[] buffer = new char[contentLength]; int read = reader.ReadBlock(buffer, 0, contentLength); body = new string(buffer, 0, read); } Uri uri = new Uri("http://127.0.0.1" + requestParts[1]); return new BridgeRequest(requestParts[0].ToUpperInvariant(), uri.AbsolutePath, ParseQuery(uri.Query), body); } private static Dictionary ParseQuery(string query) { Dictionary values = new Dictionary(StringComparer.OrdinalIgnoreCase); if (string.IsNullOrEmpty(query)) return values; string trimmed = query[0] == '?' ? query.Substring(1) : query; string[] pairs = trimmed.Split('&'); foreach (string pair in pairs) { if (string.IsNullOrEmpty(pair)) continue; int separatorIndex = pair.IndexOf('='); string key = separatorIndex >= 0 ? pair.Substring(0, separatorIndex) : pair; string value = separatorIndex >= 0 ? pair.Substring(separatorIndex + 1) : string.Empty; values[Uri.UnescapeDataString(key)] = Uri.UnescapeDataString(value.Replace("+", " ")); } return values; } private static void WriteResponse(Stream stream, BridgeResponse response) { if (response == null) response = BridgeResponse.Json(500, "{\"ok\":false,\"error\":\"null_response\"}"); byte[] body = Encoding.UTF8.GetBytes(response.Body ?? string.Empty); string headers = "HTTP/1.1 " + response.StatusCode + " " + StatusText(response.StatusCode) + "\r\n" + "Content-Type: " + response.ContentType + "; charset=utf-8\r\n" + "Content-Length: " + body.Length.ToString(CultureInfo.InvariantCulture) + "\r\n" + "Access-Control-Allow-Origin: http://localhost\r\n" + "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n" + "Access-Control-Allow-Headers: Content-Type\r\n" + "Connection: close\r\n\r\n"; byte[] headerBytes = Encoding.UTF8.GetBytes(headers); stream.Write(headerBytes, 0, headerBytes.Length); if (body.Length > 0) stream.Write(body, 0, body.Length); } private static string StatusText(int statusCode) { switch (statusCode) { case 200: return "OK"; case 204: return "No Content"; case 400: return "Bad Request"; case 404: return "Not Found"; case 405: return "Method Not Allowed"; case 500: return "Internal Server Error"; case 504: return "Gateway Timeout"; default: return "OK"; } } private static void ProcessJobs() { while (Jobs.TryDequeue(out BridgeJob job)) { try { job.Response = Execute(job.Request); } catch (Exception ex) { job.Response = BridgeResponse.Json(500, "{\"ok\":false,\"error\":" + JsonString(ex.Message) + "}"); } finally { job.Done.Set(); } } } private static BridgeResponse Execute(BridgeRequest request) { switch (request.Path) { case "/health": case "/state": return GetHealth(); case "/capture": return CaptureGameView(request.Query); case "/logs": return GetLogs(request.Query); case "/scene/roots": return GetSceneRoots(); case "/scene/objects": return GetSceneObjects(request.Query); case "/object": return GetObjectDetails(request.Query); case "/play": return SetPlayState(true, false); case "/pause": return SetPlayState(true, true); case "/stop": return SetPlayState(false, false); case "/transform": if (request.Method != "POST") return BridgeResponse.Json(405, "{\"ok\":false,\"error\":\"method_not_allowed\"}"); return SetTransform(request.Body); default: return BridgeResponse.Json(404, "{\"ok\":false,\"error\":\"not_found\"}"); } } private static BridgeResponse GetHealth() { Scene scene = SceneManager.GetActiveScene(); string body = "{\"ok\":true" + ",\"bridge\":\"unity-codex-bridge\"" + ",\"port\":" + _port.ToString(CultureInfo.InvariantCulture) + ",\"unityVersion\":" + JsonString(Application.unityVersion) + ",\"projectPath\":" + JsonString(Directory.GetCurrentDirectory()) + ",\"scene\":" + JsonString(scene.IsValid() ? scene.name : string.Empty) + ",\"isPlaying\":" + Bool(EditorApplication.isPlaying) + ",\"isPaused\":" + Bool(EditorApplication.isPaused) + "}"; return BridgeResponse.Json(200, body); } private static BridgeResponse CaptureGameView(Dictionary query) { int width = GetInt(query, "width", 1280, 320, 4096); int height = GetInt(query, "height", 720, 180, 4096); string relativePath = "Captures/latest.png"; string absolutePath = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), relativePath)); Directory.CreateDirectory(Path.GetDirectoryName(absolutePath)); Camera camera = ResolveCaptureCamera(); string cameraName = camera != null ? camera.name : string.Empty; bool usedCameraRender = false; if (camera != null) { RenderTexture previousTarget = camera.targetTexture; RenderTexture previousActive = RenderTexture.active; RenderTexture renderTexture = RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.ARGB32); Texture2D texture = null; try { camera.targetTexture = renderTexture; RenderTexture.active = renderTexture; camera.Render(); texture = new Texture2D(width, height, TextureFormat.RGB24, false); texture.ReadPixels(new Rect(0, 0, width, height), 0, 0); texture.Apply(); File.WriteAllBytes(absolutePath, texture.EncodeToPNG()); usedCameraRender = true; } finally { camera.targetTexture = previousTarget; RenderTexture.active = previousActive; RenderTexture.ReleaseTemporary(renderTexture); if (texture != null) UnityEngine.Object.DestroyImmediate(texture); } } if (!usedCameraRender) ScreenCapture.CaptureScreenshot(relativePath); string body = "{\"ok\":true" + ",\"path\":" + JsonString(absolutePath) + ",\"relativePath\":" + JsonString(relativePath) + ",\"width\":" + width.ToString(CultureInfo.InvariantCulture) + ",\"height\":" + height.ToString(CultureInfo.InvariantCulture) + ",\"camera\":" + JsonString(cameraName) + ",\"mode\":" + JsonString(usedCameraRender ? "camera_render" : "screen_capture") + "}"; return BridgeResponse.Json(200, body); } private static Camera ResolveCaptureCamera() { Camera main = Camera.main; if (main != null && main.isActiveAndEnabled) return main; Camera[] cameras = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); foreach (Camera camera in cameras) { if (camera != null && camera.isActiveAndEnabled) return camera; } return main != null ? main : (cameras.Length > 0 ? cameras[0] : null); } private static BridgeResponse GetLogs(Dictionary query) { int count = GetInt(query, "count", 80, 1, MaxLogs); List snapshot; lock (LogLock) { int start = Mathf.Max(0, Logs.Count - count); snapshot = Logs.GetRange(start, Logs.Count - start); } StringBuilder sb = new StringBuilder(); sb.Append("{\"ok\":true,\"logs\":["); for (int i = 0; i < snapshot.Count; i++) { if (i > 0) sb.Append(','); LogEntry entry = snapshot[i]; sb.Append('{'); sb.Append("\"index\":").Append(entry.Index.ToString(CultureInfo.InvariantCulture)).Append(','); sb.Append("\"time\":").Append(JsonString(entry.Time)).Append(','); sb.Append("\"type\":").Append(JsonString(entry.Type)).Append(','); sb.Append("\"message\":").Append(JsonString(entry.Message)).Append(','); sb.Append("\"stackTrace\":").Append(JsonString(entry.StackTrace)); sb.Append('}'); } sb.Append("]}"); return BridgeResponse.Json(200, sb.ToString()); } private static BridgeResponse GetSceneRoots() { Scene scene = SceneManager.GetActiveScene(); GameObject[] roots = scene.IsValid() ? scene.GetRootGameObjects() : Array.Empty(); StringBuilder sb = new StringBuilder(); sb.Append("{\"ok\":true,\"scene\":").Append(JsonString(scene.IsValid() ? scene.name : string.Empty)); sb.Append(",\"roots\":["); for (int i = 0; i < roots.Length; i++) { if (i > 0) sb.Append(','); AppendGameObjectSummary(sb, roots[i]); } sb.Append("]}"); return BridgeResponse.Json(200, sb.ToString()); } private static BridgeResponse GetSceneObjects(Dictionary query) { string filter = GetString(query, "query", string.Empty); int limit = GetInt(query, "limit", 120, 1, 500); string lowerFilter = filter.ToLowerInvariant(); Transform[] transforms = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); StringBuilder sb = new StringBuilder(); sb.Append("{\"ok\":true,\"objects\":["); int written = 0; foreach (Transform transform in transforms) { if (transform == null || transform.gameObject == null) continue; if (EditorUtility.IsPersistent(transform.gameObject)) continue; if (!transform.gameObject.scene.IsValid()) continue; string path = GetHierarchyPath(transform.gameObject); if (!string.IsNullOrEmpty(lowerFilter) && path.ToLowerInvariant().IndexOf(lowerFilter, StringComparison.Ordinal) < 0) continue; if (written > 0) sb.Append(','); AppendGameObjectSummary(sb, transform.gameObject); written++; if (written >= limit) break; } sb.Append("],\"count\":").Append(written.ToString(CultureInfo.InvariantCulture)).Append('}'); return BridgeResponse.Json(200, sb.ToString()); } private static BridgeResponse GetObjectDetails(Dictionary query) { string path = GetString(query, "path", string.Empty); GameObject go = FindGameObjectByPath(path); if (go == null) return BridgeResponse.Json(404, "{\"ok\":false,\"error\":\"object_not_found\",\"path\":" + JsonString(path) + "}"); StringBuilder sb = new StringBuilder(); sb.Append("{\"ok\":true,\"object\":"); AppendGameObjectDetails(sb, go); sb.Append('}'); return BridgeResponse.Json(200, sb.ToString()); } private static BridgeResponse SetPlayState(bool play, bool pause) { EditorApplication.isPlaying = play; EditorApplication.isPaused = pause; return GetHealth(); } private static BridgeResponse SetTransform(string body) { string path = ExtractString(body, "path"); if (string.IsNullOrEmpty(path)) return BridgeResponse.Json(400, "{\"ok\":false,\"error\":\"missing_path\"}"); GameObject go = FindGameObjectByPath(path); if (go == null) return BridgeResponse.Json(404, "{\"ok\":false,\"error\":\"object_not_found\",\"path\":" + JsonString(path) + "}"); Undo.RecordObject(go.transform, "Codex Bridge Set Transform"); if (TryExtractVector3(body, "position", out Vector3 position)) go.transform.position = position; if (TryExtractVector3(body, "localPosition", out Vector3 localPosition)) go.transform.localPosition = localPosition; if (TryExtractVector3(body, "rotationEuler", out Vector3 rotationEuler)) go.transform.eulerAngles = rotationEuler; if (TryExtractVector3(body, "localRotationEuler", out Vector3 localRotationEuler)) go.transform.localEulerAngles = localRotationEuler; if (TryExtractVector3(body, "scale", out Vector3 scale)) go.transform.localScale = scale; EditorUtility.SetDirty(go.transform); StringBuilder sb = new StringBuilder(); sb.Append("{\"ok\":true,\"object\":"); AppendGameObjectDetails(sb, go); sb.Append('}'); return BridgeResponse.Json(200, sb.ToString()); } private static void AppendGameObjectSummary(StringBuilder sb, GameObject go) { sb.Append('{'); sb.Append("\"name\":").Append(JsonString(go.name)).Append(','); sb.Append("\"path\":").Append(JsonString(GetHierarchyPath(go))).Append(','); sb.Append("\"activeSelf\":").Append(Bool(go.activeSelf)).Append(','); sb.Append("\"activeInHierarchy\":").Append(Bool(go.activeInHierarchy)).Append(','); sb.Append("\"scene\":").Append(JsonString(go.scene.IsValid() ? go.scene.name : string.Empty)).Append(','); sb.Append("\"position\":").Append(VectorJson(go.transform.position)).Append(','); sb.Append("\"rotationEuler\":").Append(VectorJson(go.transform.eulerAngles)).Append(','); sb.Append("\"scale\":").Append(VectorJson(go.transform.localScale)); sb.Append('}'); } private static void AppendGameObjectDetails(StringBuilder sb, GameObject go) { AppendGameObjectSummary(sb, go); sb.Length -= 1; Component[] components = go.GetComponents(); sb.Append(",\"components\":["); for (int i = 0; i < components.Length; i++) { if (i > 0) sb.Append(','); Component component = components[i]; sb.Append(JsonString(component != null ? component.GetType().FullName : "Missing Script")); } sb.Append("]}"); } private static GameObject FindGameObjectByPath(string path) { if (string.IsNullOrEmpty(path)) return null; string[] parts = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); if (parts.Length == 0) return null; Scene scene = SceneManager.GetActiveScene(); if (!scene.IsValid()) return null; foreach (GameObject root in scene.GetRootGameObjects()) { if (root.name != parts[0]) continue; Transform current = root.transform; for (int i = 1; i < parts.Length && current != null; i++) { current = FindChildByName(current, parts[i]); } if (current != null) return current.gameObject; } return null; } private static Transform FindChildByName(Transform parent, string childName) { for (int i = 0; i < parent.childCount; i++) { Transform child = parent.GetChild(i); if (child.name == childName) return child; } return null; } private static string GetHierarchyPath(GameObject go) { Stack parts = new Stack(); Transform current = go.transform; while (current != null) { parts.Push(current.name); current = current.parent; } return string.Join("/", parts.ToArray()); } private static void OnLogMessageReceived(string condition, string stackTrace, LogType type) { lock (LogLock) { Logs.Add(new LogEntry { Index = ++_logIndex, Time = DateTime.Now.ToString("HH:mm:ss", CultureInfo.InvariantCulture), Type = type.ToString(), Message = condition ?? string.Empty, StackTrace = stackTrace ?? string.Empty }); if (Logs.Count > MaxLogs) Logs.RemoveAt(0); } } private static int GetInt(Dictionary query, string key, int fallback, int min, int max) { if (!query.TryGetValue(key, out string value) || !int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed)) return fallback; return Mathf.Clamp(parsed, min, max); } private static string GetString(Dictionary query, string key, string fallback) { return query.TryGetValue(key, out string value) ? value : fallback; } private static string ExtractString(string json, string key) { if (string.IsNullOrEmpty(json)) return string.Empty; Match match = Regex.Match(json, "\"" + Regex.Escape(key) + "\"\\s*:\\s*\"((?:\\\\.|[^\"])*)\"", RegexOptions.CultureInvariant); return match.Success ? Regex.Unescape(match.Groups[1].Value) : string.Empty; } private static bool TryExtractVector3(string json, string key, out Vector3 value) { value = Vector3.zero; if (string.IsNullOrEmpty(json)) return false; Match objectMatch = Regex.Match(json, "\"" + Regex.Escape(key) + "\"\\s*:\\s*\\{(?.*?)\\}", RegexOptions.Singleline | RegexOptions.CultureInvariant); if (!objectMatch.Success) return false; string objectBody = objectMatch.Groups["body"].Value; if (!TryExtractFloat(objectBody, "x", out float x) || !TryExtractFloat(objectBody, "y", out float y) || !TryExtractFloat(objectBody, "z", out float z)) return false; value = new Vector3(x, y, z); return true; } private static bool TryExtractFloat(string json, string key, out float value) { value = 0f; Match match = Regex.Match(json, "\"" + Regex.Escape(key) + "\"\\s*:\\s*(?-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)", RegexOptions.CultureInvariant); return match.Success && float.TryParse(match.Groups["num"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out value); } private static string VectorJson(Vector3 value) { return "{\"x\":" + FloatJson(value.x) + ",\"y\":" + FloatJson(value.y) + ",\"z\":" + FloatJson(value.z) + "}"; } private static string FloatJson(float value) { if (float.IsNaN(value) || float.IsInfinity(value)) return "0"; return value.ToString("0.#####", CultureInfo.InvariantCulture); } private static string Bool(bool value) { return value ? "true" : "false"; } private static string JsonString(string value) { if (value == null) return "null"; StringBuilder sb = new StringBuilder(value.Length + 2); sb.Append('"'); foreach (char c in value) { switch (c) { case '\\': sb.Append("\\\\"); break; case '"': sb.Append("\\\""); break; case '\n': sb.Append("\\n"); break; case '\r': sb.Append("\\r"); break; case '\t': sb.Append("\\t"); break; default: if (c < 32) sb.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture)); else sb.Append(c); break; } } sb.Append('"'); return sb.ToString(); } private sealed class BridgeRequest { public readonly string Method; public readonly string Path; public readonly Dictionary Query; public readonly string Body; public BridgeRequest(string method, string path, Dictionary query, string body) { Method = method; Path = path; Query = query; Body = body ?? string.Empty; } } private sealed class BridgeResponse { public readonly int StatusCode; public readonly string ContentType; public readonly string Body; private BridgeResponse(int statusCode, string contentType, string body) { StatusCode = statusCode; ContentType = contentType; Body = body; } public static BridgeResponse Json(int statusCode, string body) { return new BridgeResponse(statusCode, "application/json", body); } } private sealed class BridgeJob { public readonly BridgeRequest Request; public readonly ManualResetEventSlim Done = new ManualResetEventSlim(false); public BridgeResponse Response; public BridgeJob(BridgeRequest request) { Request = request; } } private sealed class LogEntry { public int Index; public string Time; public string Type; public string Message; public string StackTrace; } } }