@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.
- package/README.md +44 -54
- package/dist/bin/codex-remote.mjs +34 -60
- package/dist/bin/migrate-relay-db.mjs +104 -0
- package/dist/bin/self-relay-server.mjs +17 -10
- package/dist/shared/app-server-events.cjs +15 -3
- package/dist/shared/file-mentions.cjs +35 -0
- package/dist/shared/server-requests.cjs +86 -58
- package/dist/skills/code-relax/SKILL.md +1 -1
- package/dist/src/desktop-launcher.mjs +6 -11
- package/dist/src/file-upload-session.mjs +124 -0
- package/dist/src/lan-socket-server.mjs +51 -8
- package/dist/src/managed-app-server.mjs +21 -10
- package/dist/src/onboarding.mjs +3 -3
- package/dist/src/platform/windows/codex-runtime.mjs +138 -4
- package/dist/src/platform/windows/desktop-host.mjs +1 -2
- package/dist/src/platform/windows/desktop-runtime.ps1 +10 -0
- package/dist/src/platform/windows/desktop-shortcuts.ps1 +6 -13
- package/dist/src/platform/windows/resident-start.js +8 -0
- package/dist/src/platform/windows/resident-startup.ps1 +6 -3
- package/dist/src/platform/windows/service-host.mjs +0 -9
- package/dist/src/self-relay/admin-page.mjs +1 -1
- package/dist/src/self-relay/admin-state.mjs +8 -6
- package/dist/src/self-relay/connector.mjs +84 -12
- package/dist/src/self-relay/email-accounts.mjs +125 -0
- package/dist/src/self-relay/lifecycle.mjs +52 -100
- package/dist/src/self-relay/mysql-state.mjs +104 -0
- package/dist/src/self-relay/p2p-probe.mjs +1 -1
- package/dist/src/self-relay/server.mjs +155 -38
- package/dist/src/server.mjs +736 -610
- package/dist/src/service-doctor.mjs +1 -1
- package/dist/src/service-install.mjs +1 -4
- package/dist/src/shared-app-server.mjs +274 -272
- package/dist/src/shared-catalog.mjs +1 -11
- package/dist/src/shared-recovery.mjs +5 -5
- package/dist/src/skill-install.mjs +0 -13
- package/package.json +2 -1
- package/dist/src/debug-ui.mjs +0 -131
- package/dist/src/platform/windows/desktop-shortcut-run.ps1 +0 -5
- package/dist/web/activity-view.js +0 -283
- package/dist/web/approval-ui.js +0 -188
- package/dist/web/capabilities.js +0 -50
- package/dist/web/chat-transport.js +0 -196
- package/dist/web/chat.css +0 -778
- package/dist/web/chat.js +0 -3815
- package/dist/web/client-platform.js +0 -11
- package/dist/web/community.css +0 -82
- package/dist/web/community.html +0 -36
- package/dist/web/composer-controller.js +0 -97
- package/dist/web/conversation-controller.js +0 -99
- package/dist/web/disclosure-state-controller.js +0 -95
- package/dist/web/draft-controller.js +0 -103
- package/dist/web/harmony-platform.js +0 -52
- package/dist/web/highlight-powershell.min.js +0 -39
- package/dist/web/highlight.min.js +0 -1262
- package/dist/web/history-cache.js +0 -240
- package/dist/web/history-controller.js +0 -167
- package/dist/web/index.html +0 -288
- package/dist/web/lan-session.js +0 -122
- package/dist/web/link-action-controller.js +0 -212
- package/dist/web/message-send-controller.js +0 -177
- package/dist/web/message-view.js +0 -98
- package/dist/web/p2p-data-channel.js +0 -50
- package/dist/web/p2p-diagnostics.js +0 -28
- package/dist/web/p2p-probe.js +0 -123
- package/dist/web/page-resume.js +0 -28
- package/dist/web/pending-message-store.js +0 -108
- package/dist/web/queue-controller.js +0 -82
- package/dist/web/resources.json +0 -1
- package/dist/web/self-relay-session.js +0 -129
- package/dist/web/server-requests.js +0 -67
- package/dist/web/station-connection-controller.js +0 -75
- package/dist/web/task-list-view.js +0 -289
- package/dist/web/thread-attention-controller.js +0 -124
- package/dist/web/thread-context-controller.js +0 -30
- package/dist/web/thread-list-controller.js +0 -61
- package/dist/web/thread-list-sync.js +0 -86
- package/dist/web/thread-title-controller.js +0 -57
- package/dist/web/timeline-formatters.js +0 -249
- package/dist/web/timeline-reducer.js +0 -77
- package/dist/web/timeline-renderer.js +0 -161
- package/dist/web/timeline-scroll-controller.js +0 -50
- package/dist/web/timeline-sync.js +0 -67
- package/dist/web/usage-controller.js +0 -258
- package/dist/web/vendor/README.md +0 -14
- package/dist/web/vendor/highlight-powershell.min.js +0 -39
- package/dist/web/vendor/highlight.LICENSE.txt +0 -29
- package/dist/web/vendor/highlight.min.js +0 -1262
- 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
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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:
|
|
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
|
-
|
|
346
|
-
if (
|
|
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
|
|
360
|
-
send(ws, { type: 'p2p-probe', version: 1, probeId: message.probeId,
|
|
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
|
|
476
|
+
connection[idField] = message.probeId; connection[signalsField] = 0; connection[startedField] = Date.now();
|
|
363
477
|
}
|
|
364
|
-
if (connection
|
|
365
|
-
if (++connection
|
|
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
|
}
|