@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.
- package/README.md +14 -14
- package/dist/bin/codex-remote.mjs +47 -37
- package/dist/bin/manage-relay-admin.mjs +20 -0
- package/dist/bin/migrate-relay-db.mjs +3 -1
- package/dist/bin/self-relay-demo.mjs +1 -1
- package/dist/shared/app-server-events.cjs +22 -9
- package/dist/skills/code-relax/SKILL.md +6 -5
- package/dist/src/desktop-launcher.mjs +1 -1
- package/dist/src/lan-socket-server.mjs +1 -1
- package/dist/src/onboarding.mjs +6 -6
- package/dist/src/platform/windows/resident-start.js +12 -0
- package/dist/src/platform/windows/resident-startup.ps1 +21 -8
- package/dist/src/platform/windows/user-environment.mjs +1 -1
- package/dist/src/self-relay/admin-page.mjs +13 -5
- package/dist/src/self-relay/admin-server.mjs +13 -8
- package/dist/src/self-relay/admin-style.mjs +9 -0
- package/dist/src/self-relay/admin-usage-page.mjs +28 -0
- package/dist/src/self-relay/connector.mjs +158 -20
- package/dist/src/self-relay/demo.mjs +1 -1
- package/dist/src/self-relay/email-accounts.mjs +17 -1
- package/dist/src/self-relay/lifecycle.mjs +5 -4
- package/dist/src/self-relay/mysql-state.mjs +1 -1
- package/dist/src/self-relay/server.mjs +105 -4
- package/dist/src/self-relay/usage-meter.mjs +125 -0
- package/dist/src/server.mjs +47 -9
- package/dist/src/service-control.mjs +4 -4
- package/dist/src/service-doctor.mjs +4 -4
- package/dist/src/service-health.mjs +2 -2
- package/dist/src/service-lifecycle.mjs +8 -8
- package/dist/src/shared-app-server.mjs +7 -4
- package/dist/src/shared-recovery.mjs +1 -1
- package/package.json +1 -1
- 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
|
-
<
|
|
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
|
-
|
|
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/
|
|
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), {
|
|
27
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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('
|
|
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
|
-
|
|
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);
|
|
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) {
|
|
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
|
|
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
|
|
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
|
|
129
|
-
socket.on('close', () => {
|
|
130
|
-
|
|
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
|
|
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: '
|
|
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: '
|
|
59
|
+
if (command === 'relay-status') return { running: false, displayText: '桌面服务未运行。' };
|
|
60
60
|
if (command === 'relay-stop') return { stopped: true, displayText: '中继连接已停止。' };
|
|
61
|
-
throw new Error('
|
|
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('
|
|
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: '
|
|
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');
|