@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.
Files changed (78) hide show
  1. package/README.md +44 -54
  2. package/dist/bin/codex-remote.mjs +22 -58
  3. package/dist/shared/app-server-events.cjs +15 -3
  4. package/dist/shared/file-mentions.cjs +35 -0
  5. package/dist/skills/code-relax/SKILL.md +1 -1
  6. package/dist/src/desktop-launcher.mjs +6 -11
  7. package/dist/src/file-upload-session.mjs +124 -0
  8. package/dist/src/lan-socket-server.mjs +51 -8
  9. package/dist/src/managed-app-server.mjs +9 -7
  10. package/dist/src/onboarding.mjs +3 -3
  11. package/dist/src/platform/windows/desktop-host.mjs +1 -2
  12. package/dist/src/platform/windows/desktop-shortcuts.ps1 +6 -13
  13. package/dist/src/platform/windows/service-host.mjs +0 -9
  14. package/dist/src/self-relay/admin-page.mjs +1 -1
  15. package/dist/src/self-relay/connector.mjs +84 -12
  16. package/dist/src/self-relay/lifecycle.mjs +8 -24
  17. package/dist/src/self-relay/p2p-probe.mjs +1 -1
  18. package/dist/src/self-relay/server.mjs +67 -12
  19. package/dist/src/server.mjs +141 -34
  20. package/dist/src/service-doctor.mjs +1 -1
  21. package/dist/src/service-install.mjs +1 -4
  22. package/dist/src/shared-app-server.mjs +30 -28
  23. package/dist/src/shared-catalog.mjs +1 -11
  24. package/dist/src/shared-recovery.mjs +5 -5
  25. package/dist/src/skill-install.mjs +0 -13
  26. package/package.json +1 -1
  27. package/dist/src/debug-ui.mjs +0 -131
  28. package/dist/src/platform/windows/desktop-shortcut-run.ps1 +0 -5
  29. package/dist/web/activity-view.js +0 -283
  30. package/dist/web/approval-ui.js +0 -188
  31. package/dist/web/capabilities.js +0 -50
  32. package/dist/web/chat-transport.js +0 -196
  33. package/dist/web/chat.css +0 -778
  34. package/dist/web/chat.js +0 -3815
  35. package/dist/web/client-platform.js +0 -11
  36. package/dist/web/community.css +0 -82
  37. package/dist/web/community.html +0 -36
  38. package/dist/web/composer-controller.js +0 -97
  39. package/dist/web/conversation-controller.js +0 -99
  40. package/dist/web/disclosure-state-controller.js +0 -95
  41. package/dist/web/draft-controller.js +0 -103
  42. package/dist/web/harmony-platform.js +0 -52
  43. package/dist/web/highlight-powershell.min.js +0 -39
  44. package/dist/web/highlight.min.js +0 -1262
  45. package/dist/web/history-cache.js +0 -240
  46. package/dist/web/history-controller.js +0 -167
  47. package/dist/web/index.html +0 -288
  48. package/dist/web/lan-session.js +0 -122
  49. package/dist/web/link-action-controller.js +0 -212
  50. package/dist/web/message-send-controller.js +0 -177
  51. package/dist/web/message-view.js +0 -98
  52. package/dist/web/p2p-data-channel.js +0 -50
  53. package/dist/web/p2p-diagnostics.js +0 -28
  54. package/dist/web/p2p-probe.js +0 -123
  55. package/dist/web/page-resume.js +0 -28
  56. package/dist/web/pending-message-store.js +0 -108
  57. package/dist/web/queue-controller.js +0 -82
  58. package/dist/web/resources.json +0 -1
  59. package/dist/web/self-relay-session.js +0 -129
  60. package/dist/web/server-requests.js +0 -67
  61. package/dist/web/station-connection-controller.js +0 -75
  62. package/dist/web/task-list-view.js +0 -289
  63. package/dist/web/thread-attention-controller.js +0 -124
  64. package/dist/web/thread-context-controller.js +0 -30
  65. package/dist/web/thread-list-controller.js +0 -61
  66. package/dist/web/thread-list-sync.js +0 -86
  67. package/dist/web/thread-title-controller.js +0 -57
  68. package/dist/web/timeline-formatters.js +0 -249
  69. package/dist/web/timeline-reducer.js +0 -77
  70. package/dist/web/timeline-renderer.js +0 -161
  71. package/dist/web/timeline-scroll-controller.js +0 -50
  72. package/dist/web/timeline-sync.js +0 -67
  73. package/dist/web/usage-controller.js +0 -258
  74. package/dist/web/vendor/README.md +0 -14
  75. package/dist/web/vendor/highlight-powershell.min.js +0 -39
  76. package/dist/web/vendor/highlight.LICENSE.txt +0 -29
  77. package/dist/web/vendor/highlight.min.js +0 -1262
  78. 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
