@walkhi/code-relax 0.1.0-beta.68 → 0.1.0-beta.69

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 (120) hide show
  1. package/dist/bin/manage-relay-admin.mjs +20 -20
  2. package/dist/bin/migrate-relay-db.mjs +109 -109
  3. package/dist/bin/publish-harmony-release.mjs +46 -46
  4. package/dist/bin/self-relay-admin.mjs +8 -8
  5. package/dist/bin/self-relay-demo.mjs +33 -33
  6. package/dist/bin/self-relay-server.mjs +66 -66
  7. package/dist/bin/set-relay-admin-password.mjs +11 -11
  8. package/dist/build-platform.json +1 -1
  9. package/dist/get-lan-networks.ps1 +40 -40
  10. package/dist/shared/app-server-events.cjs +511 -511
  11. package/dist/shared/p2p-data-channel.cjs +72 -72
  12. package/dist/shared/p2p-diagnostics.cjs +28 -28
  13. package/dist/shared/p2p-ice-policy.cjs +22 -22
  14. package/dist/shared/plan-pricing.cjs +32 -32
  15. package/dist/shared/question-attention.cjs +69 -69
  16. package/dist/shared/release-version.cjs +31 -31
  17. package/dist/src/archived-threads.mjs +22 -22
  18. package/dist/src/background-process.mjs +18 -18
  19. package/dist/src/code-browser.mjs +100 -100
  20. package/dist/src/desktop-launcher.mjs +62 -62
  21. package/dist/src/dots-task-files.mjs +52 -52
  22. package/dist/src/execution-health.mjs +26 -26
  23. package/dist/src/lan-socket-server.mjs +1 -1
  24. package/dist/src/message-images.mjs +9 -9
  25. package/dist/src/plan-pricing.mjs +47 -47
  26. package/dist/src/platform/codex-runtime.mjs +3 -3
  27. package/dist/src/platform/credential-store.mjs +19 -19
  28. package/dist/src/platform/desktop-host.mjs +3 -3
  29. package/dist/src/platform/desktop-permission-mode.mjs +17 -17
  30. package/dist/src/platform/desktop-project-registration.mjs +3 -3
  31. package/dist/src/platform/file-opener.mjs +9 -9
  32. package/dist/src/platform/linux/README.md +3 -3
  33. package/dist/src/platform/linux/codex-runtime.mjs +56 -56
  34. package/dist/src/platform/linux/desktop-installation.mjs +36 -36
  35. package/dist/src/platform/linux/desktop-project-registration.mjs +11 -11
  36. package/dist/src/platform/linux/network-info.mjs +11 -11
  37. package/dist/src/platform/linux/project-locations.mjs +17 -17
  38. package/dist/src/platform/linux/resident-startup.mjs +52 -52
  39. package/dist/src/platform/linux/retained-runtime.mjs +76 -76
  40. package/dist/src/platform/linux/service-host.mjs +18 -18
  41. package/dist/src/platform/macos/README.md +3 -3
  42. package/dist/src/platform/macos/desktop-project-registration.mjs +11 -11
  43. package/dist/src/platform/macos/network-info.mjs +8 -8
  44. package/dist/src/platform/macos/project-locations.mjs +8 -8
  45. package/dist/src/platform/macos/resident-startup.mjs +84 -84
  46. package/dist/src/platform/macos/retained-runtime.mjs +127 -127
  47. package/dist/src/platform/macos/service-host.mjs +18 -18
  48. package/dist/src/platform/macos/system.mjs +29 -29
  49. package/dist/src/platform/network-info.mjs +3 -3
  50. package/dist/src/platform/path-identity.mjs +5 -5
  51. package/dist/src/platform/posix/network-candidates.mjs +23 -23
  52. package/dist/src/platform/posix/runtime-paths.mjs +25 -25
  53. package/dist/src/platform/power-inhibitor.mjs +22 -22
  54. package/dist/src/platform/project-locations.mjs +3 -3
  55. package/dist/src/platform/runtime-paths.mjs +8 -8
  56. package/dist/src/platform/select.mjs +5 -5
  57. package/dist/src/platform/service-host.mjs +3 -3
  58. package/dist/src/platform/user-environment.mjs +3 -3
  59. package/dist/src/platform/windows/IsolatedProcess.cs +79 -79
  60. package/dist/src/platform/windows/background-process.mjs +30 -30
  61. package/dist/src/platform/windows/desktop-helper.mjs +179 -179
  62. package/dist/src/platform/windows/desktop-runtime.ps1 +10 -10
  63. package/dist/src/platform/windows/desktop-shortcuts.ps1 +7 -7
  64. package/dist/src/platform/windows/keep-awake.ps1 +20 -20
  65. package/dist/src/platform/windows/network-info.mjs +34 -34
  66. package/dist/src/platform/windows/resident-start.js +20 -20
  67. package/dist/src/platform/windows/resident-start.ps1 +7 -7
  68. package/dist/src/platform/windows/resident-startup.ps1 +83 -83
  69. package/dist/src/platform/windows/resolve-state-root.ps1 +14 -14
  70. package/dist/src/platform/windows/runtime-paths.mjs +20 -20
  71. package/dist/src/platform/windows/rustdesk-video-probe.mjs +167 -167
  72. package/dist/src/platform/windows/start-hidden-console.ps1 +58 -58
  73. package/dist/src/platform/windows/stop-desktop.ps1 +22 -22
  74. package/dist/src/platform/windows/user-environment.ps1 +41 -41
  75. package/dist/src/project-create.mjs +121 -121
  76. package/dist/src/project-management.mjs +1 -1
  77. package/dist/src/project-remove.mjs +55 -55
  78. package/dist/src/project-update.mjs +59 -59
  79. package/dist/src/relay-config-import.mjs +45 -45
  80. package/dist/src/self-relay/account-connections.mjs +52 -52
  81. package/dist/src/self-relay/admin-auth.mjs +59 -59
  82. package/dist/src/self-relay/admin-cloud.mjs +101 -101
  83. package/dist/src/self-relay/admin-login-page.mjs +7 -7
  84. package/dist/src/self-relay/admin-server.mjs +105 -105
  85. package/dist/src/self-relay/admin-state.mjs +127 -127
  86. package/dist/src/self-relay/admin-workstations-page.mjs +95 -95
  87. package/dist/src/self-relay/admin-workstations.mjs +143 -143
  88. package/dist/src/self-relay/android-releases.mjs +61 -61
  89. package/dist/src/self-relay/client.mjs +61 -61
  90. package/dist/src/self-relay/connection-reporting.mjs +38 -38
  91. package/dist/src/self-relay/connector.mjs +13 -2
  92. package/dist/src/self-relay/debug-allowlist.mjs +33 -33
  93. package/dist/src/self-relay/demo.mjs +6 -6
  94. package/dist/src/self-relay/device-info.mjs +27 -27
  95. package/dist/src/self-relay/device-store.mjs +92 -92
  96. package/dist/src/self-relay/lan-authorizations.mjs +71 -71
  97. package/dist/src/self-relay/lifecycle.mjs +278 -278
  98. package/dist/src/self-relay/long-pairing.mjs +40 -40
  99. package/dist/src/self-relay/mysql-state.mjs +118 -118
  100. package/dist/src/self-relay/phone-connections.mjs +55 -55
  101. package/dist/src/self-relay/server.mjs +14 -3
  102. package/dist/src/self-relay/usage-daily.mjs +55 -55
  103. package/dist/src/self-relay/windows-route-helper.mjs +75 -75
  104. package/dist/src/self-relay/wire.mjs +36 -36
  105. package/dist/src/server.mjs +40 -18
  106. package/dist/src/service-control.mjs +82 -82
  107. package/dist/src/service-health.mjs +33 -33
  108. package/dist/src/service-power.mjs +58 -58
  109. package/dist/src/service-restart.mjs +81 -81
  110. package/dist/src/service-update-worker.mjs +42 -42
  111. package/dist/src/service-update.mjs +80 -80
  112. package/dist/src/shared-recovery.mjs +152 -152
  113. package/dist/src/skill-install.mjs +51 -51
  114. package/dist/src/thread-title-generation.mjs +150 -150
  115. package/dist/src/turn-model-history.mjs +47 -47
  116. package/dist/tools/rtc-route-helper.ps1 +99 -99
  117. package/dist/tools/start-rtc-route-helper.ps1 +15 -15
  118. package/dist/web/vendor/markdown-it.LICENSE.txt +22 -22
  119. package/dist/web/vendor/markdown-it.umd.min.js +13 -13
  120. package/package.json +4 -4
