device-center 0.1.0-beta.1 → 0.2.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/Dockerfile +2 -2
  3. package/README.md +37 -127
  4. package/SECURITY.md +7 -15
  5. package/bin/device-center.mjs +6 -3
  6. package/docs/agent-install.md +105 -0
  7. package/docs/architecture.md +25 -57
  8. package/docs/connection-routing.md +1 -0
  9. package/docs/device-profiles.md +56 -0
  10. package/docs/driver-binding.md +6 -1
  11. package/docs/friend-trial.md +42 -0
  12. package/docs/hosted-access.md +52 -0
  13. package/docs/npm.md +19 -35
  14. package/docs/onboarding.md +8 -5
  15. package/docs/remote-access.md +76 -0
  16. package/docs/roadmap.md +17 -28
  17. package/docs/security-and-sync.md +9 -64
  18. package/login.html +1 -1
  19. package/npm-shrinkwrap.json +304 -0
  20. package/package.json +22 -4
  21. package/scripts/access.mjs +48 -0
  22. package/scripts/driver.mjs +4 -3
  23. package/scripts/release-check.mjs +4 -0
  24. package/server/cloudAccess.mjs +13 -6
  25. package/server/connector.mjs +11 -5
  26. package/server/drivers/access.mjs +84 -0
  27. package/server/drivers/certificates.mjs +32 -0
  28. package/server/drivers/cloudLink.mjs +46 -4
  29. package/server/drivers/cloudRegistry.mjs +107 -20
  30. package/server/drivers/deviceProfiles.mjs +47 -0
  31. package/server/drivers/driverProtocol.mjs +3 -2
  32. package/server/drivers/inventory.mjs +21 -2
  33. package/server/drivers/relay.mjs +59 -0
  34. package/server/drivers/remote.mjs +111 -0
  35. package/server/index.mjs +44 -13
  36. package/server/mcp.mjs +5 -3
  37. package/server/unifiedAccess.mjs +33 -17
  38. package/src/api.js +4 -1
  39. package/src/device-state.js +19 -0
  40. package/src/login.css +4 -0
  41. package/src/login.js +1 -0
  42. package/src/main.js +197 -42
  43. package/src/onboarding.js +62 -0
  44. package/src/start.css +47 -0
  45. package/src/start.js +23 -0
  46. package/src/styles.css +22 -0
  47. package/start.html +45 -0
package/src/main.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { api } from './api.js';
2
- import { expireSshSnapshots } from './device-state.js';
2
+ import { expireSshSnapshots, deviceStatus, collectionAvailability, DEVICE_TYPE_LABELS } from './device-state.js';
3
+ import { OFFICIAL_CONTROL_ORIGIN, connectCommand, logAccessCommands, agentInstallPrompt } from './onboarding.js';
3
4
 
4
5
  const root = document.querySelector('#root');
5
6
  const state = { loading: true, error: null, overview: null, setup: null, status: null, ssh: null, selectedAliases: [], saving: false, sshLoading: false, sshError: null, checkingSshAlias: null, dialog: null, routeDevice: null, method: 'settings', filter: 'all', query: '', project: 'all', toast: null, checking: false, checkMessage: null };
@@ -7,10 +8,13 @@ let snapshotExpiryTimer;
7
8
  let checkRefreshTimer, checkRefreshFailures = 0;
8
9
  Object.assign(state, { connectionDraft: null, connectionSaving: false, connectionError: null, setupStep: 1, setupReturn: 'start' });
9
10
  Object.assign(state, { pairCode: '', pairing: null, pairBusy: false, pairError: null, pairShare: false, requestSsh: false, revokeId: null, cloudRefreshError: null });
11
+ Object.assign(state, { cloudChoice: 'official', customOrigin: '', logManager: '', logTarget: '', logPath: '', logLabel: 'app', remotePeers: [], remoteLoading: false, remoteError: null });
10
12
  let cloudRefreshTimer, cloudRefreshBusy = false, cloudRefreshFailures = 0;
13
+ Object.assign(state, { profileDevice: null, profileDraft: null, profileError: null, profileSaving: false, collectingDevice: null, pendingCollections: {} });
14
+ Object.assign(state, { installMode: 'local', installOrigin: '', installBackground: true });
11
15
  const RELAY_PROVIDERS = [
12
- { id: 'self-hosted', name: '用户自建服务器', description: '使用用户自己的 VPS / 服务器作为中转。已有 SSH 跳板可继续按原配置使用;新的设备中继适配器待接入。' },
13
- { id: 'managed', name: 'Device Center 服务', description: '使用项目运营方提供的托管中转。服务地址、设备认证和额度规则尚未接入,不会自动连接某个服务。' },
16
+ { id: 'self-hosted', name: '用户自建服务器', description: '设备 Agent 可使用自建 HTTPS 控制面进行密文中转;已有 SSH 设备仍沿用原配置,此处只保存计划。' },
17
+ { id: 'managed', name: 'Device Center 服务', description: '设备 Agent 可使用官方 HTTPS 密文中转;需绑定自己的账号并单独授权日志。此处不会把 SSH 路径改成 Agent 通道。' },
14
18
  { id: 'cloudflare', name: 'Cloudflare', description: '使用 Cloudflare Tunnel / Access。设备侧或可访问设备的网关需要 cloudflared,管理端也需要相应客户端与认证方式;不是填写 CF 地址就能直连。' },
15
19
  ];