- server.on('upgrade', (request, socket, head) => {
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
- let allowed = request.url === '/api/socket';
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
- wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws));
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() { for (const ws of wss.clients) ws.terminate(); wss.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() { return { ...this.state, ...(this.restartStatus ? { restart: { ...this.restartStatus } } : {}) }; }
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
- for (const candidate of [readRecord(this.recordPath), readRecord(this.legacyRecordPath)].filter(Boolean)) {
72
- const process = candidate.pid ? await this.host.process(candidate.pid) : null;
73
- if (sameManagedProcess(candidate, process)) { record = candidate; actual = process; break; }
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) || readRecord(this.legacyRecordPath);
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 内核进程身份已经变化,未结束任何进程。');
@@ -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
- `已有移动设备授权。手机可继续使用私有中继;${mode} 会显示验证成功的当前网络和历史记录。`,
11
+ `已有移动设备授权。移动端可继续使用私有中继;${mode} 会显示验证成功的当前网络和历史记录。`,
12
12
  '添加另一台设备时执行 relax pair;每台设备只需扫码配对一次。',
13
13
  ] : [
14
- '手机先安装 Code Relax。当前为鸿蒙 HarmonyOS 7 私测版,调试 HAP 需通过 DevEco/调试设备安装,尚无商店下载入口。',
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 已就绪,请在手机上完成连接。', connectionModesText, ...nextSteps.map((step, index) => `${index + 1}. ${step}`), notice].join('\n\n');
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 desktopShortcuts({ serviceRoot, nodeExecutable }, remove = false, destination) {
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, [switch]$Remove, [string]$Destination)
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
- $launcher = Join-Path $PSScriptRoot 'desktop-shortcut-run.ps1'
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
- if ($existing.Arguments -ne $arguments) { throw ('快捷方式不是本安装创建的,未覆盖或删除:' + $target) }
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
- let heartbeat, operation, lastClose;
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.kind === 'start' && current.probeId !== message.probeId) {
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, requiredMode = '') {
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' : '', requiredMode], process.env, [log, log]);
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, requiredMode = '') {
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
- if (requiredMode && health.transportMode !== requiredMode) throw new Error('Codex 内核未就绪,请先执行 relax shared-start --json。');
119
- const legacyPath = path.join(stateRoot, 'self-relay-device.json');
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', process.argv[6]).catch(error => {
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
- if (connection.probeId !== message.probeId) return;
346
- if (++connection.signals > 150) return fail(1008, 'Probe signal limit');
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.probeId && (!connection.repeat || Date.now() - connection.probeStarted < 2000)) {
360
- send(ws, { type: 'p2p-probe', version: 1, probeId: message.probeId, kind: 'close' }); return;
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.probeId = message.probeId; connection.signals = 0; connection.probeStarted = Date.now();
416
+ connection[idField] = message.probeId; connection[signalsField] = 0; connection[startedField] = Date.now();
363
417
  }
364
- if (connection.probeId !== message.probeId) return;
365
- if (++connection.signals > 150) return fail(1008, 'Probe signal limit');
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') {