@@ -1,75 +1,75 @@
1
- import net from 'node:net';
2
-
3
- const pipePath = String.raw`\\.\pipe\CodeRelaxRtcRouteHelper`;
4
-
5
- export function createWindowsRouteHelper({ enabled = false, token = '', iceServers = [], report = () => {} } = {}) {
6
- const addresses = new Set();
7
- const routeAddresses = new Set(iceServers.map(stunAddress).filter(publicIpv4));
8
- let chain = Promise.resolve();
9
- const enqueue = task => chain = chain.then(task, task);
10
- if (!enabled || process.platform !== 'win32') return { prepare() { return Promise.resolve(); }, include() { return Promise.resolve(); } };
11
- if (!/^[A-Za-z0-9_-]{32,128}$/.test(token)) throw new Error('Windows RTC 路由辅助凭据无效');
12
- const add = values => enqueue(async () => {
13
- for (const value of values) addresses.add(value);
14
- if (!values.length) return;
15
- const result = await request({ token, action: 'add', addresses: [...addresses], ttlSeconds: 120 });
16
- if (!result.ok) throw new Error(result.error || 'Windows RTC 路由辅助不可用');
17
- report('windows-route-helper', { count: addresses.size });
18
- });
19
- return {
20
- prepare() { return add([...routeAddresses]); },
21
- include(message) {
22
- const discovered = candidateAddresses(message);
23
- return add(discovered.filter(value => !addresses.has(value)));
24
- }
25
- };
26
- }
27
-
28
- function stunAddress(value) {
29
- const match = /^stun:([^:]+):\d{1,5}$/i.exec(value || '');
30
- return match?.[1] || '';
31
- }
32
-
33
- function candidateAddresses(message) {
34
- const text = message?.kind === 'candidate' ? message.candidate : ['offer', 'answer'].includes(message?.kind) ? message.sdp : '';
35
- const result = new Set();
36
- for (const line of String(text || '').split(/\r?\n/)) {
37
- const parts = line.trim().replace(/^a=/, '').split(/\s+/);
38
- if (parts[0]?.startsWith('candidate:') && parts[6] === 'typ' && publicIpv4(parts[4])) result.add(parts[4]);
39
- }
40
- return [...result];
41
- }
42
-
43
- function publicIpv4(value) {
44
- const parts = String(value).split('.').map(Number);
45
- if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) return false;
46
- const [a, b] = parts;
47
- return a !== 0 && a !== 10 && a !== 127 && a < 224 && !(a === 169 && b === 254) && !(a === 172 && b >= 16 && b <= 31) &&
48
- !(a === 192 && b === 168) && !(a === 100 && b >= 64 && b <= 127) && !(a === 198 && (b === 18 || b === 19));
49
- }
50
-
51
- function request(value) {
52
- return new Promise((resolve, reject) => {
53
- const socket = net.createConnection(pipePath);
54
- let text = '';
55
- let settled = false;
56
- const rejectOnce = error => { if (!settled) { settled = true; reject(error); } };
57
- const resolveOnce = result => { if (!settled) { settled = true; resolve(result); } };
58
- socket.setTimeout(3000);
59
- socket.setEncoding('utf8');
60
- socket.once('connect', () => socket.write(`${JSON.stringify(value)}\n`));
61
- socket.on('data', chunk => {
62
- text += chunk;
63
- const line = text.indexOf('\n');
64
- if (line < 0) return;
65
- try { resolveOnce(JSON.parse(text.slice(0, line))); socket.end(); }
66
- catch { rejectOnce(new Error('Windows RTC 路由辅助响应无效')); socket.destroy(); }
67
- });
68
- socket.once('timeout', () => socket.destroy(new Error('Windows RTC 路由辅助超时')));
69
- socket.once('error', rejectOnce);
70
- socket.once('close', hadError => {
71
- if (hadError || settled) return;
72
- rejectOnce(new Error('Windows RTC 路由辅助响应无效'));
73
- });
74
- });
75
- }
1
+ import net from 'node:net';
2
+
3
+ const pipePath = String.raw`\\.\pipe\CodeRelaxRtcRouteHelper`;
4
+
5
+ export function createWindowsRouteHelper({ enabled = false, token = '', iceServers = [], report = () => {} } = {}) {
6
+ const addresses = new Set();
7
+ const routeAddresses = new Set(iceServers.map(stunAddress).filter(publicIpv4));
8
+ let chain = Promise.resolve();
9
+ const enqueue = task => chain = chain.then(task, task);
10
+ if (!enabled || process.platform !== 'win32') return { prepare() { return Promise.resolve(); }, include() { return Promise.resolve(); } };
11
+ if (!/^[A-Za-z0-9_-]{32,128}$/.test(token)) throw new Error('Windows RTC 路由辅助凭据无效');
12
+ const add = values => enqueue(async () => {
13
+ for (const value of values) addresses.add(value);
14
+ if (!values.length) return;
15
+ const result = await request({ token, action: 'add', addresses: [...addresses], ttlSeconds: 120 });
16
+ if (!result.ok) throw new Error(result.error || 'Windows RTC 路由辅助不可用');
17
+ report('windows-route-helper', { count: addresses.size });
18
+ });
19
+ return {
20
+ prepare() { return add([...routeAddresses]); },
21
+ include(message) {
22
+ const discovered = candidateAddresses(message);
23
+ return add(discovered.filter(value => !addresses.has(value)));
24
+ }
25
+ };
26
+ }
27
+
28
+ function stunAddress(value) {
29
+ const match = /^stun:([^:]+):\d{1,5}$/i.exec(value || '');
30
+ return match?.[1] || '';
31
+ }
32
+
33
+ function candidateAddresses(message) {
34
+ const text = message?.kind === 'candidate' ? message.candidate : ['offer', 'answer'].includes(message?.kind) ? message.sdp : '';
35
+ const result = new Set();
36
+ for (const line of String(text || '').split(/\r?\n/)) {
37
+ const parts = line.trim().replace(/^a=/, '').split(/\s+/);
38
+ if (parts[0]?.startsWith('candidate:') && parts[6] === 'typ' && publicIpv4(parts[4])) result.add(parts[4]);
39
+ }
40
+ return [...result];
41
+ }
42
+
43
+ function publicIpv4(value) {
44
+ const parts = String(value).split('.').map(Number);
45
+ if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) return false;
46
+ const [a, b] = parts;
47
+ return a !== 0 && a !== 10 && a !== 127 && a < 224 && !(a === 169 && b === 254) && !(a === 172 && b >= 16 && b <= 31) &&
48
+ !(a === 192 && b === 168) && !(a === 100 && b >= 64 && b <= 127) && !(a === 198 && (b === 18 || b === 19));
49
+ }
50
+
51
+ function request(value) {
52
+ return new Promise((resolve, reject) => {
53
+ const socket = net.createConnection(pipePath);
54
+ let text = '';
55
+ let settled = false;
56
+ const rejectOnce = error => { if (!settled) { settled = true; reject(error); } };
57
+ const resolveOnce = result => { if (!settled) { settled = true; resolve(result); } };
58
+ socket.setTimeout(3000);
59
+ socket.setEncoding('utf8');
60
+ socket.once('connect', () => socket.write(`${JSON.stringify(value)}\n`));
61
+ socket.on('data', chunk => {
62
+ text += chunk;
63
+ const line = text.indexOf('\n');
64
+ if (line < 0) return;
65
+ try { resolveOnce(JSON.parse(text.slice(0, line))); socket.end(); }
66
+ catch { rejectOnce(new Error('Windows RTC 路由辅助响应无效')); socket.destroy(); }
67
+ });
68
+ socket.once('timeout', () => socket.destroy(new Error('Windows RTC 路由辅助超时')));
69
+ socket.once('error', rejectOnce);
70
+ socket.once('close', hadError => {
71
+ if (hadError || settled) return;
72
+ rejectOnce(new Error('Windows RTC 路由辅助响应无效'));
73
+ });
74
+ });
75
+ }
@@ -1,36 +1,36 @@
1
- import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
2
- import { WebSocket } from 'ws';
3
-
4
- export const secret = () => randomBytes(32).toString('base64url');
5
- export const digest = value => createHash('sha256').update(value).digest('hex');
6
- export const validSecret = value => typeof value === 'string' && /^[\w-]{43}$/.test(value);
7
- export function matches(value, hash) {
8
- return validSecret(value) && typeof hash === 'string' && timingSafeEqual(Buffer.from(digest(value)), Buffer.from(hash));
9
- }
10
- export function send(socket, message) {
11
- if (socket.readyState !== WebSocket.OPEN) return false;
12
- if (socket.bufferedAmount > 64 * 1024 * 1024) { socket.close(1013, 'Receiver too slow'); return false; }
13
- socket.send(JSON.stringify(message)); return true;
14
- }
15
- export function decode(bytes, binary) {
16
- if (binary) throw new Error('Text frames required');
17
- const value = JSON.parse(bytes.toString());
18
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Object required');
19
- return value;
20
- }
21
- export function loopbackUrl(value, pathname) {
22
- const url = new URL(value);
23
- if (url.protocol !== 'ws:' || url.hostname !== '127.0.0.1' || url.pathname !== pathname || url.search || url.hash || url.username || url.password) {
24
- throw new Error('本地原型仅接受指定路径的 ws://127.0.0.1 地址');
25
- }
26
- return url.href;
27
- }
28
-
29
- export function relayAddress(value) {
30
- const url = new URL(value);
31
- if (url.protocol === 'ws:') return loopbackUrl(value, '/relay');
32
- if (url.protocol !== 'wss:' || url.pathname !== '/relay' || url.search || url.hash || url.username || url.password) {
33
- throw new Error('远程中继必须使用无查询凭据的 wss://主机/relay 地址');
34
- }
35
- return url.href;
36
- }
1
+ import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
2
+ import { WebSocket } from 'ws';
3
+
4
+ export const secret = () => randomBytes(32).toString('base64url');
5
+ export const digest = value => createHash('sha256').update(value).digest('hex');
6
+ export const validSecret = value => typeof value === 'string' && /^[\w-]{43}$/.test(value);
7
+ export function matches(value, hash) {
8
+ return validSecret(value) && typeof hash === 'string' && timingSafeEqual(Buffer.from(digest(value)), Buffer.from(hash));
9
+ }
10
+ export function send(socket, message) {
11
+ if (socket.readyState !== WebSocket.OPEN) return false;
12
+ if (socket.bufferedAmount > 64 * 1024 * 1024) { socket.close(1013, 'Receiver too slow'); return false; }
13
+ socket.send(JSON.stringify(message)); return true;
14
+ }
15
+ export function decode(bytes, binary) {
16
+ if (binary) throw new Error('Text frames required');
17
+ const value = JSON.parse(bytes.toString());
18
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Object required');
19
+ return value;
20
+ }
21
+ export function loopbackUrl(value, pathname) {
22
+ const url = new URL(value);
23
+ if (url.protocol !== 'ws:' || url.hostname !== '127.0.0.1' || url.pathname !== pathname || url.search || url.hash || url.username || url.password) {
24
+ throw new Error('本地原型仅接受指定路径的 ws://127.0.0.1 地址');
25
+ }
26
+ return url.href;
27
+ }
28
+
29
+ export function relayAddress(value) {
30
+ const url = new URL(value);
31
+ if (url.protocol === 'ws:') return loopbackUrl(value, '/relay');
32
+ if (url.protocol !== 'wss:' || url.pathname !== '/relay' || url.search || url.hash || url.username || url.password) {
33
+ throw new Error('远程中继必须使用无查询凭据的 wss://主机/relay 地址');
34
+ }
35
+ return url.href;
36
+ }
@@ -143,6 +143,25 @@ const executionHealth = new ExecutionHealth(async () => {
143
143
  : ready ? 'full' : 'unavailable';
144
144
  return { state, ready, canWrite: ready, mode: executionMode };
145
145
  });
