device-center 0.2.0-beta.1 → 0.2.0-beta.3

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.
@@ -2,6 +2,7 @@ import { randomBytes, randomUUID, createHash } from 'node:crypto';
2
2
  import { fail, object, text, publicIdentity, verifyRequest, normalizeReport } from './driverProtocol.mjs';
3
3
  import { validateCertificate } from './certificates.mjs';
4
4
  import { createRelay } from './relay.mjs';
5
+ import { createDeviceProfiles } from './deviceProfiles.mjs';
5
6
 
6
7
  const PAIR_AGE = 5 * 60_000;
7
8
  export function createCloudRegistry({ db, origin, now = Date.now, multiUser = false, legacyOwnerMapped = !multiUser }) {
@@ -19,6 +20,10 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
19
20
  }
20
21
  db.exec('CREATE INDEX IF NOT EXISTS cloud_drivers_tenant_created ON cloud_drivers(tenant_id, created_at)');
21
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(cloud_drivers)').all().some(c => c.name === 'control_seen_at')) db.exec('ALTER TABLE cloud_drivers ADD COLUMN control_seen_at INTEGER');
24
+ db.exec(`CREATE TABLE IF NOT EXISTS driver_collection_requests (driver_id TEXT PRIMARY KEY, request_id TEXT NOT NULL,
25
+ requested_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, completed_at INTEGER)`);
26
+ const profiles = createDeviceProfiles({ db, now, initialize: true });
22
27
  if (multiUser && !legacyOwnerMapped && db.prepare("SELECT 1 FROM cloud_drivers WHERE tenant_id='cloud-owner' LIMIT 1").get()) throw new Error('cloud_legacy_owner_mapping_required');
23
28
  let starts = [], globalLookups = [], globalApprovals = [];
24
29
  const lookups = new Map(), approvals = new Map();
@@ -54,7 +59,18 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
54
59
  if (row.revoked_at != null) fail('driver_revoked', 403);
55
60
  return row;
56
61
  }
57
- const descriptor = row => ({ id: row.id, name: row.name, platform: row.platform, fingerprint: row.fingerprint, shareSsh: Boolean(row.share_ssh), createdAt: row.created_at, revokedAt: row.revoked_at, lastSeen: row.last_seen });
62
+ const descriptor = row => {
63
+ const profile = profiles.get(row.tenant_id, `${row.id}:host`);
64
+ return { id: row.id, name: profile.displayName || row.name, reportedName: row.name, profile, platform: row.platform, fingerprint: row.fingerprint, shareSsh: Boolean(row.share_ssh), createdAt: row.created_at, revokedAt: row.revoked_at, lastSeen: row.last_seen };
65
+ };
66
+ const collection = row => {
67
+ const request = db.prepare('SELECT * FROM driver_collection_requests WHERE driver_id=?').get(row.id);
68
+ const connected = row.last_seen != null && now() - row.last_seen <= 90_000;
69
+ const supported = connected && row.control_seen_at != null && now() - row.control_seen_at <= 30_000;
70
+ return { supported, reason: !connected ? 'driver_not_connected' : row.control_seen_at == null ? 'driver_upgrade_required' : !supported ? 'driver_control_unavailable' : null,
71
+ status: !request ? 'idle' : request.completed_at != null ? 'completed' : request.expires_at <= now() ? 'timeout' : 'pending',
72
+ requestedAt: request?.requested_at ?? null, completedAt: request?.completed_at ?? null, expiresAt: request?.expires_at ?? null };
73
+ };
58
74
  const peers = row => db.prepare('SELECT * FROM cloud_drivers WHERE tenant_id=? AND revoked_at IS NULL AND certificate IS NOT NULL').all(row.tenant_id).map(peer => ({ ...descriptor(peer), certificate: peer.certificate }));
59
75
  const relay = createRelay({ db, now, getDriver: id => db.prepare('SELECT * FROM cloud_drivers WHERE id=?').get(id) });
