Files
BeatSaber/Assets/Editor/UnityCodexBridgeServer.cs
T

911 lines
33 KiB
C#
Raw Normal View History

2026-05-28 19:01:20 +09:00
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 VRBeats.EditorTools
{
[InitializeOnLoad]
internal static class UnityCodexBridgeServer
{
private const int Port = 19744;
private const string AutoStartPrefKey = "VRBeats.CodexBridge.AutoStart";
private const int MaxLogs = 250;
private static readonly ConcurrentQueue<BridgeJob> Jobs = new ConcurrentQueue<BridgeJob>();
private static readonly List<LogEntry> Logs = new List<LogEntry>();
private static readonly object LogLock = new object();
private static TcpListener _listener;
private static Thread _serverThread;
private static bool _running;
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<string, string>());
Debug.Log("[CodexBridge] Capture result: " + response.Body);
}
[MenuItem("Tools/Codex Bridge/Open Sync Calibration")]
private static void OpenSyncCalibrationMenu()
{
SyncCalibrationOverlay.Open();
}
private static void StartServer()
{
if (IsBackgroundEditorProcess())
return;
if (_running)
return;
try
{
_listener = new TcpListener(IPAddress.Loopback, Port);
_listener.Server.ExclusiveAddressUse = true;
_listener.Start();
_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 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<string, string> ParseQuery(string query)
{
Dictionary<string, string> values = new Dictionary<string, string>(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 "/sync/open":
SyncCalibrationOverlay.Open();
return BridgeResponse.Json(200, "{\"ok\":true,\"opened\":\"sync_calibration\"}");
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<string, string> 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<Camera>(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<string, string> query)
{
int count = GetInt(query, "count", 80, 1, MaxLogs);
List<LogEntry> 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<GameObject>();
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<string, string> 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<Transform>(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<string, string> 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<Component>();
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<string> parts = new Stack<string>();
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<string, string> 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<string, string> 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*\\{(?<body>.*?)\\}",
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*(?<num>-?\\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<string, string> Query;
public readonly string Body;
public BridgeRequest(string method, string path, Dictionary<string, string> 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;
}
}
}