16
20
  const ICONS = {
@@ -28,7 +32,6 @@ const ICONS = {
28
32
  };
29
33
  const icon = (name, size = 18) => `<svg aria-hidden="true" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${ICONS[name] ?? ICONS.device}</svg>`;
30
34
  const escapeHtml = (value = '') => String(value).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
31
- const statusText = (value) => ({ online: '在线', unknown: '未知', expired: '过期' })[value] ?? '未知';
32
35
  function formatAgo(time) {
33
36
  if (time == null) return '尚未采集';
34
37
  const elapsed = Math.max(0, Date.now() - time);
@@ -65,12 +68,64 @@ function deviceCard(device) {
65
68
  const route = device.ssh?.route;
66
69
  const path = local ? '本机进程 · 无网络转发' : device.source === 'driver-report' ? `${device.managementDriver.name} → ${new URL(device.managementDriver.origin).host} · HTTPS 回报` : route ? `本机 → ${route.hops.length ? `${route.hops.map((hop) => hop.alias).join(' → ')} → 设备` : endpointText(route.target)}` : '访问路径未核验';
67
70
  const detail = device.transport === 'ssh' ? !device.ssh.configured ? '本机 SSH 配置不可用' : device.ssh.error ? sshErrorText(device.ssh.error) : device.ssh.checkState !== 'verified' ? 'SSH 配置已关联 · 尚未检查' : null : local ? null : '设备状态回报';
68
- const statusLabel = device.transport === 'ssh' && device.state === 'expired' ? '待刷新' : statusText(device.state);
69
- const statusTitle = device.transport === 'ssh' && device.state === 'expired' ? '上次采集超过 5 分钟;数据待刷新,不代表设备断线或密钥过期。' : device.transport === 'ssh' && device.state === 'online' ? '最近一次 SSH 检查成功,不表示持续连接。' : statusLabel;
70
- const checking = state.checkingSshAlias === device.ssh?.alias || device.ssh?.checking;
71
- const queued = device.ssh?.queued;
72
- const checkButton = device.transport === 'ssh' ? `<button class="button button-light ssh-check-button" data-action="ssh-check" data-alias="${escapeHtml(device.ssh.alias)}" ${!device.ssh.checkAvailable || state.checkingSshAlias || checking || queued ? 'disabled' : ''}>${checking ? '正在检查…' : queued ? '等待检查…' : '检查 SSH(只读)'}</button>` : '';
73
- return `<article class="device-card"><div class="device-identity"><div class="device-heading"><span class="device-icon">${icon('device', 17)}</span><div><h2>${escapeHtml(device.name)}</h2><p>${escapeHtml(subtitle)}</p></div><span class="status-pill ${device.state}" title="${escapeHtml(statusTitle)}">${statusLabel}</span></div><div class="device-path-line"><button class="device-route" data-action="route" data-device="${escapeHtml(device.id)}" aria-label="${escapeHtml(device.name)}的访问路径" title="查看连接方式与当前访问路径">${escapeHtml(path)}</button>${device.transport === 'ssh' ? connectivityMarkup(device) : ''}</div>${detail ? `<div class="device-detail">${escapeHtml(detail)}</div>` : ''}</div><div class="resource-grid">${metricMarkup(device, 'memory')}${metricMarkup(device, 'disk')}</div><div class="device-actions"><div class="device-footer">${device.transport === 'ssh' ? '最近检查' : '最近采集'} <span>${formatAgo(device.transport === 'ssh' ? device.ssh.lastCheckedAt : device.lastHeartbeat)}</span></div>${checkButton}</div></article>`;
71
+ const status = deviceStatus(device), availability = collectionAvailability(device);
72
+ const collecting = state.collectingDevice === device.id || device.transport === 'ssh' && (state.checkingSshAlias === device.ssh?.alias || device.ssh?.checking || device.ssh?.queued);
73
+ const collectButton = `<button class="button button-light ssh-check-button" data-action="collect-device" data-device="${escapeHtml(device.id)}" aria-label="采集 ${escapeHtml(device.name)}" title="${escapeHtml(availability.hint)}" ${!availability.available || collecting || state.collectingDevice ? 'disabled' : ''}>${collecting ? '正在采集…' : availability.pending ? '等待设备采集…' : '采集一次'}</button>`;
74
+ const profile = device.profile;
75
+ const description = profile && (profile.type !== 'unspecified' || profile.tags.length || profile.purpose) ? `<div class="device-description">${profile.type !== 'unspecified' ? `<span class="device-type">${DEVICE_TYPE_LABELS[profile.type]}</span>` : ''}${profile.tags.slice(0, 3).map(tag => `<span class="device-tag">${escapeHtml(tag)}</span>`).join('')}${profile.purpose ? `<p title="${escapeHtml(profile.purpose)}">${escapeHtml(profile.purpose)}</p>` : ''}</div>` : '';
76
+ const identityHint = device.managementDriver?.fingerprint ? ` · ${device.managementDriver.fingerprint.slice(0, 8)}` : '';
77
+ return `<article class="device-card"><div class="device-identity"><div class="device-heading"><span class="device-icon">${icon('device', 17)}</span><div><h2 title="${escapeHtml(device.name)}">${escapeHtml(device.name)}</h2><p>${escapeHtml(subtitle + identityHint)}</p></div><span class="status-pill ${device.state}" title="${escapeHtml(status.title)}">${status.label}</span></div>${description}<div class="device-path-line"><button class="device-route" data-action="route" data-device="${escapeHtml(device.id)}" aria-label="${escapeHtml(device.name)}的访问路径" title="查看连接方式与当前访问路径">${escapeHtml(path)}</button>${device.transport === 'ssh' ? connectivityMarkup(device) : ''}</div>${detail ? `<div class="device-detail">${escapeHtml(detail)}</div>` : ''}</div><div class="resource-grid">${metricMarkup(device, 'memory')}${metricMarkup(device, 'disk')}</div><div class="device-actions"><div class="device-footer">最近采集 <span>${formatAgo(device.lastHeartbeat)}</span></div>${collectButton}${!availability.available && !availability.pending && !collecting ? `<small class="collection-hint">${escapeHtml(availability.hint)}</small>` : ''}<button class="text-button profile-edit" data-action="profile" data-device="${escapeHtml(device.id)}" aria-label="编辑 ${escapeHtml(device.name)} 的设备说明">设备说明</button></div></article>`;
78
+ }
79
+
80
+ function openDeviceProfile(deviceId) {
81
+ const device = state.overview?.devices.find(d => d.id === deviceId);
82
+ if (!device) return;
83
+ const profile = device.profile ?? { displayName: '', type: 'unspecified', purpose: '', tags: [], services: [], revision: 0 };
84
+ state.profileDevice = deviceId;
85
+ state.profileDraft = { ...profile, tags: profile.tags.join(','), services: profile.services.join(',') };
86
+ state.profileError = null; state.toast = null; state.dialog = 'profile';
87
+ }
88
+ function profileBody() {
89
+ const device = state.overview?.devices.find(d => d.id === state.profileDevice), draft = state.profileDraft;
90
+ if (!device || !draft) return '<p class="modal-intro">设备已不在列表,请刷新后查看。</p>';
91
+ const disabled = state.profileSaving ? 'disabled' : '';
92
+ return `<p class="modal-intro">给设备一个容易辨认的名字,让智能体知道它适合做什么。</p><form id="device-profile-form"><div class="profile-fields"><label class="field-label" for="profile-name">设备名称<input id="profile-name" data-profile-field="displayName" value="${escapeHtml(draft.displayName)}" placeholder="${escapeHtml(device.reportedName ?? device.name)}" maxlength="80" ${disabled}></label><div><label class="field-label" for="profile-type">设备类型</label><select id="profile-type" data-profile-field="type" ${disabled}>${Object.entries(DEVICE_TYPE_LABELS).map(([value, label]) => `<option value="${value}" ${draft.type === value ? 'selected' : ''}>${label}</option>`).join('')}</select></div></div><label class="field-label" for="profile-purpose">用途<textarea id="profile-purpose" data-profile-field="purpose" maxlength="240" rows="2" placeholder="例如:Windows 程序验收、读取测试日志" ${disabled}>${escapeHtml(draft.purpose)}</textarea></label><label class="field-label" for="profile-tags">标签<input id="profile-tags" data-profile-field="tags" value="${escapeHtml(draft.tags)}" maxlength="200" placeholder="例如:开发,测试,东京 · 最多 8 个" ${disabled}></label><label class="field-label" for="profile-services">服务与应用<input id="profile-services" data-profile-field="services" value="${escapeHtml(draft.services)}" maxlength="500" placeholder="例如:Docker,PostgreSQL,Windows SDK" ${disabled}></label><p class="small-note profile-service-note">标签、服务与应用用逗号分隔。服务清单为你手动登记,未自动检测安装或运行状态。</p><details class="profile-identity"><summary>原始名称与设备身份</summary><p>原始名称:${escapeHtml(device.reportedName ?? device.name)}</p><code class="fingerprint">${escapeHtml(device.id)}</code>${device.managementDriver?.fingerprint ? `<p>身份指纹</p><code class="fingerprint">${escapeHtml(device.managementDriver.fingerprint)}</code>` : ''}<p>自定义名称不改变主机名、SSH 别名、身份密钥或权限。</p></details><p class="small-note">这些说明会出现在智能体的资源清单中;具体操作仍需单独授权。${cloudMode() ? '保存到当前账号的云端设备清单。' : '保存到本机清单;本轮尚未同步到云端。'}</p>${state.profileError ? `<p class="form-error" role="alert">${escapeHtml(state.profileError)}</p>` : ''}<button type="submit" class="button button-primary save-profile" ${disabled}>${state.profileSaving ? '正在保存…' : '保存设备说明'}</button></form>`;
93
+ }
94
+ async function saveDeviceProfile() {
95
+ if (state.profileSaving || !state.profileDraft) return;
96
+ const draft = { ...state.profileDraft }, deviceId = state.profileDevice;
97
+ const split = value => value.split(/[,,\n]/).map(v => v.trim()).filter(Boolean);
98
+ state.profileSaving = true; state.profileError = null; render();
99
+ try {
100
+ await api.saveDeviceProfile({ deviceId, revision: draft.revision, profile: { displayName: draft.displayName, type: draft.type, purpose: draft.purpose, tags: split(draft.tags), services: split(draft.services) } });
101
+ state.overview = await api.overview();
102
+ if (state.dialog === 'profile' && state.profileDevice === deviceId) state.dialog = null;
103
+ toast('设备说明已保存,智能体可在资源清单中读取。');
104
+ } catch (cause) {
105
+ if (state.dialog === 'profile' && state.profileDevice === deviceId) state.profileError = ({ invalid_device_profile: '请检查字数与数量:名称 80 字,用途 240 字,标签最多 8 个(每个 24 字),服务最多 12 个(每个 40 字)。', device_profile_conflict: '其他页面已修改此设备。请关闭后重新打开,核对最新说明再保存。', device_not_found: '设备已移除或撤销,请刷新列表。' })[cause.message] ?? '未能确认保存结果,请重新打开设备说明核对后再试。';
106
+ } finally { state.profileSaving = false; render(); }
107
+ }
108
+ function updateOverview(overview) {
109
+ state.overview = overview;
110
+ for (const [id, requestedAt] of Object.entries(state.pendingCollections)) {
111
+ const device = overview.devices.find(d => d.id === id), result = device?.collection;
112
+ if (result?.requestedAt === requestedAt && result.status === 'pending') continue;
113
+ delete state.pendingCollections[id];
114
+ toast(result?.requestedAt === requestedAt && result.status === 'completed' ? `${device.name} 已返回新的资源数据。` : '未收到本次新数据,请确认设备上的 Driver 和网络正常后重试。');
115
+ }
116
+ }
117
+ async function collectDevice(deviceId) {
118
+ const device = state.overview?.devices.find(d => d.id === deviceId);
119
+ if (!device || state.collectingDevice || !collectionAvailability(device).available) return;
120
+ state.collectingDevice = deviceId; state.toast = null; render();
121
+ try {
122
+ const result = await api.collectDevice(deviceId);
123
+ if (result.status === 'pending') state.pendingCollections[deviceId] = result.collection.requestedAt;
124
+ updateOverview(await api.overview());
125
+ if (result.status !== 'pending') toast(result.status === 'failed' ? sshErrorText(result.error) : `${device.name} 已重新采集内存与磁盘。`);
126
+ } catch (cause) {
127
+ toast(({ driver_not_connected: '设备未回报,请先启动设备上的 Driver;刷新列表不会让离线设备上线。', driver_upgrade_required: '当前 Driver 尚不支持手动采集,更新设备 Driver 后重试。', collection_rate_limited: '采集过于频繁,请稍等后重试。', collection_requires_local_ssh_check: '请在管理电脑检查这台 SSH 设备,云端不会替你执行 SSH。', collection_unavailable: '当前设备不支持采集,请在宿主运行 Driver。' })[cause.message] ?? '未能确认本次采集,请刷新列表核对时间;旧数据不会标为新数据。');
128
+ } finally { state.collectingDevice = null; render(); }
74
129
  }
75
130
  function endpointText(endpoint) {
76
131
  return `${endpoint.host.includes(':') ? `[${endpoint.host}]` : endpoint.host}:${endpoint.port}`;
@@ -161,16 +216,42 @@ function recentMcpInitialize() {
161
216
  }
162
217
  function renderSidebar() {
163
218
  const projects = state.overview?.projects ?? [];
164
- if (cloudMode()) return `<aside class="sidebar"><a class="brand" href="/" aria-label="Device Center 首页"><span class="brand-mark">${icon('cloud')}</span><span><strong>Device Center</strong><small>个人云端控制面</small></span></a><div class="nav-caption">工作空间</div><nav aria-label="主导航"><button class="nav-item active" data-action="project" data-project="all">${icon('device')}<span>所有设备</span><small>${state.overview?.devices.length ?? 0}</small></button></nav><div class="nav-caption">项目</div><nav aria-label="项目导航"><p class="nav-empty">暂无项目</p></nav><div class="sidebar-footer"><span class="local-dot"></span> 云端运行<button class="text-button" data-action="logout">退出登录</button></div></aside>`;
165
- return `<aside class="sidebar"><a class="brand" href="/" aria-label="Device Center 首页"><span class="brand-mark">${icon('cloud')}</span><span><strong>Device Center</strong><small>本机连接器</small></span></a><div class="nav-caption">工作空间</div><nav aria-label="主导航"><button class="nav-item ${state.project === 'all' ? 'active' : ''}" data-action="project" data-project="all">${icon('device')}<span>所有设备</span><small>${state.overview?.devices.length ?? 0}</small></button><button class="nav-item" data-action="connect">${icon('link')}<span>智能体连接</span></button></nav><div class="nav-caption">项目</div><nav aria-label="项目导航">${projects.length ? projects.map((p) => `<button class="nav-item ${state.project === p.id ? 'active' : ''}" data-action="project" data-project="${escapeHtml(p.id)}">${icon('folder')}<span>${escapeHtml(p.name)}</span></button>`).join('') : '<p class="nav-empty">暂无项目</p>'}</nav><div class="sidebar-footer"><span class="local-dot"></span> 本机运行<button class="text-button" data-action="resident">后台运行说明</button></div></aside>`;
219
+ if (cloudMode()) return `<aside class="sidebar"><a class="brand" href="/" aria-label="Device Center 首页"><span class="brand-mark">${icon('cloud')}</span><span><strong>Device Center</strong><small>云端控制面</small></span></a><div class="nav-caption">工作空间</div><nav aria-label="主导航"><button class="nav-item active" data-action="project" data-project="all">${icon('device')}<span>所有设备</span><small>${state.overview?.devices.length ?? 0}</small></button><button class="nav-item" data-action="secure-help">${icon('link')}<span>远程日志</span></button></nav><div class="nav-caption">项目</div><nav aria-label="项目导航"><p class="nav-empty">暂无项目</p></nav><div class="sidebar-footer"><span class="local-dot"></span> 云端运行<button class="text-button" data-action="logout">退出登录</button></div></aside>`;
220
+ return `<aside class="sidebar"><a class="brand" href="/" aria-label="Device Center 首页"><span class="brand-mark">${icon('cloud')}</span><span><strong>Device Center</strong><small>本机连接器</small></span></a><div class="nav-caption">工作空间</div><nav aria-label="主导航"><button class="nav-item ${state.project === 'all' ? 'active' : ''}" data-action="project" data-project="all">${icon('device')}<span>所有设备</span><small>${state.overview?.devices.length ?? 0}</small></button><button class="nav-item" data-action="connect">${icon('link')}<span>智能体连接</span></button><button class="nav-item" data-action="secure-help">${icon('link')}<span>远程日志</span></button><button class="nav-item" data-action="cloud-connect">${icon('cloud')}<span>云端面板</span></button></nav><div class="nav-caption">项目</div><nav aria-label="项目导航">${projects.length ? projects.map((p) => `<button class="nav-item ${state.project === p.id ? 'active' : ''}" data-action="project" data-project="${escapeHtml(p.id)}">${icon('folder')}<span>${escapeHtml(p.name)}</span></button>`).join('') : '<p class="nav-empty">暂无项目</p>'}</nav><div class="sidebar-footer"><span class="local-dot"></span> 本机运行<button class="text-button" data-action="resident">后台运行说明</button></div></aside>`;
166
221
  }
167
222
  function renderConnection() {
168
223
  if (state.loading && !state.overview) return '';
169
- if (cloudMode()) { const count = (state.overview?.drivers ?? []).filter(item => item.revokedAt == null).length; return `<section class="driver-summary" aria-label="云端接入状态"><span class="local-dot"></span><strong>${count ? `已绑定 ${count} 台管理电脑` : '个人控制面已上线'}</strong><p>${state.cloudRefreshError ?? (count ? '只读回报 · 约每 15 秒刷新面板' : '先连接自己的电脑,再查看资源')}</p></section>`; }
224
+ if (cloudMode()) { const count = (state.overview?.drivers ?? []).filter(item => item.revokedAt == null).length; return `<section class="driver-summary" aria-label="云端接入状态"><span class="local-dot"></span><strong>${count ? `已绑定 ${count} 台管理电脑` : '控制面已上线'}</strong><p>${state.cloudRefreshError ?? (count ? '只读回报 · 约每 15 秒刷新面板' : '先连接自己的电脑,再查看资源')}</p></section>`; }
170
225
  const container = state.status?.service.runtime === 'container';
171
226
  return `<section class="driver-summary" aria-label="本机 Driver"><span class="local-dot"></span><strong>${container ? '容器 Driver 已运行' : '本机已作为 Driver'}</strong><p>${container ? '宿主设备不可见' : '已有 SSH 可直接导入'}</p><button class="text-button initialize-entry" data-action="start">开始使用</button></section>`;
172
227
  }
173
228
  function managementComputer() { return state.overview?.devices.find((item) => item.source === 'local-runtime'); }
229
+ function openAgentInstall() {
230
+ const origin = cloudMode() ? state.overview.controlOrigin : null;
231
+ state.installMode = origin ? origin === OFFICIAL_CONTROL_ORIGIN ? 'official' : 'self-hosted' : 'local';
232
+ state.installOrigin = origin && origin !== OFFICIAL_CONTROL_ORIGIN ? origin : '';
233
+ state.toast = null; state.dialog = 'agent-install';
234
+ }
235
+ function installDraft() {
236
+ try { return { text: agentInstallPrompt({ mode: state.installMode, background: state.installBackground, ...(state.installMode === 'self-hosted' ? { origin: state.installOrigin.trim() } : {}) }), error: null }; }
237
+ catch (error) { return { text: '', error: error.message }; }
238
+ }
239
+ function agentInstallBody() {
240
+ const draft = installDraft();
241
+ return `<p class="modal-intro">复制一次,交给正在这台电脑上运行的 Agent。它会检查环境、安装本机 Driver,并配置智能体连接。</p><div class="method-tabs install-mode-tabs" role="group" aria-label="安装方式">${[['local', '本机使用'], ['official', '官方托管'], ['self-hosted', '自建服务']].map(([mode, label]) => `<button data-action="install-mode" data-mode="${mode}" aria-pressed="${state.installMode === mode}" class="${state.installMode === mode ? 'active' : ''}">${label}</button>`).join('')}</div><p class="small-note">${state.installMode === 'local' ? '无需账号。本机面板与 MCP 都在电脑上,默认不申请云端绑定。' : state.installMode === 'official' ? '使用你的 App Services 账号管理设备,官方服务提供面板与加密中转;Driver 和私钥仍在设备上。' : '接入已经部署的自建控制面。这里不自动部署服务器或迁移已有绑定。'}</p>${state.installMode === 'self-hosted' ? `<label class="field-label" for="install-origin">自建控制面的 HTTPS 地址</label><input id="install-origin" data-field="installOrigin" type="url" value="${escapeHtml(state.installOrigin)}" placeholder="https://devices.example.com" autocomplete="off" spellcheck="false">` : ''}<label class="binding-scope"><input type="checkbox" data-field="installBackground" ${state.installBackground ? 'checked' : ''}>登录系统后在后台运行(用户级服务)</label><ol class="install-flow"><li>Agent 检查环境并安装</li><li>${state.installMode === 'local' ? '无需云端配对' : '你登录账号并核对绑定'}</li><li>Agent 核验面板、资源和 MCP</li></ol><p id="install-error" class="form-error" role="alert" ${draft.error ? '' : 'hidden'}>${escapeHtml(draft.error ?? '')}</p><button class="button button-primary install-copy" data-action="agent-install-copy" ${draft.error ? 'disabled' : ''}>${icon('copy', 15)} 复制给 Agent</button><details class="install-details"><summary>查看完整安装说明</summary><textarea id="agent-install-text" aria-label="交给 Agent 的安装说明" readonly rows="7" spellcheck="false">${escapeHtml(draft.text)}</textarea></details><p class="small-note">支持 macOS / Linux,需要 Node.js 24。说明固定使用 0.2.0-beta.2。本页只复制说明;安装由你交给的 Agent 在本机执行。</p>`;
242
+ }
243
+ async function copyAgentInstall() {
244
+ const field = root.querySelector('#agent-install-text');
245
+ if (!field?.value) return;
246
+ let copied = false;
247
+ try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(field.value); copied = true; } } catch { /* manual selection remains available */ }
248
+ if (!copied) {
249
+ root.querySelector('.install-details').open = true; field.focus(); field.select();
250
+ try { copied = document.execCommand('copy'); } catch { /* manual selection remains available */ }
251
+ }
252
+ toast(copied ? '已复制。粘贴给目标电脑上的 Agent;需要绑定时由你登录并核对。' : '请手动复制已选中的安装说明。');
253
+ if (!copied) { root.querySelector('.install-details').open = true; root.querySelector('#agent-install-text')?.focus(); root.querySelector('#agent-install-text')?.select(); }
254
+ }
174
255
  function cloudMode() { return state.overview?.mode === 'cloud' || state.status?.service.runtime === 'cloud'; }
175
256
  function managementReady() { return state.status?.service.runtime === 'native' && managementComputer()?.scope === 'host'; }
176
257
  function managementBody() {
@@ -204,32 +285,63 @@ function connectionBody() {
204
285
  function addBody() {
205
286
  if (cloudMode()) return cloudBindingBody();
206
287
  const candidates = state.ssh?.candidates ?? [];
207
- return `<p class="modal-intro">本机已在设备列表中。选择已有 SSH 主机,交给本机 Driver 管理。</p><div class="ssh-toolbar"><strong>本机 SSH 配置 · ${candidates.length} 个候选</strong><button class="button button-light" data-action="ssh-refresh" ${state.sshLoading || state.saving ? 'disabled' : ''}>${state.sshLoading ? '读取中…' : '重新读取'}</button></div>${state.sshError ? `<p class="form-error" role="alert">${escapeHtml(state.sshError)}</p>` : ''}${state.sshLoading ? '<p class="small-note">正在读取本机 SSH 配置…</p>' : candidates.length ? `<div class="ssh-candidates" role="group" aria-label="SSH 主机候选">${candidates.map((candidate, index) => `<label class="ssh-candidate"><input id="ssh-choice-${index}" type="checkbox" data-field="ssh-alias" data-alias="${escapeHtml(candidate.alias)}" ${state.selectedAliases.includes(candidate.alias) ? 'checked' : ''} ${candidate.associated || state.saving ? 'disabled' : ''}><span><strong>${escapeHtml(candidate.alias)}</strong><small>${escapeHtml(candidate.source)}</small></span><small>${candidate.associated ? '已关联' : '未连接'}</small></label>`).join('')}</div><button class="button button-primary associate-button" data-action="associate" ${!state.selectedAliases.length || state.saving ? 'disabled' : ''}>${state.saving ? '保存中…' : `关联选中的 ${state.selectedAliases.length} 台设备`}</button>` : '<div class="plain-panel"><h3>没有找到明确的 SSH 主机别名</h3><p>先在系统 SSH 配置中添加目标主机的 Host 别名,再回来重新读取。通配规则不会当作设备。</p></div>'}<p class="small-note">关联后自动检查一次当前 SSH 路径,并标记结果;使用本机现有认证,最多同时检查两台。失败会保留设备和原因,可在卡片重试。私钥留在本机。</p>${state.ssh?.notices?.length ? `<details><summary>配置读取说明</summary>${state.ssh.notices.map((notice) => `<p>${escapeHtml(notice)}</p>`).join('')}</details>` : ''}<details><summary>目标主机还没有 SSH?</summary><p>这一步需要目标机已有 SSH 服务和本机的 SSH 配置。局域网与公网都复用已有配置,不扫描网络或打开端口。</p><p>设备 Agent 安装脚本、压缩包和主动连接尚未实现。未来由设备在本机生成身份密钥,私钥留在设备,短期登记码单独输入。</p></details>`;
288
+ return `<p class="modal-intro">本机已在设备列表中。选择已有 SSH 主机,交给本机 Driver 管理。</p><div class="ssh-toolbar"><strong>本机 SSH 配置 · ${candidates.length} 个候选</strong><button class="button button-light" data-action="ssh-refresh" ${state.sshLoading || state.saving ? 'disabled' : ''}>${state.sshLoading ? '读取中…' : '重新读取'}</button></div>${state.sshError ? `<p class="form-error" role="alert">${escapeHtml(state.sshError)}</p>` : ''}${state.sshLoading ? '<p class="small-note">正在读取本机 SSH 配置…</p>' : candidates.length ? `<div class="ssh-candidates" role="group" aria-label="SSH 主机候选">${candidates.map((candidate, index) => `<label class="ssh-candidate"><input id="ssh-choice-${index}" type="checkbox" data-field="ssh-alias" data-alias="${escapeHtml(candidate.alias)}" ${state.selectedAliases.includes(candidate.alias) ? 'checked' : ''} ${candidate.associated || state.saving ? 'disabled' : ''}><span><strong>${escapeHtml(candidate.alias)}</strong><small>${escapeHtml(candidate.source)}</small></span><small>${candidate.associated ? '已关联' : '未连接'}</small></label>`).join('')}</div><button class="button button-primary associate-button" data-action="associate" ${!state.selectedAliases.length || state.saving ? 'disabled' : ''}>${state.saving ? '保存中…' : `关联选中的 ${state.selectedAliases.length} 台设备`}</button>` : '<div class="plain-panel"><h3>没有找到明确的 SSH 主机别名</h3><p>先在系统 SSH 配置中添加目标主机的 Host 别名,再回来重新读取。通配规则不会当作设备。</p></div>'}<p class="small-note">关联后自动检查一次当前 SSH 路径,并标记结果;使用本机现有认证,最多同时检查两台。失败会保留设备和原因,可在卡片重试。私钥留在本机。</p>${state.ssh?.notices?.length ? `<details><summary>配置读取说明</summary>${state.ssh.notices.map((notice) => `<p>${escapeHtml(notice)}</p>`).join('')}</details>` : ''}<details><summary>目标主机还没有 SSH?</summary><p>这一步需要目标机已有 SSH 服务和本机的 SSH 配置。局域网与公网都复用已有配置,不扫描网络或打开端口。</p><p>可在该设备运行 npm 接入命令,先绑定同一云端账号,再单独授权日志。点击侧栏“云端面板”获取命令;不需要 SSH 服务。设备在本机生成身份密钥,私钥留在设备,配对码单独输入。</p></details>`;
208
289
  }
209
290
  function driverCommand() {
210
- return `npm run driver -- connect --cloud ${state.overview?.controlOrigin ?? window.location.origin}${state.requestSsh ? ' --share-ssh' : ''} --confirm`;
291
+ return connectCommand(state.overview?.controlOrigin ?? window.location.origin, state.requestSsh);
292
+ }
293
+ function cloudConnectDraft() {
294
+ const official = state.cloudChoice === 'official';
295
+ let command = '', error = null;
296
+ try { command = connectCommand(official ? OFFICIAL_CONTROL_ORIGIN : state.customOrigin.trim()); }
297
+ catch (cause) { if (state.customOrigin.trim()) error = cause.message; }
298
+ return { command, error };
299
+ }
300
+ function cloudConnectBody() {
301
+ const official = state.cloudChoice === 'official';
302
+ const { command, error } = cloudConnectDraft();
303
+ return `<p class="modal-intro">本机与已有 SSH 设备可直接使用,无需平台注册。需要在外查看资源时,再绑定云端账号。</p><div class="method-tabs" role="group" aria-label="控制面选择"><button data-action="cloud-choice" data-provider="official" aria-pressed="${official}" class="${official ? 'active' : ''}">官方控制面</button><button data-action="cloud-choice" data-provider="self-hosted" aria-pressed="${!official}" class="${!official ? 'active' : ''}">自建服务</button></div>${official ? '<p class="small-note">官方服务使用 App Services 统一账号,每个账号独立管理设备。日志密文中转默认 100MiB/账号/日;操作需在设备端单独授权。</p>' : '<label class="field-label" for="custom-origin">自建控制面的 HTTPS 地址</label><input id="custom-origin" data-field="customOrigin" type="url" placeholder="https://devices.example.com" value="' + escapeHtml(state.customOrigin) + '" autocomplete="off" spellcheck="false">'}<ol class="simple-steps"><li>需要 Node.js 24,支持 macOS / Linux。在自己的电脑终端运行下面的命令。</li><li>打开对应云端面板,登录自己的账号。</li><li>输入终端显示的配对码,核对完整指纹后批准。保持终端打开。</li></ol><textarea id="cloud-connect-command" readonly rows="3" spellcheck="false" aria-label="云端接入命令">${escapeHtml(command)}</textarea><p id="cloud-command-error" class="form-error" role="alert" ${!error ? 'hidden' : ''}>${escapeHtml(error ?? '')}</p><button class="button button-primary" data-action="cloud-command-copy" ${!command ? 'disabled' : ''}>${icon('copy', 15)} 复制接入命令</button><p class="small-note">命令中没有私钥或登记凭据。执行后才在本机生成私有身份;本页不会替你执行命令或绑定。已绑定其他控制面时,需要先明确断开。</p><details class="connector-help"><summary>绑定后可以做什么?</summary><p>当前仅回报本机名称、系统、内存与磁盘,不分享 SSH 配置。私钥留在本机;绑定不授予远程操作权限。</p><p>指定日志可经双向 TLS 认证读取:明确授权的局域网地址优先,网络不可达才走云端密文中转。点击侧栏“远程日志”查看授权步骤。通用文件传输和任意命令尚未提供。</p></details>`;
304
+ }
305
+ function logCommandDraft() {
306
+ const peers = cloudMode() ? state.overview?.drivers?.filter(p=>p.revokedAt == null) ?? [] : state.remotePeers;
307
+ const manager = peers.find(p=>p.id === state.logManager), target = peers.find(p=>p.id === state.logTarget);
308
+ try { return { ...logAccessCommands({manager:manager?.fingerprint,target:target?.fingerprint,path:state.logPath,label:state.logLabel}), error:null }; }
309
+ catch(error) { return {trust:'',grant:'',error:error.message}; }
310
+ }
311
+ function logCommandFields() {
312
+ const peers = cloudMode() ? state.overview?.drivers?.filter(p=>p.revokedAt == null) ?? [] : state.remotePeers;
313
+ if (state.remoteLoading) return '<p class="small-note">正在读取同账号设备…</p>';
314
+ if (state.remoteError) return '<p class="form-error">无法读取设备,请检查本机绑定、网络和云端状态。</p>';
315
+ if (peers.length < 2) return '<p class="small-note">接入两台设备后,这里可以选择管理电脑与目标并生成具体授权命令。下方提供手动步骤。</p>';
316
+ const options = selected => '<option value="">请选择设备</option>' + peers.map(p=>`<option value="${escapeHtml(p.id)}" ${p.id===selected?'selected':''}>${escapeHtml(p.name)} · ${escapeHtml(p.fingerprint.slice(0,12))}…</option>`).join('');
317
+ const draft=logCommandDraft();
318
+ return `<div class="plain-panel"><h3>生成授权命令</h3><label class="field-label" for="log-manager">管理电脑(运行智能体)</label><select id="log-manager" data-log-field="logManager">${options(state.logManager)}</select><label class="field-label" for="log-target">目标设备(日志所在)</label><select id="log-target" data-log-field="logTarget">${options(state.logTarget)}</select><label class="field-label" for="log-path">目标上的日志绝对路径</label><input id="log-path" data-log-field="logPath" value="${escapeHtml(state.logPath)}" placeholder="/home/me/app.log" autocomplete="off" spellcheck="false"><label class="field-label" for="log-label">日志标签</label><input id="log-label" data-log-field="logLabel" value="${escapeHtml(state.logLabel)}" maxlength="40"><p id="log-command-error" class="small-note">${escapeHtml(draft.error??'核对两端完整指纹后分别执行;60分钟有效。')}</p><label class="field-label" for="log-trust-command">在管理电脑运行</label><textarea id="log-trust-command" readonly rows="2">${escapeHtml(draft.trust)}</textarea><button class="button button-light" data-action="log-command-copy" data-field-id="log-trust-command" ${draft.error?'disabled':''}>复制管理电脑命令</button><label class="field-label" for="log-grant-command">在目标设备运行</label><textarea id="log-grant-command" readonly rows="3">${escapeHtml(draft.grant)}</textarea><button class="button button-primary" data-action="log-command-copy" data-field-id="log-grant-command" ${draft.error?'disabled':''}>复制目标授权命令</button><p class="small-note">不会在网页执行或授予权限。日志路径只用于本页生成命令,不提交到云端或写入浏览器存储。</p></div>`;
319
+ }
320
+ function secureHelpBody() {
321
+ return `<p class="modal-intro">先将管理电脑与目标设备接入同一个账号。绑定只回报资源;以下授权在各自电脑的终端完成。</p>${logCommandFields()}<ol class="simple-steps"><li>两台电脑固定安装 <code>npm install -g device-center@0.2.0-beta.2</code>,保持 Driver 运行。旧版身份先运行 <code>device-center access enable --confirm</code>。</li><li>运行 <code>device-center access peers</code>,通过可信渠道核对两台电脑的完整指纹。</li><li>管理电脑信任目标;目标只授予指定日志,有效期默认 60 分钟。</li></ol><details open><summary>在管理电脑运行</summary><code class="block-code">device-center access trust --peer 目标完整指纹 --confirm</code></details><details open><summary>在目标设备运行</summary><code class="block-code">device-center access grant --peer 管理电脑完整指纹 --log /绝对路径/app.log --label app --minutes 60 --confirm</code></details><p>授权完成后,让本机智能体调用 <code>device_center_read_log</code>,传入目标 fingerprint 与 label。每次最多 64KiB,返回实际访问路径。</p><details><summary>可选:同局域网优先</summary><p>目标执行 <code>device-center access listen --bind 192.168.1.10 --port 5181 --confirm</code>;管理电脑重新 trust,并增加 <code>--lan 192.168.1.10 --port 5181</code>。替换为已确认的目标地址;不会扫描网段或修改防火墙。</p><p>网络不可达时走云端中转;证书、身份或权限错误直接停止。两种路径都需要控制面在线核验设备是否已撤销。</p></details><p class="small-note">目标设备可运行 <code>device-center access revoke --peer 管理电脑完整指纹 --confirm</code> 随时撤销。私钥与日志授权路径留在本机;云端仅转发 TLS 密文。Windows 原生 Agent 尚未提供。</p>`;
211
322
  }
212
323
  function cloudBindingBody() {
213
324
  const pairing = state.pairing, disabled = state.pairBusy ? 'disabled' : '';
325
+ const sourceCommand = driverCommand().replace(/^npx --yes device-center@\S+/, 'npm run driver --');
214
326
  const bindings = state.overview?.drivers ?? [];
215
327
  return `<p class="modal-intro">在自己的管理电脑运行 Driver,然后回到这里批准。云端不会读取浏览器所在电脑的文件。</p>
216
- <section class="binding-step"><h3><span>1</span> 准备本机 Driver</h3><p>支持 macOS / Linux,需要 Node.js 24。把源码放在固定目录,解压后在该目录打开终端。已有最新版仓库可直接使用。</p><a class="button button-light" href="/api/driver/download" download>下载 Driver 源码包</a><small>包里不含身份、配对码或本机配置;尚无独立安装器和 Windows Driver。</small></section>
328
+ <section class="binding-step"><h3><span>1</span> 准备自己的电脑</h3><p>支持 macOS / Linux,需要 Node.js 24。下面的 npx 命令会取得固定版本 Driver,无需先下载源码或全局安装。</p><details><summary>离线准备或使用源码</summary><a class="button button-light" href="/api/driver/download" download>下载 Driver 源码包</a><p>解压后先在源码目录运行 <code>npm ci --ignore-scripts</code>,再运行 <code>${escapeHtml(sourceCommand)}</code>。包内没有身份、配对码或本机配置。</p></details></section>
217
329
  <section class="binding-step"><h3><span>2</span> 复制并在自己的电脑运行</h3><label class="binding-scope"><input id="request-ssh" type="checkbox" data-field="requestSsh" ${state.requestSsh ? 'checked' : ''} ${disabled}>另申请分享已关联 SSH 的名称和缓存资源状态</label><textarea id="driver-command" readonly rows="3" spellcheck="false" aria-label="本机 Driver 接入命令">${escapeHtml(driverCommand())}</textarea><button class="button button-primary" data-action="driver-copy">${icon('copy', 15)} 复制接入命令</button><p>这一步会在本机生成身份私钥并申请配对。终端会显示 5 分钟有效的配对码和公钥指纹;保持终端打开。只读回报默认仅含本机名称、系统、内存和磁盘。</p></section>
218
330
  <section class="binding-step"><h3><span>3</span> 输入配对码并核对</h3><div class="pair-code-row"><label for="pair-code" class="sr-only">本机终端显示的配对码</label><input id="pair-code" data-field="pairCode" autocomplete="off" spellcheck="false" maxlength="9" placeholder="XXXX-XXXX" value="${escapeHtml(state.pairCode)}" ${disabled}><button class="button button-light" data-action="pair-lookup" ${disabled}>${state.pairBusy ? '处理中…' : '查找请求'}</button></div>${state.pairError ? `<p class="form-error" role="alert">${escapeHtml(state.pairError)}</p>` : ''}
219
331
  ${pairing ? `<div class="pair-review"><strong>${escapeHtml(pairing.name)}</strong><p>${escapeHtml(pairing.platform)} · 有效至 ${escapeHtml(new Date(pairing.expiresAt).toLocaleTimeString())}</p><label class="field-label">请与本机终端的完整公钥指纹核对</label><code class="fingerprint">${escapeHtml(pairing.fingerprint)}</code><p>只批准你刚刚亲自在这台电脑发起的请求。名称相同不能证明设备身份。</p>${pairing.requestedSsh ? `<label class="binding-scope"><input id="pair-share" type="checkbox" data-field="pairShare" ${state.pairShare ? 'checked' : ''} ${disabled}>允许分享至多 15 个已关联 SSH 名称和缓存状态(不含地址、用户、路径、配置或私钥)</label>` : ''}<button class="button button-primary" data-action="pair-approve" ${disabled}>核对一致,绑定这台电脑</button></div>` : '<p class="small-note">配对码只在这里输入,不放进智能体 prompt、脚本或链接。</p>'}</section>
220
332
  ${bindings.length ? `<details class="binding-management" open><summary>已登记的管理电脑 · ${bindings.filter(item => item.revokedAt == null).length} 台有效</summary>${bindings.map(item => `<div class="binding-row"><div><strong>${escapeHtml(item.name)}</strong><small>${item.revokedAt != null ? '已撤销' : item.lastSeen == null ? '已批准 · 等待首次回报' : `最近回报 ${formatAgo(item.lastSeen)}`}</small><code class="fingerprint">${escapeHtml(item.fingerprint)}</code></div>${item.revokedAt == null ? state.revokeId === item.id ? `<div class="revoke-confirm"><p>撤销会停止这台电脑后续回报,保留本机文件。</p><button class="button button-light" data-action="revoke-confirm" data-device="${item.id}" ${disabled}>确认撤销</button><button class="text-button" data-action="revoke-cancel" ${disabled}>取消</button></div>` : `<button class="text-button" data-action="revoke-arm" data-device="${item.id}" ${disabled}>撤销</button>` : ''}</div>`).join('')}</details>` : ''}
221
- <p class="small-note">这是源码原型。绑定只授权资源回报;远程命令、文件传输、自动选路和云端 MCP 尚未开放。私钥为本机 0600 文件,尚未封装到系统钥匙串。</p>`;
333
+ <p class="small-note">${state.overview?.accountMode === 'multi-user' ? '设备归属当前登录账号,其他账号不能读取或撤销。' : '当前为个人模式,仅部署所有者可登录。'}绑定只授权资源回报;日志读取需在目标设备单独批准,可选择局域网直连或云端密文中转。任意命令、通用文件传输和云端 MCP 尚未提供。私钥为本机 0600 文件,尚未封装到系统钥匙串。</p>`;
222
334
  }
223
335
  function pairErrorText(code) {
224
- return ({ invalid_pairing_code: '请输入终端上的配对码,格式为 XXXX-XXXX。', driver_pairing_unavailable: '找不到有效请求;配对码可能已过期或已使用,请在本机重新发起。', driver_pairing_expired: '配对已过期,请在本机重新发起。', driver_pairing_consumed: '这个请求已批准,请刷新查看连接状态。', driver_rate_limited: '请求过于频繁,请一分钟后重试。', driver_limit: '已达到 16 台管理电脑上限,请先撤销不再使用的绑定。' })[code] ?? '云端暂时没有响应,请刷新后重试;不要重复批准陌生请求。';
336
+ return ({ invalid_pairing_code: '请输入终端上的配对码,格式为 XXXX-XXXX。', driver_pairing_confirmation_required: '配对码或指纹不匹配,请重新查找并与本机终端核对。', driver_pairing_unavailable: '找不到有效请求;配对码可能已过期或已使用,请在本机重新发起。', driver_pairing_expired: '配对已过期,请在本机重新发起。', driver_pairing_consumed: '这个请求已批准,请刷新查看连接状态。', driver_rate_limited: '请求过于频繁,请一分钟后重试。', driver_limit: '当前账号已达到 16 台管理电脑上限,请先撤销不再使用的绑定。' })[code] ?? '云端暂时没有响应,请刷新后重试;不要重复批准陌生请求。';
225
337
  }
226
338
  async function pairAction(action, id) {
227
339
  if (state.pairBusy) return;
228
340
  state.pairBusy = true; state.pairError = null; render();
229
341
  try {
230
- if (action === 'pair-lookup') { state.pairing = null; state.pairShare = false; state.pairing = await api.lookupPairing(state.pairCode.trim().toUpperCase()); }
342
+ if (action === 'pair-lookup') { state.pairing = null; state.pairShare = false; const code = state.pairCode.trim().toUpperCase(); state.pairing = { ...await api.lookupPairing(code), code }; }
231
343
  else if (action === 'pair-approve' && state.pairing) {
232
- await api.approvePairing(state.pairing.id, Boolean(state.pairing.requestedSsh && state.pairShare));
344
+ await api.approvePairing(state.pairing.id, Boolean(state.pairing.requestedSsh && state.pairShare), state.pairing.code, state.pairing.fingerprint);
233
345
  state.pairing = null; state.pairCode = ''; state.pairShare = false;
234
346
  state.toast = '已批准,等待本机首次回报;保持本机终端打开。';
235
347
  state.overview = await api.overview();
@@ -238,12 +350,12 @@ async function pairAction(action, id) {
238
350
  finally { state.pairBusy = false; render(); }
239
351
  }
240
352
  async function copyDriverCommand() {
241
- const field = root.querySelector('#driver-command'); if (!field) return;
353
+ const field = root.querySelector(state.dialog === 'cloud-connect' ? '#cloud-connect-command' : '#driver-command'); if (!field || !field.value) return;
242
354
  field.focus(); field.select(); let copied = false;
243
- try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(driverCommand()); copied = true; } } catch { /* manual selection remains available */ }
355
+ try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(field.value); copied = true; } } catch { /* manual selection remains available */ }
244
356
  if (!copied) try { copied = document.execCommand('copy'); } catch { /* manual selection */ }
245
- toast(copied ? '已复制。到自己的电脑源码目录中运行,再回来输入配对码。' : '请手动复制已选中的接入命令。');
246
- if (!copied) { root.querySelector('#driver-command')?.focus(); root.querySelector('#driver-command')?.select(); }
357
+ toast(copied ? '已复制。到自己的电脑终端运行,再回来输入配对码。' : '请手动复制已选中的接入命令。');
358
+ if (!copied) { const current = root.querySelector(state.dialog === 'cloud-connect' ? '#cloud-connect-command' : '#driver-command'); current?.focus(); current?.select(); }
247
359
  }
248
360
  function initializationBody() {
249
361
  if (state.sshLoading) return '<p class="modal-intro">正在检查本机 SSH 配置…</p>';
@@ -262,13 +374,13 @@ function residentBody() {
262
374
  }
263
375
  function renderDialog() {
264
376
  if (!state.dialog) return '';
265
- const title = { setup: '开始使用', connect: '连接本机 Codex', add: cloudMode() ? '连接我的电脑' : '添加设备', initialize: '初始化 · 导入本机 SSH', resident: '让服务在后台运行', route: '连接方式' }[state.dialog];
266
- const body = state.dialog === 'setup' ? onboardingBody() : state.dialog === 'route' ? routeBody() : state.dialog === 'connect' ? connectionBody() : state.dialog === 'add' ? addBody() : state.dialog === 'initialize' ? initializationBody() : residentBody();
377
+ const title = { 'agent-install': '交给 Agent 安装', profile: '设备说明', 'secure-help': '授权远程日志', setup: '开始使用', connect: '连接本机 Codex', 'cloud-connect': '云端面板 · 可选', add: cloudMode() ? '连接我的电脑' : '添加设备', initialize: '初始化 · 导入本机 SSH', resident: '让服务在后台运行', route: '连接方式' }[state.dialog];
378
+ const body = state.dialog === 'agent-install' ? agentInstallBody() : state.dialog === 'profile' ? profileBody() : state.dialog === 'secure-help' ? secureHelpBody() : state.dialog === 'cloud-connect' ? cloudConnectBody() : state.dialog === 'setup' ? onboardingBody() : state.dialog === 'route' ? routeBody() : state.dialog === 'connect' ? connectionBody() : state.dialog === 'add' ? addBody() : state.dialog === 'initialize' ? initializationBody() : residentBody();
267
379
  return `<div class="modal-scrim" data-action="close"><section class="add-modal ${state.dialog === 'setup' ? 'onboarding-modal' : ''}" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><header class="modal-heading"><h2 id="dialog-title">${title}</h2><button class="icon-button" data-action="close" aria-label="关闭">${icon('close')}</button></header>${body}${state.toast ? `<div class="toast" role="status">${escapeHtml(state.toast)}</div>` : ''}</section></div>`;
268
380
  }
269
381
  function filteredDevices() {
270
382
  const needle = state.query.trim().toLocaleLowerCase();
271
- return (state.overview?.devices ?? []).filter((d) => (state.project === 'all' || d.projectId === state.project) && (state.filter === 'all' || d.state === state.filter) && (!needle || `${d.name} ${d.platform}`.toLocaleLowerCase().includes(needle)));
383
+ return (state.overview?.devices ?? []).filter((d) => (state.project === 'all' || d.projectId === state.project) && (state.filter === 'all' || d.state === state.filter) && (!needle || [d.name, d.reportedName, d.platform, DEVICE_TYPE_LABELS[d.profile?.type], d.profile?.purpose, ...(d.profile?.tags ?? []), ...(d.profile?.services ?? [])].join(' ').toLocaleLowerCase().includes(needle)));
272
384
  }
273
385
  function renderDevices() {
274
386
  if (state.loading) return '<div class="empty-state"><p>正在读取设备…</p></div>';
@@ -297,7 +409,7 @@ function render() {
297
409
  const focusAction = focused?.dataset.action;
298
410
  const focusId = oldDialog?.contains(focused) ? focused.id : null;
299
411
  const focusField = focused?.dataset.field;
300
- const focusQualifier = ['method', 'copy', 'filter', 'project', 'alias', 'device', 'provider', 'step']
412
+ const focusQualifier = ['method', 'copy', 'filter', 'project', 'alias', 'device', 'provider', 'step', 'mode']
301
413
  .filter((key) => focused?.dataset[key])
302
414
  .map((key) => `[data-${key}="${CSS.escape(focused.dataset[key])}"]`).join('');
303
415
  const findFocus = (container) => focusId
@@ -307,18 +419,18 @@ function render() {
307
419
  const project = (state.overview?.projects ?? []).find((p) => p.id === state.project);
308
420
  const counts = state.overview?.stats ?? { total: 0, online: 0, unknown: 0, expired: 0 };
309
421
  root.innerHTML = `<div class="app-shell">${renderSidebar()}<div class="main-area">
310
- <header class="topbar"><span>${cloudMode() ? '个人云端工作空间' : '本机工作空间'}</span><span class="environment-badge">${cloudMode() ? 'HTTPS · 已登录' : '本机 Driver'}</span></header>
422
+ <header class="topbar"><span>${cloudMode() ? '我的云端设备' : '本机工作空间'}</span><span class="environment-badge">${cloudMode() ? 'HTTPS · 已登录' : '本机 Driver'}</span></header>
311
423
  <main class="workspace">
312
- <section class="page-heading"><div><h1>${escapeHtml(project?.name ?? '所有设备')}</h1><p>把自己的电脑,变成智能体可用的资源。</p></div><button class="button button-primary" data-action="add">${icon('plus', 16)} ${cloudMode() ? state.overview?.drivers?.some(item => item.revokedAt == null) ? '管理连接' : '连接我的电脑' : '添加设备'}</button></section>
424
+ <section class="page-heading"><div><h1>${escapeHtml(project?.name ?? '所有设备')}</h1><p>把自己的电脑,变成智能体可用的资源。</p></div><div class="heading-actions"><button class="button button-light" data-action="agent-install">交给 Agent 安装</button><button class="button button-primary" data-action="add">${icon('plus', 16)} ${cloudMode() ? state.overview?.drivers?.some(item => item.revokedAt == null) ? '管理连接' : '连接我的电脑' : '添加设备'}</button></div></section>
313
425
  ${state.error ? '' : renderConnection()}
314
426
  <section class="list-section" aria-label="设备资源">
315
- <div class="list-toolbar"><div class="filter-tabs" role="group" aria-label="设备状态">${[['all', '全部', counts.total], ['online', '在线', counts.online], ['unknown', '未知', counts.unknown], ['expired', '待刷新', counts.expired]].map(([key, label, count]) => `<button class="filter-tab ${state.filter === key ? 'active' : ''}" data-action="filter" data-filter="${key}" aria-pressed="${state.filter === key}">${label}<span>${count}</span></button>`).join('')}</div>
316
- <div class="list-controls">${counts.total ? `<input class="search-input" type="search" data-field="search" placeholder="搜索设备" aria-label="搜索设备" value="${escapeHtml(state.query)}">` : ''}<button class="icon-button" data-action="refresh" aria-label="刷新设备">${icon('refresh', 17)}</button></div>
427
+ <div class="list-toolbar"><div class="filter-tabs" role="group" aria-label="设备状态">${[['all', '全部', counts.total], ['online', '在线', counts.online], ['unknown', '未知', counts.unknown], ['expired', '需更新', counts.expired]].map(([key, label, count]) => `<button class="filter-tab ${state.filter === key ? 'active' : ''}" data-action="filter" data-filter="${key}" aria-pressed="${state.filter === key}">${label}<span>${count}</span></button>`).join('')}</div>
428
+ <div class="list-controls">${counts.total ? `<input class="search-input" type="search" data-field="search" placeholder="搜索设备" aria-label="搜索设备" value="${escapeHtml(state.query)}">` : ''}<button class="icon-button" data-action="refresh" aria-label="刷新列表" title="只读取最新回报;需要重新采集时点击设备上的采集一次">${icon('refresh', 17)}</button></div>
317
429
  </div>
318
430
  <div class="device-grid" role="region" aria-label="设备列表" tabindex="0">${renderDevices()}</div>
319
- <p class="list-footnote">${cloudMode() ? '本机回报每 30 秒 · 超过 90 秒待刷新 · SSH 缓存超过 5 分钟待刷新' : '导入后检查 SSH · 快照超过 5 分钟待刷新 · 点击路径看详情'}</p>
431
+ <p class="list-footnote">${cloudMode() ? '设备约每 30 秒回报 · 停止回报时保留旧数据 · 支持手动采集' : '采集一次更新资源 · 设备说明可命名与标记用途 · 点击路径看详情'}</p>
320
432
  </section>
321
- <footer class="page-footer"><span>只读资源${cloudMode() ? '回报' : '采集'} · 远程任务尚未实现</span><details class="status-help"><summary>状态说明</summary><p>${cloudMode() ? '未知:尚无资源回报。在线:管理电脑最近 90 秒内回报,且资源快照未过期。待刷新:管理电脑回报超过 90 秒,或 SSH 缓存超过 5 分钟;不代表密钥过期。SSH 快照由管理电脑手动检查后取得,云端刷新不会执行 SSH。回报路径为管理电脑主动连接云端 HTTPS,不是 SSH 隧道。云端不能执行远程任务或自动选路。' : '本机在线表示服务进程运行。SSH 在线表示近期取得资源快照;连通标记表示当前 SSH 路径检查成功。未取得资源的设备为未知。超过 5 分钟显示待刷新,只说明快照较早,不代表设备断线或密钥过期。刷新只读取现有结果,检查 SSH 才重新采集。自动选路、远程任务尚未实现。'}内存与磁盘分别采集;内存含系统缓存,磁盘为用户目录所在卷。</p></details></footer>
433
+ <footer class="page-footer"><span>${cloudMode() && state.overview?.relayUsage ? `中转 ${(state.overview.relayUsage.bytes / 1048576).toFixed(1)} / ${state.overview.relayUsage.limitBytes / 1048576} MiB · UTC 当日` : '资源采集 · 日志需单独授权'}</span><details class="status-help"><summary>状态说明</summary><p>${cloudMode() ? '未知:尚无资源回报。在线:管理电脑最近 90 秒内回报,且资源快照未过期。未回报:超过 90 秒未收到 Driver 数据;请检查休眠、Driver 进程和网络。待采集:资源快照较早;均不代表密钥过期。采集一次只要求在线 Driver 重采资源,等待真实返回后才更新。SSH 快照需在管理电脑检查,云端不会执行 SSH。回报路径为管理电脑主动连接云端 HTTPS,不是 SSH 隧道。云端不能执行远程任务或自动选路。' : '本机在线表示服务进程运行。SSH 在线表示近期取得资源快照;连通标记表示当前 SSH 路径检查成功。未取得资源的设备为未知。超过 5 分钟显示待采集,只说明快照较早,不代表设备断线或密钥过期。刷新列表只读取现有结果,采集一次才重新读取资源;SSH 设备会使用当前配置进行只读检查。自动选路、远程任务尚未实现。'}内存与磁盘分别采集;内存含系统缓存,磁盘为用户目录所在卷。</p></details></footer>
322
434
  </main>
323
435
  </div>${renderDialog()}${state.toast && !state.dialog ? `<div class="toast page-toast" role="status">${escapeHtml(state.toast)}</div>` : ''}</div>`;
324
436
  const dialog = root.querySelector('[role="dialog"]');
@@ -329,7 +441,7 @@ function render() {
329
441
  if (dialog) {
330
442
  dialog.scrollTop = dialogScroll;
331
443
  (oldDialog ? findFocus(dialog) ?? dialog.querySelector('button') : dialog.querySelector('button'))?.focus({ preventScroll: true });
332
- } else if (oldDialog) root.querySelector(oldDialog.dataset.kind === 'setup' ? `[data-action="${CSS.escape(state.setupReturn)}"]` : oldDialog.dataset.kind === 'route' ? `[data-action="route"][data-device="${CSS.escape(state.routeDevice)}"]` : `[data-action="${{ connect: 'connect', add: 'add', initialize: 'initialize', resident: 'resident' }[oldDialog.dataset.kind] ?? 'connect'}"]`)?.focus({ preventScroll: true });
444
+ } else if (oldDialog) root.querySelector(oldDialog.dataset.kind === 'setup' ? `[data-action="${CSS.escape(state.setupReturn)}"]` : oldDialog.dataset.kind === 'profile' ? `[data-action="profile"][data-device="${CSS.escape(state.profileDevice)}"]` : oldDialog.dataset.kind === 'route' ? `[data-action="route"][data-device="${CSS.escape(state.routeDevice)}"]` : `[data-action="${{ 'agent-install': 'agent-install', connect: 'connect', 'cloud-connect': 'cloud-connect', add: 'add', initialize: 'initialize', resident: 'resident' }[oldDialog.dataset.kind] ?? 'connect'}"]`)?.focus({ preventScroll: true });
333
445
  else findFocus(root)?.focus({ preventScroll: true });
334
446
  if (dialog) dialog.dataset.kind = state.dialog;
335
447
  }
@@ -340,10 +452,10 @@ function scheduleCloudRefresh() {
340
452
  cloudRefreshTimer = setTimeout(async () => {
341
453
  if (state.pairBusy || cloudRefreshBusy) { scheduleCloudRefresh(); return; }
342
454
  cloudRefreshBusy = true;
343
- try { state.overview = await api.overview(); state.cloudRefreshError = null; cloudRefreshFailures = 0; }
455
+ try { updateOverview(await api.overview()); state.cloudRefreshError = null; cloudRefreshFailures = 0; }
344
456
  catch { cloudRefreshFailures++; state.cloudRefreshError = cloudRefreshFailures >= 3 ? '自动刷新已暂停,请手动刷新' : '暂时无法刷新,正在重试'; }
345
457
  finally { cloudRefreshBusy = false; render(); }
346
- }, 15_000);
458
+ }, Object.keys(state.pendingCollections).length ? 3000 : 15_000);
347
459
  }
348
460
  async function load() {
349
461
  checkRefreshFailures = 0;
@@ -353,7 +465,7 @@ async function load() {
353
465
  try {
354
466
  const [overview, setup, status] = await Promise.all([api.overview(), api.connectorSetup(), api.connectorStatus()]);
355
467
  if (version !== requestVersion) return;
356
- Object.assign(state, { overview, setup, status });
468
+ Object.assign(state, { setup, status }); updateOverview(overview);
357
469
  } catch { if (version === requestVersion) state.error = 'service_unavailable'; }
358
470
  finally { if (version === requestVersion) { state.loading = false; render(); } }
359
471
  }
@@ -400,7 +512,6 @@ async function copyField(kind) {
400
512
  const text = kind === 'command' ? state.setup?.setupCommand : state.setup?.url;
401
513
  const field = root.querySelector('#copy-field');
402
514
  if (!text || !field) return toast('地址尚未就绪,请先重新连接服务。');
403
- field.focus(); field.select();
404
515
  let copied = false;
405
516
  let timer;
406
517
  if (navigator.clipboard?.writeText) {
@@ -415,19 +526,28 @@ root.addEventListener('click', async (event) => {
415
526
  const button = event.target.closest('[data-action]');
416
527
  if (!button) return;
417
528
  const action = button.dataset.action;
529
+ if (action === 'agent-install') { openAgentInstall(); render(); return; }
530
+ if (action === 'install-mode') { state.installMode = button.dataset.mode; state.toast = null; render(); return; }
531
+ if (action === 'agent-install-copy') return copyAgentInstall();
418
532
  if (action === 'logout') { try { await api.logout(); location.replace('/login'); } catch { state.toast = '退出失败,请重试'; render(); } return; }
419
533
  if (cloudMode() && action === 'add') { state.dialog = 'add'; state.toast = null; state.pairError = null; state.revokeId = null; render(); return; }
420
- if (action === 'driver-copy') return copyDriverCommand();
534
+ if (['driver-copy', 'cloud-command-copy'].includes(action)) return copyDriverCommand();
535
+ if (action === 'secure-help') { state.dialog='secure-help'; state.toast=null; state.remoteError=null; render(); if (!cloudMode()) { state.remoteLoading=true; render(); try { const data=await api.remotePeers(); state.remotePeers=data.peers; state.logManager=data.driverId??''; } catch { state.remoteError=true; } finally { state.remoteLoading=false; render(); } } return; }
536
+ if (action === 'log-command-copy') { const field=root.querySelector('#'+button.dataset.fieldId); if(!field?.value)return;field.focus();field.select();try {await navigator.clipboard.writeText(field.value);toast('已复制,请在对应设备终端核对并运行。');}catch{toast('请手动复制选中的命令。');} return; }
537
+ if (action === 'cloud-connect') { state.dialog = 'cloud-connect'; state.toast = null; render(); return; }
538
+ if (action === 'cloud-choice') { state.cloudChoice = button.dataset.provider; state.toast = null; render(); return; }
421
539
  if (['pair-lookup', 'pair-approve', 'revoke-confirm'].includes(action)) return pairAction(action, button.dataset.device);
422
540
  if (action === 'revoke-arm') { state.revokeId = button.dataset.device; render(); return; }
423
541
  if (action === 'revoke-cancel') { state.revokeId = null; render(); return; }
424
- if (action === 'close') { if (button.classList.contains('modal-scrim') && button !== event.target) return; state.dialog = null; state.toast = null; }
542
+ if (action === 'close') { if (state.dialog === 'profile' && state.profileSaving || button.classList.contains('modal-scrim') && button !== event.target) return; state.dialog = null; state.toast = null; }
425
543
  else if (action === 'start') return openSetup('start');
426
544
  else if (action === 'connect') return openSetup('connect', 1);
427
545
  else if (action === 'initialize') return openSetup('start', 2);
428
546
  else if (action === 'setup-step') return selectSetupStep(Number(button.dataset.step));
429
547
  else if (action === 'setup-finish') { state.dialog = null; state.toast = null; state.filter = 'all'; state.query = ''; state.project = 'all'; }
430
548
  else if (action === 'route') openConnections(button.dataset.device);
549
+ else if (action === 'profile') openDeviceProfile(button.dataset.device);
550
+ else if (action === 'collect-device') return collectDevice(button.dataset.device);
431
551
  else if (action === 'save-connections') return saveConnections();
432
552
  else if (action === 'clear-relay') state.connectionDraft.relayProvider = null;
433
553
  else if (['connect', 'resident', 'add', 'initialize'].includes(action)) { state.dialog = action; state.toast = null; state.checkMessage = null; if (['add', 'initialize'].includes(action)) { state.ssh = null; state.selectedAliases = []; state.sshError = null; render(); return loadSsh(); } }
@@ -454,6 +574,8 @@ root.addEventListener('click', async (event) => {
454
574
  render();
455
575
  });
456
576
  root.addEventListener('change', (event) => {
577
+ if (event.target.dataset.field === 'installBackground') { state.installBackground = event.target.checked; render(); return; }
578
+ if (event.target.dataset.profileField && state.profileDraft) { state.profileDraft[event.target.dataset.profileField] = event.target.value; return; }
457
579
  if (['requestSsh', 'pairShare'].includes(event.target.dataset.field)) { state[event.target.dataset.field] = event.target.checked; render(); return; }
458
580
  if (['relayProvider', 'preference'].includes(event.target.dataset.field) && state.connectionDraft) { state.connectionDraft[event.target.dataset.field] = event.target.value; state.connectionError = null; render(); return; }
459
581
  if (event.target.dataset.field !== 'ssh-alias') return;
@@ -462,6 +584,24 @@ root.addEventListener('change', (event) => {
462
584
  render();
463
585
  });
464
586
  root.addEventListener('input', (event) => {
587
+ if (event.target.dataset.field === 'installOrigin') {
588
+ state.installOrigin = event.target.value;
589
+ const draft = installDraft();
590
+ root.querySelector('#agent-install-text').value = draft.text;
591
+ const message = root.querySelector('#install-error'); message.textContent = draft.error ?? ''; message.hidden = !draft.error;
592
+ root.querySelector('[data-action="agent-install-copy"]').disabled = Boolean(draft.error);
593
+ return;
594
+ }
595
+ if (event.target.dataset.profileField && state.profileDraft) { state.profileDraft[event.target.dataset.profileField] = event.target.value; return; }
596
+ if (event.target.dataset.field === 'customOrigin') {
597
+ state.customOrigin = event.target.value;
598
+ const { command, error } = cloudConnectDraft();
599
+ root.querySelector('#cloud-connect-command').value = command;
600
+ const message = root.querySelector('#cloud-command-error');
601
+ message.textContent = error ?? ''; message.hidden = !error;
602
+ root.querySelector('[data-action="cloud-command-copy"]').disabled = !command;
603
+ return;
604
+ }
465
605
  if (event.target.dataset.field === 'pairCode') { state.pairCode = event.target.value; const hadPairing = state.pairing; state.pairing = null; state.pairShare = false; if (hadPairing) render(); return; }
466
606
  if (['lanHost', 'lanPort'].includes(event.target.dataset.field) && state.connectionDraft) { state.connectionDraft[event.target.dataset.field] = event.target.dataset.field === 'lanPort' ? Number(event.target.value) : event.target.value; return; }
467
607
  if (event.target.dataset.field !== 'search') return;
@@ -469,10 +609,14 @@ root.addEventListener('input', (event) => {
469
609
  root.querySelector('.device-grid').innerHTML = renderDevices();
470
610
  root.querySelector('.device-grid').scrollTop = 0;
471
611
  });
612
+ root.addEventListener('submit', (event) => {
613
+ if (event.target.id !== 'device-profile-form') return;
614
+ event.preventDefault(); void saveDeviceProfile();
615
+ });
472
616
  root.addEventListener('keydown', (event) => {
473
617
  const dialog = root.querySelector('[role="dialog"]');
474
618
  if (!dialog) return;
475
- if (event.key === 'Escape') { state.dialog = null; state.toast = null; render(); return; }
619
+ if (event.key === 'Escape') { if (state.dialog === 'profile' && state.profileSaving) return; state.dialog = null; state.toast = null; render(); return; }
476
620
  if (event.key !== 'Tab') return;
477
621
  const controls = [...dialog.querySelectorAll('button:not(:disabled), input:not(:disabled), textarea:not(:disabled), select:not(:disabled), summary, a[href]')].filter((el) => el.getClientRects().length);
478
622
  const first = controls[0], last = controls.at(-1);
@@ -481,3 +625,14 @@ root.addEventListener('keydown', (event) => {
481
625
  });
482
626
  document.addEventListener('visibilitychange', () => { if (!document.hidden) { cloudRefreshFailures = 0; scheduleCloudRefresh(); } else clearTimeout(cloudRefreshTimer); });
483
627
  load();
628
+
629
+ root.addEventListener('input', event => {
630
+ const field=event.target.dataset.logField;
631
+ if(!['logManager','logTarget','logPath','logLabel'].includes(field))return;
632
+ state[field]=event.target.value;
633
+ const draft=logCommandDraft();
634
+ const trust=root.querySelector('#log-trust-command'),grant=root.querySelector('#log-grant-command');
635
+ if(trust)trust.value=draft.trust;if(grant)grant.value=draft.grant;
636
+ const error=root.querySelector('#log-command-error');if(error)error.textContent=draft.error??'核对两端完整指纹后分别执行;60分钟有效。';
637
+ for(const button of root.querySelectorAll('[data-action="log-command-copy"]'))button.disabled=Boolean(draft.error);
638
+ });
@@ -0,0 +1,62 @@
1
+ export const OFFICIAL_CONTROL_ORIGIN = 'https://devices.owenshen.top';
2
+ // Pin the CLI shipped with this release; never silently install a moving tag.
3
+ export const PUBLISHED_DRIVER_VERSION = '0.2.0-beta.2';
4
+ export function connectCommand(value, shareSsh = false) {
5
+ let url;
6
+ try { url = new URL(value); } catch { throw new Error('请输入完整的 HTTPS 域名,例如 https://devices.example.com'); }
7
+ if (url.protocol !== 'https:' || url.origin !== value || url.username || url.password ||
8
+ !/^(?:[a-z0-9.-]+|\[[a-f0-9:]+\])$/i.test(url.hostname)) throw new Error('请输入 HTTPS 控制面地址,不包含路径、账号或参数。');
9
+ // The allowlist excludes shell syntax. Quotes also work in POSIX shells and PowerShell.
10
+ return `npx --yes device-center@${PUBLISHED_DRIVER_VERSION} connect --cloud '${url.origin}'${shareSsh ? ' --share-ssh' : ''} --confirm`;
11
+ }
12
+
13
+ // A reproducible handoff, not an installer. Reading/copying it has no machine effects.
14
+ export function agentInstallPlan({ mode = 'local', origin, port = 5174, background = true } = {}) {
15
+ if (!['local', 'official', 'self-hosted'].includes(mode) || !Number.isInteger(port) || port < 1024 || port > 65535 || typeof background !== 'boolean') throw new Error('安装模式或本机端口无效。');
16
+ const controlOrigin = mode === 'local' ? null : mode === 'official' ? OFFICIAL_CONTROL_ORIGIN : origin;
17
+ if (controlOrigin) connectCommand(controlOrigin); // Same strict HTTPS and shell-safe origin validation.
18
+ if (mode === 'self-hosted' && !controlOrigin) throw new Error('请输入自建控制面的 HTTPS 地址。');
19
+ if (origin !== undefined && mode !== 'self-hosted') throw new Error('只有自建模式可以填写控制面地址。');
20
+ const cli = '"$HOME/.local/bin/device-center"';
21
+ const url = `http://127.0.0.1:${port}`;
22
+ return { mode, version: PUBLISHED_DRIVER_VERSION, controlOrigin, background, url, mcpUrl: `${url}/mcp`, commands: {
23
+ install: `npm install --global --prefix "$HOME/.local" --ignore-scripts device-center@${PUBLISHED_DRIVER_VERSION}`,
24
+ version: `${cli} --version`, bindingStatus: `${cli} status`,
25
+ serviceStatus: `${cli} service status --port ${port}`, servicePlan: `${cli} service plan --port ${port}`,
26
+ serviceInstall: `${cli} service install --port ${port} --confirm`,
27
+ foreground: `${cli} start --port ${port}`,
28
+ pairingPlan: controlOrigin ? `${cli} plan --cloud '${controlOrigin}' --port ${port}` : null,
29
+ connect: controlOrigin ? `${cli} connect --cloud '${controlOrigin}' --port ${port} --confirm` : null,
30
+ uninstallService: `${cli} service uninstall --port ${port} --confirm`,
31
+ } };
32
+ }
33
+
34
+ export function agentInstallPrompt(options) {
35
+ const plan = agentInstallPlan(options), c = plan.commands;
36
+ return `请帮我在正在操作的这台电脑安装 Device Center,并接入这台电脑上的智能体。
37
+
38
+ 我的选择:${plan.mode === 'local' ? '本机使用,无需账号,不申请云端绑定' : plan.mode === 'official' ? '使用 Device Center 官方托管控制面和加密中转' : '接入我已经部署的自建控制面'}。
39
+ ${plan.controlOrigin ? `控制面:${plan.controlOrigin}。设备 Driver 仍在本机运行,身份私钥不托管到云端。\n` : ''}版本固定为 ${plan.version};${plan.background ? '安装用户级后台服务,登录系统后自动启动' : '仅前台运行,不安装后台服务'}。
40
+
41
+ 请按以下顺序执行并核验实际结果,不要只给我命令:
42
+ 1. 检查 macOS/Linux、Node.js >=24、npm 和当前智能体的 MCP 支持。Windows 原生 Driver 尚未支持,请说明限制并停止,不能假称安装成功。缺少 Node.js 时使用官方来源;遇到管理员权限或系统授权再让我处理。
43
+ 2. 先检查已有 device-center、绑定、用户服务及 ${plan.url}/api/health,保留原配置。CLI 已在固定位置时可运行 ${c.bindingStatus} 和 ${c.serviceStatus};不要打开身份私钥文件。端口被其他程序使用时不要停止它,改用空闲的已知本机端口并同步后续地址。已有服务或绑定时先核对版本、控制面和启动方式,匹配则复用;冲突时解释并让我选择,不能覆盖、解绑或重复安装。
44
+ 3. 对全新安装使用用户可写的固定位置,无需 sudo:
45
+ ${c.install}
46
+ ${c.version}
47
+ 不修改全局 npm 配置或 shell 启动文件;后续使用上面的绝对命令路径。
48
+ 4. ${plan.background ? `先查看并核对后台服务计划:\n ${c.servicePlan}\n 确认是用户级、只监听本机回环后安装:\n ${c.serviceInstall}\n 再核验:${c.serviceStatus}\n 若配置已存在或健康检查失败,保留现场并排查,不自动清理重装。Linux 需要可用的 systemd --user,macOS 需要当前登录用户的 launchd;不支持时如实提示可改用前台模式。` : `启动并保持前台进程:\n ${plan.controlOrigin ? '先在下一步完成 connect;它会在批准后启动本机 Driver。' : c.foreground}\n 关闭网页不会停止进程;关闭终端会停止回报。`}
49
+ 5. ${plan.controlOrigin ? `查看云端回报范围,然后申请绑定:\n ${c.pairingPlan}\n ${c.connect}\n 请我在 ${plan.controlOrigin} 登录自己的账号,在本机终端核对完整公钥指纹并手动批准绑定。不要代我批准新增设备访问权限,不读取账号密码、cookie 或私钥。配对码留在本机终端,不粘贴到智能体对话、脚本或链接;若工具会把配对码自动记录进对话,让我仅在本机终端执行这条 connect 命令,你继续完成其他检查。批准后核验设备确实回报资源;不能仅凭本地 configured=true 声称云端连通。` : '不执行 connect、不生成设备身份或申请账号。若发现已有云端绑定,先说明 start/后台服务可能恢复它;不能把已有绑定伪装成本机专用模式。'}
50
+ 6. 读取 ${plan.url}/api/connector/setup,使用当前智能体支持的官方 MCP 配置接口添加 Streamable HTTP 地址 ${plan.mcpUrl}。复用一致的配置,不覆盖其他 MCP。若当前工具不能配置,只让我粘贴这一个地址;不要编造已接通。
51
+ 7. 核验 /api/health、/api/overview 的本机真实内存与磁盘,以及智能体实际调用 device_center_list_resources。最后报告版本、面板/MCP 地址、前台或后台状态、云端绑定结果(如适用)与尚未完成的步骤。后台卸载命令仅供我需要时使用:${c.uninstallService},默认保留数据和身份。
52
+
53
+ 默认不导入或连接 SSH 主机、不分享 SSH、不扫描局域网、不开放入站端口、不配置隧道、不授予日志/命令权限。安装和配对不等于远程操作授权;后续访问目标设备需另行授权。
54
+ 此说明固定使用 ${plan.version},包含设备说明与手动采集。已有旧版需先核对升级;不能假称未运行的设备已接通。`;
55
+ }
56
+ export function logAccessCommands({ manager, target, path, label = 'app' }) {
57
+ if (![manager, target].every(value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)) || manager === target) throw new Error('请选择两台不同的已绑定设备。');
58
+ if (typeof path !== 'string' || !path.startsWith('/') || path.length > 4096 || /[\x00-\x1f\x7f]/.test(path)) throw new Error('填写目标设备上日志的绝对路径。');
59
+ if (!/^[a-zA-Z0-9_-]{1,40}$/.test(label)) throw new Error('标签仅允许字母、数字、下划线或连字符。');
60
+ const quotedPath = "'" + path.replaceAll("'", "'\"'\"'") + "'";
61
+ return { trust: `device-center access trust --peer ${target} --confirm`, grant: `device-center access grant --peer ${manager} --log ${quotedPath} --label ${label} --minutes 60 --confirm` };
62
+ }