60
76
  const registry = {
@@ -118,6 +134,7 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
118
134
  const report = normalizeReport(input.payload, Boolean(row.share_ssh), now());
119
135
  if (row.last_seen != null && now() - row.last_seen < 15_000) fail('driver_report_too_frequent', 429);
120
136
  db.prepare('UPDATE cloud_drivers SET last_seen=?, report_json=? WHERE id=?').run(now(), JSON.stringify(report), row.id);
137
+ if (report.collectionRequestId && report.resources[0].observedAt != null) db.prepare('UPDATE driver_collection_requests SET completed_at=? WHERE driver_id=? AND request_id=? AND completed_at IS NULL AND expires_at>?').run(now(), row.id, report.collectionRequestId, now());
121
138
  return { ok: true, receivedAt: now() };
122
139
  },
123
140
  disconnect(input) {
@@ -133,6 +150,24 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
133
150
  return { ok: true };
134
151
  },
135
152
  bindings: tenantId => db.prepare('SELECT * FROM cloud_drivers WHERE tenant_id=? ORDER BY created_at').all(tenantId).map(descriptor),
153
+ saveProfile(input, tenantId) {
154
+ if (!registry.devices(tenantId).some(d => d.id === input?.deviceId)) fail('device_not_found', 404);
155
+ return { profile: profiles.save(tenantId, input) };
156
+ },
157
+ collect(input, tenantId) {
158
+ object(input, ['deviceId']);
159
+ if (typeof input.deviceId !== 'string') fail('invalid_device_id');
160
+ const device = registry.devices(tenantId).find(d => d.id === input.deviceId);
161
+ if (!device) fail('device_not_found', 404);
162
+ if (device.kind !== 'management') fail('collection_requires_local_ssh_check', 409);
163
+ const row = db.prepare('SELECT * FROM cloud_drivers WHERE id=? AND tenant_id=? AND revoked_at IS NULL').get(device.managementDriver.id, tenantId);
164
+ const current = collection(row);
165
+ if (!current.supported) fail(current.reason, 409);
166
+ if (current.status === 'pending') return { status: 'pending', collection: current };
167
+ if (current.requestedAt != null && now() - current.requestedAt < 10_000) fail('collection_rate_limited', 429);
168
+ db.prepare('INSERT INTO driver_collection_requests VALUES (?,?,?,?,NULL) ON CONFLICT(driver_id) DO UPDATE SET request_id=excluded.request_id,requested_at=excluded.requested_at,expires_at=excluded.expires_at,completed_at=NULL').run(row.id, randomUUID(), now(), now() + 75_000);
169
+ return { status: 'pending', collection: collection(row) };
170
+ },
136
171
  devices(tenantId) {
137
172
  return db.prepare('SELECT * FROM cloud_drivers WHERE tenant_id=? AND revoked_at IS NULL ORDER BY created_at').all(tenantId).flatMap(row => {
138
173
  const report = row.report_json ? JSON.parse(row.report_json).resources : [{ id: 'host', name: row.name, platform: row.platform, kind: 'management', observedAt: null, memory: { status: 'unobserved', usedMb: null, totalMb: null }, disk: { status: 'unobserved', usedGb: null, totalGb: null } }];
@@ -140,7 +175,7 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
140
175
  const latest = resource.observedAt == null || row.last_seen == null ? null : Math.min(resource.observedAt, row.last_seen);
141
176
  const age = latest == null ? null : Math.max(0, now() - latest);
142
177
  const connected = row.last_seen != null && now() - row.last_seen <= 90_000;
143
- return { ...resource, id: `${row.id}:${resource.id}`, role: resource.kind === 'management' ? 'driver' : 'resource', source: 'driver-report', transport: 'driver', scope: 'host', state: age == null ? 'unknown' : !connected || age > (resource.kind === 'management' ? 90_000 : 300_000) ? 'expired' : 'online', lastHeartbeat: latest, heartbeatAgeMs: age, memory: resource.memory, disk: resource.disk, synthetic: false, managementDriver: { ...descriptor(row), origin, connected }, capabilities: { observation: true, remoteExecution: false } };
178
+ return profiles.apply(tenantId, { ...resource, id: `${row.id}:${resource.id}`, role: resource.kind === 'management' ? 'driver' : 'resource', source: 'driver-report', transport: 'driver', scope: 'host', state: age == null ? 'unknown' : !connected || age > (resource.kind === 'management' ? 90_000 : 300_000) ? 'expired' : 'online', lastHeartbeat: latest, heartbeatAgeMs: age, memory: resource.memory, disk: resource.disk, synthetic: false, managementDriver: { ...descriptor(row), origin, connected }, collection: resource.kind === 'management' ? collection(row) : { supported: false, reason: 'collection_requires_local_ssh_check', status: 'idle' }, capabilities: { observation: true, remoteExecution: false } });
144
179
  });
145
180
  });
