device-center 0.2.0-beta.6 → 0.2.0-beta.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -9
- package/docs/SKILL.md +55 -0
- package/docs/agent-install.md +16 -10
- package/docs/cli.md +9 -1
- package/docs/friend-trial.md +2 -2
- package/docs/hosted-access.md +8 -4
- package/docs/npm.md +8 -8
- package/docs/offline-install.md +57 -0
- package/docs/troubleshooting.md +9 -1
- package/docs/windows-acceptance.md +2 -2
- package/index.html +1 -0
- package/login.html +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +7 -3
- package/scripts/driver.mjs +57 -29
- package/scripts/service.mjs +18 -0
- package/server/drivers/cloudRegistry.mjs +19 -6
- package/server/drivers/connectionRecovery.mjs +10 -0
- package/server/drivers/windowsService.mjs +38 -9
- package/server/index.mjs +12 -6
- package/server/installerPackages.mjs +45 -0
- package/server/unifiedAccess.mjs +1 -1
- package/src/agent-setup.js +49 -0
- package/src/api.js +2 -1
- package/src/cli-approval.js +37 -5
- package/src/install-packages.js +34 -0
- package/src/install-walkthrough.css +79 -0
- package/src/install-walkthrough.js +1 -1
- package/src/installed-cli.js +6 -0
- package/src/login-flow.js +35 -0
- package/src/login.js +15 -20
- package/src/main.js +74 -27
- package/src/messages.js +590 -108
- package/src/onboarding.js +5 -5
- package/src/pair-recovery.js +4 -0
- package/src/pair.css +24 -0
- package/src/pair.js +37 -5
- package/src/pairing-handoff.js +7 -2
- package/src/start.css +13 -80
- package/src/start.js +32 -6
- package/src/styles.css +24 -0
- package/start.html +37 -16
|
@@ -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.
|
|
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,
|
|
14
|
-
|
|
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
|
-
|
|
28
|
+
clearAccountNavigation(); rememberLoginNavigation(); location.replace(loginDestination(getLocale()));
|
|
24
29
|
} catch (cause) {
|
|
25
|
-
|
|
26
|
-
|
|
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',
|
|
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,
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 {
|
|
238
|
-
|
|
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:
|
|
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">${
|
|
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(
|
|
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
|
-
|
|
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("
|
|
334
|
-
<
|
|
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
|
-
<
|
|
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.
|
|
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"
|
|
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;
|
|
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 === '
|
|
588
|
-
if (event.target.dataset.field === '
|
|
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: () => {
|
|
698
|
+
initLanguage({ onChange: () => { resetInstallCopy(); render(); } });
|
|
652
699
|
load();
|