device-center 0.2.0-beta.6 → 0.2.0-beta.8

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.
@@ -9,6 +9,8 @@ import { cloudOrigin, publicIdentity, signedRequest } from '../server/drivers/dr
9
9
  import { cloudPost, defaultLinkFile, readLink, writeLink, linkSummary, ensurePrivateDirectory } from '../server/drivers/cloudLink.mjs';
10
10
  import { issueCertificate } from '../server/drivers/certificates.mjs';
11
11
  import { pairingBrowserUrl, openPairingBrowser, waitForFirstReport } from '../server/drivers/browserPairing.mjs';
12
+ import { transientConnectionError, validPendingPairing, connectionFailure } from '../server/drivers/connectionRecovery.mjs';
13
+ import { createInterface } from 'node:readline/promises';
12
14
 
13
15
  const usage = 'npm run driver -- plan|connect|run|status|disconnect --cloud https://你的域名 [--browser] [--share-ssh] [--port 5174] [--confirm]';
14
16
  export function driverPlan(argv = []) {
@@ -45,7 +47,12 @@ export async function runLocal(plan) {
45
47
  const shutdown = () => instance.server.close(() => { instance.db.close(); process.exit(0); });
46
48
  process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown);
47
49
  }
48
- export async function main(argv = process.argv.slice(2), { file = defaultLinkFile(), post = cloudPost, openBrowser = openPairingBrowser, runDriver = runLocal, waitReport = waitForFirstReport } = {}) {
50
+ export async function main(argv = process.argv.slice(2), { file = defaultLinkFile(), post = cloudPost, openBrowser = openPairingBrowser, runDriver = runLocal, waitReport = waitForFirstReport, now = Date.now, sleep = ms => new Promise(done => setTimeout(done, ms)), renew = async () => {
51
+ if (!process.stdin.isTTY) return false;
52
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
53
+ try { return /^(y|yes|是)$/i.test((await rl.question('配对申请已过期,安装与身份已保留。登录或邮箱验证完成后,重新打开绑定页面继续?[y/N]:')).trim()); }
54
+ finally { rl.close(); }
55
+ } } = {}) {
49
56
  const plan = driverPlan(argv);
50
57
  if (Number(process.versions.node.split('.')[0]) < 24) throw new Error('需要 Node.js 24 或更高版本。');
51
58
  if (plan.command === 'plan') return plan; // No files, key generation, network or installation.
@@ -67,36 +74,57 @@ export async function main(argv = process.argv.slice(2), { file = defaultLinkFil
67
74
  writeLink(file, state, { create: true });
68
75
  }
69
76
  if (!state.certificate) { state.certificate = await issueCertificate(state); writeLink(file, state); }
70
- let pending;
71
- try { pending = await post(plan.origin, '/api/driver/pair/start', { publicKey: state.publicKey, certificate: state.certificate, name: hostname().slice(0,80), platform: ({ darwin: 'macOS', linux: 'Linux', win32: 'Windows' })[platform()] ?? platform(), shareSsh: plan.shareSsh, ...(plan.browser ? { browser: true } : {}) }); }
72
- catch (error) {
73
- if (plan.browser && error.statusCode === 400 && error.message === 'invalid_driver_input') throw new Error('控制面尚不支持浏览器接入,请升级控制面;或在本机终端使用不带 --browser 的 connect。未自动输出配对凭据。');
74
- throw error;
75
- }
76
- if (!/^[a-f0-9-]{36}$/.test(pending.id) || (!plan.browser && !/^[A-F0-9]{4}-[A-F0-9]{4}$/.test(pending.code)) || pending.fingerprint !== state.fingerprint || !Number.isSafeInteger(pending.expiresAt)) throw new Error('云端配对响应无效。');
77
- if (plan.browser) {
78
- const url = pairingBrowserUrl(plan.origin, pending);
79
- console.log(`正在打开本机浏览器。请本人登录账号,核对设备后批准一次。\n本机公钥指纹:${state.fingerprint}\n无需填写配对码,也无需回复 Agent;程序会自动等待并核验。`);
80
- await openBrowser(url);
81
- } else console.log(`请打开 ${plan.origin},点“连接我的电脑”,在面板单独输入以下配对码(5分钟内有效):\n${pending.code}\n核对本机公钥指纹:${state.fingerprint}\n仅批准你刚在这台电脑发起的请求;配对码不放入智能体prompt或安装脚本。`);
82
- const deadline = Math.min(pending.expiresAt, Date.now() + 300_000);
83
- while (Date.now() < deadline) {
84
- const result = await post(plan.origin, '/api/driver/pair/poll', signedRequest(state.privateKey, plan.origin, '/api/driver/pair/poll', pending.id, {}));
85
- if (result.state === 'approved') {
86
- if (!/^[a-f0-9-]{36}$/.test(result.driverId) || typeof result.shareSsh !== 'boolean' || result.shareSsh && !plan.shareSsh) throw new Error('云端批准范围无效。');
87
- state = { ...state, driverId: result.driverId, shareSsh: result.shareSsh, disabled: false }; writeLink(file, state);
88
- console.log('已绑定。私钥留在本机,只回报获准字段;远程任务未授权。');
89
- const since = Date.now();
90
- await runDriver(plan);
91
- if (!plan.browser) return linkSummary(state);
92
- const report = await waitReport({ port: plan.port, driverId: state.driverId, origin: state.origin, since });
93
- console.log(report.verified ? '云端已接收首次资源回报,接入完成。' : '账号绑定已完成,但尚未确认首次资源回报。请检查本机服务与网络,不要重复绑定。');
94
- return { ...linkSummary(state), firstReport: report };
77
+ let pending = validPendingPairing(state.pendingPairing, { now: now(), browser: plan.browser, shareSsh: plan.shareSsh, recoverApproved: true }) ? state.pendingPairing : null;
78
+ const sessionDeadline = now() + 30 * 60_000;
79
+ while (now() < sessionDeadline) {
80
+ if (!pending) try { pending = await post(plan.origin, '/api/driver/pair/start', { publicKey: state.publicKey, certificate: state.certificate, name: hostname().slice(0,80), platform: ({ darwin: 'macOS', linux: 'Linux', win32: 'Windows' })[platform()] ?? platform(), shareSsh: plan.shareSsh, ...(plan.browser ? { browser: true } : {}) }); }
81
+ catch (error) {
82
+ if (plan.browser && error.statusCode === 400 && error.message === 'invalid_driver_input') throw new Error('控制面尚不支持浏览器接入,请升级控制面;或在本机终端使用不带 --browser 的 connect。未自动输出配对凭据。');
83
+ throw connectionFailure(error, '创建配对申请');
84
+ }
85
+ pending = { id: pending.id, fingerprint: pending.fingerprint, expiresAt: pending.expiresAt, browser: plan.browser, shareSsh: plan.shareSsh, ...(plan.browser ? { browserTicket: pending.browserTicket } : { code: pending.code }) };
86
+ if (!validPendingPairing(pending, { now: now(), browser: plan.browser, shareSsh: plan.shareSsh, recoverApproved: true }) || pending.fingerprint !== state.fingerprint) throw new Error('云端配对响应无效。');
87
+ state.pendingPairing = pending;
88
+ writeLink(file, state);
89
+ const recoveringExpired = pending.expiresAt <= now();
90
+ if (recoveringExpired) console.log('正在核对上次申请的批准结果;不会重复批准或新增设备。');
91
+ else if (plan.browser) {
92
+ const url = pairingBrowserUrl(plan.origin, pending);
93
+ console.log(`正在打开本机浏览器。请本人登录账号,核对设备后批准一次。\n本机公钥指纹:${state.fingerprint}\n无需填写配对码,也无需回复 Agent;程序会自动等待并核验。`);
94
+ await openBrowser(url);
95
+ } else console.log(`请打开 ${plan.origin},点“连接我的电脑”,在面板单独输入以下配对码(5分钟内有效):\n${pending.code}\n核对本机公钥指纹:${state.fingerprint}\n仅批准你刚在这台电脑发起的请求;配对码不放入智能体prompt或安装脚本。`);
96
+ const deadline = Math.min(pending.expiresAt, now() + 300_000);
97
+ let networkWaiting = false;
98
+ while (true) {
99
+ let result;
100
+ try { result = await post(plan.origin, '/api/driver/pair/poll', signedRequest(state.privateKey, plan.origin, '/api/driver/pair/poll', pending.id, {}, now())); }
101
+ catch (error) {
102
+ if (error.statusCode === 410) break;
103
+ if (error.statusCode === 403 && error.message === 'driver_revoked') { delete state.pendingPairing; writeLink(file, state); throw connectionFailure(error, '上次批准已撤销'); }
104
+ if (!transientConnectionError(error) || recoveringExpired || now() >= deadline) throw connectionFailure(error, '等待设备批准');
105
+ if (!networkWaiting) console.log('等待批准时网络暂不可用,正在保留当前申请并重试;无需重新安装。');
106
+ networkWaiting = true; await sleep(5000); continue;
107
+ }
108
+ networkWaiting = false;
109
+ if (result.state === 'approved') {
110
+ if (!/^[a-f0-9-]{36}$/.test(result.driverId) || typeof result.shareSsh !== 'boolean' || result.shareSsh && !plan.shareSsh) throw new Error('云端批准范围无效。');
111
+ state = { ...state, driverId: result.driverId, shareSsh: result.shareSsh, disabled: false }; delete state.pendingPairing; writeLink(file, state);
112
+ console.log('已绑定。私钥留在本机,只回报获准字段;远程任务未授权。');
113
+ const since = now();
114
+ await runDriver(plan);
115
+ const report = await waitReport({ port: plan.port, driverId: state.driverId, origin: state.origin, since });
116
+ console.log(report.verified ? '云端已接收首次资源回报,接入完成。' : '账号绑定已完成,但尚未确认首次资源回报。请检查本机服务与网络,不要重复绑定。');
117
+ return { ...linkSummary(state), firstReport: report };
118
+ }
119
+ if (result.state !== 'pending') throw new Error('配对响应无效。');
120
+ if (now() >= deadline) throw connectionFailure(new Error('pairing_result_unconfirmed'), '等待设备批准');
121
+ await sleep(5000);
95
122
  }
96
- if (result.state !== 'pending') throw new Error('配对响应无效。');
97
- await new Promise(done => setTimeout(done, 5000));
123
+ pending = null;
124
+ delete state.pendingPairing; writeLink(file, state);
125
+ if (!await renew()) break;
98
126
  }
99
- throw new Error('配对已过期;未绑定,可重新 connect。身份文件保留在本机。');
127
+ throw new Error('配对已过期;安装与身份已保留。完成登录后重新运行同一 connect 命令或打开安装器继续,无需重装。');
100
128
  }
101
129
  if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
102
130
  try { const result = await main(); if (result) console.log(JSON.stringify(result, null, 2)); }
@@ -50,6 +50,24 @@ function run(command, args) {
50
50
  return child.stdout;
51
51
  }
52
52
  async function exists(path) { try { await lstat(path); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } }
53
+ // Used only after the installer explicitly confirms a program upgrade.
54
+ export async function controlExistingService(plan, operation) {
55
+ if (!['start', 'stop'].includes(operation) || !plan.supported) throw new Error('unsupported_service_control');
56
+ const { readFile } = await import('node:fs/promises');
57
+ const configuration = plan.task ? JSON.stringify(readPrivateJson(plan.configPath, null)) : await readFile(plan.configPath, 'utf8');
58
+ if (configuration !== plan.configuration) throw new Error('已有后台配置与程序位置或端口不同,已保留,未停止或替换。');
59
+ if (plan.task) windowsTask(operation, plan.task);
60
+ else if (plan.manager === 'launchd') run('launchctl', [operation === 'stop' ? 'bootout' : 'bootstrap', `gui/${process.getuid()}`, plan.configPath]);
61
+ else run('systemctl', ['--user', operation, 'device-center.service']);
62
+ for (let attempt = 0; attempt < 40; attempt++) {
63
+ let running = false;
64
+ try { const status = await (await fetch(`${plan.url}/api/connector/status`, { signal: AbortSignal.timeout(500) })).json(); running = status.service?.persistence === plan.manager; }
65
+ catch (error) { if (operation === 'stop' && error.cause?.code === 'ECONNREFUSED') return; }
66
+ if (operation === 'start' && running) return;
67
+ await new Promise(resolve => setTimeout(resolve, 250));
68
+ }
69
+ throw new Error('后台启动或停止尚未确认,已保留配置。请检查服务日志后继续。');
70
+ }
53
71
  async function install(plan) {
54
72
  if (!plan.supported) throw new Error('Unsupported user service platform.');
55
73
  if (await exists(plan.configPath)) throw new Error('The service config already exists. Inspect it first; this installer will not overwrite it.');
@@ -41,9 +41,9 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
41
41
  if (typeof code !== 'string' || !/^[A-F0-9]{4}-[A-F0-9]{4}$/.test(code)) fail('invalid_pairing_code');
42
42
  return publicCodeHash(code);
43
43
  };
44
- function pairing(id) {
44
+ function pairing(id, recoverApproved = false) {
45
45
  const row = db.prepare('SELECT * FROM driver_pairings WHERE id=?').get(id);
46
- if (!row || row.expires_at <= now()) fail('driver_pairing_expired', 410);
46
+ if (!row || (row.expires_at <= now() && !(recoverApproved && row.driver_id && row.expires_at + 86_400_000 > now()))) fail('driver_pairing_expired', 410);
47
47
  return row;
48
48
  }
49
49
  function credential(input) {
@@ -93,9 +93,9 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
93
93
  const name = text(input.name), platform = text(input.platform, 64);
94
94
  if (typeof input.shareSsh !== 'boolean') fail('invalid_driver_scope');
95
95
  // Only protocol-expired pending requests are removed; registered devices and reports are retained.
96
- db.prepare('DELETE FROM driver_pairings WHERE expires_at<=?').run(now());
96
+ db.prepare('DELETE FROM driver_pairings WHERE (driver_id IS NULL AND expires_at<=?) OR expires_at<=?').run(now(), now() - 86_400_000);
97
97
  for (const [id, at] of polls) if (at <= now() - PAIR_AGE) polls.delete(id);
98
- if (db.prepare('SELECT count(*) AS n FROM driver_pairings').get().n >= 32) fail('driver_pairing_busy', 429);
98
+ if (db.prepare('SELECT count(*) AS n FROM driver_pairings WHERE driver_id IS NULL').get().n >= 32) fail('driver_pairing_busy', 429);
99
99
  const id = randomUUID(), raw = randomBytes(4).toString('hex').toUpperCase(), code = `${raw.slice(0,4)}-${raw.slice(4)}`;
100
100
  db.prepare('INSERT INTO driver_pairings (id,code_hash,public_key,fingerprint,name,platform,requested_ssh,created_at,expires_at,certificate) VALUES (?,?,?,?,?,?,?,?,?,?)').run(id, hashCode(code), identity.publicKey, identity.fingerprint, name, platform, Number(input.shareSsh), now(), now() + PAIR_AGE, certificate);
101
101
  if (input.browser) {
@@ -108,7 +108,7 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
108
108
  poll(input) {
109
109
  object(input, ['id', 'at', 'nonce', 'payload', 'signature']);
110
110
  if (typeof input.id !== 'string') fail('driver_proof_required', 401);
111
- const row = pairing(input.id); proof(row, '/api/driver/pair/poll', input); object(input.payload, []);
111
+ const row = pairing(input.id, true); proof(row, '/api/driver/pair/poll', input); object(input.payload, []);
112
112
  if (polls.has(row.id) && now() - polls.get(row.id) < 4500) fail('driver_poll_too_frequent', 429);
113
113
  polls.set(row.id, now());
114
114
  if (!row.driver_id) return { state: 'pending' };
@@ -124,6 +124,19 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
124
124
  if (!row || row.expires_at <= now() || row.driver_id) fail('driver_pairing_unavailable', 404);
125
125
  return { id: row.id, name: row.name, platform: row.platform, fingerprint: row.fingerprint, requestedSsh: Boolean(row.requested_ssh), expiresAt: row.expires_at };
126
126
  },
127
+ pairingResult(input, tenantId) {
128
+ globalLookups = limited(globalLookups, 64); limitAccount(lookups, tenantId); object(input, ['browserTicket']);
129
+ const token = credential(input);
130
+ const row = db.prepare('SELECT * FROM driver_pairings WHERE browser_hash=?').get(token.hash);
131
+ if (!row || row.expires_at + 86_400_000 <= now()) fail('driver_pairing_unavailable', 404);
132
+ if (row.driver_id) {
133
+ const driver = db.prepare('SELECT * FROM cloud_drivers WHERE id=? AND tenant_id=? AND revoked_at IS NULL').get(row.driver_id, tenantId);
134
+ if (!driver) fail('driver_pairing_unavailable', 404);
135
+ return { state: 'approved', driverId: driver.id };
136
+ }
137
+ if (row.expires_at <= now()) fail('driver_pairing_expired', 410);
138
+ return { state: 'pending' };
139
+ },
127
140
  approve(input, tenantId) {
128
141
  globalApprovals = limited(globalApprovals, 64); limitAccount(approvals, tenantId);
129
142
  object(input, ['id', 'code', 'browserTicket', 'fingerprint', 'shareSsh']);
@@ -222,7 +235,7 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
222
235
  forTenant(tenantId) {
223
236
  if (typeof tenantId !== 'string' || !/^(cloud-owner|account-[a-f0-9]{64})$/.test(tenantId)) fail('driver_account_required', 403);
224
237
  return {
225
- lookup: input => registry.lookup(input, tenantId), approve: input => registry.approve(input, tenantId),
238
+ lookup: input => registry.lookup(input, tenantId), approve: input => registry.approve(input, tenantId), pairingResult: input => registry.pairingResult(input, tenantId),
226
239
  revoke: input => registry.revoke(input, tenantId), bindings: () => registry.bindings(tenantId), devices: () => registry.devices(tenantId), relayUsage: () => relay.usage(tenantId),
227
240
  saveProfile: input => registry.saveProfile(input, tenantId), collect: input => registry.collect(input, tenantId),
228
241
  cli: management.owner(tenantId),
@@ -0,0 +1,10 @@
1
+ export function transientConnectionError(error) {
2
+ return [429, 502, 503, 504].includes(error.statusCode) || ['TimeoutError', 'AbortError'].includes(error.name) || ['ECONNRESET','ECONNREFUSED','ETIMEDOUT','EHOSTUNREACH','ENETUNREACH','EAI_AGAIN','UND_ERR_CONNECT_TIMEOUT','UND_ERR_SOCKET'].includes(error.code ?? error.cause?.code) || (error.name === 'TypeError' && error.message === 'fetch failed');
3
+ }
4
+ export function validPendingPairing(value, { now = Date.now(), browser, shareSsh, recoverApproved = false } = {}) {
5
+ return Boolean(value && /^[a-f0-9-]{36}$/.test(value.id ?? '') && Number.isSafeInteger(value.expiresAt) && value.expiresAt + (recoverApproved ? 86_400_000 : 0) > now && value.expiresAt <= now + 330_000 && value.browser === browser && value.shareSsh === shareSsh && (browser ? /^[a-f0-9]{64}$/.test(value.browserTicket ?? '') : /^[A-F0-9]{4}-[A-F0-9]{4}$/.test(value.code ?? '')));
6
+ }
7
+ export function connectionFailure(error, stage) {
8
+ const code = transientConnectionError(error) ? 'network_timeout_or_unavailable' : /^[a-z_]+$/.test(error.message ?? '') ? error.message : 'connection_failed';
9
+ return new Error(`接入停在“${stage}”(${code})。已保留程序、身份与当前申请;网络恢复后重新运行同一 connect 命令或打开安装器继续,无需重新安装或解绑。`);
10
+ }
@@ -11,8 +11,18 @@ export function windowsServiceDefinition({ home, root, node, script, port, dbPat
11
11
  workingDirectory: root, logonType: 'InteractiveToken', runLevel: 'Limited', trigger: 'CurrentUserLogon',
12
12
  };
13
13
  }
14
- // COM Task Scheduler is built into Windows. Only this user's exact task is touched.
15
- export const windowsTaskSource = powershellPreamble + `
14
+ export const windowsTaskIdentitySource = `
15
+ function Resolve-TaskSid([string] $userId) {
16
+ try {
17
+ if ($userId -match '^S-1-') { return ([System.Security.Principal.SecurityIdentifier]::new($userId)).Value }
18
+ return ([System.Security.Principal.NTAccount]::new($userId)).Translate([System.Security.Principal.SecurityIdentifier]).Value
19
+ } catch { throw 'task_user_unresolved' }
20
+ }
21
+ `;
22
+ // COM may return account names even when the task was registered with a SID.
23
+ // Normalize both identities while retaining every action, scope and trigger check.
24
+ export const windowsTaskSource = powershellPreamble + windowsTaskIdentitySource + `
25
+ try {
16
26
  $scheduler = New-Object -ComObject 'Schedule.Service'
17
27
  $scheduler.Connect()
18
28
  $folder = $scheduler.GetFolder('\\')
@@ -26,15 +36,19 @@ function Assert-OurTask {
26
36
  if (-not $task) { throw 'task_missing' }
27
37
  $definition = $task.Definition
28
38
  $principal = $definition.Principal
29
- $principalSid = $principal.UserId
30
- if ($principal.UserId -ne $sid.Value) { $principalSid = ([System.Security.Principal.NTAccount]::new($principal.UserId)).Translate([System.Security.Principal.SecurityIdentifier]).Value }
39
+ $principalSid = Resolve-TaskSid $principal.UserId
31
40
  if ($principalSid -ne $sid.Value -or $principal.LogonType -ne 3 -or $principal.RunLevel -ne 0) { throw 'task_principal_changed' }
32
41
  if ($definition.Actions.Count -ne 1 -or $definition.Triggers.Count -ne 1) { throw 'task_changed' }
33
42
  $action = $definition.Actions.Item(1)
34
43
  $trigger = $definition.Triggers.Item(1)
35
- if ($action.Type -ne 0 -or $action.Path -ne $request.task.executable -or $action.Arguments -cne $request.task.arguments -or $action.WorkingDirectory -cne $request.task.workingDirectory -or $trigger.Type -ne 9 -or $trigger.UserId -ne $sid.Value) { throw 'task_action_changed' }
44
+ if ($action.Type -ne 0 -or $action.Path -ne $request.task.executable -or $action.Arguments -cne $request.task.arguments -or $action.WorkingDirectory -cne $request.task.workingDirectory -or $trigger.Type -ne 9 -or (Resolve-TaskSid $trigger.UserId) -ne $sid.Value) { throw 'task_action_changed' }
36
45
  }
37
46
  switch ($request.operation) {
47
+ 'start' {
48
+ Assert-OurTask
49
+ $null = $task.Run($null)
50
+ @{ started = $true } | ConvertTo-Json -Compress
51
+ }
38
52
  'install' {
39
53
  if ($task) { throw 'task_already_exists' }
40
54
  $definition = $scheduler.NewTask(0)
@@ -69,9 +83,24 @@ switch ($request.operation) {
69
83
  'remove' { Assert-OurTask; $folder.DeleteTask($request.task.taskName, 0); @{ removed = $true } | ConvertTo-Json -Compress }
70
84
  default { throw 'unknown_task_operation' }
71
85
  }
86
+ } catch {
87
+ $failure = $_.Exception
88
+ while ($failure.InnerException) { $failure = $failure.InnerException }
89
+ $known = @('task_missing','task_user_unresolved','task_principal_changed','task_changed','task_action_changed','task_already_exists','unknown_task_operation')
90
+ $code = 'windows_task_failed'
91
+ if ($_.Exception.Message -in $known) { $code = $_.Exception.Message }
92
+ @{ error = $code; hresult = $failure.HResult } | ConvertTo-Json -Compress
93
+ }
72
94
  `;
73
- export function windowsTask(operation, task) {
74
- if (process.platform !== 'win32') throw new Error('windows_only');
75
- try { return runPowerShell(windowsTaskSource, { operation, task }); }
76
- catch { throw new Error('Windows 当前用户任务操作失败。请检查任务计划程序、组策略或既有任务;未提权、改策略或覆盖任务。可改用 start 前台模式。'); }
95
+ export function windowsTask(operation, task, { platform = process.platform, run = runPowerShell } = {}) {
96
+ if (platform !== 'win32') throw new Error('windows_only');
97
+ let result;
98
+ try { result = run(windowsTaskSource, { operation, task }); }
99
+ catch { result = { error: 'windows_task_failed' }; }
100
+ if (result.error) {
101
+ const known = ['task_missing','task_user_unresolved','task_principal_changed','task_changed','task_action_changed','task_already_exists'];
102
+ const code = known.includes(result.error) ? result.error : 'windows_task_failed';
103
+ throw new Error(`Windows 后台任务检查失败(${code}${Number.isInteger(result.hresult) ? `,HRESULT ${result.hresult}` : ''})。已保留任务和数据;可在安装向导中选择前台继续。请查看本用户 Device Center 数据目录中的 service.log / service-error.log。`);
104
+ }
105
+ return result;
77
106
  }
package/server/index.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { installerCatalog, serveInstaller } from './installerPackages.mjs';
1
2
  import { createServer as createHttpServer } from 'node:http';
2
3
  import { readFileSync } from 'node:fs';
3
4
  import { readFile } from 'node:fs/promises';
@@ -14,7 +15,7 @@ import { createRemoteAgent, createRemoteClient } from './drivers/remote.mjs';
14
15
 
15
16
  const PROJECT_ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)));
16
17
  const MIME_TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png' };
17
- const PUBLIC_GUIDES = Object.freeze({ '/install/agent.md': 'docs/agent-install.md', '/install/trial.md': 'docs/friend-trial.md', '/install/remote-access.md': 'docs/remote-access.md', '/install/device-profiles.md': 'docs/device-profiles.md', '/install/troubleshooting.md': 'docs/troubleshooting.md' });
18
+ const PUBLIC_GUIDES = Object.freeze({ '/install/offline.md': 'docs/offline-install.md', '/install/agent.md': 'docs/agent-install.md', '/install/skill.md': 'docs/SKILL.md', '/install/cli.md': 'docs/cli.md', '/install/trial.md': 'docs/friend-trial.md', '/install/remote-access.md': 'docs/remote-access.md', '/install/device-profiles.md': 'docs/device-profiles.md', '/install/troubleshooting.md': 'docs/troubleshooting.md' });
18
19
  // Deployed archives carry release.json; npm and source installs report only the package version.
19
20
  function deployedVersion(releaseFile) {
20
21
  const version = JSON.parse(readFileSync(resolve(PROJECT_ROOT, 'package.json'), 'utf8')).version;
@@ -70,7 +71,7 @@ async function serveStatic(pathname, response) {
70
71
  } catch { sendJson(response, 404, { error: 'not_found' }); }
71
72
  }
72
73
 
73
- export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite', web = true, runtime = 'native', sshHome, sshProbe, publicOrigin, authFile, authFetch, cloudLinkFile, driverFetch, driverBundlePath = resolve(PROJECT_ROOT, 'driver-source.tar.gz'), releaseFile = resolve(PROJECT_ROOT, 'release.json') } = {}) {
74
+ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite', web = true, runtime = 'native', sshHome, sshProbe, publicOrigin, authFile, authFetch, cloudLinkFile, driverFetch, driverBundlePath = resolve(PROJECT_ROOT, 'driver-source.tar.gz'), releaseFile = resolve(PROJECT_ROOT, 'release.json'), installerDirectory = process.env.DEVICE_CENTER_INSTALLERS_DIR } = {}) {
74
75
  const cloudAccess = createCloudAccess({ publicOrigin, authFile, fetchImpl: authFetch });
75
76
  if (runtime === 'cloud' && !cloudAccess) throw new Error('cloud_authentication_required');
76
77
  const cloud = Boolean(cloudAccess);
@@ -95,15 +96,20 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
95
96
  const path = url.pathname;
96
97
  // Public liveness and deployed version only; no account, device or configuration data.
97
98
  if (path === '/api/health' && request.method === 'GET') return sendJson(response, 200, health);
98
- if (['/pair', '/src/pair.js', '/src/pair.css', '/src/pairing-handoff.js', '/src/api.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
99
- if (['/cli', '/src/cli-approval.js', '/src/cli-handoff.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
99
+ if (['/pair', '/src/pair.js', '/src/pair.css', '/src/pairing-handoff.js', '/src/pair-recovery.js', '/src/installed-cli.js', '/src/api.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
100
+ if (['/cli', '/src/cli-approval.js', '/src/cli-handoff.js', '/src/agent-setup.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
100
101
  // Public static installation reference only. No identity, configuration or owner API.
101
- if (['/start', '/start.html', '/favicon.svg', '/src/start.js', '/src/start.css', '/src/onboarding.js', '/src/i18n.js', '/src/i18n.css', '/src/messages.js', '/src/install-prompt.js', '/src/install-walkthrough.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
102
+ if (['/start', '/start.html', '/favicon.svg', '/src/start.js', '/src/install-packages.js', '/src/start.css', '/src/onboarding.js', '/src/i18n.js', '/src/i18n.css', '/src/messages.js', '/src/install-prompt.js', '/src/install-walkthrough.js', '/src/install-walkthrough.css'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
102
103
  if (Object.hasOwn(PUBLIC_GUIDES, path) && request.method === 'GET') {
103
104
  const body = await readFile(resolve(PROJECT_ROOT, PUBLIC_GUIDES[path]));
104
105
  response.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'content-security-policy': "default-src 'none'; frame-ancestors 'none'", 'referrer-policy': 'no-referrer' });
105
106
  return response.end(body);
106
107
  }
108
+ if (path === '/install/packages' && request.method === 'GET') return sendJson(response, 200, { packages: await installerCatalog(installerDirectory) });
109
+ if (path.startsWith('/install/packages/') && request.method === 'GET') {
110
+ if (await serveInstaller(installerDirectory, path.slice('/install/packages/'.length), response)) return;
111
+ return sendJson(response, 404, { error: 'installer_unavailable' });
112
+ }
107
113
  let accountRegistry;
108
114
  if (cloud) {
109
115
  const machineActions = { '/api/driver/pair/start': 'start', '/api/driver/pair/poll': 'poll', '/api/driver/report': 'report', '/api/driver/disconnect': 'disconnect' };
@@ -152,7 +158,7 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
152
158
  if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
153
159
  return sendJson(response, 200, accountRegistry.cli[cliOwnerActions[path]](await readJson(request)));
154
160
  }
155
- const ownerActions = { '/api/devices/profile': 'saveProfile', '/api/devices/collect': 'collect', '/api/drivers/pairing/lookup': 'lookup', '/api/drivers/pairing/approve': 'approve', '/api/drivers/revoke': 'revoke' };
161
+ const ownerActions = { '/api/devices/profile': 'saveProfile', '/api/devices/collect': 'collect', '/api/drivers/pairing/lookup': 'lookup', '/api/drivers/pairing/approve': 'approve', '/api/drivers/pairing/result': 'pairingResult', '/api/drivers/revoke': 'revoke' };
156
162
  if (Object.hasOwn(ownerActions, path) && request.method === 'POST') {
157
163
  if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
158
164
  return sendJson(response, 200, accountRegistry[ownerActions[path]](await readJson(request)));
@@ -0,0 +1,45 @@
1
+ import { promises as fs, constants } from 'node:fs';
2
+ import { resolve, basename, join } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { pipeline } from 'node:stream/promises';
5
+
6
+ const ID = /^device-center-[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+-(win32|darwin|linux)-(x64|arm64)-[a-f0-9]{12}$/;
7
+ const type = { win32: ['zip', 'application/zip'], darwin: ['dmg', 'application/x-apple-diskimage'], linux: ['sh', 'application/octet-stream'] };
8
+ export async function installerCatalog(directory) {
9
+ if (!directory) return [];
10
+ const root = resolve(directory);
11
+ const info = await fs.lstat(root).catch(() => null);
12
+ if (!info) return [];
13
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('invalid_installer_directory');
14
+ const packages = [];
15
+ for (const name of await fs.readdir(root)) {
16
+ if (!name.endsWith('.installer.json')) continue;
17
+ try {
18
+ if (!(await fs.lstat(join(root, name))).isFile()) continue;
19
+ const meta = JSON.parse(await fs.readFile(join(root, name), 'utf8'));
20
+ const match = ID.exec(meta.id);
21
+ if (meta.format !== 1 || !match || match[1] !== meta.platform || match[2] !== meta.arch || !type[meta.platform] || meta.filename !== `${meta.id}.${type[meta.platform][0]}` || name !== `${meta.filename}.installer.json` || basename(meta.filename) !== meta.filename || !/^[a-f0-9]{64}$/.test(meta.sha256 ?? '') || !Number.isSafeInteger(meta.bytes) || meta.bytes < 1 || !/^[a-f0-9]{40}$/.test(meta.sourceSha ?? '') || typeof meta.dirty !== 'boolean' || typeof meta.signed !== 'boolean' || typeof meta.notarized !== 'boolean') continue;
22
+ const file = await fs.lstat(join(root, meta.filename));
23
+ if (!file.isFile() || file.isSymbolicLink() || file.size !== meta.bytes) continue;
24
+ packages.push({ id: meta.id, filename: meta.filename, platform: meta.platform, arch: meta.arch, version: meta.version, bytes: meta.bytes, sha256: meta.sha256, sourceSha: meta.sourceSha, signed: meta.signed, notarized: meta.notarized, candidate: Boolean(meta.dirty || !meta.signed), url: `/install/packages/${meta.id}` });
25
+ } catch { /* Incomplete or invalid assets are never advertised as downloadable installers. */ }
26
+ }
27
+ return packages.sort((a, b) => a.filename.localeCompare(b.filename, 'en'));
28
+ }
29
+ export async function serveInstaller(directory, id, response) {
30
+ const meta = ID.test(id) ? (await installerCatalog(directory)).find(item => item.id === id) : null;
31
+ if (!meta) return false;
32
+ const file = join(resolve(directory), meta.filename);
33
+ // Pin the same regular file across verification and streaming, even if an archive is replaced.
34
+ const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
35
+ try {
36
+ const info = await handle.stat();
37
+ if (!info.isFile() || info.size !== meta.bytes) throw Object.assign(new Error('installer_integrity_failed'), { statusCode: 503 });
38
+ const digest = createHash('sha256');
39
+ for await (const bytes of handle.createReadStream({ start: 0, autoClose: false })) digest.update(bytes);
40
+ if (digest.digest('hex') !== meta.sha256) throw Object.assign(new Error('installer_integrity_failed'), { statusCode: 503 });
41
+ response.writeHead(200, { 'content-type': type[meta.platform][1], 'content-length': meta.bytes, 'content-disposition': `attachment; filename="${meta.filename}"`, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'content-security-policy': "default-src 'none'; frame-ancestors 'none'", 'referrer-policy': 'no-referrer' });
42
+ await pipeline(handle.createReadStream({ start: 0, autoClose: false }), response);
43
+ } finally { await handle.close(); }
44
+ return true;
45
+ }
@@ -120,7 +120,7 @@ export function createUnifiedAccess({ config, publicOrigin, now = Date.now, fetc
120
120
  legacyOwnerMapped: accountMode === 'personal' || config.legacyOwnerId !== undefined,
121
121
  resolvePrincipal,
122
122
  options(request) {
123
- return { provider: 'app-services', accountMode, accountLoginUrl: `${account.origin}/?login=1&loginNext=%2Fdashboard`, automaticContinue: readCookie(request, SIGNED_OUT) !== '1' };
123
+ return { provider: 'app-services', accountMode, accountLoginUrl: `${account.origin}/auth/connect?${new URLSearchParams({ product: 'device-center', parentOrigin: publicOrigin })}`, automaticContinue: readCookie(request, SIGNED_OUT) !== '1' };
124
124
  },
125
125
  async authenticated(request) {
126
126
  return Boolean(await resolvePrincipal(request));
@@ -0,0 +1,49 @@
1
+ import { agentInstallPlan, terminalInstallScript, OFFICIAL_CONTROL_ORIGIN } from './onboarding.js';
2
+ import { translate, getLocale, h } from './i18n.js';
3
+ import { installedCliCommand } from './installed-cli.js';
4
+
5
+ // Public instructions only. No installation, authentication or credential generation here.
6
+ export function agentSetupPrompt({ role = 'manager', locale = 'zh-CN', ...options } = {}) {
7
+ if (!['manager', 'device'].includes(role)) throw new Error('invalid_agent_role');
8
+ const plan = agentInstallPlan(options);
9
+ return translate(locale, 'agent.setup.prompt', {
10
+ skill: `${plan.controlOrigin ?? OFFICIAL_CONTROL_ORIGIN}/install/skill.md`,
11
+ role: translate(locale, `agent.setup.${role}`),
12
+ mode: translate(locale, plan.mode === 'local' ? 'guide.local' : plan.mode === 'official' ? 'guide.hosted' : 'guide.custom'),
13
+ origin: plan.controlOrigin ?? translate(locale, 'guide.local'),
14
+ os: plan.os === 'windows' ? 'Windows 11 / PowerShell' : 'macOS / Linux',
15
+ version: plan.version, port: new URL(plan.url).port,
16
+ run: translate(locale, plan.background ? 'prompt.background' : 'prompt.foreground'),
17
+ });
18
+ }
19
+
20
+ export function managerSetupPlan({ origin, os = 'posix', locale = 'zh-CN' } = {}) {
21
+ const options = { mode: origin === OFFICIAL_CONTROL_ORIGIN ? 'official' : 'self-hosted', ...(origin === OFFICIAL_CONTROL_ORIGIN ? {} : { origin }), os, background: true };
22
+ const plan = agentInstallPlan(options);
23
+ const authorization = installedCliCommand('auth login --minutes 60 --confirm', os);
24
+ const verify = installedCliCommand('devices list --cloud --json', os);
25
+ const install = terminalInstallScript(options);
26
+ // Keep the original installer's stop-on-existing-install and error semantics.
27
+ const script = os === 'windows'
28
+ ? `& {\n$ErrorActionPreference = 'Stop'\n${install}\n${authorization}\nif ($LASTEXITCODE -ne 0) { throw 'CLI authorization failed. Keep the existing installation and binding.' }\n${verify}\nif ($LASTEXITCODE -ne 0) { throw 'Cloud verification failed. Inspect auth status; do not rebind.' }\n}`
29
+ : `(\nset -eu\n${install}\n${authorization}\n${verify}\n)`;
30
+ return { prompt: agentSetupPrompt({ ...options, role: 'manager', locale }), script, authorization, verify, skillUrl: `${plan.controlOrigin}/install/skill.md` };
31
+ }
32
+
33
+ const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
34
+ const flowIcon = type => `<svg viewBox="0 0 32 24" aria-hidden="true">${type === 'browser' ? '<rect x="2" y="2" width="28" height="20" rx="2"/><path d="M2 7h28m-19 7 4 4 7-8"/>' : type === 'devices' ? '<rect x="2" y="2" width="19" height="13" rx="2"/><path d="M5 20h13m-7-5v5"/><rect x="24" y="9" width="6" height="13" rx="1"/>' : '<rect x="2" y="2" width="28" height="20" rx="2"/><path d="m7 8 5 4-5 4m9 0h8"/>'}</svg>`;
35
+
36
+ export function managerSetupMarkup({ origin, os = 'posix', method = 'agent', copied = false } = {}) {
37
+ if (!['agent', 'manual'].includes(method)) throw new Error('invalid_setup_method');
38
+ const plan = managerSetupPlan({ origin, os, locale: getLocale() }), manual = method === 'manual';
39
+ return `<section class="cli-setup" aria-labelledby="cli-setup-title"><h2 id="cli-setup-title">${h('cli.setup.start')}</h2><p>${h('cli.setup.body')}</p>
40
+ <div class="cli-setup-destination"><span>${h('cli.setup.destination')}</span><strong>${escape(origin)}</strong></div>
41
+ <div class="cli-setup-choices" role="group" aria-label="${h('guide.method.title')}">${[['agent','guide.method.agent'],['manual','guide.method.manual']].map(([value,key]) => `<button type="button" data-setup-method="${value}" aria-pressed="${method === value}">${h(key)}</button>`).join('')}</div>
42
+ <label class="cli-setup-os" for="cli-setup-os">${h('guide.os.title')}<select id="cli-setup-os"><option value="posix" ${os === 'posix' ? 'selected' : ''}>macOS / Linux</option><option value="windows" ${os === 'windows' ? 'selected' : ''}>Windows 11</option></select></label>
43
+ <button type="button" data-setup-copy="cli-setup-text">${h(manual ? 'guide.manual.copy' : 'cli.setup.copyAgent')}</button><p class="cli-setup-copy-status" role="status">${copied ? h(manual ? 'cli.setup.copiedManual' : 'cli.setup.copiedAgent') : ''}</p>
44
+ ${manual ? `<p>${h(os === 'windows' ? 'guide.windowsPrerequisites' : 'guide.manual.prerequisites')}</p>` : ''}<details class="cli-setup-preview"><summary>${h(manual ? 'guide.manual.preview' : 'guide.preview')}</summary><textarea id="cli-setup-text" readonly rows="${manual ? 8 : 5}" aria-label="${h(manual ? 'guide.manual.scriptLabel' : 'guide.instructions')}" spellcheck="false">${escape(manual ? plan.script : plan.prompt)}</textarea></details>
45
+ <ol class="cli-setup-flow"><li>${flowIcon('terminal')}<strong>01 · ${h(manual ? 'cli.setup.terminal' : 'cli.setup.agent')}</strong><span>${h(manual ? 'cli.setup.terminalBody' : 'cli.setup.agentBody')}</span></li><li>${flowIcon('browser')}<strong>02 · ${h('cli.setup.approval')}</strong><span>${h('cli.setup.approvalBody')}</span></li><li>${flowIcon('devices')}<strong>03 · ${h('cli.setup.ready')}</strong><span>${h(manual ? 'walk.cloudCheckBody' : 'cli.setup.readyBody')}</span></li></ol>
46
+ <p class="cli-setup-scope">${h('cli.setup.scope')}</p>
47
+ <details class="cli-setup-existing"><summary>${h('cli.setup.existing')}</summary><p>${h('cli.setup.existingBody')}</p><textarea id="cli-existing-auth" readonly rows="3" aria-label="${h('cli.setup.existing')}" spellcheck="false">${escape(plan.authorization)}</textarea><button type="button" class="secondary" data-setup-copy="cli-existing-auth">${h('cli.setup.copyAuth')}</button></details>
48
+ <div class="cli-setup-links"><a href="${escape(plan.skillUrl)}">${h('cli.setup.skill')} ↗</a><a href="/install/cli.md">${h('cli.setup.manual')} ↗</a></div></section>`;
49
+ }
package/src/api.js CHANGED
@@ -4,11 +4,12 @@ async function request(path, { timeoutMs = 12000, ...options } = {}) {
4
4
  const response = await fetch(path, { ...options, cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) });
5
5
  const payload = await response.json().catch(() => ({}));
6
6
  if (response.status === 401) { location.replace('/login?lang=' + getLocale()); throw new Error('authentication_required'); }
7
- if (!response.ok) throw new Error(payload.error ?? `http_${response.status}`);
7
+ if (!response.ok) throw Object.assign(new Error(payload.error ?? `http_${response.status}`), { statusCode: response.status });
8
8
  clearLoginNavigation();
9
9
  return payload;
10
10
  }
11
11
  export const api = {
12
+ browserPairingResult: browserTicket => request('/api/drivers/pairing/result', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ browserTicket }) }),
12
13
  lookupBrowserPairing: browserTicket => request('/api/drivers/pairing/lookup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ browserTicket }) }),
13
14
  approveBrowserPairing: input => request('/api/drivers/pairing/approve', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input) }),
14
15
  saveDeviceProfile: (profile) => request('/api/devices/profile', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(profile) }),
@@ -1,7 +1,9 @@
1
1
  import { captureCli, clearCli } from './cli-handoff.js';
2
- import { initLanguage, getLocale, h } from './i18n.js';
2
+ import { initLanguage, getLocale, h, t } from './i18n.js';
3
+ import { managerSetupMarkup } from './agent-setup.js';
3
4
  const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
4
5
  let handoff = captureCli(), request, phase = handoff ? 'loading' : 'missing', timer;
6
+ let setupOrigin, setup = { os: 'posix', method: 'agent', copied: false }, copyGeneration = 0;
5
7
  document.querySelector('link[href="/src/login.css"]').href = '/src/pair.css';
6
8
  document.body.innerHTML = `<header><a class="brand" href="/start" data-language-link><img src="/favicon.svg" alt="" width="26" height="26">Device Center</a><div class="language-picker" data-language-picker></div></header><main id="cli-root" aria-live="polite"></main><footer data-i18n="cli.boundary"></footer>`;
7
9
  document.querySelector('title').dataset.i18n = 'cli.title';
@@ -14,15 +16,37 @@ async function api(path, input) {
14
16
  return response.json();
15
17
  }
16
18
  function render() {
19
+ const titleKey = phase === 'manage' ? 'cli.setup.title' : 'cli.title';
20
+ document.querySelector('title').dataset.i18n = titleKey;
21
+ document.title = t(titleKey);
22
+ root.classList.toggle('cli-management', phase === 'manage');
17
23
  root.setAttribute('aria-busy', String(phase === 'loading' || phase === 'saving'));
18
24
  let body = '';
19
25
  if (phase === 'ready') body = `<p>${h('cli.intro')}</p><div class="device"><strong>${escape(request.name)}</strong><p>${escape(request.platform)} · ${h('cli.duration', { minutes: request.minutes })}</p></div><p>${h('pair.fingerprint')}</p><code class="fingerprint">${escape(request.fingerprint)}</code><ul><li>${h('cli.read')}</li><li>${h('cli.profile')}</li><li>${h('cli.collect')}</li></ul><p>${h('cli.boundary')}</p><button id="approve">${h('cli.approve')}</button><button id="deny" class="secondary">${h('pair.cancel')}</button>`;
20
26
  else if (phase === 'done') body = `<div class="status-mark" aria-hidden="true">✓</div><p>${h('cli.done')}</p><p>${h('pair.noReply')}</p><a class="primary" href="/cli?lang=${getLocale()}">${h('cli.manage')}</a>`;
21
- else if (phase === 'manage') body = `<p>${h('cli.manageBody')}</p>${request.grants.map(grant => `<div class="device"><strong>${escape(grant.name)}</strong><p>${escape(new Date(grant.expiresAt).toLocaleString(getLocale()))} · ${h(grant.authorized ? 'cli.active' : 'cli.inactive')}</p><code class="fingerprint">${escape(grant.fingerprint)}</code>${grant.authorized ? `<button data-revoke="${escape(grant.driverId)}" class="secondary">${h('cli.revoke')}</button>` : ''}</div>`).join('') || `<p>${h('cli.empty')}</p>`}`;
27
+ else if (phase === 'manage') body = `${managerSetupMarkup({ origin: setupOrigin, ...setup })}<details class="cli-grants"><summary>${h('cli.manage')}</summary><p>${h('cli.manageBody')}</p>${request.grants.map(grant => `<div class="device"><strong>${escape(grant.name)}</strong><p>${escape(new Date(grant.expiresAt).toLocaleString(getLocale()))} · ${h(grant.authorized ? 'cli.active' : 'cli.inactive')}</p><code class="fingerprint">${escape(grant.fingerprint)}</code>${grant.authorized ? `<button data-revoke="${escape(grant.driverId)}" class="secondary">${h('cli.revoke')}</button>` : ''}</div>`).join('') || `<p>${h('cli.empty')}</p>`}</details>`;
22
28
  else body = `<p>${h(['loading', 'saving'].includes(phase) ? 'pair.wait' : phase === 'cancelled' ? 'pair.cancelledBody' : 'cli.missing')}</p>`;
23
- root.innerHTML = `<p class="eyebrow">DEVICE CENTER / CLI</p><h1>${h(phase === 'manage' ? 'cli.manage' : 'cli.title')}</h1>${body}<p><a href="/?lang=${getLocale()}">${h('pair.dashboard')}</a></p>`;
29
+ root.innerHTML = `<p class="eyebrow">DEVICE CENTER / CLI</p><h1>${h(phase === 'manage' ? 'cli.setup.title' : 'cli.title')}</h1>${body}<p><a href="/?lang=${getLocale()}">${h('pair.dashboard')}</a></p>`;
24
30
  }
25
31
  root.addEventListener('click', async event => {
32
+ const button = event.target.closest('button');
33
+ if (phase === 'manage' && ['agent','manual'].includes(button?.dataset.setupMethod)) {
34
+ copyGeneration++; setup.method = button.dataset.setupMethod; setup.copied = false; render(); return;
35
+ }
36
+ if (phase === 'manage' && ['cli-setup-text','cli-existing-auth'].includes(button?.dataset.setupCopy)) {
37
+ const field = document.getElementById(button.dataset.setupCopy), generation = ++copyGeneration;
38
+ let copied = false;
39
+ try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(field.value); copied = true; } } catch { /* retain manual copy */ }
40
+ if (generation !== copyGeneration || phase !== 'manage') return;
41
+ if (!copied) {
42
+ field.closest('details')?.setAttribute('open',''); field.focus(); field.select();
43
+ try { copied = document.execCommand('copy'); } catch { /* selection remains available */ }
44
+ }
45
+ if (button.dataset.setupCopy === 'cli-existing-auth' || !copied) {
46
+ root.querySelector('.cli-setup-copy-status').textContent = t(copied ? 'panel.492' : 'panel.229');
47
+ } else { setup.copied = true; render(); }
48
+ return;
49
+ }
26
50
  const action = event.target.id, revoke = event.target.dataset.revoke;
27
51
  if (revoke && phase === 'manage') {
28
52
  phase = 'saving'; render();
@@ -38,12 +62,20 @@ root.addEventListener('click', async event => {
38
62
  } catch (error) { if (error.message === 'authentication_required') return; phase = 'missing'; }
39
63
  clearTimeout(timer); clearCli(); handoff = null; render();
40
64
  });
41
- initLanguage({ onChange: render }); render();
65
+ root.addEventListener('change', event => {
66
+ if (event.target.id === 'cli-setup-os' && ['posix','windows'].includes(event.target.value)) {
67
+ copyGeneration++; setup.os = event.target.value; setup.copied = false; render();
68
+ }
69
+ });
70
+ initLanguage({ onChange: () => { copyGeneration++; setup.copied = false; render(); } }); render();
42
71
  try {
43
72
  if (handoff) {
44
73
  request = await api('/api/cli/lookup', { ticket: handoff.ticket }); phase = 'ready';
45
74
  timer = setTimeout(() => { if (phase === 'ready') { clearCli(); handoff = null; phase = 'missing'; render(); } }, Math.max(0, request.expiresAt - Date.now()));
46
- } else { request = await api('/api/cli/grants'); phase = 'manage'; }
75
+ } else {
76
+ const [grants, overview] = await Promise.all([api('/api/cli/grants'), api('/api/overview')]);
77
+ request = grants; setupOrigin = overview.controlOrigin; phase = 'manage';
78
+ }
47
79
  render();
48
80
  } catch (error) { if (error.message !== 'authentication_required') { clearCli(); handoff = null; phase = 'missing'; render(); } }
49
81
  window.addEventListener('pagehide', () => clearTimeout(timer));
@@ -0,0 +1,34 @@
1
+ import { t } from './i18n.js';
2
+
3
+ const platforms = { windows: 'win32', macos: 'darwin', linux: 'linux' };
4
+ export function architectureChoices(os) {
5
+ const choices = { windows: [['x64', 'x64 (Intel / AMD)'], ['arm64', 'ARM64 (Windows on ARM)']], macos: [['arm64', 'Apple Silicon (ARM64)'], ['x64', 'Intel (x64)']], linux: [['x64', 'x86_64 (Intel / AMD)'], ['arm64', 'ARM64 (aarch64)']] };
6
+ if (!choices[os]) throw new Error('invalid_install_os');
7
+ return choices[os];
8
+ }
9
+ export function matchingPackages(packages, os, arch) {
10
+ return packages.filter(p => p.platform === platforms[os] && p.arch === arch &&
11
+ /^device-center-[\w.-]+$/.test(p.id ?? '') && p.url === `/install/packages/${p.id}` &&
12
+ /^device-center-[\w.-]+\.(zip|dmg|sh)$/.test(p.filename ?? '') && /^[a-f0-9]{64}$/.test(p.sha256 ?? ''));
13
+ }
14
+ export function renderPackageChoices(container, { packages, os, arch, loading = false, failed = false }) {
15
+ container.replaceChildren();
16
+ if (loading || failed || !matchingPackages(packages, os, arch).length) {
17
+ const p = document.createElement('p'); p.className = 'hint';
18
+ p.textContent = t(loading ? 'guide.package.loading' : failed ? 'guide.package.error' : 'guide.package.unavailable');
19
+ container.append(p); return;
20
+ }
21
+ for (const item of matchingPackages(packages, os, arch)) {
22
+ const card = document.createElement('div'); card.className = 'package-download';
23
+ const link = document.createElement('a'); link.className = 'primary'; link.href = item.url; link.download = item.filename;
24
+ link.textContent = t('guide.package.download', { format: item.filename.split('.').at(-1).toUpperCase(), arch: item.arch });
25
+ const name = document.createElement('p'); name.className = 'hint'; name.textContent = `${item.filename} · ${(item.bytes / 1048576).toFixed(1)} MiB`;
26
+ const digest = document.createElement('code'); digest.className = 'package-digest'; digest.textContent = `SHA-256: ${item.sha256}`;
27
+ card.append(link, name, digest);
28
+ const launch = document.createElement('p'); launch.className = 'hint';
29
+ launch.textContent = t(item.platform === 'darwin' && item.signed && item.notarized ? 'guide.package.macosApp' : item.platform === 'darwin' ? 'guide.package.macos' : item.platform === 'win32' ? 'guide.package.windows' : 'guide.package.linux');
30
+ card.append(launch);
31
+ if (item.candidate) { const notice = document.createElement('p'); notice.className = 'hint'; notice.textContent = t(item.platform === 'darwin' ? 'guide.package.candidate' : 'guide.package.unsigned'); card.append(notice); }
32
+ container.append(card);
33
+ }
34
+ }