146
181
  },
@@ -149,6 +184,13 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
149
184
  start: registry.start, poll: registry.poll, report: registry.report, disconnect: registry.disconnect,
150
185
  machine(action, input) {
151
186
  const row = driver(input, `/api/driver/${action}`);
187
+ if (action === 'control') {
188
+ object(input.payload, []);
189
+ if (row.control_seen_at != null && now() - row.control_seen_at < 3000) fail('driver_control_too_frequent', 429);
190
+ db.prepare('UPDATE cloud_drivers SET control_seen_at=? WHERE id=?').run(now(), row.id);
191
+ const request = db.prepare('SELECT * FROM driver_collection_requests WHERE driver_id=? AND completed_at IS NULL AND expires_at>?').get(row.id, now());
192
+ return { request: request ? { id: request.request_id, action: 'collect-resources', expiresAt: request.expires_at } : null };
193
+ }
152
194
  if (action === 'certificate') { object(input.payload, ['certificate']); const cert = validateCertificate(input.payload.certificate, row.fingerprint, now()); db.prepare('UPDATE cloud_drivers SET certificate=? WHERE id=?').run(cert, row.id); return { ok: true }; }
153
195
  if (action === 'peers') { object(input.payload, []); return { peers: peers(row) }; }
154
196
  if (!row.certificate) fail('driver_certificate_required', 409);
@@ -160,6 +202,7 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
160
202
  return {
161
203
  lookup: input => registry.lookup(input, tenantId), approve: input => registry.approve(input, tenantId),
162
204
  revoke: input => registry.revoke(input, tenantId), bindings: () => registry.bindings(tenantId), devices: () => registry.devices(tenantId), relayUsage: () => relay.usage(tenantId),
205
+ saveProfile: input => registry.saveProfile(input, tenantId), collect: input => registry.collect(input, tenantId),
163
206
  };
164
207
  },
165
208
  };
