@walkhi/code-relax 0.1.0-beta.22 → 0.1.0-beta.24

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.
@@ -1,22 +1,19 @@
1
- import fs from 'node:fs';
2
1
  import path from 'node:path';
3
- import { execFile } from 'node:child_process';
4
- import { promisify } from 'node:util';
5
- import { randomBytes, randomUUID } from 'node:crypto';
6
- import { hostname } from 'node:os';
7
- import QRCode from 'qrcode';
8
- import { credentialStore } from '../platform/windows/credential-store.mjs';
9
- import { readRecord, writeRecord } from '../service-lifecycle.mjs';
10
- import { serviceHealth } from '../service-health.mjs';
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/windows/credential-store.mjs';
7
+ import { readRecord, writeRecord } from '../service-lifecycle.mjs';
8
+ import { serviceHealth } from '../service-health.mjs';
11
9
  import { controlRequest } from '../service-control.mjs';
12
- import { connectBridgeRelay } from './connector.mjs';
13
- import { validProbeIceServers } from './p2p-probe.mjs';
14
- import { relayAddress, validSecret } from './wire.mjs';
15
- import { saveLanAuthorizations } from './lan-authorizations.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';
16
14
  import { lanNetworks } from '../platform/windows/network-info.mjs';
17
15
 
18
- const exec = promisify(execFile);
19
- const officialConfig = { url: 'wss://api.uulife.site/relay' };
16
+ const officialConfig = { url: serviceEndpoints.relayUrl };
20
17
 
