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.
- package/README.md +11 -9
- package/docs/SKILL.md +55 -0
- package/docs/agent-install.md +16 -10
- package/docs/cli.md +9 -1
- package/docs/friend-trial.md +2 -2
- package/docs/hosted-access.md +8 -4
- package/docs/npm.md +8 -8
- package/docs/offline-install.md +57 -0
- package/docs/troubleshooting.md +9 -1
- package/docs/windows-acceptance.md +2 -2
- package/index.html +1 -0
- package/login.html +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +7 -3
- package/scripts/driver.mjs +57 -29
- package/scripts/service.mjs +18 -0
- package/server/drivers/cloudRegistry.mjs +19 -6
- package/server/drivers/connectionRecovery.mjs +10 -0
- package/server/drivers/windowsService.mjs +38 -9
- package/server/index.mjs +12 -6
- package/server/installerPackages.mjs +45 -0
- package/server/unifiedAccess.mjs +1 -1
- package/src/agent-setup.js +49 -0
- package/src/api.js +2 -1
- package/src/cli-approval.js +37 -5
- package/src/install-packages.js +34 -0
- package/src/install-walkthrough.css +79 -0
- package/src/install-walkthrough.js +1 -1
- package/src/installed-cli.js +6 -0
- package/src/login-flow.js +35 -0
- package/src/login.js +15 -20
- package/src/main.js +74 -27
- package/src/messages.js +590 -108
- package/src/onboarding.js +5 -5
- package/src/pair-recovery.js +4 -0
- package/src/pair.css +24 -0
- package/src/pair.js +37 -5
- package/src/pairing-handoff.js +7 -2
- package/src/start.css +13 -80
- package/src/start.js +32 -6
- package/src/styles.css +24 -0
- package/start.html +37 -16
package/scripts/driver.mjs
CHANGED
|
@@ -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
|
-
|
|
72
|
-
|
|
73
|
-
if (plan.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
123
|
+
pending = null;
|
|
124
|
+
delete state.pendingPairing; writeLink(file, state);
|
|
125
|
+
if (!await renew()) break;
|
|
98
126
|
}
|
|
99
|
-
throw new Error('
|
|
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)); }
|
package/scripts/service.mjs
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
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 (
|
|
75
|
-
|
|
76
|
-
|
|
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
|
+
}
|
package/server/unifiedAccess.mjs
CHANGED
|
@@ -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}
|
|
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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) }),
|
package/src/cli-approval.js
CHANGED
|
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 =
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
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
|
+
}
|