@@ -0,0 +1,47 @@
1
+ // Descriptive owner metadata. It never changes device identity or grants access.
2
+ export const DEVICE_TYPES = ['unspecified', 'personal', 'server', 'workstation', 'other'];
3
+ const fail = (message, statusCode = 400) => { throw Object.assign(new Error(message), { statusCode }); };
4
+ function string(value, max, multiline = false) {
5
+ if (typeof value !== 'string' || value.length > max || (multiline ? /[\x00-\x09\x0b-\x1f\x7f]/ : /[\x00-\x1f\x7f]/).test(value)) fail('invalid_device_profile');
6
+ return value.trim();
7
+ }
8
+ export function normalizeDeviceProfile(value) {
9
+ if (!value || Array.isArray(value) || Object.keys(value).some(k => !['displayName', 'type', 'purpose', 'tags', 'services'].includes(k)) || !DEVICE_TYPES.includes(value.type)) fail('invalid_device_profile');
10
+ const list = (items, max, length) => {
11
+ if (!Array.isArray(items) || items.length > max) fail('invalid_device_profile');
12
+ const result = items.map(v => string(v, length));
13
+ if (result.some(v => !v)) fail('invalid_device_profile');
14
+ return [...new Set(result)];
15
+ };
16
+ return { displayName: string(value.displayName, 80), type: value.type, purpose: string(value.purpose, 240, true), tags: list(value.tags, 8, 24), services: list(value.services, 12, 40) };
17
+ }
18
+ const EMPTY = { displayName: '', type: 'unspecified', purpose: '', tags: [], services: [], revision: 0, updatedAt: null, servicesSource: 'user-declared' };
19
+ export function createDeviceProfiles({ db, now = Date.now, initialize = false }) {
20
+ const ensure = () => db.exec(`CREATE TABLE IF NOT EXISTS device_profiles (
21
+ tenant_id TEXT NOT NULL, device_id TEXT NOT NULL, profile_json TEXT NOT NULL,
22
+ revision INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY(tenant_id, device_id))`);
23
+ if (initialize) ensure();
24
+ const get = (tenantId, id) => {
25
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='device_profiles'").get()) return { ...EMPTY, tags: [], services: [] };
26
+ const row = db.prepare('SELECT * FROM device_profiles WHERE tenant_id=? AND device_id=?').get(tenantId, id);
27
+ return row ? { ...normalizeDeviceProfile(JSON.parse(row.profile_json)), revision: row.revision, updatedAt: row.updated_at, servicesSource: 'user-declared' } : { ...EMPTY, tags: [], services: [] };
28
+ };
29
+ return {
30
+ get,
31
+ apply(tenantId, device) {
32
+ const profile = get(tenantId, device.id);
33
+ return { ...device, reportedName: device.reportedName ?? device.name, name: profile.displayName || device.name, profile };
34
+ },
35
+ save(tenantId, input) {
36
+ if (!input || Array.isArray(input) || Object.keys(input).some(k => !['deviceId', 'profile', 'revision'].includes(k)) || typeof input.deviceId !== 'string' || !input.deviceId || input.deviceId.length > 200 || !Number.isSafeInteger(input.revision) || input.revision < 0) fail('invalid_device_profile');
37
+ const profile = normalizeDeviceProfile(input.profile); ensure();
38
+ db.exec('BEGIN IMMEDIATE');
39
+ try {
40
+ if (get(tenantId, input.deviceId).revision !== input.revision) fail('device_profile_conflict', 409);
41
+ db.prepare('INSERT INTO device_profiles VALUES (?,?,?,?,?) ON CONFLICT(tenant_id,device_id) DO UPDATE SET profile_json=excluded.profile_json,revision=excluded.revision,updated_at=excluded.updated_at').run(tenantId, input.deviceId, JSON.stringify(profile), input.revision + 1, now());
42
+ db.exec('COMMIT');
43
+ return get(tenantId, input.deviceId);
44
+ } catch (error) { db.exec('ROLLBACK'); throw error; }
45
+ },
46
+ };
47
+ }
@@ -50,7 +50,8 @@ function metric(input, kind) {
50
50
  return { status: input.status, [used]: input.status === 'ok' ? input[used] : null, [total]: input.status === 'ok' ? input[total] : null };
51
51
  }
