@walkhi/code-relax 0.1.0-beta.51 → 0.1.0-beta.59

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.
@@ -6,9 +6,53 @@ export const adminUiTokens = `:root{
6
6
  --admin-accent:#286e43;--admin-accent-surface:#e0f0e5;
7
7
  --admin-error:#a5443b;--admin-card-radius:16px;--admin-control-radius:9px;
8
8
  --admin-card-padding:22px;
9
+ --admin-font:14px;--admin-heading:24px;--admin-title:18px;--admin-value:16px;--admin-label:12px;
10
+ --admin-space-sm:6px;--admin-space:12px;--admin-space-lg:18px;--admin-control-height:44px;
11
+ --admin-login-width:400px;--admin-status-height:42px;
9
12
  --admin-footer-caption:12px;--admin-footer-opacity:0.75;--admin-footer-space:24px;
10
13
  }`;
11
14
 
12
15
  export const adminFooterStyles = `body{display:flex;flex-direction:column}
13
16
  body>main{width:100%;flex:1;margin:0 auto}
14
17
  .admin-footer{flex-shrink:0;padding:0 var(--admin-footer-space) var(--admin-footer-space);text-align:center;color:var(--admin-muted);font-size:var(--admin-footer-caption);opacity:var(--admin-footer-opacity)}`;
