@walkhi/code-relax 0.1.0-beta.10 → 0.1.0-beta.11
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 +22 -58
- package/dist/shared/app-server-events.cjs +15 -3
- package/dist/shared/file-mentions.cjs +35 -0
- 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 +9 -7
- package/dist/src/onboarding.mjs +3 -3
- package/dist/src/platform/windows/desktop-host.mjs +1 -2
- package/dist/src/platform/windows/desktop-shortcuts.ps1 +6 -13
- 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/connector.mjs +84 -12
- package/dist/src/self-relay/lifecycle.mjs +8 -24
- package/dist/src/self-relay/p2p-probe.mjs +1 -1
- package/dist/src/self-relay/server.mjs +67 -12
- package/dist/src/server.mjs +141 -34
- package/dist/src/service-doctor.mjs +1 -1
- package/dist/src/service-install.mjs +1 -4
- package/dist/src/shared-app-server.mjs +30 -28
- 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 +1 -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,16 +1,20 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
|
-
import { WebSocketServer, WebSocket } from 'ws';
|
|
3
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
|
+
import { createFileUploadSession, FILE_CHUNK_BYTES, MAX_FILE_BYTES, MAX_FILE_COUNT, MAX_TOTAL_BYTES } from './file-upload-session.mjs';
|
|
4
5
|
|
|
5
6
|
// One authenticated connection carries requests and explicitly owned subscriptions.
|
|
6
7
|
// The dispatcher is shared business routing, not an HTTP loopback proxy.
|
|
7
|
-
export function attachLanSocket(server, { token, dispatch, authorizeDevice = () => false }) {
|
|
8
|
+
export function attachLanSocket(server, { token, dispatch, authorizeDevice = () => false, uploadsRoot }) {
|
|
8
9
|
// Allow four individually valid images to reach the aggregate-size validator,
|
|
9
10
|
// so an oversized composition returns an error instead of dropping the connection.
|
|
10
11
|
const wss = new WebSocketServer({ noServer: true, maxPayload: 48 * 1024 * 1024, perMessageDeflate: false });
|
|
11
|
-
|
|
12
|
+
const uploadWss = new WebSocketServer({ noServer: true, maxPayload: 512 * 1024, perMessageDeflate: false });
|
|
13
|
+
const activeUploads = new Set();
|
|
14
|
+
server.on('upgrade', (request, socket, head) => {
|
|
12
15
|
const origin = request.headers.origin;
|
|
13
|
-
|
|
16
|
+
const upload = request.url === '/api/file-transfer' && Boolean(uploadsRoot);
|
|
17
|
+
let allowed = request.url === '/api/socket' || upload;
|
|
14
18
|
// Packaged ArkWeb pages have an opaque ("null") origin; device authentication remains required.
|
|
15
19
|
if (origin && origin !== 'null') {
|
|
16
20
|
try {
|
|
@@ -23,8 +27,44 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
23
27
|
catch { allowed = false; }
|
|
24
28
|
}
|
|
25
29
|
if (!allowed) { socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return; }
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
const target = upload ? uploadWss : wss;
|
|
31
|
+
target.handleUpgrade(request, socket, head, ws => target.emit('connection', ws));
|
|
32
|
+
});
|
|
33
|
+
uploadWss.on('connection', ws => {
|
|
34
|
+
let authenticated = false, identity = '', upload;
|
|
35
|
+
let pending = Promise.resolve();
|
|
36
|
+
const authTimer = setTimeout(() => ws.close(1008, 'Authentication required'), 5000);
|
|
37
|
+
const reply = value => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(value)); };
|
|
38
|
+
ws.on('error', () => {});
|
|
39
|
+
ws.on('close', () => {
|
|
40
|
+
clearTimeout(authTimer);
|
|
41
|
+
void pending.finally(async () => {
|
|
42
|
+
await upload?.close();
|
|
43
|
+
if (identity) activeUploads.delete(identity);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
ws.on('message', (bytes, binary) => {
|
|
47
|
+
pending = pending.then(async () => {
|
|
48
|
+
if (binary || bytes.length > 512 * 1024) throw new Error('无效的上传分片');
|
|
49
|
+
const message = JSON.parse(bytes.toString());
|
|
50
|
+
if (!authenticated) {
|
|
51
|
+
if (message?.type === 'device-auth' && typeof message.clientId === 'string' &&
|
|
52
|
+
authorizeDevice(message.clientId, message.credential) === true) identity = `device:${message.clientId}`;
|
|
53
|
+
else if (token && message?.type === 'auth' && typeof message.token === 'string') {
|
|
54
|
+
const supplied = Buffer.from(message.token), expected = Buffer.from(token || '');
|
|
55
|
+
if (supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected)) identity = 'local-token';
|
|
56
|
+
}
|
|
57
|
+
if (!identity || activeUploads.has(identity) || activeUploads.size >= 2) throw new Error('上传身份无效或工作站已有上传任务');
|
|
58
|
+
activeUploads.add(identity); authenticated = true; clearTimeout(authTimer);
|
|
59
|
+
upload = createFileUploadSession(uploadsRoot);
|
|
60
|
+
reply({ type: 'ready', maxFileBytes: MAX_FILE_BYTES, maxFileCount: MAX_FILE_COUNT,
|
|
61
|
+
maxTotalBytes: MAX_TOTAL_BYTES, chunkBytes: FILE_CHUNK_BYTES });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
reply(await upload.receive(message));
|
|
65
|
+
}).catch(error => { reply({ type: 'error', error: error.message }); ws.close(1008, 'Upload rejected'); });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
28
68
|
wss.on('connection', ws => {
|
|
29
69
|
let authenticated = false, alive = true;
|
|
30
70
|
const active = new Map();
|
|
@@ -105,5 +145,8 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
105
145
|
catch (error) { reply.json(500, { error: error.message }); }
|
|
106
146
|
});
|
|
107
147
|
});
|
|
108
|
-
return { close() {
|
|
109
|
-
|
|
148
|
+
return { close() {
|
|
149
|
+
for (const ws of wss.clients) ws.terminate(); wss.close();
|
|
150
|
+
for (const ws of uploadWss.clients) ws.terminate(); uploadWss.close();
|
|
151
|
+
} };
|
|
152
|
+
}
|
|
@@ -32,7 +32,6 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
32
32
|
this.timeout = timeout;
|
|
33
33
|
this.now = now;
|
|
34
34
|
this.recordPath = path.join(stateRoot, 'managed-app-server.json');
|
|
35
|
-
this.legacyRecordPath = path.join(stateRoot, 'shared-desktop.json');
|
|
36
35
|
this.client = client;
|
|
37
36
|
this.state = { state: 'absent', ready: false, error: null };
|
|
38
37
|
this.restartStatus = null;
|
|
@@ -45,7 +44,11 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
45
44
|
});
|
|
46
45
|
}
|
|
47
46
|
|
|
48
|
-
snapshot() {
|
|
47
|
+
snapshot() {
|
|
48
|
+
return { ...this.state,
|
|
49
|
+
version: this.state.ready && this.client.ready ? this.client.serverVersion || null : null,
|
|
50
|
+
...(this.restartStatus ? { restart: { ...this.restartStatus } } : {}) };
|
|
51
|
+
}
|
|
49
52
|
|
|
50
53
|
async start() {
|
|
51
54
|
this.stopped = false;
|
|
@@ -68,10 +71,9 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
68
71
|
try {
|
|
69
72
|
fs.mkdirSync(this.stateRoot, { recursive: true });
|
|
70
73
|
let record = null, actual = null;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
74
|
+
const candidate = readRecord(this.recordPath);
|
|
75
|
+
const process = candidate?.pid ? await this.host.process(candidate.pid) : null;
|
|
76
|
+
if (sameManagedProcess(candidate, process)) { record = candidate; actual = process; }
|
|
75
77
|
const runtime = actual
|
|
76
78
|
? readRecord(path.join(this.stateRoot, 'codex-runtime.json')) || { executable: actual.executable }
|
|
77
79
|
: await resolveCodexRuntime(this.stateRoot, { environment: this.environment });
|
|
@@ -145,7 +147,7 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
145
147
|
const previous = this.state;
|
|
146
148
|
this.publish({ ...previous, state: 'restarting', ready: false, error: null });
|
|
147
149
|
try {
|
|
148
|
-
const record = readRecord(this.recordPath)
|
|
150
|
+
const record = readRecord(this.recordPath);
|
|
149
151
|
const actual = record?.pid ? await this.host.process(record.pid) : null;
|
|
150
152
|
if (actual && !sameManagedProcess(record, actual)) {
|
|
151
153
|
throw new Error('Codex 内核进程身份已经变化,未结束任何进程。');
|
package/dist/src/onboarding.mjs
CHANGED
|
@@ -8,10 +8,10 @@ export const connectionModesText = [
|
|
|
8
8
|
export function phonePairingSteps(transportMode, paired = false) {
|
|
9
9
|
const mode = transportMode === 'lan-shared' ? '局域网 · Codex 内核已就绪' : '局域网(Codex 内核未就绪)';
|
|
10
10
|
return paired ? [
|
|
11
|
-
|
|
11
|
+
`已有移动设备授权。移动端可继续使用私有中继;${mode} 会显示验证成功的当前网络和历史记录。`,
|
|
12
12
|
'添加另一台设备时执行 relax pair;每台设备只需扫码配对一次。',
|
|
13
13
|
] : [
|
|
14
|
-
'
|
|
14
|
+
'请先在移动端安装 Code Relax。当前为鸿蒙 HarmonyOS 7 私测版,调试 HAP 需通过 DevEco/调试设备安装,尚无商店下载入口。',
|
|
15
15
|
'使用 App 扫描配对二维码;每台移动设备只需配对一次。',
|
|
16
16
|
`配对后可直接使用私有中继;${mode} 会接收并验证当前网络候选,也可手动输入 IP。`,
|
|
17
17
|
];
|
|
@@ -25,5 +25,5 @@ export const desktopSetupSteps = [
|
|
|
25
25
|
];
|
|
26
26
|
|
|
27
27
|
export function pairingDisplayText(nextSteps, notice) {
|
|
28
|
-
return ['Bridge
|
|
28
|
+
return ['Bridge 已就绪,请在移动端完成连接。', connectionModesText, ...nextSteps.map((step, index) => `${index + 1}. ${step}`), notice].join('\n\n');
|
|
29
29
|
}
|
|
@@ -37,10 +37,9 @@ export const desktopHost = {
|
|
|
37
37
|
};
|
|
38
38
|
export const resolveStateRoot = stateRoot => runScript('resolve-state-root.ps1', ['-StateRoot', stateRoot]);
|
|
39
39
|
|
|
40
|
-
export async function
|
|
40
|
+
export async function removeDesktopShortcut({ serviceRoot, nodeExecutable }, destination) {
|
|
41
41
|
const entry = path.join(serviceRoot, 'dist/bin/codex-remote.mjs');
|
|
42
42
|
const args = ['-NodePath', nodeExecutable, '-Entry', entry];
|
|
43
|
-
if (remove) args.push('-Remove');
|
|
44
43
|
if (destination) args.push('-Destination', destination);
|
|
45
44
|
return JSON.parse(await runScript('desktop-shortcuts.ps1', args));
|
|
46
45
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
param([string]$NodePath, [string]$Entry, [
|
|
1
|
+
param([string]$NodePath, [string]$Entry, [string]$Destination)
|
|
2
2
|
$ErrorActionPreference = 'Stop'
|
|
3
3
|
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
|
4
4
|
$shell = New-Object -ComObject WScript.Shell
|
|
5
5
|
if (-not $Destination) { $Destination = $shell.SpecialFolders.Item('Desktop') }
|
|
6
|
-
|
|
6
|
+
# Only identify and remove the retired shortcut; never create a new launcher.
|
|
7
|
+
$launcher = Join-Path $PSScriptRoot 'desktop-shortcut-run.ps1'
|
|
7
8
|
$items = @(@{Name='Code Relax - Codex 内核'; Command='shared-start'})
|
|
8
9
|
$results = @()
|
|
9
10
|
foreach ($item in $items) {
|
|
@@ -11,18 +12,10 @@ foreach ($item in $items) {
|
|
|
11
12
|
$arguments = '-NoProfile -NoExit -ExecutionPolicy Bypass -File "' + $launcher + '" -NodePath "' + $NodePath + '" -Entry "' + $Entry + '" -Action ' + $item.Command
|
|
12
13
|
if (Test-Path -LiteralPath $target) {
|
|
13
14
|
$existing = $shell.CreateShortcut($target)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if ($Remove) {
|
|
17
|
-
if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target }
|
|
18
|
-
} else {
|
|
19
|
-
$link = $shell.CreateShortcut($target)
|
|
20
|
-
$link.TargetPath = Join-Path $env:SystemRoot 'System32/WindowsPowerShell/v1.0/powershell.exe'
|
|
21
|
-
$link.Arguments = $arguments
|
|
22
|
-
$link.WorkingDirectory = Split-Path $Entry -Parent
|
|
23
|
-
$link.Description = $item.Name
|
|
24
|
-
$link.Save()
|
|
15
|
+
$powershell = Join-Path $env:SystemRoot 'System32/WindowsPowerShell/v1.0/powershell.exe'
|
|
16
|
+
if ($existing.Arguments -ne $arguments -or $existing.TargetPath -ne $powershell) { continue }
|
|
25
17
|
}
|
|
18
|
+
if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target }
|
|
26
19
|
$results += $target
|
|
27
20
|
}
|
|
28
21
|
ConvertTo-Json -InputObject $results -Compress
|
|
@@ -20,21 +20,12 @@ async function detect(script) {
|
|
|
20
20
|
export const sharedServiceUrl = () => detect('get-shared-service-url.ps1');
|
|
21
21
|
export const lanAddress = async () => (await lanNetworks())[0]?.address || '';
|
|
22
22
|
export { lanNetworks };
|
|
23
|
-
export function migrateState(stateRoot) {
|
|
24
|
-
fs.mkdirSync(stateRoot, { recursive: true });
|
|
25
|
-
for (const name of ['bridge-config.json', 'bridge-session.json']) {
|
|
26
|
-
const target = path.join(stateRoot, name);
|
|
27
|
-
const old = path.join(bridgeRoot, '../.codex/bridge', name);
|
|
28
|
-
if (!fs.existsSync(target) && fs.existsSync(old)) fs.copyFileSync(old, target, fs.constants.COPYFILE_EXCL);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
23
|
export async function bridgeEnvironment() {
|
|
32
24
|
const environment = { ...process.env };
|
|
33
25
|
const installation = readRecord(path.join(runtimePaths(environment).serviceRoot, 'installation.json'));
|
|
34
26
|
const executable = installation?.nodeExecutable;
|
|
35
27
|
if (!executable || !fs.existsSync(executable)) throw new Error('安装时选用的 Node 不存在,请重新安装 Code Relax。');
|
|
36
28
|
environment.CODEX_MCP_NODE_PATH = executable;
|
|
37
|
-
environment.CODEX_REMOTE_LEGACY_UPLOADS_ROOTS = JSON.stringify(installation.legacyUploadsRoots || []);
|
|
38
29
|
delete environment.CODEX_APP_TOOLS_PIPE_PATH;
|
|
39
30
|
return { executable, environment };
|
|
40
31
|
}
|
|
@@ -47,7 +47,7 @@ const gb=value=>value==null?'—':number(Number(value)/1073741824,2)+' GB';
|
|
|
47
47
|
el('trafficUsage').textContent=trafficFresh?'已用 '+gb(cloud.trafficUsedBytes)+' / '+gb(cloud.trafficTotalBytes):'流量暂不可用';
|
|
48
48
|
el('trafficNote').textContent=trafficFresh?'更新时间 '+new Date(cloud.trafficSampledAt).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'}):'暂不可用';
|
|
49
49
|
renderTrafficTimeline(cloud,trafficFresh);
|
|
50
|
-
el('metrics').replaceChildren(metric(number(state.activeRelaySessions)+' / '+number(state.maxRelaySessions),'受限转发',null,state.maxRelaySessions?toneAbove(state.activeRelaySessions/state.maxRelaySessions,.75,.9):''),metric(number(state.activeExemptRelaySessions),'白名单转发'),metric(number(state.websocketConnections),'WebSocket 连接'),metric(number(state.onlineComputers),'在线工作站'),metric(number(state.activePhones),'
|
|
50
|
+
el('metrics').replaceChildren(metric(number(state.activeRelaySessions)+' / '+number(state.maxRelaySessions),'受限转发',null,state.maxRelaySessions?toneAbove(state.activeRelaySessions/state.maxRelaySessions,.75,.9):''),metric(number(state.activeExemptRelaySessions),'白名单转发'),metric(number(state.websocketConnections),'WebSocket 连接'),metric(number(state.onlineComputers),'在线工作站'),metric(number(state.activePhones),'活动移动端'))}
|
|
51
51
|
let editorItem=null,editorMode='new',writing=false;
|
|
52
52
|
function openEditor(item=null,duplicate=false){editorItem=duplicate?null:item;editorMode=duplicate?'new':item?.status==='draft'?'draft':item?'view':'new';el('editorHeading').textContent=duplicate?'复制为新公告':editorMode==='draft'?'编辑草稿':editorMode==='view'?'查看公告':'新建公告';el('title').value=item?.title||'';el('message').value=item?.message||'';el('title').disabled=editorMode==='view';el('message').disabled=editorMode==='view';el('editorActions').hidden=editorMode==='view';el('editorNote').textContent=editorMode==='view'?'已发布内容只读;可复制为新公告再修改。':'发布后会立即替换当前公告并通知在线设备。';el('editorError').hidden=true;el('announcementEditor').showModal();if(editorMode!=='view')el('title').focus()}
|
|
53
53
|
function action(label,className,handler,disabled=false){const button=node('button',className,label);button.type='button';button.disabled=disabled;button.addEventListener('click',handler);return button}
|
|
@@ -15,19 +15,65 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
15
15
|
serverNoContextTakeover: true, clientNoContextTakeover: true,
|
|
16
16
|
threshold: 1024, zlibDeflateOptions: { level: 3 },
|
|
17
17
|
} });
|
|
18
|
-
const locals = new Map();
|
|
19
|
-
|
|
18
|
+
const locals = new Map();
|
|
19
|
+
const fileLocals = new Map();
|
|
20
|
+
let fileRemote, fileRetryTimer, heartbeat, operation, lastClose, closing = false;
|
|
20
21
|
const alive = () => { clearTimeout(heartbeat); heartbeat = setTimeout(() => remote.terminate(), 75000); };
|
|
21
22
|
remote.on('open', alive); remote.on('ping', alive); remote.on('message', alive);
|
|
22
23
|
function dropLocal(session) {
|
|
23
24
|
const current = locals.get(session);
|
|
24
25
|
if (!current) return;
|
|
25
26
|
locals.delete(session); clearTimeout(current.timer); current.socket?.terminate();
|
|
26
|
-
current.probe?.then(probe => probe.close());
|
|
27
|
-
|
|
28
|
-
function dropLocals() {
|
|
29
|
-
for (const session of [...locals.keys()]) dropLocal(session);
|
|
27
|
+
current.probe?.then(probe => probe.close());
|
|
28
|
+
current.diagnosticProbe?.then(probe => probe.close());
|
|
30
29
|
}
|
|
30
|
+
function dropLocals() {
|
|
31
|
+
for (const session of [...locals.keys()]) dropLocal(session);
|
|
32
|
+
for (const socket of fileLocals.values()) socket.terminate();
|
|
33
|
+
fileLocals.clear(); clearTimeout(fileRetryTimer); fileRemote?.terminate(); fileRemote = undefined;
|
|
34
|
+
}
|
|
35
|
+
function startFileReceiver() {
|
|
36
|
+
if (!identity || closing || remote.readyState !== WebSocket.OPEN) return;
|
|
37
|
+
const receiver = fileRemote = new WebSocket(relayUrl, { maxPayload: 512 * 1024, perMessageDeflate: false });
|
|
38
|
+
receiver.on('open', () => send(receiver, { type: 'file-host', deviceId: identity.id, deviceSecret: identity.secret }));
|
|
39
|
+
receiver.on('error', error => events.emit('diagnostic', `File receiver: ${error.message}`));
|
|
40
|
+
receiver.on('close', () => {
|
|
41
|
+
for (const socket of fileLocals.values()) socket.terminate();
|
|
42
|
+
fileLocals.clear();
|
|
43
|
+
if (fileRemote === receiver) {
|
|
44
|
+
fileRemote = undefined;
|
|
45
|
+
if (!closing && remote.readyState === WebSocket.OPEN) fileRetryTimer = setTimeout(startFileReceiver, 3000);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
receiver.on('message', (bytes, binary) => {
|
|
49
|
+
try {
|
|
50
|
+
const message = decode(bytes, binary);
|
|
51
|
+
if (message.type === 'file-host-ready') return;
|
|
52
|
+
if (message.type === 'file-open') {
|
|
53
|
+
const connection = bridgeConnection?.() || { url: bridgeUrl, token: bridgeToken };
|
|
54
|
+
const url = new URL(connection.url); url.pathname = '/api/file-transfer';
|
|
55
|
+
loopbackUrl(url.href, '/api/file-transfer');
|
|
56
|
+
const socket = new WebSocket(url.href, { maxPayload: 512 * 1024, perMessageDeflate: false });
|
|
57
|
+
fileLocals.set(message.session, socket);
|
|
58
|
+
socket.on('open', () => send(socket, { type: 'auth', token: connection.token }));
|
|
59
|
+
socket.on('error', error => events.emit('diagnostic', `Local file receiver: ${error.message}`));
|
|
60
|
+
socket.on('close', () => {
|
|
61
|
+
if (fileLocals.get(message.session) !== socket) return;
|
|
62
|
+
fileLocals.delete(message.session);
|
|
63
|
+
send(receiver, { type: 'file-close', session: message.session });
|
|
64
|
+
});
|
|
65
|
+
socket.on('message', (data, isBinary) => {
|
|
66
|
+
if (isBinary || fileLocals.get(message.session) !== socket) return socket.terminate();
|
|
67
|
+
send(receiver, { type: 'file-frame', session: message.session, payload: data.toString() });
|
|
68
|
+
});
|
|
69
|
+
} else if (message.type === 'file-frame') {
|
|
70
|
+
const socket = fileLocals.get(message.session);
|
|
71
|
+
if (socket?.readyState === WebSocket.OPEN) socket.send(message.payload);
|
|
72
|
+
} else if (message.type === 'file-close') fileLocals.get(message.session)?.terminate();
|
|
73
|
+
else throw new Error('Invalid file receiver frame');
|
|
74
|
+
} catch (error) { events.emit('diagnostic', error.message); receiver.close(1008, 'Invalid file frame'); }
|
|
75
|
+
});
|
|
76
|
+
}
|
|
31
77
|
const ready = new Promise((resolve, reject) => {
|
|
32
78
|
let registered = false;
|
|
33
79
|
const timer = setTimeout(() => { reject(new Error('Relay registration timeout')); remote.terminate(); }, 5000);
|
|
@@ -44,7 +90,7 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
44
90
|
remote.on('message', (bytes, binary) => {
|
|
45
91
|
try {
|
|
46
92
|
const message = decode(bytes, binary);
|
|
47
|
-
if (message.type === 'registered' && !registered) { registered = true; clearTimeout(timer); resolve(message); return; }
|
|
93
|
+
if (message.type === 'registered' && !registered) { registered = true; clearTimeout(timer); startFileReceiver(); resolve(message); return; }
|
|
48
94
|
if (!registered) throw new Error('Relay not registered');
|
|
49
95
|
if (message.type === 'pair-code' || message.type === 'revoked' || message.type === 'status') {
|
|
50
96
|
if (operation && message.commandId === operation.id && message.type === operation.responseType) {
|
|
@@ -102,10 +148,36 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
102
148
|
}
|
|
103
149
|
} catch (error) { events.emit('diagnostic', error.message); socket.terminate(); }
|
|
104
150
|
});
|
|
105
|
-
} else if (message.type === 'p2p-probe' && locals.has(message.session)) {
|
|
106
|
-
const current = locals.get(message.session);
|
|
107
|
-
if (!current.ready || !current.p2pProbe || !validProbeSignal(message)) throw new Error('Invalid probe signal');
|
|
108
|
-
if (message.
|
|
151
|
+
} else if (message.type === 'p2p-probe' && locals.has(message.session)) {
|
|
152
|
+
const current = locals.get(message.session);
|
|
153
|
+
if (!current.ready || !current.p2pProbe || !validProbeSignal(message)) throw new Error('Invalid probe signal');
|
|
154
|
+
if (message.purpose === 'diagnostic') {
|
|
155
|
+
if (message.kind === 'start' && current.diagnosticProbeId !== message.probeId) {
|
|
156
|
+
void current.diagnosticProbe?.then(probe => probe.close());
|
|
157
|
+
current.diagnosticProbeId = message.probeId;
|
|
158
|
+
const probeId = message.probeId;
|
|
159
|
+
current.diagnosticProbe = createP2pProbe({ iceServers,
|
|
160
|
+
signal: signal => {
|
|
161
|
+
if (locals.get(message.session) === current && current.diagnosticProbeId === probeId) {
|
|
162
|
+
send(remote, { type: 'p2p-probe', purpose: 'diagnostic', session: message.session,
|
|
163
|
+
version: 1, probeId, ...signal });
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
report: (state, diagnostic) => events.emit('p2p-probe', { state, diagnostic })
|
|
167
|
+
}).catch(() => {
|
|
168
|
+
if (locals.get(message.session) === current && current.diagnosticProbeId === probeId) {
|
|
169
|
+
send(remote, { type: 'p2p-probe', purpose: 'diagnostic', session: message.session,
|
|
170
|
+
version: 1, probeId, kind: 'close' });
|
|
171
|
+
}
|
|
172
|
+
return { receive() {}, close() {} };
|
|
173
|
+
});
|
|
174
|
+
} else if (current.diagnosticProbe && current.diagnosticProbeId === message.probeId) {
|
|
175
|
+
void current.diagnosticProbe.then(probe => probe.receive(message));
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (message.purpose) throw new Error('Invalid probe purpose');
|
|
180
|
+
if (message.kind === 'start' && current.probeId !== message.probeId) {
|
|
109
181
|
void current.probe?.then(probe => probe.close());
|
|
110
182
|
current.probeId = message.probeId;
|
|
111
183
|
current.routeSignals = Promise.resolve();
|
|
@@ -157,6 +229,6 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
157
229
|
updateLanCandidates(candidates) {
|
|
158
230
|
if (!send(remote, { type: 'lan-candidates', candidates })) throw new Error('中继未连接');
|
|
159
231
|
},
|
|
160
|
-
close() { clearTimeout(heartbeat); dropLocals(); remote.terminate(); }
|
|
232
|
+
close() { closing = true; clearTimeout(heartbeat); dropLocals(); remote.terminate(); }
|
|
161
233
|
};
|
|
162
234
|
}
|
|
@@ -58,7 +58,7 @@ async function registrationKey(config) {
|
|
|
58
58
|
if (!validSecret(hostKey)) throw new Error('中继注册凭据格式无效。');
|
|
59
59
|
return hostKey;
|
|
60
60
|
}
|
|
61
|
-
export async function selfRelay(command, stateRoot
|
|
61
|
+
export async function selfRelay(command, stateRoot) {
|
|
62
62
|
const previous = readRecord(recordPath(stateRoot));
|
|
63
63
|
let current;
|
|
64
64
|
if (previous) {
|
|
@@ -77,22 +77,18 @@ export async function selfRelay(command, stateRoot, requiredMode = '') {
|
|
|
77
77
|
return { stopped: true, displayText: '私有中继连接器已停止;Bridge 和 Codex 保持运行。' };
|
|
78
78
|
}
|
|
79
79
|
if (current) {
|
|
80
|
-
if (requiredMode) {
|
|
81
|
-
const bridge = await serviceHealth(readRecord(path.join(stateRoot, 'bridge-session.json')));
|
|
82
|
-
if (bridge?.transportMode !== requiredMode) throw new Error('Codex 内核未就绪;请先执行 shared-start。');
|
|
83
|
-
}
|
|
84
80
|
if (command === 'relay-revoke') return controlRequest(previous, 'POST', 'revoke');
|
|
85
81
|
if (command === 'relay-pair' || (current.relayConnected && !current.paired && !current.qrPath)) return controlRequest(previous, 'POST', 'pair');
|
|
86
82
|
if (command === 'relay-start' && current.relayConnected) return controlRequest(previous, 'POST', 'refresh-lan');
|
|
87
83
|
return current;
|
|
88
84
|
}
|
|
89
|
-
if (command === 'relay-revoke') throw new Error('
|
|
85
|
+
if (command === 'relay-revoke') throw new Error('请先启动连接器,再撤销移动端授权。');
|
|
90
86
|
fs.mkdirSync(stateRoot, { recursive: true });
|
|
91
87
|
const instanceId = randomUUID();
|
|
92
88
|
const log = path.join(stateRoot, 'self-relay.log');
|
|
93
89
|
const readyPath = path.join(stateRoot, `relay-ready-${instanceId}.json`);
|
|
94
90
|
const pid = await startBackground(process.execPath, [script, '--worker', stateRoot, instanceId,
|
|
95
|
-
command === 'relay-pair' ? 'pair' : ''
|
|
91
|
+
command === 'relay-pair' ? 'pair' : ''], process.env, [log, log]);
|
|
96
92
|
try {
|
|
97
93
|
const deadline = Date.now() + 25000;
|
|
98
94
|
while (true) {
|
|
@@ -110,16 +106,13 @@ export async function selfRelay(command, stateRoot, requiredMode = '') {
|
|
|
110
106
|
finally { fs.rmSync(readyPath, { force: true }); }
|
|
111
107
|
}
|
|
112
108
|
|
|
113
|
-
async function worker(stateRoot, instanceId, refresh = false
|
|
109
|
+
async function worker(stateRoot, instanceId, refresh = false) {
|
|
114
110
|
const config = await loadConfig(stateRoot);
|
|
115
111
|
const session = () => readRecord(path.join(stateRoot, 'bridge-session.json'));
|
|
116
112
|
let health = await serviceHealth(session());
|
|
117
113
|
if (health?.status !== 'ok') throw new Error('Bridge 尚未运行,请先执行 relax start --json。');
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
const legacy = readRecord(legacyPath);
|
|
121
|
-
const stored = await credentialStore('load');
|
|
122
|
-
let identities = stored.record || legacy || { version: 1, servers: {} };
|
|
114
|
+
const stored = await credentialStore('load');
|
|
115
|
+
const identities = stored.record || { version: 1, servers: {} };
|
|
123
116
|
const validate = value => {
|
|
124
117
|
if (value?.version !== 1 || !value.servers || typeof value.servers !== 'object' || Array.isArray(value.servers)) throw new Error('本机设备身份损坏,未自动覆盖');
|
|
125
118
|
for (const [url, item] of Object.entries(value.servers)) {
|
|
@@ -128,15 +121,6 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
|
|
|
128
121
|
}
|
|
129
122
|
};
|
|
130
123
|
validate(identities);
|
|
131
|
-
if (legacy) {
|
|
132
|
-
validate(legacy);
|
|
133
|
-
if (stored.record && JSON.stringify(stored.record) !== JSON.stringify(legacy)) throw new Error('新旧设备身份冲突,未覆盖任何凭据');
|
|
134
|
-
if (!stored.record) {
|
|
135
|
-
const verified = await credentialStore('save', identities);
|
|
136
|
-
if (JSON.stringify(verified.record) !== JSON.stringify(identities)) throw new Error('凭据迁移校验失败');
|
|
137
|
-
}
|
|
138
|
-
fs.rmSync(legacyPath);
|
|
139
|
-
}
|
|
140
124
|
let identity = identities.servers[config.url];
|
|
141
125
|
if (!identity) {
|
|
142
126
|
identity = { id: randomUUID(), secret: randomBytes(32).toString('base64url'), registered: false };
|
|
@@ -163,7 +147,7 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
|
|
|
163
147
|
const status = () => {
|
|
164
148
|
const available = online && !paired && qrPath && Date.now() < expiresAt;
|
|
165
149
|
const next = !online ? (lastError || '电脑连接器正在恢复中继连接,已保存授权。')
|
|
166
|
-
: paired ? '
|
|
150
|
+
: paired ? '设备已授权,移动端可直接重连;网络断开不需要重新扫码。'
|
|
167
151
|
: available ? '用要添加的移动设备在有效期内扫描统一配对二维码。连接器在后台运行。'
|
|
168
152
|
: '尚未完成设备授权;调用 relax pair --json 生成配对二维码。';
|
|
169
153
|
return { running: !closing, relayConnected: online, mode: 'self-relay', transportMode: health.transportMode,
|
|
@@ -278,7 +262,7 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
|
|
|
278
262
|
}
|
|
279
263
|
|
|
280
264
|
if (process.argv[1] && path.resolve(process.argv[1]) === script && process.argv[2] === '--worker') {
|
|
281
|
-
worker(process.argv[3], process.argv[4], process.argv[5] === 'pair'
|
|
265
|
+
worker(process.argv[3], process.argv[4], process.argv[5] === 'pair').catch(error => {
|
|
282
266
|
writeRecord(path.join(process.argv[3], `relay-ready-${process.argv[4]}.json`), { instanceId: process.argv[4], pid: process.pid, error: error.message });
|
|
283
267
|
console.error(error.message); process.exitCode = 1;
|
|
284
268
|
});
|
|
@@ -110,7 +110,7 @@ export function validProbeSignal(value) {
|
|
|
110
110
|
if (!value || value.version !== 1 || !/^[\w-]{16,128}$/.test(value.probeId || '')) return false;
|
|
111
111
|
if (['start', 'close'].includes(value.kind)) return true;
|
|
112
112
|
if (['offer', 'answer'].includes(value.kind)) return typeof value.sdp === 'string' && value.sdp.length <= 65536 &&
|
|
113
|
-
(value.direct === undefined || value.direct === 1) &&
|
|
113
|
+
(value.direct === undefined || value.direct === 0 || value.direct === 1) &&
|
|
114
114
|
(value.iceServers === undefined || validProbeIceServers(value.iceServers));
|
|
115
115
|
if (value.kind === 'candidate') return typeof value.candidate === 'string' && value.candidate.length <= 2048 &&
|
|
116
116
|
typeof value.mid === 'string' && value.mid.length <= 64;
|
|
@@ -25,7 +25,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
25
25
|
const debugAllowlist = readDebugAllowlist(debugAllowlistFile);
|
|
26
26
|
const adminState = relayAdminState(adminStateFile, maxRelaySessions);
|
|
27
27
|
let activeRelaySessions = 0;
|
|
28
|
-
for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map() });
|
|
28
|
+
for (const record of store.entries()) rooms.set(record.id, { ...record, durable: true, connections: new Map(), observers: new Map(), uploadClients: new Map() });
|
|
29
29
|
function commit(room, changes) {
|
|
30
30
|
const next = { ...room, ...changes, updatedAt: new Date().toISOString() };
|
|
31
31
|
if (room.durable) {
|
|
@@ -195,6 +195,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
195
195
|
clearTimeout(authTimer); clearInterval(heartbeat);
|
|
196
196
|
if (role === 'host' && room?.host === ws) {
|
|
197
197
|
room.host = undefined; room.hostName = undefined; room.lanCandidates = [];
|
|
198
|
+
room.uploadHost?.close(4410, 'Computer offline');
|
|
199
|
+
for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'Computer offline');
|
|
198
200
|
for (const observer of room.observers.values()) send(observer.ws, { type: 'observing', online: false });
|
|
199
201
|
if (!room.durable) rooms.delete(room.id);
|
|
200
202
|
for (const connection of room.connections.values()) connection.ws.close(4410, 'Computer offline; authorization retained');
|
|
@@ -204,12 +206,41 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
204
206
|
if (room.host) send(room.host, { type: 'detach', session });
|
|
205
207
|
} else if (role === 'observer' && room?.observers.get(clientId)?.ws === ws) {
|
|
206
208
|
room.observers.delete(clientId);
|
|
207
|
-
}
|
|
209
|
+
} else if (role === 'file-host' && room?.uploadHost === ws) {
|
|
210
|
+
room.uploadHost = undefined;
|
|
211
|
+
for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'File receiver offline');
|
|
212
|
+
} else if (role === 'file-client' && room?.uploadClients?.get(clientId)?.ws === ws) {
|
|
213
|
+
room.uploadClients.delete(clientId);
|
|
214
|
+
if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session });
|
|
215
|
+
}
|
|
208
216
|
});
|
|
209
217
|
ws.on('message', (bytes, binary) => {
|
|
210
218
|
try {
|
|
211
|
-
const message = decode(bytes, binary);
|
|
212
|
-
if (!role) {
|
|
219
|
+
const message = decode(bytes, binary);
|
|
220
|
+
if (!role) {
|
|
221
|
+
if (message.type === 'file-host') {
|
|
222
|
+
room = rooms.get(message.deviceId);
|
|
223
|
+
if (!room?.host || room.status !== 'active' || !matches(message.deviceSecret, room.hostHash)) return fail();
|
|
224
|
+
role = 'file-host'; clearTimeout(authTimer);
|
|
225
|
+
room.uploadClients ||= new Map();
|
|
226
|
+
room.uploadHost?.close(4409, 'File receiver replaced');
|
|
227
|
+
room.uploadHost = ws;
|
|
228
|
+
send(ws, { type: 'file-host-ready' }); return;
|
|
229
|
+
}
|
|
230
|
+
if (message.type === 'file-client') {
|
|
231
|
+
room = rooms.get(message.room);
|
|
232
|
+
const client = room?.clients.find(item => item.id === message.clientId);
|
|
233
|
+
if (!room?.host || !room.uploadHost || room.status !== 'active' || !client ||
|
|
234
|
+
!matches(message.credential, client.hash)) return fail();
|
|
235
|
+
room.uploadClients ||= new Map();
|
|
236
|
+
if (room.uploadClients.has(message.clientId) ||
|
|
237
|
+
[...rooms.values()].reduce((sum, item) => sum + (item.uploadClients?.size || 0), 0) >= 2) {
|
|
238
|
+
return fail(4429, 'Too many file uploads');
|
|
239
|
+
}
|
|
240
|
+
role = 'file-client'; clientId = message.clientId; session = randomUUID(); clearTimeout(authTimer);
|
|
241
|
+
room.uploadClients.set(clientId, { ws, session });
|
|
242
|
+
send(room.uploadHost, { type: 'file-open', session }); return;
|
|
243
|
+
}
|
|
213
244
|
if (message.type === 'host') {
|
|
214
245
|
const durable = message.deviceId !== undefined;
|
|
215
246
|
if (durable && (!validId(message.deviceId) || !validSecret(message.deviceSecret))) return fail();
|
|
@@ -229,13 +260,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
229
260
|
if (rooms.size >= 256) return fail(4429, 'Device limit reached');
|
|
230
261
|
const now = new Date().toISOString();
|
|
231
262
|
room = { id: durable ? message.deviceId : randomUUID(), durable,
|
|
232
|
-
revision: 0, status: 'active', clients: [], connections: new Map(), observers: new Map(), createdAt: now, updatedAt: now,
|
|
263
|
+
revision: 0, status: 'active', clients: [], connections: new Map(), observers: new Map(), uploadClients: new Map(), createdAt: now, updatedAt: now,
|
|
233
264
|
...(durable ? { hostHash: digest(message.deviceSecret) } : {}) };
|
|
234
265
|
commit(room, {}); rooms.set(room.id, room);
|
|
235
266
|
}
|
|
236
267
|
const oldHost = room.host;
|
|
237
268
|
room.observers ||= new Map();
|
|
238
269
|
room.host = ws; room.hostName = hostName; room.lanCandidates = lanCandidates;
|
|
270
|
+
if (oldHost && oldHost !== ws) room.uploadHost?.close(4409, 'Computer connection replaced');
|
|
239
271
|
room.p2pProbe = message.p2pProbe === 1; room.p2pProbeRepeat = message.p2pProbeRepeat === true;
|
|
240
272
|
role = 'host'; clearTimeout(authTimer);
|
|
241
273
|
if (oldHost && oldHost !== ws) {
|
|
@@ -331,6 +363,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
331
363
|
connection.ws.close(4401, 'Device authorization revoked');
|
|
332
364
|
}
|
|
333
365
|
room.connections.clear();
|
|
366
|
+
for (const client of room.uploadClients?.values() || []) client.ws.close(4401, 'Device authorization revoked');
|
|
367
|
+
room.uploadClients?.clear();
|
|
334
368
|
for (const observer of room.observers.values()) observer.ws.close(4401, 'Device authorization revoked');
|
|
335
369
|
room.observers.clear();
|
|
336
370
|
send(ws, { type: 'authorization-changed', clients: [], revision: room.revision });
|
|
@@ -342,12 +376,26 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
342
376
|
else if (message.type === 'ready') queueClientMessage(connection, { type: 'ready', p2pProbe: connection.p2pProbe ? 1 : 0,
|
|
343
377
|
p2pProbeRepeat: connection.p2pProbe && connection.repeat });
|
|
344
378
|
else if (message.type === 'p2p-probe' && connection.p2pProbe && validProbeSignal(message)) {
|
|
345
|
-
|
|
346
|
-
if (
|
|
379
|
+
const diagnostic = message.purpose === 'diagnostic';
|
|
380
|
+
if (message.purpose && !diagnostic) return fail(1008, 'Invalid probe purpose');
|
|
381
|
+
if ((diagnostic ? connection.diagnosticProbeId : connection.probeId) !== message.probeId) return;
|
|
382
|
+
if (++connection[diagnostic ? 'diagnosticSignals' : 'signals'] > 150) return fail(1008, 'Probe signal limit');
|
|
347
383
|
send(connection.ws, { ...message, session: undefined });
|
|
348
384
|
}
|
|
349
385
|
else if (message.type === 'detach') connection.ws.close(1011, 'Local Bridge unavailable');
|
|
350
386
|
else fail();
|
|
387
|
+
} else if (role === 'file-host' && room.uploadHost === ws) {
|
|
388
|
+
const client = [...room.uploadClients.values()].find(item => item.session === message.session);
|
|
389
|
+
if (!client) return;
|
|
390
|
+
if (message.type === 'file-frame' && typeof message.payload === 'string' && message.payload.length <= 512 * 1024) {
|
|
391
|
+
if (client.ws.bufferedAmount > 1024 * 1024) return client.ws.close(1013, 'File receiver too slow');
|
|
392
|
+
send(client.ws, { type: 'file-frame', payload: message.payload });
|
|
393
|
+
} else if (message.type === 'file-close') client.ws.close(1011, 'File receiver unavailable');
|
|
394
|
+
else fail(1008, 'Invalid file frame');
|
|
395
|
+
} else if (role === 'file-client' && room.uploadClients?.get(clientId)?.ws === ws) {
|
|
396
|
+
if (message.type !== 'file-frame' || typeof message.payload !== 'string' || message.payload.length > 512 * 1024) return fail(1008, 'Invalid file frame');
|
|
397
|
+
if (room.uploadHost?.bufferedAmount > 1024 * 1024) return fail(1013, 'File receiver too slow');
|
|
398
|
+
send(room.uploadHost, { type: 'file-frame', session, payload: message.payload });
|
|
351
399
|
} else if (role === 'observer' && room.observers.get(clientId)?.ws === ws && message.type === 'official-computer') {
|
|
352
400
|
const computer = normalizeOfficialComputer(message);
|
|
353
401
|
commit(room, { officialComputer: computer });
|
|
@@ -355,15 +403,22 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
355
403
|
} else if (role === 'client' && room.connections.get(clientId)?.ws === ws && message.type === 'p2p-probe') {
|
|
356
404
|
const connection = room.connections.get(clientId);
|
|
357
405
|
if (!connection.p2pProbe || !validProbeSignal(message)) return fail(1008, 'Invalid probe signal');
|
|
406
|
+
const diagnostic = message.purpose === 'diagnostic';
|
|
407
|
+
if (message.purpose && !diagnostic) return fail(1008, 'Invalid probe purpose');
|
|
408
|
+
const idField = diagnostic ? 'diagnosticProbeId' : 'probeId';
|
|
409
|
+
const signalsField = diagnostic ? 'diagnosticSignals' : 'signals';
|
|
410
|
+
const startedField = diagnostic ? 'diagnosticProbeStarted' : 'probeStarted';
|
|
358
411
|
if (message.kind === 'start') {
|
|
359
|
-
if (connection
|
|
360
|
-
send(ws, { type: 'p2p-probe', version: 1, probeId: message.probeId,
|
|
412
|
+
if (connection[idField] && (!connection.repeat || Date.now() - connection[startedField] < 2000)) {
|
|
413
|
+
send(ws, { type: 'p2p-probe', version: 1, probeId: message.probeId,
|
|
414
|
+
...(diagnostic ? { purpose: 'diagnostic' } : {}), kind: 'close' }); return;
|
|
361
415
|
}
|
|
362
|
-
connection
|
|
416
|
+
connection[idField] = message.probeId; connection[signalsField] = 0; connection[startedField] = Date.now();
|
|
363
417
|
}
|
|
364
|
-
if (connection
|
|
365
|
-
if (++connection
|
|
418
|
+
if (connection[idField] !== message.probeId) return;
|
|
419
|
+
if (++connection[signalsField] > 150) return fail(1008, 'Probe signal limit');
|
|
366
420
|
send(room.host, { ...message, session });
|
|
421
|
+
if (diagnostic && message.kind === 'close') connection.diagnosticProbeId = undefined;
|
|
367
422
|
} else if (room.connections.get(clientId)?.ws === ws && message.type === 'p2p-route' && message.direct === 1) {
|
|
368
423
|
releaseRelay(room.connections.get(clientId));
|
|
369
424
|
} else if (room.connections.get(clientId)?.ws === ws && message.type === 'data' && typeof message.payload === 'string') {
|