52
52
  export function normalizeReport(payload, shareSsh, now) {
53
- object(payload, ['resources']);
53
+ object(payload, ['resources', 'collectionRequestId']);
54
+ if (payload.collectionRequestId !== undefined && (typeof payload.collectionRequestId !== 'string' || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(payload.collectionRequestId))) fail('invalid_collection_request');
54
55
  if (!Array.isArray(payload.resources) || !payload.resources.length || payload.resources.length > 16) fail('invalid_driver_resources');
55
56
  const ids = new Set();
56
57
  const resources = payload.resources.map((value, index) => {
@@ -62,5 +63,5 @@ export function normalizeReport(payload, shareSsh, now) {
62
63
  if (observedAt !== null && (!Number.isSafeInteger(observedAt) || observedAt < 0 || observedAt > now + 90_000)) fail('invalid_driver_observation_time');
63
64
  return { id: value.id, name: text(value.name), platform: text(value.platform, 64), kind: value.kind, observedAt: observedAt == null ? null : Math.min(observedAt, now), memory: metric(value.memory, 'memory'), disk: metric(value.disk, 'disk') };
64
65
  });
65
- return { resources };
66
+ return { resources, ...(payload.collectionRequestId ? { collectionRequestId: payload.collectionRequestId } : {}) };
66
67
  }
@@ -4,12 +4,14 @@ import { discoverSsh } from './sshConfig.mjs';
4
4
  import { prepareSshProfile, probeSsh } from './sshProbe.mjs';
5
5
  import { normalizeConnectionPlan, readConnectionPlan, writeConnectionPlan } from './connectionPlans.mjs';
6
6
  import { inspectSshImport } from './sshInitialization.mjs';
7
+ import { createDeviceProfiles } from './deviceProfiles.mjs';
7
8
 
8
9
  function associations(db, tenantId) {
9
10
  if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='ssh_associations'").get()) return [];
10
11
  return db.prepare('SELECT alias, created_at AS createdAt FROM ssh_associations WHERE tenant_id = ? ORDER BY alias COLLATE NOCASE').all(tenantId);
11
12
  }
12
13
  export function createInventory({ db, tenantId, runtime = 'native', sshHome, sshProbe = probeSsh }) {
14
+ const profiles = createDeviceProfiles({ db });
13
15
  const activeChecks = new Set();
14
16
  const pendingChecks = new Set();
15
17
  const exclusions = () => db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='ssh_exclusions'").get()
@@ -48,7 +50,7 @@ export function createInventory({ db, tenantId, runtime = 'native', sshHome, ssh
48
50
  capabilities: { observation: Boolean(profile), remoteExecution: false },
49
51
  };
50
52
  });
51
- return [observeLocalDriver({ runtime }), ...remote, ...listDevices(db, tenantId)];
53
+ return [observeLocalDriver({ runtime }), ...remote, ...listDevices(db, tenantId)].map(device => profiles.apply(tenantId, device));
52
54
  };
53
55
  const check = async (input) => {
54
56
  if (!input || Array.isArray(input) || Object.keys(input).some((key) => key !== 'alias') || typeof input.alias !== 'string') throw Object.assign(new Error('invalid_ssh_check'), { statusCode: 400 });
@@ -149,5 +151,22 @@ export function createInventory({ db, tenantId, runtime = 'native', sshHome, ssh
149
151
  return { removed, excluded: chosen.length, systemConfigurationChanged: false };
150
152
  } catch (cause) { db.exec('ROLLBACK'); throw cause; }
151
153
  };
152
- return { list, discovery, associate, check, checkBatch, remove, associateAndCheck: (input) => configure(input, false), initializeAndCheck: (input) => configure(input, true), saveConnections, initialization, initialize };
154
+ const find = input => {
155
+ if (!input || Array.isArray(input) || typeof input.deviceId !== 'string') throw Object.assign(new Error('invalid_device_id'), { statusCode: 400 });
156
+ const device = list().find(d => d.id === input.deviceId);
157
+ if (!device) throw Object.assign(new Error('device_not_found'), { statusCode: 404 });
158
+ return device;
159
+ };
160
+ const saveProfile = input => { find(input); return { profile: profiles.save(tenantId, input) }; };
161
+ const collect = async input => {
162
+ if (Object.keys(input ?? {}).some(k => k !== 'deviceId')) throw Object.assign(new Error('invalid_collection_request'), { statusCode: 400 });
163
+ const device = find(input);
164
+ if (device.transport === 'ssh') {
165
+ const result = await check({ alias: device.ssh.alias });
166
+ if (!result.ok) return { status: 'failed', error: result.error, device: list().find(d => d.id === device.id) };
167
+ }
168
+ else if (device.source !== 'local-runtime' || runtime !== 'native') throw Object.assign(new Error('collection_unavailable'), { statusCode: 409 });
169
+ return { status: 'completed', device: list().find(d => d.id === device.id) };
170
+ };
171
+ return { list, discovery, associate, check, checkBatch, remove, saveProfile, collect, associateAndCheck: (input) => configure(input, false), initializeAndCheck: (input) => configure(input, true), saveConnections, initialization, initialize };
153
172
  }
