@walkhi/code-relax 0.1.0-beta.7 → 0.1.0-beta.9

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 (45) hide show
  1. package/README.md +12 -13
  2. package/dist/bin/codex-remote.mjs +43 -18
  3. package/dist/bin/self-relay-demo.mjs +1 -1
  4. package/dist/shared/app-server-events.cjs +5 -3
  5. package/dist/src/desktop-launcher.mjs +7 -10
  6. package/dist/src/managed-app-server.mjs +3 -0
  7. package/dist/src/message-images.mjs +0 -18
  8. package/dist/src/onboarding.mjs +3 -4
  9. package/dist/src/platform/README.md +1 -1
  10. package/dist/src/platform/windows/desktop-shortcut-run.ps1 +1 -1
  11. package/dist/src/platform/windows/desktop-shortcuts.ps1 +1 -1
  12. package/dist/src/platform/windows/resident-startup.ps1 +58 -18
  13. package/dist/src/platform/windows/service-host.mjs +4 -7
  14. package/dist/src/platform/windows/stop-desktop.ps1 +7 -4
  15. package/dist/src/self-relay/admin-page.mjs +54 -0
  16. package/dist/src/self-relay/admin-state.mjs +25 -12
  17. package/dist/src/self-relay/demo.mjs +2 -3
  18. package/dist/src/self-relay/lifecycle.mjs +6 -6
  19. package/dist/src/self-relay/server.mjs +7 -11
  20. package/dist/src/server.mjs +163 -1034
  21. package/dist/src/service-doctor.mjs +10 -5
  22. package/dist/src/service-lifecycle.mjs +3 -8
  23. package/dist/src/shared-app-server.mjs +13 -11
  24. package/dist/src/shared-recovery.mjs +5 -5
  25. package/dist/src/shared-thread-stream.mjs +1 -1
  26. package/dist/web/capabilities.js +1 -8
  27. package/dist/web/chat-transport.js +6 -12
  28. package/dist/web/chat.css +13 -14
  29. package/dist/web/chat.js +23 -41
  30. package/dist/web/community.css +15 -10
  31. package/dist/web/community.html +4 -5
  32. package/dist/web/composer-controller.js +0 -4
  33. package/dist/web/index.html +7 -39
  34. package/dist/web/resources.json +1 -1
  35. package/dist/web/task-list-view.js +1 -8
  36. package/dist/web/timeline-reducer.js +0 -4
  37. package/package.json +16 -18
  38. package/tools/postinstall.mjs +4 -1
  39. package/dist/src/app-server-client.mjs +0 -468
  40. package/dist/src/app-server-tasks.mjs +0 -358
  41. package/dist/src/platform/windows/desktop-monitor.mjs +0 -34
  42. package/dist/src/platform/windows/desktop-tools.mjs +0 -48
  43. package/dist/src/thread-catalog.mjs +0 -47
  44. package/dist/tools/find-desktop-pipe.mjs +0 -10
  45. package/dist/web/community-view.js +0 -20
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
 
