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.
@@ -0,0 +1,79 @@
1
+ .walkthrough-tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px; margin: 0 0 16px; padding: 0; list-style: none; }
2
+ .walkthrough-tabs li { min-width: 0; }
3
+ .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; }
4
+ .walkthrough-tabs button span { color: #86756a; font: 10px var(--mono); }
5
+ .walkthrough-tabs button:hover { border-color: #bc7a56; color: #f4c8ab; }
6
+ .walkthrough-tabs button[aria-current="step"] { background: #3d2a20; border-color: #c48d68; color: #ffcfb1; }
7
+ .walkthrough-tabs button[aria-current="step"] span { color: #ffc29b; }
8
+ .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; }
9
+ .walkthrough-caption small { flex-shrink: 0; color: #a48c7c; font: 11px var(--mono); }
10
+ .walkthrough-picture { overflow: hidden; padding: 16px 18px 14px; border: 1px solid #3b302a; border-radius: 5px; background: #121011; }
11
+ .walkthrough-picture svg { width: 22px; height: 22px; flex-shrink: 0; fill: none; stroke: currentColor; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; }
12
+ .picture-transfer { display: flex; align-items: center; justify-content: center; gap: 18px; margin-bottom: 16px; color: #dfb394; font-size: 12px; line-height: 1.7; }
13
+ .picture-transfer > span { display: flex; align-items: center; gap: 8px; min-width: 0; }
14
+ .picture-transfer > svg { width: 28px; color: #b58160; }
15
+ .picture-window { min-width: 0; border: 1px solid #5a4940; border-radius: 7px; background: #1b1818; box-shadow: 0 8px 20px #0003; }
16
+ .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; }
17
+ .picture-titlebar > i { flex-shrink: 0; width: 6px; height: 6px; border: 1px solid #887369; border-radius: 50%; }
18
+ .picture-titlebar > i:first-child { background: #956e5e; }
19
+ .picture-titlebar > span { min-width: 0; margin-left: 9px; color: #baa599; font-size: 10px; line-height: 1.6; }
20
+ .picture-address { display: block; overflow-wrap: anywhere; font: 10px var(--mono); }
21
+ .picture-terminal { padding: 22px 18px 16px; }
22
+ .picture-paste-line { display: flex; align-items: center; gap: 12px; position: relative; color: #ffb98a; font: 12px var(--mono); }
23
+ .picture-paste-line > div { flex: 1; padding: 14px 12px; border: 1px dashed #db9365; border-radius: 3px; background: #35261e; color: #ffcfaf; line-height: 1.8; }
24
+ .picture-caret { display: inline-block; height: 15px; margin-left: 9px; border-left: 2px solid #ffa875; vertical-align: middle; }
25
+ .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); }
26
+ .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; }
27
+ .picture-keyline strong { color: #ffd5b9; font-weight: 400; }
28
+ .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); }
29
+ .picture-launch { display: flex; flex-wrap: wrap; gap: 5px 18px; margin-top: 15px; color: #a38c7e; font-size: 10px; line-height: 1.8; }
30
+ .picture-chat { padding: 22px 18px 16px; }
31
+ .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; }
32
+ .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; }
33
+ .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; }
34
+ .picture-send .picture-pointer { bottom: -9px; right: 0; width: 20px; }
35
+ .picture-auto-open { margin-bottom: 14px; color: #c8a88f; text-align: center; font-size: 11px; line-height: 1.7; }
36
+ .picture-pair { padding: 12px 16px; }
37
+ .picture-signin { display: flex; align-items: baseline; gap: 9px; color: #d4b7a4; font-size: 11px; line-height: 1.7; }
38
+ .picture-signin > span { color: #c29170; font: 9px var(--mono); }
39
+ .picture-signin + .picture-signin { margin-top: 8px; }
40
+ .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; }
41
+ .picture-fingerprints > span { color: #bca08b; }
42
+ .picture-fingerprints code { overflow-wrap: anywhere; color: #ffd1a9; font: 10px/1.7 var(--mono); }
43
+ .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; }
44
+ .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; }
45
+ .picture-approval .picture-pointer { right: -7px; bottom: -15px; width: 21px; }
46
+ .headless-transfer { gap: 13px; font-size: 11px; }
47
+ .headless-transfer > span:first-child { flex-direction: column; align-items: flex-start; gap: 6px; }
48
+ .headless-transfer code { padding: 4px 7px; border: 1px dashed #a57958; color: #ffd0a9; font: 10px/1.7 var(--mono); }
49
+ .picture-code-entry { margin: 10px 0 12px; color: #b49a89; font-size: 10px; }
50
+ .picture-code-entry > div { position: relative; margin-top: 6px; padding: 9px 12px; border: 1px dashed #b7825f; border-radius: 3px; color: #ffd1b0; background: #30231c; }
51
+ .picture-install { padding: 21px 20px; }
52
+ .picture-install-command { margin-bottom: 19px; color: #e4c5af; font: 13px var(--mono); }
53
+ .picture-install-command > span { color: #ac856a; }
54
+ .picture-install-row { display: flex; align-items: baseline; gap: 12px; margin: 10px 0; color: #cfb5a3; font-size: 12px; }
55
+ .picture-install-row > span { color: #a7866f; font: 10px var(--mono); }
56
+ .picture-install-track { display: flex; gap: 5px; margin-top: 21px; }
57
+ .picture-install-track i { flex: 1; height: 3px; background: #8b644b; }
58
+ .picture-install-track i:nth-child(2) { background: #5e473a; }
59
+ .picture-install-track i:nth-child(3) { background: #392d26; }
60
+ .picture-dashboard { padding: 20px 18px; }
61
+ .picture-device { display: grid; grid-template-columns: 24px minmax(0, 1fr); align-items: center; gap: 4px 12px; color: #d4aa8a; }
62
+ .picture-device svg { grid-row: span 2; }
63
+ .picture-device strong { color: #f1d1b9; font-size: 13px; font-weight: 500; }
64
+ .picture-device > span { color: #a38b7a; font-size: 10px; }
65
+ .picture-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; margin: 24px 0 20px; }
66
+ .picture-metrics > div { display: grid; gap: 8px; }
67
+ .picture-metrics span { color: #b29c8c; font-size: 11px; }
68
+ .picture-metrics b { color: #ceb39e; font: 14px var(--mono); }
69
+ .picture-metrics i { height: 3px; background: #4b3b30; }
70
+ .picture-report-time { position: relative; padding: 10px 12px; border: 1px dashed #9f7555; color: #ebc3a2; font-size: 11px; line-height: 1.8; }
71
+ .picture-report-time .picture-pointer { right: 3px; bottom: -14px; width: 21px; }
72
+ .walkthrough-description { margin: 12px 0 0; color: #c5ad9d; font-size: 12px; line-height: 1.9; }
73
+ .walkthrough-copy-first { margin: 8px 0 0; color: #aa9382; font-size: 11px; line-height: 1.8; }
74
+ .walkthrough-navigation { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 12px; padding-top: 10px; border-top: 1px solid #49362b; }
75
+ .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; }
76
+ .walkthrough-navigation button:hover { color: #ffe0c6; text-decoration: underline; text-underline-offset: 4px; }
77
+ .walkthrough-navigation button:disabled { color: #766155; cursor: default; text-decoration: none; }
78
+ .walkthrough-tabs button:focus-visible, .walkthrough-navigation button:focus-visible, #walkthrough-title:focus-visible { outline: 2px solid #ffc29b; outline-offset: 4px; }
79
+ @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; } }
@@ -34,7 +34,7 @@ export function installWalkthroughMarkup({ method = 'agent', mode = 'local', pai
34
34
  } else if (active.picture === 'pair' || active.picture === 'headless') {
35
35
  const headless = active.picture === 'headless';
36
36
  const transfer = headless ? `<div class="picture-transfer headless-transfer"><span>${tr('walk.targetTerminal')}<code>${tr('walk.pairCode')}</code></span>${arrows}<span>${tr('walk.otherBrowser')}</span></div>` : `<div class="picture-auto-open">${tr('walk.autoBrowser')}</div>`;
37
- picture = `${transfer}${window(browserTitle, `<div class="picture-pair"><div class="picture-signin"><span>01</span>${tr('walk.signIn')}</div>${headless ? `<div class="picture-code-entry"><span>${tr('walk.openPairing', { action: translate(locale, 'panel.434') })}</span><div>${tr('walk.pasteCode')}${pointer}</div></div>` : ''}<div class="picture-signin"><span>02</span>${tr('walk.compare')}</div>${fingerprint}${approval}</div>`, 'browser-picture')}`;
37
+ picture = `${transfer}${window(browserTitle, `<div class="picture-pair"><div class="picture-signin"><span>01</span>${tr('walk.signIn')}</div>${headless ? `<div class="picture-code-entry"><span>${tr('walk.openPairing', { action: translate(locale, 'panel.435') + ' → ' + translate(locale, 'panel.install.existingCode') })}</span><div>${tr('walk.pasteCode')}${pointer}</div></div>` : ''}<div class="picture-signin"><span>02</span>${tr('walk.compare')}</div>${fingerprint}${approval}</div>`, 'browser-picture')}`;
38
38
  } else if (active.picture === 'install') {
39
39
  picture = window(tr('walk.targetTerminal'), `<div class="picture-install"><div class="picture-install-command"><span>$</span> device-center</div><div class="picture-install-row"><span>01</span>${tr('walk.driver')}</div><div class="picture-install-row"><span>02</span>${tr('walk.service')}</div>${method === 'agent' ? `<div class="picture-install-row"><span>03</span>${tr('walk.mcp')}</div>` : ''}<div class="picture-install-track"><i></i><i></i><i></i></div></div>`, 'terminal-picture');
40
40
  } else {
@@ -0,0 +1,6 @@
1
+ // Resolve an existing installation on the target machine. Never installs or changes PATH.
2
+ export function installedCliCommand(args, os = 'posix') {
3
+ if (!['posix', 'windows'].includes(os) || typeof args !== 'string' || /[\r\n]/.test(args)) throw new Error('invalid_cli_command');
4
+ if (os === 'windows') return `& { $dcRoot = "$HOME\\AppData\\Local\\Programs\\Device Center\\offline"; $dcNpm = "$HOME\\AppData\\Local\\Programs\\Device Center\\cli\\device-center.cmd"; if ((Test-Path -LiteralPath "$dcRoot\\runtime\\node.exe") -and (Test-Path -LiteralPath "$dcRoot\\app\\bin\\device-center.mjs")) { & "$dcRoot\\runtime\\node.exe" "$dcRoot\\app\\bin\\device-center.mjs" ${args} } elseif (Test-Path -LiteralPath $dcNpm) { & $dcNpm ${args} } elseif (Get-Command device-center.cmd -ErrorAction SilentlyContinue) { & device-center.cmd ${args} } else { throw 'Device Center not found. Open the installer to continue.' }; if ($LASTEXITCODE -ne 0) { throw 'Device Center command failed. Keep the existing installation and inspect the error.' } }`;
5
+ return `( dc_root="$HOME/.local/share/device-center/offline"; if [ -x "$dc_root/runtime/bin/node" ] && [ -f "$dc_root/app/bin/device-center.mjs" ]; then "$dc_root/runtime/bin/node" "$dc_root/app/bin/device-center.mjs" ${args}; elif [ -x "$HOME/.local/bin/device-center" ]; then "$HOME/.local/bin/device-center" ${args}; elif command -v device-center >/dev/null 2>&1; then device-center ${args}; else printf '%s\\n' 'Device Center not found. Open the installer to continue.' >&2; exit 1; fi )`;
6
+ }
package/src/login-flow.js CHANGED
@@ -35,3 +35,38 @@ export async function continueSession({ fetchImpl = fetch, signal = AbortSignal.
35
35
  throw new Error(cause.name === 'TimeoutError' ? 'login.timeout' : 'login.unavailable');
36
36
  }
37
37
  }
38
+
39
+
40
+ const ACCOUNT_NAVIGATION_KEY = 'device-center-account-navigation';
41
+ const accountNavigationAge = 10 * 60_000;
42
+ const validAccountNavigation = (value, now) => value && /^[a-f0-9-]{36}$/.test(value.id ?? '') && Number.isSafeInteger(value.at) && value.at <= now && now - value.at < accountNavigationAge;
43
+ export function clearAccountNavigation({ store = storage() } = {}) { try { store.removeItem(ACCOUNT_NAVIGATION_KEY); } catch { /* No credentials stored. */ } }
44
+ export function pendingAccountNavigation({ store = storage(), now = Date.now() } = {}) {
45
+ try { if (validAccountNavigation(JSON.parse(store.getItem(ACCOUNT_NAVIGATION_KEY)), now)) return true; } catch { /* Optional storage. */ }
46
+ clearAccountNavigation({ store }); return false;
47
+ }
48
+ export function accountLoginDestination(accountUrl, locale, { origin = window.location.origin, search = globalThis.window?.location?.search ?? '', store = storage(), now = Date.now(), requestId = crypto.randomUUID() } = {}) {
49
+ const target = new URL(accountUrl);
50
+ if (target.protocol !== 'https:' || target.username || target.password || target.hash || target.pathname !== '/auth/connect' || target.searchParams.get('product') !== 'device-center' || target.searchParams.get('parentOrigin') !== origin || !validAccountNavigation({ id: requestId, at: now }, now)) throw new Error('login.unavailable');
51
+ const value = JSON.stringify({ id: requestId, at: now });
52
+ try { store.setItem(ACCOUNT_NAVIGATION_KEY, value); if (store.getItem(ACCOUNT_NAVIGATION_KEY) !== value) throw new Error(); }
53
+ catch { throw new Error('login.storage'); }
54
+ // Pairing and CLI proofs stay in this tab's product storage, never in the account URL.
55
+ target.searchParams.set('requestId', requestId);
56
+ const continuation = new URLSearchParams({ lang: locale });
57
+ if (new URLSearchParams(search).get('continue') === 'cli') continuation.set('continue', 'cli');
58
+ target.searchParams.set('path', '/login?' + continuation);
59
+ return target.href;
60
+ }
61
+ export function consumeAccountReturn({ location = window.location, history = window.history, store = storage(), now = Date.now() } = {}) {
62
+ const url = new URL(location.href);
63
+ if (!url.searchParams.has('authReturn')) return null;
64
+ const ids = url.searchParams.getAll('authReturn');
65
+ url.searchParams.delete('authReturn');
66
+ history.replaceState(null, '', url.pathname + url.search);
67
+ let pending;
68
+ try { pending = JSON.parse(store.getItem(ACCOUNT_NAVIGATION_KEY)); } catch { /* Never treat missing storage as verified return. */ }
69
+ const valid = ids.length === 1 && validAccountNavigation(pending, now) && ids[0] === pending.id;
70
+ clearAccountNavigation({ store });
71
+ return Boolean(valid);
72
+ }
package/src/login.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { initLanguage, getLocale, t } from './i18n.js';
2
2
  import { loginDestination } from './pairing-handoff.js';
3
- import { continueSession, rememberLoginNavigation, pendingLoginNavigation } from './login-flow.js';
3
+ import { continueSession, rememberLoginNavigation, pendingLoginNavigation, accountLoginDestination, consumeAccountReturn, pendingAccountNavigation, clearAccountNavigation } from './login-flow.js';
4
4
  if (location.pathname === '/pair') {
5
5
  await import('./pair.js');
6
6
  } else if (location.pathname === '/cli') {
@@ -10,8 +10,13 @@ initLanguage();
10
10
  function setMessage(element, key) { if (key) element.dataset.i18n = key; else delete element.dataset.i18n; element.textContent = key ? t(key) : ''; }
11
11
  const form = document.querySelector('#login');
12
12
  const loading = document.querySelector('#loading');
13
- let checking = false, watchingUntil = 0, timer, options;
14
- async function continueUnified(manual = false) {
13
+ let checking = false, options;
14
+ const accountReturn = consumeAccountReturn();
15
+ function enterAccount() { location.replace(accountLoginDestination(options.accountLoginUrl, getLocale())); }
16
+ function openAccount() {
17
+ try { enterAccount(); } catch (error) { setMessage(document.querySelector('#unified-message'), error.message); }
18
+ }
19
+ async function continueUnified() {
15
20
  if (checking || options?.provider !== 'app-services') return;
16
21
  checking = true;
17
22
  const message = document.querySelector('#unified-message'), button = document.querySelector('#continue-login');
@@ -20,22 +25,14 @@ async function continueUnified(manual = false) {
20
25
  setMessage(message, 'login.checking');
21
26
  try {
22
27
  await continueSession();
23
- clearInterval(timer); rememberLoginNavigation(); location.replace(loginDestination(getLocale()));
28
+ clearAccountNavigation(); rememberLoginNavigation(); location.replace(loginDestination(getLocale()));
24
29
  } catch (cause) {
25
- setMessage(message, cause.message === 'login.first' && !manual ? null : cause.message);
26
- if (cause.message !== 'login.first') { clearInterval(timer); watchingUntil = 0; }
30
+ if (cause.message === 'login.first' && accountReturn === null && !pendingAccountNavigation()) openAccount();
31
+ else setMessage(message, cause.message);
27
32
  }
28
33
  finally { checking = false; button.disabled = false; document.querySelector('#unified').setAttribute('aria-busy', 'false'); }
29
34
  }
30
- document.querySelector('#continue-login').addEventListener('click', () => continueUnified(true));
31
- document.querySelector('#unified-login').addEventListener('click', () => {
32
- watchingUntil = Date.now() + 5 * 60_000;
33
- setMessage(document.querySelector('#unified-message'), 'login.return');
34
- clearInterval(timer);
35
- timer = setInterval(() => { if (Date.now() > watchingUntil) clearInterval(timer); else void continueUnified(); }, 4000);
36
- });
37
- window.addEventListener('focus', () => { if (watchingUntil > Date.now()) void continueUnified(); });
38
- document.addEventListener('visibilitychange', () => { if (!document.hidden && watchingUntil > Date.now()) void continueUnified(); });
35
+ document.querySelector('#continue-login').addEventListener('click', openAccount);
39
36
  try {
40
37
  const response = await fetch('/api/auth/options', { signal: AbortSignal.timeout(5000) });
41
38
  if (!response.ok) throw new Error();
@@ -43,13 +40,11 @@ try {
43
40
  setMessage(document.querySelector('#access-notes'), options.accountMode === 'multi-user' ? 'login.multi' : 'login.personal');
44
41
  loading.hidden = true;
45
42
  if (options.provider === 'app-services') {
46
- const target = new URL(options.accountLoginUrl);
47
- if (target.protocol !== 'https:' || target.username || target.password) throw new Error();
48
- document.querySelector('#unified-login').href = target.href;
49
43
  document.querySelector('#unified').hidden = false;
50
44
  if (new URLSearchParams(location.search).get('reason') === 'unavailable') setMessage(document.querySelector('#unified-message'), 'login.unavailable');
51
- else if (pendingLoginNavigation()) setMessage(document.querySelector('#unified-message'), 'login.returned');
52
- else if (options.automaticContinue) void continueUnified();
45
+ else if (accountReturn === false || (accountReturn !== true && pendingLoginNavigation())) setMessage(document.querySelector('#unified-message'), 'login.returned');
46
+ else if (accountReturn === true || options.automaticContinue) void continueUnified();
47
+ else openAccount();
53
48
  } else if (options.provider === 'password') { form.hidden = false; form.elements.username.focus(); }
54
49
  else throw new Error();
55
50
  } catch { loading.hidden = false; setMessage(loading, 'login.optionsError'); }
package/src/main.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { t, h, getLocale, initLanguage, languagePickerMarkup, reportMessage } from './i18n.js';
2
2
  import { api } from './api.js';
3
+ import { installWalkthroughMarkup } from './install-walkthrough.js';
4
+ import { agentSetupPrompt } from './agent-setup.js';
3
5
  import { expireSshSnapshots, deviceStatus, collectionAvailability, DEVICE_TYPE_LABELS } from './device-state.js';
4
- import { OFFICIAL_CONTROL_ORIGIN, connectCommand, logAccessCommands, agentInstallPrompt } from './onboarding.js';
6
+ import { OFFICIAL_CONTROL_ORIGIN, connectCommand, logAccessCommands, agentInstallPlan, terminalInstallScript } from './onboarding.js';
5
7
 
6
8
  const root = document.querySelector('#root');
7
9
  const state = { loading: true, error: null, overview: null, setup: null, status: null, ssh: null, selectedAliases: [], saving: false, sshLoading: false, sshError: null, checkingSshAlias: null, dialog: null, routeDevice: null, method: 'settings', filter: 'all', query: '', project: 'all', toast: null, checking: false, checkMessage: null };
@@ -12,7 +14,8 @@ Object.assign(state, { pairCode: '', pairing: null, pairBusy: false, pairError:
12
14
  Object.assign(state, { cloudChoice: 'official', customOrigin: '', logManager: '', logTarget: '', logPath: '', logLabel: 'app', remotePeers: [], remoteLoading: false, remoteError: null });
13
15
  let cloudRefreshTimer, cloudRefreshBusy = false, cloudRefreshFailures = 0;
14
16
  Object.assign(state, { profileDevice: null, profileDraft: null, profileError: null, profileSaving: false, collectingDevice: null, pendingCollections: {} });
15
- Object.assign(state, { installMode: 'local', installOs: 'posix', installOrigin: '', installBackground: true });
17
+ Object.assign(state, { installMode: 'local', installOs: 'posix', installOrigin: '', installBackground: true, installMethod: 'manual', installPairing: 'browser', installStep: 0, installCopied: false });
18
+ let installCopyGeneration = 0;
16
19
  const RELAY_PROVIDERS = [
17
20
  { id: 'self-hosted', get name() { return t("panel.1"); }, get description() { return t("panel.2"); } },
18
21
  { id: 'managed', get name() { return t("panel.3"); }, get description() { return t("panel.4"); } },
@@ -217,8 +220,8 @@ function recentMcpInitialize() {
217
220
  }
218
221
  function renderSidebar() {
219
222
  const projects = state.overview?.projects ?? [];
220
- if (cloudMode()) return `<aside class="sidebar"><a class="brand" href="/?lang=${getLocale()}" aria-label="${h("panel.183")}"><span class="brand-mark">${icon('cloud')}</span><span><strong>Device Center</strong><small>${h("panel.184")}</small></span></a><div class="nav-caption">${h("panel.185")}</div><nav aria-label="${h("panel.186")}"><button class="nav-item active" data-action="project" data-project="all">${icon('device')}<span>${h("panel.187")}</span><small>${state.overview?.devices.length ?? 0}</small></button><button class="nav-item" data-action="secure-help">${icon('link')}<span>${h("panel.188")}</span></button><a class="nav-item" href="/cli?lang=${getLocale()}">${icon('link')}<span>${h("cli.manage")}</span></a></nav><div class="nav-caption">${h("panel.189")}</div><nav class="project-navigation" aria-label="${h("panel.190")}"><p class="nav-empty">${h("panel.191")}</p></nav><div class="sidebar-footer"><span class="local-dot"></span> ${h("panel.192")}<button class="text-button" data-action="logout">${h("panel.193")}</button></div></aside>`;
221
- return `<aside class="sidebar"><a class="brand" href="/?lang=${getLocale()}" aria-label="${h("panel.183")}"><span class="brand-mark">${icon('cloud')}</span><span><strong>Device Center</strong><small>${h("panel.194")}</small></span></a><div class="nav-caption">${h("panel.185")}</div><nav aria-label="${h("panel.186")}"><button class="nav-item ${state.project === 'all' ? 'active' : ''}" data-action="project" data-project="all">${icon('device')}<span>${h("panel.187")}</span><small>${state.overview?.devices.length ?? 0}</small></button><button class="nav-item" data-action="connect">${icon('link')}<span>${h("panel.195")}</span></button><button class="nav-item" data-action="secure-help">${icon('link')}<span>${h("panel.188")}</span></button><button class="nav-item" data-action="cloud-connect">${icon('cloud')}<span>${h("panel.196")}</span></button></nav><div class="nav-caption">${h("panel.189")}</div><nav class="project-navigation" aria-label="${h("panel.190")}">${projects.length ? projects.map((p) => `<button class="nav-item ${state.project === p.id ? 'active' : ''}" data-action="project" data-project="${escapeHtml(p.id)}">${icon('folder')}<span>${escapeHtml(p.name)}</span></button>`).join('') : `<p class="nav-empty">${h("panel.191")}</p>`}</nav><div class="sidebar-footer"><span class="local-dot"></span> ${h("panel.197")}<button class="text-button" data-action="resident">${h("panel.198")}</button></div></aside>`;
223
+ if (cloudMode()) return `<aside class="sidebar"><a class="brand" href="/?lang=${getLocale()}" aria-label="${h("panel.183")}"><span class="brand-mark">${icon('cloud')}</span><span><strong>Device Center</strong><small>${h("panel.184")}</small></span></a><div class="nav-caption">${h("panel.185")}</div><nav aria-label="${h("panel.186")}"><button class="nav-item active" data-action="project" data-project="all">${icon('device')}<span>${h("panel.187")}</span><small>${state.overview?.devices.length ?? 0}</small></button><button class="nav-item" data-action="secure-help">${icon('link')}<span>${h("panel.188")}</span></button><a class="nav-item" href="/cli?lang=${getLocale()}">${icon('link')}<span>${h("cli.setup.title")}</span></a></nav><div class="nav-caption">${h("panel.189")}</div><nav class="project-navigation" aria-label="${h("panel.190")}"><p class="nav-empty">${h("panel.191")}</p></nav><a class="nav-item guide-navigation" href="${escapeHtml(panelGuideHref())}">${icon('device')}<span>${h("panel.install.guide")}</span></a><div class="sidebar-footer"><span class="local-dot"></span> ${h("panel.192")}<button class="text-button" data-action="logout">${h("panel.193")}</button></div></aside>`;
224
+ return `<aside class="sidebar"><a class="brand" href="/?lang=${getLocale()}" aria-label="${h("panel.183")}"><span class="brand-mark">${icon('cloud')}</span><span><strong>Device Center</strong><small>${h("panel.194")}</small></span></a><div class="nav-caption">${h("panel.185")}</div><nav aria-label="${h("panel.186")}"><button class="nav-item ${state.project === 'all' ? 'active' : ''}" data-action="project" data-project="all">${icon('device')}<span>${h("panel.187")}</span><small>${state.overview?.devices.length ?? 0}</small></button><button class="nav-item" data-action="connect">${icon('link')}<span>${h("panel.195")}</span></button><button class="nav-item" data-action="secure-help">${icon('link')}<span>${h("panel.188")}</span></button><button class="nav-item" data-action="cloud-connect">${icon('cloud')}<span>${h("panel.196")}</span></button></nav><div class="nav-caption">${h("panel.189")}</div><nav class="project-navigation" aria-label="${h("panel.190")}">${projects.length ? projects.map((p) => `<button class="nav-item ${state.project === p.id ? 'active' : ''}" data-action="project" data-project="${escapeHtml(p.id)}">${icon('folder')}<span>${escapeHtml(p.name)}</span></button>`).join('') : `<p class="nav-empty">${h("panel.191")}</p>`}</nav><a class="nav-item guide-navigation" href="${escapeHtml(panelGuideHref())}">${icon('device')}<span>${h("panel.install.guide")}</span></a><div class="sidebar-footer"><span class="local-dot"></span> ${h("panel.197")}<button class="text-button" data-action="resident">${h("panel.198")}</button></div></aside>`;
222
225
  }
223
226
  function renderConnection() {
224
227
  if (state.loading && !state.overview) return '';
@@ -228,34 +231,65 @@ function renderConnection() {
228
231
  }
229
232
  function managementComputer() { return state.overview?.devices.find((item) => item.source === 'local-runtime'); }
230
233
  function openAgentInstall() {
231
- const origin = cloudMode() ? state.overview.controlOrigin : null;
234
+ const origin = cloudMode() ? state.overview?.controlOrigin : null;
232
235
  state.installMode = origin ? origin === OFFICIAL_CONTROL_ORIGIN ? 'official' : 'self-hosted' : 'local';
233
236
  state.installOrigin = origin && origin !== OFFICIAL_CONTROL_ORIGIN ? origin : '';
234
- state.toast = null; state.dialog = 'agent-install';
237
+ resetInstallCopy(); state.dialog = 'agent-install';
238
+ }
239
+ function resetInstallCopy() {
240
+ installCopyGeneration++; state.installCopied = false; state.installStep = 0; state.toast = null;
241
+ }
242
+ function installOptions() {
243
+ return { locale: getLocale(), mode: state.installMode, os: state.installOs, background: state.installBackground, ...(state.installMode === 'self-hosted' ? { origin: state.installOrigin.trim() } : {}) };
235
244
  }
236
245
  function installDraft() {
237
- try { return { text: agentInstallPrompt({ locale: getLocale(), mode: state.installMode, os: state.installOs, background: state.installBackground, ...(state.installMode === 'self-hosted' ? { origin: state.installOrigin.trim() } : {}) }), error: null }; }
238
- catch (error) { return { text: '', error: t(error.translationKey ?? 'error.install') }; }
246
+ try {
247
+ const options = installOptions(), plan = agentInstallPlan(options);
248
+ return { text: state.installMethod === 'manual' ? terminalInstallScript({ ...options, browser: state.installPairing === 'browser' }) : agentSetupPrompt({ ...options, role: cloudMode() ? 'device' : 'manager' }), plan, error: null };
249
+ } catch (error) { return { text: '', plan: null, error: t(error.translationKey ?? 'error.install') }; }
239
250
  }
240
251
  function manualInstallHref(draft) {
241
- const manualParams = new URLSearchParams({ lang: getLocale(), method: 'manual', mode: state.installMode, os: state.installOs });
252
+ const manualParams = new URLSearchParams({ lang: getLocale(), method: state.installMethod, mode: state.installMode, os: state.installOs });
242
253
  if (state.installMode === 'self-hosted' && !draft.error) manualParams.set('origin', state.installOrigin.trim());
243
254
  return `/start?${manualParams}#install-title`;
244
255
  }
256
+ function panelGuideHref() {
257
+ const origin = cloudMode() ? state.overview?.controlOrigin : null;
258
+ const params = new URLSearchParams({ lang: getLocale(), method: 'manual', os: state.installOs, mode: origin ? origin === OFFICIAL_CONTROL_ORIGIN ? 'official' : 'self-hosted' : 'local' });
259
+ if (origin && origin !== OFFICIAL_CONTROL_ORIGIN) params.set('origin', origin);
260
+ return `/start?${params}#install-title`;
261
+ }
262
+ function panelWalkthrough(draft) {
263
+ return installWalkthroughMarkup({ method: state.installMethod, mode: state.installMode, os: state.installOs, pairingMethod: state.installPairing, step: state.installStep, copied: state.installCopied, controlOrigin: draft.plan?.controlOrigin, localUrl: draft.plan?.url, locale: getLocale() });
264
+ }
245
265
  function agentInstallBody() {
246
- const draft = installDraft();
247
- return `<p class="modal-intro">${h("panel.219")}</p><label class="field-label" for="install-os">${h("guide.os.title")}</label><select id="install-os" data-field="installOs"><option value="posix" ${state.installOs === "posix" ? "selected" : ""}>macOS / Linux</option><option value="windows" ${state.installOs === "windows" ? "selected" : ""}>Windows 11</option></select><p class="small-note">${h("guide.windowsCandidate")}</p><p class="small-note"><a data-manual-install href="${escapeHtml(manualInstallHref(draft))}">${h('guide.manual.openGuide')}</a></p><div class="method-tabs install-mode-tabs" role="group" aria-label="${h("panel.220")}">${[['local', t("panel.210")], ['official', t("panel.211")], ['self-hosted', t("panel.212")]].map(([mode, label]) => `<button data-action="install-mode" data-mode="${mode}" aria-pressed="${state.installMode === mode}" class="${state.installMode === mode ? 'active' : ''}">${label}</button>`).join('')}</div><p class="small-note">${state.installMode === 'local' ? t("panel.213") : state.installMode === 'official' ? t("panel.214") : t("panel.215")}</p>${state.installMode === 'self-hosted' ? `<label class="field-label" for="install-origin">${h("panel.216")}</label><input id="install-origin" data-field="installOrigin" type="url" value="${escapeHtml(state.installOrigin)}" placeholder="https://devices.example.com" autocomplete="off" spellcheck="false">` : ''}<label class="binding-scope"><input type="checkbox" data-field="installBackground" ${state.installBackground ? 'checked' : ''}>${h("panel.221")}</label><ol class="install-flow"><li>${h("panel.222")}</li><li>${state.installMode === 'local' ? t("panel.217") : t("panel.218")}</li><li>${h("panel.223")}</li></ol><p id="install-error" class="form-error" role="alert" ${draft.error ? '' : 'hidden'}>${escapeHtml(draft.error ?? '')}</p><button class="button button-primary install-copy" data-action="agent-install-copy" ${draft.error ? 'disabled' : ''}>${icon('copy', 15)} ${h("panel.224")}</button><details class="install-details"><summary>${h("panel.225")}</summary><textarea id="agent-install-text" aria-label="${h("panel.226")}" readonly rows="7" spellcheck="false">${escapeHtml(draft.text)}</textarea></details><p class="small-note">${h("panel.227")}</p>`;
266
+ const draft = installDraft(), manual = state.installMethod === 'manual', hosted = state.installMode !== 'local';
267
+ return `<p class="modal-intro">${cloudMode() ? h("panel.install.intro") : h("guide.focus.startBody")}</p><div class="panel-install-layout"><div class="panel-install-options">
268
+ ${cloudMode() ? `<div class="install-destination"><span>${h("panel.install.destination")}</span><strong>${escapeHtml(state.overview?.controlOrigin ?? '')}</strong></div>` : `<div class="method-tabs install-mode-tabs" role="group" aria-label="${h("guide.focus.select")}">${[['local', t("panel.210")], ['official', t("panel.211")], ['self-hosted', t("panel.212")]].map(([mode, label]) => `<button data-action="install-mode" data-mode="${mode}" aria-pressed="${state.installMode === mode}" class="${state.installMode === mode ? 'active' : ''}">${label}</button>`).join('')}</div>${state.installMode === 'self-hosted' ? `<label class="field-label" for="install-origin">${h("panel.216")}</label><input id="install-origin" data-field="installOrigin" type="url" value="${escapeHtml(state.installOrigin)}" placeholder="https://devices.example.com" autocomplete="off" spellcheck="false">` : ''}`}
269
+ <label class="field-label" for="install-os">${h("guide.os.title")}</label><select id="install-os" data-field="installOs"><option value="posix" ${state.installOs === 'posix' ? 'selected' : ''}>macOS / Linux</option><option value="windows" ${state.installOs === 'windows' ? 'selected' : ''}>Windows 11</option></select>
270
+ <div class="method-tabs panel-install-methods" role="group" aria-label="${h("guide.method.title")}">${[['manual', t('guide.method.manual')], ['agent', t('guide.method.agent')]].map(([method,label]) => `<button data-action="install-method" data-method="${method}" aria-pressed="${state.installMethod === method}" class="${state.installMethod === method ? 'active' : ''}">${label}</button>`).join('')}</div>
271
+ <p class="install-target-hint">${manual ? h(cloudMode() ? state.installOs === 'windows' ? "panel.install.windowsTarget" : "panel.install.terminalTarget" : state.installOs === 'windows' ? "walk.windowsBody" : "walk.terminalBody") : h("walk.agentBody")}</p>
272
+ ${manual && hosted ? `<label class="field-label" for="install-pairing">${h("guide.manual.browserQuestion")}</label><select id="install-pairing" data-field="installPairing"><option value="browser" ${state.installPairing === 'browser' ? 'selected' : ''}>${h("guide.manual.hasBrowser")}</option><option value="headless" ${state.installPairing === 'headless' ? 'selected' : ''}>${h("guide.manual.noBrowser")}</option></select>` : ''}
273
+ <details class="install-options-details"><summary>${h("panel.install.options")}</summary><label class="binding-scope"><input type="checkbox" data-field="installBackground" ${state.installBackground ? 'checked' : ''}>${h("panel.221")}</label><p class="small-note">${h(state.installOs === 'windows' ? "guide.windowsPrerequisites" : "guide.manual.prerequisites")}</p></details>
274
+ <p id="install-error" class="form-error" role="alert" ${draft.error ? '' : 'hidden'}>${escapeHtml(draft.error ?? '')}</p>
275
+ <button class="button button-primary install-copy" data-action="agent-install-copy" ${draft.error ? 'disabled' : ''}>${icon('copy', 15)} ${manual ? h("guide.manual.copy") : h("panel.224")}</button><p class="small-note">${h("panel.install.copyOnly")}</p>
276
+ <details class="install-details"><summary>${manual ? h("guide.manual.preview") : h("panel.225")}</summary><textarea id="agent-install-text" aria-label="${manual ? h("guide.manual.scriptLabel") : h("panel.226")}" readonly rows="7" spellcheck="false">${escapeHtml(draft.text)}</textarea></details>
277
+ <div class="panel-install-links"><a data-manual-install href="${escapeHtml(manualInstallHref(draft))}">${h("panel.install.guide")} ↗</a>${cloudMode() ? `<button class="text-button" data-action="pair-existing">${h("panel.install.existingCode")}</button>` : ''}</div>
278
+ </div><section class="panel-walkthrough" aria-label="${h("walk.title")}"><h3>${h("walk.title")}</h3><p class="walkthrough-disclaimer">${h("walk.disclaimer")}</p><div id="panel-install-walkthrough">${panelWalkthrough(draft)}</div></section></div>`;
248
279
  }
249
280
  async function copyAgentInstall() {
250
281
  const field = root.querySelector('#agent-install-text');
251
282
  if (!field?.value) return;
283
+ const generation = ++installCopyGeneration, text = field.value;
252
284
  let copied = false;
253
- try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(field.value); copied = true; } } catch { /* manual selection remains available */ }
285
+ try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); copied = true; } } catch { /* manual selection remains available */ }
286
+ if (generation !== installCopyGeneration || state.dialog !== 'agent-install') return;
254
287
  if (!copied) {
255
288
  root.querySelector('.install-details').open = true; field.focus(); field.select();
256
289
  try { copied = document.execCommand('copy'); } catch { /* manual selection remains available */ }
257
290
  }
258
- toast(copied ? t("panel.228") : t("panel.229"));
291
+ state.installCopied = copied; state.installStep = 0;
292
+ toast(copied ? t(state.installMethod === 'manual' ? "guide.manual.copied" : "panel.228") : t("panel.229"));
259
293
  if (!copied) { root.querySelector('.install-details').open = true; root.querySelector('#agent-install-text')?.focus(); root.querySelector('#agent-install-text')?.select(); }
260
294
  }
261
295
  function cloudMode() { return state.overview?.mode === 'cloud' || state.status?.service.runtime === 'cloud'; }
@@ -330,13 +364,14 @@ function cloudBindingBody() {
330
364
  const pairing = state.pairing, disabled = state.pairBusy ? 'disabled' : '';
331
365
  const sourceCommand = driverCommand().replace(/^npx --yes device-center@\S+/, 'npm run driver --');
332
366
  const bindings = state.overview?.drivers ?? [];
333
- return `<p class="modal-intro">${h("panel.365")}</p>
334
- <section class="binding-step"><h3><span>1</span> ${h("panel.366")}</h3><p>${h("panel.367")}</p><details><summary>${h("panel.368")}</summary><a class="button button-light" href="/api/driver/download" download>${h("panel.369")}</a><p>${h("panel.370")} <code>npm ci --ignore-scripts</code>${h("panel.371")} <code>${escapeHtml(sourceCommand)}</code>${h("panel.372")}</p></details></section>
335
- <section class="binding-step"><h3><span>2</span> ${h("panel.373")}</h3><label class="binding-scope"><input id="request-ssh" type="checkbox" data-field="requestSsh" ${state.requestSsh ? 'checked' : ''} ${disabled}>${h("panel.374")}</label><textarea id="driver-command" readonly rows="3" spellcheck="false" aria-label="${h("panel.375")}">${escapeHtml(driverCommand())}</textarea><button class="button button-primary" data-action="driver-copy">${icon('copy', 15)} ${h("panel.309")}</button><p>${h("pair.browserHint")}</p></section>
336
- <details class="binding-step" ${pairing || state.pairError ? 'open' : ''}><summary>${h("pair.manual")}</summary><p>${h("pair.manualHint")}</p><textarea id="manual-driver-command" readonly rows="3" spellcheck="false" aria-label="${h("panel.375")}">${escapeHtml(driverCommand().replace(' --browser', ''))}</textarea><button class="button button-light" data-action="driver-manual-copy">${h("panel.309")}</button><h3><span>3</span> ${h("panel.377")}</h3><div class="pair-code-row"><label for="pair-code" class="sr-only">${h("panel.378")}</label><input id="pair-code" data-field="pairCode" autocomplete="off" spellcheck="false" maxlength="9" placeholder="XXXX-XXXX" value="${escapeHtml(state.pairCode)}" ${disabled}><button class="button button-light" data-action="pair-lookup" ${disabled}>${state.pairBusy ? t("panel.347") : t("panel.348")}</button></div>${state.pairError ? `<p class="form-error" role="alert">${escapeHtml(t(state.pairError))}</p>` : ''}
367
+ return `<button class="button button-primary" data-action="agent-install">${icon('plus', 16)} ${h("panel.435")}</button><p class="modal-intro">${h("guide.pair2")}</p>
368
+ <details class="binding-step" ${pairing || state.pairError || state.pairManualOpen ? 'open' : ''}><summary>${h("pair.manual")}</summary><h3> ${h("panel.377")}</h3><div class="pair-code-row"><label for="pair-code" class="sr-only">${h("panel.378")}</label><input id="pair-code" data-field="pairCode" autocomplete="off" spellcheck="false" maxlength="9" placeholder="XXXX-XXXX" value="${escapeHtml(state.pairCode)}" ${disabled}><button class="button button-light" data-action="pair-lookup" ${disabled}>${state.pairBusy ? t("panel.347") : t("panel.348")}</button></div>${state.pairError ? `<p class="form-error" role="alert">${escapeHtml(t(state.pairError))}</p>` : ''}
337
369
  ${pairing ? `<div class="pair-review"><strong>${escapeHtml(pairing.name)}</strong><p>${escapeHtml(pairing.platform)} ${h("panel.350")} ${escapeHtml(new Date(pairing.expiresAt).toLocaleTimeString(getLocale()))}</p><label class="field-label">${h("panel.351")}</label><code class="fingerprint">${escapeHtml(pairing.fingerprint)}</code><p>${h("panel.352")}</p>${pairing.requestedSsh ? `<label class="binding-scope"><input id="pair-share" type="checkbox" data-field="pairShare" ${state.pairShare ? 'checked' : ''} ${disabled}>${h("panel.349")}</label>` : ''}<button class="button button-primary" data-action="pair-approve" ${disabled}>${h("panel.353")}</button></div>` : `<p class="small-note">${h("panel.354")}</p>`}</details>
338
370
  ${bindings.length ? `<details class="binding-management" open><summary>${h("count.active", { count: bindings.filter(item => item.revokedAt == null).length })}</summary>${bindings.map(item => `<div class="binding-row"><div><strong>${escapeHtml(item.name)}</strong><small>${item.revokedAt != null ? t("panel.355") : item.lastSeen == null ? t("panel.356") : `${h("panel.14")} ${formatAgo(item.lastSeen)}`}</small><code class="fingerprint">${escapeHtml(item.fingerprint)}</code></div>${item.revokedAt == null ? state.revokeId === item.id ? `<div class="revoke-confirm"><p>${h("panel.357")}</p><button class="button button-light" data-action="revoke-confirm" data-device="${item.id}" ${disabled}>${h("panel.358")}</button><button class="text-button" data-action="revoke-cancel" ${disabled}>${h("panel.359")}</button></div>` : `<button class="text-button" data-action="revoke-arm" data-device="${item.id}" ${disabled}>${h("panel.360")}</button>` : ''}</div>`).join('')}</details>` : ''}
339
- <p class="small-note">${state.overview?.accountMode === 'multi-user' ? t("panel.363") : t("panel.364")}${h("panel.379")}</p>`;
371
+ <details class="binding-existing-instructions"><summary>${h("panel.375")}</summary>
372
+ <section class="binding-step"><h3><span>1</span> ${h("panel.366")}</h3><p>${h("guide.platform")}</p><details><summary>${h("panel.368")}</summary><a class="button button-light" href="/api/driver/download" download>${h("panel.369")}</a><p>${h("panel.370")} <code>npm ci --ignore-scripts</code>${h("panel.371")} <code>${escapeHtml(sourceCommand)}</code>${h("panel.372")}</p></details></section>
373
+ <section class="binding-step"><h3><span>2</span> ${h("panel.373")}</h3><label class="binding-scope"><input id="request-ssh" type="checkbox" data-field="requestSsh" ${state.requestSsh ? 'checked' : ''} ${disabled}>${h("panel.374")}</label><textarea id="driver-command" readonly rows="3" spellcheck="false" aria-label="${h("panel.375")}">${escapeHtml(driverCommand())}</textarea><button class="button button-primary" data-action="driver-copy">${icon('copy', 15)} ${h("panel.309")}</button><p>${h("pair.browserHint")}</p></section><textarea id="manual-driver-command" readonly rows="3" spellcheck="false" aria-label="${h("panel.375")}">${escapeHtml(driverCommand().replace(' --browser', ''))}</textarea><button class="button button-light" data-action="driver-manual-copy">${h("panel.309")}</button></details>
374
+ <p class="small-note">${h("guide.footer")}</p>`;
340
375
  }
341
376
  function pairErrorText(code) {
342
377
  return ({ invalid_pairing_code: "panel.380", driver_pairing_confirmation_required: "panel.381", driver_pairing_unavailable: "panel.382", driver_pairing_expired: "panel.383", driver_pairing_consumed: "panel.384", driver_rate_limited: "panel.385", driver_limit: "panel.386" })[code] ?? "panel.387";
@@ -381,9 +416,9 @@ function residentBody() {
381
416
  }
382
417
  function renderDialog() {
383
418
  if (!state.dialog) return '';
384
- const title = { 'agent-install': t("panel.430"), profile: t("panel.49"), 'secure-help': t("panel.431"), setup: t("panel.209"), connect: t("panel.432"), 'cloud-connect': t("panel.433"), add: cloudMode() ? t("panel.434") : t("panel.435"), initialize: t("panel.436"), resident: t("panel.437"), route: t("panel.438") }[state.dialog];
419
+ const title = { 'agent-install': cloudMode() ? t("panel.435") : t("panel.430"), profile: t("panel.49"), 'secure-help': t("panel.431"), setup: t("panel.209"), connect: t("panel.432"), 'cloud-connect': t("panel.433"), add: cloudMode() ? t("panel.install.manage") : t("panel.435"), initialize: t("panel.436"), resident: t("panel.437"), route: t("panel.438") }[state.dialog];
385
420
  const body = state.dialog === 'agent-install' ? agentInstallBody() : state.dialog === 'profile' ? profileBody() : state.dialog === 'secure-help' ? secureHelpBody() : state.dialog === 'cloud-connect' ? cloudConnectBody() : state.dialog === 'setup' ? onboardingBody() : state.dialog === 'route' ? routeBody() : state.dialog === 'connect' ? connectionBody() : state.dialog === 'add' ? addBody() : state.dialog === 'initialize' ? initializationBody() : residentBody();
386
- return `<div class="modal-scrim" data-action="close"><section class="add-modal ${state.dialog === 'setup' ? 'onboarding-modal' : ''}" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><header class="modal-heading"><h2 id="dialog-title">${title}</h2>${languagePickerMarkup()}<button class="icon-button" data-action="close" aria-label="${h("panel.439")}">${icon('close')}</button></header>${body}${state.toast ? `<div class="toast" role="status">${escapeHtml(state.toast)}</div>` : ''}</section></div>`;
421
+ return `<div class="modal-scrim" data-action="close"><section class="add-modal ${state.dialog === 'setup' ? 'onboarding-modal' : state.dialog === 'agent-install' ? 'panel-install-modal' : ''}" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><header class="modal-heading"><h2 id="dialog-title">${title}</h2>${languagePickerMarkup()}<button class="icon-button" data-action="close" aria-label="${h("panel.439")}">${icon('close')}</button></header>${body}${state.toast ? `<div class="toast" role="status">${escapeHtml(state.toast)}</div>` : ''}</section></div>`;
387
422
  }
388
423
  function filteredDevices() {
389
424
  const needle = state.query.trim().toLocaleLowerCase();
@@ -430,7 +465,7 @@ function render() {
430
465
  root.innerHTML = `<div class="app-shell">${renderSidebar()}<div class="main-area">
431
466
  <header class="topbar"><span>${cloudMode() ? t("panel.451") : t("panel.452")}</span><div class="header-actions"><span class="environment-badge">${cloudMode() ? t("panel.453") : t("panel.29")}</span>${languagePickerMarkup()}</div></header>
432
467
  <main class="workspace">
433
- <section class="page-heading"><div><h1>${escapeHtml(project?.name ?? t("panel.187"))}</h1><p>${h("panel.467")}</p></div><div class="heading-actions"><button class="button button-light" data-action="agent-install">${h("panel.430")}</button><button class="button button-primary" data-action="add">${icon('plus', 16)} ${cloudMode() ? state.overview?.drivers?.some(item => item.revokedAt == null) ? t("panel.454") : t("panel.434") : t("panel.435")}</button></div></section>
468
+ <section class="page-heading"><div><h1>${escapeHtml(project?.name ?? t("panel.187"))}</h1><p>${h("panel.467")}</p></div><div class="heading-actions">${cloudMode() ? `<button class="button button-light" data-action="add">${h("panel.install.manage")}</button><button class="button button-primary" data-action="agent-install">${icon('plus',16)} ${h("panel.435")}</button>` : `<button class="button button-light" data-action="agent-install">${h("panel.430")}</button><button class="button button-primary" data-action="add">${icon('plus',16)} ${h("panel.435")}</button>`}</div></section>
434
469
  ${state.error ? '' : renderConnection()}
435
470
  <section class="list-section" aria-label="${h("panel.468")}">
436
471
  <div class="list-toolbar"><div class="filter-tabs" role="group" aria-label="${h("panel.469")}">${[['all', t("panel.455"), counts.total], ['online', t("panel.456"), counts.online], ['unknown', t("panel.457"), counts.unknown], ['expired', t("panel.458"), counts.expired]].map(([key, label, count]) => `<button class="filter-tab ${state.filter === key ? 'active' : ''}" data-action="filter" data-filter="${key}" aria-pressed="${state.filter === key}">${label}<span>${count}</span></button>`).join('')}</div>
@@ -533,14 +568,24 @@ async function copyField(kind) {
533
568
  if (!copied) { root.querySelector('#copy-field')?.focus(); root.querySelector('#copy-field')?.select(); }
534
569
  }
535
570
  root.addEventListener('click', async (event) => {
571
+ const walkButton = event.target.closest('[data-walk-step]');
572
+ if (state.dialog === 'agent-install' && walkButton && !walkButton.disabled) {
573
+ const step = Number(walkButton.dataset.walkStep);
574
+ if (!Number.isInteger(step) || step < 0 || step > 2) return;
575
+ state.installStep = step; render();
576
+ root.querySelector(`#panel-install-walkthrough [data-walk-step="${step}"][aria-current="step"]`)?.focus({ preventScroll: true });
577
+ return;
578
+ }
536
579
  const button = event.target.closest('[data-action]');
537
580
  if (!button) return;
538
581
  const action = button.dataset.action;
539
582
  if (action === 'agent-install') { openAgentInstall(); render(); return; }
540
- if (action === 'install-mode') { state.installMode = button.dataset.mode; state.toast = null; render(); return; }
583
+ if (action === 'install-mode' && !cloudMode() && ['local', 'official', 'self-hosted'].includes(button.dataset.mode)) { state.installMode = button.dataset.mode; resetInstallCopy(); render(); return; }
584
+ if (action === 'install-method' && ['agent', 'manual'].includes(button.dataset.method)) { state.installMethod = button.dataset.method; resetInstallCopy(); render(); return; }
585
+ if (action === 'pair-existing' && cloudMode()) { state.dialog = 'add'; state.pairManualOpen = true; state.toast = null; state.pairError = null; render(); return; }
541
586
  if (action === 'agent-install-copy') return copyAgentInstall();
542
587
  if (action === 'logout') { try { await api.logout(); location.replace('/login?lang=' + getLocale()); } catch { state.toast = t("panel.491"); render(); } return; }
543
- if (cloudMode() && action === 'add') { state.dialog = 'add'; state.toast = null; state.pairError = null; state.revokeId = null; render(); return; }
588
+ if (cloudMode() && action === 'add') { state.pairManualOpen = false; state.dialog = 'add'; state.toast = null; state.pairError = null; state.revokeId = null; render(); return; }
544
589
  if (['driver-copy', 'cloud-command-copy', 'driver-manual-copy'].includes(action)) return copyDriverCommand(action === 'driver-manual-copy');
545
590
  if (action === 'secure-help') { state.dialog='secure-help'; state.toast=null; state.remoteError=null; render(); if (!cloudMode()) { state.remoteLoading=true; render(); try { const data=await api.remotePeers(); state.remotePeers=data.peers; state.logManager=data.driverId??''; } catch { state.remoteError=true; } finally { state.remoteLoading=false; render(); } } return; }
546
591
  if (action === 'log-command-copy') { const field=root.querySelector('#'+button.dataset.fieldId); if(!field?.value)return;field.focus();field.select();try {await navigator.clipboard.writeText(field.value);toast(t("panel.492"));}catch{toast(t("panel.493"));} return; }
@@ -584,8 +629,9 @@ root.addEventListener('click', async (event) => {
584
629
  render();
585
630
  });
586
631
  root.addEventListener('change', (event) => {
587
- if (event.target.dataset.field === 'installOs') { state.installOs = event.target.value; state.toast = null; render(); return; }
588
- if (event.target.dataset.field === 'installBackground') { state.installBackground = event.target.checked; render(); return; }
632
+ if (event.target.dataset.field === 'installPairing' && ['browser', 'headless'].includes(event.target.value)) { state.installPairing = event.target.value; resetInstallCopy(); render(); return; }
633
+ if (event.target.dataset.field === 'installOs') { state.installOs = event.target.value; resetInstallCopy(); render(); return; }
634
+ if (event.target.dataset.field === 'installBackground') { state.installBackground = event.target.checked; resetInstallCopy(); render(); return; }
589
635
  if (event.target.dataset.profileField && state.profileDraft) { state.profileDraft[event.target.dataset.profileField] = event.target.value; return; }
590
636
  if (['requestSsh', 'pairShare'].includes(event.target.dataset.field)) { state[event.target.dataset.field] = event.target.checked; render(); return; }
591
637
  if (['relayProvider', 'preference'].includes(event.target.dataset.field) && state.connectionDraft) { state.connectionDraft[event.target.dataset.field] = event.target.value; state.connectionError = null; render(); return; }
@@ -596,12 +642,13 @@ root.addEventListener('change', (event) => {
596
642
  });
597
643
  root.addEventListener('input', (event) => {
598
644
  if (event.target.dataset.field === 'installOrigin') {
599
- state.installOrigin = event.target.value;
645
+ state.installOrigin = event.target.value; resetInstallCopy(); root.querySelector('.toast')?.remove();
600
646
  const draft = installDraft();
601
647
  root.querySelector('#agent-install-text').value = draft.text;
602
648
  const message = root.querySelector('#install-error'); message.textContent = draft.error ?? ''; message.hidden = !draft.error;
603
649
  root.querySelector('[data-action="agent-install-copy"]').disabled = Boolean(draft.error);
604
650
  root.querySelector('[data-manual-install]').href = manualInstallHref(draft);
651
+ root.querySelector('#panel-install-walkthrough').innerHTML = panelWalkthrough(draft);
605
652
  return;
606
653
  }
607
654
  if (event.target.dataset.profileField && state.profileDraft) { state.profileDraft[event.target.dataset.profileField] = event.target.value; return; }
@@ -648,5 +695,5 @@ root.addEventListener('input', event => {
648
695
  for(const button of root.querySelectorAll('[data-action="log-command-copy"]'))button.disabled=Boolean(draft.error);
649
696
  });
650
697
 
651
- initLanguage({ onChange: () => { state.toast = null; render(); } });
698
+ initLanguage({ onChange: () => { resetInstallCopy(); render(); } });
652
699
  load();