@walkhi/code-relax 0.1.0-beta.16 → 0.1.0-beta.18

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 (33) hide show
  1. package/README.md +14 -14
  2. package/dist/bin/codex-remote.mjs +47 -37
  3. package/dist/bin/manage-relay-admin.mjs +20 -0
  4. package/dist/bin/migrate-relay-db.mjs +3 -1
  5. package/dist/bin/self-relay-demo.mjs +1 -1
  6. package/dist/shared/app-server-events.cjs +22 -9
  7. package/dist/skills/code-relax/SKILL.md +6 -5
  8. package/dist/src/desktop-launcher.mjs +1 -1
  9. package/dist/src/lan-socket-server.mjs +1 -1
  10. package/dist/src/onboarding.mjs +6 -6
  11. package/dist/src/platform/windows/resident-start.js +12 -0
  12. package/dist/src/platform/windows/resident-startup.ps1 +21 -8
  13. package/dist/src/platform/windows/user-environment.mjs +1 -1
  14. package/dist/src/self-relay/admin-page.mjs +13 -5
  15. package/dist/src/self-relay/admin-server.mjs +13 -8
  16. package/dist/src/self-relay/admin-style.mjs +9 -0
  17. package/dist/src/self-relay/admin-usage-page.mjs +28 -0
  18. package/dist/src/self-relay/connector.mjs +158 -20
  19. package/dist/src/self-relay/demo.mjs +1 -1
  20. package/dist/src/self-relay/email-accounts.mjs +17 -1
  21. package/dist/src/self-relay/lifecycle.mjs +5 -4
  22. package/dist/src/self-relay/mysql-state.mjs +1 -1
  23. package/dist/src/self-relay/server.mjs +105 -4
  24. package/dist/src/self-relay/usage-meter.mjs +125 -0
  25. package/dist/src/server.mjs +47 -9
  26. package/dist/src/service-control.mjs +4 -4
  27. package/dist/src/service-doctor.mjs +4 -4
  28. package/dist/src/service-health.mjs +2 -2
  29. package/dist/src/service-lifecycle.mjs +8 -8
  30. package/dist/src/shared-app-server.mjs +7 -4
  31. package/dist/src/shared-recovery.mjs +1 -1
  32. package/package.json +1 -1
  33. package/tools/postinstall.mjs +3 -3