package/server/index.mjs CHANGED
@@ -13,6 +13,7 @@ import { createRemoteAgent, createRemoteClient } from './drivers/remote.mjs';
13
13
 
14
14
  const PROJECT_ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)));
15
15
  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
+ 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' });
16
17
  function sendJson(response, status, payload, headers = {}) {
17
18
  response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...headers });
18
19
  response.end(JSON.stringify(payload));
@@ -42,6 +43,7 @@ async function serveStatic(pathname, response) {
42
43
  let filePath;
43
44
  if (pathname === '/' || pathname === '/index.html') filePath = resolve(PROJECT_ROOT, 'index.html');
44
45
  else if (pathname === '/login') filePath = resolve(PROJECT_ROOT, 'login.html');
46
+ else if (pathname === '/start' || pathname === '/start.html') filePath = resolve(PROJECT_ROOT, 'start.html');
45
47
  else if (pathname.startsWith('/src/')) {
46
48
  filePath = resolve(PROJECT_ROOT, `.${pathname}`);
47
49
  if (!filePath.startsWith(resolve(PROJECT_ROOT, 'src') + '/')) return sendJson(response, 404, { error: 'not_found' });
@@ -79,11 +81,18 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
79
81
  const origin = cloudAccess ? cloudAccess.validate(request) : validateLocalRequest(request);
80
82
  const url = new URL(request.url ?? '/', origin);
81
83
  const path = url.pathname;
84
+ // 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', '/src/i18n.js', '/src/i18n.css', '/src/messages.js', '/src/install-prompt.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
86
+ if (Object.hasOwn(PUBLIC_GUIDES, path) && request.method === 'GET') {
87
+ const body = await readFile(resolve(PROJECT_ROOT, PUBLIC_GUIDES[path]));
88
+ 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' });
89
+ return response.end(body);
90
+ }
82
91
  let accountRegistry;
83
92
  if (cloud) {
84
93
  const machineActions = { '/api/driver/pair/start': 'start', '/api/driver/pair/poll': 'poll', '/api/driver/report': 'report', '/api/driver/disconnect': 'disconnect' };
85
94
  if (Object.hasOwn(machineActions, path) && request.method === 'POST') return sendJson(response, 200, registry[machineActions[path]](await readJson(request)));
86
- if (['certificate','peers','relay/open','relay/accept','relay/exchange','relay/close'].some(action => path === `/api/driver/${action}`) && request.method === 'POST') return sendJson(response, 200, registry.machine(path.slice('/api/driver/'.length), await readJson(request)));
95
+ if (['control','certificate','peers','relay/open','relay/accept','relay/exchange','relay/close'].some(action => path === `/api/driver/${action}`) && request.method === 'POST') return sendJson(response, 200, registry.machine(path.slice('/api/driver/'.length), await readJson(request)));
87
96
  if (['/login', '/src/login.js', '/src/login.css'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
88
97
  if (path === '/api/auth/options' && request.method === 'GET') return sendJson(response, 200, cloudAccess.options(request));
89
98
  if (path === '/api/auth/continue' && request.method === 'POST') {
@@ -106,12 +115,12 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
106
115
  }
107
116
  const principal = await cloudAccess.resolvePrincipal(request);
108
117
  if (!principal) {
109
- if (path === '/' && request.method === 'GET') { response.writeHead(303, { location: '/login', 'cache-control': 'no-store' }); return response.end(); }
118
+ 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(); }
110
119
  return sendJson(response, 401, { error: 'authentication_required' });
111
120
  }
112
121
  accountRegistry = registry.forTenant(principal.tenantId);
113
122
  if (path === '/api/drivers' && request.method === 'GET') return sendJson(response, 200, { drivers: accountRegistry.bindings() });
114
- const ownerActions = { '/api/drivers/pairing/lookup': 'lookup', '/api/drivers/pairing/approve': 'approve', '/api/drivers/revoke': 'revoke' };
123
+ const ownerActions = { '/api/devices/profile': 'saveProfile', '/api/devices/collect': 'collect', '/api/drivers/pairing/lookup': 'lookup', '/api/drivers/pairing/approve': 'approve', '/api/drivers/revoke': 'revoke' };
115
124
  if (Object.hasOwn(ownerActions, path) && request.method === 'POST') {
116
125
  if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
117
126
  return sendJson(response, 200, accountRegistry[ownerActions[path]](await readJson(request)));
@@ -144,6 +153,14 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
144
153
  if (request.method !== 'GET' || !web) return sendJson(response, 404, { error: 'not_found' });
145
154
  return await serveStatic(path, response);
146
155
  }
156
+ if (!cloud && ['/api/devices/profile', '/api/devices/collect'].includes(path) && request.method === 'POST') {
157
+ if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
158
+ const input = await readJson(request);
159
+ if (path === '/api/devices/profile') return sendJson(response, 200, inventory.saveProfile(input));
160
+ const result = await inventory.collect(input);
161
+ if (result.device.source === 'local-runtime') publisher?.requestCollection();
162
+ return sendJson(response, 200, result);
163
+ }
147
164
  if (path === '/api/devices/ssh' && request.method === 'POST') {
148
165
  if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
149
166
  return sendJson(response, 200, inventory.associate(await readJson(request)));
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
+ saveDeviceProfile: (profile) => request('/api/devices/profile', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(profile) }),
11
+ collectDevice: (deviceId) => request('/api/devices/collect', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ deviceId }), timeoutMs: 30000 }),
9
12
  remotePeers: () => request('/api/remote/peers'),
10
13
  lookupPairing: (code) => request('/api/drivers/pairing/lookup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code }), timeoutMs: 12000 }),
11
14
  approvePairing: (id, shareSsh, code, fingerprint) => request('/api/drivers/pairing/approve', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, shareSsh, code, fingerprint }), timeoutMs: 12000 }),