18
+
19
+ export const adminAuthStyles = `
20
+ *{box-sizing:border-box}.admin-login{margin:0;min-height:100vh;background:var(--admin-page);color:var(--admin-text);font:var(--admin-font)/1.5 system-ui,"Microsoft YaHei",sans-serif}
21
+ .admin-login main{display:grid;place-items:center;padding:var(--admin-card-padding)}
22
+ .login-card{width:100%;max-width:var(--admin-login-width);padding:var(--admin-card-padding);background:var(--admin-surface);border:1px solid var(--admin-border);border-radius:var(--admin-card-radius);display:grid;gap:var(--admin-space)}
23
+ .login-card h1{font-size:var(--admin-heading);margin:0}.login-card p{margin:0;color:var(--admin-muted)}
24
+ .login-card input,.login-card button{font:inherit;min-height:var(--admin-control-height);padding:var(--admin-space);border:1px solid var(--admin-border);border-radius:var(--admin-control-radius)}
25
+ .login-card input{width:100%;background:var(--admin-surface);color:var(--admin-text)}
26
+ .login-card button{background:var(--admin-accent-surface);color:var(--admin-accent);cursor:pointer;font-weight:600}
27
+ .login-card button:disabled{opacity:.6;cursor:wait}.login-card :focus-visible{outline:2px solid var(--admin-accent);outline-offset:2px}
28
+ .login-card #result{min-height:var(--admin-status-height);color:var(--admin-error)}
29
+ .page-head .admin-logout{margin-left:auto;flex:none;white-space:nowrap}
30
+ @media(max-width:600px){.page-head{flex-wrap:wrap}.page-head .nav{order:3;flex-basis:100%}}`;
31
+
32
+ export const adminWorkstationStyles = `
33
+ .workstation-content{display:grid;gap:var(--admin-space-lg)}
34
+ .workstation-row{display:grid;gap:var(--admin-space-lg);width:100%;text-align:left;text-decoration:none;min-height:var(--admin-control-height);padding:var(--admin-card-padding);border:1px solid var(--admin-row-border);border-radius:var(--admin-card-radius);background:var(--admin-row);color:var(--admin-text);font-weight:400}
35
+ .workstation-row:hover{background:var(--admin-accent-surface);color:var(--admin-text)}
36
+ .workstation-row:focus-visible{outline:2px solid var(--admin-accent);outline-offset:2px}
37
+ .workstation-row-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space)}
38
+ .workstation-row-heading strong{min-width:0;overflow-wrap:anywhere;font-size:var(--admin-title)}
39
+ .workstation-state{display:inline-flex;flex:none;align-items:center;padding:var(--admin-space-sm) var(--admin-space);border-radius:var(--admin-card-radius);background:var(--admin-accent-surface);color:var(--admin-accent);font-size:var(--admin-label);font-weight:700}
40
+ .workstation-state.offline{background:var(--admin-row-border);color:var(--admin-muted)}
41
+ .workstation-key-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) minmax(0,2fr) minmax(0,1fr);gap:var(--admin-space-lg);margin:0}
42
+ .workstation-key{min-width:0}.workstation-key dt{color:var(--admin-muted);font-size:var(--admin-label);margin-bottom:var(--admin-space-sm)}
43
+ .workstation-key dd{margin:0;font-size:var(--admin-value);font-weight:600;overflow-wrap:anywhere;color:var(--admin-text)}
44
+ .workstation-key dd.working{color:var(--admin-accent)}
45
+ .workstation-account-note{display:block;font-size:var(--admin-label);font-weight:400;color:var(--admin-muted);margin-top:var(--admin-space-sm)}
46
+ .workstation-row-footer{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space)}
47
+ .workstation-more{flex:none;color:var(--admin-accent);font-size:var(--admin-label)}
48
+ .workstation-overview{display:grid;gap:var(--admin-space-lg);padding:var(--admin-card-padding);background:var(--admin-row);border:1px solid var(--admin-row-border);border-radius:var(--admin-card-radius)}
49
+ .workstation-disclosure{min-width:0;border:1px solid var(--admin-border);border-radius:var(--admin-control-radius);background:var(--admin-surface)}
50
+ .workstation-disclosure>summary{cursor:pointer;padding:var(--admin-space);font-weight:600;min-height:var(--admin-control-height);color:var(--admin-text)}
51
+ .workstation-disclosure>summary:focus-visible{outline:2px solid var(--admin-accent);outline-offset:2px}
52
+ .workstation-disclosure-content{display:grid;gap:var(--admin-space);padding:0 var(--admin-space) var(--admin-space)}
53
+ .workstation-phone{display:grid;gap:var(--admin-space);padding:var(--admin-space);background:var(--admin-row);border-radius:var(--admin-control-radius)}
54
+ .workstation-block{display:grid;gap:var(--admin-space);min-width:0;padding:var(--admin-space);border:1px solid var(--admin-border);border-radius:var(--admin-control-radius)}
55
+ .workstation-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space);margin:0}
56
+ .workstation-fields>div{min-width:0}.workstation-fields dd{margin:0;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}
57
+ #workstationBack[hidden]{display:none}
58
+ @media(max-width:600px){.workstation-fields{grid-template-columns:minmax(0,1fr)}.workstation-row,.workstation-overview{padding:var(--admin-space)}.workstation-key-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.workstation-key-account{grid-column:1/-1;order:3}.workstation-row-heading{align-items:start}}`;
@@ -0,0 +1,95 @@
1
+ export const adminWorkstationsMarkup = `
2
+ <section id="workstations" class="page stack" hidden>
3
+ <section class="card">
4
+ <div class="usage-toolbar"><div><h2 id="workstationHeading">工作站</h2><p id="workstationCount" class="hint">正在读取</p></div>
5
+ <div class="actions"><button id="workstationBack" class="secondary" type="button" hidden>返回列表</button><button id="workstationRefresh" class="secondary" type="button">刷新</button></div></div>
6
+ <div id="workstationContent" class="workstation-content"></div><p id="workstationError" class="status error" role="alert"></p>
7
+ </section>
8
+ </section>`;
9
+
10
+ export const adminWorkstationsScript = String.raw`
11
+ let workstationId=null,workstationController=null,workstationGeneration=0,workstationRendered='';
12
+ const workstationDisclosures=new Map();
13
+ const workstationRouteNames={management:'管理连接',pending:'连接方式待确认',direct:'中继直连',relay:'中继转发',lan:'局域网','self-relay':'Code Relax 中继'};
14
+ function workstationTime(value){return value?new Date(value).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false}):'未记录'}
15
+ function workstationFields(values){const list=node('dl','workstation-fields');for(const [label,value] of values){const row=node('div');row.append(node('dt','hint',label),node('dd','',value==null||value===''?'未提供':String(value)));list.append(row)}return list}
16
+ function workstationBlock(title){const block=node('section','workstation-block');block.append(node('h3','',title));return block}
17
+ function workstationAccount(accounts,missing='未上报'){return accounts.length?accounts.map(account=>account.email||account.id).join('、'):missing}
18
+ function workstationAccountView(item){return item.accounts.length?{value:workstationAccount(item.accounts),note:'当前认证'}:item.associatedAccounts.length?{value:workstationAccount(item.associatedAccounts),note:'最近确认'}:{value:'未上报',note:''}}
19
+ function workstationAssociated(accounts){return accounts.length?accounts.map(account=>account.email+(account.lastVerifiedAt?'('+workstationTime(account.lastVerifiedAt)+' 确认)':'')).join('、'):'未上报'}
20
+ function workstationStatus(online){return node('span','workstation-state'+(online?'':' offline'),online?'在线':'离线')}
21
+ function workstationLastRoute(item){return workstationRouteNames[item.lastConnection?.mode]||'未记录'}
22
+ function workstationConnectionState(item){return {connected:'已连接',disconnected:'已断开',unconfirmed:'未确认'}[item.connectionState]||'未确认'}
23
+ function workstationWork(item,online=item.online){return online?(item.activeTasks?'工作中 · '+item.activeTasks+' 项':'未确认'):'—'}
24
+ function workstationHighlights(item,online=item.online){
25
+ const list=node('dl','workstation-key-grid');const account=workstationAccountView(item);
26
+ for(const [key,label,value,note] of [['state','手机连接状态',workstationConnectionState(item),''],['connection','上次连接方式',workstationLastRoute(item),''],['account','手机账号',account.value,account.note],['work','工作状态',workstationWork(item,online),'']]){
27
+ const field=node('div','workstation-key workstation-key-'+key);field.append(node('dt','',label));const result=node('dd','',value);
28
+ if(note)result.append(node('small','workstation-account-note',note));if(key==='work'&&online&&item.activeTasks)result.classList.add('working');field.append(result);list.append(field)
29
+ }return list
30
+ }
31
+ function workstationDisclosure(item,key,title){const details=node('details','workstation-disclosure');const identity=item.id+':'+key;details.open=workstationDisclosures.get(identity)===true;details.append(node('summary','',title));details.addEventListener('toggle',()=>{if(details.isConnected)workstationDisclosures.set(identity,details.open)});return details}
32
+ function openWorkstation(id){history.pushState({workstationDetail:true},'','#workstations/'+encodeURIComponent(id));showPage('workstations/'+encodeURIComponent(id))}
33
+ function cancelWorkstations(){workstationGeneration++;workstationController?.abort();workstationController=null;workstationRendered=''}
34
+ function workstationCaption(result){if(result.workstation){const item=result.workstation;return (item.online?'在线':'离线')+' · 更新 '+workstationTime(result.sampledAt)}const online=result.workstations.filter(item=>item.online).length;return online+' 台在线 · '+(result.workstations.length-online)+' 台离线 · 按上线时间从新到旧'}
35
+ function renderWorkstationList(result){
36
+ const list=el('workstationContent');list.replaceChildren();el('workstationCount').textContent=workstationCaption(result);
37
+ if(!result.workstations.length){list.append(node('p','empty-note','暂无工作站'));return}
38
+ for(const item of result.workstations){
39
+ const row=node('a','workstation-row');row.href='#workstations/'+encodeURIComponent(item.id);row.addEventListener('click',event=>{if(event.button!==0||event.ctrlKey||event.metaKey||event.shiftKey||event.altKey)return;event.preventDefault();openWorkstation(item.id)});const heading=node('span','workstation-row-heading');heading.append(node('strong','',item.name),workstationStatus(item.online));
40
+ const footer=node('span','workstation-row-footer');footer.append(node('span','hint',(item.online?'上线时间 ':'最近上线 ')+workstationTime(item.onlineAt)),node('span','workstation-more','查看 ›'));
41
+ row.append(heading,workstationHighlights(item),footer);list.append(row)
42
+ }
43
+ }
44
+ function renderWorkstationDetail(result){
45
+ const item=result.workstation;const content=el('workstationContent');content.replaceChildren();el('workstationHeading').textContent=item.name;el('workstationCount').textContent=workstationCaption(result);
46
+ const overview=node('section','workstation-overview');const heading=node('div','workstation-row-heading');heading.append(workstationStatus(item.online),node('span','hint',(item.online?'上线时间 ':'最近上线 ')+workstationTime(item.onlineAt)));
47
+ overview.append(heading,workstationHighlights(item),node('p','hint','聊天连接 '+item.chatPhones+' 台手机 · 已配对 '+item.pairedPhones+' 台'));content.append(overview);
48
+ const phones=workstationDisclosure(item,'phones','手机与账号 · '+item.pairedPhones+' 台');const phoneList=node('div','workstation-disclosure-content');
49
+ if(!item.phones.length)phoneList.append(node('p','hint','尚无配对手机'));
50
+ for(const phone of item.phones){
51
+ const card=node('article','workstation-phone');const title=node('div','workstation-row-heading');const platform={android:'Android',harmony:'HarmonyOS',ios:'iOS'}[phone.platform]||'';
52
+ title.append(node('strong','',phone.deviceName||platform||'手机'),node('span','hint',workstationConnectionState(phone)));
53
+ const account=workstationAccountView(phone);card.append(title,workstationFields([['上次连接方式',workstationLastRoute(phone)],['手机账号',account.value+(account.note?' · '+account.note:'')]]));phoneList.append(card)
54
+ }phones.append(phoneList);content.append(phones);
55
+ const diagnostics=workstationDisclosure(item,'diagnostics','诊断详情');const body=node('div','workstation-disclosure-content');
56
+ const station=workstationBlock('工作站记录');station.append(workstationFields([
57
+ ['工作站 ID',item.id],['上线时间',workstationTime(item.onlineAt)],['来源 IP',item.connection?.address],['登记时间',workstationTime(item.registeredAt)],['授权更新时间',workstationTime(item.updatedAt)],
58
+ ['手机关联账号',workstationAssociated(item.associatedAccounts)],['当前认证账号',workstationAccount(item.accounts,'未确认')],['任务账号',workstationAccount(item.taskAccounts,'未记录')],
59
+ ['元数据连接',item.metadataPhones+' 台手机'],['调试白名单',item.debugEnabled?'已启用':'未启用'],['文件接收通道',item.fileReceiverOnline?'在线':'未连接']
60
+ ]));body.append(station);
61
+ const connectionEvents=workstationBlock('手机连接记录 · 工作站上报');
62
+ connectionEvents.append(node('p','hint',item.connectionReportingCurrent?'已收到当前工作站连接状态':'当前上报未确认;不据此推断手机断开'));
63
+ for(const event of item.connectionEvents||[]){const phone=item.phones.find(value=>value.id===event.clientId);connectionEvents.append(workstationFields([
64
+ ['时间',workstationTime(event.at)],['事件',event.state==='connected'?'手机已连接':'手机已断开'],['手机',phone?.deviceName||event.clientId],['连接方式',workstationRouteNames[event.mode]||'未确认']
65
+ ]))}
66
+ if(!item.connectionEvents?.length)connectionEvents.append(node('p','hint','暂无连接 / 断开记录'));body.append(connectionEvents);
67
+ const networks=workstationBlock('网络与 OpenAI 环境');
68
+ for(const network of item.lanCandidates)networks.append(workstationFields([['局域网地址',network.address],['网络',network.networkType==='wifi'?'Wi-Fi':'以太网'],['网络名称',network.networkName],['网卡',network.interfaceName]]));
69
+ if(item.officialComputer){const official=item.officialComputer;networks.append(workstationFields([['OpenAI 环境 ID',official.environmentId],['环境名称',official.name],['主机名',official.hostname],['登记时间',workstationTime(official.updatedAt)]]))}
70
+ if(!item.lanCandidates.length&&!item.officialComputer)networks.append(node('p','hint','尚未上报网络资料'));body.append(networks);
71
+ for(const phone of item.phones){
72
+ const card=workstationBlock(phone.deviceName||'手机连接记录');card.append(workstationFields([
73
+ ['手机 ID',phone.id],['平台',phone.platform],['关联账号',workstationAssociated(phone.associatedAccounts)],['当前认证账号',workstationAccount(phone.accounts,'未确认')],['任务账号',workstationAccount(phone.taskAccounts,'未记录')],
74
+ ['元数据连接',phone.metadata?'已连接':'未连接'],['设备管理连接',phone.managers.length+' 个'],['配对时间',workstationTime(phone.pairedAt)],['最近鉴权',workstationTime(phone.lastConnectedAt)],['最近局域网上报',workstationTime(phone.lastLanConnectedAt)],['上次连接时间',workstationTime(phone.lastConnection?.at)]
75
+ ]));
76
+ const channels=[...(phone.chat?[['聊天 / 管理',phone.chat]]:[]),...(phone.metadata?[['元数据',phone.metadata]]:[]),...phone.managers.map(channel=>['设备管理',channel]),...phone.transfers.map(channel=>[channel.purpose==='upload'?'文件上传':'文件下载',channel])];
77
+ for(const [name,channel] of channels)card.append(workstationFields([['通道',name],['认证账号',channel.account?.email||'未上报'],['连接时间',workstationTime(channel.connectedAt)],['来源 IP',channel.address]]));
78
+ if(phone.chat)card.append(workstationFields([['转发容量',phone.chat.relayAdmitted?(phone.chat.capacityExempt?'白名单豁免':'占用名额'):'未占用'],['待发送队列',phone.chat.queuedBytes+' 字节']]));body.append(card)
79
+ }
80
+ if(item.tasks.length){const tasks=workstationBlock('任务记录');for(const task of item.tasks)tasks.append(workstationFields([['线程 / 轮次',task.key],['手机 ID',task.clientId],['任务账号',task.account?.email||task.userId]]));body.append(tasks)}
81
+ const notes=workstationBlock('状态说明');notes.append(node('p','hint','在线表示工作站已连接云端中继,不代表内核已就绪。手机连接状态与上次连接方式独立:方式保留最近一次工作站确认的局域网 / Code Relax 中继,断开、工作站离线或云端重启不清空;未记录不推测。未确认表示云端没有足够的当前状态,不能据此判断手机未登录或工作站空闲。'),node('p','hint','局域网 / Code Relax 中继的连接、断开由新版工作站上报;健康探测、管理和文件通道不计入。工作站上报链路丢失不记作手机断开,OpenAI 中继暂未提供可靠的手机连接事件。'),node('p','hint','手机账号标为“最近确认”时来自既有验证记录,不代表当前登录;具体时间见关联账号记录。时间按北京时间显示,每 5 秒更新。'));
82
+ body.append(notes);diagnostics.append(body);content.append(diagnostics);
83
+ }
84
+ async function loadWorkstations(){
85
+ if(el('workstations').hidden||workstationController)return;const generation=workstationGeneration;const controller=workstationController=new AbortController();el('workstationRefresh').disabled=true;
86
+ try{
87
+ const result=await request('/admin/api/workstations'+(workstationId?'/'+encodeURIComponent(workstationId):''),{signal:controller.signal});if(generation!==workstationGeneration||el('workstations').hidden)return;
88
+ el('workstationError').textContent='';const signature=JSON.stringify(result.workstation||result.workstations);
89
+ if(signature!==workstationRendered){if(workstationId)renderWorkstationDetail(result);else renderWorkstationList(result);workstationRendered=signature}else el('workstationCount').textContent=workstationCaption(result)
90
+ }catch(error){if(generation!==workstationGeneration||controller.signal.aborted)return;workstationRendered='';el('workstationCount').textContent='读取失败,当前状态未确认';el('workstationContent').replaceChildren();el('workstationError').textContent=error.message}
91
+ finally{if(workstationController===controller){workstationController=null;el('workstationRefresh').disabled=false}}
92
+ }
93
+ el('workstationRefresh').addEventListener('click',()=>loadWorkstations());
94
+ el('workstationBack').addEventListener('click',()=>{if(history.state?.workstationDetail)history.back();else navigatePage('workstations')});
95
+ `;
@@ -0,0 +1,143 @@
1
+ // Explicit projections: authorization hashes, credentials, socket objects and payloads
2
+ // must never become part of the management API.
3
+ const open = entry => entry?.ws?.readyState === 1;
4
+ const socket = ws => ({ connectedAt: ws.adminInfo?.connectedAt || null,
5
+ address: ws.adminInfo?.address || null, account: ws.adminInfo?.account
6
+ ? { id: ws.adminInfo.account.id, email: ws.adminInfo.account.email } : null });
7
+
8
+ export function workstationDetail(room, debugEnabled = false) {
9
+ const online = room.host?.readyState === 1;
10
+ const connectionReportingCurrent = online && room.adminPhoneConnections !== undefined;
11
+ const tasks = online ? [...(room.usageTasks?.entries() || [])] : [];
12
+ const phones = room.clients.map(client => {
13
+ const connection = room.connections.get(client.id);
14
+ const chat = online && open(connection) ? connection : null;
15
+ const observer = room.observers?.get(client.id);
16
+ const metadata = open(observer) ? socket(observer.ws) : null;
17
+ const managers = [...(room.deviceManagers?.entries() || [])]
18
+ .filter(([ws, id]) => id === client.id && ws.readyState === 1).map(([ws]) => socket(ws));
19
+ const transfers = online ? [...(room.uploadClients?.values() || [])]
20
+ .filter(entry => entry.clientId === client.id && open(entry))
21
+ .map(entry => ({ purpose: entry.purpose, ...socket(entry.ws) })) : [];
22
+ const activeTasks = tasks.filter(([, task]) => task.clientId === client.id).length;
23
+ const reportedConnections = online ? [...(room.adminPhoneConnections?.values() || [])]
24
+ .filter(item => item.clientId === client.id).map(item => ({ connectionId: item.connectionId, mode: item.mode })) : [];
25
+ const connected = reportedConnections.length > 0 || Boolean(chat?.userId);
26
+ const channels = [chat && socket(chat.ws), metadata, ...managers, ...transfers].filter(Boolean);
27
+ const accounts = [...new Map(channels.filter(item => item.account)
28
+ .map(item => [item.account.id, item.account])).values()];
29
+ const taskAccounts = [...new Set(tasks.filter(([, task]) => task.clientId === client.id)
30
+ .map(([, task]) => task.userId).filter(Boolean))].map(id => accounts.find(account => account.id === id) || { id, email: null });
31
+ return {
32
+ id: client.id, deviceName: client.deviceName || null, platform: client.platform || null,
33
+ pairedAt: client.createdAt || null, lastConnectedAt: client.lastConnectedAt || null,
34
+ connected, connectionState: connected ? 'connected' : connectionReportingCurrent ? 'disconnected' : 'unconfirmed',
35
+ lastConnection: null, reportedConnections,
36
+ accounts, taskAccounts, associatedAccounts: [], metadata, managers, transfers, activeTasks,
37
+ chat: chat ? { ...socket(chat.ws), route: chat.userId ? chat.adminRoute || 'pending' : 'management',
38
+ capacityExempt: Boolean(chat.capacityExempt), relayAdmitted: Boolean(chat.relayAdmitted || chat.relayExempt),
39
+ queuedBytes: chat.queuedBytes || 0 } : null,
40
+ lastLanConnectedAt: room.adminLanConnections?.get(client.id) || null,
41
+ };
42
+ }).sort((a, b) => Number(b.connected) - Number(a.connected) ||
43
+ (b.lastConnectedAt || '').localeCompare(a.lastConnectedAt || '') || a.id.localeCompare(b.id));
44
+ const accounts = [...new Map(phones.flatMap(phone => phone.accounts).map(account => [account.id, account])).values()];
45
+ const taskAccounts = [...new Set(tasks.map(([, task]) => task.userId).filter(Boolean))]
46
+ .map(id => accounts.find(account => account.id === id) || { id, email: null });
47
+ return {
48
+ id: room.id, name: room.hostName || room.adminLastHostName || room.officialComputer?.hostname || '工作站 …' + room.id.slice(-8),
49
+ online, registeredAt: room.createdAt || null, updatedAt: room.updatedAt || null,
50
+ onlineAt: (online ? room.host.adminInfo?.connectedAt : room.adminLastOnlineAt) || null,
51
+ connection: online ? socket(room.host) : null,
52
+ pairedPhones: phones.length, connectedPhones: phones.filter(phone => phone.connected).length,
53
+ chatPhones: phones.filter(phone => phone.reportedConnections.length || phone.chat && phone.chat.route !== 'management').length,
54
+ connectionReportingCurrent,
55
+ connectionState: phones.some(phone => phone.connected) ? 'connected' : connectionReportingCurrent ? 'disconnected' : 'unconfirmed',
56
+ lastConnection: null,
57
+ metadataPhones: phones.filter(phone => phone.metadata).length,
58
+ activeTasks: tasks.length, accounts, taskAccounts, associatedAccounts: [], debugEnabled,
59
+ fileReceiverOnline: online && room.uploadHost?.readyState === 1,
60
+ lanCandidates: online ? (room.lanCandidates || []).map(item => ({ address: item.address,
61
+ networkType: item.networkType, networkName: item.networkName, interfaceName: item.interfaceName })) : [],
62
+ officialComputer: room.officialComputer ? { environmentId: room.officialComputer.environmentId,
63
+ name: room.officialComputer.name, hostname: room.officialComputer.hostname,
64
+ updatedAt: room.officialComputer.updatedAt } : null,
65
+ tasks: tasks.map(([key, task]) => ({ key, clientId: task.clientId, userId: task.userId,
66
+ account: taskAccounts.find(account => account.id === task.userId) || null })), phones,
67
+ };
68
+ }
69
+
70
+ // Previous connection mode is durable history; it never establishes current presence.
71
+ export async function resolveWorkstationConnections(workstations, events) {
72
+ const paired = new Map(workstations.map(item => [item.id, new Map(item.phones.map(phone => [phone.id, phone]))]));
73
+ for (const record of await events.latest(workstations.map(item => item.id))) {
74
+ const phone = paired.get(record.computerId)?.get(record.clientId);
75
+ if (phone) phone.lastConnection = { mode: record.mode, at: record.at };
76
+ }
77
+ for (const item of workstations) {
78
+ const last = item.phones.filter(phone => phone.lastConnection)
79
+ .sort((a, b) => b.lastConnection.at.localeCompare(a.lastConnection.at) || a.id.localeCompare(b.id))[0];
80
+ item.lastConnection = last ? { ...last.lastConnection, clientId: last.id } : null;
81
+ }
82
+ return workstations;
83
+ }
84
+
85
+ // Account association belongs to a workstation/mobile identity pair. A missing
86
+ // signaling socket says nothing about whether that phone has a login session.
87
+ // Read existing verified usage attribution without rewriting any history.
88
+ export async function resolveWorkstationAccounts(workstations, pool) {
89
+ const paired = new Map(workstations.map(item => [item.id, new Map(item.phones.map(phone => [phone.id, phone]))]));
90
+ if (pool && workstations.length) {
91
+ const [rows] = await pool.execute(`SELECT computer_id,client_id,user_id,MAX(occurred_at) AS last_verified_at
92
+ FROM relay_usage_actions WHERE computer_id IN (${workstations.map(() => '?').join(',')})
93
+ GROUP BY computer_id,client_id,user_id`, workstations.map(item => item.id));
94
+ for (const row of rows) {
95
+ const phone = paired.get(row.computer_id)?.get(row.client_id);
96
+ if (phone) phone.associatedAccounts.push({ id: row.user_id, email: null,
97
+ lastVerifiedAt: row.last_verified_at.replace(' ', 'T') + 'Z' });
98
+ }
99
+ }
100
+ const references = workstations.flatMap(item => [...item.taskAccounts,
101
+ ...item.phones.flatMap(phone => [...phone.taskAccounts, ...phone.associatedAccounts])]);
102
+ const missing = [...new Set(references.filter(account => !account.email).map(account => account.id))];
103
+ if (pool && missing.length) {
104
+ const [rows] = await pool.execute(`SELECT i.user_id,i.normalized_value AS email FROM user_login_identities i
105
+ JOIN users u ON u.id=i.user_id AND u.status='active'
106
+ WHERE i.kind='email' AND i.user_id IN (${missing.map(() => '?').join(',')})`, missing);
107
+ const emails = new Map(rows.map(row => [row.user_id, row.email]));
108
+ for (const account of references) if (!account.email) account.email = emails.get(account.id) || null;
109
+ }
110
+ for (const item of workstations) {
111
+ for (const phone of item.phones) {
112
+ const known = new Map(phone.associatedAccounts.filter(account => account.email)
113
+ .sort((a, b) => b.lastVerifiedAt.localeCompare(a.lastVerifiedAt)).map(account => [account.id, account]));
114
+ for (const account of [...phone.taskAccounts, ...phone.accounts]) if (account.email) {
115
+ known.set(account.id, { ...account, lastVerifiedAt: known.get(account.id)?.lastVerifiedAt || null });
116
+ }
117
+ phone.associatedAccounts = [...known.values()];
118
+ }
119
+ const known = new Map();
120
+ for (const phone of item.phones) for (const account of phone.associatedAccounts) {
121
+ if (!known.has(account.id) || (account.lastVerifiedAt || '') > (known.get(account.id).lastVerifiedAt || '')) known.set(account.id, account);
122
+ }
123
+ item.associatedAccounts = [...known.values()];
124
+ }
125
+ return workstations;
126
+ }
127
+
128
+ export function workstationSummary(item) {
129
+ const { phones, tasks, lanCandidates, officialComputer, fileReceiverOnline, debugEnabled, ...summary } = item;
130
+ return { ...summary, routes: workstationRoutes(phones) };
131
+ }
132
+
133
+ function workstationRoutes(phones) {
134
+ return [...new Set(phones.flatMap(phone => [...(phone.chat ? [phone.chat.route] : []),
135
+ ...phone.reportedConnections.filter(item => item.mode === 'lan' || !phone.chat).map(item => item.mode)]))];
136
+ }
137
+
138
+ export function listWorkstations(rooms, { details = false } = {}) {
139
+ const items = [...rooms.values()].filter(room => room.status === 'active').map(room => workstationDetail(room))
140
+ .sort((a, b) => Number(b.online) - Number(a.online) || (b.onlineAt || '').localeCompare(a.onlineAt || '') ||
141
+ a.name.localeCompare(b.name, 'zh-CN') || a.id.localeCompare(b.id));
142
+ return details ? items : items.map(workstationSummary);
143
+ }
@@ -0,0 +1,38 @@
1
+ import path from 'node:path';
2
+ import { EventEmitter } from 'node:events';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ const reporters = new Map();
6
+
7
+ // Workstation observations only. The bounded in-memory outbox covers cloud
8
+ // reconnects, not process exit; no polling or message payloads are involved.
9
+ export function connectionReporting(stateRoot) {
10
+ let key = path.resolve(stateRoot);
11
+ if (process.platform === 'win32') key = key.toLowerCase();
12
+ if (reporters.has(key)) return reporters.get(key);
13
+ const active = new Map(), pending = new Map(), events = new EventEmitter();
14
+ function record(connection, state) {
15
+ const event = { ...connection, id: randomUUID(), state, at: new Date().toISOString() };
16
+ pending.set(event.id, event);
17
+ if (pending.size > 128) pending.delete(pending.keys().next().value);
18
+ events.emit('change', event);
19
+ }
20
+ const reporter = {
21
+ connected(connectionId, clientId, mode) {
22
+ if (active.has(connectionId)) return;
23
+ const connection = { connectionId, clientId, mode };
24
+ active.set(connectionId, connection); record(connection, 'connected');
25
+ },
26
+ disconnected(connectionId) {
27
+ const connection = active.get(connectionId);
28
+ if (!connection) return;
29
+ active.delete(connectionId); record(connection, 'disconnected');
30
+ },
31
+ snapshot: () => [...active.values()],
32
+ pending: () => [...pending.values()],
33
+ acknowledge: id => pending.delete(id),
34
+ watch(listener) { events.on('change', listener); return () => events.off('change', listener); },
35
+ };
36
+ reporters.set(key, reporter);
37
+ return reporter;
38
+ }
@@ -244,6 +244,7 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
244
244
  resolve(message); return;