5
5
  export function relayAdminState(file, initialMaxRelaySessions) {
6
- let value = { version: 1, maxRelaySessions: initialMaxRelaySessions, announcement: null };
6
+ let value = { version: 2, maxRelaySessions: initialMaxRelaySessions, announcement: null, announcements: [] };
7
7
  if (file && fs.existsSync(file)) value = validate(JSON.parse(fs.readFileSync(file, 'utf8')));
8
8
 
9
9
  function save(next) {
@@ -32,25 +32,38 @@ export function relayAdminState(file, initialMaxRelaySessions) {
32
32
  read: () => structuredClone(value),
33
33
  setMaxRelaySessions(maxRelaySessions) { return save({ ...value, maxRelaySessions }); },
34
34
  publish(title, message) {
35
- return save({ ...value, announcement: { id: randomUUID(), title: clean(title, 80), message: clean(message, 2000),
36
- publishedAt: new Date().toISOString() } });
35
+ const announcement = { id: randomUUID(), title: clean(title, 80), message: clean(message, 2000),
36
+ publishedAt: new Date().toISOString() };
37
+ return save({ ...value, announcement, announcements: [announcement, ...value.announcements] });
37
38
  },
38
39
  withdraw() { return save({ ...value, announcement: null }); },
40
+ deleteAnnouncement(id) {
41
+ if (typeof id !== 'string' || !value.announcements.some(item => item.id === id)) throw new Error('Announcement not found');
42
+ if (value.announcement?.id === id) throw new Error('Withdraw the current announcement before deleting it');
43
+ return save({ ...value, announcements: value.announcements.filter(item => item.id !== id) });
44
+ },
39
45
  };
40
46
  }
41
47
 
42
48
  function validate(source) {
43
- if (!source || source.version !== 1 || !Number.isSafeInteger(source.maxRelaySessions) ||
49
+ if (!source || ![1, 2].includes(source.version) || !Number.isSafeInteger(source.maxRelaySessions) ||
44
50
  source.maxRelaySessions < 1 || source.maxRelaySessions > 256) throw new Error('Invalid relay admin state');
45
- let announcement = null;
46
- if (source.announcement !== null && source.announcement !== undefined) {
47
- const item = source.announcement;
48
- if (!item || !/^[\w-]{16,128}$/.test(item.id) || !Number.isFinite(Date.parse(item.publishedAt))) {
49
- throw new Error('Invalid relay announcement');
50
- }
51
- announcement = { id: item.id, title: clean(item.title, 80), message: clean(item.message, 2000), publishedAt: item.publishedAt };
51
+ const announcement = source.announcement == null ? null : validateAnnouncement(source.announcement);
52
+ const announcements = source.version === 1 ? (announcement ? [announcement] : []) : source.announcements;
53
+ if (!Array.isArray(announcements)) throw new Error('Invalid relay announcement history');
54
+ const validated = announcements.map(validateAnnouncement);
55
+ if (new Set(validated.map(item => item.id)).size !== validated.length ||
56
+ (announcement && !validated.some(item => item.id === announcement.id &&
57
+ item.title === announcement.title && item.message === announcement.message && item.publishedAt === announcement.publishedAt))) {
58
+ throw new Error('Invalid relay announcement history');
52
59
  }
53
- return { version: 1, maxRelaySessions: source.maxRelaySessions, announcement };
60
+ return { version: 2, maxRelaySessions: source.maxRelaySessions, announcement, announcements: validated };
61
+ }
62
+
63
+ function validateAnnouncement(item) {
64
+ if (!item || !/^[\w-]{16,128}$/.test(item.id) || typeof item.publishedAt !== 'string' ||
65
+ !Number.isFinite(Date.parse(item.publishedAt))) throw new Error('Invalid relay announcement');
66
+ return { id: item.id, title: clean(item.title, 80), message: clean(item.message, 2000), publishedAt: item.publishedAt };
54
67
  }
55
68
 
56
69
  function clean(value, limit) {
@@ -7,7 +7,7 @@ import { connectRelayClient } from './client.mjs';
7
7
  import { secret } from './wire.mjs';
8
8
 
9
9
  export async function startRelayDemo({ mode = 'lan-shared', pairingTtlMs, dispatch, remoteRelay, hostName = 'TEST-PC', lanCandidates = [], relayOptions = {} } = {}) {
10
- if (!['lan-legacy', 'lan-shared'].includes(mode)) throw new Error('Invalid demo mode');
10
+ if (mode !== 'lan-shared') throw new Error('Invalid demo mode');
11
11
  const token = secret(), calls = [];
12
12
  const bridge = http.createServer((_request, response) => { response.writeHead(404); response.end(); });
13
13
  const lan = attachLanSocket(bridge, { token, dispatch: async (request, reply) => {
@@ -17,8 +17,7 @@ export async function startRelayDemo({ mode = 'lan-shared', pairingTtlMs, dispat
17
17
  if (request.url.split('?')[0] === '/api/threads/demo/events') {
18
18
  reply.startEvents(); reply.event('update', { thread: { id: 'demo' }, simulated: true }); return;
19
19
  }
20
- if (request.url === '/api/demo/stop') return reply.json(mode === 'lan-shared' ? 200 : 409,
21
- mode === 'lan-shared' ? { stopped: true, simulated: true } : { error: '基础控制不支持停止任务' });
20
+ if (request.url === '/api/demo/stop') return reply.json(200, { stopped: true, simulated: true });
22
21
  reply.json(404, { error: '模拟 Bridge 不执行真实任务' });
23
22
  } });
24
23
  await new Promise(resolve => bridge.listen(0, '127.0.0.1', resolve));
@@ -79,7 +79,7 @@ export async function selfRelay(command, stateRoot, requiredMode = '') {
79
79
  if (current) {
80
80
  if (requiredMode) {
81
81
  const bridge = await serviceHealth(readRecord(path.join(stateRoot, 'bridge-session.json')));
82
- if (bridge?.transportMode !== requiredMode) throw new Error('当前 Bridge 未启用完整控制;请先执行 shared-start。');
82
+ if (bridge?.transportMode !== requiredMode) throw new Error('Codex 内核未就绪;请先执行 shared-start。');
83
83
  }
84
84
  if (command === 'relay-revoke') return controlRequest(previous, 'POST', 'revoke');
85
85
  if (command === 'relay-pair' || (current.relayConnected && !current.paired && !current.qrPath)) return controlRequest(previous, 'POST', 'pair');
@@ -115,7 +115,7 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
115
115
  const session = () => readRecord(path.join(stateRoot, 'bridge-session.json'));
116
116
  let health = await serviceHealth(session());
117
117
  if (health?.status !== 'ok') throw new Error('Bridge 尚未运行,请先执行 relax start --json。');
118
- if (requiredMode && health.transportMode !== requiredMode) throw new Error('完整控制的 Code Relax 中继需要完整控制 Bridge,请先执行 relax shared-start --json。');
118
+ if (requiredMode && health.transportMode !== requiredMode) throw new Error('Codex 内核未就绪,请先执行 relax shared-start --json。');
119
119
  const legacyPath = path.join(stateRoot, 'self-relay-device.json');
120
120
  const legacy = readRecord(legacyPath);
121
121
  const stored = await credentialStore('load');
@@ -145,8 +145,8 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
145
145
  const hostKey = identity.registered ? undefined : await registrationKey(config);
146
146
  let bridgeError = '';
147
147
  async function refreshHealth() {
148
- try { health = await serviceHealth(session()) || { transportMode: health.transportMode, status: 'offline', desktopConnected: false }; bridgeError = ''; }
149
- catch (error) { bridgeError = error.message; health = { transportMode: health.transportMode, status: 'offline', desktopConnected: false }; }
148
+ try { health = await serviceHealth(session()) || { transportMode: health.transportMode, status: 'offline' }; bridgeError = ''; }
149
+ catch (error) { bridgeError = error.message; health = { transportMode: health.transportMode, status: 'offline' }; }
150
150
  }
151
151
  const secret = randomBytes(32).toString('base64url');
152
152
  let connector, control, qrPath, expiresAt = 0, paired = false, clientCount = 0, attachedCount = 0;
@@ -168,10 +168,10 @@ async function worker(stateRoot, instanceId, refresh = false, requiredMode = '')
168
168
  : '尚未完成设备授权;调用 relax pair --json 生成配对二维码。';
169
169
  return { running: !closing, relayConnected: online, mode: 'self-relay', transportMode: health.transportMode,
170
170
  relayUrl: config.url, deviceId: identity.id, paired, clientCount, authorization: online ? (paired ? 'authorized' : 'unpaired') : 'unverified', phoneConnected: attachedCount > 0,
171
- bridgeReady: health.status === 'ok' && Boolean(health.sharedAppServerConnected || health.desktopConnected), bridgeError,
171
+ bridgeReady: health.status === 'ok' && health.execution?.ready === true && health.sharedAppServerConnected === true, bridgeError,
172
172
  expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
173
173
  ...(available ? { qrPath } : {}), mobileAccess: 'unverified', lastError,
174
- displayText: `Code Relax 中继 · ${health.transportMode === 'lan-shared' ? '完整控制' : '基础控制'}\n${next}\n停止连接器保留授权:relax relay-stop --json。撤销全部移动设备:relax relay-revoke --json。` };
174
+ displayText: `Code Relax 中继 · ${health.transportMode === 'lan-shared' ? 'Codex 内核已就绪' : 'Codex 内核未就绪'}\n${next}\n停止连接器保留授权:relax relay-stop --json。撤销全部移动设备:relax relay-revoke --json。` };
175
175
  };
176
176
  async function shutdown() {
177
177
  if (closing) return;
@@ -6,6 +6,7 @@ import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
6
6
  import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
7
7
  import { validProbeSignal } from './p2p-probe.mjs';
8
8
  import { relayAdminState } from './admin-state.mjs';
9
+ import { adminPage } from './admin-page.mjs';
9
10
 
10
11
  // Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
11
12
  export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
@@ -91,7 +92,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
91
92
  adminReply(response, 200, { maxRelaySessions: state.maxRelaySessions, activeRelaySessions,
92
93
  activeExemptRelaySessions: [...rooms.values()].reduce((sum, room) => sum + [...room.connections.values()].filter(connection => connection.relayExempt).length, 0),
93
94
  websocketConnections: wss.clients.size, onlineComputers: [...rooms.values()].filter(room => room.host).length,
94
- activePhones: [...rooms.values()].reduce((sum, room) => sum + room.connections.size, 0), announcement: state.announcement }); return;
95
+ activePhones: [...rooms.values()].reduce((sum, room) => sum + room.connections.size, 0),
96
+ announcement: state.announcement, announcements: state.announcements }); return;
95
97
  }
96
98
  if (!['PUT', 'DELETE'].includes(request.method || '') || request.headers['content-type'] !== 'application/json' ||
97
99
  request.headers['x-code-relax-admin'] !== '1') { adminReply(response, 405, { error: 'Unsupported admin request' }); return; }
@@ -106,6 +108,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
106
108
  } else if (target.pathname === '/admin/api/announcement' && request.method === 'DELETE') {
107
109
  const state = adminState.withdraw(); broadcastAnnouncement(state.announcement);
108
110
  log({ event: 'admin-announcement-withdrawn' });
111
+ } else if (target.pathname.startsWith('/admin/api/announcements/') && request.method === 'DELETE') {
112
+ const id = target.pathname.slice('/admin/api/announcements/'.length);
113
+ adminState.deleteAnnouncement(id);
114
+ log({ event: 'admin-announcement-deleted', announcementId: id });
109
115
  } else { adminReply(response, 404, { error: 'Admin route not found' }); return; }
110
116
  adminReply(response, 200, { ok: true });
111
117
  } catch (error) { adminReply(response, 400, { error: error.message }); }
@@ -456,13 +462,3 @@ function readBody(request, limit) {
456
462
  request.on('end', () => resolve(value)); request.on('error', reject);
457
463
  });
458
464
  }
459
-
460
- function adminPage() {
461
- return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Code Relax 管理</title><style>
462
- body{margin:0;background:#0d1210;color:#eef4f0;font:15px system-ui,sans-serif}main{max-width:760px;margin:40px auto;padding:0 20px}section{background:#151d19;border:1px solid #2a3931;border-radius:14px;padding:18px;margin:16px 0}h1{font-size:24px}h2{font-size:17px}label{display:block;margin:12px 0 6px;color:#aab7af}input,textarea{box-sizing:border-box;width:100%;padding:11px;border:1px solid #43564b;border-radius:9px;background:#0d1210;color:#eef4f0}textarea{min-height:120px;resize:vertical}button{margin:12px 8px 0 0;padding:10px 16px;border:0;border-radius:9px;background:#2c7653;color:white}button.secondary{background:#39443e}pre{white-space:pre-wrap;color:#b9c8bf}.row{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.metric{padding:12px;background:#0d1210;border-radius:9px}.metric b{display:block;font-size:22px}</style><main><h1>Code Relax 管理</h1><section><h2>运行状态</h2><div id="metrics" class="row"></div><button onclick="load()">刷新</button></section><section><h2>服务器转发容量</h2><label>最大并发 WSS 转发会话</label><input id="limit" type="number" min="1" max="256"><button onclick="saveLimit()">保存</button></section><section><h2>系统公告</h2><label>标题</label><input id="title" maxlength="80"><label>正文</label><textarea id="message" maxlength="2000"></textarea><button onclick="publish()">发布</button><button class="secondary" onclick="withdraw()">撤下</button><pre id="current"></pre></section><pre id="result"></pre><script>
463
- const request=async(path,options={})=>{const response=await fetch(path,options);const body=await response.json();if(!response.ok)throw new Error(body.error||('HTTP '+response.status));return body};
464
- async function load(){try{const s=await request('/admin/api/status');limit.value=s.maxRelaySessions;metrics.innerHTML='<div class="metric"><b>'+s.activeRelaySessions+'</b>受限转发 / '+s.maxRelaySessions+'</div><div class="metric"><b>'+s.activeExemptRelaySessions+'</b>白名单转发</div><div class="metric"><b>'+s.websocketConnections+'</b>WebSocket</div><div class="metric"><b>'+s.onlineComputers+'</b>在线工作站</div><div class="metric"><b>'+s.activePhones+'</b>活动手机</div>';current.textContent=s.announcement?s.announcement.title+'\\n'+s.announcement.message+'\\n'+s.announcement.publishedAt:'当前无公告';}catch(e){result.textContent=e.message}}
465
- const write=(method,path,body)=>request(path,{method,headers:{'Content-Type':'application/json','X-Code-Relax-Admin':'1'},body:JSON.stringify(body)}).then(()=>{result.textContent='已保存';return load()}).catch(e=>result.textContent=e.message);
466
- function saveLimit(){return write('PUT','/admin/api/settings',{maxRelaySessions:Number(limit.value)})}function publish(){return write('PUT','/admin/api/announcement',{title:title.value,message:message.value})}function withdraw(){return write('DELETE','/admin/api/announcement',{})}load();
467
- </script></main></html>`;
468
- }