@walkhi/code-relax 0.1.0-beta.22 → 0.1.0-beta.23
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/dist/bin/self-relay-server.mjs +20 -19
- package/dist/shared/app-server-events.cjs +107 -100
- package/dist/shared/service-endpoints.cjs +21 -0
- package/dist/shared/timeline-sync.cjs +94 -5
- package/dist/skills/code-relax/SKILL.md +21 -18
- package/dist/src/lan-socket-server.mjs +50 -10
- package/dist/src/self-relay/admin-page.mjs +9 -9
- package/dist/src/self-relay/connector.mjs +1 -1
- package/dist/src/self-relay/lifecycle.mjs +160 -160
- package/dist/src/server.mjs +100 -203
- package/dist/src/shared-app-server.mjs +132 -116
- package/dist/src/shared-thread-stream.mjs +56 -50
- package/dist/src/turn-model-history.mjs +47 -0
- package/package.json +1 -1
|
@@ -21,8 +21,8 @@ button{background:#e0f0e5;color:#235d3a;border-color:#a9cbb4}button:hover{backgr
|
|
|
21
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}
|
|
22
22
|
@media(max-width:800px){#metrics{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
|
23
23
|
@media(max-width:600px){.resource-card .throughput .metric strong{font-size:14px}}
|
|
24
|
-
</style><style>main:not(.authenticated)>:not(#adminLogin){display:none!important}#adminLogin{max-width:430px;margin:10vh auto}#adminLogin[hidden]{display:none}.usage-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:16px}.usage-views,.usage-pages{display:flex;align-items:center;gap:8px}.usage-views button[aria-pressed=true]{background:#286e43;color:#fff}.usage-rows{display:grid;gap:8px}.usage-row{display:grid;
|
|
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>
|
|
24
|
+
</style><style>main:not(.authenticated)>:not(#adminLogin){display:none!important}#adminLogin{max-width:430px;margin:10vh auto}#adminLogin[hidden]{display:none}.usage-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:16px}.usage-views,.usage-pages{display:flex;align-items:center;gap:8px}.usage-views button[aria-pressed=true]{background:#286e43;color:#fff}.usage-rows{display:grid;gap:8px}.usage-row{display:grid;gap:8px;padding:12px 14px;border:1px solid var(--admin-row-border);border-radius:var(--admin-control-radius);background:var(--admin-row)}.usage-row-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;min-width:0}.usage-row-head span{min-width:0;overflow-wrap:anywhere}.usage-row-head small{flex-shrink:0;white-space:nowrap}.usage-row strong{font-variant-numeric:tabular-nums}.usage-row small{text-align:right}.usage-pages{margin-top:16px}</style></head><body><main>
|
|
25
|
+
<section id="adminLogin" class="card" hidden><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
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><button type="button" data-page="usage">使用统计</button><button id="adminLogout" type="button" class="secondary">退出后台</button></nav></header>
|
|
27
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>
|
|
28
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>
|
|
@@ -31,7 +31,7 @@ button{background:#e0f0e5;color:#235d3a;border-color:#a9cbb4}button:hover{backgr
|
|
|
31
31
|
<section id="usage" class="page" hidden><section class="card"><div class="usage-toolbar"><div><h2>今日使用统计</h2><p id="usageCount" class="hint">正在读取</p></div><div class="usage-views"><button id="usageUsers" type="button" class="secondary" aria-pressed="true">用户</button><button id="usageComputers" type="button" class="secondary" aria-pressed="false">工作站</button><button id="usageRefresh" type="button" class="secondary">刷新</button></div></div><div id="usageRows" class="usage-rows"></div><div class="usage-pages"><button id="usagePrev" type="button" class="secondary">上一页</button><span id="usagePageText" class="hint"></span><button id="usageNext" type="button" class="secondary">下一页</button></div><p id="usageError" class="status error" role="alert"></p></section></section><p id="result" class="status" role="status" aria-live="polite"></p>
|
|
32
32
|
</main><script>
|
|
33
33
|
const el=id=>document.getElementById(id);let snapshot=null;
|
|
34
|
-
const request=async(path,options={})=>{const token=
|
|
34
|
+
const request=async(path,options={})=>{const token=localStorage.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){const message=body.error||('HTTP '+response.status);if(path.startsWith('/admin/api/')&&(response.status===401||response.status===403)){localStorage.removeItem('codeRelaxAdminToken');showLogin(message)}throw new Error(message)}return body};
|
|
35
35
|
function setResult(message,error=false){el('result').textContent=message;el('result').classList.toggle('error',error)}
|
|
36
36
|
function node(tag,className,value){const item=document.createElement(tag);if(className)item.className=className;if(value!==undefined)item.textContent=value;return item}
|
|
37
37
|
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,8 +66,8 @@ async function load(){try{render(await request('/admin/api/status'));setResult('
|
|
|
66
66
|
async function write(method,path,body,message){if(writing)return false;writing=true;try{await request(path,{method,headers:{'Content-Type':'application/json','X-Code-Relax-Admin':'1'},body:JSON.stringify(body)});await load();setResult(message);return true}catch(error){setResult(error.message,true);if(el('announcementEditor').open){el('editorError').textContent=error.message;el('editorError').hidden=false}return false}finally{writing=false}}
|
|
67
67
|
let usageView='user',usagePage=1,usageTotal=0;
|
|
68
68
|
function usageDuration(value){const seconds=Math.floor(Number(value)||0),hours=Math.floor(seconds/3600),minutes=Math.floor(seconds%3600/60),rest=seconds%60;return hours?hours+' 小时 '+minutes+' 分钟':minutes?minutes+' 分钟 '+rest+' 秒':rest+' 秒'}
|
|
69
|
-
function usageTime(value){return value?new Date(value.replace(' ','T')+'Z').
|
|
70
|
-
async function loadUsage(){const view=usageView,page=usagePage;el('usageError').textContent='';el('usageCount').textContent='正在读取';try{const result=await request('/admin/api/usage?view='+view+'&page='+page);if(view!==usageView||page!==usagePage||el('usage').hidden)return;usageTotal=result.total;el('usageCount').textContent='今日有记录:'+usageTotal+(view==='user'?' 个用户':' 台工作站')+' · 北京时间';el('usagePageText').textContent='第 '+page+' / '+Math.max(1,Math.ceil(usageTotal/30))+' 页';el('usagePrev').disabled=page<=1;el('usageNext').disabled=page*30>=usageTotal;const rows=el('usageRows');rows.replaceChildren();if(!result.rows.length){rows.append(node('div','empty-note','今日暂无计时记录'));return}for(const item of result.rows){const row=node('div','usage-row');const counts=' · 消息 '+Number(item.message_count||0)+' · 轮次 '+Number(item.turn_count||0);row
|
|
69
|
+
function usageTime(value){return value?new Date(value.replace(' ','T')+'Z').toLocaleTimeString('zh-CN',{timeZone:'Asia/Shanghai',hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false}):'—'}
|
|
70
|
+
async function loadUsage(){const view=usageView,page=usagePage;el('usageError').textContent='';el('usageCount').textContent='正在读取';try{const result=await request('/admin/api/usage?view='+view+'&page='+page);if(view!==usageView||page!==usagePage||el('usage').hidden)return;usageTotal=result.total;el('usageCount').textContent='今日有记录:'+usageTotal+(view==='user'?' 个用户':' 台工作站')+' · 北京时间';el('usagePageText').textContent='第 '+page+' / '+Math.max(1,Math.ceil(usageTotal/30))+' 页';el('usagePrev').disabled=page<=1;el('usageNext').disabled=page*30>=usageTotal;const rows=el('usageRows');rows.replaceChildren();if(!result.rows.length){rows.append(node('div','empty-note','今日暂无计时记录'));return}for(const item of result.rows){const row=node('div','usage-row');const counts=' · 消息 '+Number(item.message_count||0)+' · 轮次 '+Number(item.turn_count||0);const heading=node('div','usage-row-head');const title=node('span','',view==='user'?item.email:'工作站 …'+item.id.slice(-8));title.title=view==='user'?item.email:item.id;heading.append(title,node('small','hint','最后使用 '+usageTime(item.last_used_at)));row.append(heading,node('strong','',view==='user'?'任务 '+usageDuration(item.today_task_seconds)+counts:'任务 '+usageDuration(item.today_task_seconds)+' · 免费账号 '+usageDuration(item.today_free_task_seconds)+counts));rows.append(row)}}catch(error){if(view===usageView&&page===usagePage){usageTotal=0;el('usageCount').textContent='统计暂不可用';el('usageRows').replaceChildren();el('usagePageText').textContent='';el('usagePrev').disabled=true;el('usageNext').disabled=true;el('usageError').textContent=error.message}}}
|
|
71
71
|
function setUsageView(view){if(usageView===view)return;usageView=view;usagePage=1;el('usageUsers').setAttribute('aria-pressed',String(view==='user'));el('usageComputers').setAttribute('aria-pressed',String(view==='computer'));void loadUsage()}
|
|
72
72
|
el('usageUsers').onclick=()=>setUsageView('user');el('usageComputers').onclick=()=>setUsageView('computer');el('usageRefresh').onclick=()=>loadUsage();el('usagePrev').onclick=()=>{if(usagePage>1){usagePage--;void loadUsage()}};el('usageNext').onclick=()=>{if(usagePage*30<usageTotal){usagePage++;void loadUsage()}};
|
|
73
73
|
function showPage(page){for(const button of document.querySelectorAll('[data-page]'))button.setAttribute('aria-current',button.dataset.page===page?'page':'false');for(const section of document.querySelectorAll('.page'))section.hidden=section.id!==page;history.replaceState(null,'','#'+page);if(page==='usage')void loadUsage();else void load()}
|
|
@@ -77,10 +77,10 @@ el('newAnnouncement').addEventListener('click',()=>openEditor());el('closeEditor
|
|
|
77
77
|
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()}
|
|
78
78
|
el('saveDraft').addEventListener('click',()=>saveAnnouncement(false));el('publish').addEventListener('click',()=>saveAnnouncement(true));
|
|
79
79
|
function showLogin(error=''){document.querySelector('main').classList.remove('authenticated');el('adminLogin').hidden=false;el('loginError').textContent=error}
|
|
80
|
-
async function authorize(){try{await request('/admin/api/session');document.querySelector('main').classList.add('authenticated');el('adminLogin').hidden=true;showPage(['overview','announcements','settings','usage'].includes(location.hash.slice(1))?location.hash.slice(1):'overview')}catch(error){
|
|
80
|
+
async function authorize(){try{await request('/admin/api/session');document.querySelector('main').classList.add('authenticated');el('adminLogin').hidden=true;showPage(['overview','announcements','settings','usage'].includes(location.hash.slice(1))?location.hash.slice(1):'overview')}catch(error){showLogin(localStorage.getItem('codeRelaxAdminToken')?'登录验证暂时失败,请刷新页面重试:'+error.message:error.message)}}
|
|
81
81
|
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}};
|
|
82
|
-
el('verifyLogin').onclick=async()=>{const email=el('loginEmail').value.trim().toLowerCase(),code=el('loginCode').value.trim();if(
|
|
83
|
-
el('adminLogout').onclick=()=>{
|
|
84
|
-
if(
|
|
82
|
+
el('verifyLogin').onclick=async()=>{const email=el('loginEmail').value.trim().toLowerCase(),code=el('loginCode').value.trim();if(!/^[0-9]{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})});localStorage.setItem('codeRelaxAdminToken',result.token);await authorize()}catch(error){el('loginError').textContent=error.message}};
|
|
83
|
+
el('adminLogout').onclick=()=>{localStorage.removeItem('codeRelaxAdminToken');showLogin()};
|
|
84
|
+
if(localStorage.getItem('codeRelaxAdminToken'))void authorize();else showLogin();setInterval(()=>{if(!document.hidden&&!el('overview').hidden&&document.querySelector('main').classList.contains('authenticated'))load()},5000);
|
|
85
85
|
</script></body></html>`;
|
|
86
86
|
}
|
|
@@ -251,7 +251,7 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
251
251
|
current.forwardToBridge = payload => {
|
|
252
252
|
if (locals.get(message.session) !== current || !current.ready) throw new Error('Local desktop service connection not ready');
|
|
253
253
|
const request = decode(payload, false);
|
|
254
|
-
if (!['request', 'subscribe', 'unsubscribe'].includes(request.type)) throw new Error('Unsupported LAN message');
|
|
254
|
+
if (!['request', 'subscribe', 'unsubscribe', 'response-ack', 'cancel'].includes(request.type)) throw new Error('Unsupported LAN message');
|
|
255
255
|
if (request.type === 'request' && request.method === 'POST' && Number.isSafeInteger(request.id)) {
|
|
256
256
|
const path = (request.path || '').split('?')[0];
|
|
257
257
|
const existing = /^\/api\/threads\/([^/?]+)\/(?:messages|queue)$/.exec(path);
|
|
@@ -1,22 +1,19 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
1
|
import path from 'node:path';
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { serviceHealth } from '../service-health.mjs';
|
|
2
|
+
import serviceEndpoints from '../../shared/service-endpoints.cjs';
|
|
3
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
4
|
+
import { hostname } from 'node:os';
|
|
5
|
+
import QRCode from 'qrcode';
|
|
6
|
+
import { credentialStore } from '../platform/windows/credential-store.mjs';
|
|
7
|
+
import { readRecord, writeRecord } from '../service-lifecycle.mjs';
|
|
8
|
+
import { serviceHealth } from '../service-health.mjs';
|
|
11
9
|
import { controlRequest } from '../service-control.mjs';
|
|
12
|
-
import { connectBridgeRelay } from './connector.mjs';
|
|
13
|
-
import { validProbeIceServers } from './p2p-probe.mjs';
|
|
14
|
-
import { relayAddress, validSecret } from './wire.mjs';
|
|
15
|
-
import { saveLanAuthorizations } from './lan-authorizations.mjs';
|
|
10
|
+
import { connectBridgeRelay } from './connector.mjs';
|
|
11
|
+
import { validProbeIceServers } from './p2p-probe.mjs';
|
|
12
|
+
import { relayAddress, validSecret } from './wire.mjs';
|
|
13
|
+
import { saveLanAuthorizations } from './lan-authorizations.mjs';
|
|
16
14
|
import { lanNetworks } from '../platform/windows/network-info.mjs';
|
|
17
15
|
|
|
18
|
-
const
|
|
19
|
-
const officialConfig = { url: 'wss://api.uulife.site/relay' };
|
|
16
|
+
const officialConfig = { url: serviceEndpoints.relayUrl };
|
|
20
17
|
|
|
21
18
|
async function loadConfig(stateRoot) {
|
|
22
19
|
const configPath = path.join(stateRoot, 'self-relay-config.json');
|
|
@@ -26,33 +23,16 @@ async function loadConfig(stateRoot) {
|
|
|
26
23
|
if (config.p2pIceServers !== undefined && !validProbeIceServers(config.p2pIceServers)) throw new Error('p2pIceServers 只接受最多 4 个 stun:主机:端口地址');
|
|
27
24
|
if (config.p2pWindowsRouteHelper !== undefined && typeof config.p2pWindowsRouteHelper !== 'boolean') throw new Error('p2pWindowsRouteHelper 必须是布尔值');
|
|
28
25
|
if (config.p2pWindowsRouteHelper && !/^[A-Za-z0-9_-]{32,128}$/.test(config.p2pWindowsRouteHelperToken || '')) throw new Error('p2pWindowsRouteHelperToken 无效');
|
|
29
|
-
const url = process.env.RELAY_URL || config.url;
|
|
30
|
-
if (!url) throw new Error('请先配置 self-relay-config.json 的 url
|
|
31
|
-
relayAddress(url);
|
|
32
|
-
const resolved = relayAddress(url);
|
|
33
|
-
const pairingUrl = config.pairingUrl || `${resolved.startsWith('wss:') ? 'https:' : 'http:'}//${new URL(resolved).host}/pair
|
|
34
|
-
const pairing = new URL(pairingUrl);
|
|
35
|
-
if (!['https:', 'http:'].includes(pairing.protocol) || pairing.pathname !== '/pair' || pairing.search || pairing.hash || pairing.username || pairing.password ||
|
|
36
|
-
(pairing.protocol === 'http:' && pairing.hostname !== '127.0.0.1')) throw new Error('self-relay-config.json 的 pairingUrl 必须是 HTTPS /pair 地址。');
|
|
26
|
+
const url = process.env.RELAY_URL || serviceEndpoints.resolveRelayUrl(config.url);
|
|
27
|
+
if (!url) throw new Error('请先配置 self-relay-config.json 的 url,详见自建中继文档。');
|
|
28
|
+
relayAddress(url);
|
|
29
|
+
const resolved = relayAddress(url);
|
|
30
|
+
const pairingUrl = config.pairingUrl || (resolved === serviceEndpoints.relayUrl ? serviceEndpoints.pairingUrl : `${resolved.startsWith('wss:') ? 'https:' : 'http:'}//${new URL(resolved).host}/pair`);
|
|
31
|
+
const pairing = new URL(pairingUrl);
|
|
32
|
+
if (!['https:', 'http:'].includes(pairing.protocol) || pairing.pathname !== '/pair' || pairing.search || pairing.hash || pairing.username || pairing.password ||
|
|
33
|
+
(pairing.protocol === 'http:' && pairing.hostname !== '127.0.0.1')) throw new Error('self-relay-config.json 的 pairingUrl 必须是 HTTPS /pair 地址。');
|
|
37
34
|
return { ...config, url: resolved, pairingUrl: pairing.href };
|
|
38
35
|
}
|
|
39
|
-
async function registrationKey(config) {
|
|
40
|
-
if (config.url === officialConfig.url) return undefined;
|
|
41
|
-
const hostKeyFile = process.env.RELAY_HOST_KEY_FILE || config.hostKeyFile;
|
|
42
|
-
let hostKey;
|
|
43
|
-
if (hostKeyFile) hostKey = fs.readFileSync(hostKeyFile, 'utf8').trim();
|
|
44
|
-
else {
|
|
45
|
-
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(config.sshHost || '') ||
|
|
46
|
-
!/^\/[a-zA-Z0-9/_.-]+$/.test(config.sshKeyPath || '')) throw new Error('中继注册凭据配置不完整或无效。');
|
|
47
|
-
try {
|
|
48
|
-
const result = await exec('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', config.sshHost,
|
|
49
|
-
`sudo -n cat ${config.sshKeyPath}`], { encoding: 'utf8', timeout: 15000, windowsHide: true });
|
|
50
|
-
hostKey = result.stdout.trim();
|
|
51
|
-
} catch { throw new Error('无法通过已配置 SSH 读取中继注册凭据。'); }
|
|
52
|
-
}
|
|
53
|
-
if (!validSecret(hostKey)) throw new Error('中继注册凭据格式无效。');
|
|
54
|
-
return hostKey;
|
|
55
|
-
}
|
|
56
36
|
export async function selfRelay(command, stateRoot) {
|
|
57
37
|
const session = readRecord(path.join(stateRoot, 'bridge-session.json'));
|
|
58
38
|
if (!session) {
|
|
@@ -64,142 +44,162 @@ export async function selfRelay(command, stateRoot) {
|
|
|
64
44
|
}
|
|
65
45
|
|
|
66
46
|
async function createRelayRuntime(stateRoot) {
|
|
67
|
-
const config = await loadConfig(stateRoot);
|
|
68
|
-
const
|
|
69
|
-
|
|
47
|
+
const config = await loadConfig(stateRoot);
|
|
48
|
+
const configPath = path.join(stateRoot, 'self-relay-config.json');
|
|
49
|
+
const session = () => readRecord(path.join(stateRoot, 'bridge-session.json'));
|
|
50
|
+
let health = await serviceHealth(session());
|
|
70
51
|
if (health?.status !== 'ok') throw new Error('桌面服务尚未运行,请先执行 relax start --json。');
|
|
71
52
|
const stored = await credentialStore('load');
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
53
|
+
let identities = stored.record || { version: 2, devices: {} };
|
|
54
|
+
const validIdentity = (id, item) => /^[\w-]{16,128}$/.test(id) && item?.id === id &&
|
|
55
|
+
validSecret(item.secret) && typeof item.registered === 'boolean';
|
|
56
|
+
if (identities.version === 1 && identities.servers && typeof identities.servers === 'object' && !Array.isArray(identities.servers)) {
|
|
57
|
+
const devices = {};
|
|
58
|
+
for (const [url, item] of Object.entries(identities.servers)) {
|
|
59
|
+
relayAddress(url);
|
|
60
|
+
if (!validIdentity(item?.id, item) || devices[item.id]) throw new Error('本机设备身份损坏,未自动覆盖');
|
|
61
|
+
devices[item.id] = item;
|
|
62
|
+
}
|
|
63
|
+
const previous = identities.servers[config.url];
|
|
64
|
+
identities = { version: 2, devices };
|
|
65
|
+
await credentialStore('save', identities);
|
|
66
|
+
if (!config.deviceId && previous) config.deviceId = previous.id;
|
|
67
|
+
}
|
|
68
|
+
if (identities.version !== 2 || !identities.devices || typeof identities.devices !== 'object' || Array.isArray(identities.devices) ||
|
|
69
|
+
Object.entries(identities.devices).some(([id, item]) => !validIdentity(id, item))) throw new Error('本机设备身份损坏,未自动覆盖');
|
|
70
|
+
if (!config.deviceId) {
|
|
71
|
+
const known = Object.values(identities.devices);
|
|
72
|
+
if (known.length > 1) throw new Error('存在多个工作站身份,请在 self-relay-config.json 中指定 deviceId。');
|
|
73
|
+
config.deviceId = known[0]?.id || randomUUID();
|
|
74
|
+
}
|
|
75
|
+
const createIdentity = config.deviceId === 'new';
|
|
76
|
+
if (createIdentity) config.deviceId = randomUUID();
|
|
77
|
+
if (!/^[\w-]{16,128}$/.test(config.deviceId)) throw new Error('self-relay-config.json 的 deviceId 无效。');
|
|
78
|
+
let identity = identities.devices[config.deviceId];
|
|
82
79
|
if (!identity) {
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
if (!createIdentity && Object.keys(identities.devices).length) throw new Error('找不到配置指定的工作站身份,未自动创建新身份。');
|
|
81
|
+
identity = { id: config.deviceId, secret: randomBytes(32).toString('base64url'), registered: false };
|
|
82
|
+
identities.devices[identity.id] = identity;
|
|
83
|
+
await credentialStore('save', identities);
|
|
85
84
|
}
|
|
86
|
-
const
|
|
85
|
+
const persistedConfig = readRecord(configPath) || {};
|
|
86
|
+
if (persistedConfig.deviceId !== identity.id) writeRecord(configPath, { ...persistedConfig, deviceId: identity.id });
|
|
87
87
|
let bridgeError = '';
|
|
88
|
-
async function refreshHealth() {
|
|
88
|
+
async function refreshHealth() {
|
|
89
89
|
try { health = await serviceHealth(session()) || { transportMode: health.transportMode, status: 'offline' }; bridgeError = ''; }
|
|
90
90
|
catch (error) { bridgeError = error.message; health = { transportMode: health.transportMode, status: 'offline' }; }
|
|
91
|
-
}
|
|
91
|
+
}
|
|
92
92
|
let connector, qrPath, expiresAt = 0, paired = false, clientCount = 0, attachedCount = 0;
|
|
93
|
-
let closing = false, online = false, connecting = false, retryTimer, attempts = 0, lastError = '';
|
|
94
|
-
const audit = (event, details = {}) => console.log(JSON.stringify({ time: new Date().toISOString(), event, ...details }));
|
|
95
|
-
async function currentLanCandidates() {
|
|
96
|
-
try {
|
|
97
|
-
const bridge = session(), port = bridge?.port || 45831;
|
|
98
|
-
return (await lanNetworks()).map(network => ({ ...network, address: `http://${network.address}:${port}` }));
|
|
99
|
-
} catch (error) {
|
|
100
|
-
audit('lan-candidates-unavailable', { message: error.message }); return [];
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
const status = () => {
|
|
104
|
-
const available = online && !paired && qrPath && Date.now() < expiresAt;
|
|
105
|
-
const next = !online ? (lastError || '电脑连接器正在恢复中继连接,已保存授权。')
|
|
106
|
-
: paired ? '设备已授权,移动端可直接重连;网络断开不需要重新扫码。'
|
|
107
|
-
: available ? '用要添加的移动设备在有效期内扫描统一配对二维码。连接器在后台运行。'
|
|
93
|
+
let closing = false, online = false, connecting = false, retryTimer, attempts = 0, lastError = '';
|
|
94
|
+
const audit = (event, details = {}) => console.log(JSON.stringify({ time: new Date().toISOString(), event, ...details }));
|
|
95
|
+
async function currentLanCandidates() {
|
|
96
|
+
try {
|
|
97
|
+
const bridge = session(), port = bridge?.port || 45831;
|
|
98
|
+
return (await lanNetworks()).map(network => ({ ...network, address: `http://${network.address}:${port}` }));
|
|
99
|
+
} catch (error) {
|
|
100
|
+
audit('lan-candidates-unavailable', { message: error.message }); return [];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const status = () => {
|
|
104
|
+
const available = online && !paired && qrPath && Date.now() < expiresAt;
|
|
105
|
+
const next = !online ? (lastError || '电脑连接器正在恢复中继连接,已保存授权。')
|
|
106
|
+
: paired ? '设备已授权,移动端可直接重连;网络断开不需要重新扫码。'
|
|
107
|
+
: available ? '用要添加的移动设备在有效期内扫描统一配对二维码。连接器在后台运行。'
|
|
108
108
|
: '尚未完成设备授权;调用 relax pair --json 生成配对二维码。';
|
|
109
|
-
return { running: !closing, relayConnected: online, mode: 'self-relay', transportMode: health.transportMode,
|
|
110
|
-
relayUrl: config.url, deviceId: identity.id, paired, clientCount, authorization: online ? (paired ? 'authorized' : 'unpaired') : 'unverified', phoneConnected: attachedCount > 0,
|
|
109
|
+
return { running: !closing, relayConnected: online, mode: 'self-relay', transportMode: health.transportMode,
|
|
110
|
+
relayUrl: config.url, deviceId: identity.id, paired, clientCount, authorization: online ? (paired ? 'authorized' : 'unpaired') : 'unverified', phoneConnected: attachedCount > 0,
|
|
111
111
|
bridgeReady: health.status === 'ok' && health.execution?.ready === true && health.sharedAppServerConnected === true, bridgeError,
|
|
112
|
-
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
|
|
113
|
-
...(available ? { qrPath } : {}), mobileAccess: 'unverified', lastError,
|
|
112
|
+
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
|
|
113
|
+
...(available ? { qrPath } : {}), mobileAccess: 'unverified', lastError,
|
|
114
114
|
displayText: `Code Relax 中继 · ${health.transportMode === 'lan-shared' ? 'Codex 内核已就绪' : 'Codex 内核未就绪'}\n${next}` };
|
|
115
|
-
};
|
|
116
|
-
async function shutdown() {
|
|
117
|
-
if (closing) return;
|
|
118
|
-
closing = true; clearTimeout(retryTimer);
|
|
119
|
-
connector?.events.removeAllListeners(); connector?.close();
|
|
120
|
-
}
|
|
121
|
-
async function saveInvitation(message) {
|
|
122
|
-
if (!message.code) return;
|
|
123
|
-
expiresAt = message.expires;
|
|
124
|
-
const link = new URL(config.pairingUrl);
|
|
125
|
-
link.searchParams.set('invite', message.code);
|
|
126
|
-
// Keep previously displayed files intact even when their invitation expires.
|
|
127
|
-
qrPath = path.join(stateRoot, `self-relay-qr-${randomUUID()}.png`);
|
|
128
|
-
await QRCode.toFile(qrPath, link.href, { width: 640, margin: 3 });
|
|
129
|
-
}
|
|
130
|
-
function scheduleRetry() {
|
|
131
|
-
if (closing || retryTimer) return;
|
|
132
|
-
const delay = Math.min(1000 * 2 ** Math.min(attempts++, 5), 30000) * (0.8 + Math.random() * 0.2);
|
|
133
|
-
retryTimer = setTimeout(() => { retryTimer = undefined; void connect(); }, delay);
|
|
134
|
-
}
|
|
135
|
-
async function connect() {
|
|
136
|
-
if (closing || connecting) return;
|
|
137
|
-
connecting = true;
|
|
138
|
-
let retryAfterAttempt = false;
|
|
139
|
-
try {
|
|
140
|
-
const bridge = session();
|
|
141
|
-
await refreshHealth();
|
|
142
|
-
const lanCandidates = await currentLanCandidates();
|
|
143
|
-
connector?.events.removeAllListeners(); connector?.close();
|
|
144
|
-
const current = connector = connectBridgeRelay({ relayUrl: config.url,
|
|
115
|
+
};
|
|
116
|
+
async function shutdown() {
|
|
117
|
+
if (closing) return;
|
|
118
|
+
closing = true; clearTimeout(retryTimer);
|
|
119
|
+
connector?.events.removeAllListeners(); connector?.close();
|
|
120
|
+
}
|
|
121
|
+
async function saveInvitation(message) {
|
|
122
|
+
if (!message.code) return;
|
|
123
|
+
expiresAt = message.expires;
|
|
124
|
+
const link = new URL(config.pairingUrl);
|
|
125
|
+
link.searchParams.set('invite', message.code);
|
|
126
|
+
// Keep previously displayed files intact even when their invitation expires.
|
|
127
|
+
qrPath = path.join(stateRoot, `self-relay-qr-${randomUUID()}.png`);
|
|
128
|
+
await QRCode.toFile(qrPath, link.href, { width: 640, margin: 3 });
|
|
129
|
+
}
|
|
130
|
+
function scheduleRetry() {
|
|
131
|
+
if (closing || retryTimer) return;
|
|
132
|
+
const delay = Math.min(1000 * 2 ** Math.min(attempts++, 5), 30000) * (0.8 + Math.random() * 0.2);
|
|
133
|
+
retryTimer = setTimeout(() => { retryTimer = undefined; void connect(); }, delay);
|
|
134
|
+
}
|
|
135
|
+
async function connect() {
|
|
136
|
+
if (closing || connecting) return;
|
|
137
|
+
connecting = true;
|
|
138
|
+
let retryAfterAttempt = false;
|
|
139
|
+
try {
|
|
140
|
+
const bridge = session();
|
|
141
|
+
await refreshHealth();
|
|
142
|
+
const lanCandidates = await currentLanCandidates();
|
|
143
|
+
connector?.events.removeAllListeners(); connector?.close();
|
|
144
|
+
const current = connector = connectBridgeRelay({ relayUrl: config.url, identity,
|
|
145
145
|
usageCacheFile: path.join(stateRoot, 'relay-usage-tasks.json'),
|
|
146
|
-
bridgeUrl: `ws://127.0.0.1:${bridge?.port || 45831}/api/socket`, bridgeToken: bridge?.accessUrl ? new URL(bridge.accessUrl).searchParams.get('token') : 'unavailable',
|
|
147
|
-
bridgeConnection: () => { const latest = session(); return { url: `ws://127.0.0.1:${latest.port}/api/socket`, token: new URL(latest.accessUrl).searchParams.get('token') }; },
|
|
146
|
+
bridgeUrl: `ws://127.0.0.1:${bridge?.port || 45831}/api/socket`, bridgeToken: bridge?.accessUrl ? new URL(bridge.accessUrl).searchParams.get('token') : 'unavailable',
|
|
147
|
+
bridgeConnection: () => { const latest = session(); return { url: `ws://127.0.0.1:${latest.port}/api/socket`, token: new URL(latest.accessUrl).searchParams.get('token') }; },
|
|
148
148
|
hostName: hostname(), lanCandidates, p2pProbe: config.p2pProbe === true, iceServers: config.p2pIceServers || [],
|
|
149
149
|
p2pWindowsRouteHelper: config.p2pWindowsRouteHelper === true, p2pWindowsRouteHelperToken: config.p2pWindowsRouteHelperToken || '' });
|
|
150
|
-
current.events.on('p2p-probe', result => audit('p2p-probe', result));
|
|
151
|
-
current.events.on('diagnostic', message => audit('transport-error', { message }));
|
|
152
|
-
current.events.on('attached', () => { paired = true; attachedCount++; audit('phone-attached', { attachedCount }); });
|
|
153
|
-
current.events.on('detached', () => { attachedCount = Math.max(0, attachedCount - 1); audit('phone-detached', { attachedCount }); });
|
|
154
|
-
current.events.on('authorization-changed', update => {
|
|
155
|
-
saveLanAuthorizations(stateRoot, identity.id, update.clients, update.revision);
|
|
156
|
-
paired = update.clients.length > 0; clientCount = update.clients.length;
|
|
157
|
-
audit('mobile-authorizations-updated', { clientCount, revision: update.revision });
|
|
158
|
-
});
|
|
159
|
-
current.events.once('disconnected', event => {
|
|
160
|
-
if (connector !== current || closing) return;
|
|
161
|
-
online = false; attachedCount = 0; lastError = `电脑与中继连接断开(${event.code}):${event.reason}`; audit('relay-disconnected', event);
|
|
162
|
-
if (![1008, 4401, 4406, 4409, 4429].includes(event.code)) scheduleRetry();
|
|
163
|
-
});
|
|
164
|
-
const registration = await current.ready;
|
|
150
|
+
current.events.on('p2p-probe', result => audit('p2p-probe', result));
|
|
151
|
+
current.events.on('diagnostic', message => audit('transport-error', { message }));
|
|
152
|
+
current.events.on('attached', () => { paired = true; attachedCount++; audit('phone-attached', { attachedCount }); });
|
|
153
|
+
current.events.on('detached', () => { attachedCount = Math.max(0, attachedCount - 1); audit('phone-detached', { attachedCount }); });
|
|
154
|
+
current.events.on('authorization-changed', update => {
|
|
155
|
+
saveLanAuthorizations(stateRoot, identity.id, update.clients, update.revision);
|
|
156
|
+
paired = update.clients.length > 0; clientCount = update.clients.length;
|
|
157
|
+
audit('mobile-authorizations-updated', { clientCount, revision: update.revision });
|
|
158
|
+
});
|
|
159
|
+
current.events.once('disconnected', event => {
|
|
160
|
+
if (connector !== current || closing) return;
|
|
161
|
+
online = false; attachedCount = 0; lastError = `电脑与中继连接断开(${event.code}):${event.reason}`; audit('relay-disconnected', event);
|
|
162
|
+
if (![1008, 4401, 4406, 4409, 4429].includes(event.code)) scheduleRetry();
|
|
163
|
+
});
|
|
164
|
+
const registration = await current.ready;
|
|
165
165
|
if (registration.protocolVersion !== 7 || registration.room !== identity.id) {
|
|
166
|
-
current.events.removeAllListeners(); current.close(); throw Object.assign(new Error('中继服务器尚未升级设备授权协议,未使用临时配对降级。'), { code: 4401 });
|
|
167
|
-
}
|
|
168
|
-
if (!identity.registered) {
|
|
169
|
-
const registeredIdentity = { ...identity, registered: true };
|
|
170
|
-
identities.
|
|
171
|
-
await credentialStore('save', identities); identity = registeredIdentity;
|
|
172
|
-
}
|
|
173
|
-
current.assertConnected();
|
|
174
|
-
online = true; attempts = 0; paired = registration.paired; clientCount = registration.clientCount || 0; lastError = '';
|
|
175
|
-
saveLanAuthorizations(stateRoot, identity.id, registration.clients || [], registration.revision);
|
|
176
|
-
await saveInvitation(registration); audit('relay-connected', { paired });
|
|
177
|
-
} catch (error) {
|
|
178
|
-
online = false; lastError = error.message; audit('connect-failed', { message: error.message });
|
|
179
|
-
if (![1008, 4401, 4406, 4409, 4429].includes(error.code)) retryAfterAttempt = true;
|
|
180
|
-
else { clearTimeout(retryTimer); retryTimer = undefined; }
|
|
181
|
-
} finally { connecting = false; if (retryAfterAttempt) scheduleRetry(); }
|
|
182
|
-
}
|
|
183
|
-
async function pair() {
|
|
184
|
-
if (!online || closing) throw new Error('中继尚未连接,设备授权保持不变。');
|
|
185
|
-
await saveInvitation(await connector.command('pair-code'));
|
|
186
|
-
// Each invitation adds or refreshes one mobile identity without revoking other devices.
|
|
187
|
-
return { ...status(), qrPath, expiresAt: new Date(expiresAt).toISOString(),
|
|
188
|
-
displayText: '用要添加的移动设备扫描此码。每台设备只需配对一次;已有设备授权保持不变。' };
|
|
189
|
-
}
|
|
190
|
-
async function revoke() {
|
|
191
|
-
if (!online || closing) throw new Error('中继尚未连接,未撤销授权。');
|
|
192
|
-
await connector.command('revoke'); paired = false; clientCount = 0; attachedCount = 0; qrPath = undefined; expiresAt = 0;
|
|
166
|
+
current.events.removeAllListeners(); current.close(); throw Object.assign(new Error('中继服务器尚未升级设备授权协议,未使用临时配对降级。'), { code: 4401 });
|
|
167
|
+
}
|
|
168
|
+
if (!identity.registered) {
|
|
169
|
+
const registeredIdentity = { ...identity, registered: true };
|
|
170
|
+
identities.devices[identity.id] = registeredIdentity;
|
|
171
|
+
await credentialStore('save', identities); identity = registeredIdentity;
|
|
172
|
+
}
|
|
173
|
+
current.assertConnected();
|
|
174
|
+
online = true; attempts = 0; paired = registration.paired; clientCount = registration.clientCount || 0; lastError = '';
|
|
175
|
+
saveLanAuthorizations(stateRoot, identity.id, registration.clients || [], registration.revision);
|
|
176
|
+
await saveInvitation(registration); audit('relay-connected', { paired });
|
|
177
|
+
} catch (error) {
|
|
178
|
+
online = false; lastError = error.message; audit('connect-failed', { message: error.message });
|
|
179
|
+
if (![1008, 4401, 4406, 4409, 4429].includes(error.code)) retryAfterAttempt = true;
|
|
180
|
+
else { clearTimeout(retryTimer); retryTimer = undefined; }
|
|
181
|
+
} finally { connecting = false; if (retryAfterAttempt) scheduleRetry(); }
|
|
182
|
+
}
|
|
183
|
+
async function pair() {
|
|
184
|
+
if (!online || closing) throw new Error('中继尚未连接,设备授权保持不变。');
|
|
185
|
+
await saveInvitation(await connector.command('pair-code'));
|
|
186
|
+
// Each invitation adds or refreshes one mobile identity without revoking other devices.
|
|
187
|
+
return { ...status(), qrPath, expiresAt: new Date(expiresAt).toISOString(),
|
|
188
|
+
displayText: '用要添加的移动设备扫描此码。每台设备只需配对一次;已有设备授权保持不变。' };
|
|
189
|
+
}
|
|
190
|
+
async function revoke() {
|
|
191
|
+
if (!online || closing) throw new Error('中继尚未连接,未撤销授权。');
|
|
192
|
+
await connector.command('revoke'); paired = false; clientCount = 0; attachedCount = 0; qrPath = undefined; expiresAt = 0;
|
|
193
193
|
return { ...status(), displayText: '全部移动设备授权已撤销;重新添加请调用 relax pair --json。' };
|
|
194
|
-
}
|
|
195
|
-
async function refreshLan() {
|
|
196
|
-
if (!online || closing) throw new Error('中继尚未连接,未更新局域网候选。');
|
|
197
|
-
await refreshHealth();
|
|
198
|
-
const candidates = await currentLanCandidates();
|
|
199
|
-
connector.updateLanCandidates(candidates);
|
|
200
|
-
audit('lan-candidates-updated', { count: candidates.length });
|
|
201
|
-
return { ...status(), lanCandidateCount: candidates.length };
|
|
202
|
-
}
|
|
194
|
+
}
|
|
195
|
+
async function refreshLan() {
|
|
196
|
+
if (!online || closing) throw new Error('中继尚未连接,未更新局域网候选。');
|
|
197
|
+
await refreshHealth();
|
|
198
|
+
const candidates = await currentLanCandidates();
|
|
199
|
+
connector.updateLanCandidates(candidates);
|
|
200
|
+
audit('lan-candidates-updated', { count: candidates.length });
|
|
201
|
+
return { ...status(), lanCandidateCount: candidates.length };
|
|
202
|
+
}
|
|
203
203
|
const inspect = async () => {
|
|
204
204
|
if (online) {
|
|
205
205
|
const remote = await connector.command('status'); paired = remote.paired;
|