@@ -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)));
@@ -7,3 +8,22 @@ export function expireSshSnapshots(overview, now = Date.now()) {
7
8
  const stats = devices.reduce((totals, device) => { totals.total++; totals[device.state]++; return totals; }, { total: 0, online: 0, unknown: 0, expired: 0 });
8
9
  return { ...overview, devices, stats };
9
10
  }
11
+
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"); } };
13
+ export function deviceStatus(device, now = Date.now()) {
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") };
18
+ }
19
+ export function collectionAvailability(device, now = Date.now()) {
20
+ if (device.source === 'driver-report') {
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") };
26
+ }
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") };
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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
+ }
package/src/login.css CHANGED
@@ -14,3 +14,7 @@ button:disabled { opacity: .6; }
14
14
  .login-link { display: grid; place-items: center; min-height: 44px; margin-top: 24px; border-radius: 8px; color: white; background: #3c622a; text-decoration: none; font-size: 14px; }
15
15
  .help { line-height: 1.7; margin: 20px 0; }
16
16
  small { color: #788473; }
17
+
18
+ .install-help { margin-top: 24px; padding-top: 18px; border-top: 1px solid #e3e7e0; line-height: 1.8; }
19
+ .install-help a { color: #3c622a; text-underline-offset: 3px; }
20
+ .install-help span { font-size: 12px; }