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.
- package/CONTRIBUTING.md +1 -1
- package/README.md +14 -4
- package/docs/agent-install.md +105 -0
- package/docs/device-profiles.md +56 -0
- package/docs/friend-trial.md +42 -0
- package/docs/hosted-access.md +1 -1
- package/docs/i18n.md +32 -0
- package/docs/npm.md +7 -7
- package/docs/onboarding.md +4 -4
- package/docs/remote-access.md +2 -2
- package/docs/roadmap.md +21 -31
- package/index.html +1 -0
- package/login.html +2 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +7 -2
- package/server/connector.mjs +1 -1
- package/server/drivers/cloudLink.mjs +42 -2
- package/server/drivers/cloudRegistry.mjs +45 -2
- package/server/drivers/deviceProfiles.mjs +47 -0
- package/server/drivers/driverProtocol.mjs +3 -2
- package/server/drivers/inventory.mjs +21 -2
- package/server/index.mjs +20 -3
- package/src/api.js +4 -1
- package/src/device-state.js +20 -0
- package/src/i18n.css +61 -0
- package/src/i18n.js +174 -0
- package/src/install-prompt.js +19 -0
- package/src/login.css +4 -0
- package/src/login.js +14 -11
- package/src/main.js +230 -126
- package/src/messages.js +5880 -0
- package/src/onboarding.js +56 -7
- package/src/start.css +47 -0
- package/src/start.js +39 -0
- package/src/styles.css +23 -1
- package/start.html +46 -0
|
@@ -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 =>
|
|
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
|
-
|
|
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:
|
|
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 }),
|
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)));
|
|
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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; }
|