device-center 0.2.0-beta.2 → 0.2.0-beta.4
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 +2 -0
- package/Dockerfile +2 -1
- package/README.md +23 -4
- package/bin/device-center.mjs +3 -2
- package/docs/agent-install.md +41 -7
- package/docs/driver-binding.md +11 -1
- package/docs/friend-trial.md +2 -2
- package/docs/hosted-access.md +1 -1
- package/docs/i18n.md +32 -0
- package/docs/npm.md +9 -7
- package/docs/onboarding.md +3 -1
- package/docs/remote-access.md +2 -2
- package/docs/roadmap.md +6 -0
- package/docs/security-and-sync.md +2 -0
- package/favicon.svg +4 -0
- package/index.html +2 -0
- package/login.html +2 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/scripts/driver.mjs +27 -10
- package/server/cloudAccess.mjs +7 -1
- package/server/drivers/browserPairing.mjs +41 -0
- package/server/drivers/cloudRegistry.mjs +23 -6
- package/server/index.mjs +5 -2
- package/src/api.js +4 -1
- package/src/device-state.js +13 -12
- package/src/i18n.css +61 -0
- package/src/i18n.js +174 -0
- package/src/install-prompt.js +19 -0
- package/src/install-walkthrough.js +49 -0
- package/src/login.js +20 -11
- package/src/main.js +137 -124
- package/src/messages.js +7365 -0
- package/src/onboarding.js +41 -13
- package/src/pair.css +23 -0
- package/src/pair.js +60 -0
- package/src/pairing-handoff.js +30 -0
- package/src/start.css +265 -45
- package/src/start.js +153 -16
- package/src/styles.css +1 -1
- package/start.html +138 -29
|
@@ -20,6 +20,8 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
|
|
|
20
20
|
}
|
|
21
21
|
db.exec('CREATE INDEX IF NOT EXISTS cloud_drivers_tenant_created ON cloud_drivers(tenant_id, created_at)');
|
|
22
22
|
for (const table of ['driver_pairings', 'cloud_drivers']) if (!db.prepare(`PRAGMA table_info(${table})`).all().some(c => c.name === 'certificate')) db.exec(`ALTER TABLE ${table} ADD COLUMN certificate TEXT`);
|
|
23
|
+
if (!db.prepare('PRAGMA table_info(driver_pairings)').all().some(c => c.name === 'browser_hash')) db.exec('ALTER TABLE driver_pairings ADD COLUMN browser_hash TEXT');
|
|
24
|
+
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS driver_pairings_browser ON driver_pairings(browser_hash)');
|
|
23
25
|
if (!db.prepare('PRAGMA table_info(cloud_drivers)').all().some(c => c.name === 'control_seen_at')) db.exec('ALTER TABLE cloud_drivers ADD COLUMN control_seen_at INTEGER');
|
|
24
26
|
db.exec(`CREATE TABLE IF NOT EXISTS driver_collection_requests (driver_id TEXT PRIMARY KEY, request_id TEXT NOT NULL,
|
|
25
27
|
requested_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, completed_at INTEGER)`);
|
|
@@ -43,6 +45,13 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
|
|
|
43
45
|
if (!row || row.expires_at <= now()) fail('driver_pairing_expired', 410);
|
|
44
46
|
return row;
|
|
45
47
|
}
|
|
48
|
+
function credential(input) {
|
|
49
|
+
if (input.browserTicket !== undefined) {
|
|
50
|
+
if (input.code !== undefined || typeof input.browserTicket !== 'string' || !/^[a-f0-9]{64}$/.test(input.browserTicket)) fail('invalid_pairing_code');
|
|
51
|
+
return { column: 'browser_hash', hash: publicCodeHash(input.browserTicket) };
|
|
52
|
+
}
|
|
53
|
+
return { column: 'code_hash', hash: hashCode(input.code) };
|
|
54
|
+
}
|
|
46
55
|
function proof(row, path, input) {
|
|
47
56
|
verifyRequest(row.public_key, origin, path, input, now());
|
|
48
57
|
db.prepare('DELETE FROM driver_nonces WHERE expires_at<=?').run(now());
|
|
@@ -76,7 +85,8 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
|
|
|
76
85
|
const registry = {
|
|
77
86
|
start(input) {
|
|
78
87
|
starts = limited(starts, 8);
|
|
79
|
-
object(input, ['publicKey', 'name', 'platform', 'shareSsh', 'certificate']);
|
|
88
|
+
object(input, ['publicKey', 'name', 'platform', 'shareSsh', 'certificate', 'browser']);
|
|
89
|
+
if (input.browser !== undefined && typeof input.browser !== 'boolean') fail('invalid_driver_scope');
|
|
80
90
|
const identity = publicIdentity(input.publicKey);
|
|
81
91
|
const certificate = input.certificate == null ? null : validateCertificate(input.certificate, identity.fingerprint, now());
|
|
82
92
|
const name = text(input.name), platform = text(input.platform, 64);
|
|
@@ -87,6 +97,11 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
|
|
|
87
97
|
if (db.prepare('SELECT count(*) AS n FROM driver_pairings').get().n >= 32) fail('driver_pairing_busy', 429);
|
|
88
98
|
const id = randomUUID(), raw = randomBytes(4).toString('hex').toUpperCase(), code = `${raw.slice(0,4)}-${raw.slice(4)}`;
|
|
89
99
|
db.prepare('INSERT INTO driver_pairings (id,code_hash,public_key,fingerprint,name,platform,requested_ssh,created_at,expires_at,certificate) VALUES (?,?,?,?,?,?,?,?,?,?)').run(id, hashCode(code), identity.publicKey, identity.fingerprint, name, platform, Number(input.shareSsh), now(), now() + PAIR_AGE, certificate);
|
|
100
|
+
if (input.browser) {
|
|
101
|
+
const browserTicket = randomBytes(32).toString('hex');
|
|
102
|
+
db.prepare('UPDATE driver_pairings SET browser_hash=? WHERE id=?').run(publicCodeHash(browserTicket), id);
|
|
103
|
+
return { id, browserTicket, fingerprint: identity.fingerprint, expiresAt: now() + PAIR_AGE, pollIntervalMs: 5000 };
|
|
104
|
+
}
|
|
90
105
|
return { id, code, fingerprint: identity.fingerprint, expiresAt: now() + PAIR_AGE, verificationUrl: origin, pollIntervalMs: 5000 };
|
|
91
106
|
},
|
|
92
107
|
poll(input) {
|
|
@@ -102,24 +117,26 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
|
|
|
102
117
|
},
|
|
103
118
|
lookup(input, tenantId) {
|
|
104
119
|
globalLookups = limited(globalLookups, 64);
|
|
105
|
-
limitAccount(lookups, tenantId); object(input, ['code']);
|
|
106
|
-
const
|
|
120
|
+
limitAccount(lookups, tenantId); object(input, ['code', 'browserTicket']);
|
|
121
|
+
const proof = credential(input);
|
|
122
|
+
const row = db.prepare(`SELECT * FROM driver_pairings WHERE ${proof.column}=?`).get(proof.hash);
|
|
107
123
|
if (!row || row.expires_at <= now() || row.driver_id) fail('driver_pairing_unavailable', 404);
|
|
108
124
|
return { id: row.id, name: row.name, platform: row.platform, fingerprint: row.fingerprint, requestedSsh: Boolean(row.requested_ssh), expiresAt: row.expires_at };
|
|
109
125
|
},
|
|
110
126
|
approve(input, tenantId) {
|
|
111
127
|
globalApprovals = limited(globalApprovals, 64); limitAccount(approvals, tenantId);
|
|
112
|
-
object(input, ['id', 'code', 'fingerprint', 'shareSsh']);
|
|
128
|
+
object(input, ['id', 'code', 'browserTicket', 'fingerprint', 'shareSsh']);
|
|
113
129
|
if (typeof input.id !== 'string' || typeof input.shareSsh !== 'boolean') fail('invalid_driver_approval');
|
|
114
|
-
if (multiUser && (typeof input.code !== 'string' || typeof input.fingerprint !== 'string')) fail('driver_pairing_confirmation_required');
|
|
130
|
+
if ((multiUser || input.browserTicket !== undefined) && ((typeof input.code !== 'string' && typeof input.browserTicket !== 'string') || typeof input.fingerprint !== 'string')) fail('driver_pairing_confirmation_required');
|
|
115
131
|
db.exec('BEGIN IMMEDIATE');
|
|
116
132
|
try {
|
|
117
133
|
const row = pairing(input.id);
|
|
134
|
+
if (row.browser_hash != null && ((input.code === undefined && input.browserTicket === undefined) || typeof input.fingerprint !== 'string')) fail('driver_pairing_confirmation_required');
|
|
118
135
|
if (row.driver_id) {
|
|
119
136
|
if (!db.prepare('SELECT id FROM cloud_drivers WHERE id=? AND tenant_id=?').get(row.driver_id, tenantId)) fail('driver_pairing_unavailable', 404);
|
|
120
137
|
fail('driver_pairing_consumed', 409);
|
|
121
138
|
}
|
|
122
|
-
if (input.code !== undefined
|
|
139
|
+
if (input.code !== undefined || input.browserTicket !== undefined) { const proof = credential(input); if (proof.hash !== row[proof.column]) fail('driver_pairing_unavailable', 404); }
|
|
123
140
|
if (input.fingerprint !== undefined && input.fingerprint !== row.fingerprint) fail('driver_pairing_confirmation_required');
|
|
124
141
|
if (input.shareSsh && !row.requested_ssh) fail('driver_scope_violation');
|
|
125
142
|
if (db.prepare('SELECT count(*) AS n FROM cloud_drivers WHERE tenant_id=? AND revoked_at IS NULL').get(tenantId).n >= 16) fail('driver_limit', 409);
|
package/server/index.mjs
CHANGED
|
@@ -42,7 +42,9 @@ async function readJson(request) {
|
|
|
42
42
|
async function serveStatic(pathname, response) {
|
|
43
43
|
let filePath;
|
|
44
44
|
if (pathname === '/' || pathname === '/index.html') filePath = resolve(PROJECT_ROOT, 'index.html');
|
|
45
|
+
else if (pathname === '/favicon.svg') filePath = resolve(PROJECT_ROOT, 'favicon.svg');
|
|
45
46
|
else if (pathname === '/login') filePath = resolve(PROJECT_ROOT, 'login.html');
|
|
47
|
+
else if (pathname === '/pair') filePath = resolve(PROJECT_ROOT, 'login.html');
|
|
46
48
|
else if (pathname === '/start' || pathname === '/start.html') filePath = resolve(PROJECT_ROOT, 'start.html');
|
|
47
49
|
else if (pathname.startsWith('/src/')) {
|
|
48
50
|
filePath = resolve(PROJECT_ROOT, `.${pathname}`);
|
|
@@ -81,8 +83,9 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
|
|
|
81
83
|
const origin = cloudAccess ? cloudAccess.validate(request) : validateLocalRequest(request);
|
|
82
84
|
const url = new URL(request.url ?? '/', origin);
|
|
83
85
|
const path = url.pathname;
|
|
86
|
+
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);
|
|
84
87
|
// Public static installation reference only. No identity, configuration or owner API.
|
|
85
|
-
if (['/start', '/start.html', '/src/start.js', '/src/start.css', '/src/onboarding.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
|
|
88
|
+
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);
|
|
86
89
|
if (Object.hasOwn(PUBLIC_GUIDES, path) && request.method === 'GET') {
|
|
87
90
|
const body = await readFile(resolve(PROJECT_ROOT, PUBLIC_GUIDES[path]));
|
|
88
91
|
response.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'content-security-policy': "default-src 'none'; frame-ancestors 'none'", 'referrer-policy': 'no-referrer' });
|
|
@@ -115,7 +118,7 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
|
|
|
115
118
|
}
|
|
116
119
|
const principal = await cloudAccess.resolvePrincipal(request);
|
|
117
120
|
if (!principal) {
|
|
118
|
-
if (path === '/' && request.method === 'GET') { response.writeHead(303, { location:
|
|
121
|
+
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(); }
|
|
119
122
|
return sendJson(response, 401, { error: 'authentication_required' });
|
|
120
123
|
}
|
|
121
124
|
accountRegistry = registry.forTenant(principal.tenantId);
|
package/src/api.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { getLocale } from './i18n.js';
|
|
1
2
|
async function request(path, { timeoutMs = 12000, ...options } = {}) {
|
|
2
3
|
const response = await fetch(path, { ...options, cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) });
|
|
3
4
|
const payload = await response.json().catch(() => ({}));
|
|
4
|
-
if (response.status === 401) { location.replace('/login'); throw new Error('authentication_required'); }
|
|
5
|
+
if (response.status === 401) { location.replace('/login?lang=' + getLocale()); throw new Error('authentication_required'); }
|
|
5
6
|
if (!response.ok) throw new Error(payload.error ?? `http_${response.status}`);
|
|
6
7
|
return payload;
|
|
7
8
|
}
|
|
8
9
|
export const api = {
|
|
10
|
+
lookupBrowserPairing: browserTicket => request('/api/drivers/pairing/lookup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ browserTicket }) }),
|
|
11
|
+
approveBrowserPairing: input => request('/api/drivers/pairing/approve', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input) }),
|
|
9
12
|
saveDeviceProfile: (profile) => request('/api/devices/profile', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(profile) }),
|
|
10
13
|
collectDevice: (deviceId) => request('/api/devices/collect', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ deviceId }), timeoutMs: 30000 }),
|
|
11
14
|
remotePeers: () => request('/api/remote/peers'),
|
package/src/device-state.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { t } from './i18n.js';
|
|
1
2
|
// Expire displayed SSH snapshots without a network request or a background SSH check.
|
|
2
3
|
export function expireSshSnapshots(overview, now = Date.now()) {
|
|
3
4
|
const expired = device => device.state === 'online' && (device.transport === 'ssh' && now - device.lastHeartbeat > 300_000 || device.source === 'driver-report' && (now - device.managementDriver.lastSeen > 90_000 || now - device.lastHeartbeat > (device.kind === 'management' ? 90_000 : 300_000)));
|
|
@@ -8,21 +9,21 @@ export function expireSshSnapshots(overview, now = Date.now()) {
|
|
|
8
9
|
return { ...overview, devices, stats };
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
export const DEVICE_TYPE_LABELS = { unspecified
|
|
12
|
+
export const DEVICE_TYPE_LABELS = { get unspecified() { return t("panel.498"); }, get personal() { return t("panel.499"); }, get server() { return t("panel.500"); }, get workstation() { return t("panel.501"); }, get other() { return t("panel.502"); } };
|
|
12
13
|
export function deviceStatus(device, now = Date.now()) {
|
|
13
|
-
if (device.state === 'unknown') return { label:
|
|
14
|
-
if (device.source === 'driver-report' && (device.managementDriver.lastSeen == null || now - device.managementDriver.lastSeen > 90_000)) return { label:
|
|
15
|
-
if (device.state === 'expired') return { label:
|
|
16
|
-
return { label:
|
|
14
|
+
if (device.state === 'unknown') return { label: t("panel.457"), title: t("panel.503") };
|
|
15
|
+
if (device.source === 'driver-report' && (device.managementDriver.lastSeen == null || now - device.managementDriver.lastSeen > 90_000)) return { label: t("panel.504"), title: t("panel.505") };
|
|
16
|
+
if (device.state === 'expired') return { label: t("panel.506"), title: t("panel.507") };
|
|
17
|
+
return { label: t("panel.456"), title: device.transport === 'ssh' ? t("panel.508") : t("panel.509") };
|
|
17
18
|
}
|
|
18
19
|
export function collectionAvailability(device, now = Date.now()) {
|
|
19
20
|
if (device.source === 'driver-report') {
|
|
20
|
-
if (device.collection?.status === 'pending' && device.collection.expiresAt > now) return { available: false, pending: true, hint:
|
|
21
|
-
if (device.kind !== 'management') return { available: false, hint:
|
|
22
|
-
if (device.managementDriver.lastSeen == null || now - device.managementDriver.lastSeen > 90_000) return { available: false, hint:
|
|
23
|
-
if (!device.collection?.supported) return { available: false, hint: device.collection?.reason === 'driver_control_unavailable' ?
|
|
24
|
-
return { available: true, hint:
|
|
21
|
+
if (device.collection?.status === 'pending' && device.collection.expiresAt > now) return { available: false, pending: true, hint: t("panel.41") };
|
|
22
|
+
if (device.kind !== 'management') return { available: false, hint: t("panel.510") };
|
|
23
|
+
if (device.managementDriver.lastSeen == null || now - device.managementDriver.lastSeen > 90_000) return { available: false, hint: t("panel.511") };
|
|
24
|
+
if (!device.collection?.supported) return { available: false, hint: device.collection?.reason === 'driver_control_unavailable' ? t("panel.512") : t("panel.513") };
|
|
25
|
+
return { available: true, hint: t("panel.514") };
|
|
25
26
|
}
|
|
26
|
-
if (device.transport === 'ssh') return { available: Boolean(device.ssh?.checkAvailable && !device.ssh.checking && !device.ssh.queued), hint:
|
|
27
|
-
return { available: device.source === 'local-runtime' && device.scope === 'host', hint:
|
|
27
|
+
if (device.transport === 'ssh') return { available: Boolean(device.ssh?.checkAvailable && !device.ssh.checking && !device.ssh.queued), hint: t("panel.515") };
|
|
28
|
+
return { available: device.source === 'local-runtime' && device.scope === 'host', hint: t("panel.516") };
|
|
28
29
|
}
|
package/src/i18n.css
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
.header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 20px; flex-wrap: wrap; }
|
|
2
|
+
.language-picker { display: inline-flex; flex: 0 0 auto; min-width: 0; color: #344d3e; font-size: 12px; }
|
|
3
|
+
.language-picker button { font-family: inherit; cursor: pointer; filter: none; }
|
|
4
|
+
.language-picker .language-trigger { display: inline-flex; align-items: center; justify-content: center; gap: 7px; width: auto; height: 34px; min-height: 34px; margin: 0; padding: 0 9px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: inherit; font-size: 12px; font-weight: 500; line-height: 1; white-space: nowrap; transition: background .15s, border-color .15s; }
|
|
5
|
+
.language-trigger > svg { width: 16px; height: 16px; flex-shrink: 0; color: #758375; }
|
|
6
|
+
.language-trigger > .language-chevron { width: 12px; height: 12px; transition: transform .15s; }
|
|
7
|
+
.language-trigger[aria-expanded="true"] > .language-chevron { transform: rotate(180deg); }
|
|
8
|
+
.language-picker .language-trigger:hover, .language-trigger[aria-expanded="true"] { background: #eaf0e6; border-color: #dde6d8; }
|
|
9
|
+
.language-picker button:focus-visible { outline: 2px solid #5c965d; outline-offset: 2px; }
|
|
10
|
+
.language-menu { position: fixed; z-index: 40; width: 304px; max-width: calc(100vw - 24px); max-height: calc(100dvh - 32px); overflow: auto; padding: 8px; border: 1px solid #e0e7dc; border-radius: 13px; background: #fff; color: #344d3e; box-shadow: 0 12px 36px #2035241a, 0 2px 6px #20352409; }
|
|
11
|
+
.language-menu[hidden] { display: none; }
|
|
12
|
+
.language-menu-title { padding: 7px 9px 10px; color: #84907f; font-size: 11px; font-weight: 500; line-height: 1.3; }
|
|
13
|
+
.language-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 3px; }
|
|
14
|
+
.language-picker .language-option { display: flex; align-items: center; justify-content: space-between; gap: 5px; width: 100%; height: auto; min-height: 40px; padding: 8px 9px; margin: 0; border: 0; border-radius: 7px; background: transparent; color: #475449; text-align: left; font-size: 12px; font-weight: 400; line-height: 1.4; }
|
|
15
|
+
.language-option > span { min-width: 0; }
|
|
16
|
+
.language-option > svg { width: 14px; height: 14px; flex-shrink: 0; opacity: 0; color: #356c4c; }
|
|
17
|
+
.language-picker .language-option:hover, .language-picker .language-option:focus-visible { background: #f2f5ef; }
|
|
18
|
+
.language-picker .language-option[aria-checked="true"] { background: #eaf2e5; color: #2f6545; font-weight: 600; }
|
|
19
|
+
.language-option[aria-checked="true"] > svg { opacity: 1; }
|
|
20
|
+
.topbar { position: relative; z-index: 4; }
|
|
21
|
+
.login-page main { position: relative; }
|
|
22
|
+
.login-page main > .language-picker { position: absolute; top: 36px; right: 24px; }
|
|
23
|
+
@media (max-width: 560px) { .header-actions { gap: 8px; } }
|
|
24
|
+
@media (prefers-reduced-motion: reduce) { .language-trigger, .language-chevron { transition: none !important; } }
|
|
25
|
+
/* Longer translations must wrap without moving the sidebar or top bar. */
|
|
26
|
+
.modal-heading { gap: 12px; flex-wrap: wrap; }
|
|
27
|
+
.modal-heading h2 { flex: 1 1 140px; min-width: 0; }
|
|
28
|
+
.modal-heading .language-picker { margin-left: auto; }
|
|
29
|
+
.nav-item svg { flex-shrink: 0; }
|
|
30
|
+
.nav-item > span { min-width: 0; overflow-wrap: anywhere; }
|
|
31
|
+
.resource-top { flex-wrap: wrap; }
|
|
32
|
+
.resource-top > span { overflow-wrap: anywhere; white-space: normal; }
|
|
33
|
+
@media (max-width: 760px) {
|
|
34
|
+
.topbar .environment-badge { display: none; }
|
|
35
|
+
.topbar .header-actions { flex-wrap: nowrap; gap: 0; }
|
|
36
|
+
.topbar > span { min-width: 0; }
|
|
37
|
+
.page-heading { flex-wrap: wrap; align-items: flex-start; gap: 8px; }
|
|
38
|
+
.page-heading > div:first-child { width: 100%; min-width: 0; }
|
|
39
|
+
.page-heading h1 { overflow-wrap: anywhere; line-height: 1.25; }
|
|
40
|
+
.heading-actions { max-width: none; width: 100%; justify-content: flex-start; }
|
|
41
|
+
.heading-actions > button { white-space: normal; max-width: 100%; }
|
|
42
|
+
.driver-summary { flex-wrap: wrap; }
|
|
43
|
+
.driver-summary p { flex-basis: 100%; margin-left: 12px; }
|
|
44
|
+
.initialize-entry { margin-left: auto; }
|
|
45
|
+
.filter-tabs { flex-wrap: wrap; }
|
|
46
|
+
.page-footer { gap: 8px; flex-wrap: wrap; }
|
|
47
|
+
.method-tabs { flex-wrap: wrap; }
|
|
48
|
+
.method-tabs button { white-space: normal; min-width: 0; }
|
|
49
|
+
.start-page header { flex-wrap: wrap; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@media (max-width: 440px) {
|
|
53
|
+
.nav-item svg { display: none; }
|
|
54
|
+
.nav-item > span { overflow-wrap: normal; hyphens: auto; }
|
|
55
|
+
}
|
|
56
|
+
@media (max-width: 560px) {
|
|
57
|
+
.modal-heading { display: grid; grid-template-columns: minmax(0, 1fr) auto; }
|
|
58
|
+
.modal-heading h2 { grid-column: 1; grid-row: 1; }
|
|
59
|
+
.modal-heading .icon-button { grid-column: 2; grid-row: 1; }
|
|
60
|
+
.modal-heading .language-picker { grid-column: 1 / -1; grid-row: 2; margin-left: 0; }
|
|
61
|
+
}
|
package/src/i18n.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { messages } from './messages.js';
|
|
2
|
+
|
|
3
|
+
export const LANGUAGES = Object.freeze([
|
|
4
|
+
{ code: 'zh-CN', name: '简体中文' }, { code: 'zh-TW', name: '繁體中文' },
|
|
5
|
+
{ code: 'en', name: 'English' }, { code: 'ja', name: '日本語' },
|
|
6
|
+
{ code: 'ko', name: '한국어' }, { code: 'es', name: 'Español' },
|
|
7
|
+
{ code: 'fr', name: 'Français' }, { code: 'de', name: 'Deutsch' },
|
|
8
|
+
{ code: 'pt-BR', name: 'Português (Brasil)' },
|
|
9
|
+
]);
|
|
10
|
+
const STORAGE_KEY = 'device-center.locale';
|
|
11
|
+
export function normalizeLocale(value) {
|
|
12
|
+
if (typeof value !== 'string') return null;
|
|
13
|
+
const tag = value.replaceAll('_', '-').toLowerCase();
|
|
14
|
+
if (/^zh(?:-|$)/.test(tag)) return /(?:hant|tw|hk|mo)(?:-|$)/.test(tag) ? 'zh-TW' : 'zh-CN';
|
|
15
|
+
if (/^pt(?:-|$)/.test(tag)) return 'pt-BR';
|
|
16
|
+
return LANGUAGES.find(item => item.code === tag.split('-')[0])?.code ?? null;
|
|
17
|
+
}
|
|
18
|
+
export function resolveLocale({ query, saved, languages = [] } = {}) {
|
|
19
|
+
return normalizeLocale(query) ?? normalizeLocale(saved) ?? languages.map(normalizeLocale).find(Boolean) ?? 'en';
|
|
20
|
+
}
|
|
21
|
+
export function translate(locale, key, values = {}) {
|
|
22
|
+
const code = normalizeLocale(locale) ?? 'en';
|
|
23
|
+
const entry = lang => Object.hasOwn(messages[lang], key) ? messages[lang][key] : undefined;
|
|
24
|
+
const text = entry(code) ?? entry('en') ?? entry('zh-CN') ?? key;
|
|
25
|
+
return text.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, name) => Object.hasOwn(values, name) ? String(values[name]) : match);
|
|
26
|
+
}
|
|
27
|
+
let locale = 'zh-CN';
|
|
28
|
+
export const getLocale = () => locale;
|
|
29
|
+
export const t = (key, values) => translate(locale, key, values);
|
|
30
|
+
export const h = (key, values) => t(key, values).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]);
|
|
31
|
+
let pickerSequence = 0;
|
|
32
|
+
const globe = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a17 17 0 0 1 0 18 17 17 0 0 1 0-18Z"/></svg>';
|
|
33
|
+
const chevron = '<svg class="language-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg>';
|
|
34
|
+
function pickerContents() {
|
|
35
|
+
const id = `language-menu-${++pickerSequence}`;
|
|
36
|
+
const current = LANGUAGES.find(item => item.code === locale);
|
|
37
|
+
return `<button type="button" class="language-trigger" data-language-trigger aria-label="${h('language')}: ${current.name}" aria-haspopup="menu" aria-expanded="false" aria-controls="${id}">${globe}<span>${current.code === 'pt-BR' ? 'Português' : current.name}</span>${chevron}</button>
|
|
38
|
+
<div class="language-menu" id="${id}" data-language-menu role="menu" aria-label="${h('language')}" hidden><div class="language-menu-title" aria-hidden="true">${h('language')}</div><div class="language-options" role="none">${LANGUAGES.map(item => `<button type="button" class="language-option" data-language-option="${item.code}" role="menuitemradio" aria-checked="${locale === item.code}" tabindex="-1" lang="${item.code}"><span>${item.name}</span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="m3 8 3 3 7-7"/></svg></button>`).join('')}</div></div>`;
|
|
39
|
+
}
|
|
40
|
+
export function languagePickerMarkup() {
|
|
41
|
+
return `<div class="language-picker" data-language-picker>${pickerContents()}</div>`;
|
|
42
|
+
}
|
|
43
|
+
export function applyTranslations(root = document) {
|
|
44
|
+
for (const element of root.querySelectorAll('[data-i18n]')) element.textContent = t(element.dataset.i18n);
|
|
45
|
+
for (const attribute of ['placeholder', 'title', 'aria-label', 'content']) {
|
|
46
|
+
for (const element of root.querySelectorAll(`[data-i18n-${attribute}]`)) element.setAttribute(attribute, t(element.getAttribute(`data-i18n-${attribute}`)));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Only explicit UI markers are translated. User names, tags, code and paths are untouched.
|
|
50
|
+
export function initLanguage({ onChange = () => {} } = {}) {
|
|
51
|
+
let saved;
|
|
52
|
+
try { saved = localStorage.getItem(STORAGE_KEY); } catch { /* Private mode can deny storage. */ }
|
|
53
|
+
const params = new URLSearchParams(location.search);
|
|
54
|
+
locale = resolveLocale({ query: params.get('lang'), saved, languages: navigator.languages ?? [navigator.language] });
|
|
55
|
+
if (normalizeLocale(params.get('lang'))) {
|
|
56
|
+
try { localStorage.setItem(STORAGE_KEY, locale); } catch { /* Share links also work without storage. */ }
|
|
57
|
+
}
|
|
58
|
+
const apply = () => {
|
|
59
|
+
document.documentElement.lang = locale;
|
|
60
|
+
document.documentElement.dir = 'ltr';
|
|
61
|
+
applyTranslations();
|
|
62
|
+
for (const picker of document.querySelectorAll('[data-language-picker]')) picker.innerHTML = pickerContents();
|
|
63
|
+
// Language links stay on this origin and never include account/pairing information.
|
|
64
|
+
for (const link of document.querySelectorAll('a[data-language-link]')) {
|
|
65
|
+
const url = new URL(link.getAttribute('href'), location.origin);
|
|
66
|
+
if (url.origin === location.origin) { url.searchParams.set('lang', locale); link.setAttribute('href', `${url.pathname}${url.search}${url.hash}`); }
|
|
67
|
+
}
|
|
68
|
+
onChange(locale);
|
|
69
|
+
};
|
|
70
|
+
const closeMenus = (restoreFocus = false) => {
|
|
71
|
+
for (const picker of document.querySelectorAll('[data-language-picker]')) {
|
|
72
|
+
const trigger = picker.querySelector('[data-language-trigger]');
|
|
73
|
+
const menu = picker.querySelector('[data-language-menu]');
|
|
74
|
+
if (!menu || menu.hidden) continue;
|
|
75
|
+
menu.hidden = true; trigger.setAttribute('aria-expanded', 'false');
|
|
76
|
+
if (restoreFocus) trigger.focus({ preventScroll: true });
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const openMenu = (picker, last = false) => {
|
|
80
|
+
closeMenus();
|
|
81
|
+
const trigger = picker.querySelector('[data-language-trigger]');
|
|
82
|
+
const menu = picker.querySelector('[data-language-menu]');
|
|
83
|
+
menu.hidden = false; trigger.setAttribute('aria-expanded', 'true');
|
|
84
|
+
const anchor = trigger.getBoundingClientRect();
|
|
85
|
+
const size = menu.getBoundingClientRect();
|
|
86
|
+
menu.style.left = `${Math.max(12, Math.min(anchor.right - size.width, innerWidth - size.width - 12))}px`;
|
|
87
|
+
const top = anchor.bottom + 8;
|
|
88
|
+
menu.style.top = `${Math.max(12, top + size.height <= innerHeight - 12 ? top : anchor.top - size.height - 8)}px`;
|
|
89
|
+
const options = [...menu.querySelectorAll('[data-language-option]')];
|
|
90
|
+
(last ? options.at(-1) : menu.querySelector('[aria-checked="true"]') ?? options[0])?.focus({ preventScroll: true });
|
|
91
|
+
};
|
|
92
|
+
const choose = (value, picker) => {
|
|
93
|
+
const next = normalizeLocale(value); if (!next) return;
|
|
94
|
+
const inDialog = Boolean(picker.closest('[role="dialog"]'));
|
|
95
|
+
closeMenus(true);
|
|
96
|
+
locale = next;
|
|
97
|
+
try { localStorage.setItem(STORAGE_KEY, locale); } catch { /* Selection still works in memory. */ }
|
|
98
|
+
const url = new URL(location.href); url.searchParams.set('lang', locale);
|
|
99
|
+
try { history.replaceState(null, '', url); } catch { /* Do not prevent language selection. */ }
|
|
100
|
+
apply();
|
|
101
|
+
const container = picker.isConnected ? picker : document.querySelector(inDialog ? '[role="dialog"]' : '.topbar') ?? document;
|
|
102
|
+
container.querySelector('[data-language-trigger]')?.focus({ preventScroll: true });
|
|
103
|
+
};
|
|
104
|
+
// Capture events before the page's dialog handlers; Escape closes this menu first.
|
|
105
|
+
document.addEventListener('click', event => {
|
|
106
|
+
const trigger = event.target.closest('[data-language-trigger]');
|
|
107
|
+
const option = event.target.closest('[data-language-option]');
|
|
108
|
+
if (trigger) {
|
|
109
|
+
event.stopPropagation();
|
|
110
|
+
if (trigger.getAttribute('aria-expanded') === 'true') closeMenus(true);
|
|
111
|
+
else openMenu(trigger.closest('[data-language-picker]'));
|
|
112
|
+
} else if (option) {
|
|
113
|
+
event.stopPropagation(); choose(option.dataset.languageOption, option.closest('[data-language-picker]'));
|
|
114
|
+
} else if (!event.target.closest('[data-language-menu]')) closeMenus();
|
|
115
|
+
}, true);
|
|
116
|
+
document.addEventListener('keydown', event => {
|
|
117
|
+
const picker = event.target.closest('[data-language-picker]');
|
|
118
|
+
if (!picker) return;
|
|
119
|
+
const menu = picker.querySelector('[data-language-menu]');
|
|
120
|
+
if (menu.hidden) {
|
|
121
|
+
if (event.target.matches('[data-language-trigger]') && ['ArrowDown', 'ArrowUp'].includes(event.key)) {
|
|
122
|
+
event.preventDefault(); event.stopPropagation(); openMenu(picker, event.key === 'ArrowUp');
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeMenus(true); return; }
|
|
127
|
+
const options = [...menu.querySelectorAll('[data-language-option]')];
|
|
128
|
+
const index = options.indexOf(document.activeElement);
|
|
129
|
+
let next;
|
|
130
|
+
if (event.key === 'Home') next = 0;
|
|
131
|
+
else if (event.key === 'End') next = options.length - 1;
|
|
132
|
+
else if (event.key === 'ArrowDown') next = (index + 2) % options.length;
|
|
133
|
+
else if (event.key === 'ArrowUp') next = (index - 2 + options.length) % options.length;
|
|
134
|
+
else if (event.key === 'ArrowRight') next = (index + 1) % options.length;
|
|
135
|
+
else if (event.key === 'ArrowLeft') next = (index - 1 + options.length) % options.length;
|
|
136
|
+
if (next !== undefined) { event.preventDefault(); event.stopPropagation(); options[next].focus(); }
|
|
137
|
+
if (event.key === 'Tab') closeMenus(true);
|
|
138
|
+
}, true);
|
|
139
|
+
document.addEventListener('focusout', event => {
|
|
140
|
+
const picker = event.target.closest('[data-language-picker]');
|
|
141
|
+
if (picker?.isConnected && !picker.contains(event.relatedTarget)) closeMenus();
|
|
142
|
+
});
|
|
143
|
+
document.addEventListener('scroll', event => { if (!event.target.closest?.('[data-language-menu]')) closeMenus(); }, true);
|
|
144
|
+
window.addEventListener('resize', () => closeMenus());
|
|
145
|
+
apply();
|
|
146
|
+
return locale;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Compatibility for server-owned diagnostics only; never pass device names or user input.
|
|
150
|
+
const REPORT_MESSAGE_KEYS = {
|
|
151
|
+
"容器不能代表宿主内存": "metric.containerMemory",
|
|
152
|
+
"容器不能代表宿主磁盘": "metric.containerDisk",
|
|
153
|
+
"无法读取系统内存": "metric.memoryFailure",
|
|
154
|
+
"无法读取用户目录所在卷": "metric.diskFailure",
|
|
155
|
+
"尚无有效 SSH 采集": "metric.sshPending",
|
|
156
|
+
"SSH 内存采集失败": "metric.sshMemory",
|
|
157
|
+
"SSH 磁盘采集失败": "metric.sshDisk",
|
|
158
|
+
"容器不读取宿主 SSH 配置。请在宿主运行 Driver。": "notice.container",
|
|
159
|
+
"本机尚无可读取的 SSH 配置。": "notice.missing",
|
|
160
|
+
"Include 路径过深,已跳过。": "notice.deep",
|
|
161
|
+
"Include 目录读取达到上限,候选可能不完整。": "notice.directoryLimit",
|
|
162
|
+
"已跳过目录外的 Include。": "notice.outside",
|
|
163
|
+
"部分 Include 无法读取。": "notice.unreadable",
|
|
164
|
+
"配置读取达到上限,部分候选未列出。": "notice.limit",
|
|
165
|
+
"已跳过受限目录外或非 config / .conf 的 Include。": "notice.restricted",
|
|
166
|
+
"已跳过循环 Include。": "notice.loop",
|
|
167
|
+
"配置文件过大或不是普通文件,已跳过。": "notice.fileSize",
|
|
168
|
+
"配置文件过大,已跳过。": "notice.large",
|
|
169
|
+
"文件不是 SSH 配置,已忽略。": "notice.notConfig",
|
|
170
|
+
"候选数达到上限。": "notice.candidateLimit",
|
|
171
|
+
"含 Match 条件:仅登记别名,未解析条件或执行命令。": "notice.match",
|
|
172
|
+
"已跳过 Host / Match 内的条件 Include,候选可能不完整。": "notice.conditional"
|
|
173
|
+
};
|
|
174
|
+
export const reportMessage = value => Object.hasOwn(REPORT_MESSAGE_KEYS, value) ? t(REPORT_MESSAGE_KEYS[value]) : value;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { translate } from './i18n.js';
|
|
2
|
+
|
|
3
|
+
export function localizedInstallPrompt(plan, locale) {
|
|
4
|
+
const c = plan.commands;
|
|
5
|
+
const tr = (key, values) => translate(locale, key, values);
|
|
6
|
+
return [
|
|
7
|
+
tr('prompt.intro'),
|
|
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
|
+
plan.controlOrigin ? tr('prompt.private', { origin: plan.controlOrigin }) : '',
|
|
10
|
+
`1. ${tr('prompt.environment')}`,
|
|
11
|
+
`2. ${tr('prompt.existing', { url: plan.url, status: c.bindingStatus, service: c.serviceStatus })}`,
|
|
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}`}`}`,
|
|
14
|
+
`5. ${plan.controlOrigin ? `${tr('prompt.pairBefore')}\n ${c.pairingPlan}\n ${c.connect}\n ${tr('prompt.pairAfter', { origin: plan.controlOrigin })}` : tr('prompt.local')}`,
|
|
15
|
+
`6. ${tr('prompt.mcp', { url: plan.url, mcp: plan.mcpUrl })}`,
|
|
16
|
+
`7. ${tr('prompt.verify')}\n ${tr('prompt.uninstall')}\n ${c.uninstallService}`,
|
|
17
|
+
tr('prompt.boundaries'), tr('prompt.end'),
|
|
18
|
+
].filter(Boolean).join('\n\n');
|
|
19
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { translate } from './i18n.js';
|
|
2
|
+
|
|
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');
|
|
6
|
+
const manual = method === 'manual', cloud = mode !== 'local', headless = manual && pairingMethod === 'headless';
|
|
7
|
+
return [
|
|
8
|
+
{ tab: 'walk.pasteTab', title: manual ? 'walk.terminalTitle' : 'walk.agentTitle', body: manual ? 'walk.terminalBody' : 'walk.agentBody', picture: manual ? 'terminal' : 'chat' },
|
|
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
|
+
{ tab: 'walk.checkTab', title: 'walk.checkTitle', body: cloud ? 'walk.cloudCheckBody' : 'walk.localCheckBody', picture: 'dashboard' },
|
|
11
|
+
];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
15
|
+
const arrows = '<svg viewBox="0 0 32 20" aria-hidden="true"><path d="M2 10h26M20 3l8 7-8 7"/></svg>';
|
|
16
|
+
const clipboard = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5H5v16h14V5h-3M8 3h8v5H8zM8 12h8m-8 4h5"/></svg>';
|
|
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
|
+
|
|
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 });
|
|
21
|
+
if (!Number.isInteger(step) || step < 0 || step >= steps.length) throw new Error('invalid_installation_step');
|
|
22
|
+
const tr = (key, values) => escape(translate(locale, key, values));
|
|
23
|
+
const active = steps[step], cloud = mode !== 'local';
|
|
24
|
+
const window = (title, contents, type = '') => `<div class="picture-window ${type}"><div class="picture-titlebar"><i></i><i></i><i></i><span>${title}</span></div>${contents}</div>`;
|
|
25
|
+
const paste = type => `<div class="picture-transfer"><span>${clipboard}${tr('walk.copiedContent')}</span>${arrows}<span>${tr(type === 'terminal' ? 'walk.terminal' : 'walk.chat')}</span></div>`;
|
|
26
|
+
const fingerprint = `<div class="picture-fingerprints"><span>${tr('walk.terminal')}</span><code>${tr('walk.fullFingerprint')}</code><b>=</b><span>${tr('walk.browser')}</span><code>${tr('walk.fullFingerprint')}</code></div>`;
|
|
27
|
+
const approval = `<div class="picture-approval">${tr('walk.approve')}${pointer}</div>`;
|
|
28
|
+
const browserTitle = `<span class="picture-address">${escape(controlOrigin || 'https://devices.example.com')}</span>`;
|
|
29
|
+
let picture;
|
|
30
|
+
if (active.picture === 'terminal') {
|
|
31
|
+
picture = `${paste('terminal')}${window(tr('walk.targetTerminal'), `<div class="picture-terminal"><div class="picture-paste-line"><span>$</span><div>${tr('walk.pasteScript')}<i class="picture-caret"></i></div>${pointer}</div><div class="picture-keyline"><span>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"><span>${tr('walk.openMac')}</span><span>${tr('walk.openLinux')}</span></div>`;
|
|
32
|
+
} else if (active.picture === 'chat') {
|
|
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
|
+
} else if (active.picture === 'pair' || active.picture === 'headless') {
|
|
35
|
+
const headless = active.picture === 'headless';
|
|
36
|
+
const transfer = headless ? `<div class="picture-transfer headless-transfer"><span>${tr('walk.targetTerminal')}<code>${tr('walk.pairCode')}</code></span>${arrows}<span>${tr('walk.otherBrowser')}</span></div>` : `<div class="picture-auto-open">${tr('walk.autoBrowser')}</div>`;
|
|
37
|
+
picture = `${transfer}${window(browserTitle, `<div class="picture-pair"><div class="picture-signin"><span>01</span>${tr('walk.signIn')}</div>${headless ? `<div class="picture-code-entry"><span>${tr('walk.openPairing', { action: translate(locale, 'panel.434') })}</span><div>${tr('walk.pasteCode')}${pointer}</div></div>` : ''}<div class="picture-signin"><span>02</span>${tr('walk.compare')}</div>${fingerprint}${approval}</div>`, 'browser-picture')}`;
|
|
38
|
+
} else if (active.picture === 'install') {
|
|
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
|
+
} else {
|
|
41
|
+
picture = `${!cloud ? `<div class="picture-auto-open">${tr('walk.sameComputer')}</div>` : ''}${window(`<span class="picture-address">${escape(cloud ? controlOrigin || 'https://devices.example.com' : localUrl)}</span>`, `<div class="picture-dashboard"><div class="picture-device"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="13" rx="2"/><path d="M1 21h22M8 16l-1 5m9-5 1 5"/></svg><strong>${tr('walk.yourDevice')}</strong><span>${tr('walk.awaitReport')}</span></div><div class="picture-metrics"><div><span>${tr('panel.8')}</span><b>— / —</b><i></i></div><div><span>${tr('panel.9')}</span><b>— / —</b><i></i></div></div><div class="picture-report-time">${tr('walk.reportTime')}${pointer}</div></div>`, 'browser-picture')}`;
|
|
42
|
+
}
|
|
43
|
+
return `<ol class="walkthrough-tabs" aria-label="${tr('walk.steps')}">${steps.map((item, index) => `<li><button type="button" data-walk-step="${index}" ${index === step ? 'aria-current="step"' : ''}><span>0${index + 1}</span>${tr(item.tab)}</button></li>`).join('')}</ol>
|
|
44
|
+
<div class="walkthrough-caption" aria-live="polite"><span>${tr(active.title)}</span><small>${tr('walk.counter', { current: step + 1, total: steps.length })}</small></div>
|
|
45
|
+
<div class="walkthrough-picture" role="img" aria-label="${tr(active.title)}. ${tr(active.body)}">${picture}</div>
|
|
46
|
+
<p class="walkthrough-description">${tr(active.body)}</p>
|
|
47
|
+
${step === 0 && !copied ? `<p class="walkthrough-copy-first">${tr('walk.copyFirst')}</p>` : ''}
|
|
48
|
+
<div class="walkthrough-navigation"><button type="button" data-walk-step="${step - 1}" ${step === 0 ? 'disabled' : ''}>${tr('walk.previous')}</button><button type="button" data-walk-step="${step === 2 ? 0 : step + 1}">${tr(step === 2 ? 'walk.restart' : 'walk.next')}</button></div>`;
|
|
49
|
+
}
|
package/src/login.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import { initLanguage, getLocale, t } from './i18n.js';
|
|
2
|
+
import { loginDestination } from './pairing-handoff.js';
|
|
3
|
+
if (location.pathname === '/pair') {
|
|
4
|
+
await import('./pair.js');
|
|
5
|
+
} else {
|
|
6
|
+
initLanguage();
|
|
7
|
+
function setMessage(element, key) { if (key) element.dataset.i18n = key; else delete element.dataset.i18n; element.textContent = key ? t(key) : ''; }
|
|
1
8
|
const form = document.querySelector('#login');
|
|
2
9
|
const loading = document.querySelector('#loading');
|
|
3
10
|
let checking = false, watchingUntil = 0, timer, options;
|
|
@@ -8,19 +15,19 @@ async function continueUnified(manual = false) {
|
|
|
8
15
|
button.disabled = true;
|
|
9
16
|
try {
|
|
10
17
|
const response = await fetch('/api/auth/continue', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', signal: AbortSignal.timeout(10_000) });
|
|
11
|
-
if (response.ok) { clearInterval(timer); location.replace(
|
|
12
|
-
if (response.status === 401) message
|
|
18
|
+
if (response.ok) { clearInterval(timer); location.replace(loginDestination(getLocale())); return; }
|
|
19
|
+
if (response.status === 401) setMessage(message, manual ? 'login.first' : null);
|
|
13
20
|
else {
|
|
14
21
|
clearInterval(timer);
|
|
15
|
-
message
|
|
22
|
+
setMessage(message, response.status === 403 ? 'login.owner' : response.status === 429 ? 'login.rate' : 'login.unavailable');
|
|
16
23
|
}
|
|
17
|
-
} catch { message
|
|
24
|
+
} catch { setMessage(message, 'login.timeout'); clearInterval(timer); }
|
|
18
25
|
finally { checking = false; button.disabled = false; }
|
|
19
26
|
}
|
|
20
27
|
document.querySelector('#continue-login').addEventListener('click', () => continueUnified(true));
|
|
21
28
|
document.querySelector('#unified-login').addEventListener('click', () => {
|
|
22
29
|
watchingUntil = Date.now() + 5 * 60_000;
|
|
23
|
-
document.querySelector('#unified-message')
|
|
30
|
+
setMessage(document.querySelector('#unified-message'), 'login.return');
|
|
24
31
|
clearInterval(timer);
|
|
25
32
|
timer = setInterval(() => { if (Date.now() > watchingUntil) clearInterval(timer); else void continueUnified(); }, 4000);
|
|
26
33
|
});
|
|
@@ -30,7 +37,7 @@ try {
|
|
|
30
37
|
const response = await fetch('/api/auth/options', { signal: AbortSignal.timeout(5000) });
|
|
31
38
|
if (!response.ok) throw new Error();
|
|
32
39
|
options = await response.json();
|
|
33
|
-
document.querySelector('#access-notes')
|
|
40
|
+
setMessage(document.querySelector('#access-notes'), options.accountMode === 'multi-user' ? 'login.multi' : 'login.personal');
|
|
34
41
|
loading.hidden = true;
|
|
35
42
|
if (options.provider === 'app-services') {
|
|
36
43
|
const target = new URL(options.accountLoginUrl);
|
|
@@ -40,17 +47,19 @@ try {
|
|
|
40
47
|
if (options.automaticContinue) void continueUnified();
|
|
41
48
|
} else if (options.provider === 'password') { form.hidden = false; form.elements.username.focus(); }
|
|
42
49
|
else throw new Error();
|
|
43
|
-
} catch { loading.hidden = false; loading
|
|
50
|
+
} catch { loading.hidden = false; setMessage(loading, 'login.optionsError'); }
|
|
44
51
|
form.addEventListener('submit', async (event) => {
|
|
45
52
|
event.preventDefault();
|
|
46
53
|
const button = form.querySelector('button');
|
|
47
54
|
const message = document.querySelector('#message');
|
|
48
|
-
button.disabled = true; message
|
|
55
|
+
button.disabled = true; setMessage(message, null);
|
|
49
56
|
try {
|
|
50
57
|
const data = Object.fromEntries(new FormData(form));
|
|
51
58
|
const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(data), signal: AbortSignal.timeout(10_000) });
|
|
52
|
-
if (!response.ok) throw new Error(response.status === 429 ? '
|
|
53
|
-
form.reset(); location.replace(
|
|
54
|
-
} catch (cause) { message
|
|
59
|
+
if (!response.ok) throw new Error(response.status === 429 ? 'login.rate' : 'login.credentials');
|
|
60
|
+
form.reset(); location.replace(loginDestination(getLocale()));
|
|
61
|
+
} catch (cause) { setMessage(message, cause.name === 'TimeoutError' ? 'login.timeout' : cause.message.startsWith('login.') ? cause.message : 'login.unavailable'); }
|
|
55
62
|
finally { button.disabled = false; }
|
|
56
63
|
});
|
|
64
|
+
|
|
65
|
+
}
|