21
18
  async function loadConfig(stateRoot) {
22
19
  const configPath = path.join(stateRoot, 'self-relay-config.json');
@@ -26,33 +23,16 @@ async function loadConfig(stateRoot) {
26
23
  if (config.p2pIceServers !== undefined && !validProbeIceServers(config.p2pIceServers)) throw new Error('p2pIceServers 只接受最多 4 个 stun:主机:端口地址');
27
24
  if (config.p2pWindowsRouteHelper !== undefined && typeof config.p2pWindowsRouteHelper !== 'boolean') throw new Error('p2pWindowsRouteHelper 必须是布尔值');
28
25
  if (config.p2pWindowsRouteHelper && !/^[A-Za-z0-9_-]{32,128}$/.test(config.p2pWindowsRouteHelperToken || '')) throw new Error('p2pWindowsRouteHelperToken 无效');
29
- const url = process.env.RELAY_URL || config.url;
30
- if (!url) throw new Error('请先配置 self-relay-config.json 的 url 和 hostKeyFile(或 sshHost、sshKeyPath),详见自建中继文档。');
31
- relayAddress(url);
32
- const resolved = relayAddress(url);
33
- const pairingUrl = config.pairingUrl || `${resolved.startsWith('wss:') ? 'https:' : 'http:'}//${new URL(resolved).host}/pair`;
34
- const pairing = new URL(pairingUrl);
35
- if (!['https:', 'http:'].includes(pairing.protocol) || pairing.pathname !== '/pair' || pairing.search || pairing.hash || pairing.username || pairing.password ||
36
- (pairing.protocol === 'http:' && pairing.hostname !== '127.0.0.1')) throw new Error('self-relay-config.json 的 pairingUrl 必须是 HTTPS /pair 地址。');
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 地址。');
37
34
  return { ...config, url: resolved, pairingUrl: pairing.href };
38
35
  }
39
- async function registrationKey(config) {
40
- if (config.url === officialConfig.url) return undefined;
41
- const hostKeyFile = process.env.RELAY_HOST_KEY_FILE || config.hostKeyFile;
42
- let hostKey;
43
- if (hostKeyFile) hostKey = fs.readFileSync(hostKeyFile, 'utf8').trim();
44
- else {
45
- if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(config.sshHost || '') ||
46
- !/^\/[a-zA-Z0-9/_.-]+$/.test(config.sshKeyPath || '')) throw new Error('中继注册凭据配置不完整或无效。');
47
- try {
48
- const result = await exec('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', config.sshHost,
49
- `sudo -n cat ${config.sshKeyPath}`], { encoding: 'utf8', timeout: 15000, windowsHide: true });
50
- hostKey = result.stdout.trim();
51
- } catch { throw new Error('无法通过已配置 SSH 读取中继注册凭据。'); }
52
- }
53
- if (!validSecret(hostKey)) throw new Error('中继注册凭据格式无效。');
54
- return hostKey;
55
- }
56
36
  export async function selfRelay(command, stateRoot) {
57
37
  const session = readRecord(path.join(stateRoot, 'bridge-session.json'));
58
38
  if (!session) {
@@ -64,142 +44,162 @@ export async function selfRelay(command, stateRoot) {
64
44
  }
65
45
 
66
46
  async function createRelayRuntime(stateRoot) {
67
- const config = await loadConfig(stateRoot);
68
- const session = () => readRecord(path.join(stateRoot, 'bridge-session.json'));
69
- let health = await serviceHealth(session());
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());
70
51
  if (health?.status !== 'ok') throw new Error('桌面服务尚未运行,请先执行 relax start --json。');
71
52
  const stored = await credentialStore('load');
72
- const identities = stored.record || { version: 1, servers: {} };
73
- const validate = value => {
74
- if (value?.version !== 1 || !value.servers || typeof value.servers !== 'object' || Array.isArray(value.servers)) throw new Error('本机设备身份损坏,未自动覆盖');
75
- for (const [url, item] of Object.entries(value.servers)) {
76
- relayAddress(url);
77
- if (!validSecret(item.secret) || !/^[\w-]{16,128}$/.test(item.id)) throw new Error('本机设备身份损坏,未自动覆盖');
78
- }
79
- };
80
- validate(identities);
81
- let identity = identities.servers[config.url];
53
+ let 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 === 1 && identities.servers && typeof identities.servers === 'object' && !Array.isArray(identities.servers)) {
57
+ const devices = {};
58
+ for (const [url, item] of Object.entries(identities.servers)) {
59
+ relayAddress(url);
60
+ if (!validIdentity(item?.id, item) || devices[item.id]) throw new Error('本机设备身份损坏,未自动覆盖');
61
+ devices[item.id] = item;
62
+ }
63
+ const previous = identities.servers[config.url];
64
+ identities = { version: 2, devices };
65
+ await credentialStore('save', identities);
66
+ if (!config.deviceId && previous) config.deviceId = previous.id;
67
+ }
68
+ if (identities.version !== 2 || !identities.devices || typeof identities.devices !== 'object' || Array.isArray(identities.devices) ||
69
+ Object.entries(identities.devices).some(([id, item]) => !validIdentity(id, item))) throw new Error('本机设备身份损坏,未自动覆盖');
70
+ if (!config.deviceId) {
71
+ const known = Object.values(identities.devices);
72
+ if (known.length > 1) throw new Error('存在多个工作站身份,请在 self-relay-config.json 中指定 deviceId。');
73
+ config.deviceId = known[0]?.id || randomUUID();
74
+ }
75
+ const createIdentity = config.deviceId === 'new';
76
+ if (createIdentity) config.deviceId = randomUUID();
77
+ if (!/^[\w-]{16,128}$/.test(config.deviceId)) throw new Error('self-relay-config.json 的 deviceId 无效。');
78
+ let identity = identities.devices[config.deviceId];
82
79
  if (!identity) {
83
- identity = { id: randomUUID(), secret: randomBytes(32).toString('base64url'), registered: false };
84
- identities.servers[config.url] = identity; await credentialStore('save', identities);
80
+ if (!createIdentity && Object.keys(identities.devices).length) throw new Error('找不到配置指定的工作站身份,未自动创建新身份。');
81
+ identity = { id: config.deviceId, secret: randomBytes(32).toString('base64url'), registered: false };
82
+ identities.devices[identity.id] = identity;
83
+ await credentialStore('save', identities);
85
84
  }
86
- const hostKey = identity.registered ? undefined : await registrationKey(config);
85
+ const persistedConfig = readRecord(configPath) || {};
86
+ if (persistedConfig.deviceId !== identity.id) writeRecord(configPath, { ...persistedConfig, deviceId: identity.id });
87
87
  let bridgeError = '';
88
- async function refreshHealth() {
88
+ async function refreshHealth() {
89
89
  try { health = await serviceHealth(session()) || { transportMode: health.transportMode, status: 'offline' }; bridgeError = ''; }
90
90
  catch (error) { bridgeError = error.message; health = { transportMode: health.transportMode, status: 'offline' }; }
91
- }
91
+ }
92
92
  let connector, qrPath, expiresAt = 0, paired = false, clientCount = 0, attachedCount = 0;
93
- let closing = false, online = false, connecting = false, retryTimer, attempts = 0, lastError = '';
94
- const audit = (event, details = {}) => console.log(JSON.stringify({ time: new Date().toISOString(), event, ...details }));
95
- async function currentLanCandidates() {
96
- try {
97
- const bridge = session(), port = bridge?.port || 45831;
98
- return (await lanNetworks()).map(network => ({ ...network, address: `http://${network.address}:${port}` }));
99
- } catch (error) {
100
- audit('lan-candidates-unavailable', { message: error.message }); return [];
101
- }
102
- }
103
- const status = () => {
104
- const available = online && !paired && qrPath && Date.now() < expiresAt;
105
- const next = !online ? (lastError || '电脑连接器正在恢复中继连接,已保存授权。')
106
- : paired ? '设备已授权,移动端可直接重连;网络断开不需要重新扫码。'
107
- : available ? '用要添加的移动设备在有效期内扫描统一配对二维码。连接器在后台运行。'
93
+ let closing = false, online = false, connecting = false, retryTimer, attempts = 0, lastError = '';
94
+ const audit = (event, details = {}) => console.log(JSON.stringify({ time: new Date().toISOString(), event, ...details }));
95
+ async function currentLanCandidates() {
96
+ try {
97
+ const bridge = session(), port = bridge?.port || 45831;
98
+ return (await lanNetworks()).map(network => ({ ...network, address: `http://${network.address}:${port}` }));
99
+ } catch (error) {
100
+ audit('lan-candidates-unavailable', { message: error.message }); return [];
101
+ }
102
+ }
103
+ const status = () => {
104
+ const available = online && !paired && qrPath && Date.now() < expiresAt;
105
+ const next = !online ? (lastError || '电脑连接器正在恢复中继连接,已保存授权。')
106
+ : paired ? '设备已授权,移动端可直接重连;网络断开不需要重新扫码。'
107
+ : available ? '用要添加的移动设备在有效期内扫描统一配对二维码。连接器在后台运行。'
108
108
  : '尚未完成设备授权;调用 relax pair --json 生成配对二维码。';
109
- return { running: !closing, relayConnected: online, mode: 'self-relay', transportMode: health.transportMode,
110
- relayUrl: config.url, deviceId: identity.id, paired, clientCount, authorization: online ? (paired ? 'authorized' : 'unpaired') : 'unverified', phoneConnected: attachedCount > 0,
109
+ return { running: !closing, relayConnected: online, mode: 'self-relay', transportMode: health.transportMode,
110
+ relayUrl: config.url, deviceId: identity.id, paired, clientCount, authorization: online ? (paired ? 'authorized' : 'unpaired') : 'unverified', phoneConnected: attachedCount > 0,
111
111
  bridgeReady: health.status === 'ok' && health.execution?.ready === true && health.sharedAppServerConnected === true, bridgeError,
112
- expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
113
- ...(available ? { qrPath } : {}), mobileAccess: 'unverified', lastError,
112
+ expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
113
+ ...(available ? { qrPath } : {}), mobileAccess: 'unverified', lastError,
114
114
  displayText: `Code Relax 中继 · ${health.transportMode === 'lan-shared' ? 'Codex 内核已就绪' : 'Codex 内核未就绪'}\n${next}` };
115
- };
116
- async function shutdown() {
117
- if (closing) return;
118
- closing = true; clearTimeout(retryTimer);
119
- connector?.events.removeAllListeners(); connector?.close();
120
- }
121
- async function saveInvitation(message) {
122
- if (!message.code) return;
123
- expiresAt = message.expires;
124
- const link = new URL(config.pairingUrl);
125
- link.searchParams.set('invite', message.code);
126
- // Keep previously displayed files intact even when their invitation expires.
127
- qrPath = path.join(stateRoot, `self-relay-qr-${randomUUID()}.png`);
128
- await QRCode.toFile(qrPath, link.href, { width: 640, margin: 3 });
129
- }
130
- function scheduleRetry() {
131
- if (closing || retryTimer) return;
132
- const delay = Math.min(1000 * 2 ** Math.min(attempts++, 5), 30000) * (0.8 + Math.random() * 0.2);
133
- retryTimer = setTimeout(() => { retryTimer = undefined; void connect(); }, delay);
134
- }
135
- async function connect() {
136
- if (closing || connecting) return;
137
- connecting = true;
138
- let retryAfterAttempt = false;
139
- try {
140
- const bridge = session();
141
- await refreshHealth();
142
- const lanCandidates = await currentLanCandidates();
143
- connector?.events.removeAllListeners(); connector?.close();
144
- const current = connector = connectBridgeRelay({ relayUrl: config.url, hostKey: identity.registered ? undefined : hostKey, identity,
115
+ };
116
+ async function shutdown() {
117
+ if (closing) return;
118
+ closing = true; clearTimeout(retryTimer);
119
+ connector?.events.removeAllListeners(); connector?.close();
120
+ }
121
+ async function saveInvitation(message) {
122
+ if (!message.code) return;
123
+ expiresAt = message.expires;
124
+ const link = new URL(config.pairingUrl);
125
+ link.searchParams.set('invite', message.code);
126
+ // Keep previously displayed files intact even when their invitation expires.
127
+ qrPath = path.join(stateRoot, `self-relay-qr-${randomUUID()}.png`);
128
+ await QRCode.toFile(qrPath, link.href, { width: 640, margin: 3 });
129
+ }
130
+ function scheduleRetry() {
131
+ if (closing || retryTimer) return;
132
+ const delay = Math.min(1000 * 2 ** Math.min(attempts++, 5), 30000) * (0.8 + Math.random() * 0.2);
133
+ retryTimer = setTimeout(() => { retryTimer = undefined; void connect(); }, delay);
134
+ }
135
+ async function connect() {
136
+ if (closing || connecting) return;
137
+ connecting = true;
138
+ let retryAfterAttempt = false;
139
+ try {
140
+ const bridge = session();
141
+ await refreshHealth();
142
+ const lanCandidates = await currentLanCandidates();
143
+ connector?.events.removeAllListeners(); connector?.close();
144
+ const current = connector = connectBridgeRelay({ relayUrl: config.url, identity,
145
145
  usageCacheFile: path.join(stateRoot, 'relay-usage-tasks.json'),
146
- bridgeUrl: `ws://127.0.0.1:${bridge?.port || 45831}/api/socket`, bridgeToken: bridge?.accessUrl ? new URL(bridge.accessUrl).searchParams.get('token') : 'unavailable',
147
- bridgeConnection: () => { const latest = session(); return { url: `ws://127.0.0.1:${latest.port}/api/socket`, token: new URL(latest.accessUrl).searchParams.get('token') }; },
146
+ bridgeUrl: `ws://127.0.0.1:${bridge?.port || 45831}/api/socket`, bridgeToken: bridge?.accessUrl ? new URL(bridge.accessUrl).searchParams.get('token') : 'unavailable',
147
+ bridgeConnection: () => { const latest = session(); return { url: `ws://127.0.0.1:${latest.port}/api/socket`, token: new URL(latest.accessUrl).searchParams.get('token') }; },
148
148
  hostName: hostname(), lanCandidates, p2pProbe: config.p2pProbe === true, iceServers: config.p2pIceServers || [],
149
149
  p2pWindowsRouteHelper: config.p2pWindowsRouteHelper === true, p2pWindowsRouteHelperToken: config.p2pWindowsRouteHelperToken || '' });
150
- current.events.on('p2p-probe', result => audit('p2p-probe', result));
151
- current.events.on('diagnostic', message => audit('transport-error', { message }));
152
- current.events.on('attached', () => { paired = true; attachedCount++; audit('phone-attached', { attachedCount }); });
153
- current.events.on('detached', () => { attachedCount = Math.max(0, attachedCount - 1); audit('phone-detached', { attachedCount }); });
154
- current.events.on('authorization-changed', update => {
155
- saveLanAuthorizations(stateRoot, identity.id, update.clients, update.revision);
156
- paired = update.clients.length > 0; clientCount = update.clients.length;
157
- audit('mobile-authorizations-updated', { clientCount, revision: update.revision });
158
- });
159
- current.events.once('disconnected', event => {
160
- if (connector !== current || closing) return;
161
- online = false; attachedCount = 0; lastError = `电脑与中继连接断开(${event.code}):${event.reason}`; audit('relay-disconnected', event);
162
- if (![1008, 4401, 4406, 4409, 4429].includes(event.code)) scheduleRetry();
163
- });
164
- const registration = await current.ready;
150
+ current.events.on('p2p-probe', result => audit('p2p-probe', result));
151
+ current.events.on('diagnostic', message => audit('transport-error', { message }));
152
+ current.events.on('attached', () => { paired = true; attachedCount++; audit('phone-attached', { attachedCount }); });
153
+ current.events.on('detached', () => { attachedCount = Math.max(0, attachedCount - 1); audit('phone-detached', { attachedCount }); });
154
+ current.events.on('authorization-changed', update => {
155
+ saveLanAuthorizations(stateRoot, identity.id, update.clients, update.revision);
156
+ paired = update.clients.length > 0; clientCount = update.clients.length;
157
+ audit('mobile-authorizations-updated', { clientCount, revision: update.revision });
158
+ });
159
+ current.events.once('disconnected', event => {
160
+ if (connector !== current || closing) return;
161
+ online = false; attachedCount = 0; lastError = `电脑与中继连接断开(${event.code}):${event.reason}`; audit('relay-disconnected', event);
162
+ if (![1008, 4401, 4406, 4409, 4429].includes(event.code)) scheduleRetry();
163
+ });
164
+ const registration = await current.ready;
165
165
  if (registration.protocolVersion !== 7 || registration.room !== identity.id) {
166
- current.events.removeAllListeners(); current.close(); throw Object.assign(new Error('中继服务器尚未升级设备授权协议,未使用临时配对降级。'), { code: 4401 });
167
- }
168
- if (!identity.registered) {
169
- const registeredIdentity = { ...identity, registered: true };
170
- identities.servers[config.url] = registeredIdentity;
171
- await credentialStore('save', identities); identity = registeredIdentity;
172
- }
173
- current.assertConnected();
174
- online = true; attempts = 0; paired = registration.paired; clientCount = registration.clientCount || 0; lastError = '';
175
- saveLanAuthorizations(stateRoot, identity.id, registration.clients || [], registration.revision);
176
- await saveInvitation(registration); audit('relay-connected', { paired });
177
- } catch (error) {
178
- online = false; lastError = error.message; audit('connect-failed', { message: error.message });
179
- if (![1008, 4401, 4406, 4409, 4429].includes(error.code)) retryAfterAttempt = true;
180
- else { clearTimeout(retryTimer); retryTimer = undefined; }
181
- } finally { connecting = false; if (retryAfterAttempt) scheduleRetry(); }
182
- }
183
- async function pair() {
184
- if (!online || closing) throw new Error('中继尚未连接,设备授权保持不变。');
185
- await saveInvitation(await connector.command('pair-code'));
186
- // Each invitation adds or refreshes one mobile identity without revoking other devices.
187
- return { ...status(), qrPath, expiresAt: new Date(expiresAt).toISOString(),
188
- displayText: '用要添加的移动设备扫描此码。每台设备只需配对一次;已有设备授权保持不变。' };
189
- }
190
- async function revoke() {
191
- if (!online || closing) throw new Error('中继尚未连接,未撤销授权。');
192
- await connector.command('revoke'); paired = false; clientCount = 0; attachedCount = 0; qrPath = undefined; expiresAt = 0;
166
+ current.events.removeAllListeners(); current.close(); throw Object.assign(new Error('中继服务器尚未升级设备授权协议,未使用临时配对降级。'), { code: 4401 });
167
+ }
168
+ if (!identity.registered) {
169
+ const registeredIdentity = { ...identity, registered: true };
170
+ identities.devices[identity.id] = registeredIdentity;
171
+ await credentialStore('save', identities); identity = registeredIdentity;
172
+ }
173
+ current.assertConnected();
174
+ online = true; attempts = 0; paired = registration.paired; clientCount = registration.clientCount || 0; lastError = '';
175
+ saveLanAuthorizations(stateRoot, identity.id, registration.clients || [], registration.revision);
176
+ await saveInvitation(registration); audit('relay-connected', { paired });
177
+ } catch (error) {
178
+ online = false; lastError = error.message; audit('connect-failed', { message: error.message });
179
+ if (![1008, 4401, 4406, 4409, 4429].includes(error.code)) retryAfterAttempt = true;
180
+ else { clearTimeout(retryTimer); retryTimer = undefined; }
181
+ } finally { connecting = false; if (retryAfterAttempt) scheduleRetry(); }
182
+ }
183
+ async function pair() {
184
+ if (!online || closing) throw new Error('中继尚未连接,设备授权保持不变。');
185
+ await saveInvitation(await connector.command('pair-code'));
186
+ // Each invitation adds or refreshes one mobile identity without revoking other devices.
187
+ return { ...status(), qrPath, expiresAt: new Date(expiresAt).toISOString(),
188
+ displayText: '用要添加的移动设备扫描此码。每台设备只需配对一次;已有设备授权保持不变。' };
189
+ }
190
+ async function revoke() {
191
+ if (!online || closing) throw new Error('中继尚未连接,未撤销授权。');
192
+ await connector.command('revoke'); paired = false; clientCount = 0; attachedCount = 0; qrPath = undefined; expiresAt = 0;
193
193
  return { ...status(), displayText: '全部移动设备授权已撤销;重新添加请调用 relax pair --json。' };
194
- }
195
- async function refreshLan() {
196
- if (!online || closing) throw new Error('中继尚未连接,未更新局域网候选。');
197
- await refreshHealth();
198
- const candidates = await currentLanCandidates();
199
- connector.updateLanCandidates(candidates);
200
- audit('lan-candidates-updated', { count: candidates.length });
201
- return { ...status(), lanCandidateCount: candidates.length };
202
- }
194
+ }
195
+ async function refreshLan() {
196
+ if (!online || closing) throw new Error('中继尚未连接,未更新局域网候选。');
197
+ await refreshHealth();
198
+ const candidates = await currentLanCandidates();
199
+ connector.updateLanCandidates(candidates);
200
+ audit('lan-candidates-updated', { count: candidates.length });
201
+ return { ...status(), lanCandidateCount: candidates.length };
202
+ }
203
203
  const inspect = async () => {
204
204
  if (online) {
205
205
  const remote = await connector.command('status'); paired = remote.paired;
@@ -0,0 +1,26 @@
1
+ // Public policy page: no scripts, cookies, external assets or native bridge.
2
+ export const privacyPage = `<!doctype html>
3
+ <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>Code Relax 隐私政策</title>
4
+ <style>:root{--page-background:#ffffff;--primary-text:#202520;--secondary-text:#667066;--surface-border:#d5ddd8;--space:16px;--body:14px;--title:20px}@media(prefers-color-scheme:dark){:root{--page-background:#181d1a;--primary-text:#eef4ef;--secondary-text:#a5afa7;--surface-border:#354139}}*{box-sizing:border-box}body{margin:0;padding:24px var(--space);background:var(--page-background);color:var(--primary-text);font:var(--body)/1.8 system-ui,sans-serif;overflow-wrap:anywhere}main{max-width:720px;margin:auto}h1{font-size:var(--title)}h2{font-size:16px;margin-top:24px}p,li{margin-block:8px}.meta{color:var(--secondary-text)}a{color:inherit}footer{margin-top:24px;border-top:1px solid var(--surface-border);padding-top:var(--space)}</style></head><body><main>
5
+ <h1>Code Relax 隐私政策</h1><p class="meta">更新及生效日期:2026 年 9 月 26 日<br>运营者:房鹤<br>联系邮箱:wyshmily@126.com</p>
6
+ <p>Code Relax 用于通过手机访问您已授权的电脑上的 Codex。本文说明当前私测版本如何处理信息。请在使用邮箱账号、配对、语音、图片或文件功能前阅读;相关权限在您使用对应功能时申请。</p>
7
+ <h2>一、我们处理哪些信息</h2>
8
+ <ul><li><strong>邮箱账号:</strong>邮箱地址、验证状态、账号标识、登录会话及验证码校验记录,用于登录、身份校验和注销。验证码不以明文保存;服务端保存登录令牌的哈希。</li>
9
+ <li><strong>工作站配对:</strong>工作站标识、名称、连接地址和配对授权标识,用于发现、鉴权和重新连接。工作站及手机凭据保存在各自设备的受保护存储中,云端保存凭据哈希。</li>
10
+ <li><strong>聊天与附件:</strong>您输入的文本、所选图片或文件、Codex 的回复和工具状态,用于远程交互。局域网直连不经过 Code Relax 云中继;使用 Code Relax 中继时,业务数据可能经服务器转发,采用 TLS 传输,目前不提供端到端加密。中继不以聊天历史库或附件库的形式保存这些内容。工作站会保存收到的附件,Codex 历史由电脑上的 Codex 管理。</li>
11
+ <li><strong>用量与诊断:</strong>账号、工作站、手机授权标识、消息/轮次标识、数量、任务时长及时间记录,用于展示服务用量与排障;服务还会产生必要的连接、错误和访问日志,可能包含 IP 地址、设备标识和时间。我们不以用量统计为目的记录聊天正文。</li>
12
+ <li><strong>手机缓存:</strong>手机保留配对记录、账号凭据、展示历史、缩略图和诊断日志,以支持恢复和显示。当前历史与图片缓存有容量淘汰策略,缓存并非电脑原始历史的唯一副本。</li></ul>
13
+ <h2>二、权限与第三方服务</h2>
14
+ <p>扫码时使用相机,语音录入时使用麦克风与华为系统语音识别,添加附件时读取您选择的图片或文件。语音识别由手机系统能力完成,识别结果用于输入框;Code Relax 不建立云端录音库。系统服务自身的信息处理以对应服务说明为准。</p>
15
+ <p>邮箱验证码使用腾讯云邮件推送;人机验证使用腾讯云验证码服务,涉及邮箱、IP 及验证票据等完成验证所需信息。服务部署于腾讯云基础设施。使用 OpenAI 登录、官方中继及 Codex 时,相关账号与业务数据由 OpenAI 按其政策处理;Code Relax 邮箱账号不等于 OpenAI 账号。我们不出售您的个人信息,不使用广告 SDK。</p>
16
+ <h2>三、保存和删除</h2>
17
+ <p>邮箱账号信息与账号用量明细在账号存续期间保存。注销后账号立即停用,相关个人信息及账号用量记录将在7天内删除。清理期间该邮箱暂时无法注册或登录;清理完成后,同一邮箱可重新注册为新用户,不继承原账号记录或额度。登录会话有效期为 30 天,邮件验证码有效期为 10 分钟;有效期指授权或验证有效时间,不代表数据库记录恰好在该时刻物理清除。安全限流记录、诊断及系统日志按其用途和清理策略保留;您可通过上述邮箱申请查询或删除与您相关的信息。</p>
18
+ <p>服务器部署会生成受访问限制的数据库备份,仅用于故障恢复;这些备份定时过期清理,注销相关账号数据及备份副本的清理纳入上述7天期限。</p>
19
+ <h2>四、账号注销与您的选择</h2>
20
+ <p>您可在“我的账号 → 注销账号”确认注销。确认后账号被标记为已注销,全部登录会话失效,停止账号计时并断开该账号中继业务连接;如有管理员权限,也不能继续使用。邮箱登录身份、会话、账号用量和账号关联记录将在7天内删除;7天是最长期限,不代表必须保留满7天。清理完成前不能用同一邮箱注册或登录,清理完成后可重新注册。</p>
21
+ <p><strong>注销 Code Relax 账号不会删除电脑上的 Codex 对话和文件、OpenAI 账号,也不会撤销独立的工作站与手机配对授权。</strong>如需撤销配对,请在工作站授权管理中处理。工作站级汇总统计与独立设备授权记录可能继续保留;这些记录不应被理解为完全匿名数据。手机已有历史缓存也不会因邮箱账号注销而全部清除;可通过清除 App 数据或卸载清理本地数据,此操作会丢失本机保存的配对凭据。</p>
22
+ <p>您可联系运营者申请查询、更正、删除相关个人信息、说明保存期限或提出投诉。我们会核验请求者身份,并在收到有效请求后 15 个工作日内答复处理情况;必要的核验只用于保护您的账号与数据。</p>
23
+ <h2>五、政策更新</h2>
24
+ <p>当处理目的、信息类型或服务发生变化时,我们会更新本页面,并按变化情况在应用中告知、取得必要的授权。本页面的说明必须与实际功能一致;未启用的社区、广告或付费功能不作为本政策已实施的内容。</p>
25
+ <footer>运营者:房鹤<br>个人信息与账号问题:wyshmily@126.com</footer>
26
+ </main></body></html>`;