@mflrevan/ucp 0.2.0 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/bridge/com.ucp.bridge/CHANGELOG.md +56 -0
- package/bridge/com.ucp.bridge/CHANGELOG.md.meta +7 -0
- package/bridge/com.ucp.bridge/Editor/Bridge/BridgeServer.cs +573 -0
- package/bridge/com.ucp.bridge/Editor/Bridge/BridgeServer.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Bridge.meta +8 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/AssetController.cs +499 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/AssetController.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/BuildController.cs +230 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/BuildController.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/CompilationController.cs +26 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/CompilationController.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/EditorSettingsController.cs +435 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/EditorSettingsController.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/FileController.cs +130 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/FileController.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/HierarchyController.cs +319 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/HierarchyController.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/LogsController.cs +291 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/LogsController.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/MaterialController.cs +295 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/MaterialController.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/PlayModeController.cs +38 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/PlayModeController.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/PrefabController.cs +242 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/PrefabController.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/PropertyController.cs +551 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/PropertyController.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/SceneController.cs +70 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/SceneController.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/ScreenshotController.cs +125 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/ScreenshotController.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/ScriptController.cs +104 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/ScriptController.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/SnapshotController.cs +227 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/SnapshotController.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/TestRunnerController.cs +180 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/TestRunnerController.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/VcsController.cs +611 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/VcsController.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Controllers.meta +8 -0
- package/bridge/com.ucp.bridge/Editor/Protocol/CommandRouter.cs +45 -0
- package/bridge/com.ucp.bridge/Editor/Protocol/CommandRouter.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Protocol/MessageTypes.cs +80 -0
- package/bridge/com.ucp.bridge/Editor/Protocol/MessageTypes.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Protocol/MiniJson.cs +358 -0
- package/bridge/com.ucp.bridge/Editor/Protocol/MiniJson.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Protocol.meta +8 -0
- package/bridge/com.ucp.bridge/Editor/Scripts/IUCPScript.cs +37 -0
- package/bridge/com.ucp.bridge/Editor/Scripts/IUCPScript.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Editor/Scripts.meta +8 -0
- package/bridge/com.ucp.bridge/Editor/UCP.Bridge.Editor.asmdef +16 -0
- package/bridge/com.ucp.bridge/Editor/UCP.Bridge.Editor.asmdef.meta +7 -0
- package/bridge/com.ucp.bridge/Editor.meta +8 -0
- package/bridge/com.ucp.bridge/Runtime/UCP.Bridge.Runtime.asmdef +14 -0
- package/bridge/com.ucp.bridge/Runtime/UCP.Bridge.Runtime.asmdef.meta +7 -0
- package/bridge/com.ucp.bridge/Runtime.meta +8 -0
- package/bridge/com.ucp.bridge/Tests/Editor/ControllerSmokeTests.cs +194 -0
- package/bridge/com.ucp.bridge/Tests/Editor/ControllerSmokeTests.cs.meta +2 -0
- package/bridge/com.ucp.bridge/Tests/Editor/UCP.Bridge.Editor.Tests.asmdef +12 -0
- package/bridge/com.ucp.bridge/Tests/Editor/UCP.Bridge.Editor.Tests.asmdef.meta +7 -0
- package/bridge/com.ucp.bridge/Tests/Editor.meta +8 -0
- package/bridge/com.ucp.bridge/Tests.meta +8 -0
- package/bridge/com.ucp.bridge/package.json +27 -0
- package/bridge/com.ucp.bridge/package.json.meta +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @mflrevan/ucp
|
|
2
2
|
|
|
3
|
-
Version `0.2.
|
|
3
|
+
Version `0.2.3` of the Unity Control Protocol CLI.
|
|
4
4
|
|
|
5
|
-
This package installs the `ucp` command
|
|
5
|
+
This package installs the `ucp` command, downloads the matching published binary for your platform during `postinstall`, and ships the matching Unity bridge payload inside the npm package.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -47,7 +47,7 @@ Or add this to `Packages/manifest.json`:
|
|
|
47
47
|
```json
|
|
48
48
|
{
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"com.ucp.bridge": "https://github.com/mflRevan/unity-control-protocol.git?path=unity-package/com.ucp.bridge"
|
|
50
|
+
"com.ucp.bridge": "https://github.com/mflRevan/unity-control-protocol.git?path=unity-package/com.ucp.bridge#v0.2.3"
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
```
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.3] - 2026-03-12
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Fixed the release packaging pipeline so metadata validation no longer depends on optional website-only files
|
|
8
|
+
|
|
9
|
+
## [0.2.2] - 2026-03-12
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- The bridge is now intended to be consumed through CLI-managed local mounts by default, with tracked manifest installation remaining an explicit opt-in path
|
|
14
|
+
|
|
15
|
+
## [0.2.1] - 2026-03-12
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- `LogsController` buffered history support for `logs/tail`, `logs/search`, and `logs/get`
|
|
20
|
+
- EditMode smoke coverage for buffered log truncation, regex filtering, and id-window filtering
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- Snapshot responses remain shallow by default and log-heavy reads are now designed for summary-first inspection
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- EditMode test duration reporting now uses the editor uptime clock to avoid negative durations
|
|
29
|
+
|
|
30
|
+
## [0.2.0] - 2026-03-12
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- Asset controller for asset search, inspection, field reads, writes, and ScriptableObject creation
|
|
35
|
+
- Property and hierarchy controllers for GameObject, component, and hierarchy manipulation
|
|
36
|
+
- Material controller for shader properties and keywords
|
|
37
|
+
- Prefab controller for status, overrides, apply, revert, unpack, and prefab creation
|
|
38
|
+
- Editor settings controller for player, quality, physics, lighting, tags, and layers
|
|
39
|
+
- Build controller for targets, scenes, scripting defines, and build execution
|
|
40
|
+
|
|
41
|
+
## [0.1.0] - 2026-03-09
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- Initial WebSocket bridge server
|
|
46
|
+
- Play/stop/pause control
|
|
47
|
+
- Compilation trigger
|
|
48
|
+
- Scene management (list, load, active)
|
|
49
|
+
- State snapshots
|
|
50
|
+
- Screenshot capture (Game view)
|
|
51
|
+
- Console log streaming
|
|
52
|
+
- Test runner integration
|
|
53
|
+
- File read/write/patch operations
|
|
54
|
+
- JSON-RPC 2.0 protocol
|
|
55
|
+
- Lock file discovery mechanism
|
|
56
|
+
- Per-session token authentication
|
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Collections.Concurrent;
|
|
3
|
+
using System.Collections.Generic;
|
|
4
|
+
using System.IO;
|
|
5
|
+
using System.Net;
|
|
6
|
+
using System.Net.Sockets;
|
|
7
|
+
using System.Net.WebSockets;
|
|
8
|
+
using System.Security.Cryptography;
|
|
9
|
+
using System.Text;
|
|
10
|
+
using System.Text.RegularExpressions;
|
|
11
|
+
using System.Threading;
|
|
12
|
+
using System.Threading.Tasks;
|
|
13
|
+
using UnityEditor;
|
|
14
|
+
using UnityEngine;
|
|
15
|
+
|
|
16
|
+
namespace UCP.Bridge
|
|
17
|
+
{
|
|
18
|
+
/// <summary>
|
|
19
|
+
/// WebSocket server that runs inside the Unity Editor.
|
|
20
|
+
/// Uses raw TcpListener + manual WebSocket upgrade for maximum compatibility.
|
|
21
|
+
/// Starts automatically via [InitializeOnLoad] and handles JSON-RPC commands.
|
|
22
|
+
/// </summary>
|
|
23
|
+
[InitializeOnLoad]
|
|
24
|
+
public static class BridgeServer
|
|
25
|
+
{
|
|
26
|
+
private const int DefaultPort = 21342;
|
|
27
|
+
private const int MaxPort = 21352;
|
|
28
|
+
private const int MaxConnections = 4;
|
|
29
|
+
private const string ProtocolVersion = "0.2.3";
|
|
30
|
+
|
|
31
|
+
private static TcpListener s_listener;
|
|
32
|
+
private static CancellationTokenSource s_cts;
|
|
33
|
+
private static readonly List<WebSocket> s_clients = new();
|
|
34
|
+
private static readonly object s_clientLock = new();
|
|
35
|
+
private static int s_port;
|
|
36
|
+
private static string s_token;
|
|
37
|
+
private static bool s_running;
|
|
38
|
+
|
|
39
|
+
// Main-thread action queue
|
|
40
|
+
private static readonly ConcurrentQueue<Action> s_mainThreadQueue = new();
|
|
41
|
+
|
|
42
|
+
// Command router
|
|
43
|
+
private static readonly CommandRouter s_router = new();
|
|
44
|
+
|
|
45
|
+
// Log subscribers
|
|
46
|
+
private static readonly HashSet<WebSocket> s_logSubscribers = new();
|
|
47
|
+
|
|
48
|
+
static BridgeServer()
|
|
49
|
+
{
|
|
50
|
+
// Use delayCall + update fallback to ensure reliable startup after domain reload
|
|
51
|
+
EditorApplication.delayCall += Initialize;
|
|
52
|
+
EditorApplication.update += EnsureRunning;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private static void EnsureRunning()
|
|
56
|
+
{
|
|
57
|
+
if (!s_running)
|
|
58
|
+
{
|
|
59
|
+
EditorApplication.update -= EnsureRunning;
|
|
60
|
+
Initialize();
|
|
61
|
+
}
|
|
62
|
+
else
|
|
63
|
+
{
|
|
64
|
+
EditorApplication.update -= EnsureRunning;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private static void Initialize()
|
|
69
|
+
{
|
|
70
|
+
if (s_running) return;
|
|
71
|
+
|
|
72
|
+
try
|
|
73
|
+
{
|
|
74
|
+
RegisterHandlers();
|
|
75
|
+
|
|
76
|
+
EditorApplication.update += PumpMainThread;
|
|
77
|
+
EditorApplication.quitting += Shutdown;
|
|
78
|
+
AssemblyReloadEvents.beforeAssemblyReload += Shutdown;
|
|
79
|
+
Application.logMessageReceived += OnLogMessage;
|
|
80
|
+
|
|
81
|
+
s_token = Guid.NewGuid().ToString("N").Substring(0, 16);
|
|
82
|
+
StartServer();
|
|
83
|
+
}
|
|
84
|
+
catch (Exception ex)
|
|
85
|
+
{
|
|
86
|
+
Debug.LogError($"[UCP] Failed to initialize bridge: {ex}");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private static void RegisterHandlers()
|
|
91
|
+
{
|
|
92
|
+
// Handshake
|
|
93
|
+
s_router.Register("handshake", (paramsJson) =>
|
|
94
|
+
{
|
|
95
|
+
return new
|
|
96
|
+
{
|
|
97
|
+
serverVersion = ProtocolVersion,
|
|
98
|
+
protocolVersion = ProtocolVersion,
|
|
99
|
+
unityVersion = Application.unityVersion,
|
|
100
|
+
projectName = Application.productName,
|
|
101
|
+
projectPath = Path.GetDirectoryName(Application.dataPath)
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Play mode
|
|
106
|
+
PlayModeController.Register(s_router);
|
|
107
|
+
|
|
108
|
+
// Compilation
|
|
109
|
+
CompilationController.Register(s_router);
|
|
110
|
+
|
|
111
|
+
// Scenes
|
|
112
|
+
SceneController.Register(s_router);
|
|
113
|
+
|
|
114
|
+
// Snapshots
|
|
115
|
+
SnapshotController.Register(s_router);
|
|
116
|
+
|
|
117
|
+
// Screenshots
|
|
118
|
+
ScreenshotController.Register(s_router);
|
|
119
|
+
|
|
120
|
+
// Logs
|
|
121
|
+
LogsController.Register(s_router);
|
|
122
|
+
|
|
123
|
+
// Tests
|
|
124
|
+
TestRunnerController.Register(s_router);
|
|
125
|
+
|
|
126
|
+
// Scripts (exec)
|
|
127
|
+
ScriptController.Register(s_router);
|
|
128
|
+
|
|
129
|
+
// Files
|
|
130
|
+
FileController.Register(s_router);
|
|
131
|
+
|
|
132
|
+
// Version Control (Unity VCS / Plastic SCM)
|
|
133
|
+
VcsController.Register(s_router);
|
|
134
|
+
|
|
135
|
+
// Object Properties
|
|
136
|
+
PropertyController.Register(s_router);
|
|
137
|
+
|
|
138
|
+
// Hierarchy Operations
|
|
139
|
+
HierarchyController.Register(s_router);
|
|
140
|
+
|
|
141
|
+
// Asset Management
|
|
142
|
+
AssetController.Register(s_router);
|
|
143
|
+
|
|
144
|
+
// Editor Settings (Player, Quality, Physics, Lighting, Tags/Layers)
|
|
145
|
+
EditorSettingsController.Register(s_router);
|
|
146
|
+
|
|
147
|
+
// Material Properties
|
|
148
|
+
MaterialController.Register(s_router);
|
|
149
|
+
|
|
150
|
+
// Prefab Operations
|
|
151
|
+
PrefabController.Register(s_router);
|
|
152
|
+
|
|
153
|
+
// Build Pipeline
|
|
154
|
+
BuildController.Register(s_router);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private static void StartServer()
|
|
158
|
+
{
|
|
159
|
+
s_cts = new CancellationTokenSource();
|
|
160
|
+
|
|
161
|
+
for (int port = DefaultPort; port <= MaxPort; port++)
|
|
162
|
+
{
|
|
163
|
+
try
|
|
164
|
+
{
|
|
165
|
+
s_listener = new TcpListener(IPAddress.Loopback, port);
|
|
166
|
+
s_listener.Start();
|
|
167
|
+
s_port = port;
|
|
168
|
+
s_running = true;
|
|
169
|
+
Debug.Log($"[UCP] Bridge server started on port {port}");
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
catch (Exception)
|
|
173
|
+
{
|
|
174
|
+
s_listener?.Stop();
|
|
175
|
+
s_listener = null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!s_running)
|
|
180
|
+
{
|
|
181
|
+
Debug.LogError("[UCP] Failed to start bridge server — all ports in use");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
WriteLockFile();
|
|
186
|
+
Task.Run(() => AcceptLoop(s_cts.Token));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private static async Task AcceptLoop(CancellationToken ct)
|
|
190
|
+
{
|
|
191
|
+
while (!ct.IsCancellationRequested && s_running)
|
|
192
|
+
{
|
|
193
|
+
try
|
|
194
|
+
{
|
|
195
|
+
var tcp = await s_listener.AcceptTcpClientAsync();
|
|
196
|
+
var stream = tcp.GetStream();
|
|
197
|
+
|
|
198
|
+
// Read HTTP upgrade request (may arrive in multiple segments)
|
|
199
|
+
var requestBuilder = new StringBuilder();
|
|
200
|
+
var buffer = new byte[4096];
|
|
201
|
+
do
|
|
202
|
+
{
|
|
203
|
+
int read = await stream.ReadAsync(buffer, 0, buffer.Length, ct);
|
|
204
|
+
if (read == 0) { tcp.Close(); continue; }
|
|
205
|
+
requestBuilder.Append(Encoding.UTF8.GetString(buffer, 0, read));
|
|
206
|
+
}
|
|
207
|
+
while (!requestBuilder.ToString().Contains("\r\n\r\n") && stream.DataAvailable);
|
|
208
|
+
|
|
209
|
+
var request = requestBuilder.ToString();
|
|
210
|
+
|
|
211
|
+
// Check if it's a WebSocket upgrade
|
|
212
|
+
if (!request.Contains("Upgrade: websocket", StringComparison.OrdinalIgnoreCase))
|
|
213
|
+
{
|
|
214
|
+
var resp = "HTTP/1.1 426 Upgrade Required\r\nContent-Length: 0\r\n\r\n";
|
|
215
|
+
var respBytes = Encoding.UTF8.GetBytes(resp);
|
|
216
|
+
await stream.WriteAsync(respBytes, 0, respBytes.Length, ct);
|
|
217
|
+
tcp.Close();
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Extract Sec-WebSocket-Key
|
|
222
|
+
var keyMatch = Regex.Match(request, @"Sec-WebSocket-Key:\s*(\S+)",
|
|
223
|
+
RegexOptions.IgnoreCase);
|
|
224
|
+
if (!keyMatch.Success)
|
|
225
|
+
{
|
|
226
|
+
Debug.LogError("[UCP] No Sec-WebSocket-Key found in request");
|
|
227
|
+
tcp.Close();
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
var wsKey = keyMatch.Groups[1].Value.Trim();
|
|
232
|
+
var acceptKey = ComputeWebSocketAcceptKey(wsKey);
|
|
233
|
+
|
|
234
|
+
// Send upgrade response
|
|
235
|
+
var upgradeResponse =
|
|
236
|
+
"HTTP/1.1 101 Switching Protocols\r\n" +
|
|
237
|
+
"Upgrade: websocket\r\n" +
|
|
238
|
+
"Connection: Upgrade\r\n" +
|
|
239
|
+
$"Sec-WebSocket-Accept: {acceptKey}\r\n\r\n";
|
|
240
|
+
var upgradeBytes = Encoding.UTF8.GetBytes(upgradeResponse);
|
|
241
|
+
await stream.WriteAsync(upgradeBytes, 0, upgradeBytes.Length, ct);
|
|
242
|
+
|
|
243
|
+
// Create WebSocket from stream
|
|
244
|
+
var ws = WebSocket.CreateFromStream(stream, true, null, TimeSpan.FromSeconds(30));
|
|
245
|
+
|
|
246
|
+
lock (s_clientLock)
|
|
247
|
+
{
|
|
248
|
+
if (s_clients.Count >= MaxConnections)
|
|
249
|
+
{
|
|
250
|
+
_ = ws.CloseAsync(WebSocketCloseStatus.PolicyViolation,
|
|
251
|
+
"Max connections reached", CancellationToken.None);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
s_clients.Add(ws);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
_ = Task.Run(() => HandleClient(ws, ct));
|
|
258
|
+
}
|
|
259
|
+
catch (ObjectDisposedException) { break; }
|
|
260
|
+
catch (SocketException) { break; }
|
|
261
|
+
catch (Exception ex)
|
|
262
|
+
{
|
|
263
|
+
if (!ct.IsCancellationRequested)
|
|
264
|
+
Debug.LogError($"[UCP] Accept error: {ex.Message}");
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private static string ComputeWebSocketAcceptKey(string key)
|
|
270
|
+
{
|
|
271
|
+
const string magic = "258EAFA5-E914-47DA-95CA-5AB5DC85B11B";
|
|
272
|
+
var combined = key + magic;
|
|
273
|
+
var hash = SHA1.Create().ComputeHash(Encoding.UTF8.GetBytes(combined));
|
|
274
|
+
return Convert.ToBase64String(hash);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private static async Task HandleClient(WebSocket ws, CancellationToken ct)
|
|
278
|
+
{
|
|
279
|
+
var buffer = new byte[64 * 1024]; // 64KB buffer
|
|
280
|
+
|
|
281
|
+
try
|
|
282
|
+
{
|
|
283
|
+
while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested)
|
|
284
|
+
{
|
|
285
|
+
var segment = new ArraySegment<byte>(buffer);
|
|
286
|
+
WebSocketReceiveResult received;
|
|
287
|
+
var messageBuilder = new StringBuilder();
|
|
288
|
+
|
|
289
|
+
do
|
|
290
|
+
{
|
|
291
|
+
received = await ws.ReceiveAsync(segment, ct);
|
|
292
|
+
if (received.MessageType == WebSocketMessageType.Close)
|
|
293
|
+
{
|
|
294
|
+
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
messageBuilder.Append(Encoding.UTF8.GetString(buffer, 0, received.Count));
|
|
298
|
+
}
|
|
299
|
+
while (!received.EndOfMessage);
|
|
300
|
+
|
|
301
|
+
var message = messageBuilder.ToString();
|
|
302
|
+
ProcessMessage(ws, message);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
catch (OperationCanceledException) { }
|
|
306
|
+
catch (WebSocketException) { }
|
|
307
|
+
catch (Exception ex)
|
|
308
|
+
{
|
|
309
|
+
Debug.LogError($"[UCP] Client error: {ex.Message}");
|
|
310
|
+
}
|
|
311
|
+
finally
|
|
312
|
+
{
|
|
313
|
+
lock (s_clientLock)
|
|
314
|
+
{
|
|
315
|
+
s_clients.Remove(ws);
|
|
316
|
+
s_logSubscribers.Remove(ws);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private static void ProcessMessage(WebSocket ws, string message)
|
|
322
|
+
{
|
|
323
|
+
// Parse JSON-RPC request
|
|
324
|
+
long id = 0;
|
|
325
|
+
string method = null;
|
|
326
|
+
string paramsJson = "{}";
|
|
327
|
+
|
|
328
|
+
try
|
|
329
|
+
{
|
|
330
|
+
// Minimal JSON parsing using Unity's JsonUtility won't work well for dynamic JSON.
|
|
331
|
+
// Use a simple manual parse for the top-level fields.
|
|
332
|
+
var json = MiniJson.Deserialize(message) as Dictionary<string, object>;
|
|
333
|
+
if (json == null)
|
|
334
|
+
{
|
|
335
|
+
SendError(ws, 0, ErrorCodes.ParseError, "Invalid JSON");
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (json.TryGetValue("id", out var idVal))
|
|
340
|
+
id = Convert.ToInt64(idVal);
|
|
341
|
+
|
|
342
|
+
if (json.TryGetValue("method", out var methodVal))
|
|
343
|
+
method = methodVal as string;
|
|
344
|
+
|
|
345
|
+
if (json.TryGetValue("params", out var paramsVal))
|
|
346
|
+
paramsJson = MiniJson.Serialize(paramsVal);
|
|
347
|
+
}
|
|
348
|
+
catch (Exception ex)
|
|
349
|
+
{
|
|
350
|
+
SendError(ws, 0, ErrorCodes.ParseError, $"Parse error: {ex.Message}");
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (string.IsNullOrEmpty(method))
|
|
355
|
+
{
|
|
356
|
+
SendError(ws, id, ErrorCodes.InvalidRequest, "Missing 'method' field");
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Handle log subscription specially
|
|
361
|
+
if (method == "logs/subscribe")
|
|
362
|
+
{
|
|
363
|
+
lock (s_clientLock) { s_logSubscribers.Add(ws); }
|
|
364
|
+
}
|
|
365
|
+
else if (method == "logs/unsubscribe")
|
|
366
|
+
{
|
|
367
|
+
lock (s_clientLock) { s_logSubscribers.Remove(ws); }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Dispatch on main thread
|
|
371
|
+
var capturedId = id;
|
|
372
|
+
var capturedMethod = method;
|
|
373
|
+
var capturedParams = paramsJson;
|
|
374
|
+
var capturedWs = ws;
|
|
375
|
+
|
|
376
|
+
s_mainThreadQueue.Enqueue(() =>
|
|
377
|
+
{
|
|
378
|
+
var response = s_router.Dispatch(capturedMethod, capturedId, capturedParams);
|
|
379
|
+
SendResponse(capturedWs, response);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private static void SendResponse(WebSocket ws, JsonRpcResponse response)
|
|
384
|
+
{
|
|
385
|
+
try
|
|
386
|
+
{
|
|
387
|
+
var json = MiniJson.Serialize(ResponseToDict(response));
|
|
388
|
+
var bytes = Encoding.UTF8.GetBytes(json);
|
|
389
|
+
_ = ws.SendAsync(new ArraySegment<byte>(bytes),
|
|
390
|
+
WebSocketMessageType.Text, true, CancellationToken.None);
|
|
391
|
+
}
|
|
392
|
+
catch (Exception ex)
|
|
393
|
+
{
|
|
394
|
+
Debug.LogError($"[UCP] Send error: {ex.Message}");
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private static void SendError(WebSocket ws, long id, int code, string message)
|
|
399
|
+
{
|
|
400
|
+
SendResponse(ws, JsonRpcResponse.Error(id, code, message));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/// <summary>
|
|
404
|
+
/// Send a notification to log subscribers only.
|
|
405
|
+
/// </summary>
|
|
406
|
+
public static void SendNotification(string method, object data)
|
|
407
|
+
{
|
|
408
|
+
SendNotificationTo(method, data, false);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/// <summary>
|
|
412
|
+
/// Broadcast a notification to ALL connected clients.
|
|
413
|
+
/// Use for test results and other non-log notifications.
|
|
414
|
+
/// </summary>
|
|
415
|
+
public static void BroadcastNotification(string method, object data)
|
|
416
|
+
{
|
|
417
|
+
SendNotificationTo(method, data, true);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private static void SendNotificationTo(string method, object data, bool toAll)
|
|
421
|
+
{
|
|
422
|
+
var dict = new Dictionary<string, object>
|
|
423
|
+
{
|
|
424
|
+
["jsonrpc"] = "2.0",
|
|
425
|
+
["method"] = method,
|
|
426
|
+
["params"] = data
|
|
427
|
+
};
|
|
428
|
+
var json = MiniJson.Serialize(dict);
|
|
429
|
+
var bytes = Encoding.UTF8.GetBytes(json);
|
|
430
|
+
var segment = new ArraySegment<byte>(bytes);
|
|
431
|
+
|
|
432
|
+
List<WebSocket> targets;
|
|
433
|
+
lock (s_clientLock)
|
|
434
|
+
{
|
|
435
|
+
targets = toAll
|
|
436
|
+
? new List<WebSocket>(s_clients)
|
|
437
|
+
: new List<WebSocket>(s_logSubscribers);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
foreach (var ws in targets)
|
|
441
|
+
{
|
|
442
|
+
try
|
|
443
|
+
{
|
|
444
|
+
if (ws.State == WebSocketState.Open)
|
|
445
|
+
{
|
|
446
|
+
_ = ws.SendAsync(segment, WebSocketMessageType.Text, true, CancellationToken.None);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
catch { /* ignore send failures for notifications */ }
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private static void OnLogMessage(string message, string stackTrace, LogType type)
|
|
454
|
+
{
|
|
455
|
+
// Don't forward our own log messages to avoid infinite recursion
|
|
456
|
+
if (message.StartsWith("[UCP]")) return;
|
|
457
|
+
|
|
458
|
+
SendNotification("log", LogsController.RecordLog(message, stackTrace, type));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private static void PumpMainThread()
|
|
462
|
+
{
|
|
463
|
+
int processed = 0;
|
|
464
|
+
while (s_mainThreadQueue.TryDequeue(out var action) && processed < 50)
|
|
465
|
+
{
|
|
466
|
+
try { action(); }
|
|
467
|
+
catch (Exception ex) { Debug.LogError($"[UCP] Main thread error: {ex}"); }
|
|
468
|
+
processed++;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private static void WriteLockFile()
|
|
473
|
+
{
|
|
474
|
+
try
|
|
475
|
+
{
|
|
476
|
+
var projectPath = Path.GetDirectoryName(Application.dataPath);
|
|
477
|
+
var ucpDir = Path.Combine(projectPath, ".ucp");
|
|
478
|
+
Directory.CreateDirectory(ucpDir);
|
|
479
|
+
|
|
480
|
+
var lockData = new Dictionary<string, object>
|
|
481
|
+
{
|
|
482
|
+
["pid"] = System.Diagnostics.Process.GetCurrentProcess().Id,
|
|
483
|
+
["port"] = s_port,
|
|
484
|
+
["protocolVersion"] = ProtocolVersion,
|
|
485
|
+
["unityVersion"] = Application.unityVersion,
|
|
486
|
+
["projectPath"] = projectPath,
|
|
487
|
+
["startedAt"] = DateTime.UtcNow.ToString("o"),
|
|
488
|
+
["token"] = s_token
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
File.WriteAllText(
|
|
492
|
+
Path.Combine(ucpDir, "bridge.lock"),
|
|
493
|
+
MiniJson.Serialize(lockData)
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
catch (Exception ex)
|
|
497
|
+
{
|
|
498
|
+
Debug.LogError($"[UCP] Failed to write lock file: {ex.Message}");
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private static void CleanLockFile()
|
|
503
|
+
{
|
|
504
|
+
try
|
|
505
|
+
{
|
|
506
|
+
var projectPath = Path.GetDirectoryName(Application.dataPath);
|
|
507
|
+
var lockPath = Path.Combine(projectPath, ".ucp", "bridge.lock");
|
|
508
|
+
if (File.Exists(lockPath))
|
|
509
|
+
File.Delete(lockPath);
|
|
510
|
+
}
|
|
511
|
+
catch { }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private static void Shutdown()
|
|
515
|
+
{
|
|
516
|
+
if (!s_running) return;
|
|
517
|
+
s_running = false;
|
|
518
|
+
|
|
519
|
+
Debug.Log("[UCP] Bridge server shutting down");
|
|
520
|
+
|
|
521
|
+
s_cts?.Cancel();
|
|
522
|
+
|
|
523
|
+
// Stop listener first to release port immediately
|
|
524
|
+
try { s_listener?.Stop(); }
|
|
525
|
+
catch { }
|
|
526
|
+
s_listener = null;
|
|
527
|
+
|
|
528
|
+
lock (s_clientLock)
|
|
529
|
+
{
|
|
530
|
+
foreach (var ws in s_clients)
|
|
531
|
+
{
|
|
532
|
+
try { ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Shutting down", CancellationToken.None); }
|
|
533
|
+
catch { }
|
|
534
|
+
}
|
|
535
|
+
s_clients.Clear();
|
|
536
|
+
s_logSubscribers.Clear();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
s_cts?.Dispose();
|
|
540
|
+
s_cts = null;
|
|
541
|
+
|
|
542
|
+
CleanLockFile();
|
|
543
|
+
|
|
544
|
+
EditorApplication.update -= PumpMainThread;
|
|
545
|
+
Application.logMessageReceived -= OnLogMessage;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private static Dictionary<string, object> ResponseToDict(JsonRpcResponse r)
|
|
549
|
+
{
|
|
550
|
+
var dict = new Dictionary<string, object>
|
|
551
|
+
{
|
|
552
|
+
["jsonrpc"] = "2.0",
|
|
553
|
+
["id"] = r.id
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
if (r.error != null)
|
|
557
|
+
{
|
|
558
|
+
dict["error"] = new Dictionary<string, object>
|
|
559
|
+
{
|
|
560
|
+
["code"] = r.error.code,
|
|
561
|
+
["message"] = r.error.message,
|
|
562
|
+
["data"] = r.error.data
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
else
|
|
566
|
+
{
|
|
567
|
+
dict["result"] = r.result;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return dict;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|