@walkhi/code-relax 0.1.0-beta.10 → 0.1.0-beta.12

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.
Files changed (88) hide show
  1. package/README.md +44 -54
  2. package/dist/bin/codex-remote.mjs +34 -60
  3. package/dist/bin/migrate-relay-db.mjs +104 -0
  4. package/dist/bin/self-relay-server.mjs +17 -10
  5. package/dist/shared/app-server-events.cjs +15 -3
  6. package/dist/shared/file-mentions.cjs +35 -0
  7. package/dist/shared/server-requests.cjs +86 -58
  8. package/dist/skills/code-relax/SKILL.md +1 -1
  9. package/dist/src/desktop-launcher.mjs +6 -11
  10. package/dist/src/file-upload-session.mjs +124 -0
  11. package/dist/src/lan-socket-server.mjs +51 -8
  12. package/dist/src/managed-app-server.mjs +21 -10
  13. package/dist/src/onboarding.mjs +3 -3
  14. package/dist/src/platform/windows/codex-runtime.mjs +138 -4
  15. package/dist/src/platform/windows/desktop-host.mjs +1 -2
  16. package/dist/src/platform/windows/desktop-runtime.ps1 +10 -0
  17. package/dist/src/platform/windows/desktop-shortcuts.ps1 +6 -13
  18. package/dist/src/platform/windows/resident-start.js +8 -0
  19. package/dist/src/platform/windows/resident-startup.ps1 +6 -3
  20. package/dist/src/platform/windows/service-host.mjs +0 -9
  21. package/dist/src/self-relay/admin-page.mjs +1 -1
  22. package/dist/src/self-relay/admin-state.mjs +8 -6
  23. package/dist/src/self-relay/connector.mjs +84 -12
  24. package/dist/src/self-relay/email-accounts.mjs +125 -0
  25. package/dist/src/self-relay/lifecycle.mjs +52 -100
  26. package/dist/src/self-relay/mysql-state.mjs +104 -0
  27. package/dist/src/self-relay/p2p-probe.mjs +1 -1
  28. package/dist/src/self-relay/server.mjs +155 -38
  29. package/dist/src/server.mjs +736 -610
  30. package/dist/src/service-doctor.mjs +1 -1
  31. package/dist/src/service-install.mjs +1 -4
  32. package/dist/src/shared-app-server.mjs +274 -272
  33. package/dist/src/shared-catalog.mjs +1 -11
  34. package/dist/src/shared-recovery.mjs +5 -5
  35. package/dist/src/skill-install.mjs +0 -13
  36. package/package.json +2 -1
  37. package/dist/src/debug-ui.mjs +0 -131
  38. package/dist/src/platform/windows/desktop-shortcut-run.ps1 +0 -5
  39. package/dist/web/activity-view.js +0 -283
  40. package/dist/web/approval-ui.js +0 -188
  41. package/dist/web/capabilities.js +0 -50
  42. package/dist/web/chat-transport.js +0 -196
  43. package/dist/web/chat.css +0 -778
  44. package/dist/web/chat.js +0 -3815
  45. package/dist/web/client-platform.js +0 -11
  46. package/dist/web/community.css +0 -82
  47. package/dist/web/community.html +0 -36
  48. package/dist/web/composer-controller.js +0 -97
  49. package/dist/web/conversation-controller.js +0 -99
  50. package/dist/web/disclosure-state-controller.js +0 -95
  51. package/dist/web/draft-controller.js +0 -103
  52. package/dist/web/harmony-platform.js +0 -52
  53. package/dist/web/highlight-powershell.min.js +0 -39
  54. package/dist/web/highlight.min.js +0 -1262
  55. package/dist/web/history-cache.js +0 -240
  56. package/dist/web/history-controller.js +0 -167
  57. package/dist/web/index.html +0 -288
  58. package/dist/web/lan-session.js +0 -122
  59. package/dist/web/link-action-controller.js +0 -212
  60. package/dist/web/message-send-controller.js +0 -177
  61. package/dist/web/message-view.js +0 -98
  62. package/dist/web/p2p-data-channel.js +0 -50
  63. package/dist/web/p2p-diagnostics.js +0 -28
  64. package/dist/web/p2p-probe.js +0 -123
  65. package/dist/web/page-resume.js +0 -28
  66. package/dist/web/pending-message-store.js +0 -108
  67. package/dist/web/queue-controller.js +0 -82
  68. package/dist/web/resources.json +0 -1
  69. package/dist/web/self-relay-session.js +0 -129
  70. package/dist/web/server-requests.js +0 -67
  71. package/dist/web/station-connection-controller.js +0 -75
  72. package/dist/web/task-list-view.js +0 -289
  73. package/dist/web/thread-attention-controller.js +0 -124
  74. package/dist/web/thread-context-controller.js +0 -30
  75. package/dist/web/thread-list-controller.js +0 -61
  76. package/dist/web/thread-list-sync.js +0 -86
  77. package/dist/web/thread-title-controller.js +0 -57
  78. package/dist/web/timeline-formatters.js +0 -249
  79. package/dist/web/timeline-reducer.js +0 -77
  80. package/dist/web/timeline-renderer.js +0 -161
  81. package/dist/web/timeline-scroll-controller.js +0 -50
  82. package/dist/web/timeline-sync.js +0 -67
  83. package/dist/web/usage-controller.js +0 -258
  84. package/dist/web/vendor/README.md +0 -14
  85. package/dist/web/vendor/highlight-powershell.min.js +0 -39
  86. package/dist/web/vendor/highlight.LICENSE.txt +0 -29
  87. package/dist/web/vendor/highlight.min.js +0 -1262
  88. package/dist/web/vendor/lucide.LICENSE.txt +0 -17
