device-center 0.2.0-beta.4 → 0.2.0-beta.6
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/CONTRIBUTING.md +1 -1
- package/README.md +25 -8
- package/SECURITY.md +14 -2
- package/bin/device-center.mjs +29 -5
- package/docs/agent-install.md +5 -5
- package/docs/cli.md +97 -0
- package/docs/driver-binding.md +5 -5
- package/docs/friend-trial.md +5 -5
- package/docs/hosted-access.md +1 -1
- package/docs/npm.md +12 -10
- package/docs/onboarding.md +4 -4
- package/docs/remote-access.md +2 -2
- package/docs/roadmap.md +13 -1
- package/docs/security-and-sync.md +2 -0
- package/docs/troubleshooting.md +11 -0
- package/docs/windows-acceptance.md +57 -0
- package/login.html +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/scripts/access.mjs +0 -1
- package/scripts/driver.mjs +2 -3
- package/scripts/service.mjs +42 -16
- package/server/cli/commands.mjs +181 -0
- package/server/cloudAccess.mjs +1 -1
- package/server/drivers/access.mjs +15 -14
- package/server/drivers/browserPairing.mjs +9 -4
- package/server/drivers/cliClient.mjs +87 -0
- package/server/drivers/cliManagement.mjs +119 -0
- package/server/drivers/cloudLink.mjs +19 -20
- package/server/drivers/cloudRegistry.mjs +6 -0
- package/server/drivers/privateFiles.mjs +57 -0
- package/server/drivers/windowsSecurity.mjs +119 -0
- package/server/drivers/windowsService.mjs +77 -0
- package/server/index.mjs +36 -8
- package/server/unifiedAccess.mjs +24 -4
- package/src/api.js +2 -0
- package/src/cli-approval.js +49 -0
- package/src/cli-handoff.js +19 -0
- package/src/install-prompt.js +2 -2
- package/src/install-walkthrough.js +6 -6
- package/src/login-flow.js +37 -0
- package/src/login.css +3 -1
- package/src/login.js +15 -10
- package/src/main.js +7 -6
- package/src/messages.js +359 -117
- package/src/onboarding.js +35 -15
- package/src/pairing-handoff.js +6 -2
- package/src/start.js +10 -2
- package/src/styles.css +1 -0
- package/start.html +12 -4
package/server/index.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createServer as createHttpServer } from 'node:http';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
2
3
|
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import { extname, resolve } from 'node:path';
|
|
4
|
+
import { extname, resolve, sep } from 'node:path';
|
|
4
5
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
6
|
import { openDatabase } from './database.mjs';
|
|
6
7
|
import { currentPrincipal } from './adapters/identity/localDemo.mjs';
|
|
@@ -14,6 +15,14 @@ import { createRemoteAgent, createRemoteClient } from './drivers/remote.mjs';
|
|
|
14
15
|
const PROJECT_ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
15
16
|
const MIME_TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png' };
|
|
16
17
|
const PUBLIC_GUIDES = Object.freeze({ '/install/agent.md': 'docs/agent-install.md', '/install/trial.md': 'docs/friend-trial.md', '/install/remote-access.md': 'docs/remote-access.md', '/install/device-profiles.md': 'docs/device-profiles.md', '/install/troubleshooting.md': 'docs/troubleshooting.md' });
|
|
18
|
+
// Deployed archives carry release.json; npm and source installs report only the package version.
|
|
19
|
+
function deployedVersion(releaseFile) {
|
|
20
|
+
const version = JSON.parse(readFileSync(resolve(PROJECT_ROOT, 'package.json'), 'utf8')).version;
|
|
21
|
+
let manifest = null;
|
|
22
|
+
try { manifest = JSON.parse(readFileSync(releaseFile, 'utf8')); } catch { /* not a deployed archive */ }
|
|
23
|
+
const matches = manifest?.kind === 'device-center-web' && manifest.version === version && /^[a-f0-9]{40}$/.test(manifest.sourceSha ?? '');
|
|
24
|
+
return { version, sourceSha: matches ? manifest.sourceSha : null };
|
|
25
|
+
}
|
|
17
26
|
function sendJson(response, status, payload, headers = {}) {
|
|
18
27
|
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...headers });
|
|
19
28
|
response.end(JSON.stringify(payload));
|
|
@@ -44,11 +53,11 @@ async function serveStatic(pathname, response) {
|
|
|
44
53
|
if (pathname === '/' || pathname === '/index.html') filePath = resolve(PROJECT_ROOT, 'index.html');
|
|
45
54
|
else if (pathname === '/favicon.svg') filePath = resolve(PROJECT_ROOT, 'favicon.svg');
|
|
46
55
|
else if (pathname === '/login') filePath = resolve(PROJECT_ROOT, 'login.html');
|
|
47
|
-
else if (pathname === '/pair') filePath = resolve(PROJECT_ROOT, 'login.html');
|
|
56
|
+
else if (pathname === '/pair' || pathname === '/cli') filePath = resolve(PROJECT_ROOT, 'login.html');
|
|
48
57
|
else if (pathname === '/start' || pathname === '/start.html') filePath = resolve(PROJECT_ROOT, 'start.html');
|
|
49
58
|
else if (pathname.startsWith('/src/')) {
|
|
50
59
|
filePath = resolve(PROJECT_ROOT, `.${pathname}`);
|
|
51
|
-
if (!filePath.startsWith(resolve(PROJECT_ROOT, 'src') +
|
|
60
|
+
if (!filePath.startsWith(resolve(PROJECT_ROOT, 'src') + sep)) return sendJson(response, 404, { error: 'not_found' });
|
|
52
61
|
} else return sendJson(response, 404, { error: 'not_found' });
|
|
53
62
|
try {
|
|
54
63
|
const body = await readFile(filePath);
|
|
@@ -61,10 +70,11 @@ async function serveStatic(pathname, response) {
|
|
|
61
70
|
} catch { sendJson(response, 404, { error: 'not_found' }); }
|
|
62
71
|
}
|
|
63
72
|
|
|
64
|
-
export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite', web = true, runtime = 'native', sshHome, sshProbe, publicOrigin, authFile, authFetch, cloudLinkFile, driverFetch, driverBundlePath = resolve(PROJECT_ROOT, 'driver-source.tar.gz') } = {}) {
|
|
73
|
+
export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite', web = true, runtime = 'native', sshHome, sshProbe, publicOrigin, authFile, authFetch, cloudLinkFile, driverFetch, driverBundlePath = resolve(PROJECT_ROOT, 'driver-source.tar.gz'), releaseFile = resolve(PROJECT_ROOT, 'release.json') } = {}) {
|
|
65
74
|
const cloudAccess = createCloudAccess({ publicOrigin, authFile, fetchImpl: authFetch });
|
|
66
75
|
if (runtime === 'cloud' && !cloudAccess) throw new Error('cloud_authentication_required');
|
|
67
76
|
const cloud = Boolean(cloudAccess);
|
|
77
|
+
const health = Object.freeze({ ok: true, mode: cloud ? 'cloud' : 'local', runtime: cloud ? 'cloud' : runtime, ...deployedVersion(releaseFile) });
|
|
68
78
|
const absoluteDbPath = resolve(dbPath);
|
|
69
79
|
const db = openDatabase(absoluteDbPath);
|
|
70
80
|
const startedAt = Date.now();
|
|
@@ -83,7 +93,10 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
|
|
|
83
93
|
const origin = cloudAccess ? cloudAccess.validate(request) : validateLocalRequest(request);
|
|
84
94
|
const url = new URL(request.url ?? '/', origin);
|
|
85
95
|
const path = url.pathname;
|
|
96
|
+
// Public liveness and deployed version only; no account, device or configuration data.
|
|
97
|
+
if (path === '/api/health' && request.method === 'GET') return sendJson(response, 200, health);
|
|
86
98
|
if (['/pair', '/src/pair.js', '/src/pair.css', '/src/pairing-handoff.js', '/src/api.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
|
|
99
|
+
if (['/cli', '/src/cli-approval.js', '/src/cli-handoff.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
|
|
87
100
|
// Public static installation reference only. No identity, configuration or owner API.
|
|
88
101
|
if (['/start', '/start.html', '/favicon.svg', '/src/start.js', '/src/start.css', '/src/onboarding.js', '/src/i18n.js', '/src/i18n.css', '/src/messages.js', '/src/install-prompt.js', '/src/install-walkthrough.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
|
|
89
102
|
if (Object.hasOwn(PUBLIC_GUIDES, path) && request.method === 'GET') {
|
|
@@ -95,8 +108,9 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
|
|
|
95
108
|
if (cloud) {
|
|
96
109
|
const machineActions = { '/api/driver/pair/start': 'start', '/api/driver/pair/poll': 'poll', '/api/driver/report': 'report', '/api/driver/disconnect': 'disconnect' };
|
|
97
110
|
if (Object.hasOwn(machineActions, path) && request.method === 'POST') return sendJson(response, 200, registry[machineActions[path]](await readJson(request)));
|
|
98
|
-
if (['control','certificate','peers','relay/open','relay/accept','relay/exchange','relay/close'
|
|
99
|
-
|
|
111
|
+
if (['control','certificate','peers','relay/open','relay/accept','relay/exchange','relay/close',
|
|
112
|
+
'cli/start','cli/poll','cli/cancel','cli/status','cli/logout','cli/list','cli/show','cli/profile','cli/collect'].some(action => path === `/api/driver/${action}`) && request.method === 'POST') return sendJson(response, 200, registry.machine(path.slice('/api/driver/'.length), await readJson(request)));
|
|
113
|
+
if (['/login', '/src/login.js', '/src/login-flow.js', '/src/login.css'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
|
|
100
114
|
if (path === '/api/auth/options' && request.method === 'GET') return sendJson(response, 200, cloudAccess.options(request));
|
|
101
115
|
if (path === '/api/auth/continue' && request.method === 'POST') {
|
|
102
116
|
if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
|
|
@@ -116,13 +130,28 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
|
|
|
116
130
|
if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
|
|
117
131
|
return sendJson(response, 200, { ok: true }, { 'set-cookie': cloudAccess.logout(request) });
|
|
118
132
|
}
|
|
119
|
-
|
|
133
|
+
let principal;
|
|
134
|
+
try { principal = await cloudAccess.resolvePrincipal(request); }
|
|
135
|
+
catch (cause) {
|
|
136
|
+
// An identity-provider outage is recoverable in the login UI, not another trip to the account homepage.
|
|
137
|
+
if (path !== '/' || request.method !== 'GET' || cause.statusCode !== 503) throw cause;
|
|
138
|
+
const query = new URLSearchParams({ reason: 'unavailable' });
|
|
139
|
+
if (/^(zh-CN|zh-TW|en|ja|ko|es|fr|de|pt-BR)$/.test(url.searchParams.get('lang') ?? '')) query.set('lang', url.searchParams.get('lang'));
|
|
140
|
+
response.writeHead(303, { location: `/login?${query}`, 'cache-control': 'no-store' }); return response.end();
|
|
141
|
+
}
|
|
120
142
|
if (!principal) {
|
|
121
143
|
if (path === '/' && request.method === 'GET') { response.writeHead(303, { location: `/login${/^(zh-CN|zh-TW|en|ja|ko|es|fr|de|pt-BR)$/.test(url.searchParams.get('lang') ?? '') ? '?lang=' + url.searchParams.get('lang') : ''}`, 'cache-control': 'no-store' }); return response.end(); }
|
|
122
144
|
return sendJson(response, 401, { error: 'authentication_required' });
|
|
123
145
|
}
|
|
124
146
|
accountRegistry = registry.forTenant(principal.tenantId);
|
|
147
|
+
if (path === '/api/auth/session' && request.method === 'GET') return sendJson(response, 200, { authenticated: true });
|
|
125
148
|
if (path === '/api/drivers' && request.method === 'GET') return sendJson(response, 200, { drivers: accountRegistry.bindings() });
|
|
149
|
+
if (path === '/api/cli/grants' && request.method === 'GET') return sendJson(response, 200, accountRegistry.cli.grants());
|
|
150
|
+
const cliOwnerActions = { '/api/cli/lookup': 'lookup', '/api/cli/approve': 'approve', '/api/cli/deny': 'deny', '/api/cli/revoke': 'revoke' };
|
|
151
|
+
if (Object.hasOwn(cliOwnerActions, path) && request.method === 'POST') {
|
|
152
|
+
if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
|
|
153
|
+
return sendJson(response, 200, accountRegistry.cli[cliOwnerActions[path]](await readJson(request)));
|
|
154
|
+
}
|
|
126
155
|
const ownerActions = { '/api/devices/profile': 'saveProfile', '/api/devices/collect': 'collect', '/api/drivers/pairing/lookup': 'lookup', '/api/drivers/pairing/approve': 'approve', '/api/drivers/revoke': 'revoke' };
|
|
127
156
|
if (Object.hasOwn(ownerActions, path) && request.method === 'POST') {
|
|
128
157
|
if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
|
|
@@ -193,7 +222,6 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
|
|
|
193
222
|
}
|
|
194
223
|
if (path === '/api/ssh/candidates') return sendJson(response, 200, inventory.discovery());
|
|
195
224
|
if (path === '/api/ssh/initialization') return sendJson(response, 200, inventory.initialization());
|
|
196
|
-
if (path === '/api/health') return sendJson(response, 200, { ok: true, mode: cloud ? 'cloud' : 'local', runtime: cloud ? 'cloud' : runtime });
|
|
197
225
|
if (path === '/api/connector/setup') return sendJson(response, 200, {
|
|
198
226
|
...(cloud ? { name: 'device-center-connector', dataMode: 'cloud', available: false, url: null, setupCommand: null, reason: 'cloud_driver_not_bound', tools: [] } : {
|
|
199
227
|
name: 'device-center-connector', transport: 'Streamable HTTP', accessMode: 'read-only', dataMode: 'local-inventory',
|
package/server/unifiedAccess.mjs
CHANGED
|
@@ -4,6 +4,7 @@ const COOKIE = '__Host-device-center';
|
|
|
4
4
|
const SIGNED_OUT = '__Host-device-center-signed-out';
|
|
5
5
|
const SESSION_AGE = 8 * 60 * 60 * 1000;
|
|
6
6
|
const PROFILE_AGE = 10_000;
|
|
7
|
+
const PROFILE_TIMEOUT = 8_000;
|
|
7
8
|
const digest = (value) => createHash('sha256').update(value).digest('hex');
|
|
8
9
|
const failure = (message, statusCode) => Object.assign(new Error(message), { statusCode });
|
|
9
10
|
const cookie = (name, value, age) => `${name}=${value}; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=${age}`;
|
|
@@ -29,7 +30,7 @@ export function createUnifiedAccess({ config, publicOrigin, now = Date.now, fetc
|
|
|
29
30
|
const ownerEmail = typeof config.ownerEmail === 'string' ? config.ownerEmail.toLowerCase() : undefined;
|
|
30
31
|
const validId = id => typeof id === 'string' && id.length > 0 && id.length <= 256 && !/[\x00-\x1f\x7f]/.test(id);
|
|
31
32
|
if (config.legacyOwnerId !== undefined && (accountMode !== 'multi-user' || !validId(config.legacyOwnerId))) throw new Error('unified_legacy_owner_invalid');
|
|
32
|
-
const sessions = new Map(), profiles = new Map(), attempts = new Map();
|
|
33
|
+
const sessions = new Map(), profiles = new Map(), attempts = new Map(), verifying = new Map();
|
|
33
34
|
let active = 0;
|
|
34
35
|
const tokenFrom = (request) => {
|
|
35
36
|
const token = readCookie(request, 'sessionToken');
|
|
@@ -40,15 +41,34 @@ export function createUnifiedAccess({ config, publicOrigin, now = Date.now, fetc
|
|
|
40
41
|
for (const [id, profile] of profiles) if (profile.expires <= now()) profiles.delete(id);
|
|
41
42
|
for (const [id, times] of attempts) if (!times.some((time) => time > now() - 60_000)) attempts.delete(id);
|
|
42
43
|
};
|
|
43
|
-
async function
|
|
44
|
+
async function requestProfile(token) {
|
|
45
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
46
|
+
try {
|
|
47
|
+
const response = await fetchImpl(new URL('/v1/me', backend), { headers: { authorization: `Bearer ${token}`, accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(PROFILE_TIMEOUT) });
|
|
48
|
+
if (attempt === 0 && [502, 503, 504].includes(response.status)) { await response.body?.cancel(); continue; }
|
|
49
|
+
return response;
|
|
50
|
+
} catch (cause) {
|
|
51
|
+
// Retry only transport failures, never an invalid identity, body or TLS redirect.
|
|
52
|
+
const transient = ['TimeoutError', 'AbortError'].includes(cause.name) || ['ETIMEDOUT', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET'].includes(cause.code ?? cause.cause?.code);
|
|
53
|
+
if (attempt || !transient) throw cause;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function verifyProfile(token) {
|
|
44
58
|
expire();
|
|
45
59
|
const key = digest(token), cached = profiles.get(key);
|
|
46
|
-
if (cached) return cached.profile;
|
|
60
|
+
if (cached) return Promise.resolve(cached.profile);
|
|
61
|
+
if (verifying.has(key)) return verifying.get(key);
|
|
62
|
+
const pending = loadProfile(token, key).finally(() => verifying.delete(key));
|
|
63
|
+
verifying.set(key, pending);
|
|
64
|
+
return pending;
|
|
65
|
+
}
|
|
66
|
+
async function loadProfile(token, key) {
|
|
47
67
|
if (active >= 8) throw failure('unified_service_busy', 503);
|
|
48
68
|
active++;
|
|
49
69
|
let phase = 'request', upstreamStatus;
|
|
50
70
|
try {
|
|
51
|
-
const response = await
|
|
71
|
+
const response = await requestProfile(token);
|
|
52
72
|
phase = 'response'; upstreamStatus = response.status;
|
|
53
73
|
if ([401, 403].includes(response.status)) throw failure('unified_sign_in_required', 401);
|
|
54
74
|
if (!response.ok || !/^application\/json(?:;|$)/i.test(response.headers.get('content-type') ?? '')) throw failure('unified_service_unavailable', 503);
|
package/src/api.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { getLocale } from './i18n.js';
|
|
2
|
+
import { clearLoginNavigation } from './login-flow.js';
|
|
2
3
|
async function request(path, { timeoutMs = 12000, ...options } = {}) {
|
|
3
4
|
const response = await fetch(path, { ...options, cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) });
|
|
4
5
|
const payload = await response.json().catch(() => ({}));
|
|
5
6
|
if (response.status === 401) { location.replace('/login?lang=' + getLocale()); throw new Error('authentication_required'); }
|
|
6
7
|
if (!response.ok) throw new Error(payload.error ?? `http_${response.status}`);
|
|
8
|
+
clearLoginNavigation();
|
|
7
9
|
return payload;
|
|
8
10
|
}
|
|
9
11
|
export const api = {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { captureCli, clearCli } from './cli-handoff.js';
|
|
2
|
+
import { initLanguage, getLocale, h } from './i18n.js';
|
|
3
|
+
const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
4
|
+
let handoff = captureCli(), request, phase = handoff ? 'loading' : 'missing', timer;
|
|
5
|
+
document.querySelector('link[href="/src/login.css"]').href = '/src/pair.css';
|
|
6
|
+
document.body.innerHTML = `<header><a class="brand" href="/start" data-language-link><img src="/favicon.svg" alt="" width="26" height="26">Device Center</a><div class="language-picker" data-language-picker></div></header><main id="cli-root" aria-live="polite"></main><footer data-i18n="cli.boundary"></footer>`;
|
|
7
|
+
document.querySelector('title').dataset.i18n = 'cli.title';
|
|
8
|
+
const root = document.querySelector('#cli-root');
|
|
9
|
+
async function api(path, input) {
|
|
10
|
+
const response = await fetch(path, { method: input === undefined ? 'GET' : 'POST', headers: input === undefined ? {} : { 'content-type': 'application/json' },
|
|
11
|
+
...(input === undefined ? {} : { body: JSON.stringify(input) }), cache: 'no-store', redirect: 'error', signal: AbortSignal.timeout(12_000) });
|
|
12
|
+
if (response.status === 401) { location.replace(`/login?lang=${getLocale()}&continue=cli`); throw new Error('authentication_required'); }
|
|
13
|
+
if (!response.ok) throw new Error('request_failed');
|
|
14
|
+
return response.json();
|
|
15
|
+
}
|
|
16
|
+
function render() {
|
|
17
|
+
root.setAttribute('aria-busy', String(phase === 'loading' || phase === 'saving'));
|
|
18
|
+
let body = '';
|
|
19
|
+
if (phase === 'ready') body = `<p>${h('cli.intro')}</p><div class="device"><strong>${escape(request.name)}</strong><p>${escape(request.platform)} · ${h('cli.duration', { minutes: request.minutes })}</p></div><p>${h('pair.fingerprint')}</p><code class="fingerprint">${escape(request.fingerprint)}</code><ul><li>${h('cli.read')}</li><li>${h('cli.profile')}</li><li>${h('cli.collect')}</li></ul><p>${h('cli.boundary')}</p><button id="approve">${h('cli.approve')}</button><button id="deny" class="secondary">${h('pair.cancel')}</button>`;
|
|
20
|
+
else if (phase === 'done') body = `<div class="status-mark" aria-hidden="true">✓</div><p>${h('cli.done')}</p><p>${h('pair.noReply')}</p><a class="primary" href="/cli?lang=${getLocale()}">${h('cli.manage')}</a>`;
|
|
21
|
+
else if (phase === 'manage') body = `<p>${h('cli.manageBody')}</p>${request.grants.map(grant => `<div class="device"><strong>${escape(grant.name)}</strong><p>${escape(new Date(grant.expiresAt).toLocaleString(getLocale()))} · ${h(grant.authorized ? 'cli.active' : 'cli.inactive')}</p><code class="fingerprint">${escape(grant.fingerprint)}</code>${grant.authorized ? `<button data-revoke="${escape(grant.driverId)}" class="secondary">${h('cli.revoke')}</button>` : ''}</div>`).join('') || `<p>${h('cli.empty')}</p>`}`;
|
|
22
|
+
else body = `<p>${h(['loading', 'saving'].includes(phase) ? 'pair.wait' : phase === 'cancelled' ? 'pair.cancelledBody' : 'cli.missing')}</p>`;
|
|
23
|
+
root.innerHTML = `<p class="eyebrow">DEVICE CENTER / CLI</p><h1>${h(phase === 'manage' ? 'cli.manage' : 'cli.title')}</h1>${body}<p><a href="/?lang=${getLocale()}">${h('pair.dashboard')}</a></p>`;
|
|
24
|
+
}
|
|
25
|
+
root.addEventListener('click', async event => {
|
|
26
|
+
const action = event.target.id, revoke = event.target.dataset.revoke;
|
|
27
|
+
if (revoke && phase === 'manage') {
|
|
28
|
+
phase = 'saving'; render();
|
|
29
|
+
try { await api('/api/cli/revoke', { driverId: revoke }); request = await api('/api/cli/grants'); phase = 'manage'; }
|
|
30
|
+
catch (error) { if (error.message === 'authentication_required') return; phase = 'missing'; }
|
|
31
|
+
render(); return;
|
|
32
|
+
}
|
|
33
|
+
if (!['approve', 'deny'].includes(action) || phase !== 'ready' || !handoff) return;
|
|
34
|
+
phase = 'saving'; render();
|
|
35
|
+
try {
|
|
36
|
+
await api(action === 'approve' ? '/api/cli/approve' : '/api/cli/deny', { ticket: handoff.ticket, ...(action === 'approve' ? { fingerprint: request.fingerprint } : {}) });
|
|
37
|
+
phase = action === 'approve' ? 'done' : 'cancelled';
|
|
38
|
+
} catch (error) { if (error.message === 'authentication_required') return; phase = 'missing'; }
|
|
39
|
+
clearTimeout(timer); clearCli(); handoff = null; render();
|
|
40
|
+
});
|
|
41
|
+
initLanguage({ onChange: render }); render();
|
|
42
|
+
try {
|
|
43
|
+
if (handoff) {
|
|
44
|
+
request = await api('/api/cli/lookup', { ticket: handoff.ticket }); phase = 'ready';
|
|
45
|
+
timer = setTimeout(() => { if (phase === 'ready') { clearCli(); handoff = null; phase = 'missing'; render(); } }, Math.max(0, request.expiresAt - Date.now()));
|
|
46
|
+
} else { request = await api('/api/cli/grants'); phase = 'manage'; }
|
|
47
|
+
render();
|
|
48
|
+
} catch (error) { if (error.message !== 'authentication_required') { clearCli(); handoff = null; phase = 'missing'; render(); } }
|
|
49
|
+
window.addEventListener('pagehide', () => clearTimeout(timer));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const KEY = 'device-center-cli-v1';
|
|
2
|
+
const safeStorage = () => { try { return window.sessionStorage; } catch { return null; } };
|
|
3
|
+
const valid = (value, now) => value && /^[a-f0-9]{64}$/.test(value.ticket ?? '') && Number.isSafeInteger(value.expiresAt) && value.expiresAt > now && value.expiresAt <= now + 330_000;
|
|
4
|
+
export function pendingCli({ storage = safeStorage(), now = Date.now() } = {}) {
|
|
5
|
+
try { const value = JSON.parse(storage.getItem(KEY)); if (valid(value, now)) return value; storage.removeItem(KEY); } catch { /* no credentials in errors */ }
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
export function clearCli({ storage = safeStorage() } = {}) { try { storage.removeItem(KEY); } catch { /* memory remains with caller */ } }
|
|
9
|
+
export function captureCli({ location = window.location, history = window.history, storage = safeStorage(), now = Date.now() } = {}) {
|
|
10
|
+
if (!location.hash) return pendingCli({ storage, now });
|
|
11
|
+
const fragment = new URLSearchParams(location.hash.slice(1));
|
|
12
|
+
history.replaceState(null, '', location.pathname + location.search); clearCli({ storage });
|
|
13
|
+
if ([...fragment.keys()].sort().join(',') !== 'expires,ticket') return null;
|
|
14
|
+
const value = { ticket: fragment.get('ticket'), expiresAt: Number(fragment.get('expires')) };
|
|
15
|
+
if (!valid(value, now)) return null;
|
|
16
|
+
try { storage.removeItem('device-center-pairing-v1'); } catch { /* handoffs stay in memory */ }
|
|
17
|
+
try { storage.setItem(KEY, JSON.stringify(value)); } catch { /* signed-in use remains in memory */ }
|
|
18
|
+
return value;
|
|
19
|
+
}
|
package/src/install-prompt.js
CHANGED
|
@@ -7,10 +7,10 @@ export function localizedInstallPrompt(plan, locale) {
|
|
|
7
7
|
tr('prompt.intro'),
|
|
8
8
|
tr('prompt.choice', { mode: tr(plan.mode === 'local' ? 'guide.local' : plan.mode === 'official' ? 'guide.hosted' : 'guide.custom'), version: plan.version, run: tr(plan.background ? 'prompt.background' : 'prompt.foreground') }),
|
|
9
9
|
plan.controlOrigin ? tr('prompt.private', { origin: plan.controlOrigin }) : '',
|
|
10
|
-
`1. ${tr('prompt.environment')}`,
|
|
10
|
+
`1. ${tr(plan.os === 'windows' ? 'prompt.windowsEnvironment' : 'prompt.environment')}`,
|
|
11
11
|
`2. ${tr('prompt.existing', { url: plan.url, status: c.bindingStatus, service: c.serviceStatus })}`,
|
|
12
12
|
`3. ${tr('prompt.userInstall')}\n ${c.install}\n ${c.version}\n ${tr('prompt.config')}`,
|
|
13
|
-
`4. ${plan.background ? `${tr('prompt.bg')}\n ${c.servicePlan}\n ${c.serviceInstall}\n ${c.serviceStatus}` : `${tr('prompt.fg')}${plan.controlOrigin ? '' : `\n ${c.foreground}`}`}`,
|
|
13
|
+
`4. ${plan.background ? `${tr(plan.os === 'windows' ? 'prompt.windowsBackground' : 'prompt.bg')}\n ${c.servicePlan}\n ${c.serviceInstall}\n ${c.serviceStatus}` : `${tr('prompt.fg')}${plan.controlOrigin ? '' : `\n ${c.foreground}`}`}`,
|
|
14
14
|
`5. ${plan.controlOrigin ? `${tr('prompt.pairBefore')}\n ${c.pairingPlan}\n ${c.connect}\n ${tr('prompt.pairAfter', { origin: plan.controlOrigin })}` : tr('prompt.local')}`,
|
|
15
15
|
`6. ${tr('prompt.mcp', { url: plan.url, mcp: plan.mcpUrl })}`,
|
|
16
16
|
`7. ${tr('prompt.verify')}\n ${tr('prompt.uninstall')}\n ${c.uninstallService}`,
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { translate } from './i18n.js';
|
|
2
2
|
|
|
3
3
|
// Instruction illustrations only. Navigation never installs software or confirms a binding.
|
|
4
|
-
export function installationSteps({ method = 'agent', mode = 'local', pairingMethod = 'browser' } = {}) {
|
|
5
|
-
if (!['agent', 'manual'].includes(method) || !['local', 'official', 'self-hosted'].includes(mode) || !['browser', 'headless'].includes(pairingMethod)) throw new Error('invalid_installation_walkthrough');
|
|
4
|
+
export function installationSteps({ method = 'agent', mode = 'local', pairingMethod = 'browser', os = 'posix' } = {}) {
|
|
5
|
+
if (!['agent', 'manual'].includes(method) || !['local', 'official', 'self-hosted'].includes(mode) || !['posix', 'windows'].includes(os) || !['browser', 'headless'].includes(pairingMethod)) throw new Error('invalid_installation_walkthrough');
|
|
6
6
|
const manual = method === 'manual', cloud = mode !== 'local', headless = manual && pairingMethod === 'headless';
|
|
7
7
|
return [
|
|
8
|
-
{ tab: 'walk.pasteTab', title: manual ? 'walk.terminalTitle' : 'walk.agentTitle', body: manual ? 'walk.terminalBody' : 'walk.agentBody', picture: manual ? 'terminal' : 'chat' },
|
|
8
|
+
{ tab: 'walk.pasteTab', title: manual ? os === 'windows' ? 'walk.windowsTitle' : 'walk.terminalTitle' : 'walk.agentTitle', body: manual ? os === 'windows' ? 'walk.windowsBody' : 'walk.terminalBody' : 'walk.agentBody', picture: manual ? 'terminal' : 'chat' },
|
|
9
9
|
{ tab: cloud ? 'walk.bindTab' : 'walk.installTab', title: cloud ? headless ? 'walk.headlessTitle' : 'walk.browserTitle' : 'walk.installTitle', body: cloud ? headless ? 'walk.headlessBody' : 'walk.browserBody' : manual ? 'walk.installBody' : 'walk.agentInstallBody', picture: cloud ? headless ? 'headless' : 'pair' : 'install' },
|
|
10
10
|
{ tab: 'walk.checkTab', title: 'walk.checkTitle', body: cloud ? 'walk.cloudCheckBody' : 'walk.localCheckBody', picture: 'dashboard' },
|
|
11
11
|
];
|
|
@@ -16,8 +16,8 @@ const arrows = '<svg viewBox="0 0 32 20" aria-hidden="true"><path d="M2 10h26M20
|
|
|
16
16
|
const clipboard = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5H5v16h14V5h-3M8 3h8v5H8zM8 12h8m-8 4h5"/></svg>';
|
|
17
17
|
const pointer = '<svg class="picture-pointer" viewBox="0 0 30 36" aria-hidden="true"><path d="m4 3 21 18-10 1-5 10z"/></svg>';
|
|
18
18
|
|
|
19
|
-
export function installWalkthroughMarkup({ method = 'agent', mode = 'local', pairingMethod = 'browser', step = 0, copied = false, controlOrigin = '', localUrl = 'http://127.0.0.1:5174', locale = 'zh-CN' } = {}) {
|
|
20
|
-
const steps = installationSteps({ method, mode, pairingMethod });
|
|
19
|
+
export function installWalkthroughMarkup({ method = 'agent', mode = 'local', pairingMethod = 'browser', os = 'posix', step = 0, copied = false, controlOrigin = '', localUrl = 'http://127.0.0.1:5174', locale = 'zh-CN' } = {}) {
|
|
20
|
+
const steps = installationSteps({ method, mode, pairingMethod, os });
|
|
21
21
|
if (!Number.isInteger(step) || step < 0 || step >= steps.length) throw new Error('invalid_installation_step');
|
|
22
22
|
const tr = (key, values) => escape(translate(locale, key, values));
|
|
23
23
|
const active = steps[step], cloud = mode !== 'local';
|
|
@@ -28,7 +28,7 @@ export function installWalkthroughMarkup({ method = 'agent', mode = 'local', pai
|
|
|
28
28
|
const browserTitle = `<span class="picture-address">${escape(controlOrigin || 'https://devices.example.com')}</span>`;
|
|
29
29
|
let picture;
|
|
30
30
|
if (active.picture === 'terminal') {
|
|
31
|
-
picture = `${paste('terminal')}${window(tr('walk.targetTerminal'), `<div class="picture-terminal"><div class="picture-paste-line"><span
|
|
31
|
+
picture = `${paste('terminal')}${window(os === 'windows' ? 'Windows PowerShell' : tr('walk.targetTerminal'), `<div class="picture-terminal"><div class="picture-paste-line"><span>${os === 'windows' ? 'PS>' : '$'}</span><div>${tr('walk.pasteScript')}<i class="picture-caret"></i></div>${pointer}</div><div class="picture-keyline"><span>${os === 'windows' ? 'Windows <kbd>Ctrl V</kbd>' : 'macOS <kbd>⌘ V</kbd> · Linux <kbd>Ctrl Shift V</kbd>'}</span><strong>${tr('walk.pressEnter')} <kbd>Enter ↵</kbd></strong></div></div>`, 'terminal-picture')}<div class="picture-launch">${os === 'windows' ? `<span>${tr('walk.openWindows')}</span>` : `<span>${tr('walk.openMac')}</span><span>${tr('walk.openLinux')}</span>`}</div>`;
|
|
32
32
|
} else if (active.picture === 'chat') {
|
|
33
33
|
picture = `${paste('chat')}${window(tr('walk.targetChat'), `<div class="picture-chat"><div class="picture-chat-message">${tr('walk.pastePrompt')}<i class="picture-caret"></i></div><div class="picture-send"><span>${tr('walk.send')}</span><b>↑</b>${pointer}</div></div>`, 'chat-picture')}`;
|
|
34
34
|
} else if (active.picture === 'pair' || active.picture === 'headless') {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// A successful POST alone does not prove that the browser saved the panel cookie.
|
|
2
|
+
// This read-only probe keeps a missing session from becoming a navigation loop.
|
|
3
|
+
const NAVIGATION_KEY = 'device-center-login-navigation';
|
|
4
|
+
const storage = () => { try { return window.sessionStorage; } catch { return null; } };
|
|
5
|
+
export function clearLoginNavigation({ store = storage() } = {}) { try { store.removeItem(NAVIGATION_KEY); } catch { /* Optional storage. */ } }
|
|
6
|
+
export function rememberLoginNavigation({ store = storage(), now = Date.now() } = {}) { try { store.setItem(NAVIGATION_KEY, String(now)); } catch { /* The session probe still applies. */ } }
|
|
7
|
+
export function pendingLoginNavigation({ store = storage(), now = Date.now() } = {}) {
|
|
8
|
+
try {
|
|
9
|
+
const raw = store.getItem(NAVIGATION_KEY), at = Number(raw);
|
|
10
|
+
if (raw && Number.isSafeInteger(at) && at <= now && now - at < 60_000) return true;
|
|
11
|
+
} catch { /* Only a timestamp is stored, never an identity, token or destination. */ }
|
|
12
|
+
clearLoginNavigation({ store }); return false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function checkedFetch(fetchImpl, path, signal, method = 'GET') {
|
|
16
|
+
const response = await fetchImpl(path, { method, credentials: 'same-origin', cache: 'no-store', redirect: 'error', signal,
|
|
17
|
+
...(method === 'POST' ? { headers: { 'content-type': 'application/json' }, body: '{}' } : {}) });
|
|
18
|
+
if (![200, 401].includes(response.status)) throw new Error(response.status === 403 ? 'login.owner' : response.status === 429 ? 'login.rate' : 'login.unavailable');
|
|
19
|
+
return response;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function continueSession({ fetchImpl = fetch, signal = AbortSignal.timeout(20_000) } = {}) {
|
|
23
|
+
try {
|
|
24
|
+
const existing = await checkedFetch(fetchImpl, '/api/auth/session', signal);
|
|
25
|
+
if (existing.status === 200 && (await existing.json()).authenticated === true) return;
|
|
26
|
+
if (existing.status !== 401) throw new Error('login.unavailable');
|
|
27
|
+
const resumed = await checkedFetch(fetchImpl, '/api/auth/continue', signal, 'POST');
|
|
28
|
+
if (resumed.status === 401) throw new Error('login.first');
|
|
29
|
+
if ((await resumed.json()).ok !== true) throw new Error('login.unavailable');
|
|
30
|
+
const session = await checkedFetch(fetchImpl, '/api/auth/session', signal);
|
|
31
|
+
if (session.status === 401) throw new Error('login.session');
|
|
32
|
+
if ((await session.json()).authenticated !== true) throw new Error('login.unavailable');
|
|
33
|
+
} catch (cause) {
|
|
34
|
+
if (cause.message?.startsWith('login.')) throw cause;
|
|
35
|
+
throw new Error(cause.name === 'TimeoutError' ? 'login.timeout' : 'login.unavailable');
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/login.css
CHANGED
|
@@ -11,7 +11,9 @@ input:focus { outline: 2px solid #769e5e; outline-offset: 2px; }
|
|
|
11
11
|
button { width: 100%; height: 42px; border: 0; border-radius: 8px; color: white; background: #3c622a; font: inherit; cursor: pointer; }
|
|
12
12
|
button:disabled { opacity: .6; }
|
|
13
13
|
#message, #unified-message { min-height: 20px; color: #a53939; }
|
|
14
|
-
.login-link { display: grid; place-items: center; min-height: 44px; margin-top:
|
|
14
|
+
.login-link { display: grid; place-items: center; min-height: 44px; margin-top: 16px; border: 1px solid #cfd7cb; border-radius: 8px; color: #3c622a; text-decoration: none; font-size: 14px; }
|
|
15
|
+
.login-link:focus-visible { outline: 2px solid #769e5e; outline-offset: 2px; }
|
|
16
|
+
#unified[aria-busy="true"] #unified-message { color: #667363; }
|
|
15
17
|
.help { line-height: 1.7; margin: 20px 0; }
|
|
16
18
|
small { color: #788473; }
|
|
17
19
|
|
package/src/login.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
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
4
|
if (location.pathname === '/pair') {
|
|
4
5
|
await import('./pair.js');
|
|
6
|
+
} else if (location.pathname === '/cli') {
|
|
7
|
+
await import('./cli-approval.js');
|
|
5
8
|
} else {
|
|
6
9
|
initLanguage();
|
|
7
10
|
function setMessage(element, key) { if (key) element.dataset.i18n = key; else delete element.dataset.i18n; element.textContent = key ? t(key) : ''; }
|
|
@@ -13,16 +16,16 @@ async function continueUnified(manual = false) {
|
|
|
13
16
|
checking = true;
|
|
14
17
|
const message = document.querySelector('#unified-message'), button = document.querySelector('#continue-login');
|
|
15
18
|
button.disabled = true;
|
|
19
|
+
document.querySelector('#unified').setAttribute('aria-busy', 'true');
|
|
20
|
+
setMessage(message, 'login.checking');
|
|
16
21
|
try {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
} catch { setMessage(message, 'login.timeout'); clearInterval(timer); }
|
|
25
|
-
finally { checking = false; button.disabled = false; }
|
|
22
|
+
await continueSession();
|
|
23
|
+
clearInterval(timer); rememberLoginNavigation(); location.replace(loginDestination(getLocale()));
|
|
24
|
+
} catch (cause) {
|
|
25
|
+
setMessage(message, cause.message === 'login.first' && !manual ? null : cause.message);
|
|
26
|
+
if (cause.message !== 'login.first') { clearInterval(timer); watchingUntil = 0; }
|
|
27
|
+
}
|
|
28
|
+
finally { checking = false; button.disabled = false; document.querySelector('#unified').setAttribute('aria-busy', 'false'); }
|
|
26
29
|
}
|
|
27
30
|
document.querySelector('#continue-login').addEventListener('click', () => continueUnified(true));
|
|
28
31
|
document.querySelector('#unified-login').addEventListener('click', () => {
|
|
@@ -44,7 +47,9 @@ try {
|
|
|
44
47
|
if (target.protocol !== 'https:' || target.username || target.password) throw new Error();
|
|
45
48
|
document.querySelector('#unified-login').href = target.href;
|
|
46
49
|
document.querySelector('#unified').hidden = false;
|
|
47
|
-
if (
|
|
50
|
+
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();
|
|
48
53
|
} else if (options.provider === 'password') { form.hidden = false; form.elements.username.focus(); }
|
|
49
54
|
else throw new Error();
|
|
50
55
|
} catch { loading.hidden = false; setMessage(loading, 'login.optionsError'); }
|
package/src/main.js
CHANGED
|
@@ -12,7 +12,7 @@ Object.assign(state, { pairCode: '', pairing: null, pairBusy: false, pairError:
|
|
|
12
12
|
Object.assign(state, { cloudChoice: 'official', customOrigin: '', logManager: '', logTarget: '', logPath: '', logLabel: 'app', remotePeers: [], remoteLoading: false, remoteError: null });
|
|
13
13
|
let cloudRefreshTimer, cloudRefreshBusy = false, cloudRefreshFailures = 0;
|
|
14
14
|
Object.assign(state, { profileDevice: null, profileDraft: null, profileError: null, profileSaving: false, collectingDevice: null, pendingCollections: {} });
|
|
15
|
-
Object.assign(state, { installMode: 'local', installOrigin: '', installBackground: true });
|
|
15
|
+
Object.assign(state, { installMode: 'local', installOs: 'posix', installOrigin: '', installBackground: true });
|
|
16
16
|
const RELAY_PROVIDERS = [
|
|
17
17
|
{ id: 'self-hosted', get name() { return t("panel.1"); }, get description() { return t("panel.2"); } },
|
|
18
18
|
{ id: 'managed', get name() { return t("panel.3"); }, get description() { return t("panel.4"); } },
|
|
@@ -217,7 +217,7 @@ function recentMcpInitialize() {
|
|
|
217
217
|
}
|
|
218
218
|
function renderSidebar() {
|
|
219
219
|
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></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>`;
|
|
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
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>`;
|
|
222
222
|
}
|
|
223
223
|
function renderConnection() {
|
|
@@ -234,17 +234,17 @@ function openAgentInstall() {
|
|
|
234
234
|
state.toast = null; state.dialog = 'agent-install';
|
|
235
235
|
}
|
|
236
236
|
function installDraft() {
|
|
237
|
-
try { return { text: agentInstallPrompt({ locale: getLocale(), mode: state.installMode, background: state.installBackground, ...(state.installMode === 'self-hosted' ? { origin: state.installOrigin.trim() } : {}) }), error: null }; }
|
|
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
238
|
catch (error) { return { text: '', error: t(error.translationKey ?? 'error.install') }; }
|
|
239
239
|
}
|
|
240
240
|
function manualInstallHref(draft) {
|
|
241
|
-
const manualParams = new URLSearchParams({ lang: getLocale(), method: 'manual', mode: state.installMode });
|
|
241
|
+
const manualParams = new URLSearchParams({ lang: getLocale(), method: 'manual', mode: state.installMode, os: state.installOs });
|
|
242
242
|
if (state.installMode === 'self-hosted' && !draft.error) manualParams.set('origin', state.installOrigin.trim());
|
|
243
243
|
return `/start?${manualParams}#install-title`;
|
|
244
244
|
}
|
|
245
245
|
function agentInstallBody() {
|
|
246
246
|
const draft = installDraft();
|
|
247
|
-
return `<p class="modal-intro">${h("panel.219")}</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>`;
|
|
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>`;
|
|
248
248
|
}
|
|
249
249
|
async function copyAgentInstall() {
|
|
250
250
|
const field = root.querySelector('#agent-install-text');
|
|
@@ -311,7 +311,7 @@ function cloudConnectBody() {
|
|
|
311
311
|
function logCommandDraft() {
|
|
312
312
|
const peers = cloudMode() ? state.overview?.drivers?.filter(p=>p.revokedAt == null) ?? [] : state.remotePeers;
|
|
313
313
|
const manager = peers.find(p=>p.id === state.logManager), target = peers.find(p=>p.id === state.logTarget);
|
|
314
|
-
try { return { ...logAccessCommands({manager:manager?.fingerprint,target:target?.fingerprint,path:state.logPath,label:state.logLabel}), error:null }; }
|
|
314
|
+
try { return { ...logAccessCommands({manager:manager?.fingerprint,target:target?.fingerprint,path:state.logPath,label:state.logLabel,os:/Windows/i.test(target?.platform ?? '')?'windows':'posix',managerOs:/Windows/i.test(manager?.platform ?? '')?'windows':'posix'}), error:null }; }
|
|
315
315
|
catch(error) { return {trust:'',grant:'',error:t(error.translationKey ?? 'error.devices')}; }
|
|
316
316
|
}
|
|
317
317
|
function logCommandFields() {
|
|
@@ -584,6 +584,7 @@ root.addEventListener('click', async (event) => {
|
|
|
584
584
|
render();
|
|
585
585
|
});
|
|
586
586
|
root.addEventListener('change', (event) => {
|
|
587
|
+
if (event.target.dataset.field === 'installOs') { state.installOs = event.target.value; state.toast = null; render(); return; }
|
|
587
588
|
if (event.target.dataset.field === 'installBackground') { state.installBackground = event.target.checked; render(); return; }
|
|
588
589
|
if (event.target.dataset.profileField && state.profileDraft) { state.profileDraft[event.target.dataset.profileField] = event.target.value; return; }
|
|
589
590
|
if (['requestSsh', 'pairShare'].includes(event.target.dataset.field)) { state[event.target.dataset.field] = event.target.checked; render(); return; }
|