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/src/onboarding.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { localizedInstallPrompt } from './install-prompt.js';
2
2
  import { normalizeLocale } from './i18n.js';
3
+ import { installedCliCommand } from './installed-cli.js';
3
4
 
4
5
  export const OFFICIAL_CONTROL_ORIGIN = 'https://devices.owenshen.top';
5
6
  // Publish this exact CLI before deploying the matching guide.
6
- export const PUBLISHED_DRIVER_VERSION = '0.2.0-beta.6';
7
+ export const PUBLISHED_DRIVER_VERSION = '0.2.0-beta.8';
7
8
  const inputError = (key, message) => Object.assign(new Error(message), { translationKey: key });
8
9
  export function connectCommand(value, shareSsh = false) {
9
10
  let url;
@@ -16,7 +17,7 @@ export function connectCommand(value, shareSsh = false) {
16
17
 
17
18
  // A reproducible handoff, not an installer. Reading/copying it has no machine effects.
18
19
  export function agentInstallPlan({ mode = 'local', origin, port = 5174, background = true, os = 'posix' } = {}) {
19
- if (!['local', 'official', 'self-hosted'].includes(mode) || !['posix', 'windows'].includes(os) || !Number.isInteger(port) || port < 1024 || port > 65535 || typeof background !== 'boolean') throw inputError('error.install', '安装模式、系统或本机端口无效。');
20
+ if (!['local', 'official', 'self-hosted'].includes(mode) || !['posix', 'macos', 'linux', 'windows'].includes(os) || !Number.isInteger(port) || port < 1024 || port > 65535 || typeof background !== 'boolean') throw inputError('error.install', '安装模式、系统或本机端口无效。');
20
21
  const controlOrigin = mode === 'local' ? null : mode === 'official' ? OFFICIAL_CONTROL_ORIGIN : origin;
21
22
  if (controlOrigin) connectCommand(controlOrigin); // Same strict HTTPS and shell-safe origin validation.
22
23
  if (mode === 'self-hosted' && !controlOrigin) throw inputError('error.custom', '请输入自建控制面的 HTTPS 地址。');
@@ -69,7 +70,7 @@ export function terminalInstallScript({ browser = true, ...options } = {}) {
69
70
  const connect = c.connect && (browser ? c.connect : c.connect.replace(' --browser', ''));
70
71
  return `(
71
72
  set -eu
72
- case "$(uname -s)" in Darwin|Linux) ;; *) printf '%s\\n' 'Choose the Windows PowerShell installer for Windows; this block is macOS / Linux only.'; exit 1 ;; esac
73
+ case "$(uname -s)" in ${plan.os === 'macos' ? 'Darwin' : plan.os === 'linux' ? 'Linux' : 'Darwin|Linux'}) ;; *) printf '%s\\n' 'Choose the Windows PowerShell installer for Windows; this block is macOS / Linux only.'; exit 1 ;; esac
73
74
  command -v node >/dev/null 2>&1 && command -v npm >/dev/null 2>&1 || { printf '%s\\n' 'Install Node.js 24+ and npm from nodejs.org, then retry.'; exit 1; }
74
75
  node -e 'if (Number(process.versions.node.split(".")[0]) < 24) { console.error("Node.js 24+ required"); process.exit(1); }'
75
76
  if command -v device-center >/dev/null 2>&1 || [ -e "$HOME/.local/bin/device-center" ] || [ -L "$HOME/.local/bin/device-center" ] || [ -e "$HOME/.local/lib/node_modules/device-center" ]; then
@@ -105,6 +106,5 @@ export function logAccessCommands({ manager, target, path, label = 'app', os = '
105
106
  if (![os, managerOs].every(value => ['posix', 'windows'].includes(value)) || typeof path !== 'string' || !(os === 'windows' ? /^[A-Za-z]:\\/.test(path) && !/[<>:"|?*]/.test(path.slice(2)) : path.startsWith('/')) || path.length > 4096 || /[\x00-\x1f\x7f]/.test(path)) throw inputError('error.path', '填写目标设备上日志的绝对路径。');
106
107
  if (!/^[a-zA-Z0-9_-]{1,40}$/.test(label)) throw inputError('error.label', '标签仅允许字母、数字、下划线或连字符。');
107
108
  const quotedPath = "'" + path.replaceAll("'", os === 'windows' ? "''" : "'\"'\"'") + "'";
108
- const cli = system => system === 'windows' ? '& "$HOME\\AppData\\Local\\Programs\\Device Center\\cli\\device-center.cmd"' : 'device-center';
109
- return { trust: `${cli(managerOs)} access trust --peer ${target} --confirm`, grant: `${cli(os)} access grant --peer ${manager} --log ${quotedPath} --label ${label} --minutes 60 --confirm` };
109
+ return { trust: installedCliCommand(`access trust --peer ${target} --confirm`, managerOs), grant: installedCliCommand(`access grant --peer ${manager} --log ${quotedPath} --label ${label} --minutes 60 --confirm`, os) };
110
110
  }
@@ -0,0 +1,4 @@
1
+ export function pairingFailureKind(error) {
2
+ if (error.message === 'authentication_required') return 'login';
3
+ return ['driver_pairing_expired','driver_pairing_unavailable','invalid_pairing_code','driver_pairing_confirmation_required'].includes(error.message) || [400, 404, 410].includes(error.statusCode) ? 'expired' : 'retry';
4
+ }
package/src/pair.css CHANGED
@@ -20,4 +20,28 @@ button:focus-visible, a:focus-visible { outline: 2px solid #517543; outline-offs
20
20
  .error { color: #a13d32; }
21
21
  .status-mark { font-size: 32px; margin: 4px 0 20px; color: #517543; }
22
22
  footer { text-align: center; padding: 20px 16px; color: #777e72; font-size: 12px; }
23
+ main.cli-management { width: min(940px, calc(100% - 32px)); margin: 22px auto; }
24
+ .cli-setup h2 { font-size: 18px; margin: 28px 0 8px; }
25
+ .cli-setup-destination { padding: 12px 16px; border: 1px solid #e1e5dc; background: #f6f7f2; font-size: 12px; overflow-wrap: anywhere; }
26
+ .cli-setup-destination span { display: block; margin-bottom: 4px; color: #7c8577; }
27
+ .cli-setup-choices { display: flex; gap: 8px; margin: 20px 0 16px; }
28
+ .cli-setup-choices button { width: auto; flex: 1; margin: 0; background: #f3f4ee; color: #687563; border: 1px solid #dce2d6; }
29
+ .cli-setup-choices button[aria-pressed="true"] { background: #263d2e; color: white; border-color: #263d2e; }
30
+ .cli-setup-os { display: flex; align-items: center; gap: 14px; font-size: 12px; color: #65745f; }
31
+ .cli-setup-os select { padding: 8px 12px; border: 1px solid #dce2d6; background: white; color: #35472f; border-radius: 3px; }
32
+ .cli-setup-flow { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 12px; list-style: none; padding: 0; margin: 24px 0; }
33
+ .cli-setup-flow li { padding: 18px 14px; border: 1px solid #e2e7dc; background: #fafbf8; }
34
+ .cli-setup-flow svg { display: block; width: 32px; height: 24px; fill: none; stroke: #6b8b60; stroke-width: 1.4; margin-bottom: 14px; }
35
+ .cli-setup-flow strong { display: block; font-size: 13px; margin-bottom: 7px; color: #374d30; }
36
+ .cli-setup-flow span { font-size: 12px; line-height: 1.8; color: #7e8877; }
37
+ .cli-setup-scope { border-left: 3px solid #c6d7bb; padding-left: 14px; font-size: 12px; }
38
+ .cli-setup textarea { width: 100%; padding: 14px; border: 1px solid #dce2d6; border-radius: 3px; background: #fbfcf8; font: 12px/1.8 ui-monospace, Consolas, monospace; color: #43533b; resize: vertical; }
39
+ .cli-setup summary, .cli-grants summary { cursor: pointer; color: #526a48; font-size: 13px; }
40
+ .cli-setup-preview { margin-top: 20px; }
41
+ .cli-setup-preview textarea, .cli-setup-existing textarea { margin-top: 12px; }
42
+ .cli-setup-copy-status { min-height: 22px; margin: 8px 0; color: #547c40; font-size: 12px; }
43
+ .cli-setup-existing { margin: 20px 0; }
44
+ .cli-setup-links { display: flex; flex-wrap: wrap; gap: 10px 24px; font-size: 12px; color: #687f5c; }
45
+ .cli-grants { margin-top: 32px; padding-top: 22px; border-top: 1px solid #e3e6dc; }
46
+ @media (max-width: 640px) { .cli-setup-flow { grid-template-columns: 1fr; gap: 8px; } .cli-setup-flow li { display: grid; grid-template-columns: 32px 1fr; gap: 0 14px; } .cli-setup-flow svg { grid-row: span 2; margin: 2px 0; } .cli-setup-os { flex-wrap: wrap; } }
23
47
  @media (prefers-reduced-motion: no-preference) { main { animation: appear .2s ease-out; } @keyframes appear { from { opacity: 0; transform: translateY(5px); } } }
package/src/pair.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { capturePairing, clearPairing } from './pairing-handoff.js';
2
2
  import { api } from './api.js';
3
3
  import { initLanguage, getLocale, t, h } from './i18n.js';
4
+ import { pairingFailureKind } from './pair-recovery.js';
5
+ import { installedCliCommand } from './installed-cli.js';
4
6
 
5
7
  let handoff = capturePairing();
6
8
  document.querySelector('link[href="/src/login.css"]').href = '/src/pair.css';
@@ -18,8 +20,12 @@ function render() {
18
20
  else if (phase === 'waiting') root.innerHTML = heading('pair.bound') + `<p>${h('pair.reporting')}</p><p>${h('pair.noReply')}</p>${dashboard()}`;
19
21
  else if (phase === 'done') root.innerHTML = `<div class="status-mark" aria-hidden="true">✓</div>` + heading('pair.done') + `<p>${h('pair.doneBody')}</p><p>${h('pair.noReply')}</p>${dashboard()}`;
20
22
  else if (phase === 'report-timeout') root.innerHTML = heading('pair.bound') + `<p>${h('pair.reportTimeout')}</p>${dashboard()}`;
21
- else if (phase === 'approval-unknown') root.innerHTML = heading('pair.approvalUnknown') + `<p>${h('pair.approvalUnknownBody')}</p>${dashboard()}`;
22
- else root.innerHTML = heading(phase === 'cancelled' ? 'pair.cancelled' : 'pair.missing') + `<p>${h(phase === 'cancelled' ? 'pair.cancelledBody' : 'pair.missingBody')}</p><a class="primary" href="/start?lang=${getLocale()}">${h('pair.start')}</a>`;
23
+ else if (phase === 'approval-unknown') root.innerHTML = heading('pair.approvalUnknown') + `<p>${h('pair.approvalUnknownBody')}</p>${handoff ? `<button id="check-result">${h('pair.checkResult')}</button>` : ''}${dashboard()}`;
24
+ else if (phase === 'retry') root.innerHTML = heading('pair.retryTitle') + `<p>${h('pair.retryBody')}</p><button id="retry">${h('pair.retry')}</button>`;
25
+ else {
26
+ const command = installedCliCommand(`connect --cloud '${location.origin}' --port 5174 --browser --confirm`, navigator.userAgent.includes('Windows') ? 'windows' : 'posix');
27
+ root.innerHTML = heading(phase === 'cancelled' ? 'pair.cancelled' : 'pair.missing') + `<p>${h(phase === 'cancelled' ? 'pair.cancelledBody' : 'pair.resumeBody')}</p>${phase === 'cancelled' ? '' : `<details><summary>${h('pair.resumeCommand')}</summary><p>${h('pair.targetTerminal')}</p><textarea readonly rows="5" aria-label="${h('pair.resumeCommand')}">${escape(command)}</textarea></details>`}<a class="primary" href="/start?lang=${getLocale()}">${h('pair.start')}</a>`;
28
+ }
23
29
  }
24
30
  async function watchReport(driverId, deadline) {
25
31
  try {
@@ -31,6 +37,22 @@ async function watchReport(driverId, deadline) {
31
37
  }
32
38
  root.addEventListener('change', event => { if (event.target.id === 'share') shareSsh = event.target.checked; });
33
39
  root.addEventListener('click', async event => {
40
+ if (event.target.id === 'retry' && phase === 'retry') { await loadPairing(); return; }
41
+ if (event.target.id === 'check-result' && phase === 'approval-unknown' && handoff) {
42
+ phase = 'loading'; render();
43
+ try {
44
+ const result = await api.browserPairingResult(handoff.ticket);
45
+ if (result.state === 'approved') { clearPairing(); handoff = null; phase = 'waiting'; render(); void watchReport(result.driverId, Date.now() + 90_000); }
46
+ else if (result.state === 'pending') await loadPairing();
47
+ else { phase = 'approval-unknown'; render(); }
48
+ } catch (cause) {
49
+ if (pairingFailureKind(cause) === 'login') return;
50
+ if (pairingFailureKind(cause) === 'expired') { clearPairing(); handoff = null; phase = 'missing'; }
51
+ else phase = 'approval-unknown';
52
+ render();
53
+ }
54
+ return;
55
+ }
34
56
  if (event.target.id === 'cancel' && phase === 'ready') { clearTimeout(expiresTimer); clearPairing(); handoff = null; phase = 'cancelled'; render(); return; }
35
57
  if (event.target.id !== 'approve' || phase !== 'ready' || !handoff) return;
36
58
  phase = 'approving'; render();
@@ -42,19 +64,29 @@ root.addEventListener('click', async event => {
42
64
  if (cause.message === 'authentication_required') return;
43
65
  if (['driver_rate_limited', 'driver_limit'].includes(cause.message)) { phase = 'ready'; error = cause.message === 'driver_limit' ? 'panel.386' : 'panel.385'; }
44
66
  else if (['driver_pairing_expired', 'driver_pairing_unavailable', 'invalid_pairing_code', 'driver_pairing_confirmation_required'].includes(cause.message)) { clearPairing(); handoff = null; phase = 'missing'; }
45
- else { clearTimeout(expiresTimer); clearPairing(); handoff = null; phase = 'approval-unknown'; }
67
+ else { clearTimeout(expiresTimer); phase = 'approval-unknown'; }
46
68
  render();
47
69
  }
48
70
  });
49
71
  initLanguage({ onChange: render });
50
72
  render();
51
- if (handoff) {
73
+ async function loadPairing() {
74
+ if (!handoff || handoff.expiresAt <= Date.now()) { clearPairing(); handoff = null; phase = 'missing'; render(); return; }
75
+ clearTimeout(expiresTimer); phase = 'loading'; render();
52
76
  try {
53
77
  pairing = await api.lookupBrowserPairing(handoff.ticket);
54
78
  phase = 'ready'; render();
55
79
  expiresTimer = setTimeout(() => { if (phase !== 'ready') return; clearPairing(); handoff = null; phase = 'missing'; render(); }, Math.max(0, pairing.expiresAt - Date.now()));
56
80
  } catch (cause) {
57
- if (cause.message !== 'authentication_required') { clearPairing(); handoff = null; phase = 'missing'; render(); }
81
+ const kind = pairingFailureKind(cause);
82
+ if (kind === 'login') return;
83
+ if (kind === 'expired' && cause.message === 'driver_pairing_unavailable') {
84
+ phase = 'approval-unknown'; render(); return;
85
+ }
86
+ if (kind === 'expired') { clearPairing(); handoff = null; phase = 'missing'; }
87
+ else phase = 'retry';
88
+ render();
58
89
  }
59
90
  }
91
+ if (handoff) await loadPairing();
60
92
  window.addEventListener('pagehide', () => { clearTimeout(timer); clearTimeout(expiresTimer); });
@@ -1,5 +1,6 @@
1
1
  import { pendingCli, clearCli } from './cli-handoff.js';
2
2
  const KEY = 'device-center-pairing-v1';
3
+ const EXPIRED = 'device-center-pairing-expired';
3
4
  const safeStorage = () => { try { return window.sessionStorage; } catch { return null; } };
4
5
  const valid = (value, now) => value && /^[a-f0-9]{64}$/.test(value.ticket ?? '') && Number.isSafeInteger(value.expiresAt) && value.expiresAt > now && value.expiresAt <= now + 330_000;
5
6
 
@@ -7,11 +8,15 @@ export function pendingPairing({ storage = safeStorage(), now = Date.now() } = {
7
8
  try {
8
9
  const value = JSON.parse(storage.getItem(KEY));
9
10
  if (valid(value, now)) return value;
11
+ if (/^[a-f0-9]{64}$/.test(value?.ticket ?? '') && Number.isSafeInteger(value?.expiresAt) && value.expiresAt <= now && now - value.expiresAt < 600_000) storage.setItem(EXPIRED, String(now));
10
12
  storage.removeItem(KEY);
11
13
  } catch { /* No token in error messages. */ }
12
14
  return null;
13
15
  }
14
- export function clearPairing({ storage = safeStorage() } = {}) { try { storage.removeItem(KEY); } catch { /* memory is cleared by the caller */ } }
16
+ export function clearPairing({ storage = safeStorage() } = {}) { try { storage.removeItem(KEY); storage.removeItem(EXPIRED); } catch { /* memory is cleared by the caller */ } }
17
+ export function recentlyExpiredPairing({ storage = safeStorage(), now = Date.now() } = {}) {
18
+ try { const at = Number(storage.getItem(EXPIRED)); return at > 0 && at <= now && now - at < 600_000; } catch { return false; }
19
+ }
15
20
 
16
21
  export function capturePairing({ location = window.location, history = window.history, storage = safeStorage(), now = Date.now() } = {}) {
17
22
  if (!location.hash) return pendingPairing({ storage, now });
@@ -30,5 +35,5 @@ export function capturePairing({ location = window.location, history = window.hi
30
35
  export function loginDestination(locale, { search = globalThis.window?.location?.search ?? '' } = {}) {
31
36
  // Only this fixed management page can be selected without an active handoff.
32
37
  // Never accept an arbitrary return URL from the login query.
33
- return `${pendingCli() ? '/cli' : pendingPairing() ? '/pair' : new URLSearchParams(search).get('continue') === 'cli' ? '/cli' : '/'}?lang=${encodeURIComponent(locale)}`;
38
+ return `${pendingCli() ? '/cli' : pendingPairing() || recentlyExpiredPairing() ? '/pair' : new URLSearchParams(search).get('continue') === 'cli' ? '/cli' : '/'}?lang=${encodeURIComponent(locale)}`;
34
39
  }
package/src/start.css CHANGED
@@ -1,3 +1,4 @@
1
+ @import url('./install-walkthrough.css');
1
2
  :root { color-scheme: dark; font-family: "Helvetica Neue", "Arial", "PingFang SC", "Hiragino Sans GB", sans-serif; color: #eeece8; background: #0c0c0e; font-synthesis: none; --accent: #ff8254; --line: #2b2b2e; --muted: #9a999f; --mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; }
2
3
  * { box-sizing: border-box; }
3
4
  html { scroll-behavior: smooth; scroll-padding-top: 108px; }
@@ -110,7 +111,7 @@ h3 { margin: 0; color: #e8e1db; font-size: 16px; font-weight: 500; line-height:
110
111
  .copy-step { margin-top: 23px; padding-top: 21px; border-top: 1px solid #363034; }
111
112
  .copy-step .action-heading { margin-bottom: 13px; }
112
113
  .method-options, .pairing-options { min-width: 0; padding: 0; margin: 0; border: 0; }
113
- .method-switch { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; padding: 4px; margin-bottom: 16px; border: 1px solid #373337; border-radius: 5px; background: #111113; }
114
+ .method-switch { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; padding: 4px; margin-bottom: 16px; border: 1px solid #373337; border-radius: 5px; background: #111113; }
114
115
  .method-switch label { position: relative; min-width: 0; cursor: pointer; }
115
116
  .method-switch input { position: absolute; width: 1px; height: 1px; padding: 0; margin: 0; opacity: 0; }
116
117
  .method-switch span { display: block; padding: 9px 12px; border: 1px solid transparent; border-radius: 3px; color: #a79892; font-size: 13px; text-align: center; line-height: 1.6; }
@@ -140,85 +141,7 @@ h3 { margin: 0; color: #e8e1db; font-size: 16px; font-weight: 500; line-height:
140
141
  .next-action[data-copied="true"] { border-color: #bc7a56; background: #241b16; }
141
142
  .next-action[data-copied="true"] .action-heading, .next-action[data-copied="true"] .action-number { color: #ffc29b; }
142
143
  .binding-link { display: inline-block; margin: 13px 0 0; color: #f4b991; font-size: 12px; line-height: 1.7; }
143
- .walkthrough-tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px; margin: 0 0 16px; padding: 0; list-style: none; }
144
- .walkthrough-tabs li { min-width: 0; }
145
- .walkthrough-tabs button { width: 100%; min-height: 38px; display: flex; align-items: center; justify-content: center; gap: 7px; padding: 7px; border: 1px solid #493a33; border-radius: 3px; background: transparent; color: #a9968a; font: inherit; font-size: 12px; line-height: 1.5; cursor: pointer; }
146
- .walkthrough-tabs button span { color: #86756a; font: 10px var(--mono); }
147
- .walkthrough-tabs button:hover { border-color: #bc7a56; color: #f4c8ab; }
148
- .walkthrough-tabs button[aria-current="step"] { background: #3d2a20; border-color: #c48d68; color: #ffcfb1; }
149
- .walkthrough-tabs button[aria-current="step"] span { color: #ffc29b; }
150
- .walkthrough-caption { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; margin-bottom: 10px; color: #f4dfd0; font-size: 14px; line-height: 1.7; }
151
- .walkthrough-caption small { flex-shrink: 0; color: #a48c7c; font: 11px var(--mono); }
152
- .walkthrough-picture { overflow: hidden; padding: 16px 18px 14px; border: 1px solid #3b302a; border-radius: 5px; background: #121011; }
153
- .walkthrough-picture svg { width: 22px; height: 22px; flex-shrink: 0; fill: none; stroke: currentColor; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; }
154
- .picture-transfer { display: flex; align-items: center; justify-content: center; gap: 18px; margin-bottom: 16px; color: #dfb394; font-size: 12px; line-height: 1.7; }
155
- .picture-transfer > span { display: flex; align-items: center; gap: 8px; min-width: 0; }
156
- .picture-transfer > svg { width: 28px; color: #b58160; }
157
- .picture-window { min-width: 0; border: 1px solid #5a4940; border-radius: 7px; background: #1b1818; box-shadow: 0 8px 20px #0003; }
158
- .picture-titlebar { display: flex; align-items: center; gap: 5px; min-height: 28px; padding: 6px 12px; border-bottom: 1px solid #3e3430; background: #252020; border-radius: 6px 6px 0 0; }
159
- .picture-titlebar > i { flex-shrink: 0; width: 6px; height: 6px; border: 1px solid #887369; border-radius: 50%; }
160
- .picture-titlebar > i:first-child { background: #956e5e; }
161
- .picture-titlebar > span { min-width: 0; margin-left: 9px; color: #baa599; font-size: 10px; line-height: 1.6; }
162
- .picture-address { display: block; overflow-wrap: anywhere; font: 10px var(--mono); }
163
- .picture-terminal { padding: 22px 18px 16px; }
164
- .picture-paste-line { display: flex; align-items: center; gap: 12px; position: relative; color: #ffb98a; font: 12px var(--mono); }
165
- .picture-paste-line > div { flex: 1; padding: 14px 12px; border: 1px dashed #db9365; border-radius: 3px; background: #35261e; color: #ffcfaf; line-height: 1.8; }
166
- .picture-caret { display: inline-block; height: 15px; margin-left: 9px; border-left: 2px solid #ffa875; vertical-align: middle; }
167
- .walkthrough-picture .picture-pointer { position: absolute; right: 6px; bottom: -16px; width: 25px; height: 30px; fill: #f5e9df; stroke: #151112; stroke-width: 1.8; transform: rotate(-9deg); }
168
- .picture-keyline { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-top: 26px; color: #c0a596; font-size: 10px; line-height: 1.8; }
169
- .picture-keyline strong { color: #ffd5b9; font-weight: 400; }
170
- .walkthrough-picture kbd { display: inline-block; margin-left: 3px; padding: 2px 6px; border: 1px solid #655045; border-bottom-width: 2px; border-radius: 3px; background: #28201d; font: 10px var(--mono); }
171
- .picture-launch { display: flex; flex-wrap: wrap; gap: 5px 18px; margin-top: 15px; color: #a38c7e; font-size: 10px; line-height: 1.8; }
172
- .picture-chat { padding: 22px 18px 16px; }
173
- .picture-chat-message { width: 88%; margin-left: auto; padding: 21px 15px; border: 1px dashed #d08b64; border-radius: 9px 9px 2px 9px; background: #37271e; color: #ffd3b5; font-size: 13px; line-height: 1.8; }
174
- .picture-send { position: relative; display: flex; align-items: center; justify-content: flex-end; gap: 10px; margin-top: 19px; padding-right: 9px; color: #d8bca9; font-size: 11px; }
175
- .picture-send b { display: grid; place-items: center; width: 29px; height: 29px; background: #e8b08b; border-radius: 50%; color: #241912; font-size: 19px; font-weight: 400; }
176
- .picture-send .picture-pointer { bottom: -9px; right: 0; width: 20px; }
177
- .picture-auto-open { margin-bottom: 14px; color: #c8a88f; text-align: center; font-size: 11px; line-height: 1.7; }
178
- .picture-pair { padding: 12px 16px; }
179
- .picture-signin { display: flex; align-items: baseline; gap: 9px; color: #d4b7a4; font-size: 11px; line-height: 1.7; }
180
- .picture-signin > span { color: #c29170; font: 9px var(--mono); }
181
- .picture-signin + .picture-signin { margin-top: 8px; }
182
- .picture-fingerprints { display: grid; grid-template-columns: auto minmax(0, 1fr) 20px; gap: 6px 12px; padding: 10px; margin: 10px 0 13px; border: 1px solid #65503f; border-radius: 3px; background: #29211c; font-size: 10px; }
183
- .picture-fingerprints > span { color: #bca08b; }
184
- .picture-fingerprints code { overflow-wrap: anywhere; color: #ffd1a9; font: 10px/1.7 var(--mono); }
185
- .picture-fingerprints > b { grid-column: 3; grid-row: 1 / span 2; align-self: center; justify-self: center; color: #e9b07d; font-size: 20px; font-weight: 400; line-height: .7; }
186
- .picture-approval { position: relative; width: fit-content; max-width: 100%; margin: 0 0 5px auto; padding: 8px 12px; border: 1px solid #c58c63; border-radius: 3px; background: #4a3222; color: #ffcfaa; font-size: 11px; line-height: 1.7; }
187
- .picture-approval .picture-pointer { right: -7px; bottom: -15px; width: 21px; }
188
- .headless-transfer { gap: 13px; font-size: 11px; }
189
- .headless-transfer > span:first-child { flex-direction: column; align-items: flex-start; gap: 6px; }
190
- .headless-transfer code { padding: 4px 7px; border: 1px dashed #a57958; color: #ffd0a9; font: 10px/1.7 var(--mono); }
191
- .picture-code-entry { margin: 10px 0 12px; color: #b49a89; font-size: 10px; }
192
- .picture-code-entry > div { position: relative; margin-top: 6px; padding: 9px 12px; border: 1px dashed #b7825f; border-radius: 3px; color: #ffd1b0; background: #30231c; }
193
- .picture-install { padding: 21px 20px; }
194
- .picture-install-command { margin-bottom: 19px; color: #e4c5af; font: 13px var(--mono); }
195
- .picture-install-command > span { color: #ac856a; }
196
- .picture-install-row { display: flex; align-items: baseline; gap: 12px; margin: 10px 0; color: #cfb5a3; font-size: 12px; }
197
- .picture-install-row > span { color: #a7866f; font: 10px var(--mono); }
198
- .picture-install-track { display: flex; gap: 5px; margin-top: 21px; }
199
- .picture-install-track i { flex: 1; height: 3px; background: #8b644b; }
200
- .picture-install-track i:nth-child(2) { background: #5e473a; }
201
- .picture-install-track i:nth-child(3) { background: #392d26; }
202
- .picture-dashboard { padding: 20px 18px; }
203
- .picture-device { display: grid; grid-template-columns: 24px minmax(0, 1fr); align-items: center; gap: 4px 12px; color: #d4aa8a; }
204
- .picture-device svg { grid-row: span 2; }
205
- .picture-device strong { color: #f1d1b9; font-size: 13px; font-weight: 500; }
206
- .picture-device > span { color: #a38b7a; font-size: 10px; }
207
- .picture-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; margin: 24px 0 20px; }
208
- .picture-metrics > div { display: grid; gap: 8px; }
209
- .picture-metrics span { color: #b29c8c; font-size: 11px; }
210
- .picture-metrics b { color: #ceb39e; font: 14px var(--mono); }
211
- .picture-metrics i { height: 3px; background: #4b3b30; }
212
- .picture-report-time { position: relative; padding: 10px 12px; border: 1px dashed #9f7555; color: #ebc3a2; font-size: 11px; line-height: 1.8; }
213
- .picture-report-time .picture-pointer { right: 3px; bottom: -14px; width: 21px; }
214
- .walkthrough-description { margin: 12px 0 0; color: #c5ad9d; font-size: 12px; line-height: 1.9; }
215
- .walkthrough-copy-first { margin: 8px 0 0; color: #aa9382; font-size: 11px; line-height: 1.8; }
216
- .walkthrough-navigation { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 12px; padding-top: 10px; border-top: 1px solid #49362b; }
217
- .walkthrough-navigation button { min-height: 30px; padding: 4px 0; border: 0; background: transparent; color: #f3bd94; font: inherit; font-size: 12px; line-height: 1.7; cursor: pointer; }
218
- .walkthrough-navigation button:hover { color: #ffe0c6; text-decoration: underline; text-underline-offset: 4px; }
219
- .walkthrough-navigation button:disabled { color: #766155; cursor: default; text-decoration: none; }
220
- .walkthrough-tabs button:focus-visible, .walkthrough-navigation button:focus-visible, #walkthrough-title:focus-visible { outline: 2px solid #ffc29b; outline-offset: 4px; }
221
- @media (max-width: 700px) { .walkthrough-tabs { gap: 4px; margin-bottom: 16px; } .walkthrough-tabs button { flex-direction: column; gap: 4px; padding: 9px 5px; font-size: 11px; } .walkthrough-caption { font-size: 13px; gap: 8px; } .walkthrough-picture { padding: 17px 12px 15px; } .picture-terminal { padding: 23px 12px 15px; } .picture-paste-line { gap: 8px; font-size: 11px; } .picture-paste-line > div { padding: 12px 10px; } .picture-transfer { gap: 13px; font-size: 11px; } .picture-titlebar { padding: 8px; } .picture-titlebar > span { margin-left: 5px; } .picture-keyline { gap: 9px; } .picture-pair { padding: 15px 12px; } .picture-fingerprints { gap: 6px 8px; padding: 10px; } .picture-fingerprints code { font-size: 9px; } .picture-dashboard { padding: 18px 12px; } .picture-metrics { gap: 18px; } .walkthrough-description { font-size: 11px; } .headless-transfer { gap: 8px; font-size: 10px; } .headless-transfer code { font-size: 9px; } }
144
+
222
145
  summary { color: #a2938b; cursor: pointer; font-size: 11px; line-height: 1.8; list-style: none; }
223
146
  summary::-webkit-details-marker { display: none; }
224
147
  summary::before { content: "+"; display: inline-block; margin-right: 9px; color: #7e716a; font: 14px var(--mono); }
@@ -265,3 +188,13 @@ footer span { width: 100%; color: #8c807a; }
265
188
  @media (max-width: 1000px) { section.install-section { gap: 30px; } .choices { grid-template-columns: 1fr; } .mode-choice { padding: 14px; } .section-intro { font-size: 13px; } }
266
189
  @media (max-width: 700px) { .returning-user { display: none; } .cta-hint { max-width: 150px; font-size: 11px; } section.install-section { grid-template-columns: 1fr; gap: 25px; padding: 34px 0 30px; } .section-heading .eyebrow { margin-bottom: 10px; } .section-heading h2 { font-size: 26px; } .section-intro { max-width: 380px; margin-top: 11px; font-size: 13px; } .section-note { margin-top: 8px; } .choices { gap: 10px; } .mode-choice { padding: 15px; } .choice-copy strong { font-size: 15px; } .choice-copy > span { margin-top: 2px; font-size: 12px; } .copy-step { margin-top: 20px; padding-top: 20px; } .next-action { padding: 17px 15px; margin-top: 20px; } .action-heading, .mode-options legend { font-size: 14px; gap: 10px; } .copy-hint { font-size: 11px; } }
267
190
  @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } *, *::before, *::after { animation: none !important; transition: none !important; } .motion-toggle { display: none; } .scene-stage { transform: none !important; } }
191
+
192
+ .package-architecture { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin: 16px 0; color: #e8e1db; }
193
+ .package-architecture select { padding: 8px; color: #e8e1db; background: #191719; border: 1px solid #74503d; border-radius: 4px; }
194
+ .package-download { margin: 16px 0; padding-bottom: 12px; border-bottom: 1px solid #373337; }
195
+ .package-download .primary { display: flex; align-items: center; justify-content: center; min-height: 44px; padding: 10px 16px; border-radius: 4px; font-size: 14px; text-decoration: none; }
196
+ .package-digest { display: block; overflow-wrap: anywhere; font-size: 11px; line-height: 1.6; color: #a79892; }
197
+ @media (max-width: 480px) { .method-switch span { padding: 9px 4px; font-size: 12px; } }
198
+ .action-workbench > #os-options { margin-bottom: 22px; }
199
+ .package-followup { margin: 20px 0; padding-left: 22px; color: #c9bdb4; font-size: 13px; line-height: 1.9; }
200
+ .package-followup li { padding-left: 4px; margin: 8px 0; }
package/src/start.js CHANGED
@@ -1,4 +1,6 @@
1
- import { agentInstallPrompt, agentInstallPlan, terminalInstallScript, OFFICIAL_CONTROL_ORIGIN } from './onboarding.js';
1
+ import { renderPackageChoices, architectureChoices } from './install-packages.js';
2
+ import { agentInstallPlan, terminalInstallScript, OFFICIAL_CONTROL_ORIGIN } from './onboarding.js';
3
+ import { agentSetupPrompt } from './agent-setup.js';
2
4
  import { initLanguage, getLocale, t } from './i18n.js';
3
5
  import { installWalkthroughMarkup } from './install-walkthrough.js';
4
6
 
@@ -7,9 +9,13 @@ const result = document.querySelector('#copy-result');
7
9
  const customOrigin = document.querySelector('#custom-origin');
8
10
  // Shared links only preselect UI choices; strict plan validation still gates copying.
9
11
  const guideParams = new URLSearchParams(location.search);
10
- let mode = ['local', 'official', 'self-hosted'].includes(guideParams.get('mode')) ? guideParams.get('mode') : 'local';
11
- let method = guideParams.get('method') === 'manual' ? 'manual' : 'agent';
12
- let os = guideParams.get('os') === 'windows' ? 'windows' : 'posix';
12
+ let mode = ['local', 'official', 'self-hosted'].includes(guideParams.get('mode')) ? guideParams.get('mode') : 'official';
13
+ let method = ['agent', 'manual', 'package'].includes(guideParams.get('method')) ? guideParams.get('method') : 'package';
14
+ let os = ['windows', 'macos', 'linux'].includes(guideParams.get('os')) ? guideParams.get('os') : navigator.userAgent.includes('Linux') ? 'linux' : navigator.userAgent.includes('Windows') ? 'windows' : 'macos';
15
+ let packageArch = os === 'macos' ? 'arm64' : 'x64';
16
+ let packages = [];
17
+ let packagesLoading = true;
18
+ let packagesFailed = false;
13
19
  let pairingMethod = 'browser';
14
20
  customOrigin.value = mode === 'self-hosted' ? guideParams.get('origin') ?? '' : '';
15
21
  for (const input of document.querySelectorAll('input[name="install-mode"]')) input.checked = input.value === mode;
@@ -59,6 +65,16 @@ function translatedText(selector, key) {
59
65
  }
60
66
  function updateGuidance() {
61
67
  const manual = method === 'manual';
68
+ document.querySelector('.mode-options').hidden = method === 'package';
69
+ document.querySelector('#windows-candidate').hidden = os !== 'windows';
70
+ document.querySelector('#package-options').hidden = method !== 'package';
71
+ document.querySelector('#copy-options').hidden = method === 'package';
72
+ document.querySelector('#next-action').hidden = method === 'package';
73
+ const select = document.querySelector('#package-arch');
74
+ select.replaceChildren(...architectureChoices(os).map(([value, label]) => new Option(label, value)));
75
+ select.value = packageArch;
76
+ translatedText('#package-run', `guide.package.archHelp.${os}`);
77
+ renderPackageChoices(document.querySelector('#package-downloads'), { packages, os, arch: packageArch, loading: packagesLoading, failed: packagesFailed });
62
78
  translatedText('#copy-label', manual ? 'guide.manual.copy' : { local: 'guide.localCopy', official: 'guide.hostedCopy', 'self-hosted': 'guide.customCopy' }[mode]);
63
79
  translatedText('#copy-hint', manual ? 'guide.manual.copyHint' : 'guide.focus.copyHint');
64
80
  translatedText('#instruction-preview', manual ? 'guide.manual.preview' : 'guide.preview');
@@ -80,7 +96,7 @@ function refreshInstructions() {
80
96
  let plan;
81
97
  try {
82
98
  plan = agentInstallPlan(options);
83
- text.value = method === 'manual' ? terminalInstallScript({ ...options, browser: pairingMethod === 'browser' }) : agentInstallPrompt(options);
99
+ text.value = method === 'manual' ? terminalInstallScript({ ...options, browser: pairingMethod === 'browser' }) : agentSetupPrompt({ ...options, role: mode === 'local' ? 'manager' : 'device' });
84
100
  instructionsValid = true;
85
101
  customOrigin.removeAttribute('aria-invalid');
86
102
  const url = new URL('/login', plan.controlOrigin ?? OFFICIAL_CONTROL_ORIGIN);
@@ -95,7 +111,7 @@ function refreshInstructions() {
95
111
  result.textContent = t(errorKey(error));
96
112
  result.className = 'error';
97
113
  }
98
- document.querySelector('#install-walkthrough').innerHTML = installWalkthroughMarkup({ method, mode, os, pairingMethod, step: walkthroughStep, copied, controlOrigin: plan?.controlOrigin, localUrl: plan?.url, locale: getLocale() });
114
+ document.querySelector('#install-walkthrough').innerHTML = installWalkthroughMarkup({ method: method === 'package' ? 'manual' : method, mode, os: os === 'windows' ? 'windows' : 'posix', pairingMethod, step: walkthroughStep, copied, controlOrigin: plan?.controlOrigin, localUrl: plan?.url, locale: getLocale() });
99
115
  copyButton.disabled = !instructionsValid;
100
116
  }
101
117
  function resetCopyState(resetStep = true) {
@@ -135,6 +151,8 @@ document.querySelector('#pairing-options').addEventListener('change', event => {
135
151
  document.querySelector('#os-options').addEventListener('change', event => {
136
152
  if (!event.target.matches('input[name="install-os"]')) return;
137
153
  os = event.target.value;
154
+ packageArch = os === 'macos' ? 'arm64' : 'x64';
155
+ document.querySelector('#package-arch').value = packageArch;
138
156
  resetCopyState();
139
157
  });
140
158
  customOrigin.addEventListener('input', () => {
@@ -166,3 +184,11 @@ copyButton.addEventListener('click', async () => {
166
184
  document.querySelector('#walkthrough-title').focus({ preventScroll: true });
167
185
  }
168
186
  });
187
+
188
+ document.querySelector('#package-arch').addEventListener('change', event => { packageArch = event.target.value; updateGuidance(); });
189
+ fetch('/install/packages', { credentials: 'omit' }).then(async response => {
190
+ if (!response.ok) throw new Error('installer_catalog_unavailable');
191
+ const body = await response.json();
192
+ if (!Array.isArray(body.packages)) throw new Error('invalid_installer_catalog');
193
+ packages = body.packages;
194
+ }).catch(() => { packagesFailed = true; }).finally(() => { packagesLoading = false; updateGuidance(); });
package/src/styles.css CHANGED
@@ -271,3 +271,27 @@ details p { font-size: 12px; color: #8da37c; line-height: 1.8; }
271
271
  .device-actions { flex-wrap: wrap; } .device-footer { flex: 1 1 80px; white-space: nowrap; } .ssh-check-button { flex: 0 0 auto; }
272
272
  .empty-state { padding: 22px 12px; } .add-modal { padding: 19px; } .method-tabs button { font-size: 11px; } .connection-check { flex-wrap: wrap; }
273
273
  }
274
+
275
+ /* The panel uses the same instruction component as /start; illustrations are inert. */
276
+ .panel-install-modal { width: min(1040px, 100%); }
277
+ .panel-install-layout { display: grid; grid-template-columns: minmax(260px, .8fr) minmax(0, 1.2fr); gap: 26px; align-items: start; }
278
+ .panel-install-options { min-width: 0; }
279
+ .panel-install-options .field-label { display: block; margin: 16px 0 8px; }
280
+ .panel-install-options select { width: 100%; min-height: 38px; padding: 8px 10px; border: 1px solid #dce4d8; border-radius: 7px; color: #344137; background: #fff; font: inherit; font-size: 12px; }
281
+ .panel-install-options .panel-install-methods { margin: 12px 0; }
282
+ .panel-install-options .panel-install-methods button { flex: 1; }
283
+ .install-destination { padding: 11px 13px; border: 1px solid var(--line); border-radius: 8px; background: #f5f8f3; }
284
+ .install-destination span { display: block; color: #78857b; font-size: 10px; margin-bottom: 5px; }
285
+ .install-destination strong { display: block; color: #366548; font-size: 12px; overflow-wrap: anywhere; }
286
+ .install-target-hint { color: #356447; background: #edf5ed; border-left: 3px solid #7aa384; padding: 10px 12px; margin: 12px 0; font-size: 12px; line-height: 1.8; }
287
+ .install-options-details { margin: 14px 0; color: #657660; font-size: 11px; }
288
+ .install-options-details summary { cursor: pointer; }
289
+ .panel-install-links { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; margin-top: 18px; font-size: 11px; }
290
+ .panel-install-links a { color: var(--green); text-underline-offset: 3px; }
291
+ .panel-install-links button { font-size: 11px; padding: 0; text-align: left; }
292
+ .panel-walkthrough { --mono: "SFMono-Regular", Consolas, monospace; min-width: 0; border-radius: 10px; padding: 18px; background: #151213; color: #eeece8; }
293
+ .panel-walkthrough h3 { margin: 0 0 7px; font-size: 14px; font-weight: 600; }
294
+ .panel-walkthrough > .walkthrough-disclaimer { margin: 0 0 17px; font-size: 10px; color: #aa9382; line-height: 1.7; }
295
+ .panel-walkthrough button:disabled { cursor: default; }
296
+ .guide-navigation { margin-top: 14px; }
297
+ @media (max-width: 760px) { .panel-install-layout { grid-template-columns: 1fr; gap: 22px; } .panel-install-modal { padding: 20px; } .panel-walkthrough { padding: 14px; } }
package/start.html CHANGED
@@ -31,8 +31,8 @@
31
31
  <h1 id="hero-title"><span data-i18n="guide.visual.heroLine1">少切电脑。</span><span data-i18n="guide.visual.heroLine2">少搬日志。</span></h1>
32
32
  <p class="lead" data-i18n="guide.visual.lead">让本机 Agent 查看其他设备的状态与授权日志。</p>
33
33
  <div class="hero-actions"><a class="button primary hero-cta" href="#install-title"><span data-i18n="guide.visual.begin">开始接入</span><svg class="icon" aria-hidden="true"><use href="#icon-arrow"/></svg></a><span class="cta-hint" data-i18n="guide.focus.firstTry">第一次用?先选本机,无需账号。</span></div>
34
- <p class="platform" data-i18n="guide.platform">macOS / Linux / Windows 11 预览 · Node.js 24+</p>
35
- <p class="release-note"><span class="release-dot" aria-hidden="true"></span><span data-i18n="guide.badge">开发预览 · 0.2.0-beta.6 · MIT</span></p>
34
+ <p class="platform" data-i18n="guide.platform">macOS / Linux / Windows 11 · beta</p>
35
+ <p class="release-note"><span class="release-dot" aria-hidden="true"></span><span data-i18n="guide.badge">开发预览 · 0.2.0-beta.8 · MIT</span></p>
36
36
  </div>
37
37
  <figure class="scene" data-paused="false">
38
38
  <figcaption class="scene-caption"><span><span class="caption-cross" aria-hidden="true">+</span><span data-i18n="guide.focus.example">工作原理示意 · 无需操作</span></span><button class="motion-toggle" type="button" data-i18n-aria-label="guide.visual.pauseMotion" data-i18n-title="guide.visual.pauseMotion" aria-label="暂停动效" title="暂停动效"><svg class="icon pause-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M8 5v14M16 5v14"/></svg><svg class="icon play-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="m8 5 11 7-11 7Z"/></svg></button></figcaption>
@@ -81,20 +81,29 @@
81
81
  <div class="section-heading">
82
82
  <p class="eyebrow"><span class="action-marker" aria-hidden="true">↘</span><span data-i18n="guide.focus.start">开始操作</span></p>
83
83
  <h2 id="install-title" tabindex="-1" data-i18n="guide.focus.startTitle">接入,从这里开始</h2>
84
- <p class="section-intro" data-i18n="guide.focus.startBody">选好使用方式,再选交给 Agent 或手动安装。目标设备没有 Agent 也能接入。</p>
84
+ <p class="section-intro" data-i18n="guide.focus.startBody">先选择目标系统和安装方式。推荐下载内置运行环境的安装包;使用模式在安装器里确认。</p>
85
85
  <p class="section-note" data-i18n="guide.visual.noInstall">复制不会执行安装</p>
86
86
  </div>
87
87
  <div class="action-workbench">
88
+ <fieldset class="pairing-options" id="os-options">
89
+ <legend data-i18n="guide.os.title">目标电脑的系统</legend>
90
+ <div class="pairing-choices">
91
+ <label><input type="radio" name="install-os" value="macos" checked><span>macOS</span></label>
92
+ <label><input type="radio" name="install-os" value="linux"><span>Linux</span></label>
93
+ <label><input type="radio" name="install-os" value="windows"><span>Windows 11</span></label>
94
+ </div>
95
+ </fieldset>
96
+
88
97
  <fieldset class="mode-options">
89
98
  <legend><span class="action-number" aria-hidden="true">01</span><span data-i18n="guide.focus.select">选择使用方式</span></legend>
90
99
  <div class="choices">
91
100
  <label class="mode-choice">
92
- <input type="radio" name="install-mode" value="local" checked>
101
+ <input type="radio" name="install-mode" value="local">
93
102
  <span class="choice-icon"><svg class="icon" aria-hidden="true"><use href="#icon-computer"/></svg></span>
94
103
  <span class="choice-copy"><strong data-i18n="guide.local">本机使用</strong><span data-i18n="guide.visual.localBody">本机面板 + MCP,无需账号。</span></span>
95
104
  </label>
96
105
  <label class="mode-choice">
97
- <input type="radio" name="install-mode" value="official">
106
+ <input type="radio" name="install-mode" value="official" checked>
98
107
  <span class="choice-icon"><svg class="icon" aria-hidden="true"><use href="#icon-cloud"/></svg></span>
99
108
  <span class="choice-copy"><strong data-i18n="guide.hosted">官方托管</strong><span data-i18n="guide.visual.hostedBody">用自己的账号绑定设备,使用加密中转。</span></span>
100
109
  </label>
@@ -110,21 +119,32 @@
110
119
  </details>
111
120
  </fieldset>
112
121
  <div class="copy-step">
113
- <fieldset class="pairing-options" id="os-options">
114
- <legend data-i18n="guide.os.title">目标电脑的系统</legend>
115
- <div class="pairing-choices">
116
- <label><input type="radio" name="install-os" value="posix" checked><span>macOS / Linux</span></label>
117
- <label><input type="radio" name="install-os" value="windows"><span>Windows 11</span></label>
118
- </div>
119
- </fieldset>
120
- <p class="hint" data-i18n="guide.windowsCandidate">Windows 11 预览版 · 使用 PowerShell 安装,按图示核对绑定与后台状态。</p>
122
+ <p class="hint" id="windows-candidate" data-i18n="guide.windowsCandidate" hidden>Windows 11 预览版 · 安装后核对绑定与后台状态。</p>
121
123
  <fieldset class="method-options">
122
124
  <legend class="action-heading"><span class="action-number" aria-hidden="true">02</span><span data-i18n="guide.method.title">选择安装方式</span></legend>
123
125
  <div class="method-switch">
124
- <label><input type="radio" name="install-method" value="agent" checked><span data-i18n="guide.method.agent">交给 Agent</span></label>
125
- <label><input type="radio" name="install-method" value="manual"><span data-i18n="guide.method.manual">手动安装</span></label>
126
+ <label><input type="radio" name="install-method" value="agent"><span data-i18n="guide.method.agent">交给 Agent</span></label>
127
+ <label><input type="radio" name="install-method" value="package" checked><span data-i18n="guide.method.package">下载安装包</span></label>
128
+ <label><input type="radio" name="install-method" value="manual"><span data-i18n="guide.method.script">复制脚本</span></label>
126
129
  </div>
127
130
  </fieldset>
131
+ <div id="package-options" hidden>
132
+ <p class="hint" data-i18n="guide.package.description">内含程序、运行环境和依赖,无需系统 Node.js 或 npm 下载。</p>
133
+ <label class="package-architecture"><span data-i18n="guide.package.arch">处理器架构</span>
134
+ <select id="package-arch" aria-describedby="package-run"></select>
135
+ </label>
136
+ <div id="package-downloads" aria-live="polite"></div>
137
+ <p class="hint" id="package-run"></p>
138
+ <p class="hint" data-i18n="guide.package.confirmations">在安装向导中确认本机、官方或已有自建服务,再确认后台运行与本人配对。</p>
139
+ <ol class="package-followup">
140
+ <li data-i18n="guide.package.step1"></li>
141
+ <li data-i18n="guide.package.step2"></li>
142
+ <li data-i18n="guide.package.step3"></li>
143
+ <li data-i18n="guide.package.step4"></li>
144
+ </ol>
145
+ <a href="/install/offline.md" data-i18n="guide.package.details">查看安装包说明</a>
146
+ </div>
147
+ <div id="copy-options">
128
148
  <div id="manual-options" hidden>
129
149
  <p class="hint manual-prerequisites" id="manual-prerequisites" data-i18n="guide.manual.prerequisites">Node.js 24+ · 全新安装;已有安装会停止并保留配置。</p>
130
150
  <fieldset class="pairing-options" id="pairing-options" hidden>
@@ -139,6 +159,7 @@
139
159
  <p class="copy-hint" id="copy-hint" data-i18n="guide.focus.copyHint">这是给 Agent 的说明,请粘贴到 Agent 对话中,而不是终端。</p>
140
160
  <p id="copy-result" role="status" aria-live="polite" aria-atomic="true"></p>
141
161
  <details id="instruction-details"><summary id="instruction-preview" data-i18n="guide.preview">查看或手动复制完整说明</summary><textarea id="install-instructions" aria-label="Agent 安装说明" data-i18n-aria-label="guide.instructions" rows="9" readonly spellcheck="false"></textarea></details>
162
+ </div>
142
163
  </div>
143
164
  <div class="next-action" id="next-action" data-copied="false">
144
165
  <h3 class="action-heading" id="walkthrough-title" tabindex="-1"><span class="action-number" aria-hidden="true">03</span><span data-i18n="walk.title">复制后,照图操作</span></h3>
@@ -156,7 +177,7 @@
156
177
  <details><summary data-i18n="guide.faq">遇到问题先查什么?</summary><div class="detail-content"><p data-i18n="guide.faqBody"></p><p data-i18n="guide.feedback"></p></div></details>
157
178
  </section>
158
179
  </main>
159
- <footer><a href="/install/agent.md" data-i18n="guide.full">Agent 完整安装指南(中文)</a><a href="/install/trial.md" data-i18n="guide.markdown">文字版试用指南(中文)</a><a href="https://www.npmjs.com/package/device-center/v/0.2.0-beta.6" target="_blank" rel="noopener noreferrer" data-i18n="guide.source">MIT npm 源码包 ↗</a><span data-i18n="guide.footer">本机安装 · 私钥留在设备 · 操作单独授权</span></footer>
180
+ <footer><a href="/install/agent.md" data-i18n="guide.full">Agent 完整安装指南(中文)</a><a href="/install/trial.md" data-i18n="guide.markdown">文字版试用指南(中文)</a><a href="https://www.npmjs.com/package/device-center/v/0.2.0-beta.8" target="_blank" rel="noopener noreferrer" data-i18n="guide.source">MIT npm 源码包 ↗</a><span data-i18n="guide.footer">本机安装 · 私钥留在设备 · 操作单独授权</span></footer>
160
181
  <script type="module" src="/src/start.js"></script>
161
182
  </body>
162
183
  </html>