245
245
  }
246
246
  if (!registered) throw new Error('Relay not registered');
247
+ if (message.type === 'phone-connection-ack') { events.emit('phone-connection-ack', message.id); return; }
247
248
  if (['pair-code', 'revoked', 'status', 'invite-create', 'invite-list', 'invite-revoke', 'devices-list', 'device-revoke'].includes(message.type)) {
248
249
  if (operation && message.commandId === operation.id && message.type === operation.responseType) {
249
250
  clearTimeout(operation.timer); const pending = operation; operation = undefined;
@@ -257,7 +258,7 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
257
258
  if (message.type === 'attach') {
258
259
  events.emit('attached', { session: message.session, clientId: message.clientId });
259
260
  dropLocal(message.session);
260
- const current = { socket: undefined, ready: false, timer: undefined, p2pProbe: p2pProbe && message.p2pProbe === 1,
261
+ const current = { clientId: message.clientId, socket: undefined, ready: false, timer: undefined, p2pProbe: p2pProbe && message.p2pProbe === 1,
261
262
  bridgeOutbound: Promise.resolve(), healthRequests: new Set(), usageRequests: new Map(), detached: false, detachedTimer: undefined };
262
263
  locals.set(message.session, current);
263
264
  let connection;
@@ -292,7 +293,8 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
292
293
  send(socket, request);
293
294
  };
294
295
  current.timer = setTimeout(() => socket.terminate(), 5000);
295
- socket.on('open', () => send(socket, { type: 'auth', token: connection.token }));
296
+ socket.on('open', () => send(socket, { type: 'auth', token: connection.token,
297
+ ...(message.chat === true ? { relayClientId: message.clientId } : {}) }));
296
298
  socket.on('error', error => events.emit('diagnostic', `Local desktop service: ${error.message}`));
297
299
  socket.on('close', () => {
298
300
  clearTimeout(current.detachedTimer);
@@ -391,7 +393,8 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
391
393
  },
392
394
  onData: file ? undefined : payload => current.forwardToBridge(payload),
393
395
  onFileChannel: file ? channel => attachRtcFile(channel, bridgeConnection?.() || { url: bridgeUrl, token: bridgeToken },
394
- () => locals.get(message.session) === current && current.ready && current[idField] === probeId && remote.readyState === WebSocket.OPEN) : undefined,
396
+ () => locals.get(message.session) === current && current.ready && current[idField] === probeId && remote.readyState === WebSocket.OPEN,
397
+ current.clientId) : undefined,
395
398
  report: (state, diagnostic) => events.emit('p2p-probe', { state, diagnostic, purpose: file ? 'file' : 'chat' })
396
399
  })).catch(() => {
397
400
  events.emit('diagnostic', p2pWindowsRouteHelper ? 'Windows RTC 路由辅助不可用' : 'WebRTC probe initialization failed');
@@ -422,6 +425,8 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
422
425
  return { ready, events,
423
426
  assertConnected() { if (remote.readyState !== WebSocket.OPEN) throw lastClose || new Error('Relay disconnected during registration'); },
424
427
  reportDeviceConnection(info) { return send(remote, { ...info, type: 'device-connected' }); },
428
+ reportPhoneConnection(event) { return send(remote, { type: 'phone-connection', event }); },
429
+ reportPhoneConnections(active) { return send(remote, { type: 'phone-connections', active }); },
425
430
  command(type, details = {}) {
426
431
  if (operation) return Promise.reject(new Error('已有设备管理操作正在执行'));
427
432
  return new Promise((resolve, reject) => {
@@ -12,6 +12,7 @@ import { validProbeIceServers } from './p2p-probe.mjs';
12
12
  import { relayAddress, validSecret } from './wire.mjs';
13
13
  import { saveLanAuthorizations, onLanDeviceConnected, pendingLanConnections } from './lan-authorizations.mjs';
14
14
  import { lanNetworks } from '../platform/network-info.mjs';
15
+ import { connectionReporting } from './connection-reporting.mjs';
15
16
 
16
17
  const officialConfig = { url: serviceEndpoints.relayUrl };
17
18
 
@@ -83,6 +84,12 @@ async function createRelayRuntime(stateRoot) {
83
84
  const stopWatchingDevices = onLanDeviceConnected(stateRoot, info => {
84
85
  if (online && !closing) connector.reportDeviceConnection(info);
85
86
  });
87
+ const phoneReporting = connectionReporting(stateRoot);
88
+ const stopWatchingPhones = phoneReporting.watch(event => {
89
+ if (!online || closing) return;
90
+ try { connector.reportPhoneConnection(event); }
91
+ catch (error) { audit('phone-report-unavailable', { message: error.message }); }
92
+ });
86
93
  async function currentLanCandidates() {
87
94
  try {
88
95
  const bridge = session(), port = bridge?.port || 45831;
@@ -108,6 +115,7 @@ async function createRelayRuntime(stateRoot) {
108
115
  if (closing) return;
109
116
  closing = true; online = false; attachedCount = 0; clearTimeout(retryTimer);
110
117
  stopWatchingDevices();
118
+ stopWatchingPhones();
111
119
  connector?.events.removeAllListeners(); connector?.close();
112
120
  }
113
121
  async function saveInvitation(message) {
@@ -143,6 +151,7 @@ async function createRelayRuntime(stateRoot) {
143
151
  p2pWindowsRouteHelper: config.p2pWindowsRouteHelper === true, p2pWindowsRouteHelperToken: config.p2pWindowsRouteHelperToken || '' });
144
152
  current.events.on('p2p-probe', result => audit('p2p-probe', result));
145
153
  current.events.on('diagnostic', message => audit('transport-error', { message }));
154
+ current.events.on('phone-connection-ack', id => phoneReporting.acknowledge(id));
146
155
  current.events.on('attached', () => { paired = true; attachedCount++; audit('phone-attached', { attachedCount }); });
147
156
  current.events.on('detached', () => { attachedCount = Math.max(0, attachedCount - 1); audit('phone-detached', { attachedCount }); });
148
157
  current.events.on('authorization-changed', update => {
@@ -170,6 +179,8 @@ async function createRelayRuntime(stateRoot) {
170
179
  online = true; attempts = 0; paired = registration.paired; clientCount = registration.clientCount || 0; lastError = '';
171
180
  saveLanAuthorizations(stateRoot, identity.id, registration.clients || [], registration.revision);
172
181
  for (const info of pendingLanConnections(stateRoot)) current.reportDeviceConnection(info);
182
+ for (const event of phoneReporting.pending()) current.reportPhoneConnection(event);
183
+ current.reportPhoneConnections(phoneReporting.snapshot());
173
184
  await saveInvitation(registration);
174
185
  if (!closing) audit('relay-connected', { paired });
175
186
  } catch (error) {
@@ -0,0 +1,55 @@
1
+ import { validId } from './device-store.mjs';
2
+
3
+ export function validPhoneConnection(value) {
4
+ return value && validId(value.connectionId) && validId(value.clientId) && ['lan', 'self-relay'].includes(value.mode);
5
+ }
6
+
7
+ export function phoneConnectionEvents(pool) {
8
+ const recent = new Map();
9
+ const lastConnections = new Map();
10
+ return {
11
+ async record(computerId, event) {
12
+ if (!validPhoneConnection(event) || !validId(event.id) ||
13
+ !['connected', 'disconnected'].includes(event.state) ||
14
+ typeof event.at !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(event.at) ||
15
+ !Number.isFinite(Date.parse(event.at)) || new Date(event.at).toISOString() !== event.at) throw new Error('Invalid phone connection event');
16
+ const item = { id: event.id, connectionId: event.connectionId, clientId: event.clientId,
17
+ mode: event.mode, state: event.state, at: event.at, receivedAt: new Date().toISOString() };
18
+ if (pool) await pool.execute(`INSERT INTO workstation_phone_events
19
+ (event_id,computer_id,client_id,connection_id,mode,state,occurred_at,received_at) VALUES (?,?,?,?,?,?,?,?)
20
+ ON DUPLICATE KEY UPDATE event_id=event_id`,
21
+ [item.id, computerId, item.clientId, item.connectionId, item.mode, item.state,
22
+ item.at.slice(0, 23).replace('T', ' '), item.receivedAt.slice(0, 23).replace('T', ' ')]);
23
+ else {
24
+ const items = recent.get(computerId) || [];
25
+ if (!items.some(value => value.id === item.id)) items.push(item);
26
+ recent.set(computerId, items.slice(-20));
27
+ if (item.state === 'connected') {
28
+ const phones = lastConnections.get(computerId) || new Map();
29
+ if (!phones.has(item.clientId) || phones.get(item.clientId).at <= item.at) phones.set(item.clientId, item);
30
+ lastConnections.set(computerId, phones);
31
+ }
32
+ }
33
+ },
34
+ async latest(computerIds) {
35
+ if (!computerIds.length) return [];
36
+ if (!pool) return computerIds.flatMap(computerId => [...(lastConnections.get(computerId)?.values() || [])]
37
+ .map(item => ({ computerId, clientId: item.clientId, mode: item.mode, at: item.at })));
38
+ const [rows] = await pool.execute(`SELECT computer_id,client_id,mode,occurred_at FROM (
39
+ SELECT computer_id,client_id,mode,occurred_at,
40
+ ROW_NUMBER() OVER (PARTITION BY computer_id,client_id ORDER BY occurred_at DESC,sequence_id DESC) AS connection_rank
41
+ FROM workstation_phone_events WHERE state='connected' AND computer_id IN (${computerIds.map(() => '?').join(',')})
42
+ ) latest WHERE connection_rank=1`, computerIds);
43
+ return rows.map(row => ({ computerId: row.computer_id, clientId: row.client_id,
44
+ mode: row.mode, at: row.occurred_at.replace(' ', 'T') + 'Z' }));
45
+ },
46
+ async list(computerId) {
47
+ if (!pool) return [...(recent.get(computerId) || [])].reverse();
48
+ const [rows] = await pool.execute(`SELECT event_id,client_id,connection_id,mode,state,occurred_at,received_at
49
+ FROM workstation_phone_events WHERE computer_id=? ORDER BY sequence_id DESC LIMIT 20`, [computerId]);
50
+ return rows.map(row => ({ id: row.event_id, clientId: row.client_id, connectionId: row.connection_id,
51
+ mode: row.mode, state: row.state, at: row.occurred_at.replace(' ', 'T') + 'Z',
52
+ receivedAt: row.received_at.replace(' ', 'T') + 'Z' }));
53
+ },
54
+ };
55
+ }
@@ -3,7 +3,7 @@ import { loopbackUrl } from './wire.mjs';
3
3
 
4
4
  // The RTC peer belongs to an authenticated relay chat session. Its teardown
5
5
  // closes this channel on logout, expiry, revocation or signaling disconnect.
6
- export function attachRtcFile(channel, connection, authorized) {
6
+ export function attachRtcFile(channel, connection, authorized, clientId) {
7
7
  let local, authenticated = false;
8
8
  const timer = setTimeout(() => channel.close(1008), 5000);
9
9
  channel.addEventListener('close', () => { clearTimeout(timer); local?.terminate(); });
@@ -19,13 +19,18 @@ export function attachRtcFile(channel, connection, authorized) {
19
19
  local = new WebSocket(url.href, { maxPayload: 512 * 1024, perMessageDeflate: false });
20
20
  local.on('open', () => {
21
21
  if (!authorized() || channel.readyState !== 1) { local.terminate(); return; }
22
- local.send(JSON.stringify({ type: 'auth', token: connection.token, purpose: auth.purpose }));
22
+ local.send(JSON.stringify({ type: 'auth', token: connection.token, purpose: auth.purpose,
23
+ uploadSessionId: auth.uploadSessionId, resumeUpload: auth.resumeUpload, relayClientId: clientId }));
23
24
  });
24
25
  local.on('message', (bytes, binary) => {
25
26
  if (!authorized() || channel.readyState !== 1) { channel.close(4401); return; }
26
27
  try {
27
28
  if (!authenticated) {
28
- if (binary || JSON.parse(bytes.toString()).type !== 'ready') throw new Error('File authentication failed');
29
+ const response = binary ? null : JSON.parse(bytes.toString());
30
+ if (response?.type === 'error') {
31
+ channel.send(bytes.toString()); clearTimeout(timer); return;
32
+ }
33
+ if (response?.type !== 'ready') throw new Error('File authentication failed');
29
34
  authenticated = true; clearTimeout(timer);
30
35
  }
31
36
  channel.send(binary ? bytes : bytes.toString());