@walkhi/code-relax 0.1.0-beta.30 → 0.1.0-beta.31

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 (185) hide show
  1. package/README.md +95 -89
  2. package/dist/bin/cleanup-accounts.mjs +63 -63
  3. package/dist/bin/codex-remote.mjs +374 -367
  4. package/dist/bin/manage-relay-admin.mjs +20 -20
  5. package/dist/bin/migrate-relay-db.mjs +109 -109
  6. package/dist/bin/publish-android-release.mjs +62 -58
  7. package/dist/bin/self-relay-admin.mjs +8 -8
  8. package/dist/bin/self-relay-demo.mjs +33 -33
  9. package/dist/bin/self-relay-server.mjs +66 -66
  10. package/dist/build-platform.json +1 -1
  11. package/dist/get-lan-networks.ps1 +40 -40
  12. package/dist/get-shared-service-url.ps1 +6 -6
  13. package/dist/shared/app-server-events.cjs +422 -412
  14. package/dist/shared/code-browser.cjs +159 -159
  15. package/dist/shared/file-changes.cjs +31 -31
  16. package/dist/shared/file-citations.cjs +117 -117
  17. package/dist/shared/file-mentions.cjs +92 -92
  18. package/dist/shared/message-delivery.cjs +54 -54
  19. package/dist/shared/p2p-data-channel.cjs +72 -72
  20. package/dist/shared/p2p-diagnostics.cjs +28 -28
  21. package/dist/shared/p2p-file-channel.cjs +74 -74
  22. package/dist/shared/plan-pricing.cjs +32 -32
  23. package/dist/shared/question-attention.cjs +40 -0
  24. package/dist/shared/server-requests.cjs +219 -219
  25. package/dist/shared/timeline-sync.cjs +173 -173
  26. package/dist/skills/code-relax/SKILL.md +22 -22
  27. package/dist/src/archived-threads.mjs +22 -0
  28. package/dist/src/background-process.mjs +18 -18
  29. package/dist/src/bounded-process-log.mjs +63 -63
  30. package/dist/src/celsius-client.mjs +107 -107
  31. package/dist/src/code-browser.mjs +100 -100
  32. package/dist/src/desktop-launcher.mjs +46 -46
  33. package/dist/src/dots-approval-timeline.mjs +99 -99
  34. package/dist/src/dots-avatar.mjs +42 -42
  35. package/dist/src/dots-chat-projection.mjs +50 -50
  36. package/dist/src/dots-chat-server.mjs +461 -461
  37. package/dist/src/dots-message-images.mjs +319 -319
  38. package/dist/src/dots-profile.mjs +77 -77
  39. package/dist/src/dots-room-activity.mjs +94 -94
  40. package/dist/src/dots-room-client.mjs +135 -135
  41. package/dist/src/dots-room-projection.mjs +94 -94
  42. package/dist/src/dots-schedules.mjs +66 -66
  43. package/dist/src/dots-summary-tasks.mjs +32 -32
  44. package/dist/src/dots-task-catalog.mjs +430 -430
  45. package/dist/src/dots-task-files.mjs +52 -52
  46. package/dist/src/execution-health.mjs +26 -26
  47. package/dist/src/file-transfer-range.mjs +22 -22
  48. package/dist/src/file-upload-session.mjs +130 -130
  49. package/dist/src/hosted-history-app-server.mjs +103 -103
  50. package/dist/src/hosted-history-cache.mjs +113 -113
  51. package/dist/src/lan-socket-server.mjs +265 -263
  52. package/dist/src/managed-app-server.mjs +244 -244
  53. package/dist/src/message-images.mjs +9 -9
  54. package/dist/src/message-upload-files.mjs +74 -74
  55. package/dist/src/onboarding.mjs +12 -12
  56. package/dist/src/plan-pricing.mjs +47 -47
  57. package/dist/src/platform/README.md +12 -12
  58. package/dist/src/platform/codex-protocol.mjs +46 -46
  59. package/dist/src/platform/codex-runtime.mjs +3 -3
  60. package/dist/src/platform/credential-store.mjs +3 -3
  61. package/dist/src/platform/desktop-host.mjs +3 -3
  62. package/dist/src/platform/desktop-project-registration.mjs +3 -3
  63. package/dist/src/platform/desktop-project-store.mjs +208 -149
  64. package/dist/src/platform/file-opener.mjs +9 -0
  65. package/dist/src/platform/linux/README.md +3 -3
  66. package/dist/src/platform/linux/codex-runtime.mjs +56 -0
  67. package/dist/src/platform/linux/credential-store.mjs +28 -0
  68. package/dist/src/platform/linux/desktop-host.mjs +83 -0
  69. package/dist/src/platform/linux/desktop-installation.mjs +36 -0
  70. package/dist/src/platform/linux/desktop-project-registration.mjs +11 -0
  71. package/dist/src/platform/linux/network-info.mjs +11 -0
  72. package/dist/src/platform/linux/process.mjs +42 -0
  73. package/dist/src/platform/linux/project-locations.mjs +17 -0
  74. package/dist/src/platform/linux/resident-startup.mjs +52 -0
  75. package/dist/src/platform/linux/retained-runtime.mjs +76 -0
  76. package/dist/src/platform/linux/service-host.mjs +18 -0
  77. package/dist/src/platform/linux/system.mjs +49 -0
  78. package/dist/src/platform/linux/user-environment.mjs +7 -0
  79. package/dist/src/platform/macos/README.md +3 -3
  80. package/dist/src/platform/macos/codex-runtime.mjs +125 -125
  81. package/dist/src/platform/macos/credential-store.mjs +10 -10
  82. package/dist/src/platform/macos/desktop-host.mjs +82 -82
  83. package/dist/src/platform/macos/desktop-project-registration.mjs +11 -10
  84. package/dist/src/platform/macos/network-info.mjs +8 -28
  85. package/dist/src/platform/macos/project-locations.mjs +8 -8
  86. package/dist/src/platform/macos/resident-startup.mjs +84 -84
  87. package/dist/src/platform/macos/retained-runtime.mjs +127 -127
  88. package/dist/src/platform/macos/service-host.mjs +18 -18
  89. package/dist/src/platform/macos/system.mjs +29 -29
  90. package/dist/src/platform/macos/user-environment.mjs +7 -7
  91. package/dist/src/platform/native-package.mjs +58 -46
  92. package/dist/src/platform/network-info.mjs +3 -3
  93. package/dist/src/platform/path-identity.mjs +5 -5
  94. package/dist/src/platform/posix/network-candidates.mjs +23 -0
  95. package/dist/src/platform/posix/runtime-paths.mjs +25 -25
  96. package/dist/src/platform/project-locations.mjs +3 -3
  97. package/dist/src/platform/runtime-paths.mjs +8 -8
  98. package/dist/src/platform/select.mjs +5 -5
  99. package/dist/src/platform/service-host.mjs +3 -3
  100. package/dist/src/platform/user-environment.mjs +3 -3
  101. package/dist/src/platform/windows/IsolatedProcess.cs +79 -79
  102. package/dist/src/platform/windows/activate-desktop.ps1 +48 -48
  103. package/dist/src/platform/windows/background-process.mjs +30 -30
  104. package/dist/src/platform/windows/codex-runtime.mjs +215 -215
  105. package/dist/src/platform/windows/credential-store.mjs +27 -27
  106. package/dist/src/platform/windows/credential-store.ps1 +49 -49
  107. package/dist/src/platform/windows/desktop-helper.mjs +179 -179
  108. package/dist/src/platform/windows/desktop-host.mjs +58 -58
  109. package/dist/src/platform/windows/desktop-info.ps1 +14 -14
  110. package/dist/src/platform/windows/desktop-project-registration.mjs +4 -4
  111. package/dist/src/platform/windows/desktop-runtime.ps1 +10 -10
  112. package/dist/src/platform/windows/desktop-shortcuts.ps1 +21 -21
  113. package/dist/src/platform/windows/dots-profile.mjs +49 -49
  114. package/dist/src/platform/windows/launch-worker.mjs +41 -41
  115. package/dist/src/platform/windows/network-info.mjs +34 -34
  116. package/dist/src/platform/windows/project-locations.mjs +16 -16
  117. package/dist/src/platform/windows/resident-start.js +20 -20
  118. package/dist/src/platform/windows/resident-start.ps1 +7 -7
  119. package/dist/src/platform/windows/resident-startup.ps1 +83 -83
  120. package/dist/src/platform/windows/resolve-state-root.ps1 +14 -14
  121. package/dist/src/platform/windows/runtime-paths.mjs +20 -20
  122. package/dist/src/platform/windows/rustdesk-video-probe.mjs +167 -167
  123. package/dist/src/platform/windows/service-host.mjs +31 -31
  124. package/dist/src/platform/windows/start-hidden-console.ps1 +49 -49
  125. package/dist/src/platform/windows/stop-desktop.ps1 +22 -22
  126. package/dist/src/platform/windows/user-environment.mjs +50 -50
  127. package/dist/src/platform/windows/user-environment.ps1 +41 -41
  128. package/dist/src/project-archive.mjs +30 -0
  129. package/dist/src/project-create.mjs +121 -119
  130. package/dist/src/project-management.mjs +59 -69
  131. package/dist/src/project-remove.mjs +55 -0
  132. package/dist/src/project-update.mjs +56 -0
  133. package/dist/src/runtime-discovery.mjs +47 -47
  134. package/dist/src/self-relay/account-cleanup.mjs +30 -30
  135. package/dist/src/self-relay/account-connections.mjs +52 -52
  136. package/dist/src/self-relay/admin-cloud.mjs +101 -101
  137. package/dist/src/self-relay/admin-page.mjs +80 -80
  138. package/dist/src/self-relay/admin-server.mjs +74 -74
  139. package/dist/src/self-relay/admin-state.mjs +127 -127
  140. package/dist/src/self-relay/admin-style.mjs +14 -14
  141. package/dist/src/self-relay/android-releases.mjs +61 -61
  142. package/dist/src/self-relay/client.mjs +61 -61
  143. package/dist/src/self-relay/connector.mjs +439 -438
  144. package/dist/src/self-relay/debug-allowlist.mjs +33 -33
  145. package/dist/src/self-relay/demo.mjs +37 -37
  146. package/dist/src/self-relay/device-info.mjs +27 -0
  147. package/dist/src/self-relay/device-store.mjs +92 -84
  148. package/dist/src/self-relay/diagnostics.mjs +54 -54
  149. package/dist/src/self-relay/email-accounts.mjs +183 -183
  150. package/dist/src/self-relay/file-wire.mjs +13 -13
  151. package/dist/src/self-relay/lan-authorizations.mjs +71 -42
  152. package/dist/src/self-relay/lifecycle.mjs +264 -250
  153. package/dist/src/self-relay/long-pairing.mjs +40 -40
  154. package/dist/src/self-relay/mysql-state.mjs +118 -105
  155. package/dist/src/self-relay/p2p-probe.mjs +159 -159
  156. package/dist/src/self-relay/privacy-page.mjs +26 -26
  157. package/dist/src/self-relay/public-releases.mjs +34 -0
  158. package/dist/src/self-relay/rtc-file-bridge.mjs +41 -41
  159. package/dist/src/self-relay/server.mjs +1006 -932
  160. package/dist/src/self-relay/usage-daily.mjs +55 -55
  161. package/dist/src/self-relay/usage-meter.mjs +141 -141
  162. package/dist/src/self-relay/windows-route-helper.mjs +75 -75
  163. package/dist/src/self-relay/wire.mjs +36 -36
  164. package/dist/src/server.mjs +1861 -1778
  165. package/dist/src/service-control.mjs +82 -82
  166. package/dist/src/service-doctor.mjs +110 -110
  167. package/dist/src/service-health.mjs +33 -33
  168. package/dist/src/service-install.mjs +91 -91
  169. package/dist/src/service-lifecycle.mjs +138 -138
  170. package/dist/src/service-restart.mjs +81 -81
  171. package/dist/src/shared-app-server.mjs +615 -611
  172. package/dist/src/shared-catalog.mjs +132 -132
  173. package/dist/src/shared-recovery.mjs +146 -146
  174. package/dist/src/shared-thread-stream.mjs +164 -164
  175. package/dist/src/skill-install.mjs +51 -51
  176. package/dist/src/thread-title-generation.mjs +150 -150
  177. package/dist/src/turn-model-history.mjs +47 -47
  178. package/dist/src/usage-reset.mjs +17 -17
  179. package/dist/src/workstation-downloads.mjs +120 -120
  180. package/dist/tools/rtc-route-helper.ps1 +99 -99
  181. package/dist/tools/start-rtc-route-helper.ps1 +15 -15
  182. package/dist/web/vendor/markdown-it.LICENSE.txt +22 -22
  183. package/dist/web/vendor/markdown-it.umd.min.js +13 -13
  184. package/package.json +7 -6
  185. package/tools/postinstall.mjs +127 -127
