@walkhi/code-relax 0.1.0-beta.27 → 0.1.0-beta.29
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 +80 -67
- package/dist/bin/codex-remote.mjs +303 -239
- package/dist/bin/self-relay-server.mjs +2 -0
- package/dist/shared/app-server-events.cjs +102 -16
- package/dist/shared/code-browser.cjs +159 -0
- package/dist/shared/file-changes.cjs +31 -0
- package/dist/shared/file-citations.cjs +117 -82
- package/dist/shared/file-mentions.cjs +80 -35
- package/dist/shared/message-delivery.cjs +38 -0
- package/dist/shared/p2p-file-channel.cjs +74 -0
- package/dist/shared/server-requests.cjs +149 -41
- package/dist/shared/service-endpoints.cjs +11 -11
- package/dist/shared/timeline-sync.cjs +173 -156
- package/dist/skills/code-relax/SKILL.md +2 -1
- package/dist/src/bounded-process-log.mjs +63 -0
- package/dist/src/celsius-client.mjs +107 -0
- package/dist/src/code-browser.mjs +101 -0
- package/dist/src/desktop-launcher.mjs +30 -30
- package/dist/src/dots-approval-timeline.mjs +99 -0
- package/dist/src/dots-avatar.mjs +42 -0
- package/dist/src/dots-chat-projection.mjs +50 -0
- package/dist/src/dots-chat-server.mjs +412 -0
- package/dist/src/dots-message-images.mjs +319 -0
- package/dist/src/dots-profile.mjs +77 -0
- package/dist/src/dots-room-activity.mjs +94 -0
- package/dist/src/dots-room-client.mjs +135 -0
- package/dist/src/dots-room-projection.mjs +94 -0
- package/dist/src/dots-schedules.mjs +66 -0
- package/dist/src/dots-summary-tasks.mjs +32 -0
- package/dist/src/dots-task-catalog.mjs +411 -0
- package/dist/src/file-transfer-range.mjs +22 -0
- package/dist/src/file-upload-session.mjs +130 -124
- package/dist/src/hosted-history-app-server.mjs +103 -0
- package/dist/src/hosted-history-cache.mjs +113 -0
- package/dist/src/lan-socket-server.mjs +205 -160
- package/dist/src/managed-app-server.mjs +241 -238
- package/dist/src/message-upload-files.mjs +74 -0
- package/dist/src/platform/windows/activate-desktop.ps1 +48 -0
- package/dist/src/platform/windows/codex-runtime.mjs +253 -251
- package/dist/src/platform/windows/desktop-host.mjs +38 -32
- package/dist/src/platform/windows/desktop-info.ps1 +2 -2
- package/dist/src/platform/windows/desktop-project-registration.mjs +153 -0
- package/dist/src/platform/windows/dots-profile.mjs +49 -0
- package/dist/src/platform/windows/launch-worker.mjs +41 -42
- package/dist/src/platform/windows/project-locations.mjs +16 -0
- package/dist/src/project-create.mjs +119 -0
- package/dist/src/project-management.mjs +69 -0
- package/dist/src/self-relay/account-connections.mjs +52 -0
- package/dist/src/self-relay/admin-page.mjs +80 -80
- package/dist/src/self-relay/admin-style.mjs +14 -9
- package/dist/src/self-relay/connector.mjs +23 -5
- package/dist/src/self-relay/email-accounts.mjs +35 -10
- package/dist/src/self-relay/file-wire.mjs +13 -0
- package/dist/src/self-relay/lifecycle.mjs +22 -4
- package/dist/src/self-relay/long-pairing.mjs +40 -0
- package/dist/src/self-relay/p2p-probe.mjs +152 -123
- package/dist/src/self-relay/rtc-file-bridge.mjs +41 -0
- package/dist/src/self-relay/server.mjs +922 -849
- package/dist/src/server.mjs +474 -199
- package/dist/src/service-control.mjs +13 -3
- package/dist/src/service-doctor.mjs +72 -61
- package/dist/src/service-lifecycle.mjs +51 -43
- package/dist/src/shared-app-server.mjs +166 -107
- package/dist/src/shared-catalog.mjs +132 -40
- package/dist/src/shared-thread-stream.mjs +103 -25
- package/dist/src/usage-reset.mjs +17 -0
- package/dist/src/workstation-downloads.mjs +120 -0
- package/package.json +1 -1
|
@@ -33,14 +33,14 @@ async function loadConfig(stateRoot) {
|
|
|
33
33
|
(pairing.protocol === 'http:' && pairing.hostname !== '127.0.0.1')) throw new Error('self-relay-config.json 的 pairingUrl 必须是 HTTPS /pair 地址。');
|
|
34
34
|
return { ...config, url: resolved, pairingUrl: pairing.href };
|
|
35
35
|
}
|
|
36
|
-
export async function selfRelay(command, stateRoot) {
|
|
36
|
+
export async function selfRelay(command, stateRoot, details) {
|
|
37
37
|
const session = readRecord(path.join(stateRoot, 'bridge-session.json'));
|
|
38
38
|
if (!session) {
|
|
39
39
|
if (command === 'relay-status') return { running: false, displayText: '桌面服务未运行。' };
|
|
40
40
|
if (command === 'relay-stop') return { stopped: true, displayText: '中继连接已停止。' };
|
|
41
41
|
throw new Error('桌面服务尚未运行,请先执行 relax start。');
|
|
42
42
|
}
|
|
43
|
-
return controlRequest(session, 'POST', command);
|
|
43
|
+
return controlRequest(session, 'POST', command, details);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
async function createRelayRuntime(stateRoot) {
|
|
@@ -186,6 +186,22 @@ async function createRelayRuntime(stateRoot) {
|
|
|
186
186
|
await connector.command('revoke'); paired = false; clientCount = 0; attachedCount = 0; qrPath = undefined; expiresAt = 0;
|
|
187
187
|
return { ...status(), displayText: '全部移动设备授权已撤销;重新添加请调用 relax pair --json。' };
|
|
188
188
|
}
|
|
189
|
+
async function invite(command, details) {
|
|
190
|
+
if (!online || closing) throw new Error('中继尚未连接,请先执行 relax start。');
|
|
191
|
+
const result = await connector.command(command, command === 'invite-create' ? { expires: details.expires } : command === 'invite-revoke' ? { id: details.id } : {});
|
|
192
|
+
if (command === 'invite-create') {
|
|
193
|
+
const link = new URL(config.pairingUrl);
|
|
194
|
+
link.searchParams.set('invite', result.code);
|
|
195
|
+
const inviteQrPath = path.join(stateRoot, `self-relay-long-qr-${result.id}.png`);
|
|
196
|
+
try { await QRCode.toFile(inviteQrPath, link.href, { width: 640, margin: 3 }); }
|
|
197
|
+
catch (error) { throw new Error(`二维码保存失败:${error.message}。邀请 ID:${result.id};请执行 relax pair-revoke ${result.id} 废止。`); }
|
|
198
|
+
return { invitationId: result.id, deviceId: result.room, expiresAt: new Date(result.expires).toISOString(), qrPath: inviteQrPath,
|
|
199
|
+
displayText: `长期配对邀请已创建。邀请 ID:${result.id}\n截止:${new Date(result.expires).toISOString()}` };
|
|
200
|
+
}
|
|
201
|
+
if (command === 'invite-list') return { invitations: result.invitations,
|
|
202
|
+
displayText: result.invitations.length ? result.invitations.map(item => `${item.id} ${new Date(item.expires).toISOString()}${Date.now() >= item.expires ? '(已过期)' : ''}`).join('\n') : '本工作站没有长期配对邀请。' };
|
|
203
|
+
return { invitationId: result.id, revoked: result.revoked, displayText: '长期邀请已废止;已配对手机的独立授权保持不变。' };
|
|
204
|
+
}
|
|
189
205
|
async function refreshLan() {
|
|
190
206
|
if (!online || closing) throw new Error('中继尚未连接,未更新局域网候选。');
|
|
191
207
|
await refreshHealth();
|
|
@@ -201,14 +217,14 @@ async function createRelayRuntime(stateRoot) {
|
|
|
201
217
|
}
|
|
202
218
|
return status();
|
|
203
219
|
};
|
|
204
|
-
try { await connect(); return { status, inspect, pair, revoke, refreshLan, shutdown }; }
|
|
220
|
+
try { await connect(); return { status, inspect, pair, revoke, invite, refreshLan, shutdown }; }
|
|
205
221
|
catch (error) { await shutdown(); throw error; }
|
|
206
222
|
}
|
|
207
223
|
|
|
208
224
|
export function createRelayManager(stateRoot) {
|
|
209
225
|
let current;
|
|
210
226
|
let operation = Promise.resolve();
|
|
211
|
-
const run = command => {
|
|
227
|
+
const run = (command, details = {}) => {
|
|
212
228
|
const next = operation.then(async () => {
|
|
213
229
|
if (command === 'relay-status') return current ? current.inspect() : { running: false, displayText: '中继连接未运行。' };
|
|
214
230
|
if (command === 'relay-stop') {
|
|
@@ -216,10 +232,12 @@ export function createRelayManager(stateRoot) {
|
|
|
216
232
|
return { stopped: true, displayText: '中继连接已停止;桌面服务和 Codex 内核保持运行。' };
|
|
217
233
|
}
|
|
218
234
|
if (!current) {
|
|
235
|
+
if (command.startsWith('invite-')) throw new Error('请先执行 relax start 连接中继。');
|
|
219
236
|
if (command === 'relay-revoke') throw new Error('请先启动中继连接,再撤销移动端授权。');
|
|
220
237
|
current = await createRelayRuntime(stateRoot);
|
|
221
238
|
return command === 'relay-pair' && current.status().relayConnected ? current.pair() : current.status();
|
|
222
239
|
}
|
|
240
|
+
if (command.startsWith('invite-')) return current.invite(command, details);
|
|
223
241
|
if (command === 'relay-revoke') return current.revoke();
|
|
224
242
|
if (command === 'relay-pair' || (current.status().relayConnected && !current.status().paired && !current.status().qrPath)) return current.pair();
|
|
225
243
|
if (command === 'relay-start' && current.status().relayConnected) return current.refreshLan();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { validId } from './device-store.mjs';
|
|
4
|
+
import { digest, matches, secret } from './wire.mjs';
|
|
5
|
+
|
|
6
|
+
// Operator-owned file, separate from device state. Only hashes are retained here.
|
|
7
|
+
export function longPairing(file) {
|
|
8
|
+
function read() {
|
|
9
|
+
if (!file || !fs.existsSync(file)) return [];
|
|
10
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
11
|
+
if (data.version !== 1 || !Array.isArray(data.invitations) || data.invitations.some(item =>
|
|
12
|
+
!validId(item.id) || !validId(item.room) || !/^[a-f0-9]{64}$/.test(item.hash) ||
|
|
13
|
+
!Number.isSafeInteger(item.expires))) throw new Error('Invalid long pairing invitations');
|
|
14
|
+
return data.invitations;
|
|
15
|
+
}
|
|
16
|
+
function write(invitations) {
|
|
17
|
+
if (!file) throw new Error('Long pairing file is not configured');
|
|
18
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
19
|
+
try {
|
|
20
|
+
fs.writeFileSync(temporary, JSON.stringify({ version: 1, invitations }), { mode: 0o600, flag: 'wx' });
|
|
21
|
+
fs.renameSync(temporary, file);
|
|
22
|
+
} finally { fs.rmSync(temporary, { force: true }); }
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
list: () => read().map(({ hash, ...item }) => item),
|
|
26
|
+
find: code => read().find(item => Date.now() < item.expires && matches(code, item.hash)),
|
|
27
|
+
create(room, expires) {
|
|
28
|
+
if (!validId(room) || !Number.isSafeInteger(expires) || expires <= Date.now()) throw new Error('Invalid invitation room or expiry');
|
|
29
|
+
const invitations = read(), code = secret();
|
|
30
|
+
const item = { id: randomUUID(), room, expires, hash: digest(code) };
|
|
31
|
+
write([...invitations, item]);
|
|
32
|
+
return { id: item.id, room, expires, code };
|
|
33
|
+
},
|
|
34
|
+
revoke(id) {
|
|
35
|
+
const invitations = read();
|
|
36
|
+
if (!invitations.some(item => item.id === id)) throw new Error('Invitation not found');
|
|
37
|
+
write(invitations.filter(item => item.id !== id));
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -1,123 +1,152 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import P2pDiagnostics from '../../shared/p2p-diagnostics.cjs';
|
|
3
|
-
import P2pDataFramer from '../../shared/p2p-data-channel.cjs';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
let
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import P2pDiagnostics from '../../shared/p2p-diagnostics.cjs';
|
|
3
|
+
import P2pDataFramer from '../../shared/p2p-data-channel.cjs';
|
|
4
|
+
import FileChannel from '../../shared/p2p-file-channel.cjs';
|
|
5
|
+
|
|
6
|
+
const PROBE_UDP_PORT_RANGE = Object.freeze({ begin: 42000, end: 42031 });
|
|
7
|
+
const HIGH_WATER = 1024 * 1024;
|
|
8
|
+
const LOW_WATER = 512 * 1024;
|
|
9
|
+
const MAX_QUEUED = 16 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
export async function createP2pProbe({ signal, report = () => {}, onData = () => {}, onFileChannel, iceServers = [] }) {
|
|
12
|
+
if (!validProbeIceServers(iceServers)) throw new Error('Invalid probe STUN configuration');
|
|
13
|
+
const { default: rtc } = await import('node-datachannel');
|
|
14
|
+
const peer = new rtc.PeerConnection('code-relax-probe', {
|
|
15
|
+
iceServers,
|
|
16
|
+
maxMessageSize: 65536,
|
|
17
|
+
portRangeBegin: PROBE_UDP_PORT_RANGE.begin,
|
|
18
|
+
portRangeEnd: PROBE_UDP_PORT_RANGE.end
|
|
19
|
+
});
|
|
20
|
+
const diagnostic = new P2pDiagnostics();
|
|
21
|
+
const framer = new P2pDataFramer();
|
|
22
|
+
const dataPrefix = randomUUID().replaceAll('-', '');
|
|
23
|
+
let channel, closed = false, answered = false, candidates = [], verified = false, remoteDirect = false;
|
|
24
|
+
let pingReceived = false, pongReceived = false, dataSerial = 0, queuedBytes = 0;
|
|
25
|
+
const outbound = [];
|
|
26
|
+
const files = new Map();
|
|
27
|
+
const nonce = randomUUID();
|
|
28
|
+
const timer = setTimeout(() => finish(verified ? 'closed' : 'timeout'), 15000);
|
|
29
|
+
function finish(state) {
|
|
30
|
+
if (closed) return;
|
|
31
|
+
closed = true; clearTimeout(timer); candidates = []; queuedBytes = 0; outbound.length = 0; framer.reset();
|
|
32
|
+
for (const socket of files.values()) socket.close(1011);
|
|
33
|
+
files.clear();
|
|
34
|
+
const summary = diagnostic.snapshot(state);
|
|
35
|
+
channel?.close(); peer.close(); report(state, summary);
|
|
36
|
+
}
|
|
37
|
+
function verify() {
|
|
38
|
+
if (verified || !pingReceived || !pongReceived) return;
|
|
39
|
+
verified = true; clearTimeout(timer);
|
|
40
|
+
report(remoteDirect ? 'direct' : 'verified', diagnostic.snapshot('verified'));
|
|
41
|
+
pump();
|
|
42
|
+
for (const raw of files.keys()) activateFile(raw);
|
|
43
|
+
}
|
|
44
|
+
function activateFile(raw) {
|
|
45
|
+
const socket = files.get(raw);
|
|
46
|
+
if (!socket || socket.readyState !== 0 || !raw.isOpen() || !verified || !remoteDirect || closed) return;
|
|
47
|
+
socket.opened();
|
|
48
|
+
raw.sendMessage('{"type":"rtc-file-ready"}');
|
|
49
|
+
}
|
|
50
|
+
peer.onDataChannel(raw => {
|
|
51
|
+
if (closed || !onFileChannel || raw.getLabel() !== 'code-relax-file-v1' || files.size >= 4) { raw.close(); return; }
|
|
52
|
+
const socket = new FileChannel({ buffered: () => raw.bufferedAmount(),
|
|
53
|
+
send: value => typeof value === 'string' ? raw.sendMessage(value) : raw.sendMessageBinary(value), close: () => raw.close() });
|
|
54
|
+
files.set(raw, socket);
|
|
55
|
+
socket.addEventListener('close', () => files.delete(raw));
|
|
56
|
+
try { onFileChannel(socket); }
|
|
57
|
+
catch { socket.close(1011); return; }
|
|
58
|
+
raw.setBufferedAmountLowThreshold(128 * 1024);
|
|
59
|
+
raw.onBufferedAmountLow(() => socket.pump());
|
|
60
|
+
raw.onOpen(() => activateFile(raw));
|
|
61
|
+
raw.onClosed(() => socket.close(1011)); raw.onError(() => socket.close(1011));
|
|
62
|
+
raw.onMessage(value => {
|
|
63
|
+
if (!verified || !remoteDirect || closed || socket.readyState !== 1) { socket.close(1008); return; }
|
|
64
|
+
socket.receive(value);
|
|
65
|
+
});
|
|
66
|
+
activateFile(raw);
|
|
67
|
+
});
|
|
68
|
+
function pump() {
|
|
69
|
+
if (!verified || !remoteDirect || closed || !channel?.isOpen()) return;
|
|
70
|
+
try {
|
|
71
|
+
while (outbound.length && channel.bufferedAmount() < HIGH_WATER) {
|
|
72
|
+
const item = outbound[0], frame = framer.frame(item);
|
|
73
|
+
if (!channel.sendMessage(frame)) return;
|
|
74
|
+
if (framer.commit(item)) { outbound.shift(); queuedBytes -= item.bytes; }
|
|
75
|
+
}
|
|
76
|
+
} catch { diagnostic.error = 'channel'; finish('failed'); }
|
|
77
|
+
}
|
|
78
|
+
function sendData(payload) {
|
|
79
|
+
if (!verified || !remoteDirect || closed || !channel?.isOpen()) return false;
|
|
80
|
+
let item;
|
|
81
|
+
try { item = framer.create(payload, `${dataPrefix}-${++dataSerial}`); }
|
|
82
|
+
catch { return false; }
|
|
83
|
+
if (queuedBytes + item.bytes > MAX_QUEUED) return false;
|
|
84
|
+
outbound.push(item); queuedBytes += item.bytes; pump(); return true;
|
|
85
|
+
}
|
|
86
|
+
peer.onIceStateChange(state => { if (!closed) diagnostic.ice = state; });
|
|
87
|
+
peer.onGatheringStateChange(state => { if (!closed) diagnostic.gathering = state; });
|
|
88
|
+
peer.onStateChange(state => { if (!closed && (state === 'failed' || state === 'closed')) { diagnostic.error = 'peer'; finish('failed'); } });
|
|
89
|
+
peer.onLocalDescription((sdp, type) => { if (!closed) { diagnostic.offer = type === 'offer'; signal({ kind: type, sdp, iceServers, direct: 1 }); } });
|
|
90
|
+
peer.onLocalCandidate((candidate, mid) => { if (!closed) { diagnostic.candidate('local', candidate); signal({ kind: 'candidate', candidate, mid }); } });
|
|
91
|
+
channel = peer.createDataChannel('code-relax-probe-v1');
|
|
92
|
+
channel.setBufferedAmountLowThreshold(LOW_WATER);
|
|
93
|
+
channel.onBufferedAmountLow(pump);
|
|
94
|
+
channel.onOpen(() => { if (!closed) { diagnostic.open = true; channel.sendMessage(JSON.stringify({ type: 'ping', nonce })); } });
|
|
95
|
+
channel.onError(() => { if (!closed) diagnostic.error = 'channel'; finish('failed'); });
|
|
96
|
+
channel.onClosed(() => finish('closed'));
|
|
97
|
+
channel.onMessage(value => {
|
|
98
|
+
try {
|
|
99
|
+
if (closed || typeof value !== 'string' || value.length > 65536) throw new Error('Invalid P2P frame');
|
|
100
|
+
const message = JSON.parse(value);
|
|
101
|
+
const data = framer.accept(message);
|
|
102
|
+
if (data.handled) {
|
|
103
|
+
if (!verified || !remoteDirect) throw new Error('P2P transport not verified');
|
|
104
|
+
if (data.complete) onData(data.payload);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (message.type === 'ping' && /^[\w-]{16,128}$/.test(message.nonce)) {
|
|
108
|
+
diagnostic.ping = true; pingReceived = true;
|
|
109
|
+
channel.sendMessage(JSON.stringify({ type: 'pong', nonce: message.nonce }));
|
|
110
|
+
} else if (message.type === 'pong' && message.nonce === nonce) {
|
|
111
|
+
diagnostic.pong = true; pongReceived = true;
|
|
112
|
+
} else throw new Error('Invalid P2P response');
|
|
113
|
+
verify();
|
|
114
|
+
} catch { diagnostic.error = 'frame'; finish('failed'); }
|
|
115
|
+
});
|
|
116
|
+
return {
|
|
117
|
+
receive(message) {
|
|
118
|
+
if (closed) return;
|
|
119
|
+
if (message.kind === 'answer' && !answered) {
|
|
120
|
+
try { peer.setRemoteDescription(message.sdp, 'answer'); }
|
|
121
|
+
catch { diagnostic.error = 'description'; finish('failed'); return; }
|
|
122
|
+
answered = true; remoteDirect = message.direct === 1; diagnostic.answer = true;
|
|
123
|
+
for (const item of candidates) peer.addRemoteCandidate(item.candidate, item.mid);
|
|
124
|
+
candidates = [];
|
|
125
|
+
} else if (message.kind === 'candidate') {
|
|
126
|
+
diagnostic.candidate('remote', message.candidate);
|
|
127
|
+
if (answered) peer.addRemoteCandidate(message.candidate, message.mid);
|
|
128
|
+
else if (candidates.length < 64) candidates.push(message);
|
|
129
|
+
else finish('failed');
|
|
130
|
+
} else if (message.kind === 'close') finish('closed');
|
|
131
|
+
else finish('failed');
|
|
132
|
+
},
|
|
133
|
+
send: sendData,
|
|
134
|
+
close() { finish('closed'); }
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function validProbeSignal(value) {
|
|
139
|
+
if (!value || value.version !== 1 || !/^[\w-]{16,128}$/.test(value.probeId || '')) return false;
|
|
140
|
+
if (['start', 'close'].includes(value.kind)) return true;
|
|
141
|
+
if (['offer', 'answer'].includes(value.kind)) return typeof value.sdp === 'string' && value.sdp.length <= 65536 &&
|
|
142
|
+
(value.direct === undefined || value.direct === 0 || value.direct === 1) &&
|
|
143
|
+
(value.iceServers === undefined || validProbeIceServers(value.iceServers));
|
|
144
|
+
if (value.kind === 'candidate') return typeof value.candidate === 'string' && value.candidate.length <= 2048 &&
|
|
145
|
+
typeof value.mid === 'string' && value.mid.length <= 64;
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function validProbeIceServers(value) {
|
|
150
|
+
return Array.isArray(value) && value.length <= 4 && value.every(item =>
|
|
151
|
+
typeof item === 'string' && /^stun:[a-zA-Z0-9.-]+:[0-9]{1,5}$/.test(item) && Number(item.split(':').at(-1)) <= 65535);
|
|
152
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { loopbackUrl } from './wire.mjs';
|
|
3
|
+
|
|
4
|
+
// The RTC peer belongs to an authenticated relay chat session. Its teardown
|
|
5
|
+
// closes this channel on logout, expiry, revocation or signaling disconnect.
|
|
6
|
+
export function attachRtcFile(channel, connection, authorized) {
|
|
7
|
+
let local, authenticated = false;
|
|
8
|
+
const timer = setTimeout(() => channel.close(1008), 5000);
|
|
9
|
+
channel.addEventListener('close', () => { clearTimeout(timer); local?.terminate(); });
|
|
10
|
+
channel.onmessage = event => {
|
|
11
|
+
try {
|
|
12
|
+
if (!authorized()) throw new Error('File authorization expired');
|
|
13
|
+
if (!local) {
|
|
14
|
+
if (typeof event.data !== 'string') throw new Error('File authentication required');
|
|
15
|
+
const auth = JSON.parse(event.data);
|
|
16
|
+
if (auth.type !== 'auth' || !['upload', 'download'].includes(auth.purpose)) throw new Error('Invalid file purpose');
|
|
17
|
+
const url = new URL(connection.url); url.pathname = '/api/file-transfer';
|
|
18
|
+
loopbackUrl(url.href, '/api/file-transfer');
|
|
19
|
+
local = new WebSocket(url.href, { maxPayload: 512 * 1024, perMessageDeflate: false });
|
|
20
|
+
local.on('open', () => {
|
|
21
|
+
if (!authorized() || channel.readyState !== 1) { local.terminate(); return; }
|
|
22
|
+
local.send(JSON.stringify({ type: 'auth', token: connection.token, purpose: auth.purpose }));
|
|
23
|
+
});
|
|
24
|
+
local.on('message', (bytes, binary) => {
|
|
25
|
+
if (!authorized() || channel.readyState !== 1) { channel.close(4401); return; }
|
|
26
|
+
try {
|
|
27
|
+
if (!authenticated) {
|
|
28
|
+
if (binary || JSON.parse(bytes.toString()).type !== 'ready') throw new Error('File authentication failed');
|
|
29
|
+
authenticated = true; clearTimeout(timer);
|
|
30
|
+
}
|
|
31
|
+
channel.send(binary ? bytes : bytes.toString());
|
|
32
|
+
} catch { channel.close(1011); }
|
|
33
|
+
});
|
|
34
|
+
local.on('close', () => channel.close(1011)); local.on('error', () => channel.close(1011));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (!authenticated || local.readyState !== WebSocket.OPEN || local.bufferedAmount > 1024 * 1024) throw new Error('File channel unavailable');
|
|
38
|
+
local.send(typeof event.data === 'string' ? event.data : Buffer.from(event.data));
|
|
39
|
+
} catch { channel.close(1008); }
|
|
40
|
+
};
|
|
41
|
+
}
|