@@ -1,5 +1,7 @@
1
+ import { adminUiTokens } from './admin-style.mjs';
2
+
1
3
  export function adminPage() {
2
- return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="only light"><meta name="theme-color" content="#f5f7fa"><title>Code Relax 管理</title>
4
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="only light"><meta name="theme-color" content="#f5f7fa"><title>Code Relax 管理</title><style>${adminUiTokens}</style>
3
5
  <style>@media(max-width:600px){body main{padding:12px 12px 40px}header.page-head{align-items:center;gap:8px;margin-bottom:8px}header.page-head h1{font-size:20px;line-height:1.2;margin:0}main .nav{gap:0;margin-bottom:12px}main .nav button{padding:8px 11px}main .stack,main .grid{gap:10px}main .card{padding:12px}main .hero strong{font-size:20px}main .throughput{grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-top:10px}main .metrics{gap:6px}main .metric{display:flex;flex-direction:row-reverse;align-items:center;justify-content:space-between;gap:6px;min-width:0;padding:9px 10px}main .metric strong{font-size:17px;margin:0;white-space:nowrap}main .metric span{font-size:11px}main .card-head{margin-bottom:10px}}</style>
4
6
  <style>@media(max-width:600px){main .throughput .metric strong{font-size:17px}}</style>
5
7
  <style>.metric.warn{border-color:#946d38;background:#352a1b}.metric.warn strong{color:#ffd192}.metric.critical{border-color:#a65b58;background:#38211f}.metric.critical strong{color:#ffb1a9}.state.critical{background:#72342e;color:#ffe1dc}#trafficTimeline.critical .quota-fill{background:#d46a60}</style>
@@ -19,15 +21,16 @@ button{background:#e0f0e5;color:#235d3a;border-color:#a9cbb4}button:hover{backgr
19
21
  #metrics{grid-template-columns:repeat(5,minmax(0,1fr))}.relay-heading{margin-bottom:14px}.resource-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.resource-head .hint{white-space:nowrap}.resource-card .throughput{border-top:1px solid #dce5df;padding-top:10px;margin-top:12px}.resource-card .throughput .metric{display:flex;flex-direction:row-reverse;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px}.resource-card .throughput .metric strong{font-size:16px;margin:0;white-space:nowrap}
20
22
  @media(max-width:800px){#metrics{grid-template-columns:repeat(2,minmax(0,1fr))}}
21
23
  @media(max-width:600px){.resource-card .throughput .metric strong{font-size:14px}}
22
- </style></head><body><main>
23
- <header class="page-head"><h1>中继管理</h1><nav class="nav" aria-label="管理页面"><button type="button" data-page="overview" aria-current="page">运行概览</button><button type="button" data-page="announcements">系统公告</button><button type="button" data-page="settings">容量设置</button></nav></header>
24
+ </style><style>main:not(.authenticated)>:not(#adminLogin){display:none!important}#adminLogin{max-width:430px;margin:10vh auto}#adminLogin[hidden]{display:none}.usage-link{display:inline-block;padding:12px 17px;color:#286e43;text-decoration:none}.usage-link:hover{text-decoration:underline}</style></head><body><main>
25
+ <section id="adminLogin" class="card"><h1>管理员登录</h1><p class="subtle">使用已授予管理权限的 Code Relax 邮箱账号</p><div class="field"><label for="loginEmail">邮箱</label><input id="loginEmail" type="email" autocomplete="email"></div><div class="field"><label for="loginCode">邮件验证码</label><input id="loginCode" inputmode="numeric" maxlength="6" autocomplete="one-time-code"></div><div class="actions"><button id="sendLoginCode" type="button">发送验证码</button><button id="verifyLogin" type="button">登录后台</button></div><p id="loginError" class="status error" role="alert"></p></section>
26
+ <header class="page-head"><h1>中继管理</h1><nav class="nav" aria-label="管理页面"><button type="button" data-page="overview" aria-current="page">运行概览</button><button type="button" data-page="announcements">系统公告</button><button type="button" data-page="settings">容量设置</button><a class="usage-link" href="/admin/usage">使用统计</a><button id="adminLogout" type="button" class="secondary">退出后台</button></nav></header>
24
27
  <section id="overview" class="page stack"><section class="card" aria-label="中继连接与转发"><div class="relay-heading"><h2>中继连接与转发</h2><span id="healthText" class="state">读取中</span></div><div id="metrics" class="metrics"></div></section><div class="grid"><section class="card resource-card" aria-label="服务器资源"><div class="card-head resource-head"><h2>服务器资源</h2><span id="sampleTime" class="hint">等待采样</span></div><div id="serverMetrics" class="metrics"></div><div class="throughput" id="throughput"></div></section><section class="card quota-card"><div class="card-head"><h2>流量包</h2><span id="trafficNote" class="hint">等待更新</span></div><p id="trafficUsage" class="traffic-usage">—</p><div id="trafficTimeline" class="quota-timeline" hidden><div class="quota-heading"><strong id="trafficRemaining">流量剩余 —</strong><span id="trafficPace" class="quota-pace">—×</span><span id="timeRemaining">时间剩余 —</span></div><div id="quotaTrack" class="quota-track" role="img"><span id="quotaFill" class="quota-fill"></span><span id="quotaNow" class="quota-now"></span></div><div class="quota-period"><span id="trafficEnd">到期 —</span><span id="trafficStart">本期开始 —</span></div></div></section></div></section>
25
28
  <section id="announcements" class="page" hidden><section class="card"><div class="announcement-toolbar"><div><h2>公告管理</h2><p id="announcementCount" class="hint">正在读取</p></div><button id="newAnnouncement" type="button">新建公告</button></div><div class="announcement-controls"><input id="announcementSearch" type="search" aria-label="搜索公告" placeholder="搜索标题或正文"><select id="announcementFilter" aria-label="筛选状态"><option value="all">全部状态</option><option value="active">发布中</option><option value="draft">草稿</option><option value="withdrawn">已撤下</option></select></div><div id="announcementList" class="announcement-list" style="margin-top:14px"></div></section></section>
26
29
  <dialog id="announcementEditor" aria-labelledby="editorHeading"><div class="editor-head"><h2 id="editorHeading">新建公告</h2><button id="closeEditor" class="secondary" type="button" aria-label="关闭">关闭</button></div><div class="field"><label for="title">标题</label><input id="title" maxlength="80" placeholder="输入公告标题"></div><div class="field"><label for="message">正文</label><textarea id="message" maxlength="2000" placeholder="输入公告内容"></textarea></div><p id="editorNote" class="hint"></p><p id="editorError" class="status error" role="alert" hidden></p><div id="editorActions" class="editor-actions"><button id="saveDraft" class="secondary" type="button">保存草稿</button><button id="publish" type="button">发布公告</button></div></dialog>
27
30
  <section id="settings" class="page stack" hidden><section class="card"><div class="card-head"><h2>服务器转发容量</h2><p class="hint">只限制经 WSS 转发的普通会话,WebRTC 直连与白名单转发不占用此额度</p></div><div class="field"><label for="limit">最大并发会话</label><input id="limit" type="number" min="1" max="256"></div><button id="saveLimit" type="button">保存设置</button><p class="note">当前上限是人工配置值,不随 CPU 或带宽自动改变。</p></section></section><p id="result" class="status" role="status" aria-live="polite"></p>
28
31
  </main><script>
29
32
  const el=id=>document.getElementById(id);let snapshot=null;
30
- 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};
33
+ const request=async(path,options={})=>{const token=sessionStorage.getItem('codeRelaxAdminToken');const headers={...options.headers,...(token?{Authorization:'Bearer '+token}:{})};const response=await fetch(path,{...options,headers});const body=await response.json();if(!response.ok)throw new Error(body.error||('HTTP '+response.status));return body};
31
34
  function setResult(message,error=false){el('result').textContent=message;el('result').classList.toggle('error',error)}
32
35
  function node(tag,className,value){const item=document.createElement(tag);if(className)item.className=className;if(value!==undefined)item.textContent=value;return item}
33
36
  function metric(value,label,extra,tone){const item=node('div','metric'+(tone?' '+tone:''));item.append(node('strong','',value),node('span','',label));if(extra)item.append(node('small','',extra));return item}
@@ -66,6 +69,11 @@ el('saveLimit').addEventListener('click',()=>write('PUT','/admin/api/settings',{
66
69
  el('newAnnouncement').addEventListener('click',()=>openEditor());el('closeEditor').addEventListener('click',()=>el('announcementEditor').close());el('announcementSearch').addEventListener('input',()=>{if(snapshot)renderAnnouncements(snapshot)});el('announcementFilter').addEventListener('change',()=>{if(snapshot)renderAnnouncements(snapshot)});
67
70
  async function saveAnnouncement(publish){const title=el('title').value.trim(),message=el('message').value.trim();if(!title||!message){el('editorError').textContent='请填写标题和正文';el('editorError').hidden=false;return}if(publish&&snapshot?.announcement&&snapshot.announcement.id!==editorItem?.id&&!confirm('发布后将替换当前公告,确定发布?'))return;const path=editorMode==='draft'?'/admin/api/announcements/'+encodeURIComponent(editorItem.id):'/admin/api/announcements';const target=publish&&editorMode==='draft'?path+'/publish':path;const body={title,message};if(publish&&editorMode==='new')body.publish=true;const ok=await write('PUT',target,body,publish?'公告已发布':'草稿已保存');if(ok)el('announcementEditor').close()}
68
71
  el('saveDraft').addEventListener('click',()=>saveAnnouncement(false));el('publish').addEventListener('click',()=>saveAnnouncement(true));
69
- showPage(['overview','announcements','settings'].includes(location.hash.slice(1))?location.hash.slice(1):'overview');setInterval(()=>{if(!document.hidden&&!el('overview').hidden)load()},5000);
72
+ function showLogin(error=''){document.querySelector('main').classList.remove('authenticated');el('adminLogin').hidden=false;el('loginError').textContent=error}
73
+ async function authorize(){try{await request('/admin/api/session');document.querySelector('main').classList.add('authenticated');el('adminLogin').hidden=true;showPage(['overview','announcements','settings'].includes(location.hash.slice(1))?location.hash.slice(1):'overview')}catch(error){sessionStorage.removeItem('codeRelaxAdminToken');showLogin(error.message)}}
74
+ el('sendLoginCode').onclick=async()=>{const email=el('loginEmail').value.trim().toLowerCase();if(!email){showLogin('请输入邮箱');return}el('loginError').textContent='';try{const config=await request('/account/config');if(!window.TencentCaptcha){await new Promise((resolve,reject)=>{const script=document.createElement('script');script.src='https://turing.captcha.qcloud.com/TJCaptcha.js';script.onload=resolve;script.onerror=reject;document.head.append(script)})}new TencentCaptcha(String(config.captchaAppId),async result=>{if(result.ret!==0)return;try{await request('/account/email/request',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,ticket:result.ticket,randstr:result.randstr})});el('loginError').textContent='验证码已发送'}catch(error){el('loginError').textContent=error.message}}).show()}catch(error){el('loginError').textContent='人机验证不可用:'+error.message}};
75
+ el('verifyLogin').onclick=async()=>{const email=el('loginEmail').value.trim().toLowerCase(),code=el('loginCode').value.trim();if(!/^\\d{6}$/.test(code)){el('loginError').textContent='请输入 6 位验证码';return}try{const result=await request('/account/email/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,code})});sessionStorage.setItem('codeRelaxAdminToken',result.token);await authorize()}catch(error){el('loginError').textContent=error.message}};
76
+ el('adminLogout').onclick=()=>{sessionStorage.removeItem('codeRelaxAdminToken');showLogin()};
77
+ if(sessionStorage.getItem('codeRelaxAdminToken'))void authorize();else showLogin();setInterval(()=>{if(!document.hidden&&!el('overview').hidden&&document.querySelector('main').classList.contains('authenticated'))load()},5000);
70
78
  </script></body></html>`;
71
79
  }
@@ -1,5 +1,6 @@
1
1
  import http from 'node:http';
2
2
  import { adminPage } from './admin-page.mjs';
3
+ import { adminUsagePage } from './admin-usage-page.mjs';
3
4
  import { startAdminCloud } from './admin-cloud.mjs';
4
5
 
5
6
  function reply(response, status, body, type = 'application/json; charset=utf-8') {
@@ -21,16 +22,19 @@ export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.
21
22
  if (request.method === 'GET' && target.pathname === '/admin') {
22
23
  reply(response, 200, adminPage(), 'text/html; charset=utf-8'); return;
23
24
  }
24
- if (request.method === 'GET' && target.pathname === '/admin/api/status') {
25
+ if (request.method === 'GET' && target.pathname === '/admin/usage') {
26
+ reply(response, 200, adminUsagePage(), 'text/html; charset=utf-8'); return;
27
+ }
28
+ if (request.method === 'GET' && ['/admin/api/status', '/admin/api/session', '/admin/api/usage'].includes(target.pathname)) {
25
29
  try {
26
- const upstream = await fetch(new URL(target.pathname, relayOrigin), { signal: AbortSignal.timeout(2000) });
27
- if (!upstream.ok) throw new Error('Relay status unavailable');
30
+ const upstream = await fetch(new URL(target.pathname + target.search, relayOrigin), {
31
+ signal: AbortSignal.timeout(3000), headers: { Authorization: request.headers.authorization || '' } });
28
32
  const state = await upstream.json();
29
- reply(response, 200, { ...state, relayAvailable: true, cloud: cloud.read() });
33
+ if (!upstream.ok) { reply(response, upstream.status, state); return; }
34
+ reply(response, 200, target.pathname === '/admin/api/status'
35
+ ? { ...state, relayAvailable: true, cloud: cloud.read() } : state);
30
36
  } catch {
31
- reply(response, 200, { relayAvailable: false, cloud: cloud.read(), maxRelaySessions: null,
32
- activeRelaySessions: null, activeExemptRelaySessions: null, websocketConnections: null,
33
- onlineComputers: null, activePhones: null, announcement: null, announcements: [] });
37
+ reply(response, 503, { error: '中继服务暂不可用' });
34
38
  }
35
39
  return;
36
40
  }
@@ -48,7 +52,8 @@ export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.
48
52
  const upstream = await fetch(new URL(target.pathname, relayOrigin), {
49
53
  method: request.method, signal: AbortSignal.timeout(3000),
50
54
  headers: { 'Content-Type': request.headers['content-type'] || '',
51
- 'X-Code-Relax-Admin': request.headers['x-code-relax-admin'] || '' },
55
+ 'X-Code-Relax-Admin': request.headers['x-code-relax-admin'] || '',
56
+ Authorization: request.headers.authorization || '' },
52
57
  body: Buffer.concat(chunks),
53
58
  });
54
59
  reply(response, upstream.status, await upstream.text());
@@ -0,0 +1,9 @@
1
+ // Shared semantic resources for the standalone relay management pages.
2
+ export const adminUiTokens = `:root{
3
+ --admin-page:#f5f7fa;--admin-text:#26332b;--admin-muted:#607168;
4
+ --admin-surface:#ffffffee;--admin-row:#f8faf8;
5
+ --admin-border:#d5ddd8;--admin-row-border:#dce5df;
6
+ --admin-accent:#286e43;--admin-accent-surface:#e0f0e5;
7
+ --admin-error:#a5443b;--admin-card-radius:16px;--admin-control-radius:9px;
8
+ --admin-card-padding:22px;
9
+ }`;
@@ -0,0 +1,28 @@
1
+ import { adminUiTokens } from './admin-style.mjs';
2
+
3
+ export function adminUsagePage() {
4
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="only light"><title>使用统计 · Code Relax 管理</title><style>${adminUiTokens}</style><style>
5
+ :root{font:14px/1.5 system-ui,"Microsoft YaHei",sans-serif;color:var(--admin-text);background:var(--admin-page)}
6
+ *{box-sizing:border-box}body{margin:0}main{max-width:1080px;margin:auto;padding:28px 20px 60px}
7
+ header{display:flex;align-items:center;gap:20px;flex-wrap:wrap;margin-bottom:22px}
8
+ h1{font-size:27px;margin:0}h2{font-size:18px;margin:0 0 5px}
9
+ a,.row button{color:var(--admin-accent);text-decoration:none}a:hover{text-decoration:underline}
10
+ .card{background:var(--admin-surface);border:1px solid var(--admin-border);border-radius:var(--admin-card-radius);padding:var(--admin-card-padding);box-shadow:0 10px 24px #263a2d0a}
11
+ .subtle,.row small{color:var(--admin-muted)}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:16px}
12
+ .rows{display:grid;gap:8px}.row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,2fr) 160px;align-items:center;gap:12px;padding:12px 14px;border:1px solid var(--admin-row-border);border-radius:var(--admin-control-radius);background:var(--admin-row)}
13
+ .row button{border:0;background:none;text-align:left;padding:0;cursor:pointer;overflow-wrap:anywhere;font:inherit}.row strong{font-variant-numeric:tabular-nums}
14
+ button.pager{font:inherit;background:var(--admin-accent-surface);color:var(--admin-accent);border:1px solid var(--admin-border);border-radius:var(--admin-control-radius);padding:7px 12px;cursor:pointer}
15
+ button:disabled{opacity:.45;cursor:default}.pages{display:flex;align-items:center;gap:10px;margin-top:16px}.error{color:var(--admin-error)}.detail{margin-top:18px}.detail[hidden]{display:none}
16
+ @media(max-width:650px){main{padding:16px 12px}.card{padding:15px}.row{grid-template-columns:minmax(0,1fr)}.row small{grid-column:1/-1}}
17
+ </style></head><body><main><header><a href="/admin">← 中继管理</a><h1>使用统计</h1><span class="subtle">后台试运行统计,不限制使用</span></header><section class="card"><div class="toolbar"><div><h2>账号使用记录</h2><p id="count" class="subtle">正在读取…</p></div></div><div id="rows" class="rows"></div><div class="pages"><button id="prev" class="pager" type="button">上一页</button><span id="pageText"></span><button id="next" class="pager" type="button">下一页</button></div><p id="error" class="error" role="alert"></p></section><section id="detail" class="card detail" hidden><div class="toolbar"><div><h2 id="detailTitle"></h2><p class="subtle">按工作站查看此账号的统计</p></div><button id="closeDetail" class="pager" type="button">关闭</button></div><div id="detailRows" class="rows"></div></section></main><script>
18
+ const el=id=>document.getElementById(id);let page=1,total=0;
19
+ const duration=value=>{const seconds=Math.floor(Number(value)||0),h=Math.floor(seconds/3600),m=Math.floor(seconds%3600/60),s=seconds%60;return h?h+' 小时 '+m+' 分钟':m?m+' 分钟 '+s+' 秒':s+' 秒'};
20
+ const date=value=>value?new Date(value.replace(' ','T')+'Z').toLocaleString('zh-CN'):'—';
21
+ async function request(path){const token=sessionStorage.getItem('codeRelaxAdminToken');if(!token){location.href='/admin';return null}const response=await fetch(path,{headers:{Authorization:'Bearer '+token}});const body=await response.json();if(response.status===401||response.status===403){sessionStorage.removeItem('codeRelaxAdminToken');location.href='/admin';return null}if(!response.ok)throw new Error(body.error||'读取失败');return body}
22
+ const node=(tag,text,className='')=>{const item=document.createElement(tag);item.textContent=text;if(className)item.className=className;return item};
23
+ const metrics=row=>'消息 '+Number(row.message_count||0)+' · 轮次 '+Number(row.turn_count||0)+' · 任务 '+duration(row.task_seconds);
24
+ async function load(){el('error').textContent='';try{const result=await request('/admin/api/usage?page='+page);if(!result)return;total=result.total;el('count').textContent='共 '+total+' 个账号 · 每页 30 个';el('pageText').textContent='第 '+page+' / '+Math.max(1,Math.ceil(total/30))+' 页';el('prev').disabled=page<=1;el('next').disabled=page*30>=total;el('rows').replaceChildren();if(!result.rows.length){el('rows').append(node('p','暂无使用记录','subtle'));return}for(const row of result.rows){const item=node('div','','row'),button=node('button',row.email),time=node('strong',metrics(row)),last=node('small','最近使用 '+date(row.last_used_at));button.type='button';button.onclick=()=>detail(row.id);item.append(button,time,last);el('rows').append(item)}}catch(error){el('error').textContent=error.message}}
25
+ async function detail(id){try{const result=await request('/admin/api/usage?user='+encodeURIComponent(id));if(!result)return;el('detailTitle').textContent=result.user.email;el('detailRows').replaceChildren();if(!result.computers.length)el('detailRows').append(node('p','暂无使用记录','subtle'));for(const row of result.computers){const item=node('div','','row');item.append(node('span',row.computer_id),node('strong',metrics(row)),node('small','最近使用 '+date(row.last_used_at)));el('detailRows').append(item)}el('detail').hidden=false;el('detail').scrollIntoView({behavior:'smooth',block:'start'})}catch(error){el('error').textContent=error.message}}
26
+ el('prev').onclick=()=>{if(page>1){page--;void load()}};el('next').onclick=()=>{if(page*30<total){page++;void load()}};el('closeDetail').onclick=()=>{el('detail').hidden=true};void load();
27
+ </script></body></html>`;
28
+ }
@@ -1,15 +1,17 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { EventEmitter } from 'node:events';
3
- import WebSocket from 'ws';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { EventEmitter } from 'node:events';
3
+ import fs from 'node:fs';
4
+ import WebSocket from 'ws';
4
5
  import { decode, loopbackUrl, relayAddress, send } from './wire.mjs';
5
6
  import { createP2pProbe, validProbeSignal } from './p2p-probe.mjs';
6
7
  import { createWindowsRouteHelper } from './windows-route-helper.mjs';
7
8
 
8
9
  // Reuse the actual LAN dispatcher, including its normal/shared capability boundary.
9
10
  // The LAN token never travels to the relay. No requests are replayed after a close.
10
- export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken, identity, bridgeConnection, hostName = 'Codex-PC', lanCandidates = [], p2pProbe = false, iceServers = [], p2pWindowsRouteHelper = false, p2pWindowsRouteHelperToken = '' }) {
11
+ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken, identity, bridgeConnection, usageCacheFile,
12
+ hostName = 'Codex-PC', lanCandidates = [], p2pProbe = false, iceServers = [], p2pWindowsRouteHelper = false, p2pWindowsRouteHelperToken = '' }) {
11
13
  relayAddress(relayUrl); loopbackUrl(bridgeUrl, '/api/socket');
12
- if (!bridgeToken) throw new Error('Bridge token required');
14
+ if (!bridgeToken) throw new Error('Desktop service credential required');
13
15
  const events = new EventEmitter();
14
16
  const remote = new WebSocket(relayUrl, { maxPayload: 64 * 1024 * 1024, perMessageDeflate: {
15
17
  serverNoContextTakeover: true, clientNoContextTakeover: true,
@@ -17,13 +19,42 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
17
19
  } });
18
20
  const locals = new Map();
19
21
  const fileLocals = new Map();
20
- let fileRemote, fileRetryTimer, heartbeat, operation, lastClose, closing = false;
22
+ const activeTurns = new Map();
23
+ const completedTurns = new Map();
24
+ const trackedTurns = new Map();
25
+ if (usageCacheFile) {
26
+ try {
27
+ const saved = JSON.parse(fs.readFileSync(usageCacheFile, 'utf8'));
28
+ if (saved.version === 1 && Array.isArray(saved.turns)) {
29
+ for (const item of saved.turns.slice(0, 128)) {
30
+ if (typeof item.threadId === 'string' && item.threadId.length <= 80 &&
31
+ typeof item.turnId === 'string' && item.turnId.length <= 80) trackedTurns.set(item.threadId, item.turnId);
32
+ }
33
+ }
34
+ } catch (error) { if (error.code !== 'ENOENT') events.emit('diagnostic', `Usage task cache: ${error.message}`); }
35
+ }
36
+ function saveTrackedTurns() {
37
+ if (!usageCacheFile) return;
38
+ try {
39
+ const temp = `${usageCacheFile}.tmp`;
40
+ fs.writeFileSync(temp, JSON.stringify({ version: 1, turns: [...trackedTurns].map(([threadId, turnId]) => ({ threadId, turnId })) }), { mode: 0o600 });
41
+ fs.renameSync(temp, usageCacheFile);
42
+ } catch (error) { events.emit('diagnostic', `Usage task cache: ${error.message}`); }
43
+ }
44
+ let fileRemote, fileRetryTimer, turnLocal, turnRetryTimer, turnPulseTimer, turnObserverReady = false,
45
+ queueTurnResync = () => {}, heartbeat, operation, lastClose, closing = false;
21
46
  const alive = () => { clearTimeout(heartbeat); heartbeat = setTimeout(() => remote.terminate(), 75000); };
22
47
  remote.on('open', alive); remote.on('ping', alive); remote.on('message', alive);
23
- function dropLocal(session) {
24
- const current = locals.get(session);
25
- if (!current) return;
26
- locals.delete(session); clearTimeout(current.timer); current.socket?.terminate();
48
+ function dropLocal(session) {
49
+ const current = locals.get(session);
50
+ if (!current) return;
51
+ locals.delete(session); clearTimeout(current.timer);
52
+ if (current.ready && current.socket?.readyState === WebSocket.OPEN && current.usageRequests.size &&
53
+ remote.readyState === WebSocket.OPEN && !closing) {
54
+ // Keep only the in-flight submission receipt; no late response goes to the phone.
55
+ current.detached = true;
56
+ current.detachedTimer = setTimeout(() => current.socket?.terminate(), 35000);
57
+ } else current.socket?.terminate();
27
58
  current.probe?.then(probe => probe.close());
28
59
  current.diagnosticProbe?.then(probe => probe.close());
29
60
  }
@@ -31,6 +62,75 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
31
62
  for (const session of [...locals.keys()]) dropLocal(session);
32
63
  for (const socket of fileLocals.values()) socket.terminate();
33
64
  fileLocals.clear(); clearTimeout(fileRetryTimer); fileRemote?.terminate(); fileRemote = undefined;
65
+ clearTimeout(turnRetryTimer); clearInterval(turnPulseTimer); turnLocal?.terminate(); turnLocal = undefined;
66
+ turnObserverReady = false; activeTurns.clear(); completedTurns.clear();
67
+ }
68
+ function startTurnReceiver() {
69
+ if (closing || remote.readyState !== WebSocket.OPEN) return;
70
+ let connection;
71
+ try { connection = bridgeConnection?.() || { url: bridgeUrl, token: bridgeToken }; loopbackUrl(connection.url, '/api/socket'); }
72
+ catch { turnRetryTimer = setTimeout(startTurnReceiver, 3000); return; }
73
+ const socket = turnLocal = new WebSocket(connection.url, { maxPayload: 64 * 1024, perMessageDeflate: false });
74
+ const resyncPending = new Map();
75
+ let resyncId = 1, resyncQueue = [];
76
+ function resyncNext() {
77
+ if (!turnObserverReady || turnLocal !== socket) return;
78
+ while (resyncPending.size < 8 && resyncQueue.length) {
79
+ const [threadId, turnId] = resyncQueue.shift();
80
+ if (trackedTurns.get(threadId) !== turnId) continue;
81
+ const id = ++resyncId;
82
+ resyncPending.set(id, { threadId, turnId });
83
+ send(socket, { type: 'request', id, method: 'GET', path: `/api/threads/${encodeURIComponent(threadId)}/active-turn` });
84
+ }
85
+ }
86
+ queueTurnResync = (threadId, turnId) => { resyncQueue.push([threadId, turnId]); resyncNext(); };
87
+ socket.on('open', () => send(socket, { type: 'auth', token: connection.token }));
88
+ socket.on('error', error => events.emit('diagnostic', `Local turn observer: ${error.message}`));
89
+ socket.on('close', () => {
90
+ if (turnLocal !== socket) return;
91
+ turnLocal = undefined;
92
+ if (turnObserverReady) send(remote, { type: 'usage-observer-lost' });
93
+ turnObserverReady = false;
94
+ queueTurnResync = () => {};
95
+ activeTurns.clear(); completedTurns.clear();
96
+ if (!closing && remote.readyState === WebSocket.OPEN) turnRetryTimer = setTimeout(startTurnReceiver, 3000);
97
+ });
98
+ socket.on('message', (bytes, binary) => {
99
+ if (binary || turnLocal !== socket) return socket.terminate();
100
+ try {
101
+ const message = decode(bytes, false);
102
+ if (message.type === 'ready') { send(socket, { type: 'subscribe', id: 1, path: '/api/turn-events' }); return; }
103
+ if (message.type === 'subscribed' && message.id === 1) {
104
+ turnObserverReady = true; resyncQueue = [...trackedTurns]; resyncNext(); return;
105
+ }
106
+ if (message.type === 'response' && resyncPending.has(message.id)) {
107
+ const { threadId, turnId } = resyncPending.get(message.id);
108
+ resyncPending.delete(message.id);
109
+ if (trackedTurns.get(threadId) === turnId) {
110
+ if (message.status === 200 && message.body?.turnId === turnId) {
111
+ activeTurns.set(threadId, turnId);
112
+ send(remote, { type: 'usage-turn', method: 'resume', threadId, turnId });
113
+ } else if (message.status === 200 || message.status === 404) {
114
+ trackedTurns.delete(threadId); saveTrackedTurns();
115
+ } else { socket.close(); return; }
116
+ }
117
+ resyncNext(); return;
118
+ }
119
+ if (message.type === 'event' && message.id === 1 && message.event === 'turn') {
120
+ const { method, threadId, turnId } = message.data || {};
121
+ if (!['turn/started', 'turn/completed'].includes(method) || typeof threadId !== 'string' || typeof turnId !== 'string') return;
122
+ const key = `${threadId}:${turnId}`;
123
+ if (method === 'turn/started') activeTurns.set(threadId, turnId);
124
+ else {
125
+ if (activeTurns.get(threadId) === turnId) activeTurns.delete(threadId);
126
+ if (trackedTurns.get(threadId) === turnId) { trackedTurns.delete(threadId); saveTrackedTurns(); }
127
+ completedTurns.set(key, Date.now());
128
+ if (completedTurns.size > 100) completedTurns.delete(completedTurns.keys().next().value);
129
+ }
130
+ send(remote, { type: 'usage-turn', method, threadId, turnId });
131
+ } else if (message.type === 'end' || (message.type === 'response' && message.status >= 400)) socket.close();
132
+ } catch (error) { events.emit('diagnostic', error.message); socket.terminate(); }
133
+ });
34
134
  }
35
135
  function startFileReceiver() {
36
136
  if (!identity || closing || remote.readyState !== WebSocket.OPEN) return;
@@ -90,7 +190,13 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
90
190
  remote.on('message', (bytes, binary) => {
91
191
  try {
92
192
  const message = decode(bytes, binary);
93
- if (message.type === 'registered' && !registered) { registered = true; clearTimeout(timer); startFileReceiver(); resolve(message); return; }
193
+ if (message.type === 'registered' && !registered) {
194
+ registered = true; clearTimeout(timer); startFileReceiver(); startTurnReceiver();
195
+ turnPulseTimer = setInterval(() => {
196
+ for (const [threadId, turnId] of activeTurns) send(remote, { type: 'usage-turn', method: 'heartbeat', threadId, turnId });
197
+ }, 10000);
198
+ resolve(message); return;
199
+ }
94
200
  if (!registered) throw new Error('Relay not registered');
95
201
  if (message.type === 'pair-code' || message.type === 'revoked' || message.type === 'status') {
96
202
  if (operation && message.commandId === operation.id && message.type === operation.responseType) {
@@ -106,7 +212,7 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
106
212
  events.emit('attached', { session: message.session, clientId: message.clientId });
107
213
  dropLocal(message.session);
108
214
  const current = { socket: undefined, ready: false, timer: undefined, p2pProbe: p2pProbe && message.p2pProbe === 1,
109
- bridgeOutbound: Promise.resolve() };
215
+ bridgeOutbound: Promise.resolve(), usageRequests: new Map(), detached: false, detachedTimer: undefined };
110
216
  locals.set(message.session, current);
111
217
  let connection;
112
218
  try {
@@ -114,30 +220,62 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
114
220
  loopbackUrl(connection.url, '/api/socket');
115
221
  } catch {
116
222
  send(remote, { type: 'detach', session: message.session }); dropLocal(message.session); events.emit('detached', { session: message.session });
117
- events.emit('diagnostic', 'Local Bridge connection not ready'); return;
223
+ events.emit('diagnostic', 'Local desktop service connection not ready'); return;
118
224
  }
119
225
  const socket = current.socket = new WebSocket(connection.url, { maxPayload: 64 * 1024 * 1024, perMessageDeflate: false });
120
226
  current.forwardToBridge = payload => {
121
- if (locals.get(message.session) !== current || !current.ready) throw new Error('Local Bridge connection not ready');
227
+ if (locals.get(message.session) !== current || !current.ready) throw new Error('Local desktop service connection not ready');
122
228
  const request = decode(payload, false);
123
229
  if (!['request', 'subscribe', 'unsubscribe'].includes(request.type)) throw new Error('Unsupported LAN message');
230
+ if (request.type === 'request' && request.method === 'POST' && Number.isSafeInteger(request.id)) {
231
+ const existing = /^\/api\/threads\/([^/?]+)\/(?:messages|queue)$/.exec(request.path || '');
232
+ const created = /^\/api\/projects\/[^/?]+\/threads$/.test(request.path || '');
233
+ if (existing || created) {
234
+ const threadId = existing ? decodeURIComponent(existing[1]) : null;
235
+ const messageKey = typeof request.body?.clientUserMessageId === 'string' && request.body.clientUserMessageId.length <= 64
236
+ ? request.body.clientUserMessageId : `${message.session}:${request.id}`;
237
+ current.usageRequests.set(request.id, { threadId, messageKey, created });
238
+ send(remote, { type: 'usage-arm', session: message.session, requestId: request.id, threadId });
239
+ }
240
+ }
124
241
  send(socket, request);
125
242
  };
126
243
  current.timer = setTimeout(() => socket.terminate(), 5000);
127
244
  socket.on('open', () => send(socket, { type: 'auth', token: connection.token }));
128
- socket.on('error', error => events.emit('diagnostic', `Local Bridge: ${error.message}`));
129
- socket.on('close', () => {
130
- if (locals.get(message.session)?.socket !== socket) return;
245
+ socket.on('error', error => events.emit('diagnostic', `Local desktop service: ${error.message}`));
246
+ socket.on('close', () => {
247
+ clearTimeout(current.detachedTimer);
248
+ if (locals.get(message.session)?.socket !== socket) return;
131
249
  send(remote, { type: 'detach', session: message.session }); dropLocal(message.session); events.emit('detached', { session: message.session });
132
250
  });
133
- socket.on('message', (data, isBinary) => {
134
- if (locals.get(message.session)?.socket !== socket) return;
251
+ socket.on('message', (data, isBinary) => {
252
+ if (locals.get(message.session)?.socket !== socket && !current.detached) return;
135
253
  try {
136
254
  const response = decode(data, isBinary);
137
255
  if (!current.ready) {
138
- if (response.type !== 'ready') throw new Error('Local Bridge authentication failed');
256
+ if (response.type !== 'ready') throw new Error('Local desktop service authentication failed');
139
257
  clearTimeout(current.timer); current.ready = true; send(remote, { type: 'ready', session: message.session });
140
258
  } else {
259
+ const usage = response.type === 'response' ? current.usageRequests.get(response.id) : null;
260
+ if (usage) {
261
+ current.usageRequests.delete(response.id);
262
+ const threadId = usage.threadId || response.body?.threadId;
263
+ if (response.status >= 200 && response.status < 300 && typeof threadId === 'string') {
264
+ const turnId = usage.created ? response.body?.turnId : response.body?.usage?.turnId;
265
+ const turnKind = usage.created ? 'start' : response.body?.usage?.kind;
266
+ send(remote, { type: 'usage-message', session: message.session, requestId: response.id,
267
+ threadId, messageKey: usage.messageKey, turnId, turnKind,
268
+ completed: completedTurns.has(`${threadId}:${turnId}`), observerReady: turnObserverReady });
269
+ if (turnKind === 'start' && typeof turnId === 'string' && !completedTurns.has(`${threadId}:${turnId}`)) {
270
+ trackedTurns.set(threadId, turnId); saveTrackedTurns();
271
+ if (turnObserverReady && !activeTurns.has(threadId)) queueTurnResync(threadId, turnId);
272
+ }
273
+ } else send(remote, { type: 'usage-cancel', session: message.session, requestId: response.id });
274
+ }
275
+ if (current.detached) {
276
+ if (!current.usageRequests.size) socket.terminate();
277
+ return;
278
+ }
141
279
  const payload = data.toString();
142
280
  current.bridgeOutbound = current.bridgeOutbound.then(async () => {
143
281
  const probe = await current.probe;
@@ -18,7 +18,7 @@ export async function startRelayDemo({ mode = 'lan-shared', pairingTtlMs, dispat
18
18
  reply.startEvents(); reply.event('update', { thread: { id: 'demo' }, simulated: true }); return;
19
19
  }
20
20
  if (request.url === '/api/demo/stop') return reply.json(200, { stopped: true, simulated: true });
21
- reply.json(404, { error: '模拟 Bridge 不执行真实任务' });
21
+ reply.json(404, { error: '模拟桌面服务不执行真实任务' });
22
22
  } });
23
23
  await new Promise(resolve => bridge.listen(0, '127.0.0.1', resolve));
24
24
  const relay = remoteRelay ? { ...remoteRelay, async close() {} } : await startSelfRelay({ pairingTtlMs, ...relayOptions });
@@ -115,11 +115,27 @@ export function openEmailAccounts(pool, configFile, providerOverride) {
115
115
  return { id: rows[0].id, email: rows[0].email, expiresAt: isoTime(rows[0].expires_at) };
116
116
  }
117
117
 
118
+ async function usage(userId) {
119
+ const [settings] = await pool.query('SELECT mode FROM relay_usage_settings WHERE id=1');
120
+ if (settings.length !== 1) throw new Error('Missing relay usage settings');
121
+ const [budgets] = await pool.execute(`SELECT period_start,period_end,limit_seconds,used_seconds FROM relay_usage_budgets
122
+ WHERE user_id=? AND period_start<=UTC_TIMESTAMP(3) AND period_end>UTC_TIMESTAMP(3)
123
+ ORDER BY period_start DESC LIMIT 2`, [userId]);
124
+ if (budgets.length > 1) throw new Error('Overlapping relay usage budgets');
125
+ const budget = budgets[0];
126
+ const [totals] = await pool.execute('SELECT task_seconds FROM relay_usage_totals WHERE user_id=?', [userId]);
127
+ return { mode: settings[0].mode, periodStart: budget ? isoTime(budget.period_start) : null,
128
+ periodEnd: budget ? isoTime(budget.period_end) : null,
129
+ limitSeconds: budget?.limit_seconds == null ? null : Number(budget.limit_seconds),
130
+ usedSeconds: budget ? Number(budget.used_seconds) : 0,
131
+ taskSeconds: Number(totals[0]?.task_seconds || 0) };
132
+ }
133
+
118
134
  async function logout(token) {
119
135
  await current(token);
120
136
  await pool.execute('UPDATE user_sessions SET revoked_at=UTC_TIMESTAMP(3) WHERE token_hash=? AND revoked_at IS NULL', [tokenHash(token)]);
121
137
  return { ok: true };
122
138
  }
123
139
 
124
- return { captchaAppId: config.captchaAppId, requestCode, verifyCode, current, logout };
140
+ return { captchaAppId: config.captchaAppId, requestCode, verifyCode, current, usage, logout };
125
141
  }
@@ -56,9 +56,9 @@ async function registrationKey(config) {
56
56
  export async function selfRelay(command, stateRoot) {
57
57
  const session = readRecord(path.join(stateRoot, 'bridge-session.json'));
58
58
  if (!session) {
59
- if (command === 'relay-status') return { running: false, displayText: 'Bridge 未运行。' };
59
+ if (command === 'relay-status') return { running: false, displayText: '桌面服务未运行。' };
60
60
  if (command === 'relay-stop') return { stopped: true, displayText: '中继连接已停止。' };
61
- throw new Error('Bridge 尚未运行,请先执行 relax start。');
61
+ throw new Error('桌面服务尚未运行,请先执行 relax start。');
62
62
  }
63
63
  return controlRequest(session, 'POST', command);
64
64
  }
@@ -67,7 +67,7 @@ async function createRelayRuntime(stateRoot) {
67
67
  const config = await loadConfig(stateRoot);
68
68
  const session = () => readRecord(path.join(stateRoot, 'bridge-session.json'));
69
69
  let health = await serviceHealth(session());
70
- if (health?.status !== 'ok') throw new Error('Bridge 尚未运行,请先执行 relax start --json。');
70
+ if (health?.status !== 'ok') throw new Error('桌面服务尚未运行,请先执行 relax start --json。');
71
71
  const stored = await credentialStore('load');
72
72
  const identities = stored.record || { version: 1, servers: {} };
73
73
  const validate = value => {
@@ -142,6 +142,7 @@ async function createRelayRuntime(stateRoot) {
142
142
  const lanCandidates = await currentLanCandidates();
143
143
  connector?.events.removeAllListeners(); connector?.close();
144
144
  const current = connector = connectBridgeRelay({ relayUrl: config.url, hostKey: identity.registered ? undefined : hostKey, identity,
145
+ usageCacheFile: path.join(stateRoot, 'relay-usage-tasks.json'),
145
146
  bridgeUrl: `ws://127.0.0.1:${bridge?.port || 45831}/api/socket`, bridgeToken: bridge?.accessUrl ? new URL(bridge.accessUrl).searchParams.get('token') : 'unavailable',
146
147
  bridgeConnection: () => { const latest = session(); return { url: `ws://127.0.0.1:${latest.port}/api/socket`, token: new URL(latest.accessUrl).searchParams.get('token') }; },
147
148
  hostName: hostname(), lanCandidates, p2pProbe: config.p2pProbe === true, iceServers: config.p2pIceServers || [],
@@ -218,7 +219,7 @@ export function createRelayManager(stateRoot) {
218
219
  if (command === 'relay-status') return current ? current.inspect() : { running: false, displayText: '中继连接未运行。' };
219
220
  if (command === 'relay-stop') {
220
221
  await current?.shutdown(); current = undefined;
221
- return { stopped: true, displayText: '中继连接已停止;Bridge 和 Codex 保持运行。' };
222
+ return { stopped: true, displayText: '中继连接已停止;桌面服务和 Codex 内核保持运行。' };
222
223
  }
223
224
  if (!current) {
224
225
  if (command === 'relay-revoke') throw new Error('请先启动中继连接,再撤销移动端授权。');
@@ -17,7 +17,7 @@ export async function openRelayMysql(configFile, initialMaxRelaySessions) {
17
17
  namedPlaceholders: false, dateStrings: true, timezone: 'Z', multipleStatements: false });
18
18
  try {
19
19
  const [versions] = await pool.query('SELECT version, sha256 FROM schema_migrations');
20
- const knownVersions = ['001_relay_core', '002_email_accounts'];
20
+ const knownVersions = ['001_relay_core', '002_email_accounts', '003_relay_usage', '004_relay_usage_actions'];
21
21
  if (!versions.some(row => row.version === knownVersions[0]) || versions.length > knownVersions.length) throw new Error('Unexpected relay database schema');
22
22
  for (const row of versions) {
23
23
  if (!knownVersions.includes(row.version)) throw new Error('Unexpected relay database schema');