@@ -1,4 +1,5 @@
1
- import http from 'node:http';
1
+ import http from 'node:http';
2
+ import { isIP } from 'node:net';
2
3
  import { deviceStore, validId } from './device-store.mjs';
3
4
  import { randomUUID } from 'node:crypto';
4
5
  import { WebSocketServer } from 'ws';
@@ -6,12 +7,14 @@ import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
6
7
  import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
7
8
  import { validProbeSignal } from './p2p-probe.mjs';
8
9
  import { relayAdminState } from './admin-state.mjs';
10
+ import { openRelayMysql } from './mysql-state.mjs';
9
11
  import { adminPage } from './admin-page.mjs';
12
+ import { openEmailAccounts } from './email-accounts.mjs';
10
13
 
11
14
  // Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
12
15
  export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
13
16
  maxConnections = 32, maxClientsPerHost = 16, maxPayload = 8 * 1024 * 1024, stateFile,
14
- maxRelaySessions = 8, adminStateFile, publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
17
+ maxRelaySessions = 8, adminStateFile, mysqlConfigFile, accountConfigFile, publicRelayUrl = '', appDownloadUrl = '', debugAllowlistFile, log = () => {} } = {}) {
15
18
  if (!validSecret(hostKey)) throw new Error('Invalid host credential');
16
19
  if (!Number.isSafeInteger(maxClientsPerHost) || maxClientsPerHost < 1 || maxClientsPerHost > 64) throw new Error('Invalid mobile device limit');
17
20
  if (!Number.isSafeInteger(maxRelaySessions) || maxRelaySessions < 1 || maxRelaySessions > 256) throw new Error('Invalid relay session limit');
@@ -21,24 +24,34 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
21
24
  const nativeOrigins = publicRelayUrl ? ['http:', 'https:'].map(protocol => `${protocol}//${new URL(publicRelayUrl).host}`) : [];
22
25
  if (appDownloadUrl && !/^https:\/\/[^\s]+$/.test(appDownloadUrl)) throw new Error('Invalid app download URL');
23
26
  const hostHash = digest(hostKey), rooms = new Map();
24
- const store = deviceStore(stateFile);
27
+ const database = mysqlConfigFile ? await openRelayMysql(mysqlConfigFile, maxRelaySessions) : null;
28
+ if (accountConfigFile && !database?.schemaVersions.includes('002_email_accounts')) throw new Error('Email account schema is required');
29
+ const accounts = accountConfigFile ? openEmailAccounts(database.pool, accountConfigFile) : null;
30
+ const store = database?.devices || deviceStore(stateFile);
25
31
  const debugAllowlist = readDebugAllowlist(debugAllowlistFile);
26
- const adminState = relayAdminState(adminStateFile, maxRelaySessions);
32
+ const adminState = database?.admin || relayAdminState(adminStateFile, maxRelaySessions);
27
33
  let activeRelaySessions = 0;
28
- for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map() });
34
+ for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map(), uploadClients: new Map() });
35
+ let commitTail = Promise.resolve();
29
36
  function commit(room, changes) {
37
+ const operation = commitTail.then(async () => {
38
+ changes = typeof changes === 'function' ? changes(room) : changes;
30
39
  const next = { ...room, ...changes, updatedAt: new Date().toISOString() };
31
40
  if (room.durable) {
32
41
  const { id, hostHash, clients, revision, status, createdAt, updatedAt, officialComputer } = next;
33
- store.save({ id, hostHash, clients, revision, status, createdAt, updatedAt, ...(officialComputer ? { officialComputer } : {}) });
42
+ await store.save({ id, hostHash, clients, revision, status, createdAt, updatedAt, ...(officialComputer ? { officialComputer } : {}) });
34
43
  }
35
- Object.assign(room, changes, { updatedAt: next.updatedAt });
44
+ Object.assign(room, changes, { updatedAt: next.updatedAt });
45
+ });
46
+ commitTail = operation.catch(() => {});
47
+ return operation;
36
48
  }