146
+ function workstationHealth() {
147
+ const executionReady = executionHealth.state.ready && sharedServer?.ready === true;
148
+ return {
149
+ status: 'ok', resident: true, version: runtimeVersion, serviceInstanceId,
150
+ keepAwake: powerManager.snapshot(),
151
+ serviceUpdate: serviceUpdateStatus(stateRoot),
152
+ execution: { ...executionHealth.state, ready: executionReady, canWrite: executionReady,
153
+ mode: executionMode },
154
+ managedAppServer: managedAppServer.snapshot(),
155
+ ...runtimeDiscovery.state,
156
+ sharedAppServerConnected: sharedServer?.ready === true,
157
+ sharedRecoveryAvailable: runtimeDiscovery.state.desktop?.installed === true,
158
+ mode: options.lan ? 'lan' : 'loopback',
159
+ tokenRequired: Boolean(options.token),
160
+ nativeImageTransport: sharedServer ? 'shared-app-server' : null,
161
+ transportMode: sharedServer?.ready === true ? 'lan-shared' : 'unavailable',
162
+ };
163
+ }
164
+
146
165
  async function refreshExecution() {
147
166
  await executionHealth.refresh();
148
167
  return { mode: executionMode };
@@ -320,24 +339,27 @@ async function handleRequest(request, response) {
320
339
  }
321
340
 
322
341
  if (request.method === 'GET' && url.pathname === '/api/health') {
323
- // Status readers consume the workstation's cached sample. A phone query
324
- // must not trigger PowerShell, process-identity, or app-server checks.
325
- const executionReady = executionHealth.state.ready && sharedServer?.ready === true;
326
- sendJson(response, 200, {
327
- status: 'ok', resident: true, version: runtimeVersion, serviceInstanceId,
328
- keepAwake: powerManager.snapshot(),
329
- serviceUpdate: serviceUpdateStatus(stateRoot),
330
- execution: { ...executionHealth.state, ready: executionReady, canWrite: executionReady,
331
- mode: executionMode },
332
- managedAppServer: managedAppServer.snapshot(),
333
- ...runtimeDiscovery.state,
334
- sharedAppServerConnected: sharedServer?.ready === true,
335
- sharedRecoveryAvailable: runtimeDiscovery.state.desktop?.installed === true,
336
- mode: options.lan ? 'lan' : 'loopback',
337
- tokenRequired: Boolean(options.token),
338
- nativeImageTransport: sharedServer ? 'shared-app-server' : null,
339
- transportMode: sharedServer?.ready === true ? 'lan-shared' : 'unavailable',
340
- });
342
+ sendJson(response, 200, workstationHealth()); return;
343
+ }
344
+ if (request.method === 'GET' && url.pathname === '/api/health/events') {
345
+ response.startEvents();
346
+ let previous = '', stopped = false, timer;
347
+ const publish = () => {
348
+ if (stopped) return;
349
+ try {
350
+ const health = workstationHealth();
351
+ // Sampling timestamps alone do not change a workstation's visible state.
352
+ const signature = JSON.stringify(health, (key, value) => key === 'checkedAt' ? undefined : value);
353
+ if (signature !== previous) { previous = signature; response.event('health', health); }
354
+ } catch (error) { previous = ''; response.event('health-error', { error: error.message }); }
355
+ };
356
+ const close = () => { stopped = true; clearInterval(timer); managedAppServer.off('state', publish); };
357
+ response.once('close', close);
358
+ managedAppServer.on('state', publish);
359
+ publish();
360
+ // Reads only existing local samples and operation records, never chat history
361
+ // or an extra Desktop/process probe. Idle snapshots are not sent over the network.
362
+ timer = setInterval(publish, 1000); timer.unref?.();
341
363
  return;
342
364
  }
343
365
 
@@ -1,82 +1,82 @@
1
- import http from 'node:http';
2
- import { timingSafeEqual } from 'node:crypto';
3
-
4
- function matches(left, right) {
5
- const a = Buffer.from(left || '');
6
- const b = Buffer.from(right || '');
7
- return a.length === b.length && timingSafeEqual(a, b);
8
- }
9
-
10
- // Separate from the LAN listener: mobile credentials never authorize lifecycle operations.
11
- export async function openControl({ instanceId, secret, shutdown, status = () => ({}), actions = {} }) {
12
- const server = http.createServer((request, response) => {
13
- if (!matches(request.headers.authorization, `Bearer ${secret}`)) {
14
- response.writeHead(403).end();
15
- return;
16
- }
17
- const action = Object.keys(actions).find(name => request.url === `/${instanceId}/${name}`);
18
- if ((!action && request.url !== `/${instanceId}`) || !['GET', 'POST'].includes(request.method) || (action && request.method !== 'POST')) {
19
- response.writeHead(404).end();
20
- return;
21
- }
22
- response.setHeader('Content-Type', 'application/json');
23
- response.setHeader('Connection', 'close');
24
- if (action) {
25
- Promise.resolve().then(async () => {
26
- let body = '';
27
- request.setEncoding('utf8');
28
- for await (const chunk of request) {
29
- body += chunk;
30
- if (Buffer.byteLength(body) > 4096) throw new Error('管理请求过大');
31
- }
32
- const details = body ? JSON.parse(body) : {};
33
- if (!details || typeof details !== 'object' || Array.isArray(details)) throw new Error('无效管理请求');
34
- return actions[action](details);
35
- }).then(result => {
36
- response.end(JSON.stringify({ ...result, instanceId, pid: process.pid }));
37
- }).catch(error => { response.writeHead(409).end(JSON.stringify({ error: error.message })); });
38
- return;
39
- }
40
- if (request.method === 'POST') response.once('finish', () => {
41
- Promise.resolve(shutdown()).catch(error => {
42
- console.error('桌面服务关闭失败:', error.message);
43
- process.exitCode = 1;
44
- });
45
- });
46
- response.end(JSON.stringify({ ...status(), instanceId, pid: process.pid }));
47
- });
48
- await new Promise((resolve, reject) => {
49
- server.once('error', reject);
50
- server.listen(0, '127.0.0.1', resolve);
51
- });
52
- return { port: server.address().port, close: () => new Promise((resolve, reject) => {
53
- server.close(error => error ? reject(error) : resolve());
54
- }) };
55
- }
56
-
57
- export function controlRequest(session, method = 'GET', action = '', details) {
58
- return new Promise((resolve, reject) => {
59
- const request = http.request({ hostname: '127.0.0.1', port: session.controlPort,
60
- path: `/${session.instanceId}${action ? '/' + action : ''}`, method, agent: false,
61
- headers: { Authorization: `Bearer ${session.controlSecret}` }, timeout: action ? 25000 : 2000 }, response => {
62
- let body = '';
63
- response.setEncoding('utf8');
64
- response.on('data', chunk => { body += chunk; });
65
- response.on('error', reject);
66
- response.on('end', () => {
67
- try {
68
- if (response.statusCode === 409) throw new Error(JSON.parse(body).error);
69
- if (response.statusCode !== 200) throw new Error('桌面服务管理通道身份验证失败。');
70
- const identity = JSON.parse(body);
71
- if (identity.instanceId !== session.instanceId || identity.pid !== session.pid) {
72
- throw new Error('桌面服务实例身份不匹配。');
73
- }
74
- resolve(identity);
75
- } catch (error) { reject(error); }
76
- });
77
- });
78
- request.on('timeout', () => request.destroy(new Error('桌面服务管理通道响应超时。')));
79
- request.on('error', reject);
80
- request.end(details === undefined ? undefined : JSON.stringify(details));
81
- });
82
- }
1
+ import http from 'node:http';
2
+ import { timingSafeEqual } from 'node:crypto';
3
+
4
+ function matches(left, right) {
5
+ const a = Buffer.from(left || '');
6
+ const b = Buffer.from(right || '');
7
+ return a.length === b.length && timingSafeEqual(a, b);
8
+ }
9
+
10
+ // Separate from the LAN listener: mobile credentials never authorize lifecycle operations.
11
+ export async function openControl({ instanceId, secret, shutdown, status = () => ({}), actions = {} }) {
12
+ const server = http.createServer((request, response) => {
13
+ if (!matches(request.headers.authorization, `Bearer ${secret}`)) {
14
+ response.writeHead(403).end();
15
+ return;
16
+ }
17
+ const action = Object.keys(actions).find(name => request.url === `/${instanceId}/${name}`);
18
+ if ((!action && request.url !== `/${instanceId}`) || !['GET', 'POST'].includes(request.method) || (action && request.method !== 'POST')) {
19
+ response.writeHead(404).end();
20
+ return;
21
+ }
22
+ response.setHeader('Content-Type', 'application/json');
23
+ response.setHeader('Connection', 'close');
24
+ if (action) {
25
+ Promise.resolve().then(async () => {
26
+ let body = '';
27
+ request.setEncoding('utf8');
28
+ for await (const chunk of request) {
29
+ body += chunk;
30
+ if (Buffer.byteLength(body) > 4096) throw new Error('管理请求过大');
31
+ }
32
+ const details = body ? JSON.parse(body) : {};
33
+ if (!details || typeof details !== 'object' || Array.isArray(details)) throw new Error('无效管理请求');
34
+ return actions[action](details);
35
+ }).then(result => {
36
+ response.end(JSON.stringify({ ...result, instanceId, pid: process.pid }));
37
+ }).catch(error => { response.writeHead(409).end(JSON.stringify({ error: error.message })); });
38
+ return;
39
+ }
40
+ if (request.method === 'POST') response.once('finish', () => {
41
+ Promise.resolve(shutdown()).catch(error => {
42
+ console.error('桌面服务关闭失败:', error.message);
43
+ process.exitCode = 1;
44
+ });
45
+ });
46
+ response.end(JSON.stringify({ ...status(), instanceId, pid: process.pid }));
47
+ });
48
+ await new Promise((resolve, reject) => {
49
+ server.once('error', reject);
50
+ server.listen(0, '127.0.0.1', resolve);
51
+ });
52
+ return { port: server.address().port, close: () => new Promise((resolve, reject) => {
53
+ server.close(error => error ? reject(error) : resolve());
54
+ }) };
55
+ }
56
+
57
+ export function controlRequest(session, method = 'GET', action = '', details) {
58
+ return new Promise((resolve, reject) => {
59
+ const request = http.request({ hostname: '127.0.0.1', port: session.controlPort,
60
+ path: `/${session.instanceId}${action ? '/' + action : ''}`, method, agent: false,
61
+ headers: { Authorization: `Bearer ${session.controlSecret}` }, timeout: action ? 25000 : 2000 }, response => {
62
+ let body = '';
63
+ response.setEncoding('utf8');
64
+ response.on('data', chunk => { body += chunk; });
65
+ response.on('error', reject);
66
+ response.on('end', () => {
67
+ try {
68
+ if (response.statusCode === 409) throw new Error(JSON.parse(body).error);
69
+ if (response.statusCode !== 200) throw new Error('桌面服务管理通道身份验证失败。');
70
+ const identity = JSON.parse(body);
71
+ if (identity.instanceId !== session.instanceId || identity.pid !== session.pid) {
72
+ throw new Error('桌面服务实例身份不匹配。');
73
+ }
74
+ resolve(identity);
75
+ } catch (error) { reject(error); }
76
+ });
77
+ });
78
+ request.on('timeout', () => request.destroy(new Error('桌面服务管理通道响应超时。')));
79
+ request.on('error', reject);
80
+ request.end(details === undefined ? undefined : JSON.stringify(details));
81
+ });
82
+ }
@@ -1,33 +1,33 @@
1
- import WebSocket from 'ws';
2
-
3
- // Use the same authenticated LAN protocol as mobile clients; never replay requests.
4
- export function serviceHealth(session) {
5
- if (!session) return Promise.resolve(null);
6
- return new Promise((resolve, reject) => {
7
- const token = new URL(session.accessUrl).searchParams.get('token');
8
- const socket = new WebSocket(`ws://127.0.0.1:${session.port}/api/socket`);
9
- let done = false;
10
- const finish = (error, value = null) => {
11
- if (done) return;
12
- done = true;
13
- clearTimeout(timer);
14
- socket.terminate();
15
- if (error) reject(error); else resolve(value);
16
- };
17
- const timer = setTimeout(() => finish(new Error('桌面服务健康检查超时。')), 6000);
18
- socket.on('open', () => socket.send(JSON.stringify({ type: 'auth', token })));
19
- socket.on('message', data => {
20
- try {
21
- const message = JSON.parse(data.toString());
22
- if (message.type === 'ready') {
23
- socket.send(JSON.stringify({ type: 'request', id: 1, method: 'GET', path: '/api/health' }));
24
- } else if (message.type === 'response' && message.id === 1) {
25
- finish(null, message.status === 200 ? message.body : null);
26
- }
27
- } catch (error) { finish(error); }
28
- });
29
- socket.on('error', error => finish(error.code === 'ECONNREFUSED' ? null : error));
30
- socket.on('close', code => finish(new Error(code === 1008
31
- ? '桌面服务健康检查鉴权失败。' : '桌面服务健康检查连接已断开。')));
32
- });
33
- }
1
+ import WebSocket from 'ws';
2
+
3
+ // Use the same authenticated LAN protocol as mobile clients; never replay requests.
4
+ export function serviceHealth(session) {
5
+ if (!session) return Promise.resolve(null);
6
+ return new Promise((resolve, reject) => {
7
+ const token = new URL(session.accessUrl).searchParams.get('token');
8
+ const socket = new WebSocket(`ws://127.0.0.1:${session.port}/api/socket`);
9
+ let done = false;
10
+ const finish = (error, value = null) => {
11
+ if (done) return;
12
+ done = true;
13
+ clearTimeout(timer);
14
+ socket.terminate();
15
+ if (error) reject(error); else resolve(value);
16
+ };
17
+ const timer = setTimeout(() => finish(new Error('桌面服务健康检查超时。')), 6000);
18
+ socket.on('open', () => socket.send(JSON.stringify({ type: 'auth', token })));
19
+ socket.on('message', data => {
20
+ try {
21
+ const message = JSON.parse(data.toString());
22
+ if (message.type === 'ready') {
23
+ socket.send(JSON.stringify({ type: 'request', id: 1, method: 'GET', path: '/api/health' }));
24
+ } else if (message.type === 'response' && message.id === 1) {
25
+ finish(null, message.status === 200 ? message.body : null);
26
+ }
27
+ } catch (error) { finish(error); }
28
+ });
29
+ socket.on('error', error => finish(error.code === 'ECONNREFUSED' ? null : error));
30
+ socket.on('close', code => finish(new Error(code === 1008
31
+ ? '桌面服务健康检查鉴权失败。' : '桌面服务健康检查连接已断开。')));
32
+ });
33
+ }