@@ -1,250 +1,264 @@
1
- import path from 'node:path';
2
- import serviceEndpoints from '../../shared/service-endpoints.cjs';
3
- import { randomBytes, randomUUID } from 'node:crypto';
4
- import { hostname } from 'node:os';
5
- import QRCode from 'qrcode';
6
- import { credentialStore } from '../platform/credential-store.mjs';
7
- import { readRecord, writeRecord } from '../service-lifecycle.mjs';
8
- import { serviceHealth } from '../service-health.mjs';
9
- import { controlRequest } from '../service-control.mjs';
10
- import { connectBridgeRelay } from './connector.mjs';
11
- import { validProbeIceServers } from './p2p-probe.mjs';
12
- import { relayAddress, validSecret } from './wire.mjs';
13
- import { saveLanAuthorizations } from './lan-authorizations.mjs';
14
- import { lanNetworks } from '../platform/network-info.mjs';
15
-
16
- const officialConfig = { url: serviceEndpoints.relayUrl };
17
-
18
- async function loadConfig(stateRoot) {
19
- const configPath = path.join(stateRoot, 'self-relay-config.json');
20
- const stored = readRecord(configPath) || {};
21
- const config = { ...officialConfig, ...stored };
22
- if (!stored.url) writeRecord(configPath, config);
23
- if (config.p2pIceServers !== undefined && !validProbeIceServers(config.p2pIceServers)) throw new Error('p2pIceServers 只接受最多 4 个 stun:主机:端口地址');
24
- if (config.p2pWindowsRouteHelper !== undefined && typeof config.p2pWindowsRouteHelper !== 'boolean') throw new Error('p2pWindowsRouteHelper 必须是布尔值');
25
- if (config.p2pWindowsRouteHelper && !/^[A-Za-z0-9_-]{32,128}$/.test(config.p2pWindowsRouteHelperToken || '')) throw new Error('p2pWindowsRouteHelperToken 无效');
26
- const url = process.env.RELAY_URL || serviceEndpoints.resolveRelayUrl(config.url);
27
- if (!url) throw new Error('请先配置 self-relay-config.json 的 url,详见自建中继文档。');
28
- relayAddress(url);
29
- const resolved = relayAddress(url);
30
- const pairingUrl = config.pairingUrl || (resolved === serviceEndpoints.relayUrl ? serviceEndpoints.pairingUrl : `${resolved.startsWith('wss:') ? 'https:' : 'http:'}//${new URL(resolved).host}/pair`);
31
- const pairing = new URL(pairingUrl);
32
- if (!['https:', 'http:'].includes(pairing.protocol) || pairing.pathname !== '/pair' || pairing.search || pairing.hash || pairing.username || pairing.password ||
33
- (pairing.protocol === 'http:' && pairing.hostname !== '127.0.0.1')) throw new Error('self-relay-config.json 的 pairingUrl 必须是 HTTPS /pair 地址。');
34
- return { ...config, url: resolved, pairingUrl: pairing.href };
35
- }
36
- export async function selfRelay(command, stateRoot, details) {
37
- const session = readRecord(path.join(stateRoot, 'bridge-session.json'));
38
- if (!session) {
39
- if (command === 'relay-status') return { running: false, displayText: '桌面服务未运行。' };
40
- if (command === 'relay-stop') return { stopped: true, displayText: '中继连接已停止。' };
41
- throw new Error('桌面服务尚未运行,请先执行 relax start。');
42
- }
43
- return controlRequest(session, 'POST', command, details);
44
- }
45
-
46
- async function createRelayRuntime(stateRoot) {
47
- const config = await loadConfig(stateRoot);
48
- const configPath = path.join(stateRoot, 'self-relay-config.json');
49
- const session = () => readRecord(path.join(stateRoot, 'bridge-session.json'));
50
- let health = await serviceHealth(session());
51
- if (health?.status !== 'ok') throw new Error('桌面服务尚未运行,请先执行 relax start --json。');
52
- const stored = await credentialStore('load');
53
- const identities = stored.record || { version: 2, devices: {} };
54
- const validIdentity = (id, item) => /^[\w-]{16,128}$/.test(id) && item?.id === id &&
55
- validSecret(item.secret) && typeof item.registered === 'boolean';
56
- if (identities.version !== 2 || !identities.devices || typeof identities.devices !== 'object' || Array.isArray(identities.devices) ||
57
- Object.entries(identities.devices).some(([id, item]) => !validIdentity(id, item))) throw new Error('本机设备身份损坏,未自动覆盖');
58
- if (!config.deviceId) {
59
- const known = Object.values(identities.devices);
60
- if (known.length > 1) throw new Error('存在多个工作站身份,请在 self-relay-config.json 中指定 deviceId。');
61
- config.deviceId = known[0]?.id || randomUUID();
62
- }
63
- const createIdentity = config.deviceId === 'new';
64
- if (createIdentity) config.deviceId = randomUUID();
65
- if (!/^[\w-]{16,128}$/.test(config.deviceId)) throw new Error('self-relay-config.json 的 deviceId 无效。');
66
- let identity = identities.devices[config.deviceId];
67
- if (!identity) {
68
- if (!createIdentity && Object.keys(identities.devices).length) throw new Error('找不到配置指定的工作站身份,未自动创建新身份。');
69
- identity = { id: config.deviceId, secret: randomBytes(32).toString('base64url'), registered: false };
70
- identities.devices[identity.id] = identity;
71
- await credentialStore('save', identities);
72
- }
73
- const persistedConfig = readRecord(configPath) || {};
74
- if (persistedConfig.deviceId !== identity.id) writeRecord(configPath, { ...persistedConfig, deviceId: identity.id });
75
- let bridgeError = '';
76
- async function refreshHealth() {
77
- try { health = await serviceHealth(session()) || { transportMode: health.transportMode, status: 'offline' }; bridgeError = ''; }
78
- catch (error) { bridgeError = error.message; health = { transportMode: health.transportMode, status: 'offline' }; }
79
- }
80
- let connector, qrPath, expiresAt = 0, paired = false, clientCount = 0, attachedCount = 0;
81
- let closing = false, online = false, connecting = false, retryTimer, attempts = 0, lastError = '';
82
- const audit = (event, details = {}) => console.log(JSON.stringify({ time: new Date().toISOString(), event, ...details }));
83
- async function currentLanCandidates() {
84
- try {
85
- const bridge = session(), port = bridge?.port || 45831;
86
- return (await lanNetworks()).map(network => ({ ...network, address: `http://${network.address}:${port}` }));
87
- } catch (error) {
88
- audit('lan-candidates-unavailable', { message: error.message }); return [];
89
- }
90
- }
91
- const status = () => {
92
- const available = online && !paired && qrPath && Date.now() < expiresAt;
93
- const next = !online ? (lastError || '电脑连接器正在恢复中继连接,已保存授权。')
94
- : paired ? '设备已授权,移动端可直接重连;网络断开不需要重新扫码。'
95
- : available ? '用要添加的移动设备在有效期内扫描统一配对二维码。连接器在后台运行。'
96
- : '尚未完成设备授权;调用 relax pair --json 生成配对二维码。';
97
- return { running: !closing, relayConnected: online, mode: 'self-relay', transportMode: health.transportMode,
98
- relayUrl: config.url, deviceId: identity.id, paired, clientCount, authorization: online ? (paired ? 'authorized' : 'unpaired') : 'unverified', phoneConnected: attachedCount > 0,
99
- bridgeReady: health.status === 'ok' && health.execution?.ready === true && health.sharedAppServerConnected === true, bridgeError,
100
- expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
101
- ...(available ? { qrPath } : {}), mobileAccess: 'unverified', lastError,
102
- displayText: `Code Relax 中继 · ${health.transportMode === 'lan-shared' ? 'Codex 内核已就绪' : 'Codex 内核未就绪'}\n${next}` };
103
- };
104
- async function shutdown() {
105
- if (closing) return;
106
- closing = true; online = false; attachedCount = 0; clearTimeout(retryTimer);
107
- connector?.events.removeAllListeners(); connector?.close();
108
- }
109
- async function saveInvitation(message) {
110
- if (!message.code) return;
111
- expiresAt = message.expires;
112
- const link = new URL(config.pairingUrl);
113
- link.searchParams.set('invite', message.code);
114
- // Keep previously displayed files intact even when their invitation expires.
115
- qrPath = path.join(stateRoot, `self-relay-qr-${randomUUID()}.png`);
116
- await QRCode.toFile(qrPath, link.href, { width: 640, margin: 3 });
117
- }
118
- function scheduleRetry() {
119
- if (closing || retryTimer) return;
120
- const delay = Math.min(1000 * 2 ** Math.min(attempts++, 5), 30000) * (0.8 + Math.random() * 0.2);
121
- retryTimer = setTimeout(() => { retryTimer = undefined; void connect(); }, delay);
122
- }
123
- async function connect() {
124
- if (closing || connecting) return;
125
- connecting = true;
126
- let retryAfterAttempt = false;
127
- try {
128
- const bridge = session();
129
- await refreshHealth();
130
- if (closing) return;
131
- const lanCandidates = await currentLanCandidates();
132
- if (closing) return;
133
- connector?.events.removeAllListeners(); connector?.close();
134
- const current = connector = connectBridgeRelay({ relayUrl: config.url, identity,
135
- usageCacheFile: path.join(stateRoot, 'relay-usage-tasks.json'),
136
- bridgeUrl: `ws://127.0.0.1:${bridge?.port || 45831}/api/socket`, bridgeToken: bridge?.accessUrl ? new URL(bridge.accessUrl).searchParams.get('token') : 'unavailable',
137
- bridgeConnection: () => { const latest = session(); return { url: `ws://127.0.0.1:${latest.port}/api/socket`, token: new URL(latest.accessUrl).searchParams.get('token') }; },
138
- hostName: hostname(), lanCandidates, p2pProbe: config.p2pProbe === true, iceServers: config.p2pIceServers || [],
139
- p2pWindowsRouteHelper: config.p2pWindowsRouteHelper === true, p2pWindowsRouteHelperToken: config.p2pWindowsRouteHelperToken || '' });
140
- current.events.on('p2p-probe', result => audit('p2p-probe', result));
141
- current.events.on('diagnostic', message => audit('transport-error', { message }));
142
- current.events.on('attached', () => { paired = true; attachedCount++; audit('phone-attached', { attachedCount }); });
143
- current.events.on('detached', () => { attachedCount = Math.max(0, attachedCount - 1); audit('phone-detached', { attachedCount }); });
144
- current.events.on('authorization-changed', update => {
145
- saveLanAuthorizations(stateRoot, identity.id, update.clients, update.revision);
146
- paired = update.clients.length > 0; clientCount = update.clients.length;
147
- audit('mobile-authorizations-updated', { clientCount, revision: update.revision });
148
- });
149
- current.events.once('disconnected', event => {
150
- if (connector !== current || closing) return;
151
- online = false; attachedCount = 0; lastError = `电脑与中继连接断开(${event.code}):${event.reason}`; audit('relay-disconnected', event);
152
- if (![1008, 4401, 4406, 4409, 4429].includes(event.code)) scheduleRetry();
153
- });
154
- const registration = await current.ready;
155
- if (closing) return;
156
- if (registration.protocolVersion !== 7 || registration.room !== identity.id) {
157
- current.events.removeAllListeners(); current.close(); throw Object.assign(new Error('中继服务器尚未升级设备授权协议,未使用临时配对降级。'), { code: 4401 });
158
- }
159
- if (!identity.registered) {
160
- const registeredIdentity = { ...identity, registered: true };
161
- identities.devices[identity.id] = registeredIdentity;
162
- await credentialStore('save', identities); identity = registeredIdentity;
163
- }
164
- if (closing) return;
165
- current.assertConnected();
166
- online = true; attempts = 0; paired = registration.paired; clientCount = registration.clientCount || 0; lastError = '';
167
- saveLanAuthorizations(stateRoot, identity.id, registration.clients || [], registration.revision);
168
- await saveInvitation(registration);
169
- if (!closing) audit('relay-connected', { paired });
170
- } catch (error) {
171
- if (closing) return;
172
- online = false; lastError = error.message; audit('connect-failed', { message: error.message });
173
- if (![1008, 4401, 4406, 4409, 4429].includes(error.code)) retryAfterAttempt = true;
174
- else { clearTimeout(retryTimer); retryTimer = undefined; }
175
- } finally { connecting = false; if (retryAfterAttempt) scheduleRetry(); }
176
- }
177
- async function pair() {
178
- if (!online || closing) throw new Error('中继尚未连接,设备授权保持不变。');
179
- await saveInvitation(await connector.command('pair-code'));
180
- // Each invitation adds or refreshes one mobile identity without revoking other devices.
181
- return { ...status(), qrPath, expiresAt: new Date(expiresAt).toISOString(),
182
- displayText: '用要添加的移动设备扫描此码。每台设备只需配对一次;已有设备授权保持不变。' };
183
- }
184
- async function revoke() {
185
- if (!online || closing) throw new Error('中继尚未连接,未撤销授权。');
186
- await connector.command('revoke'); paired = false; clientCount = 0; attachedCount = 0; qrPath = undefined; expiresAt = 0;
187
- return { ...status(), displayText: '全部移动设备授权已撤销;重新添加请调用 relax pair --json。' };
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
- }
205
- async function refreshLan() {
206
- if (!online || closing) throw new Error('中继尚未连接,未更新局域网候选。');
207
- await refreshHealth();
208
- const candidates = await currentLanCandidates();
209
- connector.updateLanCandidates(candidates);
210
- audit('lan-candidates-updated', { count: candidates.length });
211
- return { ...status(), lanCandidateCount: candidates.length };
212
- }
213
- const inspect = async () => {
214
- if (online) {
215
- const remote = await connector.command('status'); paired = remote.paired;
216
- await refreshHealth();
217
- }
218
- return status();
219
- };
220
- try { await connect(); return { status, inspect, pair, revoke, invite, refreshLan, shutdown }; }
221
- catch (error) { await shutdown(); throw error; }
222
- }
223
-
224
- export function createRelayManager(stateRoot) {
225
- let current;
226
- let operation = Promise.resolve();
227
- const run = (command, details = {}) => {
228
- const next = operation.then(async () => {
229
- if (command === 'relay-status') return current ? current.inspect() : { running: false, displayText: '中继连接未运行。' };
230
- if (command === 'relay-stop') {
231
- await current?.shutdown(); current = undefined;
232
- return { stopped: true, displayText: '中继连接已停止;桌面服务和 Codex 内核保持运行。' };
233
- }
234
- if (!current) {
235
- if (command.startsWith('invite-')) throw new Error('请先执行 relax start 连接中继。');
236
- if (command === 'relay-revoke') throw new Error('请先启动中继连接,再撤销移动端授权。');
237
- current = await createRelayRuntime(stateRoot);
238
- return command === 'relay-pair' && current.status().relayConnected ? current.pair() : current.status();
239
- }
240
- if (command.startsWith('invite-')) return current.invite(command, details);
241
- if (command === 'relay-revoke') return current.revoke();
242
- if (command === 'relay-pair' || (current.status().relayConnected && !current.status().paired && !current.status().qrPath)) return current.pair();
243
- if (command === 'relay-start' && current.status().relayConnected) return current.refreshLan();
244
- return current.status();
245
- });
246
- operation = next.catch(() => {});
247
- return next;
248
- };
249
- return { run, close: () => run('relay-stop'), status: () => current?.status() || { running: false } };
250
- }
1
+ import path from 'node:path';
2
+ import serviceEndpoints from '../../shared/service-endpoints.cjs';
3
+ import { randomBytes, randomUUID } from 'node:crypto';
4
+ import { hostname } from 'node:os';
5
+ import QRCode from 'qrcode';
6
+ import { credentialStore } from '../platform/credential-store.mjs';
7
+ import { readRecord, writeRecord } from '../service-lifecycle.mjs';
8
+ import { serviceHealth } from '../service-health.mjs';
9
+ import { controlRequest } from '../service-control.mjs';
10
+ import { connectBridgeRelay } from './connector.mjs';
11
+ import { validProbeIceServers } from './p2p-probe.mjs';
12
+ import { relayAddress, validSecret } from './wire.mjs';
13
+ import { saveLanAuthorizations, onLanDeviceConnected, pendingLanConnections } from './lan-authorizations.mjs';
14
+ import { lanNetworks } from '../platform/network-info.mjs';
15
+
16
+ const officialConfig = { url: serviceEndpoints.relayUrl };
17
+
18
+ async function loadConfig(stateRoot) {
19
+ const configPath = path.join(stateRoot, 'self-relay-config.json');
20
+ const stored = readRecord(configPath) || {};
21
+ const config = { ...officialConfig, ...stored };
22
+ if (!stored.url) writeRecord(configPath, config);
23
+ if (config.p2pIceServers !== undefined && !validProbeIceServers(config.p2pIceServers)) throw new Error('p2pIceServers 只接受最多 4 个 stun:主机:端口地址');
24
+ if (config.p2pWindowsRouteHelper !== undefined && typeof config.p2pWindowsRouteHelper !== 'boolean') throw new Error('p2pWindowsRouteHelper 必须是布尔值');
25
+ if (config.p2pWindowsRouteHelper && !/^[A-Za-z0-9_-]{32,128}$/.test(config.p2pWindowsRouteHelperToken || '')) throw new Error('p2pWindowsRouteHelperToken 无效');
26
+ const url = process.env.RELAY_URL || serviceEndpoints.resolveRelayUrl(config.url);
27
+ if (!url) throw new Error('请先配置 self-relay-config.json 的 url,详见自建中继文档。');
28
+ relayAddress(url);
29
+ const resolved = relayAddress(url);
30
+ const pairingUrl = config.pairingUrl || (resolved === serviceEndpoints.relayUrl ? serviceEndpoints.pairingUrl : `${resolved.startsWith('wss:') ? 'https:' : 'http:'}//${new URL(resolved).host}/pair`);
31
+ const pairing = new URL(pairingUrl);
32
+ if (!['https:', 'http:'].includes(pairing.protocol) || pairing.pathname !== '/pair' || pairing.search || pairing.hash || pairing.username || pairing.password ||
33
+ (pairing.protocol === 'http:' && pairing.hostname !== '127.0.0.1')) throw new Error('self-relay-config.json 的 pairingUrl 必须是 HTTPS /pair 地址。');
34
+ return { ...config, url: resolved, pairingUrl: pairing.href };
35
+ }
36
+ export async function selfRelay(command, stateRoot, details) {
37
+ const session = readRecord(path.join(stateRoot, 'bridge-session.json'));
38
+ if (!session) {
39
+ if (command === 'relay-status') return { running: false, displayText: '桌面服务未运行。' };
40
+ if (command === 'relay-stop') return { stopped: true, displayText: '中继连接已停止。' };
41
+ throw new Error('桌面服务尚未运行,请先执行 relax start。');
42
+ }
43
+ return controlRequest(session, 'POST', command, details);
44
+ }
45
+
46
+ async function createRelayRuntime(stateRoot) {
47
+ const config = await loadConfig(stateRoot);
48
+ const configPath = path.join(stateRoot, 'self-relay-config.json');
49
+ const session = () => readRecord(path.join(stateRoot, 'bridge-session.json'));
50
+ let health = await serviceHealth(session());
51
+ if (health?.status !== 'ok') throw new Error('桌面服务尚未运行,请先执行 relax start --json。');
52
+ const stored = await credentialStore('load');
53
+ const identities = stored.record || { version: 2, devices: {} };
54
+ const validIdentity = (id, item) => /^[\w-]{16,128}$/.test(id) && item?.id === id &&
55
+ validSecret(item.secret) && typeof item.registered === 'boolean';
56
+ if (identities.version !== 2 || !identities.devices || typeof identities.devices !== 'object' || Array.isArray(identities.devices) ||
57
+ Object.entries(identities.devices).some(([id, item]) => !validIdentity(id, item))) throw new Error('本机设备身份损坏,未自动覆盖');
58
+ if (!config.deviceId) {
59
+ const known = Object.values(identities.devices);
60
+ if (known.length > 1) throw new Error('存在多个工作站身份,请在 self-relay-config.json 中指定 deviceId。');
61
+ config.deviceId = known[0]?.id || randomUUID();
62
+ }
63
+ const createIdentity = config.deviceId === 'new';
64
+ if (createIdentity) config.deviceId = randomUUID();
65
+ if (!/^[\w-]{16,128}$/.test(config.deviceId)) throw new Error('self-relay-config.json 的 deviceId 无效。');
66
+ let identity = identities.devices[config.deviceId];
67
+ if (!identity) {
68
+ if (!createIdentity && Object.keys(identities.devices).length) throw new Error('找不到配置指定的工作站身份,未自动创建新身份。');
69
+ identity = { id: config.deviceId, secret: randomBytes(32).toString('base64url'), registered: false };
70
+ identities.devices[identity.id] = identity;
71
+ await credentialStore('save', identities);
72
+ }
73
+ const persistedConfig = readRecord(configPath) || {};
74
+ if (persistedConfig.deviceId !== identity.id) writeRecord(configPath, { ...persistedConfig, deviceId: identity.id });
75
+ let bridgeError = '';
76
+ async function refreshHealth() {
77
+ try { health = await serviceHealth(session()) || { transportMode: health.transportMode, status: 'offline' }; bridgeError = ''; }
78
+ catch (error) { bridgeError = error.message; health = { transportMode: health.transportMode, status: 'offline' }; }
79
+ }
80
+ let connector, qrPath, expiresAt = 0, paired = false, clientCount = 0, attachedCount = 0;
81
+ let closing = false, online = false, connecting = false, retryTimer, attempts = 0, lastError = '';
82
+ const audit = (event, details = {}) => console.log(JSON.stringify({ time: new Date().toISOString(), event, ...details }));
83
+ const stopWatchingDevices = onLanDeviceConnected(stateRoot, info => {
84
+ if (online && !closing) connector.reportDeviceConnection(info);
85
+ });
86
+ async function currentLanCandidates() {
87
+ try {
88
+ const bridge = session(), port = bridge?.port || 45831;
89
+ return (await lanNetworks()).map(network => ({ ...network, address: `http://${network.address}:${port}` }));
90
+ } catch (error) {
91
+ audit('lan-candidates-unavailable', { message: error.message }); return [];
92
+ }
93
+ }
94
+ const status = () => {
95
+ const available = online && !paired && qrPath && Date.now() < expiresAt;
96
+ const next = !online ? (lastError || '电脑连接器正在恢复中继连接,已保存授权。')
97
+ : paired ? '设备已授权,移动端可直接重连;网络断开不需要重新扫码。'
98
+ : available ? '用要添加的移动设备在有效期内扫描统一配对二维码。连接器在后台运行。'
99
+ : '尚未完成设备授权;调用 relax pair --json 生成配对二维码。';
100
+ return { running: !closing, relayConnected: online, mode: 'self-relay', transportMode: health.transportMode,
101
+ relayUrl: config.url, deviceId: identity.id, paired, clientCount, authorization: online ? (paired ? 'authorized' : 'unpaired') : 'unverified', phoneConnected: attachedCount > 0,
102
+ bridgeReady: health.status === 'ok' && health.execution?.ready === true && health.sharedAppServerConnected === true, bridgeError,
103
+ expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
104
+ ...(available ? { qrPath } : {}), mobileAccess: 'unverified', lastError,
105
+ displayText: `Code Relax 中继 · ${health.transportMode === 'lan-shared' ? 'Codex 内核已就绪' : 'Codex 内核未就绪'}\n${next}` };
106
+ };
107
+ async function shutdown() {
108
+ if (closing) return;
109
+ closing = true; online = false; attachedCount = 0; clearTimeout(retryTimer);
110
+ stopWatchingDevices();
111
+ connector?.events.removeAllListeners(); connector?.close();
112
+ }
113
+ async function saveInvitation(message) {
114
+ if (!message.code) return;
115
+ expiresAt = message.expires;
116
+ const link = new URL(config.pairingUrl);
117
+ link.searchParams.set('invite', message.code);
118
+ // Keep previously displayed files intact even when their invitation expires.
119
+ qrPath = path.join(stateRoot, `self-relay-qr-${randomUUID()}.png`);
120
+ await QRCode.toFile(qrPath, link.href, { width: 640, margin: 3 });
121
+ }
122
+ function scheduleRetry() {
123
+ if (closing || retryTimer) return;
124
+ const delay = Math.min(1000 * 2 ** Math.min(attempts++, 5), 30000) * (0.8 + Math.random() * 0.2);
125
+ retryTimer = setTimeout(() => { retryTimer = undefined; void connect(); }, delay);
126
+ }
127
+ async function connect() {
128
+ if (closing || connecting) return;
129
+ connecting = true;
130
+ let retryAfterAttempt = false;
131
+ try {
132
+ const bridge = session();
133
+ await refreshHealth();
134
+ if (closing) return;
135
+ const lanCandidates = await currentLanCandidates();
136
+ if (closing) return;
137
+ connector?.events.removeAllListeners(); connector?.close();
138
+ const current = connector = connectBridgeRelay({ relayUrl: config.url, identity,
139
+ usageCacheFile: path.join(stateRoot, 'relay-usage-tasks.json'),
140
+ bridgeUrl: `ws://127.0.0.1:${bridge?.port || 45831}/api/socket`, bridgeToken: bridge?.accessUrl ? new URL(bridge.accessUrl).searchParams.get('token') : 'unavailable',
141
+ bridgeConnection: () => { const latest = session(); return { url: `ws://127.0.0.1:${latest.port}/api/socket`, token: new URL(latest.accessUrl).searchParams.get('token') }; },
142
+ hostName: hostname(), lanCandidates, p2pProbe: config.p2pProbe === true, iceServers: config.p2pIceServers || [],
143
+ p2pWindowsRouteHelper: config.p2pWindowsRouteHelper === true, p2pWindowsRouteHelperToken: config.p2pWindowsRouteHelperToken || '' });
144
+ current.events.on('p2p-probe', result => audit('p2p-probe', result));
145
+ current.events.on('diagnostic', message => audit('transport-error', { message }));
146
+ current.events.on('attached', () => { paired = true; attachedCount++; audit('phone-attached', { attachedCount }); });
147
+ current.events.on('detached', () => { attachedCount = Math.max(0, attachedCount - 1); audit('phone-detached', { attachedCount }); });
148
+ current.events.on('authorization-changed', update => {
149
+ saveLanAuthorizations(stateRoot, identity.id, update.clients, update.revision);
150
+ paired = update.clients.length > 0; clientCount = update.clients.length;
151
+ audit('mobile-authorizations-updated', { clientCount, revision: update.revision });
152
+ });
153
+ current.events.once('disconnected', event => {
154
+ if (connector !== current || closing) return;
155
+ online = false; attachedCount = 0; lastError = `电脑与中继连接断开(${event.code}):${event.reason}`; audit('relay-disconnected', event);
156
+ if (![1008, 4401, 4406, 4409, 4429].includes(event.code)) scheduleRetry();
157
+ });
158
+ const registration = await current.ready;
159
+ if (closing) return;
160
+ if (registration.protocolVersion !== 7 || registration.room !== identity.id) {
161
+ current.events.removeAllListeners(); current.close(); throw Object.assign(new Error('中继服务器尚未升级设备授权协议,未使用临时配对降级。'), { code: 4401 });
162
+ }
163
+ if (!identity.registered) {
164
+ const registeredIdentity = { ...identity, registered: true };
165
+ identities.devices[identity.id] = registeredIdentity;
166
+ await credentialStore('save', identities); identity = registeredIdentity;
167
+ }
168
+ if (closing) return;
169
+ current.assertConnected();
170
+ online = true; attempts = 0; paired = registration.paired; clientCount = registration.clientCount || 0; lastError = '';
171
+ saveLanAuthorizations(stateRoot, identity.id, registration.clients || [], registration.revision);
172
+ for (const info of pendingLanConnections(stateRoot)) current.reportDeviceConnection(info);
173
+ await saveInvitation(registration);
174
+ if (!closing) audit('relay-connected', { paired });
175
+ } catch (error) {
176
+ if (closing) return;
177
+ online = false; lastError = error.message; audit('connect-failed', { message: error.message });
178
+ if (![1008, 4401, 4406, 4409, 4429].includes(error.code)) retryAfterAttempt = true;
179
+ else { clearTimeout(retryTimer); retryTimer = undefined; }
180
+ } finally { connecting = false; if (retryAfterAttempt) scheduleRetry(); }
181
+ }
182
+ async function pair() {
183
+ if (!online || closing) throw new Error('中继尚未连接,设备授权保持不变。');
184
+ await saveInvitation(await connector.command('pair-code'));
185
+ // Each invitation adds or refreshes one mobile identity without revoking other devices.
186
+ return { ...status(), qrPath, expiresAt: new Date(expiresAt).toISOString(),
187
+ displayText: '用要添加的移动设备扫描此码。每台设备只需配对一次;已有设备授权保持不变。' };
188
+ }
189
+ async function revoke() {
190
+ if (!online || closing) throw new Error('中继尚未连接,未撤销授权。');
191
+ await connector.command('revoke'); paired = false; clientCount = 0; attachedCount = 0; qrPath = undefined; expiresAt = 0;
192
+ return { ...status(), displayText: '全部移动设备授权已撤销;重新添加请调用 relax pair --json。' };
193
+ }
194
+ async function invite(command, details) {
195
+ if (!online || closing) throw new Error('中继尚未连接,请先执行 relax start。');
196
+ const result = await connector.command(command, command === 'invite-create' ? { expires: details.expires } : command === 'invite-revoke' ? { id: details.id } : {});
197
+ if (command === 'invite-create') {
198
+ const link = new URL(config.pairingUrl);
199
+ link.searchParams.set('invite', result.code);
200
+ const inviteQrPath = path.join(stateRoot, `self-relay-long-qr-${result.id}.png`);
201
+ try { await QRCode.toFile(inviteQrPath, link.href, { width: 640, margin: 3 }); }
202
+ catch (error) { throw new Error(`二维码保存失败:${error.message}。邀请 ID:${result.id};请执行 relax pair-revoke ${result.id} 废止。`); }
203
+ return { invitationId: result.id, deviceId: result.room, expiresAt: new Date(result.expires).toISOString(), qrPath: inviteQrPath,
204
+ displayText: `长期配对邀请已创建。邀请 ID:${result.id}\n截止:${new Date(result.expires).toISOString()}` };
205
+ }
206
+ if (command === 'invite-list') return { invitations: result.invitations,
207
+ displayText: result.invitations.length ? result.invitations.map(item => `${item.id} ${new Date(item.expires).toISOString()}${Date.now() >= item.expires ? '(已过期)' : ''}`).join('\n') : '本工作站没有长期配对邀请。' };
208
+ return { invitationId: result.id, revoked: result.revoked, displayText: '长期邀请已废止;已配对手机的独立授权保持不变。' };
209
+ }
210
+ async function refreshLan() {
211
+ if (!online || closing) throw new Error('中继尚未连接,未更新局域网候选。');
212
+ await refreshHealth();
213
+ const candidates = await currentLanCandidates();
214
+ connector.updateLanCandidates(candidates);
215
+ audit('lan-candidates-updated', { count: candidates.length });
216
+ return { ...status(), lanCandidateCount: candidates.length };
217
+ }
218
+ async function devices(command, details = {}) {
219
+ if (!online || closing) throw new Error('中继尚未连接,未读取或修改配对授权。');
220
+ const result = await connector.command(command, command === 'device-revoke' ? { id: details.id, confirmed: details.confirmed === true } : {});
221
+ return command === 'devices-list' ? { clients: result.clients, workstationOnline: result.workstationOnline,
222
+ displayText: result.clients.map(item => `${item.id} ${item.platform || '平台未记录'} ${item.deviceName || ''} 最后连接:${item.lastConnectedAt || '未记录'}`).join('\n') || '没有配对授权。' }
223
+ : { id: result.id, revoked: result.revoked, displayText: '指定配对授权已撤销;其他授权和长期邀请保持不变。' };
224
+ }
225
+ const inspect = async () => {
226
+ if (online) {
227
+ const remote = await connector.command('status'); paired = remote.paired;
228
+ await refreshHealth();
229
+ }
230
+ return status();
231
+ };
232
+ try { await connect(); return { status, inspect, pair, revoke, invite, devices, refreshLan, shutdown }; }
233
+ catch (error) { await shutdown(); throw error; }
234
+ }
235
+
236
+ export function createRelayManager(stateRoot) {
237
+ let current;
238
+ let operation = Promise.resolve();
239
+ const run = (command, details = {}) => {
240
+ const next = operation.then(async () => {
241
+ if (command === 'relay-status') return current ? current.inspect() : { running: false, displayText: '中继连接未运行。' };
242
+ if (command === 'relay-stop') {
243
+ await current?.shutdown(); current = undefined;
244
+ return { stopped: true, displayText: '中继连接已停止;桌面服务和 Codex 内核保持运行。' };
245
+ }
246
+ if (!current) {
247
+ if (['devices-list', 'device-revoke'].includes(command)) throw new Error('请先执行 relax start 连接中继。');
248
+ if (command.startsWith('invite-')) throw new Error('请先执行 relax start 连接中继。');
249
+ if (command === 'relay-revoke') throw new Error('请先启动中继连接,再撤销移动端授权。');
250
+ current = await createRelayRuntime(stateRoot);
251
+ return command === 'relay-pair' && current.status().relayConnected ? current.pair() : current.status();
252
+ }
253
+ if (command.startsWith('invite-')) return current.invite(command, details);
254
+ if (['devices-list', 'device-revoke'].includes(command)) return current.devices(command, details);
255
+ if (command === 'relay-revoke') return current.revoke();
256
+ if (command === 'relay-pair' || (current.status().relayConnected && !current.status().paired && !current.status().qrPath)) return current.pair();
257
+ if (command === 'relay-start' && current.status().relayConnected) return current.refreshLan();
258
+ return current.status();
259
+ });
260
+ operation = next.catch(() => {});
261
+ return next;
262
+ };
263
+ return { run, close: () => run('relay-stop'), status: () => current?.status() || { running: false } };
264
+ }