37
49
  function invitation(room) {
38
50
  const code = secret(); room.pairingHash = digest(code); room.expires = Date.now() + pairingTtlMs;
39
51
  return { code, expires: room.expires };
40
52
  }
41
53
  let listenUrl = '';
54
+ let adminTail = Promise.resolve();
42
55
  function relayUrl(request) {
43
56
  if (publicRelayUrl) return relayPublicAddress(publicRelayUrl);
44
57
  const host = request.headers.host || new URL(listenUrl).host;
@@ -52,7 +65,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
52
65
  const server = http.createServer((request, response) => {
53
66
  const target = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
54
67
  if (target.pathname === '/admin' || target.pathname.startsWith('/admin/')) {
55
- void handleAdmin(request, response, target); return;
68
+ adminTail = adminTail.then(() => handleAdmin(request, response, target)).catch(() => {
69
+ if (!response.headersSent) adminReply(response, 500, { error: 'Admin operation failed' });
70
+ });
71
+ return;
72
+ }
73
+ if (accounts && target.pathname.startsWith('/account/')) {
74
+ void handleAccount(request, response, target).catch(() => accountReply(response, 500, { error: 'Account operation failed' }));
75
+ return;
56
76
  }
57
77
  if (request.method === 'GET' && target.pathname === '/announcement') {
58
78
  const body = JSON.stringify({ announcement: adminState.read().announcement });
@@ -83,6 +103,41 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
83
103
  'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'" });
84
104
  response.end(value);
85
105
  }
106
+ function accountReply(response, status, body) {
107
+ if (response.headersSent || response.destroyed) return;
108
+ const value = JSON.stringify(body);
109
+ response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(value),
110
+ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
111
+ response.end(value);
112
+ }
113
+ async function handleAccount(request, response, target) {
114
+ try {
115
+ if (target.pathname === '/account/config' && request.method === 'GET') {
116
+ accountReply(response, 200, { captchaAppId: accounts.captchaAppId }); return;
117
+ }
118
+ const bearer = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
119
+ if (target.pathname === '/account/me' && request.method === 'GET') {
120
+ accountReply(response, 200, { user: await accounts.current(bearer) }); return;
121
+ }
122
+ if (target.pathname === '/account/logout' && request.method === 'POST') {
123
+ accountReply(response, 200, await accounts.logout(bearer)); return;
124
+ }
125
+ if (request.method !== 'POST' || request.headers['content-type'] !== 'application/json') {
126
+ accountReply(response, 404, { error: 'Account route not found' }); return;
127
+ }
128
+ const body = JSON.parse(await readBody(request, 4096));
129
+ if (!body || typeof body !== 'object' || Array.isArray(body)) { accountReply(response, 400, { error: 'Invalid request body' }); return; }
130
+ if (target.pathname === '/account/email/request') {
131
+ const ip = request.headers['x-real-ip'] || request.socket.remoteAddress || '';
132
+ if (typeof ip !== 'string' || !isIP(ip)) { accountReply(response, 400, { error: 'Invalid client address' }); return; }
133
+ accountReply(response, 202, await accounts.requestCode(body, ip));
134
+ } else if (target.pathname === '/account/email/verify') {
135
+ accountReply(response, 200, await accounts.verifyCode(body));
136
+ } else accountReply(response, 404, { error: 'Account route not found' });
137
+ } catch (failure) {
138
+ accountReply(response, failure.status || 503, { error: failure.status ? failure.message : 'Account service unavailable' });
139
+ }
140
+ }
86
141
  async function handleAdmin(request, response, target) {
87
142
  if (request.method === 'GET' && target.pathname === '/admin') {
88
143
  adminReply(response, 200, adminPage(), 'text/html; charset=utf-8'); return;
@@ -101,10 +156,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
101
156
  try {
102
157
  const body = request.method === 'DELETE' ? {} : JSON.parse(await readBody(request, 4096));
103
158
  if (target.pathname === '/admin/api/settings' && request.method === 'PUT') {
104
- const state = adminState.setMaxRelaySessions(body.maxRelaySessions);
159
+ const state = await adminState.setMaxRelaySessions(body.maxRelaySessions);
105
160
  log({ event: 'admin-relay-limit-updated', maxRelaySessions: state.maxRelaySessions });
106
161
  } else if (target.pathname === '/admin/api/announcements' && request.method === 'PUT') {
107
- const state = adminState.createAnnouncement(body.title, body.message, body.publish === true);
162
+ const state = await adminState.createAnnouncement(body.title, body.message, body.publish === true);
108
163
  if (body.publish === true) {
109
164
  broadcastAnnouncement(state.announcement);
110
165
  log({ event: 'admin-announcement-published', announcementId: state.announcement.id });
@@ -114,17 +169,17 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
114
169
  const id = parts[0];
115
170
  if (!/^[\w-]{16,128}$/.test(id)) { adminReply(response, 404, { error: 'Admin route not found' }); return; }
116
171
  if (parts.length === 1 && request.method === 'PUT') {
117
- adminState.updateDraft(id, body.title, body.message);
172
+ await adminState.updateDraft(id, body.title, body.message);
118
173
  log({ event: 'admin-announcement-draft-updated', announcementId: id });
119
174
  } else if (parts.length === 1 && request.method === 'DELETE') {
120
- adminState.deleteAnnouncement(id);
175
+ await adminState.deleteAnnouncement(id);
121
176
  log({ event: 'admin-announcement-deleted', announcementId: id });
122
177
  } else if (parts.length === 2 && parts[1] === 'publish' && request.method === 'PUT') {
123
- const state = adminState.publishDraft(id, body.title, body.message);
178
+ const state = await adminState.publishDraft(id, body.title, body.message);
124
179
  broadcastAnnouncement(state.announcement);
125
180
  log({ event: 'admin-announcement-published', announcementId: id });
126
181
  } else if (parts.length === 2 && parts[1] === 'withdraw' && request.method === 'PUT') {
127
- const state = adminState.withdraw(id);
182
+ const state = await adminState.withdraw(id);
128
183
  broadcastAnnouncement(state.announcement);
129
184
  log({ event: 'admin-announcement-withdrawn', announcementId: id });
130
185
  } else { adminReply(response, 404, { error: 'Admin route not found' }); return; }
@@ -195,6 +250,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
195
250
  clearTimeout(authTimer); clearInterval(heartbeat);
196
251
  if (role === 'host' && room?.host === ws) {
197
252
  room.host = undefined; room.hostName = undefined; room.lanCandidates = [];
253
+ room.uploadHost?.close(4410, 'Computer offline');
254
+ for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'Computer offline');
198
255
  for (const observer of room.observers.values()) send(observer.ws, { type: 'observing', online: false });
199
256
  if (!room.durable) rooms.delete(room.id);
200
257
  for (const connection of room.connections.values()) connection.ws.close(4410, 'Computer offline; authorization retained');
@@ -204,12 +261,43 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
204
261
  if (room.host) send(room.host, { type: 'detach', session });
205
262
  } else if (role === 'observer' && room?.observers.get(clientId)?.ws === ws) {
206
263
  room.observers.delete(clientId);
207
- }
264
+ } else if (role === 'file-host' && room?.uploadHost === ws) {
265
+ room.uploadHost = undefined;
266
+ for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'File receiver offline');
267
+ } else if (role === 'file-client' && room?.uploadClients?.get(clientId)?.ws === ws) {
268
+ room.uploadClients.delete(clientId);
269
+ if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session });
270
+ }
208
271
  });
209
- ws.on('message', (bytes, binary) => {
210
- try {
211
- const message = decode(bytes, binary);
212
- if (!role) {
272
+ let messageTail = Promise.resolve();
273
+ ws.on('message', (bytes, binary) => {
274
+ messageTail = messageTail.then(async () => {
275
+ try {
276
+ const message = decode(bytes, binary);
277
+ if (!role) {
278
+ if (message.type === 'file-host') {
279
+ room = rooms.get(message.deviceId);
280
+ if (!room?.host || room.status !== 'active' || !matches(message.deviceSecret, room.hostHash)) return fail();
281
+ role = 'file-host'; clearTimeout(authTimer);
282
+ room.uploadClients ||= new Map();
283
+ room.uploadHost?.close(4409, 'File receiver replaced');
284
+ room.uploadHost = ws;
285
+ send(ws, { type: 'file-host-ready' }); return;
286
+ }
287
+ if (message.type === 'file-client') {
288
+ room = rooms.get(message.room);
289
+ const client = room?.clients.find(item => item.id === message.clientId);
290
+ if (!room?.host || !room.uploadHost || room.status !== 'active' || !client ||
291
+ !matches(message.credential, client.hash)) return fail();
292
+ room.uploadClients ||= new Map();
293
+ if (room.uploadClients.has(message.clientId) ||
294
+ [...rooms.values()].reduce((sum, item) => sum + (item.uploadClients?.size || 0), 0) >= 2) {
295
+ return fail(4429, 'Too many file uploads');
296
+ }
297
+ role = 'file-client'; clientId = message.clientId; session = randomUUID(); clearTimeout(authTimer);
298
+ room.uploadClients.set(clientId, { ws, session });
299
+ send(room.uploadHost, { type: 'file-open', session }); return;
300
+ }
213
301
  if (message.type === 'host') {
214
302
  const durable = message.deviceId !== undefined;
215
303
  if (durable && (!validId(message.deviceId) || !validSecret(message.deviceSecret))) return fail();
@@ -229,13 +317,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
229
317
  if (rooms.size >= 256) return fail(4429, 'Device limit reached');
230
318
  const now = new Date().toISOString();
231
319
  room = { id: durable ? message.deviceId : randomUUID(), durable,
232
- revision: 0, status: 'active', clients: [], connections: new Map(), observers: new Map(), createdAt: now, updatedAt: now,
320
+ revision: 0, status: 'active', clients: [], connections: new Map(), observers: new Map(), uploadClients: new Map(), createdAt: now, updatedAt: now,
233
321
  ...(durable ? { hostHash: digest(message.deviceSecret) } : {}) };
234
- commit(room, {}); rooms.set(room.id, room);
322
+ await commit(room, {}); rooms.set(room.id, room);
235
323
  }
236
324
  const oldHost = room.host;
237
325
  room.observers ||= new Map();
238
326
  room.host = ws; room.hostName = hostName; room.lanCandidates = lanCandidates;
327
+ if (oldHost && oldHost !== ws) room.uploadHost?.close(4409, 'Computer connection replaced');
239
328
  room.p2pProbe = message.p2pProbe === 1; room.p2pProbeRepeat = message.p2pProbeRepeat === true;
240
329
  role = 'host'; clearTimeout(authTimer);
241
330
  if (oldHost && oldHost !== ws) {
@@ -254,7 +343,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
254
343
  room = rooms.get(message.room);
255
344
  if (!room || room.status !== 'active') return fail();
256
345
  let credential;
257
- if (message.type === 'pair' && room.pairingHash && Date.now() < room.expires && matches(message.code, room.pairingHash)) {
346
+ if (message.type === 'pair' && !room.pairingInProgress && room.pairingHash && Date.now() < room.expires && matches(message.code, room.pairingHash)) {
258
347
  if (!room.host) return fail(4410, 'Computer offline; authorization retained');
259
348
  credential = message.credential || secret();
260
349
  clientId = message.clientId || (!room.durable ? randomUUID() : undefined);
@@ -263,8 +352,11 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
263
352
  if (!existing && room.clients.length >= maxClientsPerHost) return fail(4429, 'Mobile device limit reached');
264
353
  const now = new Date().toISOString();
265
354
  const client = { id: clientId, hash: digest(credential), createdAt: existing?.createdAt || now, updatedAt: now };
266
- commit(room, { clients: [...room.clients.filter(item => item.id !== clientId), client], revision: room.revision + 1 });
267
- room.pairingHash = undefined;
355
+ room.pairingInProgress = true;
356
+ try {
357
+ await commit(room, current => ({ clients: [...current.clients.filter(item => item.id !== clientId), client], revision: current.revision + 1 }));
358
+ room.pairingHash = undefined;
359
+ } finally { room.pairingInProgress = false; }
268
360
  send(room.host, { type: 'authorization-changed', clients: room.clients, revision: room.revision });
269
361
  } else {
270
362
  clientId = message.clientId;
@@ -323,7 +415,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
323
415
  send(ws, { type: 'pair-code', commandId: message.commandId, room: room.id, ...invitation(room) }); return;
324
416
  }
325
417
  if (message.type === 'revoke') {
326
- commit(room, { clients: [], revision: room.revision + 1 });
418
+ await commit(room, current => ({ clients: [], revision: current.revision + 1 }));
327
419
  room.pairingHash = undefined;
328
420
  for (const connection of room.connections.values()) {
329
421
  releaseRelay(connection);
@@ -331,6 +423,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
331
423
  connection.ws.close(4401, 'Device authorization revoked');
332
424
  }
333
425
  room.connections.clear();
426
+ for (const client of room.uploadClients?.values() || []) client.ws.close(4401, 'Device authorization revoked');
427
+ room.uploadClients?.clear();
334
428
  for (const observer of room.observers.values()) observer.ws.close(4401, 'Device authorization revoked');
335
429
  room.observers.clear();
336
430
  send(ws, { type: 'authorization-changed', clients: [], revision: room.revision });
@@ -342,28 +436,49 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
342
436
  else if (message.type === 'ready') queueClientMessage(connection, { type: 'ready', p2pProbe: connection.p2pProbe ? 1 : 0,
343
437
  p2pProbeRepeat: connection.p2pProbe && connection.repeat });
344
438
  else if (message.type === 'p2p-probe' && connection.p2pProbe && validProbeSignal(message)) {
345
- if (connection.probeId !== message.probeId) return;
346
- if (++connection.signals > 150) return fail(1008, 'Probe signal limit');
439
+ const diagnostic = message.purpose === 'diagnostic';
440
+ if (message.purpose && !diagnostic) return fail(1008, 'Invalid probe purpose');
441
+ if ((diagnostic ? connection.diagnosticProbeId : connection.probeId) !== message.probeId) return;
442
+ if (++connection[diagnostic ? 'diagnosticSignals' : 'signals'] > 150) return fail(1008, 'Probe signal limit');
347
443
  send(connection.ws, { ...message, session: undefined });
348
444
  }
349
445
  else if (message.type === 'detach') connection.ws.close(1011, 'Local Bridge unavailable');
350
446
  else fail();
447
+ } else if (role === 'file-host' && room.uploadHost === ws) {
448
+ const client = [...room.uploadClients.values()].find(item => item.session === message.session);
449
+ if (!client) return;
450
+ if (message.type === 'file-frame' && typeof message.payload === 'string' && message.payload.length <= 512 * 1024) {
451
+ if (client.ws.bufferedAmount > 1024 * 1024) return client.ws.close(1013, 'File receiver too slow');
452
+ send(client.ws, { type: 'file-frame', payload: message.payload });
453
+ } else if (message.type === 'file-close') client.ws.close(1011, 'File receiver unavailable');
454
+ else fail(1008, 'Invalid file frame');
455
+ } else if (role === 'file-client' && room.uploadClients?.get(clientId)?.ws === ws) {
456
+ if (message.type !== 'file-frame' || typeof message.payload !== 'string' || message.payload.length > 512 * 1024) return fail(1008, 'Invalid file frame');
457
+ if (room.uploadHost?.bufferedAmount > 1024 * 1024) return fail(1013, 'File receiver too slow');
458
+ send(room.uploadHost, { type: 'file-frame', session, payload: message.payload });
351
459
  } else if (role === 'observer' && room.observers.get(clientId)?.ws === ws && message.type === 'official-computer') {
352
460
  const computer = normalizeOfficialComputer(message);
353
- commit(room, { officialComputer: computer });
461
+ await commit(room, { officialComputer: computer });
354
462
  send(ws, { type: 'official-computer-saved', computer });
355
463
  } else if (role === 'client' && room.connections.get(clientId)?.ws === ws && message.type === 'p2p-probe') {
356
464
  const connection = room.connections.get(clientId);
357
465
  if (!connection.p2pProbe || !validProbeSignal(message)) return fail(1008, 'Invalid probe signal');
466
+ const diagnostic = message.purpose === 'diagnostic';
467
+ if (message.purpose && !diagnostic) return fail(1008, 'Invalid probe purpose');
468
+ const idField = diagnostic ? 'diagnosticProbeId' : 'probeId';
469
+ const signalsField = diagnostic ? 'diagnosticSignals' : 'signals';
470
+ const startedField = diagnostic ? 'diagnosticProbeStarted' : 'probeStarted';
358
471
  if (message.kind === 'start') {
359
- if (connection.probeId && (!connection.repeat || Date.now() - connection.probeStarted < 2000)) {
360
- send(ws, { type: 'p2p-probe', version: 1, probeId: message.probeId, kind: 'close' }); return;
472
+ if (connection[idField] && (!connection.repeat || Date.now() - connection[startedField] < 2000)) {
473
+ send(ws, { type: 'p2p-probe', version: 1, probeId: message.probeId,
474
+ ...(diagnostic ? { purpose: 'diagnostic' } : {}), kind: 'close' }); return;
361
475
  }
362
- connection.probeId = message.probeId; connection.signals = 0; connection.probeStarted = Date.now();
476
+ connection[idField] = message.probeId; connection[signalsField] = 0; connection[startedField] = Date.now();
363
477
  }
364
- if (connection.probeId !== message.probeId) return;
365
- if (++connection.signals > 150) return fail(1008, 'Probe signal limit');
478
+ if (connection[idField] !== message.probeId) return;
479
+ if (++connection[signalsField] > 150) return fail(1008, 'Probe signal limit');
366
480
  send(room.host, { ...message, session });
481
+ if (diagnostic && message.kind === 'close') connection.diagnosticProbeId = undefined;
367
482
  } else if (room.connections.get(clientId)?.ws === ws && message.type === 'p2p-route' && message.direct === 1) {
368
483
  releaseRelay(room.connections.get(clientId));
369
484
  } else if (room.connections.get(clientId)?.ws === ws && message.type === 'data' && typeof message.payload === 'string') {
@@ -373,17 +488,19 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
373
488
  }
374
489
  send(room.host, { type: 'data', session, payload: message.payload });
375
490
  } else fail();
376
- } catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
377
- });
491
+ } catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
492
+ }).catch(() => ws.close(1011, 'Relay operation failed'));
493
+ });
378
494
  });
379
495
  await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
380
496
  listenUrl = `ws://127.0.0.1:${server.address().port}/relay`;
381
497
  return {
382
498
  url: listenUrl, hostKey,
383
499
  async close() {
384
- for (const ws of wss.clients) ws.terminate();
385
- await new Promise(resolve => wss.close(resolve));
386
- await new Promise(resolve => server.close(resolve));
500
+ for (const ws of wss.clients) ws.terminate();
501
+ await new Promise(resolve => wss.close(resolve));
502
+ await new Promise(resolve => server.close(resolve));
503
+ if (database) await database.close();
387
504
  },
388
505
  };
389
506
  }