@livedesk/client 0.1.234 → 0.1.236

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.
@@ -1,1046 +1,986 @@
1
- import { createServer } from 'node:http';
2
- import { randomBytes } from 'node:crypto';
3
- import { execFile } from 'node:child_process';
4
- import { accessSync, chmodSync, constants as fsConstants, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statfsSync, writeFileSync } from 'node:fs';
5
- import { delimiter, dirname, join, parse, posix, resolve } from 'node:path';
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { execFile } from 'node:child_process';
4
+ import { accessSync, chmodSync, constants as fsConstants, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statfsSync, writeFileSync } from 'node:fs';
5
+ import { delimiter, dirname, join, parse, posix, resolve } from 'node:path';
6
6
  import os from 'node:os';
7
- import { createRuntimeManager, normalizeRuntimeAuthSession, runtimeRoleError, RuntimeState } from '../../../runtime-core/src/index.js';
8
- import { createOsSecretStore, OS_SECRET_REFERENCE } from '../../../runtime-core/src/os-secret-store.js';
9
-
10
- const DEFAULT_HOST = '127.0.0.1';
11
- const DEFAULT_PORT = 5179;
12
- const CLIENT_RUNTIME_BIND_TIMEOUT_MS = 10_000;
13
- const CLIENT_DIAGNOSTIC_COMMAND_FORCE_KILL_MS = 150;
14
- const CLIENT_DIAGNOSTIC_COMMAND_DRAIN_MS = 750;
15
- const CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS = 1_500;
16
- const CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS = 15_000;
17
- const CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS = 60_000;
18
- const CLIENT_DIAGNOSTIC_HARDWARE_TTL_MS = 300_000;
19
- const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
20
- const SUPABASE_URL = process.env.LIVEDESK_SUPABASE_URL || 'https://otbyfkjxrkngvjziawki.supabase.co';
21
- const SUPABASE_PUBLISHABLE_KEY = process.env.LIVEDESK_SUPABASE_PUBLISHABLE_KEY || 'sb_publishable_NpUs0RDJH2YnllsqTKO6TQ_1jTdSsNQ';
22
- const CLIENT_STATE_DIR = process.env.LIVEDESK_STATE_DIR || (process.env.LIVEDESK_UNIFIED_RUNTIME === '1' ? join(os.homedir(), '.livedesk') : join(os.homedir(), '.livedesk-client'));
23
- const CLIENT_AUTH_PATH = join(CLIENT_STATE_DIR, 'auth.json');
24
- const CLIENT_REFRESH_SECRET_STORE = createOsSecretStore({
25
- service: 'LiveDesk',
26
- account: 'client-refresh-token',
27
- dataDir: CLIENT_STATE_DIR
28
- });
29
- const DEFAULT_TRUSTED_WEB_ORIGINS = Object.freeze([
30
- 'https://livedesk.pages.dev',
31
- 'http://127.0.0.1:5173',
32
- 'http://localhost:5173',
33
- 'http://127.0.0.1:4173',
34
- 'http://localhost:4173'
35
- ]);
36
- const LIVE_DESK_PAGES_PREVIEW_HOST = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.livedesk\.pages\.dev$/;
37
- const READ_ONLY_CLIENT_API_PATHS = new Set([
38
- '/api/health',
39
- '/api/runtime',
40
- '/api/runtime/status',
41
- '/api/runtime/events',
42
- '/api/client/status',
43
- '/api/client/computer',
44
- '/api/client/system',
45
- '/api/client/permissions',
46
- '/api/client/diagnostics',
47
- '/api/auth/status'
48
- ]);
49
-
50
- function normalizeString(value, maxLength = 1024) {
51
- return String(value ?? '').replace(/[\r\n\t]/g, ' ').trim().slice(0, maxLength);
52
- }
53
-
54
- function normalizeAccountAvatarUrl(value) {
55
- const candidate = normalizeString(value, 2048);
56
- if (!candidate) return '';
57
- try {
58
- const url = new URL(candidate);
59
- return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : '';
60
- } catch {
61
- return '';
62
- }
63
- }
64
-
65
- function readAccessTokenProfile(accessToken) {
66
- const token = normalizeString(accessToken, 8192);
67
- const payloadSegment = token.split('.')[1] || '';
68
- if (!payloadSegment) return {};
69
- try {
70
- const payload = JSON.parse(Buffer.from(payloadSegment, 'base64url').toString('utf8'));
71
- return payload && typeof payload === 'object' ? payload : {};
72
- } catch {
73
- return {};
74
- }
75
- }
76
-
77
- function readClientAccountProfile(sessionOrUser) {
78
- const tokenProfile = readAccessTokenProfile(sessionOrUser?.access_token);
79
- const user = sessionOrUser?.user && typeof sessionOrUser.user === 'object'
80
- ? sessionOrUser.user
81
- : sessionOrUser && typeof sessionOrUser === 'object'
82
- ? sessionOrUser
83
- : {};
84
- const metadata = user.user_metadata && typeof user.user_metadata === 'object'
85
- ? user.user_metadata
86
- : {};
87
- const tokenMetadata = tokenProfile.user_metadata && typeof tokenProfile.user_metadata === 'object'
88
- ? tokenProfile.user_metadata
89
- : {};
90
- const email = normalizeString(user.email || tokenProfile.email, 320);
91
- const name = normalizeString(
92
- metadata.full_name
93
- || metadata.name
94
- || metadata.preferred_username
95
- || tokenMetadata.full_name
96
- || tokenMetadata.name
97
- || tokenMetadata.preferred_username
98
- || user.name
99
- || tokenProfile.name
100
- || email
101
- || user.id,
102
- 160
103
- );
104
- const avatarUrl = normalizeAccountAvatarUrl(
105
- metadata.avatar_url
106
- || metadata.picture
107
- || tokenMetadata.avatar_url
108
- || tokenMetadata.picture
109
- || user.avatar_url
110
- || user.picture
111
- );
112
- return { email, name, avatarUrl };
113
- }
114
-
115
- function mergeClientAccountProfile(session, sourceUser) {
116
- const profile = readClientAccountProfile(sourceUser);
117
- const existingUserMetadata = session?.user?.user_metadata && typeof session.user.user_metadata === 'object'
118
- ? session.user.user_metadata
119
- : {};
120
- const userMetadata = {
121
- ...existingUserMetadata,
122
- ...(profile.name ? { full_name: profile.name } : {}),
123
- ...(profile.avatarUrl ? { avatar_url: profile.avatarUrl } : {})
124
- };
125
- return {
126
- ...session,
127
- user: {
128
- ...(session?.user || {}),
129
- ...(Object.keys(userMetadata).length > 0 ? { user_metadata: userMetadata } : {})
130
- }
131
- };
132
- }
133
-
134
- async function hydrateClientAccountProfile(session) {
135
- const accessToken = normalizeString(session?.access_token, 8192);
136
- if (!accessToken) return session;
137
- try {
138
- const response = await fetch(`${SUPABASE_URL.replace(/\/+$/, '')}/auth/v1/user`, {
139
- headers: {
140
- apikey: SUPABASE_PUBLISHABLE_KEY,
141
- Authorization: `Bearer ${accessToken}`,
142
- Accept: 'application/json'
143
- },
144
- signal: AbortSignal.timeout(5_000)
145
- });
146
- if (!response.ok) return session;
147
- const verifiedUser = await response.json().catch(() => null);
148
- return verifiedUser ? mergeClientAccountProfile(session, verifiedUser) : session;
149
- } catch {
150
- return session;
151
- }
152
- }
153
-
154
- function normalizePort(value, fallback = DEFAULT_PORT) {
155
- const port = Number(value);
156
- return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : fallback;
157
- }
158
-
159
- function normalizeSlotNumber(value) {
160
- const slotNumber = Number(String(value ?? '').trim());
161
- return Number.isInteger(slotNumber) && slotNumber >= 1 && slotNumber <= 999
162
- ? slotNumber
163
- : 0;
164
- }
165
-
166
- function normalizeRoleVersion(value) {
167
- const roleVersion = Number(String(value ?? '').trim());
168
- return Number.isInteger(roleVersion) && roleVersion >= 0 ? roleVersion : 0;
169
- }
170
-
171
- function readCpuTotals() {
172
- return os.cpus().reduce((totals, cpu) => {
173
- const times = Object.values(cpu.times || {}).map(Number);
174
- totals.idle += Number(cpu.times?.idle || 0);
175
- totals.total += times.reduce((sum, value) => sum + value, 0);
176
- return totals;
177
- }, { idle: 0, total: 0 });
178
- }
179
-
180
- function finiteNumber(value) {
181
- if (value === null
182
- || value === undefined
183
- || typeof value === 'boolean'
184
- || (typeof value === 'string' && value.trim() === '')) {
185
- return null;
186
- }
187
- const number = Number(value);
188
- return Number.isFinite(number) && number >= 0 ? number : null;
189
- }
190
-
191
- function percentFromRatio(value) {
192
- const number = finiteNumber(value);
193
- if (number === null) return null;
194
- return Math.round(Math.max(0, Math.min(100, number)) * 10) / 10;
195
- }
196
-
197
- function destroyChildRedirectedIo(child) {
198
- for (const stream of [child?.stdin, child?.stdout, child?.stderr]) {
199
- try { stream?.destroy(); } catch { /* an exact owned stream already closed */ }
200
- }
201
- }
202
-
203
- function sanitizeDiagnosticCommandName(value) {
204
- return normalizeString(value, 200) || 'unknown-diagnostic-command';
205
- }
206
-
207
- function defaultDiagnosticMonotonicNow() {
208
- return Number(process.hrtime.bigint() / 1_000_000n);
209
- }
210
-
211
- /**
212
- * Contract:
213
- * - A probe is idle until an explicit diagnostic request calls read().
214
- * - Every successful, unavailable, empty, or failed result is retained for at
215
- * least 15 monotonic seconds measured from refresh completion.
216
- * - Concurrent readers share one refresh promise. A failed tool therefore
217
- * cannot respawn once per status heartbeat.
218
- */
219
- export function createClientDiagnosticProbeCache(options = {}) {
220
- if (typeof options.refresh !== 'function') {
221
- throw new TypeError('client-diagnostic-probe-refresh-required');
222
- }
223
- const probeId = normalizeString(options.probeId, 80) || 'client-diagnostic-probe';
224
- const monotonicNow = typeof options.monotonicNow === 'function'
225
- ? options.monotonicNow
226
- : defaultDiagnosticMonotonicNow;
227
- const ttlMs = Math.max(
228
- CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
229
- Number(options.ttlMs) || CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS
230
- );
231
- const negativeTtlMs = Math.max(
232
- CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
233
- Number(options.negativeTtlMs) || CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS
234
- );
235
- const fallbackValue = options.fallbackValue;
236
- let initialized = false;
237
- let sampledAtMonotonicMs = 0;
238
- let generation = 0;
239
- let refreshCount = 0;
240
- let cacheHitCount = 0;
241
- let sharedReadCount = 0;
242
- let record = {
243
- ok: false,
244
- value: fallbackValue,
245
- error: 'not-requested'
246
- };
247
- let inFlight = null;
248
-
249
- const snapshot = () => {
250
- const now = Number(monotonicNow());
251
- const ageMs = initialized && Number.isFinite(now)
252
- ? Math.max(0, now - sampledAtMonotonicMs)
253
- : null;
254
- return {
255
- probeId,
256
- initialized,
257
- inFlight: Boolean(inFlight),
258
- ok: initialized ? record.ok === true : false,
259
- error: initialized ? normalizeString(record.error, 160) : 'not-requested',
260
- ttlMs,
261
- negativeTtlMs,
262
- ageMs,
263
- generation,
264
- refreshCount,
265
- cacheHitCount,
266
- sharedReadCount
267
- };
268
- };
269
-
270
- const read = () => {
271
- const now = Number(monotonicNow());
272
- const activeTtlMs = record.ok === true ? ttlMs : negativeTtlMs;
273
- if (initialized
274
- && Number.isFinite(now)
275
- && Math.max(0, now - sampledAtMonotonicMs) < activeTtlMs) {
276
- cacheHitCount += 1;
277
- return Promise.resolve({
278
- ...record,
279
- cached: true,
280
- shared: false,
281
- generation
282
- });
283
- }
284
- if (inFlight) {
285
- sharedReadCount += 1;
286
- return inFlight.then(result => ({ ...result, shared: true }));
287
- }
288
-
289
- inFlight = (async () => {
290
- let next;
291
- try {
292
- const candidate = await options.refresh();
293
- next = candidate && typeof candidate === 'object'
294
- ? {
295
- ok: candidate.ok === true,
296
- value: candidate.value === undefined ? fallbackValue : candidate.value,
297
- error: candidate.ok === true ? '' : normalizeString(candidate.error, 160) || 'probe-unavailable'
298
- }
299
- : {
300
- ok: false,
301
- value: fallbackValue,
302
- error: 'probe-invalid-result'
303
- };
304
- } catch (error) {
305
- next = {
306
- ok: false,
307
- value: fallbackValue,
308
- error: normalizeString(error?.message || error, 160) || 'probe-failed'
309
- };
310
- }
311
- record = next;
312
- initialized = true;
313
- generation += 1;
314
- refreshCount += 1;
315
- sampledAtMonotonicMs = Number(monotonicNow());
316
- return {
317
- ...record,
318
- cached: false,
319
- shared: false,
320
- generation
321
- };
322
- })().finally(() => {
323
- inFlight = null;
324
- });
325
- return inFlight;
326
- };
327
-
328
- return {
329
- read,
330
- getSnapshot: snapshot
331
- };
332
- }
333
-
334
- function isExecutablePath(path) {
335
- try {
336
- accessSync(path, fsConstants.X_OK);
337
- return true;
338
- } catch {
339
- return false;
340
- }
341
- }
342
-
343
- export function resolveNvidiaSmiCommand(options = {}) {
344
- const platform = normalizeString(options.platform || process.platform, 20);
345
- const executablePath = normalizeString(
346
- options.explicitPath ?? process.env.LIVEDESK_NVIDIA_SMI_PATH,
347
- 1000
348
- );
349
- const executableCheck = typeof options.isExecutable === 'function'
350
- ? options.isExecutable
351
- : isExecutablePath;
352
- if (platform === 'win32') {
353
- return executablePath || 'nvidia-smi.exe';
354
- }
355
-
356
- const pathValue = String(options.pathValue ?? process.env.PATH ?? '');
357
- const pathDelimiter = normalizeString(options.pathDelimiter, 4) || delimiter;
358
- const joinExecutablePath = platform === 'win32' ? join : posix.join;
359
- const candidates = [
360
- executablePath,
361
- ...pathValue
362
- .split(pathDelimiter)
363
- .map(directory => directory.trim())
364
- .filter(Boolean)
365
- .map(directory => joinExecutablePath(directory, 'nvidia-smi')),
366
- ...(platform === 'linux' ? ['/usr/bin/nvidia-smi', '/usr/local/bin/nvidia-smi'] : [])
367
- ].filter(Boolean);
368
- for (const candidate of [...new Set(candidates)]) {
369
- try {
370
- if (executableCheck(candidate) === true) return candidate;
371
- } catch {
372
- // An unreadable PATH entry is negative probe evidence.
373
- }
374
- }
375
- return '';
376
- }
377
-
378
- /**
379
- * Contract:
380
- * - Owns only ChildProcess objects spawned through this instance.
381
- * - Identity is ownerId + commandId + the immutable ChildProcess handle; a PID
382
- * is diagnostic evidence and is never rediscovered to authorize termination.
383
- * - A command deadline and owner close both abort, terminate, drain redirected
384
- * stdio, and wait for the exact child's terminal `close` event.
385
- * - Concurrent identical probes share the same command/output/terminal owner.
386
- * - Output, close, and active-ledger retention are bounded independently so an
387
- * inherited pipe can never keep the Client event loop alive without evidence.
388
- */
389
- export function createClientDiagnosticCommandOwner(options = {}) {
390
- const execFileImpl = typeof options.execFileImpl === 'function' ? options.execFileImpl : execFile;
391
- const ownerId = normalizeString(options.ownerId, 160)
392
- || `client-diagnostics-${process.pid}-${randomBytes(8).toString('hex')}`;
393
- const forceKillDelayMs = Math.max(
394
- 10,
395
- Number(options.forceKillDelayMs) || CLIENT_DIAGNOSTIC_COMMAND_FORCE_KILL_MS
396
- );
397
- const commandDrainMs = Math.max(
398
- forceKillDelayMs + 10,
399
- Number(options.commandDrainMs) || CLIENT_DIAGNOSTIC_COMMAND_DRAIN_MS
400
- );
401
- const active = new Map();
402
- const activeByKey = new Map();
403
- let commandSequence = 0;
404
- let spawnedCount = 0;
405
- let joinedCount = 0;
406
- let timedOutCount = 0;
407
- let sharedRequestCount = 0;
408
- let closePromise = null;
409
- let closed = false;
410
-
411
- const snapshotRecord = record => ({
412
- ownerId,
413
- commandId: record.commandId,
414
- pid: record.pid,
415
- command: record.command,
416
- startedAt: record.startedAt,
417
- terminationReason: record.terminationReason || ''
418
- });
419
-
420
- const getSnapshot = () => ({
421
- ownerId,
422
- closed,
423
- spawnedCount,
424
- joinedCount,
425
- timedOutCount,
426
- sharedRequestCount,
427
- activeCount: active.size,
428
- redirectedStreamCount: [...active.values()].reduce(
429
- (count, record) => count + [record.child?.stdin, record.child?.stdout, record.child?.stderr]
430
- .filter(stream => stream && !stream.destroyed).length,
431
- 0
432
- ),
433
- active: [...active.values()].map(snapshotRecord)
434
- });
435
-
436
- const settleOutput = (record, output = '') => {
437
- if (record.outputSettled) return;
438
- record.outputSettled = true;
439
- record.resolveOutput(String(output || ''));
440
- };
441
-
442
- const markTerminal = record => {
443
- if (record.terminal) return;
444
- record.terminal = true;
445
- clearTimeout(record.commandTimer);
446
- clearTimeout(record.forceKillTimer);
447
- clearTimeout(record.outputDrainTimer);
448
- destroyChildRedirectedIo(record.child);
449
- active.delete(record.commandId);
450
- if (activeByKey.get(record.commandKey) === record) {
451
- activeByKey.delete(record.commandKey);
452
- }
453
- joinedCount += 1;
454
- settleOutput(record, record.error ? '' : record.stdout);
455
- record.resolveTerminal();
456
- };
457
-
458
- const terminateOwnedRecord = (record, reason) => {
459
- if (!record || record.terminal || record.terminationRequested) return;
460
- record.terminationRequested = true;
461
- record.terminationReason = normalizeString(reason, 80) || 'diagnostic-owner-close';
462
- if (record.terminationReason === 'command-timeout') timedOutCount += 1;
463
- clearTimeout(record.commandTimer);
464
- try { record.abortController.abort(new Error(record.terminationReason)); } catch { /* already aborted */ }
465
- try { record.child?.kill('SIGTERM'); } catch { /* exact child already exited */ }
466
- record.forceKillTimer = setTimeout(() => {
467
- if (record.terminal) return;
468
- try { record.child?.kill('SIGKILL'); } catch { /* exact child already exited */ }
469
- // An inherited writer can keep execFile's callback pending even after the
470
- // root exits. Closing our exact pipe ends the Client-side handle owner.
471
- destroyChildRedirectedIo(record.child);
472
- }, forceKillDelayMs);
473
- record.forceKillTimer.unref?.();
474
- record.outputDrainTimer = setTimeout(() => {
475
- if (record.terminal) return;
476
- settleOutput(record, '');
477
- destroyChildRedirectedIo(record.child);
478
- record.child?.unref?.();
479
- }, commandDrainMs);
480
- record.outputDrainTimer.unref?.();
481
- };
482
-
483
- const capture = (command, args = [], timeout = 3500) => {
484
- if (closed) return Promise.resolve('');
485
- const normalizedArgs = Array.isArray(args) ? args : [];
486
- const commandKey = JSON.stringify([
487
- String(command || ''),
488
- ...normalizedArgs.map(value => String(value ?? ''))
489
- ]);
490
- const sharedRecord = activeByKey.get(commandKey);
491
- if (sharedRecord) {
492
- sharedRequestCount += 1;
493
- return sharedRecord.outputPromise;
494
- }
495
- const commandId = `${ownerId}:${++commandSequence}`;
496
- const abortController = new AbortController();
497
- let resolveOutput;
498
- let resolveTerminal;
499
- const outputPromise = new Promise(resolve => { resolveOutput = resolve; });
500
- const terminalPromise = new Promise(resolve => { resolveTerminal = resolve; });
501
- const record = {
502
- ownerId,
503
- commandId,
504
- commandKey,
505
- command: sanitizeDiagnosticCommandName(command),
506
- pid: 0,
507
- startedAt: new Date().toISOString(),
508
- child: null,
509
- abortController,
510
- resolveOutput,
511
- resolveTerminal,
512
- outputPromise,
513
- terminalPromise,
514
- stdout: '',
515
- error: null,
516
- outputSettled: false,
517
- terminal: false,
518
- terminationRequested: false,
519
- terminationReason: '',
520
- commandTimer: null,
521
- forceKillTimer: null,
522
- outputDrainTimer: null
523
- };
524
- active.set(commandId, record);
525
- activeByKey.set(commandKey, record);
526
-
527
- try {
528
- record.child = execFileImpl(command, normalizedArgs, {
529
- encoding: 'utf8',
530
- maxBuffer: 512 * 1024,
531
- windowsHide: true,
532
- signal: abortController.signal
533
- }, (error, stdout) => {
534
- record.error = error || null;
535
- record.stdout = error ? '' : String(stdout || '');
536
- settleOutput(record, record.stdout);
537
- });
538
- record.pid = Number(record.child?.pid || 0);
539
- spawnedCount += 1;
540
- record.child.once('close', () => markTerminal(record));
541
- record.child.once('error', error => {
542
- record.error = record.error || error;
543
- });
544
- const commandTimeoutMs = Math.max(10, Number(timeout) || 3500);
545
- record.commandTimer = setTimeout(
546
- () => terminateOwnedRecord(record, 'command-timeout'),
547
- commandTimeoutMs
548
- );
549
- record.commandTimer.unref?.();
550
- } catch (error) {
551
- record.error = error;
552
- settleOutput(record, '');
553
- markTerminal(record);
554
- }
555
-
556
- return outputPromise;
557
- };
558
-
559
- const close = (timeoutMs = CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS) => {
560
- if (closePromise) return closePromise;
561
- closed = true;
562
- const boundedTimeoutMs = Math.max(100, Number(timeoutMs) || CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS);
563
- closePromise = (async () => {
564
- const records = [...active.values()];
565
- for (const record of records) terminateOwnedRecord(record, 'diagnostic-owner-close');
566
- if (records.length > 0) {
567
- let deadlineTimer;
568
- await Promise.race([
569
- Promise.allSettled(records.map(record => record.terminalPromise)),
570
- new Promise(resolve => {
571
- deadlineTimer = setTimeout(resolve, boundedTimeoutMs);
572
- })
573
- ]);
574
- clearTimeout(deadlineTimer);
575
- }
576
- for (const record of active.values()) {
577
- settleOutput(record, '');
578
- try { record.child?.kill('SIGKILL'); } catch { /* exact child already exited */ }
579
- destroyChildRedirectedIo(record.child);
580
- record.child?.unref?.();
581
- }
582
- const snapshot = getSnapshot();
583
- return {
584
- ok: snapshot.activeCount === 0 && snapshot.redirectedStreamCount === 0,
585
- ...snapshot
586
- };
587
- })();
588
- return closePromise;
589
- };
590
-
591
- return {
592
- ownerId,
593
- capture,
594
- close,
595
- getSnapshot
596
- };
597
- }
598
-
599
- export function parseNvidiaSmiOutput(output) {
600
- return String(output || '')
601
- .split(/\r?\n/)
602
- .map(line => line.trim())
603
- .filter(Boolean)
604
- .map(line => {
605
- const [name = '', usage = '', totalMemoryMiB = '', usedMemoryMiB = ''] = line.split(',').map(value => value.trim());
606
- const memoryTotalBytes = finiteNumber(totalMemoryMiB);
607
- const memoryUsedBytes = finiteNumber(usedMemoryMiB);
608
- return {
609
- name: normalizeString(name, 160),
610
- usagePercent: percentFromRatio(usage),
611
- memoryTotalBytes: memoryTotalBytes === null ? null : Math.round(memoryTotalBytes * 1024 ** 2),
612
- memoryUsedBytes: memoryUsedBytes === null ? null : Math.round(memoryUsedBytes * 1024 ** 2)
613
- };
614
- })
615
- .filter(gpu => gpu.name);
616
- }
617
-
618
- function parseCounter(value) {
619
- const number = Number(String(value || '').replaceAll(',', ''));
620
- return Number.isFinite(number) && number >= 0 ? number : 0;
621
- }
622
-
623
- export function parseWindowsNetworkOutput(output) {
624
- for (const line of String(output || '').split(/\r?\n/)) {
625
- const match = line.match(/(?:^|\s)(\d[\d,]*)\s+(\d[\d,]*)\s*$/);
626
- if (match) return { receivedBytes: parseCounter(match[1]), sentBytes: parseCounter(match[2]) };
627
- }
628
- return { receivedBytes: 0, sentBytes: 0 };
629
- }
630
-
631
- export function parseMacNetworkOutput(output) {
632
- const lines = String(output || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
633
- const header = lines.find(line => /\bIbytes\b/i.test(line) && /\bObytes\b/i.test(line));
634
- if (!header) return { receivedBytes: 0, sentBytes: 0 };
635
- const columns = header.split(/\s+/);
636
- const receivedIndex = columns.findIndex(column => column.toLowerCase() === 'ibytes');
637
- const sentIndex = columns.findIndex(column => column.toLowerCase() === 'obytes');
638
- const byInterface = new Map();
639
- for (const line of lines.slice(lines.indexOf(header) + 1)) {
640
- const fields = line.split(/\s+/);
641
- const name = normalizeString(fields[0], 80);
642
- if (!name || name === 'lo0' || fields.length <= Math.max(receivedIndex, sentIndex)) continue;
643
- const receivedBytes = parseCounter(fields[receivedIndex]);
644
- const sentBytes = parseCounter(fields[sentIndex]);
645
- const previous = byInterface.get(name) || { receivedBytes: 0, sentBytes: 0 };
646
- byInterface.set(name, {
647
- receivedBytes: Math.max(previous.receivedBytes, receivedBytes),
648
- sentBytes: Math.max(previous.sentBytes, sentBytes)
649
- });
650
- }
651
- return [...byInterface.values()].reduce((total, current) => ({
652
- receivedBytes: total.receivedBytes + current.receivedBytes,
653
- sentBytes: total.sentBytes + current.sentBytes
654
- }), { receivedBytes: 0, sentBytes: 0 });
655
- }
656
-
657
- export function parseLinuxNetworkOutput(output) {
658
- let receivedBytes = 0;
659
- let sentBytes = 0;
660
- for (const line of String(output || '').split(/\r?\n/)) {
661
- const separator = line.indexOf(':');
662
- if (separator < 0) continue;
663
- const name = line.slice(0, separator).trim();
664
- if (!name || name === 'lo') continue;
665
- const fields = line.slice(separator + 1).trim().split(/\s+/);
666
- receivedBytes += parseCounter(fields[0]);
667
- sentBytes += parseCounter(fields[8]);
668
- }
669
- return { receivedBytes, sentBytes };
670
- }
671
-
672
- function activeNetworkInterfaces() {
673
- const interfaces = [];
674
- for (const [name, addresses] of Object.entries(os.networkInterfaces())) {
675
- for (const address of addresses || []) {
676
- if (address.internal) continue;
677
- interfaces.push({
678
- name: normalizeString(name, 80),
679
- address: normalizeString(address.address, 100),
680
- family: normalizeString(address.family, 20)
681
- });
682
- }
683
- }
684
- return interfaces;
685
- }
686
-
687
- async function readNetworkTotalsProbe(commandOwner, platform = process.platform) {
688
- if (platform === 'win32') {
689
- const output = await commandOwner.capture('netstat.exe', ['-e'], 2500);
690
- return String(output || '').trim()
691
- ? { ok: true, value: parseWindowsNetworkOutput(output) }
692
- : { ok: false, value: { receivedBytes: 0, sentBytes: 0 }, error: 'netstat-unavailable' };
693
- }
694
- if (platform === 'darwin') {
695
- const output = await commandOwner.capture('netstat', ['-ibn'], 2500);
696
- return String(output || '').trim()
697
- ? { ok: true, value: parseMacNetworkOutput(output) }
698
- : { ok: false, value: { receivedBytes: 0, sentBytes: 0 }, error: 'netstat-unavailable' };
699
- }
700
- try {
701
- return {
702
- ok: true,
703
- value: parseLinuxNetworkOutput(readFileSync('/proc/net/dev', 'utf8'))
704
- };
705
- } catch {
706
- return {
707
- ok: false,
708
- value: { receivedBytes: 0, sentBytes: 0 },
709
- error: 'network-counters-unavailable'
710
- };
711
- }
712
- }
713
-
714
- function normalizeGpu(value) {
715
- if (!value || typeof value !== 'object') return null;
716
- const name = normalizeString(value.name, 160);
717
- if (!name) return null;
718
- return {
719
- name,
720
- usagePercent: percentFromRatio(value.usagePercent),
721
- memoryTotalBytes: finiteNumber(value.memoryTotalBytes),
722
- memoryUsedBytes: finiteNumber(value.memoryUsedBytes)
723
- };
724
- }
725
-
726
- export function normalizeGpuIdentityName(value) {
727
- return normalizeString(value, 160)
728
- .normalize('NFKC')
729
- .replace(/\((?:r|tm)\)/giu, '')
730
- .replace(/[®™]/gu, '')
731
- .toLocaleLowerCase('en-US')
732
- .replace(/[^\p{L}\p{N}]+/gu, ' ')
733
- .trim();
734
- }
735
-
736
- export function mergeGpuSnapshots(platformGpus = [], telemetryGpus = []) {
737
- const merged = [];
738
- const indexesByIdentity = new Map();
739
- for (const value of Array.isArray(platformGpus) ? platformGpus : []) {
740
- const gpu = normalizeGpu(value);
741
- const identity = normalizeGpuIdentityName(gpu?.name);
742
- if (!gpu || !identity) continue;
743
- const index = merged.length;
744
- merged.push(gpu);
745
- const indexes = indexesByIdentity.get(identity) || [];
746
- indexes.push(index);
747
- indexesByIdentity.set(identity, indexes);
748
- }
749
-
750
- const matchedIndexes = new Set();
751
- for (const value of Array.isArray(telemetryGpus) ? telemetryGpus : []) {
752
- const gpu = normalizeGpu(value);
753
- const identity = normalizeGpuIdentityName(gpu?.name);
754
- if (!gpu || !identity) continue;
755
- const existingIndex = (indexesByIdentity.get(identity) || [])
756
- .find(index => !matchedIndexes.has(index));
757
- if (existingIndex === undefined) {
758
- merged.push(gpu);
759
- continue;
760
- }
761
- matchedIndexes.add(existingIndex);
762
- const existing = merged[existingIndex];
763
- merged[existingIndex] = {
764
- ...existing,
765
- usagePercent: gpu.usagePercent ?? existing.usagePercent,
766
- memoryTotalBytes: gpu.memoryTotalBytes ?? existing.memoryTotalBytes,
767
- memoryUsedBytes: gpu.memoryUsedBytes ?? existing.memoryUsedBytes
768
- };
769
- }
770
- return merged;
771
- }
772
-
773
- function normalizeDisk(value) {
774
- if (!value || typeof value !== 'object') return null;
775
- const mount = normalizeString(value.mount, 80);
776
- const totalBytes = finiteNumber(value.totalBytes);
777
- const freeBytes = finiteNumber(value.freeBytes);
778
- if (!mount || totalBytes === null || totalBytes <= 0 || freeBytes === null) return null;
779
- const boundedFreeBytes = Math.min(totalBytes, freeBytes);
780
- const usedBytes = Math.max(0, totalBytes - boundedFreeBytes);
781
- return {
782
- name: normalizeString(value.name, 120),
783
- mount,
784
- totalBytes,
785
- freeBytes: boundedFreeBytes,
786
- usedBytes,
787
- usedPercent: Math.round((usedBytes / totalBytes) * 1000) / 10
788
- };
789
- }
790
-
791
- export function parseWindowsHardwareOutput(output) {
792
- try {
793
- const payload = JSON.parse(String(output || '').trim());
794
- const rawGpus = Array.isArray(payload?.gpus) ? payload.gpus : payload?.gpus ? [payload.gpus] : [];
795
- const rawDisks = Array.isArray(payload?.disks) ? payload.disks : payload?.disks ? [payload.disks] : [];
796
- return {
797
- gpus: rawGpus.map(normalizeGpu).filter(Boolean),
798
- disks: rawDisks.map(normalizeDisk).filter(Boolean)
799
- };
800
- } catch {
801
- return { gpus: [], disks: [] };
802
- }
803
- }
804
-
805
- function readRootDiskSnapshot() {
806
- try {
807
- const mount = parse(os.homedir()).root || '/';
808
- const stats = statfsSync(mount, { bigint: true });
809
- return normalizeDisk({
810
- name: process.platform === 'darwin' ? 'Macintosh HD' : 'System',
811
- mount,
812
- totalBytes: Number(stats.bsize * stats.blocks),
813
- freeBytes: Number(stats.bsize * stats.bavail)
814
- });
815
- } catch {
816
- return null;
817
- }
818
- }
819
-
820
- function readFilesystemDiskSnapshot(mount, name) {
821
- try {
822
- const stats = statfsSync(mount, { bigint: true });
823
- return normalizeDisk({
824
- name,
825
- mount,
826
- totalBytes: Number(stats.bsize * stats.blocks),
827
- freeBytes: Number(stats.bsize * stats.bavail)
828
- });
829
- } catch {
830
- return null;
831
- }
832
- }
833
-
834
- export function readMacDiskSnapshots(options = {}) {
835
- const rootMount = String(options.rootMount || '/');
836
- const volumesRoot = String(options.volumesRoot || '/Volumes');
837
- const disks = [];
838
- const seenPaths = new Set();
839
- const addDisk = (mount, name) => {
840
- try {
841
- const resolvedPath = realpathSync(mount);
842
- if (seenPaths.has(resolvedPath)) return;
843
- const disk = readFilesystemDiskSnapshot(mount, name);
844
- if (!disk) return;
845
- seenPaths.add(resolvedPath);
846
- disks.push(disk);
847
- } catch {
848
- // A removable volume may disappear while the snapshot is being read.
849
- }
850
- };
851
-
852
- addDisk(rootMount, 'Macintosh HD');
853
- try {
854
- for (const entry of readdirSync(volumesRoot, { withFileTypes: true })) {
855
- if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
856
- addDisk(join(volumesRoot, entry.name), entry.name);
857
- }
858
- } catch {
859
- // /Volumes can be unavailable in restricted environments; keep the root disk.
860
- }
861
- return disks;
862
- }
863
-
864
- async function readWindowsHardwareSnapshot(commandOwner) {
865
- const script = [
866
- '$ErrorActionPreference="Stop"',
867
- '$OutputEncoding=[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false)',
868
- '$physicalDriveIds=@(Get-CimInstance Win32_DiskDrive | ForEach-Object { Get-CimAssociatedInstance -InputObject $_ -Association Win32_DiskDriveToDiskPartition | ForEach-Object { Get-CimAssociatedInstance -InputObject $_ -Association Win32_LogicalDiskToPartition } } | Select-Object -ExpandProperty DeviceID -Unique)',
869
- '$gpus=@(Get-CimInstance Win32_VideoController | Where-Object { $_.Name } | ForEach-Object { [pscustomobject]@{ name=[string]$_.Name; usagePercent=$null; memoryTotalBytes=if ($_.AdapterRAM) { [double]$_.AdapterRAM } else { $null }; memoryUsedBytes=$null } })',
870
- '$disks=@(Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | Where-Object { $physicalDriveIds -contains $_.DeviceID } | Sort-Object DeviceID | ForEach-Object { [pscustomobject]@{ name=[string]$_.VolumeName; mount=[string]$_.DeviceID; totalBytes=[double]$_.Size; freeBytes=[double]$_.FreeSpace } })',
871
- '[pscustomobject]@{ gpus=$gpus; disks=$disks } | ConvertTo-Json -Compress -Depth 5'
872
- ].join('; ');
873
- return parseWindowsHardwareOutput(await commandOwner.capture('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], 5000));
874
- }
875
-
876
- function parseMacGpuOutput(output) {
877
- try {
878
- const payload = JSON.parse(String(output || '').trim());
879
- return (Array.isArray(payload?.SPDisplaysDataType) ? payload.SPDisplaysDataType : [])
880
- .map(item => normalizeGpu({ name: item?.sppci_model || item?._name }))
881
- .filter(Boolean);
882
- } catch {
883
- return [];
884
- }
885
- }
886
-
887
- async function collectPlatformHardwareProbe(commandOwner, platform = process.platform) {
888
- if (platform === 'win32') {
889
- const snapshot = await readWindowsHardwareSnapshot(commandOwner);
890
- return snapshot.gpus.length > 0 || snapshot.disks.length > 0
891
- ? { ok: true, value: snapshot }
892
- : { ok: false, value: snapshot, error: 'windows-hardware-unavailable' };
893
- }
894
- if (platform === 'darwin') {
895
- const output = await commandOwner.capture('system_profiler', ['SPDisplaysDataType', '-json'], 5000);
896
- const snapshot = {
897
- gpus: parseMacGpuOutput(output),
898
- disks: readMacDiskSnapshots()
899
- };
900
- return String(output || '').trim()
901
- ? { ok: true, value: snapshot }
902
- : { ok: false, value: snapshot, error: 'system-profiler-unavailable' };
903
- }
904
- const disk = readRootDiskSnapshot();
905
- return {
906
- ok: Boolean(disk),
907
- value: { gpus: [], disks: disk ? [disk] : [] },
908
- error: disk ? '' : 'root-disk-unavailable'
909
- };
910
- }
911
-
912
- async function collectNvidiaGpuProbe(commandOwner, platform = process.platform) {
913
- const command = resolveNvidiaSmiCommand({ platform });
914
- if (!command) {
915
- return { ok: false, value: [], error: 'nvidia-smi-unavailable' };
916
- }
917
- const output = await commandOwner.capture(
918
- command,
919
- ['--query-gpu=name,utilization.gpu,memory.total,memory.used', '--format=csv,noheader,nounits'],
920
- 3000
921
- );
922
- const gpus = parseNvidiaSmiOutput(output);
923
- return gpus.length > 0
924
- ? { ok: true, value: gpus }
925
- : { ok: false, value: [], error: 'nvidia-smi-empty-or-failed' };
926
- }
927
-
928
- function normalizeOrigin(value) {
929
- const candidate = normalizeString(value, 300);
930
- if (!candidate) return '';
931
- try {
932
- const parsed = new URL(candidate);
933
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
934
- return parsed.origin.toLowerCase();
935
- } catch {
936
- return '';
937
- }
938
- }
939
-
940
- function createTrustedWebOrigins(port) {
941
- const origins = new Set([
942
- ...DEFAULT_TRUSTED_WEB_ORIGINS,
943
- `http://127.0.0.1:${port}`,
944
- `http://localhost:${port}`
945
- ]);
946
- for (const value of [process.env.LIVEDESK_WEB_ORIGINS, process.env.LIVEDESK_SITE_URL]) {
947
- for (const candidate of String(value || '').split(/[\s,]+/)) {
948
- const origin = normalizeOrigin(candidate);
949
- if (origin) origins.add(origin);
950
- }
951
- }
952
- return origins;
953
- }
954
-
955
- function readSavedSession() {
956
- if (process.env.LIVEDESK_DESKTOP_HOST === '1') return null;
957
- try {
7
+ import { createRuntimeManager, normalizeRuntimeAuthSession, runtimeRoleError, RuntimeState } from '../../../runtime-core/src/index.js';
8
+
9
+ const DEFAULT_HOST = '127.0.0.1';
10
+ const DEFAULT_PORT = 5179;
11
+ const CLIENT_RUNTIME_BIND_TIMEOUT_MS = 10_000;
12
+ const CLIENT_DIAGNOSTIC_COMMAND_FORCE_KILL_MS = 150;
13
+ const CLIENT_DIAGNOSTIC_COMMAND_DRAIN_MS = 750;
14
+ const CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS = 1_500;
15
+ const CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS = 15_000;
16
+ const CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS = 60_000;
17
+ const CLIENT_DIAGNOSTIC_HARDWARE_TTL_MS = 300_000;
18
+ const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
19
+ const SUPABASE_URL = process.env.LIVEDESK_SUPABASE_URL || 'https://otbyfkjxrkngvjziawki.supabase.co';
20
+ const SUPABASE_PUBLISHABLE_KEY = process.env.LIVEDESK_SUPABASE_PUBLISHABLE_KEY || 'sb_publishable_NpUs0RDJH2YnllsqTKO6TQ_1jTdSsNQ';
21
+ const CLIENT_STATE_DIR = process.env.LIVEDESK_STATE_DIR || (process.env.LIVEDESK_UNIFIED_RUNTIME === '1' ? join(os.homedir(), '.livedesk') : join(os.homedir(), '.livedesk-client'));
22
+ const CLIENT_AUTH_PATH = join(CLIENT_STATE_DIR, 'auth.json');
23
+ const DEFAULT_TRUSTED_WEB_ORIGINS = Object.freeze([
24
+ 'https://livedesk.pages.dev',
25
+ 'http://127.0.0.1:5173',
26
+ 'http://localhost:5173',
27
+ 'http://127.0.0.1:4173',
28
+ 'http://localhost:4173'
29
+ ]);
30
+ const LIVE_DESK_PAGES_PREVIEW_HOST = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.livedesk\.pages\.dev$/;
31
+ const READ_ONLY_CLIENT_API_PATHS = new Set([
32
+ '/api/health',
33
+ '/api/runtime',
34
+ '/api/runtime/status',
35
+ '/api/runtime/events',
36
+ '/api/client/status',
37
+ '/api/client/computer',
38
+ '/api/client/system',
39
+ '/api/client/permissions',
40
+ '/api/client/diagnostics',
41
+ '/api/auth/status'
42
+ ]);
43
+
44
+ function normalizeString(value, maxLength = 1024) {
45
+ return String(value ?? '').replace(/[\r\n\t]/g, ' ').trim().slice(0, maxLength);
46
+ }
47
+
48
+ function normalizeAccountAvatarUrl(value) {
49
+ const candidate = normalizeString(value, 2048);
50
+ if (!candidate) return '';
51
+ try {
52
+ const url = new URL(candidate);
53
+ return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : '';
54
+ } catch {
55
+ return '';
56
+ }
57
+ }
58
+
59
+ function readAccessTokenProfile(accessToken) {
60
+ const token = normalizeString(accessToken, 8192);
61
+ const payloadSegment = token.split('.')[1] || '';
62
+ if (!payloadSegment) return {};
63
+ try {
64
+ const payload = JSON.parse(Buffer.from(payloadSegment, 'base64url').toString('utf8'));
65
+ return payload && typeof payload === 'object' ? payload : {};
66
+ } catch {
67
+ return {};
68
+ }
69
+ }
70
+
71
+ function readClientAccountProfile(sessionOrUser) {
72
+ const tokenProfile = readAccessTokenProfile(sessionOrUser?.access_token);
73
+ const user = sessionOrUser?.user && typeof sessionOrUser.user === 'object'
74
+ ? sessionOrUser.user
75
+ : sessionOrUser && typeof sessionOrUser === 'object'
76
+ ? sessionOrUser
77
+ : {};
78
+ const metadata = user.user_metadata && typeof user.user_metadata === 'object'
79
+ ? user.user_metadata
80
+ : {};
81
+ const tokenMetadata = tokenProfile.user_metadata && typeof tokenProfile.user_metadata === 'object'
82
+ ? tokenProfile.user_metadata
83
+ : {};
84
+ const email = normalizeString(user.email || tokenProfile.email, 320);
85
+ const name = normalizeString(
86
+ metadata.full_name
87
+ || metadata.name
88
+ || metadata.preferred_username
89
+ || tokenMetadata.full_name
90
+ || tokenMetadata.name
91
+ || tokenMetadata.preferred_username
92
+ || user.name
93
+ || tokenProfile.name
94
+ || email
95
+ || user.id,
96
+ 160
97
+ );
98
+ const avatarUrl = normalizeAccountAvatarUrl(
99
+ metadata.avatar_url
100
+ || metadata.picture
101
+ || tokenMetadata.avatar_url
102
+ || tokenMetadata.picture
103
+ || user.avatar_url
104
+ || user.picture
105
+ );
106
+ return { email, name, avatarUrl };
107
+ }
108
+
109
+ function mergeClientAccountProfile(session, sourceUser) {
110
+ const profile = readClientAccountProfile(sourceUser);
111
+ const existingUserMetadata = session?.user?.user_metadata && typeof session.user.user_metadata === 'object'
112
+ ? session.user.user_metadata
113
+ : {};
114
+ const userMetadata = {
115
+ ...existingUserMetadata,
116
+ ...(profile.name ? { full_name: profile.name } : {}),
117
+ ...(profile.avatarUrl ? { avatar_url: profile.avatarUrl } : {})
118
+ };
119
+ return {
120
+ ...session,
121
+ user: {
122
+ ...(session?.user || {}),
123
+ ...(Object.keys(userMetadata).length > 0 ? { user_metadata: userMetadata } : {})
124
+ }
125
+ };
126
+ }
127
+
128
+ async function hydrateClientAccountProfile(session) {
129
+ const accessToken = normalizeString(session?.access_token, 8192);
130
+ if (!accessToken) return session;
131
+ try {
132
+ const response = await fetch(`${SUPABASE_URL.replace(/\/+$/, '')}/auth/v1/user`, {
133
+ headers: {
134
+ apikey: SUPABASE_PUBLISHABLE_KEY,
135
+ Authorization: `Bearer ${accessToken}`,
136
+ Accept: 'application/json'
137
+ },
138
+ signal: AbortSignal.timeout(5_000)
139
+ });
140
+ if (!response.ok) return session;
141
+ const verifiedUser = await response.json().catch(() => null);
142
+ return verifiedUser ? mergeClientAccountProfile(session, verifiedUser) : session;
143
+ } catch {
144
+ return session;
145
+ }
146
+ }
147
+
148
+ function normalizePort(value, fallback = DEFAULT_PORT) {
149
+ const port = Number(value);
150
+ return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : fallback;
151
+ }
152
+
153
+ function normalizeSlotNumber(value) {
154
+ const slotNumber = Number(String(value ?? '').trim());
155
+ return Number.isInteger(slotNumber) && slotNumber >= 1 && slotNumber <= 999
156
+ ? slotNumber
157
+ : 0;
158
+ }
159
+
160
+ function normalizeRoleVersion(value) {
161
+ const roleVersion = Number(String(value ?? '').trim());
162
+ return Number.isInteger(roleVersion) && roleVersion >= 0 ? roleVersion : 0;
163
+ }
164
+
165
+ function readCpuTotals() {
166
+ return os.cpus().reduce((totals, cpu) => {
167
+ const times = Object.values(cpu.times || {}).map(Number);
168
+ totals.idle += Number(cpu.times?.idle || 0);
169
+ totals.total += times.reduce((sum, value) => sum + value, 0);
170
+ return totals;
171
+ }, { idle: 0, total: 0 });
172
+ }
173
+
174
+ function finiteNumber(value) {
175
+ if (value === null
176
+ || value === undefined
177
+ || typeof value === 'boolean'
178
+ || (typeof value === 'string' && value.trim() === '')) {
179
+ return null;
180
+ }
181
+ const number = Number(value);
182
+ return Number.isFinite(number) && number >= 0 ? number : null;
183
+ }
184
+
185
+ function percentFromRatio(value) {
186
+ const number = finiteNumber(value);
187
+ if (number === null) return null;
188
+ return Math.round(Math.max(0, Math.min(100, number)) * 10) / 10;
189
+ }
190
+
191
+ function destroyChildRedirectedIo(child) {
192
+ for (const stream of [child?.stdin, child?.stdout, child?.stderr]) {
193
+ try { stream?.destroy(); } catch { /* an exact owned stream already closed */ }
194
+ }
195
+ }
196
+
197
+ function sanitizeDiagnosticCommandName(value) {
198
+ return normalizeString(value, 200) || 'unknown-diagnostic-command';
199
+ }
200
+
201
+ function defaultDiagnosticMonotonicNow() {
202
+ return Number(process.hrtime.bigint() / 1_000_000n);
203
+ }
204
+
205
+ /**
206
+ * Contract:
207
+ * - A probe is idle until an explicit diagnostic request calls read().
208
+ * - Every successful, unavailable, empty, or failed result is retained for at
209
+ * least 15 monotonic seconds measured from refresh completion.
210
+ * - Concurrent readers share one refresh promise. A failed tool therefore
211
+ * cannot respawn once per status heartbeat.
212
+ */
213
+ export function createClientDiagnosticProbeCache(options = {}) {
214
+ if (typeof options.refresh !== 'function') {
215
+ throw new TypeError('client-diagnostic-probe-refresh-required');
216
+ }
217
+ const probeId = normalizeString(options.probeId, 80) || 'client-diagnostic-probe';
218
+ const monotonicNow = typeof options.monotonicNow === 'function'
219
+ ? options.monotonicNow
220
+ : defaultDiagnosticMonotonicNow;
221
+ const ttlMs = Math.max(
222
+ CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
223
+ Number(options.ttlMs) || CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS
224
+ );
225
+ const negativeTtlMs = Math.max(
226
+ CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
227
+ Number(options.negativeTtlMs) || CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS
228
+ );
229
+ const fallbackValue = options.fallbackValue;
230
+ let initialized = false;
231
+ let sampledAtMonotonicMs = 0;
232
+ let generation = 0;
233
+ let refreshCount = 0;
234
+ let cacheHitCount = 0;
235
+ let sharedReadCount = 0;
236
+ let record = {
237
+ ok: false,
238
+ value: fallbackValue,
239
+ error: 'not-requested'
240
+ };
241
+ let inFlight = null;
242
+
243
+ const snapshot = () => {
244
+ const now = Number(monotonicNow());
245
+ const ageMs = initialized && Number.isFinite(now)
246
+ ? Math.max(0, now - sampledAtMonotonicMs)
247
+ : null;
248
+ return {
249
+ probeId,
250
+ initialized,
251
+ inFlight: Boolean(inFlight),
252
+ ok: initialized ? record.ok === true : false,
253
+ error: initialized ? normalizeString(record.error, 160) : 'not-requested',
254
+ ttlMs,
255
+ negativeTtlMs,
256
+ ageMs,
257
+ generation,
258
+ refreshCount,
259
+ cacheHitCount,
260
+ sharedReadCount
261
+ };
262
+ };
263
+
264
+ const read = () => {
265
+ const now = Number(monotonicNow());
266
+ const activeTtlMs = record.ok === true ? ttlMs : negativeTtlMs;
267
+ if (initialized
268
+ && Number.isFinite(now)
269
+ && Math.max(0, now - sampledAtMonotonicMs) < activeTtlMs) {
270
+ cacheHitCount += 1;
271
+ return Promise.resolve({
272
+ ...record,
273
+ cached: true,
274
+ shared: false,
275
+ generation
276
+ });
277
+ }
278
+ if (inFlight) {
279
+ sharedReadCount += 1;
280
+ return inFlight.then(result => ({ ...result, shared: true }));
281
+ }
282
+
283
+ inFlight = (async () => {
284
+ let next;
285
+ try {
286
+ const candidate = await options.refresh();
287
+ next = candidate && typeof candidate === 'object'
288
+ ? {
289
+ ok: candidate.ok === true,
290
+ value: candidate.value === undefined ? fallbackValue : candidate.value,
291
+ error: candidate.ok === true ? '' : normalizeString(candidate.error, 160) || 'probe-unavailable'
292
+ }
293
+ : {
294
+ ok: false,
295
+ value: fallbackValue,
296
+ error: 'probe-invalid-result'
297
+ };
298
+ } catch (error) {
299
+ next = {
300
+ ok: false,
301
+ value: fallbackValue,
302
+ error: normalizeString(error?.message || error, 160) || 'probe-failed'
303
+ };
304
+ }
305
+ record = next;
306
+ initialized = true;
307
+ generation += 1;
308
+ refreshCount += 1;
309
+ sampledAtMonotonicMs = Number(monotonicNow());
310
+ return {
311
+ ...record,
312
+ cached: false,
313
+ shared: false,
314
+ generation
315
+ };
316
+ })().finally(() => {
317
+ inFlight = null;
318
+ });
319
+ return inFlight;
320
+ };
321
+
322
+ return {
323
+ read,
324
+ getSnapshot: snapshot
325
+ };
326
+ }
327
+
328
+ function isExecutablePath(path) {
329
+ try {
330
+ accessSync(path, fsConstants.X_OK);
331
+ return true;
332
+ } catch {
333
+ return false;
334
+ }
335
+ }
336
+
337
+ export function resolveNvidiaSmiCommand(options = {}) {
338
+ const platform = normalizeString(options.platform || process.platform, 20);
339
+ const executablePath = normalizeString(
340
+ options.explicitPath ?? process.env.LIVEDESK_NVIDIA_SMI_PATH,
341
+ 1000
342
+ );
343
+ const executableCheck = typeof options.isExecutable === 'function'
344
+ ? options.isExecutable
345
+ : isExecutablePath;
346
+ if (platform === 'win32') {
347
+ return executablePath || 'nvidia-smi.exe';
348
+ }
349
+
350
+ const pathValue = String(options.pathValue ?? process.env.PATH ?? '');
351
+ const pathDelimiter = normalizeString(options.pathDelimiter, 4) || delimiter;
352
+ const joinExecutablePath = platform === 'win32' ? join : posix.join;
353
+ const candidates = [
354
+ executablePath,
355
+ ...pathValue
356
+ .split(pathDelimiter)
357
+ .map(directory => directory.trim())
358
+ .filter(Boolean)
359
+ .map(directory => joinExecutablePath(directory, 'nvidia-smi')),
360
+ ...(platform === 'linux' ? ['/usr/bin/nvidia-smi', '/usr/local/bin/nvidia-smi'] : [])
361
+ ].filter(Boolean);
362
+ for (const candidate of [...new Set(candidates)]) {
363
+ try {
364
+ if (executableCheck(candidate) === true) return candidate;
365
+ } catch {
366
+ // An unreadable PATH entry is negative probe evidence.
367
+ }
368
+ }
369
+ return '';
370
+ }
371
+
372
+ /**
373
+ * Contract:
374
+ * - Owns only ChildProcess objects spawned through this instance.
375
+ * - Identity is ownerId + commandId + the immutable ChildProcess handle; a PID
376
+ * is diagnostic evidence and is never rediscovered to authorize termination.
377
+ * - A command deadline and owner close both abort, terminate, drain redirected
378
+ * stdio, and wait for the exact child's terminal `close` event.
379
+ * - Concurrent identical probes share the same command/output/terminal owner.
380
+ * - Output, close, and active-ledger retention are bounded independently so an
381
+ * inherited pipe can never keep the Client event loop alive without evidence.
382
+ */
383
+ export function createClientDiagnosticCommandOwner(options = {}) {
384
+ const execFileImpl = typeof options.execFileImpl === 'function' ? options.execFileImpl : execFile;
385
+ const ownerId = normalizeString(options.ownerId, 160)
386
+ || `client-diagnostics-${process.pid}-${randomBytes(8).toString('hex')}`;
387
+ const forceKillDelayMs = Math.max(
388
+ 10,
389
+ Number(options.forceKillDelayMs) || CLIENT_DIAGNOSTIC_COMMAND_FORCE_KILL_MS
390
+ );
391
+ const commandDrainMs = Math.max(
392
+ forceKillDelayMs + 10,
393
+ Number(options.commandDrainMs) || CLIENT_DIAGNOSTIC_COMMAND_DRAIN_MS
394
+ );
395
+ const active = new Map();
396
+ const activeByKey = new Map();
397
+ let commandSequence = 0;
398
+ let spawnedCount = 0;
399
+ let joinedCount = 0;
400
+ let timedOutCount = 0;
401
+ let sharedRequestCount = 0;
402
+ let closePromise = null;
403
+ let closed = false;
404
+
405
+ const snapshotRecord = record => ({
406
+ ownerId,
407
+ commandId: record.commandId,
408
+ pid: record.pid,
409
+ command: record.command,
410
+ startedAt: record.startedAt,
411
+ terminationReason: record.terminationReason || ''
412
+ });
413
+
414
+ const getSnapshot = () => ({
415
+ ownerId,
416
+ closed,
417
+ spawnedCount,
418
+ joinedCount,
419
+ timedOutCount,
420
+ sharedRequestCount,
421
+ activeCount: active.size,
422
+ redirectedStreamCount: [...active.values()].reduce(
423
+ (count, record) => count + [record.child?.stdin, record.child?.stdout, record.child?.stderr]
424
+ .filter(stream => stream && !stream.destroyed).length,
425
+ 0
426
+ ),
427
+ active: [...active.values()].map(snapshotRecord)
428
+ });
429
+
430
+ const settleOutput = (record, output = '') => {
431
+ if (record.outputSettled) return;
432
+ record.outputSettled = true;
433
+ record.resolveOutput(String(output || ''));
434
+ };
435
+
436
+ const markTerminal = record => {
437
+ if (record.terminal) return;
438
+ record.terminal = true;
439
+ clearTimeout(record.commandTimer);
440
+ clearTimeout(record.forceKillTimer);
441
+ clearTimeout(record.outputDrainTimer);
442
+ destroyChildRedirectedIo(record.child);
443
+ active.delete(record.commandId);
444
+ if (activeByKey.get(record.commandKey) === record) {
445
+ activeByKey.delete(record.commandKey);
446
+ }
447
+ joinedCount += 1;
448
+ settleOutput(record, record.error ? '' : record.stdout);
449
+ record.resolveTerminal();
450
+ };
451
+
452
+ const terminateOwnedRecord = (record, reason) => {
453
+ if (!record || record.terminal || record.terminationRequested) return;
454
+ record.terminationRequested = true;
455
+ record.terminationReason = normalizeString(reason, 80) || 'diagnostic-owner-close';
456
+ if (record.terminationReason === 'command-timeout') timedOutCount += 1;
457
+ clearTimeout(record.commandTimer);
458
+ try { record.abortController.abort(new Error(record.terminationReason)); } catch { /* already aborted */ }
459
+ try { record.child?.kill('SIGTERM'); } catch { /* exact child already exited */ }
460
+ record.forceKillTimer = setTimeout(() => {
461
+ if (record.terminal) return;
462
+ try { record.child?.kill('SIGKILL'); } catch { /* exact child already exited */ }
463
+ // An inherited writer can keep execFile's callback pending even after the
464
+ // root exits. Closing our exact pipe ends the Client-side handle owner.
465
+ destroyChildRedirectedIo(record.child);
466
+ }, forceKillDelayMs);
467
+ record.forceKillTimer.unref?.();
468
+ record.outputDrainTimer = setTimeout(() => {
469
+ if (record.terminal) return;
470
+ settleOutput(record, '');
471
+ destroyChildRedirectedIo(record.child);
472
+ record.child?.unref?.();
473
+ }, commandDrainMs);
474
+ record.outputDrainTimer.unref?.();
475
+ };
476
+
477
+ const capture = (command, args = [], timeout = 3500) => {
478
+ if (closed) return Promise.resolve('');
479
+ const normalizedArgs = Array.isArray(args) ? args : [];
480
+ const commandKey = JSON.stringify([
481
+ String(command || ''),
482
+ ...normalizedArgs.map(value => String(value ?? ''))
483
+ ]);
484
+ const sharedRecord = activeByKey.get(commandKey);
485
+ if (sharedRecord) {
486
+ sharedRequestCount += 1;
487
+ return sharedRecord.outputPromise;
488
+ }
489
+ const commandId = `${ownerId}:${++commandSequence}`;
490
+ const abortController = new AbortController();
491
+ let resolveOutput;
492
+ let resolveTerminal;
493
+ const outputPromise = new Promise(resolve => { resolveOutput = resolve; });
494
+ const terminalPromise = new Promise(resolve => { resolveTerminal = resolve; });
495
+ const record = {
496
+ ownerId,
497
+ commandId,
498
+ commandKey,
499
+ command: sanitizeDiagnosticCommandName(command),
500
+ pid: 0,
501
+ startedAt: new Date().toISOString(),
502
+ child: null,
503
+ abortController,
504
+ resolveOutput,
505
+ resolveTerminal,
506
+ outputPromise,
507
+ terminalPromise,
508
+ stdout: '',
509
+ error: null,
510
+ outputSettled: false,
511
+ terminal: false,
512
+ terminationRequested: false,
513
+ terminationReason: '',
514
+ commandTimer: null,
515
+ forceKillTimer: null,
516
+ outputDrainTimer: null
517
+ };
518
+ active.set(commandId, record);
519
+ activeByKey.set(commandKey, record);
520
+
521
+ try {
522
+ record.child = execFileImpl(command, normalizedArgs, {
523
+ encoding: 'utf8',
524
+ maxBuffer: 512 * 1024,
525
+ windowsHide: true,
526
+ signal: abortController.signal
527
+ }, (error, stdout) => {
528
+ record.error = error || null;
529
+ record.stdout = error ? '' : String(stdout || '');
530
+ settleOutput(record, record.stdout);
531
+ });
532
+ record.pid = Number(record.child?.pid || 0);
533
+ spawnedCount += 1;
534
+ record.child.once('close', () => markTerminal(record));
535
+ record.child.once('error', error => {
536
+ record.error = record.error || error;
537
+ });
538
+ const commandTimeoutMs = Math.max(10, Number(timeout) || 3500);
539
+ record.commandTimer = setTimeout(
540
+ () => terminateOwnedRecord(record, 'command-timeout'),
541
+ commandTimeoutMs
542
+ );
543
+ record.commandTimer.unref?.();
544
+ } catch (error) {
545
+ record.error = error;
546
+ settleOutput(record, '');
547
+ markTerminal(record);
548
+ }
549
+
550
+ return outputPromise;
551
+ };
552
+
553
+ const close = (timeoutMs = CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS) => {
554
+ if (closePromise) return closePromise;
555
+ closed = true;
556
+ const boundedTimeoutMs = Math.max(100, Number(timeoutMs) || CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS);
557
+ closePromise = (async () => {
558
+ const records = [...active.values()];
559
+ for (const record of records) terminateOwnedRecord(record, 'diagnostic-owner-close');
560
+ if (records.length > 0) {
561
+ let deadlineTimer;
562
+ await Promise.race([
563
+ Promise.allSettled(records.map(record => record.terminalPromise)),
564
+ new Promise(resolve => {
565
+ deadlineTimer = setTimeout(resolve, boundedTimeoutMs);
566
+ })
567
+ ]);
568
+ clearTimeout(deadlineTimer);
569
+ }
570
+ for (const record of active.values()) {
571
+ settleOutput(record, '');
572
+ try { record.child?.kill('SIGKILL'); } catch { /* exact child already exited */ }
573
+ destroyChildRedirectedIo(record.child);
574
+ record.child?.unref?.();
575
+ }
576
+ const snapshot = getSnapshot();
577
+ return {
578
+ ok: snapshot.activeCount === 0 && snapshot.redirectedStreamCount === 0,
579
+ ...snapshot
580
+ };
581
+ })();
582
+ return closePromise;
583
+ };
584
+
585
+ return {
586
+ ownerId,
587
+ capture,
588
+ close,
589
+ getSnapshot
590
+ };
591
+ }
592
+
593
+ export function parseNvidiaSmiOutput(output) {
594
+ return String(output || '')
595
+ .split(/\r?\n/)
596
+ .map(line => line.trim())
597
+ .filter(Boolean)
598
+ .map(line => {
599
+ const [name = '', usage = '', totalMemoryMiB = '', usedMemoryMiB = ''] = line.split(',').map(value => value.trim());
600
+ const memoryTotalBytes = finiteNumber(totalMemoryMiB);
601
+ const memoryUsedBytes = finiteNumber(usedMemoryMiB);
602
+ return {
603
+ name: normalizeString(name, 160),
604
+ usagePercent: percentFromRatio(usage),
605
+ memoryTotalBytes: memoryTotalBytes === null ? null : Math.round(memoryTotalBytes * 1024 ** 2),
606
+ memoryUsedBytes: memoryUsedBytes === null ? null : Math.round(memoryUsedBytes * 1024 ** 2)
607
+ };
608
+ })
609
+ .filter(gpu => gpu.name);
610
+ }
611
+
612
+ function parseCounter(value) {
613
+ const number = Number(String(value || '').replaceAll(',', ''));
614
+ return Number.isFinite(number) && number >= 0 ? number : 0;
615
+ }
616
+
617
+ export function parseWindowsNetworkOutput(output) {
618
+ for (const line of String(output || '').split(/\r?\n/)) {
619
+ const match = line.match(/(?:^|\s)(\d[\d,]*)\s+(\d[\d,]*)\s*$/);
620
+ if (match) return { receivedBytes: parseCounter(match[1]), sentBytes: parseCounter(match[2]) };
621
+ }
622
+ return { receivedBytes: 0, sentBytes: 0 };
623
+ }
624
+
625
+ export function parseMacNetworkOutput(output) {
626
+ const lines = String(output || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
627
+ const header = lines.find(line => /\bIbytes\b/i.test(line) && /\bObytes\b/i.test(line));
628
+ if (!header) return { receivedBytes: 0, sentBytes: 0 };
629
+ const columns = header.split(/\s+/);
630
+ const receivedIndex = columns.findIndex(column => column.toLowerCase() === 'ibytes');
631
+ const sentIndex = columns.findIndex(column => column.toLowerCase() === 'obytes');
632
+ const byInterface = new Map();
633
+ for (const line of lines.slice(lines.indexOf(header) + 1)) {
634
+ const fields = line.split(/\s+/);
635
+ const name = normalizeString(fields[0], 80);
636
+ if (!name || name === 'lo0' || fields.length <= Math.max(receivedIndex, sentIndex)) continue;
637
+ const receivedBytes = parseCounter(fields[receivedIndex]);
638
+ const sentBytes = parseCounter(fields[sentIndex]);
639
+ const previous = byInterface.get(name) || { receivedBytes: 0, sentBytes: 0 };
640
+ byInterface.set(name, {
641
+ receivedBytes: Math.max(previous.receivedBytes, receivedBytes),
642
+ sentBytes: Math.max(previous.sentBytes, sentBytes)
643
+ });
644
+ }
645
+ return [...byInterface.values()].reduce((total, current) => ({
646
+ receivedBytes: total.receivedBytes + current.receivedBytes,
647
+ sentBytes: total.sentBytes + current.sentBytes
648
+ }), { receivedBytes: 0, sentBytes: 0 });
649
+ }
650
+
651
+ export function parseLinuxNetworkOutput(output) {
652
+ let receivedBytes = 0;
653
+ let sentBytes = 0;
654
+ for (const line of String(output || '').split(/\r?\n/)) {
655
+ const separator = line.indexOf(':');
656
+ if (separator < 0) continue;
657
+ const name = line.slice(0, separator).trim();
658
+ if (!name || name === 'lo') continue;
659
+ const fields = line.slice(separator + 1).trim().split(/\s+/);
660
+ receivedBytes += parseCounter(fields[0]);
661
+ sentBytes += parseCounter(fields[8]);
662
+ }
663
+ return { receivedBytes, sentBytes };
664
+ }
665
+
666
+ function activeNetworkInterfaces() {
667
+ const interfaces = [];
668
+ for (const [name, addresses] of Object.entries(os.networkInterfaces())) {
669
+ for (const address of addresses || []) {
670
+ if (address.internal) continue;
671
+ interfaces.push({
672
+ name: normalizeString(name, 80),
673
+ address: normalizeString(address.address, 100),
674
+ family: normalizeString(address.family, 20)
675
+ });
676
+ }
677
+ }
678
+ return interfaces;
679
+ }
680
+
681
+ async function readNetworkTotalsProbe(commandOwner, platform = process.platform) {
682
+ if (platform === 'win32') {
683
+ const output = await commandOwner.capture('netstat.exe', ['-e'], 2500);
684
+ return String(output || '').trim()
685
+ ? { ok: true, value: parseWindowsNetworkOutput(output) }
686
+ : { ok: false, value: { receivedBytes: 0, sentBytes: 0 }, error: 'netstat-unavailable' };
687
+ }
688
+ if (platform === 'darwin') {
689
+ const output = await commandOwner.capture('netstat', ['-ibn'], 2500);
690
+ return String(output || '').trim()
691
+ ? { ok: true, value: parseMacNetworkOutput(output) }
692
+ : { ok: false, value: { receivedBytes: 0, sentBytes: 0 }, error: 'netstat-unavailable' };
693
+ }
694
+ try {
695
+ return {
696
+ ok: true,
697
+ value: parseLinuxNetworkOutput(readFileSync('/proc/net/dev', 'utf8'))
698
+ };
699
+ } catch {
700
+ return {
701
+ ok: false,
702
+ value: { receivedBytes: 0, sentBytes: 0 },
703
+ error: 'network-counters-unavailable'
704
+ };
705
+ }
706
+ }
707
+
708
+ function normalizeGpu(value) {
709
+ if (!value || typeof value !== 'object') return null;
710
+ const name = normalizeString(value.name, 160);
711
+ if (!name) return null;
712
+ return {
713
+ name,
714
+ usagePercent: percentFromRatio(value.usagePercent),
715
+ memoryTotalBytes: finiteNumber(value.memoryTotalBytes),
716
+ memoryUsedBytes: finiteNumber(value.memoryUsedBytes)
717
+ };
718
+ }
719
+
720
+ export function normalizeGpuIdentityName(value) {
721
+ return normalizeString(value, 160)
722
+ .normalize('NFKC')
723
+ .replace(/\((?:r|tm)\)/giu, '')
724
+ .replace(/[®™]/gu, '')
725
+ .toLocaleLowerCase('en-US')
726
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
727
+ .trim();
728
+ }
729
+
730
+ export function mergeGpuSnapshots(platformGpus = [], telemetryGpus = []) {
731
+ const merged = [];
732
+ const indexesByIdentity = new Map();
733
+ for (const value of Array.isArray(platformGpus) ? platformGpus : []) {
734
+ const gpu = normalizeGpu(value);
735
+ const identity = normalizeGpuIdentityName(gpu?.name);
736
+ if (!gpu || !identity) continue;
737
+ const index = merged.length;
738
+ merged.push(gpu);
739
+ const indexes = indexesByIdentity.get(identity) || [];
740
+ indexes.push(index);
741
+ indexesByIdentity.set(identity, indexes);
742
+ }
743
+
744
+ const matchedIndexes = new Set();
745
+ for (const value of Array.isArray(telemetryGpus) ? telemetryGpus : []) {
746
+ const gpu = normalizeGpu(value);
747
+ const identity = normalizeGpuIdentityName(gpu?.name);
748
+ if (!gpu || !identity) continue;
749
+ const existingIndex = (indexesByIdentity.get(identity) || [])
750
+ .find(index => !matchedIndexes.has(index));
751
+ if (existingIndex === undefined) {
752
+ merged.push(gpu);
753
+ continue;
754
+ }
755
+ matchedIndexes.add(existingIndex);
756
+ const existing = merged[existingIndex];
757
+ merged[existingIndex] = {
758
+ ...existing,
759
+ usagePercent: gpu.usagePercent ?? existing.usagePercent,
760
+ memoryTotalBytes: gpu.memoryTotalBytes ?? existing.memoryTotalBytes,
761
+ memoryUsedBytes: gpu.memoryUsedBytes ?? existing.memoryUsedBytes
762
+ };
763
+ }
764
+ return merged;
765
+ }
766
+
767
+ function normalizeDisk(value) {
768
+ if (!value || typeof value !== 'object') return null;
769
+ const mount = normalizeString(value.mount, 80);
770
+ const totalBytes = finiteNumber(value.totalBytes);
771
+ const freeBytes = finiteNumber(value.freeBytes);
772
+ if (!mount || totalBytes === null || totalBytes <= 0 || freeBytes === null) return null;
773
+ const boundedFreeBytes = Math.min(totalBytes, freeBytes);
774
+ const usedBytes = Math.max(0, totalBytes - boundedFreeBytes);
775
+ return {
776
+ name: normalizeString(value.name, 120),
777
+ mount,
778
+ totalBytes,
779
+ freeBytes: boundedFreeBytes,
780
+ usedBytes,
781
+ usedPercent: Math.round((usedBytes / totalBytes) * 1000) / 10
782
+ };
783
+ }
784
+
785
+ export function parseWindowsHardwareOutput(output) {
786
+ try {
787
+ const payload = JSON.parse(String(output || '').trim());
788
+ const rawGpus = Array.isArray(payload?.gpus) ? payload.gpus : payload?.gpus ? [payload.gpus] : [];
789
+ const rawDisks = Array.isArray(payload?.disks) ? payload.disks : payload?.disks ? [payload.disks] : [];
790
+ return {
791
+ gpus: rawGpus.map(normalizeGpu).filter(Boolean),
792
+ disks: rawDisks.map(normalizeDisk).filter(Boolean)
793
+ };
794
+ } catch {
795
+ return { gpus: [], disks: [] };
796
+ }
797
+ }
798
+
799
+ function readRootDiskSnapshot() {
800
+ try {
801
+ const mount = parse(os.homedir()).root || '/';
802
+ const stats = statfsSync(mount, { bigint: true });
803
+ return normalizeDisk({
804
+ name: process.platform === 'darwin' ? 'Macintosh HD' : 'System',
805
+ mount,
806
+ totalBytes: Number(stats.bsize * stats.blocks),
807
+ freeBytes: Number(stats.bsize * stats.bavail)
808
+ });
809
+ } catch {
810
+ return null;
811
+ }
812
+ }
813
+
814
+ function readFilesystemDiskSnapshot(mount, name) {
815
+ try {
816
+ const stats = statfsSync(mount, { bigint: true });
817
+ return normalizeDisk({
818
+ name,
819
+ mount,
820
+ totalBytes: Number(stats.bsize * stats.blocks),
821
+ freeBytes: Number(stats.bsize * stats.bavail)
822
+ });
823
+ } catch {
824
+ return null;
825
+ }
826
+ }
827
+
828
+ export function readMacDiskSnapshots(options = {}) {
829
+ const rootMount = String(options.rootMount || '/');
830
+ const volumesRoot = String(options.volumesRoot || '/Volumes');
831
+ const disks = [];
832
+ const seenPaths = new Set();
833
+ const addDisk = (mount, name) => {
834
+ try {
835
+ const resolvedPath = realpathSync(mount);
836
+ if (seenPaths.has(resolvedPath)) return;
837
+ const disk = readFilesystemDiskSnapshot(mount, name);
838
+ if (!disk) return;
839
+ seenPaths.add(resolvedPath);
840
+ disks.push(disk);
841
+ } catch {
842
+ // A removable volume may disappear while the snapshot is being read.
843
+ }
844
+ };
845
+
846
+ addDisk(rootMount, 'Macintosh HD');
847
+ try {
848
+ for (const entry of readdirSync(volumesRoot, { withFileTypes: true })) {
849
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
850
+ addDisk(join(volumesRoot, entry.name), entry.name);
851
+ }
852
+ } catch {
853
+ // /Volumes can be unavailable in restricted environments; keep the root disk.
854
+ }
855
+ return disks;
856
+ }
857
+
858
+ async function readWindowsHardwareSnapshot(commandOwner) {
859
+ const script = [
860
+ '$ErrorActionPreference="Stop"',
861
+ '$OutputEncoding=[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false)',
862
+ '$physicalDriveIds=@(Get-CimInstance Win32_DiskDrive | ForEach-Object { Get-CimAssociatedInstance -InputObject $_ -Association Win32_DiskDriveToDiskPartition | ForEach-Object { Get-CimAssociatedInstance -InputObject $_ -Association Win32_LogicalDiskToPartition } } | Select-Object -ExpandProperty DeviceID -Unique)',
863
+ '$gpus=@(Get-CimInstance Win32_VideoController | Where-Object { $_.Name } | ForEach-Object { [pscustomobject]@{ name=[string]$_.Name; usagePercent=$null; memoryTotalBytes=if ($_.AdapterRAM) { [double]$_.AdapterRAM } else { $null }; memoryUsedBytes=$null } })',
864
+ '$disks=@(Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | Where-Object { $physicalDriveIds -contains $_.DeviceID } | Sort-Object DeviceID | ForEach-Object { [pscustomobject]@{ name=[string]$_.VolumeName; mount=[string]$_.DeviceID; totalBytes=[double]$_.Size; freeBytes=[double]$_.FreeSpace } })',
865
+ '[pscustomobject]@{ gpus=$gpus; disks=$disks } | ConvertTo-Json -Compress -Depth 5'
866
+ ].join('; ');
867
+ return parseWindowsHardwareOutput(await commandOwner.capture('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], 5000));
868
+ }
869
+
870
+ function parseMacGpuOutput(output) {
871
+ try {
872
+ const payload = JSON.parse(String(output || '').trim());
873
+ return (Array.isArray(payload?.SPDisplaysDataType) ? payload.SPDisplaysDataType : [])
874
+ .map(item => normalizeGpu({ name: item?.sppci_model || item?._name }))
875
+ .filter(Boolean);
876
+ } catch {
877
+ return [];
878
+ }
879
+ }
880
+
881
+ async function collectPlatformHardwareProbe(commandOwner, platform = process.platform) {
882
+ if (platform === 'win32') {
883
+ const snapshot = await readWindowsHardwareSnapshot(commandOwner);
884
+ return snapshot.gpus.length > 0 || snapshot.disks.length > 0
885
+ ? { ok: true, value: snapshot }
886
+ : { ok: false, value: snapshot, error: 'windows-hardware-unavailable' };
887
+ }
888
+ if (platform === 'darwin') {
889
+ const output = await commandOwner.capture('system_profiler', ['SPDisplaysDataType', '-json'], 5000);
890
+ const snapshot = {
891
+ gpus: parseMacGpuOutput(output),
892
+ disks: readMacDiskSnapshots()
893
+ };
894
+ return String(output || '').trim()
895
+ ? { ok: true, value: snapshot }
896
+ : { ok: false, value: snapshot, error: 'system-profiler-unavailable' };
897
+ }
898
+ const disk = readRootDiskSnapshot();
899
+ return {
900
+ ok: Boolean(disk),
901
+ value: { gpus: [], disks: disk ? [disk] : [] },
902
+ error: disk ? '' : 'root-disk-unavailable'
903
+ };
904
+ }
905
+
906
+ async function collectNvidiaGpuProbe(commandOwner, platform = process.platform) {
907
+ const command = resolveNvidiaSmiCommand({ platform });
908
+ if (!command) {
909
+ return { ok: false, value: [], error: 'nvidia-smi-unavailable' };
910
+ }
911
+ const output = await commandOwner.capture(
912
+ command,
913
+ ['--query-gpu=name,utilization.gpu,memory.total,memory.used', '--format=csv,noheader,nounits'],
914
+ 3000
915
+ );
916
+ const gpus = parseNvidiaSmiOutput(output);
917
+ return gpus.length > 0
918
+ ? { ok: true, value: gpus }
919
+ : { ok: false, value: [], error: 'nvidia-smi-empty-or-failed' };
920
+ }
921
+
922
+ function normalizeOrigin(value) {
923
+ const candidate = normalizeString(value, 300);
924
+ if (!candidate) return '';
925
+ try {
926
+ const parsed = new URL(candidate);
927
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
928
+ return parsed.origin.toLowerCase();
929
+ } catch {
930
+ return '';
931
+ }
932
+ }
933
+
934
+ function createTrustedWebOrigins(port) {
935
+ const origins = new Set([
936
+ ...DEFAULT_TRUSTED_WEB_ORIGINS,
937
+ `http://127.0.0.1:${port}`,
938
+ `http://localhost:${port}`
939
+ ]);
940
+ for (const value of [process.env.LIVEDESK_WEB_ORIGINS, process.env.LIVEDESK_SITE_URL]) {
941
+ for (const candidate of String(value || '').split(/[\s,]+/)) {
942
+ const origin = normalizeOrigin(candidate);
943
+ if (origin) origins.add(origin);
944
+ }
945
+ }
946
+ return origins;
947
+ }
948
+
949
+ function readSavedSession() {
950
+ if (process.env.LIVEDESK_DESKTOP_HOST === '1') return null;
951
+ try {
958
952
  const raw = JSON.parse(readFileSync(CLIENT_AUTH_PATH, 'utf8'))?.[CLIENT_AUTH_STORAGE_KEY];
959
- const session = typeof raw === 'string' ? JSON.parse(raw) : null;
960
- if (!session?.access_token) return null;
961
- const plaintextRefreshToken = normalizeString(session.refresh_token, 8192);
962
- const refreshToken = plaintextRefreshToken || (session.refresh_token_ref === OS_SECRET_REFERENCE
963
- ? CLIENT_REFRESH_SECRET_STORE.read()
964
- : '');
965
- if (!refreshToken) return null;
966
- if (plaintextRefreshToken) {
967
- if (!CLIENT_REFRESH_SECRET_STORE.write(plaintextRefreshToken)) {
968
- writePrivateJsonAtomic(CLIENT_AUTH_PATH, {});
969
- return null;
970
- }
971
- const migrated = { ...session, refresh_token_ref: OS_SECRET_REFERENCE };
972
- delete migrated.refresh_token;
973
- writePrivateJsonAtomic(CLIENT_AUTH_PATH, { [CLIENT_AUTH_STORAGE_KEY]: JSON.stringify(migrated) });
974
- }
975
- return { ...session, refresh_token: refreshToken };
953
+ const session = typeof raw === 'string' ? JSON.parse(raw) : null;
954
+ return session?.access_token ? session : null;
976
955
  } catch {
977
956
  return null;
978
957
  }
979
- }
980
-
981
- function resolveSavedSessionSecret(session) {
982
- if (!session || typeof session !== 'object') return null;
983
- const refreshToken = normalizeString(session.refresh_token, 8192)
984
- || (session.refresh_token_ref === OS_SECRET_REFERENCE ? CLIENT_REFRESH_SECRET_STORE.read() : '');
985
- return session.access_token && refreshToken ? { ...session, refresh_token: refreshToken } : null;
986
- }
987
-
988
- function writePrivateJsonAtomic(path, value) {
989
- mkdirSync(dirname(path), { recursive: true });
990
- const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
991
- try {
992
- writeFileSync(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
993
- try { chmodSync(temporaryPath, 0o600); } catch { /* Windows profile ACLs apply instead. */ }
994
- renameSync(temporaryPath, path);
995
- try { chmodSync(path, 0o600); } catch { /* Windows profile ACLs apply instead. */ }
996
- } catch (error) {
997
- try { rmSync(temporaryPath, { force: true }); } catch { /* best effort cleanup */ }
998
- throw error;
999
- }
1000
- }
1001
-
1002
- function writeSavedSession(session) {
1003
- if (!session?.access_token || !session?.refresh_token) return false;
1004
- if (process.env.LIVEDESK_DESKTOP_HOST === '1') return true;
1005
- if (!CLIENT_REFRESH_SECRET_STORE.write(session.refresh_token)) return false;
1006
- const persisted = { ...session, refresh_token_ref: OS_SECRET_REFERENCE };
1007
- delete persisted.refresh_token;
1008
- writePrivateJsonAtomic(CLIENT_AUTH_PATH, { [CLIENT_AUTH_STORAGE_KEY]: JSON.stringify(persisted) });
1009
- return true;
1010
- }
1011
-
1012
- function clearSavedSession() {
1013
- CLIENT_REFRESH_SECRET_STORE.clear();
1014
- writePrivateJsonAtomic(CLIENT_AUTH_PATH, {});
1015
- }
1016
-
1017
- export async function revokeClientProviderSession(session, options = {}) {
1018
- const accessToken = normalizeString(session?.access_token, 8192);
1019
- if (!accessToken) return { ok: true, skipped: true, reason: 'no-access-token' };
1020
- try {
1021
- if (typeof options.revokeProviderSession === 'function') {
1022
- const result = await options.revokeProviderSession(session);
1023
- return result && typeof result === 'object' ? result : { ok: result !== false };
1024
- }
1025
- const fetchImpl = typeof options.fetchImpl === 'function' ? options.fetchImpl : fetch;
1026
- const response = await fetchImpl(`${SUPABASE_URL.replace(/\/+$/, '')}/auth/v1/logout?scope=local`, {
1027
- method: 'POST',
1028
- headers: {
1029
- apikey: SUPABASE_PUBLISHABLE_KEY,
1030
- Authorization: `Bearer ${accessToken}`,
1031
- Accept: 'application/json'
1032
- },
1033
- signal: AbortSignal.timeout(10_000)
1034
- });
1035
- if (response.ok) return { ok: true, revoked: true };
1036
- if ([401, 403, 404].includes(response.status)) {
1037
- return { ok: true, alreadyInvalid: true, status: response.status };
1038
- }
1039
- return { ok: false, error: `provider-session-revoke-failed:${response.status}` };
1040
- } catch (error) {
1041
- return { ok: false, error: normalizeString(error?.message || error, 240) || 'provider-session-revoke-failed' };
1042
- }
1043
- }
958
+ }
959
+
960
+ function writePrivateJsonAtomic(path, value) {
961
+ mkdirSync(dirname(path), { recursive: true });
962
+ const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
963
+ try {
964
+ writeFileSync(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
965
+ try { chmodSync(temporaryPath, 0o600); } catch { /* Windows profile ACLs apply instead. */ }
966
+ renameSync(temporaryPath, path);
967
+ try { chmodSync(path, 0o600); } catch { /* Windows profile ACLs apply instead. */ }
968
+ } catch (error) {
969
+ try { rmSync(temporaryPath, { force: true }); } catch { /* best effort cleanup */ }
970
+ throw error;
971
+ }
972
+ }
973
+
974
+ function writeSavedSession(session) {
975
+ if (!session?.access_token || !session?.refresh_token) return false;
976
+ if (process.env.LIVEDESK_DESKTOP_HOST === '1') return true;
977
+ writePrivateJsonAtomic(CLIENT_AUTH_PATH, { [CLIENT_AUTH_STORAGE_KEY]: JSON.stringify(session) });
978
+ return true;
979
+ }
980
+
981
+ function clearSavedSession() {
982
+ writePrivateJsonAtomic(CLIENT_AUTH_PATH, {});
983
+ }
1044
984
 
1045
985
  function readBody(req) {
1046
986
  return new Promise((resolveBody, reject) => {
@@ -1077,82 +1017,82 @@ function contentType(pathname) {
1077
1017
  return 'text/html; charset=utf-8';
1078
1018
  }
1079
1019
 
1080
- function isLoopbackRequest(req) {
1081
- const address = String(req.socket?.remoteAddress || '').replace(/^::ffff:/, '');
1082
- return address === '127.0.0.1' || address === '::1' || address === 'localhost' || !address;
1083
- }
1084
-
1085
- function isTrustedApiOrigin(value, trustedWebOrigins) {
1086
- const rawOrigin = normalizeString(value, 300);
1087
- if (rawOrigin === 'null') return true;
1088
- const origin = normalizeOrigin(rawOrigin);
1089
- if (!origin) return false;
1090
- if (trustedWebOrigins.has(origin)) return true;
1091
- try {
1092
- const parsed = new URL(origin);
1093
- // The unified local shell has existed at both an explicit runtime port and
1094
- // the default loopback origin during Hub/Client module transitions. Treat
1095
- // every HTTP loopback origin as local. The API still requires a per-process
1096
- // CSRF token for mutations, and non-browser local processes already share
1097
- // the same machine trust boundary.
1098
- if (parsed.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(parsed.hostname.toLowerCase())) {
1099
- return true;
1100
- }
1101
- return parsed.protocol === 'https:' && LIVE_DESK_PAGES_PREVIEW_HOST.test(parsed.hostname);
1102
- } catch {
1103
- return false;
1104
- }
1105
- }
1106
-
1107
- function isTrustedLocalRequest(req, host, port, options = {}) {
1108
- if (!isLoopbackRequest(req)) return false;
1109
- const requestHost = normalizeString(req.headers.host, 300).split(':')[0].toLowerCase();
1110
- if (requestHost && requestHost !== host.toLowerCase() && requestHost !== 'localhost' && requestHost !== '127.0.0.1') return false;
1111
-
1112
- // Chromium can mark modulepreload/stylesheet requests as cross-site, or
1113
- // send Origin: null when the page was opened from a local shell. Static
1114
- // assets are not privileged, and the local shell still needs to read the
1115
- // loopback runtime API to determine the active role. Mutating API routes
1116
- // remain protected by the CSRF token check below.
1117
- if (options.allowStaticCrossSite === true) {
1118
- return true;
1119
- }
1120
-
1121
- const origin = normalizeString(req.headers.origin, 300);
1122
- if (isTrustedApiOrigin(origin, options.trustedWebOrigins || createTrustedWebOrigins(port))) return true;
1123
- if (origin) return false;
1124
- // Chrome can label a loopback fetch as cross-site while omitting Origin.
1125
- // Permit only explicitly read-only status routes in that shape. The response
1126
- // still uses the loopback ACAO value, and all mutations continue to require
1127
- // both a trusted Origin and the per-process CSRF token.
1128
- if (options.allowReadOnlyCrossSite === true && ['GET', 'HEAD'].includes(req.method || '')) return true;
1129
- const fetchSite = normalizeString(req.headers['sec-fetch-site'], 40).toLowerCase();
1130
- return !fetchSite
1131
- || fetchSite === 'same-origin'
1132
- || fetchSite === 'same-site'
1133
- || fetchSite === 'none';
1134
- }
1135
-
1136
- function resolveCorsOrigin(requestOrigin, port, trustedWebOrigins) {
1137
- const origin = normalizeString(requestOrigin, 300);
1138
- if (origin === 'null') return 'null';
1139
- return origin && isTrustedApiOrigin(origin, trustedWebOrigins)
1140
- ? normalizeOrigin(origin)
1141
- : `http://127.0.0.1:${port}`;
1142
- }
1143
-
1144
- function sendJson(res, status, payload, requestOrigin = '', port = DEFAULT_PORT, trustedWebOrigins = createTrustedWebOrigins(port)) {
1145
- const body = JSON.stringify(payload);
1146
- const allowedOrigin = resolveCorsOrigin(requestOrigin, port, trustedWebOrigins);
1147
- res.writeHead(status, {
1148
- 'Content-Type': 'application/json; charset=utf-8',
1149
- 'Cache-Control': 'no-store',
1150
- 'Access-Control-Allow-Origin': allowedOrigin,
1151
- 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
1152
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-LiveDesk-CSRF',
1153
- 'Access-Control-Allow-Private-Network': 'true',
1154
- Vary: 'Origin'
1155
- });
1020
+ function isLoopbackRequest(req) {
1021
+ const address = String(req.socket?.remoteAddress || '').replace(/^::ffff:/, '');
1022
+ return address === '127.0.0.1' || address === '::1' || address === 'localhost' || !address;
1023
+ }
1024
+
1025
+ function isTrustedApiOrigin(value, trustedWebOrigins) {
1026
+ const rawOrigin = normalizeString(value, 300);
1027
+ if (rawOrigin === 'null') return true;
1028
+ const origin = normalizeOrigin(rawOrigin);
1029
+ if (!origin) return false;
1030
+ if (trustedWebOrigins.has(origin)) return true;
1031
+ try {
1032
+ const parsed = new URL(origin);
1033
+ // The unified local shell has existed at both an explicit runtime port and
1034
+ // the default loopback origin during Hub/Client module transitions. Treat
1035
+ // every HTTP loopback origin as local. The API still requires a per-process
1036
+ // CSRF token for mutations, and non-browser local processes already share
1037
+ // the same machine trust boundary.
1038
+ if (parsed.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(parsed.hostname.toLowerCase())) {
1039
+ return true;
1040
+ }
1041
+ return parsed.protocol === 'https:' && LIVE_DESK_PAGES_PREVIEW_HOST.test(parsed.hostname);
1042
+ } catch {
1043
+ return false;
1044
+ }
1045
+ }
1046
+
1047
+ function isTrustedLocalRequest(req, host, port, options = {}) {
1048
+ if (!isLoopbackRequest(req)) return false;
1049
+ const requestHost = normalizeString(req.headers.host, 300).split(':')[0].toLowerCase();
1050
+ if (requestHost && requestHost !== host.toLowerCase() && requestHost !== 'localhost' && requestHost !== '127.0.0.1') return false;
1051
+
1052
+ // Chromium can mark modulepreload/stylesheet requests as cross-site, or
1053
+ // send Origin: null when the page was opened from a local shell. Static
1054
+ // assets are not privileged, and the local shell still needs to read the
1055
+ // loopback runtime API to determine the active role. Mutating API routes
1056
+ // remain protected by the CSRF token check below.
1057
+ if (options.allowStaticCrossSite === true) {
1058
+ return true;
1059
+ }
1060
+
1061
+ const origin = normalizeString(req.headers.origin, 300);
1062
+ if (isTrustedApiOrigin(origin, options.trustedWebOrigins || createTrustedWebOrigins(port))) return true;
1063
+ if (origin) return false;
1064
+ // Chrome can label a loopback fetch as cross-site while omitting Origin.
1065
+ // Permit only explicitly read-only status routes in that shape. The response
1066
+ // still uses the loopback ACAO value, and all mutations continue to require
1067
+ // both a trusted Origin and the per-process CSRF token.
1068
+ if (options.allowReadOnlyCrossSite === true && ['GET', 'HEAD'].includes(req.method || '')) return true;
1069
+ const fetchSite = normalizeString(req.headers['sec-fetch-site'], 40).toLowerCase();
1070
+ return !fetchSite
1071
+ || fetchSite === 'same-origin'
1072
+ || fetchSite === 'same-site'
1073
+ || fetchSite === 'none';
1074
+ }
1075
+
1076
+ function resolveCorsOrigin(requestOrigin, port, trustedWebOrigins) {
1077
+ const origin = normalizeString(requestOrigin, 300);
1078
+ if (origin === 'null') return 'null';
1079
+ return origin && isTrustedApiOrigin(origin, trustedWebOrigins)
1080
+ ? normalizeOrigin(origin)
1081
+ : `http://127.0.0.1:${port}`;
1082
+ }
1083
+
1084
+ function sendJson(res, status, payload, requestOrigin = '', port = DEFAULT_PORT, trustedWebOrigins = createTrustedWebOrigins(port)) {
1085
+ const body = JSON.stringify(payload);
1086
+ const allowedOrigin = resolveCorsOrigin(requestOrigin, port, trustedWebOrigins);
1087
+ res.writeHead(status, {
1088
+ 'Content-Type': 'application/json; charset=utf-8',
1089
+ 'Cache-Control': 'no-store',
1090
+ 'Access-Control-Allow-Origin': allowedOrigin,
1091
+ 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
1092
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-LiveDesk-CSRF',
1093
+ 'Access-Control-Allow-Private-Network': 'true',
1094
+ Vary: 'Origin'
1095
+ });
1156
1096
  res.end(body);
1157
1097
  }
1158
1098
 
@@ -1161,122 +1101,121 @@ function sendText(res, status, body, type = 'text/plain; charset=utf-8') {
1161
1101
  res.end(body);
1162
1102
  }
1163
1103
 
1164
- export function createClientRuntimeServer(options = {}) {
1165
- const host = normalizeString(options.host, 80) || DEFAULT_HOST;
1166
- const port = normalizePort(options.port, DEFAULT_PORT);
1167
- const initialChoice = options.initialChoice && typeof options.initialChoice === 'object'
1168
- ? options.initialChoice
1169
- : null;
1170
- const initialChoiceMessage = normalizeString(
1171
- options.initialChoiceMessage,
1172
- 1000
1173
- ) || 'Existing LiveDesk credentials accepted. Starting the Client.';
1174
- const trustedWebOrigins = createTrustedWebOrigins(port);
1175
- const webDist = resolve(String(options.webDist || process.env.LIVEDESK_WEB_DIST || '').trim() || join(process.cwd(), 'apps', 'web', 'dist'));
1104
+ export function createClientRuntimeServer(options = {}) {
1105
+ const host = normalizeString(options.host, 80) || DEFAULT_HOST;
1106
+ const port = normalizePort(options.port, DEFAULT_PORT);
1107
+ const initialChoice = options.initialChoice && typeof options.initialChoice === 'object'
1108
+ ? options.initialChoice
1109
+ : null;
1110
+ const initialChoiceMessage = normalizeString(
1111
+ options.initialChoiceMessage,
1112
+ 1000
1113
+ ) || 'Existing LiveDesk credentials accepted. Starting the Client.';
1114
+ const trustedWebOrigins = createTrustedWebOrigins(port);
1115
+ const webDist = resolve(String(options.webDist || process.env.LIVEDESK_WEB_DIST || '').trim() || join(process.cwd(), 'apps', 'web', 'dist'));
1176
1116
  const deviceId = normalizeString(options.deviceId || process.env.LIVEDESK_DEVICE_ID, 160);
1177
- const deviceName = normalizeString(options.deviceName || os.hostname(), 160) || os.hostname();
1178
- const appVersion = normalizeString(options.appVersion || process.env.LIVEDESK_NPM_LAUNCHER_VERSION, 80) || 'dev';
1179
- const providedSavedSession = resolveSavedSessionSecret(options.savedSession);
1180
- const savedSession = options.loadSavedSession === false
1181
- ? null
1182
- : providedSavedSession
1183
- ? providedSavedSession
1184
- : readSavedSession();
1185
- const savedSessionPersisted = Boolean(savedSession?.refresh_token);
1186
- const savedAccountProfile = readClientAccountProfile(savedSession);
1187
- const diagnosticPlatform = normalizeString(options.diagnosticPlatform || process.platform, 20);
1188
- const diagnosticMonotonicNow = typeof options.diagnosticMonotonicNow === 'function'
1189
- ? options.diagnosticMonotonicNow
1190
- : defaultDiagnosticMonotonicNow;
1191
- const diagnosticProbeTtlMs = Math.max(
1192
- CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
1193
- Number(options.diagnosticProbeTtlMs) || CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS
1194
- );
1195
- let previousCpuTotals = readCpuTotals();
1196
- let hardwareSnapshot = {
1197
- gpus: [],
1198
- disks: [],
1199
- collectedAt: '',
1200
- collecting: false,
1201
- lastError: options.diagnosticsEnabled === false ? 'diagnostics-disabled' : 'not-requested'
1202
- };
1203
- let networkSnapshot = { interfaces: activeNetworkInterfaces(), receivedBytes: 0, sentBytes: 0, receiveBytesPerSecond: 0, sendBytesPerSecond: 0, sampledAt: '' };
1204
- let previousNetworkTotals = null;
1205
- let lastDiagnosticRequestAt = '';
1206
- let lastDiagnosticCompletedAt = '';
1207
- let diagnosticRefreshInFlight = null;
1208
- let closed = false;
1209
- let runtimeClosePromise = null;
1210
- const diagnosticCommandOwner = options.diagnosticCommandOwner
1211
- || createClientDiagnosticCommandOwner();
1212
- if (typeof diagnosticCommandOwner.capture !== 'function'
1213
- || typeof diagnosticCommandOwner.close !== 'function'
1214
- || typeof diagnosticCommandOwner.getSnapshot !== 'function') {
1215
- throw new TypeError('invalid-client-diagnostic-command-owner');
1216
- }
1217
- const diagnosticTasks = new Set();
1218
- const trackDiagnosticTask = operation => {
1219
- if (closed) return Promise.resolve();
1220
- const task = Promise.resolve()
1221
- .then(operation)
1222
- .finally(() => diagnosticTasks.delete(task));
1223
- diagnosticTasks.add(task);
1224
- return task;
1225
- };
1226
- const hardwareProbeCache = createClientDiagnosticProbeCache({
1227
- probeId: 'platform-hardware',
1228
- ttlMs: Math.max(CLIENT_DIAGNOSTIC_HARDWARE_TTL_MS, diagnosticProbeTtlMs),
1229
- negativeTtlMs: CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS,
1230
- monotonicNow: diagnosticMonotonicNow,
1231
- fallbackValue: { gpus: [], disks: [] },
1232
- refresh: () => collectPlatformHardwareProbe(diagnosticCommandOwner, diagnosticPlatform)
1233
- });
1234
- const nvidiaProbeCache = createClientDiagnosticProbeCache({
1235
- probeId: 'nvidia-gpu',
1236
- ttlMs: diagnosticProbeTtlMs,
1237
- negativeTtlMs: CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS,
1238
- monotonicNow: diagnosticMonotonicNow,
1239
- fallbackValue: [],
1240
- refresh: () => collectNvidiaGpuProbe(diagnosticCommandOwner, diagnosticPlatform)
1241
- });
1242
- const networkProbeCache = createClientDiagnosticProbeCache({
1243
- probeId: 'network-totals',
1244
- ttlMs: diagnosticProbeTtlMs,
1245
- negativeTtlMs: CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS,
1246
- monotonicNow: diagnosticMonotonicNow,
1247
- fallbackValue: { receivedBytes: 0, sentBytes: 0 },
1248
- refresh: () => readNetworkTotalsProbe(diagnosticCommandOwner, diagnosticPlatform)
1249
- });
1250
- const csrfToken = randomBytes(32).toString('hex');
1251
- const state = {
1252
- route: '/computer',
1253
- manager: '',
1254
- pairToken: '',
1255
- slotNumber: normalizeSlotNumber(options.slot),
1256
- assignedHubId: normalizeString(options.assignedHubId ?? process.env.LIVEDESK_ASSIGNED_HUB_ID, 160),
1257
- endpointCandidates: [],
1258
- roleVersion: normalizeRoleVersion(options.roleVersion ?? process.env.LIVEDESK_ROLE_VERSION),
1117
+ const deviceName = normalizeString(options.deviceName || os.hostname(), 160) || os.hostname();
1118
+ const appVersion = normalizeString(options.appVersion || process.env.LIVEDESK_NPM_LAUNCHER_VERSION, 80) || 'dev';
1119
+ const savedSession = options.loadSavedSession === false
1120
+ ? null
1121
+ : options.savedSession?.refresh_token
1122
+ ? options.savedSession
1123
+ : readSavedSession();
1124
+ const savedSessionPersisted = Boolean(savedSession?.refresh_token);
1125
+ const savedAccountProfile = readClientAccountProfile(savedSession);
1126
+ const diagnosticPlatform = normalizeString(options.diagnosticPlatform || process.platform, 20);
1127
+ const diagnosticMonotonicNow = typeof options.diagnosticMonotonicNow === 'function'
1128
+ ? options.diagnosticMonotonicNow
1129
+ : defaultDiagnosticMonotonicNow;
1130
+ const diagnosticProbeTtlMs = Math.max(
1131
+ CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
1132
+ Number(options.diagnosticProbeTtlMs) || CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS
1133
+ );
1134
+ let previousCpuTotals = readCpuTotals();
1135
+ let hardwareSnapshot = {
1136
+ gpus: [],
1137
+ disks: [],
1138
+ collectedAt: '',
1139
+ collecting: false,
1140
+ lastError: options.diagnosticsEnabled === false ? 'diagnostics-disabled' : 'not-requested'
1141
+ };
1142
+ let networkSnapshot = { interfaces: activeNetworkInterfaces(), receivedBytes: 0, sentBytes: 0, receiveBytesPerSecond: 0, sendBytesPerSecond: 0, sampledAt: '' };
1143
+ let previousNetworkTotals = null;
1144
+ let lastDiagnosticRequestAt = '';
1145
+ let lastDiagnosticCompletedAt = '';
1146
+ let diagnosticRefreshInFlight = null;
1147
+ let closed = false;
1148
+ let runtimeClosePromise = null;
1149
+ const diagnosticCommandOwner = options.diagnosticCommandOwner
1150
+ || createClientDiagnosticCommandOwner();
1151
+ if (typeof diagnosticCommandOwner.capture !== 'function'
1152
+ || typeof diagnosticCommandOwner.close !== 'function'
1153
+ || typeof diagnosticCommandOwner.getSnapshot !== 'function') {
1154
+ throw new TypeError('invalid-client-diagnostic-command-owner');
1155
+ }
1156
+ const diagnosticTasks = new Set();
1157
+ const trackDiagnosticTask = operation => {
1158
+ if (closed) return Promise.resolve();
1159
+ const task = Promise.resolve()
1160
+ .then(operation)
1161
+ .finally(() => diagnosticTasks.delete(task));
1162
+ diagnosticTasks.add(task);
1163
+ return task;
1164
+ };
1165
+ const hardwareProbeCache = createClientDiagnosticProbeCache({
1166
+ probeId: 'platform-hardware',
1167
+ ttlMs: Math.max(CLIENT_DIAGNOSTIC_HARDWARE_TTL_MS, diagnosticProbeTtlMs),
1168
+ negativeTtlMs: CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS,
1169
+ monotonicNow: diagnosticMonotonicNow,
1170
+ fallbackValue: { gpus: [], disks: [] },
1171
+ refresh: () => collectPlatformHardwareProbe(diagnosticCommandOwner, diagnosticPlatform)
1172
+ });
1173
+ const nvidiaProbeCache = createClientDiagnosticProbeCache({
1174
+ probeId: 'nvidia-gpu',
1175
+ ttlMs: diagnosticProbeTtlMs,
1176
+ negativeTtlMs: CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS,
1177
+ monotonicNow: diagnosticMonotonicNow,
1178
+ fallbackValue: [],
1179
+ refresh: () => collectNvidiaGpuProbe(diagnosticCommandOwner, diagnosticPlatform)
1180
+ });
1181
+ const networkProbeCache = createClientDiagnosticProbeCache({
1182
+ probeId: 'network-totals',
1183
+ ttlMs: diagnosticProbeTtlMs,
1184
+ negativeTtlMs: CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS,
1185
+ monotonicNow: diagnosticMonotonicNow,
1186
+ fallbackValue: { receivedBytes: 0, sentBytes: 0 },
1187
+ refresh: () => readNetworkTotalsProbe(diagnosticCommandOwner, diagnosticPlatform)
1188
+ });
1189
+ const csrfToken = randomBytes(32).toString('hex');
1190
+ const state = {
1191
+ route: '/computer',
1192
+ manager: '',
1193
+ pairToken: '',
1194
+ slotNumber: normalizeSlotNumber(options.slot),
1195
+ assignedHubId: normalizeString(options.assignedHubId ?? process.env.LIVEDESK_ASSIGNED_HUB_ID, 160),
1196
+ endpointCandidates: [],
1197
+ roleVersion: normalizeRoleVersion(options.roleVersion ?? process.env.LIVEDESK_ROLE_VERSION),
1259
1198
  connectedAt: '',
1260
1199
  message: 'Sign in with Google or enter a Hub PIN to start this Client.',
1261
- startup: false,
1262
- agent: { requestedEngine: normalizeString(options.engine, 40), state: 'waiting', pid: null, engine: '' },
1263
- videoAcceleration: options.videoAcceleration && typeof options.videoAcceleration === 'object'
1264
- ? { ...options.videoAcceleration, busy: false }
1265
- : null,
1266
- lastError: '',
1267
- auth: {
1268
- lastAttemptAt: '',
1269
- lastResult: '',
1270
- lastError: '',
1271
- persisted: savedSessionPersisted,
1272
- email: savedAccountProfile.email,
1273
- name: savedAccountProfile.name,
1274
- avatarUrl: savedAccountProfile.avatarUrl
1275
- }
1200
+ startup: false,
1201
+ agent: { requestedEngine: normalizeString(options.engine, 40), state: 'waiting', pid: null, engine: '' },
1202
+ videoAcceleration: options.videoAcceleration && typeof options.videoAcceleration === 'object'
1203
+ ? { ...options.videoAcceleration, busy: false }
1204
+ : null,
1205
+ lastError: '',
1206
+ auth: {
1207
+ lastAttemptAt: '',
1208
+ lastResult: '',
1209
+ lastError: '',
1210
+ persisted: savedSessionPersisted,
1211
+ email: savedAccountProfile.email,
1212
+ name: savedAccountProfile.name,
1213
+ avatarUrl: savedAccountProfile.avatarUrl
1214
+ }
1276
1215
  };
1277
- let completed = false;
1278
- let loggedOut = false;
1279
- let lastChoice = null;
1216
+ let completed = false;
1217
+ let loggedOut = false;
1218
+ let lastChoice = null;
1280
1219
  let settleChoice;
1281
1220
  let rejectChoice;
1282
1221
  let server;
@@ -1287,219 +1226,219 @@ export function createClientRuntimeServer(options = {}) {
1287
1226
  const runtime = createRuntimeManager({
1288
1227
  role: 'client',
1289
1228
  state: RuntimeState.WAITING_AUTH,
1290
- authenticated: Boolean(savedSession?.access_token),
1291
- userId: savedSession?.user?.id || '',
1229
+ authenticated: Boolean(savedSession?.access_token),
1230
+ userId: savedSession?.user?.id || '',
1292
1231
  deviceId,
1293
1232
  appVersion,
1294
1233
  startedAt: new Date().toISOString(),
1295
1234
  runtimePid: process.pid
1296
1235
  });
1297
- runtime.emit('runtime.waiting-auth', { role: 'client' });
1298
-
1299
- const requestDiagnosticRefresh = () => {
1300
- if (closed || options.diagnosticsEnabled === false) return Promise.resolve();
1301
- if (diagnosticRefreshInFlight) return diagnosticRefreshInFlight;
1302
- lastDiagnosticRequestAt = new Date().toISOString();
1303
- hardwareSnapshot = { ...hardwareSnapshot, collecting: true };
1304
- diagnosticRefreshInFlight = trackDiagnosticTask(async () => {
1305
- const [platformResult, nvidiaResult, networkResult] = await Promise.all([
1306
- hardwareProbeCache.read(),
1307
- nvidiaProbeCache.read(),
1308
- networkProbeCache.read()
1309
- ]);
1310
- if (closed) return;
1311
-
1312
- const platformSnapshot = platformResult.value && typeof platformResult.value === 'object'
1313
- ? platformResult.value
1314
- : { gpus: [], disks: [] };
1315
- const nvidiaGpus = Array.isArray(nvidiaResult.value) ? nvidiaResult.value : [];
1316
- const errors = [platformResult, nvidiaResult, networkResult]
1317
- .filter(result => result.ok !== true && result.error)
1318
- .map(result => normalizeString(result.error, 160));
1319
- hardwareSnapshot = {
1320
- // Preserve the platform adapter inventory on hybrid-GPU computers. The
1321
- // NVIDIA sampler enriches its matching adapter instead of replacing
1322
- // every non-NVIDIA display adapter with the telemetry subset.
1323
- gpus: mergeGpuSnapshots(platformSnapshot.gpus, nvidiaGpus),
1324
- disks: Array.isArray(platformSnapshot.disks) ? platformSnapshot.disks : [],
1325
- collectedAt: new Date().toISOString(),
1326
- collecting: false,
1327
- lastError: errors.join(', ')
1328
- };
1329
-
1330
- const totals = networkResult.value && typeof networkResult.value === 'object'
1331
- ? networkResult.value
1332
- : { receivedBytes: 0, sentBytes: 0 };
1333
- if (networkResult.cached !== true || !networkSnapshot.sampledAt) {
1334
- const sampledAtMs = Date.now();
1335
- const elapsedSeconds = previousNetworkTotals
1336
- ? Math.max(0.25, (sampledAtMs - previousNetworkTotals.sampledAtMs) / 1000)
1337
- : 0;
1338
- networkSnapshot = {
1339
- interfaces: activeNetworkInterfaces(),
1340
- receivedBytes: totals.receivedBytes,
1341
- sentBytes: totals.sentBytes,
1342
- receiveBytesPerSecond: elapsedSeconds > 0
1343
- ? Math.max(0, (totals.receivedBytes - previousNetworkTotals.receivedBytes) / elapsedSeconds)
1344
- : 0,
1345
- sendBytesPerSecond: elapsedSeconds > 0
1346
- ? Math.max(0, (totals.sentBytes - previousNetworkTotals.sentBytes) / elapsedSeconds)
1347
- : 0,
1348
- sampledAt: new Date(sampledAtMs).toISOString()
1349
- };
1350
- previousNetworkTotals = { ...totals, sampledAtMs };
1351
- }
1352
- lastDiagnosticCompletedAt = new Date().toISOString();
1353
- runtime.emit('client.system.diagnostics', {
1354
- gpuCount: hardwareSnapshot.gpus.length,
1355
- diskCount: hardwareSnapshot.disks.length,
1356
- platformHardwareGeneration: platformResult.generation,
1357
- nvidiaGeneration: nvidiaResult.generation,
1358
- networkGeneration: networkResult.generation
1359
- });
1360
- });
1361
- void diagnosticRefreshInFlight.finally(() => {
1362
- diagnosticRefreshInFlight = null;
1363
- if (!closed && hardwareSnapshot.collecting) {
1364
- hardwareSnapshot = {
1365
- ...hardwareSnapshot,
1366
- collecting: false,
1367
- lastError: hardwareSnapshot.lastError || 'diagnostic-refresh-failed'
1368
- };
1369
- }
1370
- });
1371
- return diagnosticRefreshInFlight;
1372
- };
1373
-
1374
- const recordAuthAttempt = (result, error = '') => {
1375
- const message = normalizeString(error, 240);
1376
- state.auth = { ...state.auth, lastAttemptAt: new Date().toISOString(), lastResult: normalizeString(result, 80), lastError: message };
1377
- state.lastError = message;
1378
- if (message) state.message = `Client authentication needs attention: ${message}`;
1379
- runtime.emit('client.auth.session', { result: state.auth.lastResult, error: message });
1380
- };
1381
-
1382
- const complete = (choice, message = 'Client credentials accepted. Finding the Hub.') => {
1383
- lastChoice = choice || lastChoice;
1384
- if (completed) {
1385
- if (choice?.session?.access_token) {
1386
- const accountProfile = readClientAccountProfile(choice.session);
1387
- runtime.setAuthenticated(true, choice.session.user?.id || '');
1388
- state.auth = {
1389
- ...state.auth,
1390
- lastAttemptAt: new Date().toISOString(),
1391
- lastResult: 'accepted',
1392
- lastError: '',
1393
- persisted: true,
1394
- email: accountProfile.email || state.auth.email,
1395
- name: accountProfile.name || state.auth.name,
1396
- avatarUrl: accountProfile.avatarUrl || state.auth.avatarUrl
1397
- };
1398
- state.lastError = '';
1399
- if (state.message.startsWith('Client authentication needs attention:')) {
1400
- state.message = state.agent.state === 'running'
1401
- ? 'Connected to the LiveDesk Hub.'
1402
- : normalizeString(message, 1000) || 'Client credentials accepted. Finding the Hub.';
1403
- }
1404
- }
1405
- return;
1406
- }
1236
+ runtime.emit('runtime.waiting-auth', { role: 'client' });
1237
+
1238
+ const requestDiagnosticRefresh = () => {
1239
+ if (closed || options.diagnosticsEnabled === false) return Promise.resolve();
1240
+ if (diagnosticRefreshInFlight) return diagnosticRefreshInFlight;
1241
+ lastDiagnosticRequestAt = new Date().toISOString();
1242
+ hardwareSnapshot = { ...hardwareSnapshot, collecting: true };
1243
+ diagnosticRefreshInFlight = trackDiagnosticTask(async () => {
1244
+ const [platformResult, nvidiaResult, networkResult] = await Promise.all([
1245
+ hardwareProbeCache.read(),
1246
+ nvidiaProbeCache.read(),
1247
+ networkProbeCache.read()
1248
+ ]);
1249
+ if (closed) return;
1250
+
1251
+ const platformSnapshot = platformResult.value && typeof platformResult.value === 'object'
1252
+ ? platformResult.value
1253
+ : { gpus: [], disks: [] };
1254
+ const nvidiaGpus = Array.isArray(nvidiaResult.value) ? nvidiaResult.value : [];
1255
+ const errors = [platformResult, nvidiaResult, networkResult]
1256
+ .filter(result => result.ok !== true && result.error)
1257
+ .map(result => normalizeString(result.error, 160));
1258
+ hardwareSnapshot = {
1259
+ // Preserve the platform adapter inventory on hybrid-GPU computers. The
1260
+ // NVIDIA sampler enriches its matching adapter instead of replacing
1261
+ // every non-NVIDIA display adapter with the telemetry subset.
1262
+ gpus: mergeGpuSnapshots(platformSnapshot.gpus, nvidiaGpus),
1263
+ disks: Array.isArray(platformSnapshot.disks) ? platformSnapshot.disks : [],
1264
+ collectedAt: new Date().toISOString(),
1265
+ collecting: false,
1266
+ lastError: errors.join(', ')
1267
+ };
1268
+
1269
+ const totals = networkResult.value && typeof networkResult.value === 'object'
1270
+ ? networkResult.value
1271
+ : { receivedBytes: 0, sentBytes: 0 };
1272
+ if (networkResult.cached !== true || !networkSnapshot.sampledAt) {
1273
+ const sampledAtMs = Date.now();
1274
+ const elapsedSeconds = previousNetworkTotals
1275
+ ? Math.max(0.25, (sampledAtMs - previousNetworkTotals.sampledAtMs) / 1000)
1276
+ : 0;
1277
+ networkSnapshot = {
1278
+ interfaces: activeNetworkInterfaces(),
1279
+ receivedBytes: totals.receivedBytes,
1280
+ sentBytes: totals.sentBytes,
1281
+ receiveBytesPerSecond: elapsedSeconds > 0
1282
+ ? Math.max(0, (totals.receivedBytes - previousNetworkTotals.receivedBytes) / elapsedSeconds)
1283
+ : 0,
1284
+ sendBytesPerSecond: elapsedSeconds > 0
1285
+ ? Math.max(0, (totals.sentBytes - previousNetworkTotals.sentBytes) / elapsedSeconds)
1286
+ : 0,
1287
+ sampledAt: new Date(sampledAtMs).toISOString()
1288
+ };
1289
+ previousNetworkTotals = { ...totals, sampledAtMs };
1290
+ }
1291
+ lastDiagnosticCompletedAt = new Date().toISOString();
1292
+ runtime.emit('client.system.diagnostics', {
1293
+ gpuCount: hardwareSnapshot.gpus.length,
1294
+ diskCount: hardwareSnapshot.disks.length,
1295
+ platformHardwareGeneration: platformResult.generation,
1296
+ nvidiaGeneration: nvidiaResult.generation,
1297
+ networkGeneration: networkResult.generation
1298
+ });
1299
+ });
1300
+ void diagnosticRefreshInFlight.finally(() => {
1301
+ diagnosticRefreshInFlight = null;
1302
+ if (!closed && hardwareSnapshot.collecting) {
1303
+ hardwareSnapshot = {
1304
+ ...hardwareSnapshot,
1305
+ collecting: false,
1306
+ lastError: hardwareSnapshot.lastError || 'diagnostic-refresh-failed'
1307
+ };
1308
+ }
1309
+ });
1310
+ return diagnosticRefreshInFlight;
1311
+ };
1312
+
1313
+ const recordAuthAttempt = (result, error = '') => {
1314
+ const message = normalizeString(error, 240);
1315
+ state.auth = { ...state.auth, lastAttemptAt: new Date().toISOString(), lastResult: normalizeString(result, 80), lastError: message };
1316
+ state.lastError = message;
1317
+ if (message) state.message = `Client authentication needs attention: ${message}`;
1318
+ runtime.emit('client.auth.session', { result: state.auth.lastResult, error: message });
1319
+ };
1320
+
1321
+ const complete = (choice, message = 'Client credentials accepted. Finding the Hub.') => {
1322
+ lastChoice = choice || lastChoice;
1323
+ if (completed) {
1324
+ if (choice?.session?.access_token) {
1325
+ const accountProfile = readClientAccountProfile(choice.session);
1326
+ runtime.setAuthenticated(true, choice.session.user?.id || '');
1327
+ state.auth = {
1328
+ ...state.auth,
1329
+ lastAttemptAt: new Date().toISOString(),
1330
+ lastResult: 'accepted',
1331
+ lastError: '',
1332
+ persisted: true,
1333
+ email: accountProfile.email || state.auth.email,
1334
+ name: accountProfile.name || state.auth.name,
1335
+ avatarUrl: accountProfile.avatarUrl || state.auth.avatarUrl
1336
+ };
1337
+ state.lastError = '';
1338
+ if (state.message.startsWith('Client authentication needs attention:')) {
1339
+ state.message = state.agent.state === 'running'
1340
+ ? 'Connected to the LiveDesk Hub.'
1341
+ : normalizeString(message, 1000) || 'Client credentials accepted. Finding the Hub.';
1342
+ }
1343
+ }
1344
+ return;
1345
+ }
1407
1346
  completed = true;
1408
- loggedOut = false;
1409
- state.connectedAt = new Date().toISOString();
1410
- state.manager = normalizeString(choice?.manager, 256);
1411
- if (Object.prototype.hasOwnProperty.call(choice || {}, 'hubDeviceId')) {
1412
- state.assignedHubId = normalizeString(choice?.hubDeviceId, 160);
1413
- }
1414
- state.endpointCandidates = Array.isArray(choice?.endpointCandidates)
1415
- ? choice.endpointCandidates.map(value => normalizeString(value, 256)).filter(Boolean)
1416
- : [];
1417
- state.message = message;
1418
- state.lastError = '';
1419
- const accountProfile = readClientAccountProfile(choice?.session);
1420
- state.auth = {
1421
- ...state.auth,
1422
- lastAttemptAt: state.connectedAt,
1423
- lastResult: 'accepted',
1424
- lastError: '',
1425
- persisted: Boolean(choice?.session?.refresh_token) || state.auth.persisted,
1426
- email: accountProfile.email || state.auth.email,
1427
- name: accountProfile.name || state.auth.name,
1428
- avatarUrl: accountProfile.avatarUrl || state.auth.avatarUrl
1429
- };
1347
+ loggedOut = false;
1348
+ state.connectedAt = new Date().toISOString();
1349
+ state.manager = normalizeString(choice?.manager, 256);
1350
+ if (Object.prototype.hasOwnProperty.call(choice || {}, 'hubDeviceId')) {
1351
+ state.assignedHubId = normalizeString(choice?.hubDeviceId, 160);
1352
+ }
1353
+ state.endpointCandidates = Array.isArray(choice?.endpointCandidates)
1354
+ ? choice.endpointCandidates.map(value => normalizeString(value, 256)).filter(Boolean)
1355
+ : [];
1356
+ state.message = message;
1357
+ state.lastError = '';
1358
+ const accountProfile = readClientAccountProfile(choice?.session);
1359
+ state.auth = {
1360
+ ...state.auth,
1361
+ lastAttemptAt: state.connectedAt,
1362
+ lastResult: 'accepted',
1363
+ lastError: '',
1364
+ persisted: Boolean(choice?.session?.refresh_token) || state.auth.persisted,
1365
+ email: accountProfile.email || state.auth.email,
1366
+ name: accountProfile.name || state.auth.name,
1367
+ avatarUrl: accountProfile.avatarUrl || state.auth.avatarUrl
1368
+ };
1430
1369
  runtime.setAuthenticated(Boolean(choice?.session?.access_token), choice?.session?.user?.id || '');
1431
1370
  runtime.update({ state: RuntimeState.RESOLVING_ROLE }, 'credentials-accepted');
1432
1371
  settleChoice?.(choice);
1433
1372
  settleChoice = null;
1434
1373
  };
1435
1374
 
1436
- const getState = () => {
1437
- const snapshot = runtime.getSnapshot();
1438
- const currentCpuTotals = readCpuTotals();
1439
- const cpuTotalDelta = Math.max(0, currentCpuTotals.total - previousCpuTotals.total);
1440
- const cpuIdleDelta = Math.max(0, currentCpuTotals.idle - previousCpuTotals.idle);
1441
- const cpuUsagePercent = cpuTotalDelta > 0 ? Math.max(0, Math.min(100, (1 - cpuIdleDelta / cpuTotalDelta) * 100)) : 0;
1442
- previousCpuTotals = currentCpuTotals;
1443
- const totalMemoryBytes = os.totalmem();
1444
- const freeMemoryBytes = os.freemem();
1445
- return {
1375
+ const getState = () => {
1376
+ const snapshot = runtime.getSnapshot();
1377
+ const currentCpuTotals = readCpuTotals();
1378
+ const cpuTotalDelta = Math.max(0, currentCpuTotals.total - previousCpuTotals.total);
1379
+ const cpuIdleDelta = Math.max(0, currentCpuTotals.idle - previousCpuTotals.idle);
1380
+ const cpuUsagePercent = cpuTotalDelta > 0 ? Math.max(0, Math.min(100, (1 - cpuIdleDelta / cpuTotalDelta) * 100)) : 0;
1381
+ previousCpuTotals = currentCpuTotals;
1382
+ const totalMemoryBytes = os.totalmem();
1383
+ const freeMemoryBytes = os.freemem();
1384
+ return {
1446
1385
  ok: true,
1447
1386
  role: 'client',
1448
1387
  state: snapshot.state,
1449
1388
  deviceId,
1450
1389
  deviceName,
1451
1390
  runtimeStarted: completed,
1452
- authenticated: snapshot.authenticated,
1453
- userId: snapshot.userId || '',
1454
- userEmail: state.auth.email || '',
1455
- userName: state.auth.name || '',
1456
- userAvatarUrl: state.auth.avatarUrl || '',
1457
- appVersion,
1458
- runtimeId: `client-${snapshot.runtimePid}-${snapshot.startedAt}`,
1459
- runtimePid: snapshot.runtimePid,
1391
+ authenticated: snapshot.authenticated,
1392
+ userId: snapshot.userId || '',
1393
+ userEmail: state.auth.email || '',
1394
+ userName: state.auth.name || '',
1395
+ userAvatarUrl: state.auth.avatarUrl || '',
1396
+ appVersion,
1397
+ runtimeId: `client-${snapshot.runtimePid}-${snapshot.startedAt}`,
1398
+ runtimePid: snapshot.runtimePid,
1460
1399
  startedAt: snapshot.startedAt,
1461
- route: state.route,
1462
- manager: state.manager,
1463
- slotNumber: state.slotNumber,
1464
- assignedHubId: state.assignedHubId,
1465
- endpointCandidates: state.endpointCandidates,
1400
+ route: state.route,
1401
+ manager: state.manager,
1402
+ slotNumber: state.slotNumber,
1403
+ assignedHubId: state.assignedHubId,
1404
+ endpointCandidates: state.endpointCandidates,
1466
1405
  roleVersion: state.roleVersion,
1467
1406
  connectedAt: state.connectedAt,
1468
- connectionState: state.agent.state === 'running' ? 'connected' : completed ? 'connecting' : 'waiting-auth',
1469
- needsAuth: !snapshot.authenticated,
1470
- statusLabel: loggedOut ? 'Signed out' : state.agent.state === 'running' ? 'Agent running' : completed ? 'Finding Hub' : 'Waiting for auth',
1471
- message: state.message,
1472
- lastError: state.lastError,
1473
- auth: { ...state.auth },
1474
- startup: state.startup,
1475
- agent: { ...state.agent, pid: runtime.getSnapshot().agentPid },
1476
- videoAcceleration: state.videoAcceleration ? { ...state.videoAcceleration } : null,
1477
- system: {
1478
- platform: process.platform,
1479
- arch: process.arch,
1480
- osVersion: os.release(),
1481
- pid: process.pid,
1482
- cpuUsagePercent: Math.round(cpuUsagePercent * 10) / 10,
1483
- cpuLogicalCores: Math.max(1, os.cpus().length),
1484
- memoryTotalBytes: totalMemoryBytes,
1485
- memoryUsedBytes: Math.max(0, totalMemoryBytes - freeMemoryBytes),
1486
- uptimeSeconds: Math.max(0, Math.floor(os.uptime())),
1487
- gpus: hardwareSnapshot.gpus,
1488
- disks: hardwareSnapshot.disks,
1489
- network: networkSnapshot,
1490
- hardwareCollectedAt: hardwareSnapshot.collectedAt,
1491
- hardwareCollecting: hardwareSnapshot.collecting,
1492
- hardwareLastError: hardwareSnapshot.lastError || '',
1493
- diagnosticProbes: {
1494
- requestDriven: true,
1495
- minimumTtlMs: CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
1496
- requestedAt: lastDiagnosticRequestAt,
1497
- completedAt: lastDiagnosticCompletedAt,
1498
- platformHardware: hardwareProbeCache.getSnapshot(),
1499
- nvidiaGpu: nvidiaProbeCache.getSnapshot(),
1500
- networkTotals: networkProbeCache.getSnapshot()
1501
- }
1502
- },
1407
+ connectionState: state.agent.state === 'running' ? 'connected' : completed ? 'connecting' : 'waiting-auth',
1408
+ needsAuth: !snapshot.authenticated,
1409
+ statusLabel: loggedOut ? 'Signed out' : state.agent.state === 'running' ? 'Agent running' : completed ? 'Finding Hub' : 'Waiting for auth',
1410
+ message: state.message,
1411
+ lastError: state.lastError,
1412
+ auth: { ...state.auth },
1413
+ startup: state.startup,
1414
+ agent: { ...state.agent, pid: runtime.getSnapshot().agentPid },
1415
+ videoAcceleration: state.videoAcceleration ? { ...state.videoAcceleration } : null,
1416
+ system: {
1417
+ platform: process.platform,
1418
+ arch: process.arch,
1419
+ osVersion: os.release(),
1420
+ pid: process.pid,
1421
+ cpuUsagePercent: Math.round(cpuUsagePercent * 10) / 10,
1422
+ cpuLogicalCores: Math.max(1, os.cpus().length),
1423
+ memoryTotalBytes: totalMemoryBytes,
1424
+ memoryUsedBytes: Math.max(0, totalMemoryBytes - freeMemoryBytes),
1425
+ uptimeSeconds: Math.max(0, Math.floor(os.uptime())),
1426
+ gpus: hardwareSnapshot.gpus,
1427
+ disks: hardwareSnapshot.disks,
1428
+ network: networkSnapshot,
1429
+ hardwareCollectedAt: hardwareSnapshot.collectedAt,
1430
+ hardwareCollecting: hardwareSnapshot.collecting,
1431
+ hardwareLastError: hardwareSnapshot.lastError || '',
1432
+ diagnosticProbes: {
1433
+ requestDriven: true,
1434
+ minimumTtlMs: CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
1435
+ requestedAt: lastDiagnosticRequestAt,
1436
+ completedAt: lastDiagnosticCompletedAt,
1437
+ platformHardware: hardwareProbeCache.getSnapshot(),
1438
+ nvidiaGpu: nvidiaProbeCache.getSnapshot(),
1439
+ networkTotals: networkProbeCache.getSnapshot()
1440
+ }
1441
+ },
1503
1442
  permissions: {
1504
1443
  screenCapture: state.agent.state === 'running',
1505
1444
  remoteInput: state.agent.state === 'running',
@@ -1528,385 +1467,366 @@ export function createClientRuntimeServer(options = {}) {
1528
1467
  }
1529
1468
  };
1530
1469
 
1531
- const handleRequest = async (req, res) => {
1532
- const requestUrl = new URL(req.url || '/', `http://${host}:${port}`);
1533
- const pathname = requestUrl.pathname;
1534
- const requestOrigin = normalizeString(req.headers.origin, 300);
1535
- const respondJson = (status, payload) => sendJson(res, status, payload, requestOrigin, port, trustedWebOrigins);
1536
- const isStaticAppRequest = !pathname.startsWith('/api/');
1537
- const allowReadOnlyCrossSite = READ_ONLY_CLIENT_API_PATHS.has(pathname);
1538
- if (!isTrustedLocalRequest(req, host, port, { allowStaticCrossSite: isStaticAppRequest, allowReadOnlyCrossSite, trustedWebOrigins })) {
1539
- runtime.emit('client.http.rejected', {
1540
- method: normalizeString(req.method, 12),
1541
- pathname: normalizeString(pathname, 160),
1542
- reason: 'untrusted-local-origin',
1543
- origin: requestOrigin,
1544
- fetchSite: normalizeString(req.headers['sec-fetch-site'], 40)
1545
- });
1546
- if (pathname === '/api/auth/session') recordAuthAttempt('rejected', 'untrusted-local-origin');
1547
- respondJson(403, { ok: false, error: 'untrusted-local-origin' });
1548
- return;
1549
- }
1550
- if (req.method === 'OPTIONS') {
1551
- res.writeHead(204, {
1552
- 'Access-Control-Allow-Origin': resolveCorsOrigin(requestOrigin, port, trustedWebOrigins),
1553
- 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
1554
- 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-LiveDesk-CSRF',
1555
- 'Access-Control-Allow-Private-Network': 'true',
1556
- Vary: 'Origin'
1557
- });
1558
- res.end();
1559
- return;
1470
+ const handleRequest = async (req, res) => {
1471
+ const requestUrl = new URL(req.url || '/', `http://${host}:${port}`);
1472
+ const pathname = requestUrl.pathname;
1473
+ const requestOrigin = normalizeString(req.headers.origin, 300);
1474
+ const respondJson = (status, payload) => sendJson(res, status, payload, requestOrigin, port, trustedWebOrigins);
1475
+ const isStaticAppRequest = !pathname.startsWith('/api/');
1476
+ const allowReadOnlyCrossSite = READ_ONLY_CLIENT_API_PATHS.has(pathname);
1477
+ if (!isTrustedLocalRequest(req, host, port, { allowStaticCrossSite: isStaticAppRequest, allowReadOnlyCrossSite, trustedWebOrigins })) {
1478
+ runtime.emit('client.http.rejected', {
1479
+ method: normalizeString(req.method, 12),
1480
+ pathname: normalizeString(pathname, 160),
1481
+ reason: 'untrusted-local-origin',
1482
+ origin: requestOrigin,
1483
+ fetchSite: normalizeString(req.headers['sec-fetch-site'], 40)
1484
+ });
1485
+ if (pathname === '/api/auth/session') recordAuthAttempt('rejected', 'untrusted-local-origin');
1486
+ respondJson(403, { ok: false, error: 'untrusted-local-origin' });
1487
+ return;
1560
1488
  }
1561
- if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method || '') && req.headers['x-livedesk-csrf'] !== csrfToken) {
1562
- if (pathname === '/api/auth/session') recordAuthAttempt('rejected', 'csrf-token-required');
1563
- respondJson(403, { ok: false, error: 'csrf-token-required' });
1489
+ if (req.method === 'OPTIONS') {
1490
+ res.writeHead(204, {
1491
+ 'Access-Control-Allow-Origin': resolveCorsOrigin(requestOrigin, port, trustedWebOrigins),
1492
+ 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
1493
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-LiveDesk-CSRF',
1494
+ 'Access-Control-Allow-Private-Network': 'true',
1495
+ Vary: 'Origin'
1496
+ });
1497
+ res.end();
1498
+ return;
1499
+ }
1500
+ if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method || '') && req.headers['x-livedesk-csrf'] !== csrfToken) {
1501
+ if (pathname === '/api/auth/session') recordAuthAttempt('rejected', 'csrf-token-required');
1502
+ respondJson(403, { ok: false, error: 'csrf-token-required' });
1564
1503
  return;
1565
1504
  }
1566
1505
  if (pathname === '/api/health') {
1567
- respondJson(200, { ok: true, product: 'LiveDesk', role: 'client', timestamp: new Date().toISOString() });
1506
+ respondJson(200, { ok: true, product: 'LiveDesk', role: 'client', timestamp: new Date().toISOString() });
1507
+ return;
1508
+ }
1509
+ if (pathname === '/api/client/diagnostics') {
1510
+ await requestDiagnosticRefresh();
1511
+ respondJson(200, getState());
1512
+ return;
1513
+ }
1514
+ if (pathname === '/api/runtime' || pathname === '/api/runtime/status' || pathname === '/api/client/status' || pathname === '/api/client/computer' || pathname === '/api/client/system' || pathname === '/api/client/permissions') {
1515
+ respondJson(200, getState());
1516
+ return;
1517
+ }
1518
+ if (pathname === '/api/runtime/events') {
1519
+ respondJson(200, { ok: true, events: runtime.getEvents() });
1520
+ return;
1521
+ }
1522
+ if (pathname === '/api/auth/status') {
1523
+ const snapshot = runtime.getSnapshot();
1524
+ respondJson(200, {
1525
+ ok: true,
1526
+ authenticated: snapshot.authenticated,
1527
+ userId: snapshot.userId || null,
1528
+ userEmail: state.auth.email || null,
1529
+ userName: state.auth.name || null,
1530
+ userAvatarUrl: state.auth.avatarUrl || null,
1531
+ role: 'client'
1532
+ });
1533
+ return;
1534
+ }
1535
+ if (pathname === '/auth/google' && req.method === 'GET') {
1536
+ try {
1537
+ if (runtime.getSnapshot().authenticated) {
1538
+ res.writeHead(303, { Location: '/', 'Cache-Control': 'no-store' });
1539
+ res.end();
1540
+ return;
1541
+ }
1542
+ if (typeof options.beginGoogleSignIn !== 'function') {
1543
+ throw new Error('client-google-sign-in-unavailable');
1544
+ }
1545
+ recordAuthAttempt('oauth-starting');
1546
+ const redirectTo = `http://${host}:${port}/callback`;
1547
+ const result = await options.beginGoogleSignIn(redirectTo);
1548
+ const authorizationUrl = normalizeString(result?.url || result, 4000);
1549
+ if (!authorizationUrl) throw new Error(result?.error || 'google-authorization-url-missing');
1550
+ recordAuthAttempt('oauth-waiting');
1551
+ res.writeHead(302, {
1552
+ Location: authorizationUrl,
1553
+ 'Cache-Control': 'no-store',
1554
+ 'Referrer-Policy': 'no-referrer'
1555
+ });
1556
+ res.end();
1557
+ } catch (error) {
1558
+ const message = normalizeString(error?.message || error, 240);
1559
+ recordAuthAttempt('rejected', message);
1560
+ res.writeHead(303, { Location: `/?auth_error=${encodeURIComponent(message)}`, 'Cache-Control': 'no-store' });
1561
+ res.end();
1562
+ }
1568
1563
  return;
1569
1564
  }
1570
- if (pathname === '/api/client/diagnostics') {
1571
- await requestDiagnosticRefresh();
1572
- respondJson(200, getState());
1573
- return;
1574
- }
1575
- if (pathname === '/api/runtime' || pathname === '/api/runtime/status' || pathname === '/api/client/status' || pathname === '/api/client/computer' || pathname === '/api/client/system' || pathname === '/api/client/permissions') {
1576
- respondJson(200, getState());
1577
- return;
1578
- }
1579
- if (pathname === '/api/runtime/events') {
1580
- respondJson(200, { ok: true, events: runtime.getEvents() });
1565
+ if (pathname === '/callback' && req.method === 'GET') {
1566
+ const providerError = normalizeString(
1567
+ requestUrl.searchParams.get('error_description') || requestUrl.searchParams.get('error'),
1568
+ 240
1569
+ );
1570
+ const code = normalizeString(requestUrl.searchParams.get('code'), 2000);
1571
+ try {
1572
+ if (providerError) throw new Error(providerError);
1573
+ if (!code) throw new Error('google-auth-code-missing');
1574
+ if (typeof options.exchangeGoogleCode !== 'function') {
1575
+ throw new Error('client-google-callback-unavailable');
1576
+ }
1577
+ recordAuthAttempt('oauth-exchanging');
1578
+ const exchanged = await options.exchangeGoogleCode(code);
1579
+ const sessionCandidate = exchanged?.session || exchanged;
1580
+ const normalized = normalizeRuntimeAuthSession(sessionCandidate, { requireRefreshToken: true });
1581
+ if (!normalized.ok) throw new Error(normalized.error);
1582
+ const session = {
1583
+ ...sessionCandidate,
1584
+ ...mergeClientAccountProfile(normalized.session, sessionCandidate?.user)
1585
+ };
1586
+ if (!writeSavedSession(session)) throw new Error('refresh-token-required');
1587
+ state.auth.persisted = true;
1588
+ complete({ type: 'google', session }, 'Signed in. Finding the LiveDesk Hub.');
1589
+ res.writeHead(303, { Location: '/', 'Cache-Control': 'no-store' });
1590
+ res.end();
1591
+ } catch (error) {
1592
+ const message = normalizeString(error?.message || error, 240);
1593
+ recordAuthAttempt('rejected', message);
1594
+ res.writeHead(303, { Location: `/?auth_error=${encodeURIComponent(message)}`, 'Cache-Control': 'no-store' });
1595
+ res.end();
1596
+ }
1581
1597
  return;
1582
1598
  }
1583
- if (pathname === '/api/auth/status') {
1584
- const snapshot = runtime.getSnapshot();
1585
- respondJson(200, {
1586
- ok: true,
1587
- authenticated: snapshot.authenticated,
1588
- userId: snapshot.userId || null,
1589
- userEmail: state.auth.email || null,
1590
- userName: state.auth.name || null,
1591
- userAvatarUrl: state.auth.avatarUrl || null,
1592
- role: 'client'
1593
- });
1594
- return;
1595
- }
1596
- if (pathname === '/auth/google' && req.method === 'GET') {
1597
- try {
1598
- if (runtime.getSnapshot().authenticated) {
1599
- res.writeHead(303, { Location: '/', 'Cache-Control': 'no-store' });
1600
- res.end();
1601
- return;
1602
- }
1603
- if (typeof options.beginGoogleSignIn !== 'function') {
1604
- throw new Error('client-google-sign-in-unavailable');
1605
- }
1606
- recordAuthAttempt('oauth-starting');
1607
- const redirectTo = `http://${host}:${port}/callback`;
1608
- const result = await options.beginGoogleSignIn(redirectTo);
1609
- const authorizationUrl = normalizeString(result?.url || result, 4000);
1610
- if (!authorizationUrl) throw new Error(result?.error || 'google-authorization-url-missing');
1611
- recordAuthAttempt('oauth-waiting');
1612
- res.writeHead(302, {
1613
- Location: authorizationUrl,
1614
- 'Cache-Control': 'no-store',
1615
- 'Referrer-Policy': 'no-referrer'
1616
- });
1617
- res.end();
1618
- } catch (error) {
1619
- const message = normalizeString(error?.message || error, 240);
1620
- recordAuthAttempt('rejected', message);
1621
- res.writeHead(303, { Location: `/?auth_error=${encodeURIComponent(message)}`, 'Cache-Control': 'no-store' });
1622
- res.end();
1623
- }
1624
- return;
1625
- }
1626
- if (pathname === '/callback' && req.method === 'GET') {
1627
- const providerError = normalizeString(
1628
- requestUrl.searchParams.get('error_description') || requestUrl.searchParams.get('error'),
1629
- 240
1630
- );
1631
- const code = normalizeString(requestUrl.searchParams.get('code'), 2000);
1632
- try {
1633
- if (providerError) throw new Error(providerError);
1634
- if (!code) throw new Error('google-auth-code-missing');
1635
- if (typeof options.exchangeGoogleCode !== 'function') {
1636
- throw new Error('client-google-callback-unavailable');
1637
- }
1638
- recordAuthAttempt('oauth-exchanging');
1639
- const exchanged = await options.exchangeGoogleCode(code);
1640
- const sessionCandidate = exchanged?.session || exchanged;
1641
- const normalized = normalizeRuntimeAuthSession(sessionCandidate, { requireRefreshToken: true });
1642
- if (!normalized.ok) throw new Error(normalized.error);
1643
- const session = {
1644
- ...sessionCandidate,
1645
- ...mergeClientAccountProfile(normalized.session, sessionCandidate?.user)
1646
- };
1647
- if (!writeSavedSession(session)) throw new Error('refresh-token-required');
1648
- state.auth.persisted = true;
1649
- complete({ type: 'google', session }, 'Signed in. Finding the LiveDesk Hub.');
1650
- res.writeHead(303, { Location: '/', 'Cache-Control': 'no-store' });
1651
- res.end();
1652
- } catch (error) {
1653
- const message = normalizeString(error?.message || error, 240);
1654
- recordAuthAttempt('rejected', message);
1655
- res.writeHead(303, { Location: `/?auth_error=${encodeURIComponent(message)}`, 'Cache-Control': 'no-store' });
1656
- res.end();
1657
- }
1658
- return;
1659
- }
1660
- if (pathname === '/api/auth/session' && req.method === 'POST') {
1661
- const body = parseJsonBody(await readBody(req));
1662
- const normalized = normalizeRuntimeAuthSession(body, { requireRefreshToken: true });
1663
- if (!normalized.ok) {
1664
- recordAuthAttempt('rejected', normalized.error);
1665
- respondJson(400, { ok: false, error: normalized.error });
1666
- return;
1667
- }
1668
- const accessToken = normalized.session.access_token;
1669
- recordAuthAttempt('verifying');
1670
- const verifyResponse = await fetch(`${SUPABASE_URL.replace(/\/+$/, '')}/auth/v1/user`, {
1599
+ if (pathname === '/api/auth/session' && req.method === 'POST') {
1600
+ const body = parseJsonBody(await readBody(req));
1601
+ const normalized = normalizeRuntimeAuthSession(body, { requireRefreshToken: true });
1602
+ if (!normalized.ok) {
1603
+ recordAuthAttempt('rejected', normalized.error);
1604
+ respondJson(400, { ok: false, error: normalized.error });
1605
+ return;
1606
+ }
1607
+ const accessToken = normalized.session.access_token;
1608
+ recordAuthAttempt('verifying');
1609
+ const verifyResponse = await fetch(`${SUPABASE_URL.replace(/\/+$/, '')}/auth/v1/user`, {
1671
1610
  headers: { apikey: SUPABASE_PUBLISHABLE_KEY, Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }
1672
1611
  });
1673
- if (!verifyResponse.ok) {
1674
- recordAuthAttempt('rejected', `supabase-user-verification-failed:${verifyResponse.status}`);
1675
- respondJson(401, { ok: false, error: `supabase-user-verification-failed:${verifyResponse.status}` });
1612
+ if (!verifyResponse.ok) {
1613
+ recordAuthAttempt('rejected', `supabase-user-verification-failed:${verifyResponse.status}`);
1614
+ respondJson(401, { ok: false, error: `supabase-user-verification-failed:${verifyResponse.status}` });
1615
+ return;
1616
+ }
1617
+ const verifiedUser = await verifyResponse.json().catch(() => ({}));
1618
+ const verifiedSession = {
1619
+ ...normalized.session,
1620
+ user: {
1621
+ id: normalizeString(verifiedUser?.id || body.user?.id || body.userId, 160),
1622
+ email: normalizeString(verifiedUser?.email || body.user?.email || body.email, 320)
1623
+ }
1624
+ };
1625
+ const session = mergeClientAccountProfile(verifiedSession, verifiedUser);
1626
+ try {
1627
+ if (!writeSavedSession(session)) throw new Error('refresh-token-required');
1628
+ state.auth.persisted = true;
1629
+ } catch (error) {
1630
+ const message = `session-persistence-failed:${normalizeString(error?.message || error, 160)}`;
1631
+ recordAuthAttempt('rejected', message);
1632
+ respondJson(500, { ok: false, error: message });
1676
1633
  return;
1677
1634
  }
1678
- const verifiedUser = await verifyResponse.json().catch(() => ({}));
1679
- const verifiedSession = {
1680
- ...normalized.session,
1681
- user: {
1682
- id: normalizeString(verifiedUser?.id || body.user?.id || body.userId, 160),
1683
- email: normalizeString(verifiedUser?.email || body.user?.email || body.email, 320)
1684
- }
1685
- };
1686
- const session = mergeClientAccountProfile(verifiedSession, verifiedUser);
1687
- try {
1688
- if (!writeSavedSession(session)) throw new Error('refresh-token-required');
1689
- state.auth.persisted = true;
1690
- } catch (error) {
1691
- const message = `session-persistence-failed:${normalizeString(error?.message || error, 160)}`;
1692
- recordAuthAttempt('rejected', message);
1693
- respondJson(500, { ok: false, error: message });
1694
- return;
1695
- }
1696
- complete({ type: 'google', session }, 'Signed in. Finding the LiveDesk Hub.');
1697
- respondJson(200, { ok: true, authenticated: true, persisted: true, role: 'client' });
1635
+ complete({ type: 'google', session }, 'Signed in. Finding the LiveDesk Hub.');
1636
+ respondJson(200, { ok: true, authenticated: true, persisted: true, role: 'client' });
1637
+ return;
1638
+ }
1639
+ if (pathname === '/api/auth/session' && req.method === 'DELETE') {
1640
+ clearSavedSession();
1641
+ loggedOut = true;
1642
+ completed = false;
1643
+ lastChoice = null;
1644
+ state.manager = '';
1645
+ state.message = 'Saved sign-in was cleared. Sign in again to start the Client.';
1646
+ state.lastError = '';
1647
+ state.auth = {
1648
+ lastAttemptAt: new Date().toISOString(),
1649
+ lastResult: 'cleared',
1650
+ lastError: '',
1651
+ persisted: false,
1652
+ email: '',
1653
+ name: '',
1654
+ avatarUrl: ''
1655
+ };
1656
+ runtime.setAuthenticated(false);
1657
+ respondJson(200, { ok: true, authenticated: false, role: 'client' });
1698
1658
  return;
1699
- }
1700
- if (pathname === '/api/auth/session' && req.method === 'DELETE') {
1701
- const activeSession = lastChoice?.session || savedSession || readSavedSession();
1702
- const providerLogout = await revokeClientProviderSession(activeSession, {
1703
- revokeProviderSession: options.revokeProviderSession,
1704
- fetchImpl: options.fetchImpl
1705
- });
1706
- clearSavedSession();
1707
- loggedOut = true;
1708
- completed = false;
1709
- lastChoice = null;
1710
- state.manager = '';
1711
- state.message = 'Saved sign-in was cleared. Sign in again to start the Client.';
1712
- state.lastError = '';
1713
- state.auth = {
1714
- lastAttemptAt: new Date().toISOString(),
1715
- lastResult: 'cleared',
1716
- lastError: '',
1717
- persisted: false,
1718
- email: '',
1719
- name: '',
1720
- avatarUrl: ''
1721
- };
1722
- runtime.setAuthenticated(false);
1723
- respondJson(providerLogout.ok ? 200 : 502, {
1724
- ok: providerLogout.ok,
1725
- authenticated: false,
1726
- localCleared: true,
1727
- role: 'client',
1728
- providerLogout
1729
- });
1730
- return;
1731
1659
  }
1732
1660
  if (pathname === '/api/runtime/restart' && req.method === 'POST') {
1733
1661
  runtime.emit('runtime.restart.requested', { role: 'client' });
1734
- respondJson(200, { ok: true, restarting: true, role: 'client' });
1662
+ respondJson(200, { ok: true, restarting: true, role: 'client' });
1735
1663
  setTimeout(() => options.onRestart?.(), 150);
1736
1664
  return;
1737
1665
  }
1738
- if (pathname === '/api/runtime/shutdown' && req.method === 'POST') {
1739
- runtime.emit('runtime.shutdown.requested', { role: 'client' });
1740
- respondJson(200, { ok: true, shuttingDown: true, role: 'client' });
1741
- setTimeout(() => options.onShutdown?.(), 150);
1742
- return;
1743
- }
1744
- if (pathname === '/api/client/slot' && req.method === 'POST') {
1745
- const body = parseJsonBody(await readBody(req));
1746
- const slotNumber = normalizeSlotNumber(body.slotNumber ?? body.slot);
1747
- if (!slotNumber) {
1748
- respondJson(400, { ok: false, error: 'invalid-slot-number', slotNumber: state.slotNumber });
1749
- return;
1750
- }
1751
- if (!state.manager || !state.pairToken || state.agent.state !== 'running') {
1752
- respondJson(409, { ok: false, error: 'hub-not-connected', slotNumber: state.slotNumber });
1753
- return;
1754
- }
1755
- if (typeof options.assignSlot !== 'function') {
1756
- respondJson(409, { ok: false, error: 'slot-assignment-unavailable', slotNumber: state.slotNumber });
1757
- return;
1758
- }
1759
- runtime.emit('client.slot.assignment-requested', {
1760
- deviceId,
1761
- previousSlotNumber: state.slotNumber,
1762
- slotNumber
1763
- });
1764
- try {
1765
- const result = await options.assignSlot({
1766
- manager: state.manager,
1767
- pairToken: state.pairToken,
1768
- deviceId,
1769
- slotNumber
1770
- });
1771
- if (result?.ok !== true) {
1772
- const error = normalizeString(result?.error, 120) || 'slot-assignment-failed';
1773
- runtime.emit('client.slot.assignment-rejected', {
1774
- deviceId,
1775
- slotNumber,
1776
- error,
1777
- conflictDeviceId: normalizeString(result?.conflict?.deviceId, 160)
1778
- });
1779
- respondJson(error === 'slot-taken' ? 409 : 502, {
1780
- ...(result && typeof result === 'object' ? result : {}),
1781
- ok: false,
1782
- error,
1783
- slotNumber: state.slotNumber
1784
- });
1785
- return;
1786
- }
1787
- state.slotNumber = slotNumber;
1788
- state.message = `Slot ${String(slotNumber).padStart(3, '0')} saved. Reconnecting this Client.`;
1789
- runtime.emit('client.slot.assigned', { deviceId, slotNumber });
1790
- respondJson(200, { ...getState(), ...result, ok: true, slotNumber });
1791
- } catch (error) {
1792
- const message = normalizeString(error?.message || error, 240) || 'slot-assignment-failed';
1793
- runtime.emit('client.slot.assignment-failed', { deviceId, slotNumber, error: message });
1794
- respondJson(502, { ok: false, error: message, slotNumber: state.slotNumber });
1795
- }
1796
- return;
1797
- }
1798
- if (pathname === '/api/client/video-acceleration/install' && req.method === 'POST') {
1799
- if (process.platform !== 'linux') {
1800
- respondJson(409, { ok: false, error: 'linux-video-acceleration-only' });
1801
- return;
1802
- }
1803
- if (state.videoAcceleration?.busy) {
1804
- respondJson(409, { ok: false, error: 'video-acceleration-install-in-progress', ...getState() });
1805
- return;
1806
- }
1807
- if (typeof options.installVideoAcceleration !== 'function') {
1808
- respondJson(409, { ok: false, error: 'video-acceleration-install-unavailable' });
1809
- return;
1810
- }
1811
- state.videoAcceleration = {
1812
- ...(state.videoAcceleration || {}),
1813
- supported: true,
1814
- state: 'installing',
1815
- busy: true,
1816
- ready: false,
1817
- error: '',
1818
- message: 'Installing Linux hardware video support…'
1819
- };
1820
- runtime.emit('client.video-acceleration.installing', {
1821
- packageManager: normalizeString(state.videoAcceleration.packageManager?.id, 40)
1822
- });
1823
- try {
1824
- const result = await options.installVideoAcceleration();
1825
- state.videoAcceleration = {
1826
- ...(result && typeof result === 'object' ? result : {}),
1827
- busy: false
1828
- };
1829
- const ok = state.videoAcceleration.ready === true;
1830
- runtime.emit(ok ? 'client.video-acceleration.ready' : 'client.video-acceleration.failed', {
1831
- encoder: normalizeString(state.videoAcceleration.encoder, 80),
1832
- driver: normalizeString(state.videoAcceleration.driver, 80),
1833
- error: normalizeString(state.videoAcceleration.error, 240)
1834
- });
1835
- respondJson(ok ? 200 : 409, { ...getState(), ok });
1836
- if (ok && state.videoAcceleration.restartAgent) {
1837
- setTimeout(() => options.onVideoAccelerationReady?.(state.videoAcceleration), 150);
1838
- }
1839
- } catch (error) {
1840
- state.videoAcceleration = {
1841
- ...(state.videoAcceleration || {}),
1842
- state: 'error',
1843
- busy: false,
1844
- ready: false,
1845
- error: normalizeString(error?.message || error, 320)
1846
- };
1847
- runtime.emit('client.video-acceleration.failed', { error: state.videoAcceleration.error });
1848
- respondJson(409, { ok: false, ...getState(), error: state.videoAcceleration.error });
1849
- }
1850
- return;
1851
- }
1852
- if (pathname === '/api/runtime/switch-role' && req.method === 'POST') {
1666
+ if (pathname === '/api/runtime/shutdown' && req.method === 'POST') {
1667
+ runtime.emit('runtime.shutdown.requested', { role: 'client' });
1668
+ respondJson(200, { ok: true, shuttingDown: true, role: 'client' });
1669
+ setTimeout(() => options.onShutdown?.(), 150);
1670
+ return;
1671
+ }
1672
+ if (pathname === '/api/client/slot' && req.method === 'POST') {
1673
+ const body = parseJsonBody(await readBody(req));
1674
+ const slotNumber = normalizeSlotNumber(body.slotNumber ?? body.slot);
1675
+ if (!slotNumber) {
1676
+ respondJson(400, { ok: false, error: 'invalid-slot-number', slotNumber: state.slotNumber });
1677
+ return;
1678
+ }
1679
+ if (!state.manager || !state.pairToken || state.agent.state !== 'running') {
1680
+ respondJson(409, { ok: false, error: 'hub-not-connected', slotNumber: state.slotNumber });
1681
+ return;
1682
+ }
1683
+ if (typeof options.assignSlot !== 'function') {
1684
+ respondJson(409, { ok: false, error: 'slot-assignment-unavailable', slotNumber: state.slotNumber });
1685
+ return;
1686
+ }
1687
+ runtime.emit('client.slot.assignment-requested', {
1688
+ deviceId,
1689
+ previousSlotNumber: state.slotNumber,
1690
+ slotNumber
1691
+ });
1692
+ try {
1693
+ const result = await options.assignSlot({
1694
+ manager: state.manager,
1695
+ pairToken: state.pairToken,
1696
+ deviceId,
1697
+ slotNumber
1698
+ });
1699
+ if (result?.ok !== true) {
1700
+ const error = normalizeString(result?.error, 120) || 'slot-assignment-failed';
1701
+ runtime.emit('client.slot.assignment-rejected', {
1702
+ deviceId,
1703
+ slotNumber,
1704
+ error,
1705
+ conflictDeviceId: normalizeString(result?.conflict?.deviceId, 160)
1706
+ });
1707
+ respondJson(error === 'slot-taken' ? 409 : 502, {
1708
+ ...(result && typeof result === 'object' ? result : {}),
1709
+ ok: false,
1710
+ error,
1711
+ slotNumber: state.slotNumber
1712
+ });
1713
+ return;
1714
+ }
1715
+ state.slotNumber = slotNumber;
1716
+ state.message = `Slot ${String(slotNumber).padStart(3, '0')} saved. Reconnecting this Client.`;
1717
+ runtime.emit('client.slot.assigned', { deviceId, slotNumber });
1718
+ respondJson(200, { ...getState(), ...result, ok: true, slotNumber });
1719
+ } catch (error) {
1720
+ const message = normalizeString(error?.message || error, 240) || 'slot-assignment-failed';
1721
+ runtime.emit('client.slot.assignment-failed', { deviceId, slotNumber, error: message });
1722
+ respondJson(502, { ok: false, error: message, slotNumber: state.slotNumber });
1723
+ }
1724
+ return;
1725
+ }
1726
+ if (pathname === '/api/client/video-acceleration/install' && req.method === 'POST') {
1727
+ if (process.platform !== 'linux') {
1728
+ respondJson(409, { ok: false, error: 'linux-video-acceleration-only' });
1729
+ return;
1730
+ }
1731
+ if (state.videoAcceleration?.busy) {
1732
+ respondJson(409, { ok: false, error: 'video-acceleration-install-in-progress', ...getState() });
1733
+ return;
1734
+ }
1735
+ if (typeof options.installVideoAcceleration !== 'function') {
1736
+ respondJson(409, { ok: false, error: 'video-acceleration-install-unavailable' });
1737
+ return;
1738
+ }
1739
+ state.videoAcceleration = {
1740
+ ...(state.videoAcceleration || {}),
1741
+ supported: true,
1742
+ state: 'installing',
1743
+ busy: true,
1744
+ ready: false,
1745
+ error: '',
1746
+ message: 'Installing Linux hardware video support…'
1747
+ };
1748
+ runtime.emit('client.video-acceleration.installing', {
1749
+ packageManager: normalizeString(state.videoAcceleration.packageManager?.id, 40)
1750
+ });
1751
+ try {
1752
+ const result = await options.installVideoAcceleration();
1753
+ state.videoAcceleration = {
1754
+ ...(result && typeof result === 'object' ? result : {}),
1755
+ busy: false
1756
+ };
1757
+ const ok = state.videoAcceleration.ready === true;
1758
+ runtime.emit(ok ? 'client.video-acceleration.ready' : 'client.video-acceleration.failed', {
1759
+ encoder: normalizeString(state.videoAcceleration.encoder, 80),
1760
+ driver: normalizeString(state.videoAcceleration.driver, 80),
1761
+ error: normalizeString(state.videoAcceleration.error, 240)
1762
+ });
1763
+ respondJson(ok ? 200 : 409, { ...getState(), ok });
1764
+ if (ok && state.videoAcceleration.restartAgent) {
1765
+ setTimeout(() => options.onVideoAccelerationReady?.(state.videoAcceleration), 150);
1766
+ }
1767
+ } catch (error) {
1768
+ state.videoAcceleration = {
1769
+ ...(state.videoAcceleration || {}),
1770
+ state: 'error',
1771
+ busy: false,
1772
+ ready: false,
1773
+ error: normalizeString(error?.message || error, 320)
1774
+ };
1775
+ runtime.emit('client.video-acceleration.failed', { error: state.videoAcceleration.error });
1776
+ respondJson(409, { ok: false, ...getState(), error: state.videoAcceleration.error });
1777
+ }
1778
+ return;
1779
+ }
1780
+ if (pathname === '/api/runtime/switch-role' && req.method === 'POST') {
1853
1781
  const body = parseJsonBody(await readBody(req));
1854
1782
  if (String(body.role || '').trim().toLowerCase() !== 'hub') {
1855
- respondJson(400, { ok: false, error: 'client-can-only-transition-to-hub' });
1783
+ respondJson(400, { ok: false, error: 'client-can-only-transition-to-hub' });
1856
1784
  return;
1857
1785
  }
1858
1786
  try {
1859
- const result = await options.changeRole?.(
1860
- 'hub',
1861
- runtime.getSnapshot(),
1862
- lastChoice?.session || savedSession || null
1863
- );
1864
- respondJson(result?.ok === false ? 409 : 200, result || { ok: false, error: 'role-change-unavailable' });
1787
+ const result = await options.changeRole?.('hub', runtime.getSnapshot());
1788
+ respondJson(result?.ok === false ? 409 : 200, result || { ok: false, error: 'role-change-unavailable' });
1865
1789
  } catch (error) {
1866
- respondJson(409, { ok: false, error: normalizeString(error?.message || error) });
1790
+ respondJson(409, { ok: false, error: normalizeString(error?.message || error) });
1867
1791
  }
1868
1792
  return;
1869
1793
  }
1870
1794
  if (pathname === '/api/client/role' && req.method === 'POST') {
1871
1795
  const body = parseJsonBody(await readBody(req));
1872
1796
  if (String(body.role || '').trim().toLowerCase() !== 'hub') {
1873
- respondJson(400, { ok: false, error: 'client-can-only-transition-to-hub' });
1797
+ respondJson(400, { ok: false, error: 'client-can-only-transition-to-hub' });
1874
1798
  return;
1875
1799
  }
1876
1800
  try {
1877
- const result = await options.changeRole?.(
1878
- 'hub',
1879
- runtime.getSnapshot(),
1880
- lastChoice?.session || savedSession || null
1881
- );
1882
- respondJson(result?.ok === false ? 409 : 200, result || { ok: false, error: 'role-change-unavailable' });
1801
+ const result = await options.changeRole?.('hub', runtime.getSnapshot());
1802
+ respondJson(result?.ok === false ? 409 : 200, result || { ok: false, error: 'role-change-unavailable' });
1883
1803
  } catch (error) {
1884
- respondJson(409, { ok: false, error: normalizeString(error?.message || error) });
1804
+ respondJson(409, { ok: false, error: normalizeString(error?.message || error) });
1885
1805
  }
1886
1806
  return;
1887
- }
1888
- if (pathname === '/api/client/reconnect' && req.method === 'POST') {
1889
- const body = parseJsonBody(await readBody(req));
1890
- if (body.onlyIfWaiting === true && state.agent.state === 'running') {
1891
- respondJson(200, { ok: true, skipped: true, reason: 'already-connected', ...getState() });
1892
- return;
1893
- }
1894
- const previousManager = state.manager;
1895
- state.manager = '';
1896
- state.agent.state = 'reconnecting';
1897
- state.lastError = '';
1898
- state.message = 'Refreshing the LiveDesk Hub discovery now.';
1899
- runtime.update({ state: RuntimeState.RESOLVING_ROLE }, 'client-reconnect-requested');
1900
- runtime.emit('client.reconnect.requested', { deviceId, previousManager: previousManager || '' });
1901
- await options.onReconnect?.();
1902
- respondJson(200, { ok: true, ...getState() });
1807
+ }
1808
+ if (pathname === '/api/client/reconnect' && req.method === 'POST') {
1809
+ const body = parseJsonBody(await readBody(req));
1810
+ if (body.onlyIfWaiting === true && state.agent.state === 'running') {
1811
+ respondJson(200, { ok: true, skipped: true, reason: 'already-connected', ...getState() });
1812
+ return;
1813
+ }
1814
+ const previousManager = state.manager;
1815
+ state.manager = '';
1816
+ state.agent.state = 'reconnecting';
1817
+ state.lastError = '';
1818
+ state.message = 'Refreshing the LiveDesk Hub discovery now.';
1819
+ runtime.update({ state: RuntimeState.RESOLVING_ROLE }, 'client-reconnect-requested');
1820
+ runtime.emit('client.reconnect.requested', { deviceId, previousManager: previousManager || '' });
1821
+ await options.onReconnect?.();
1822
+ respondJson(200, { ok: true, ...getState() });
1903
1823
  return;
1904
1824
  }
1905
1825
  if (pathname === '/api/client/disconnect' && req.method === 'POST') {
1906
1826
  await options.onDisconnect?.();
1907
1827
  state.manager = '';
1908
1828
  state.agent.state = 'waiting';
1909
- respondJson(200, { ok: true, ...getState() });
1829
+ respondJson(200, { ok: true, ...getState() });
1910
1830
  return;
1911
1831
  }
1912
1832
  if (pathname === '/api/client/pin' && req.method === 'POST') {
@@ -1917,181 +1837,181 @@ export function createClientRuntimeServer(options = {}) {
1917
1837
  if (!resolved) throw new Error('pin-not-resolved');
1918
1838
  state.message = 'PIN accepted. Starting the Client.';
1919
1839
  complete({ type: 'pin', ...resolved }, state.message);
1920
- respondJson(200, { ok: true, ...getState() });
1840
+ respondJson(200, { ok: true, ...getState() });
1921
1841
  } catch (error) {
1922
- respondJson(409, { ok: false, error: normalizeString(error?.message || error) });
1842
+ respondJson(409, { ok: false, error: normalizeString(error?.message || error) });
1923
1843
  }
1924
1844
  return;
1925
1845
  }
1926
1846
  if (pathname.startsWith('/api/')) {
1927
- respondJson(409, runtimeRoleError('client', 'client'));
1847
+ respondJson(409, runtimeRoleError('client', 'client'));
1928
1848
  return;
1929
1849
  }
1930
1850
  serveApp(req, res, pathname);
1931
1851
  };
1932
1852
 
1933
- const start = () => new Promise((resolveStart, rejectStart) => {
1934
- if (process.env.LIVEDESK_DESKTOP_HOST === '1') clearSavedSession();
1935
- let settled = false;
1936
- server = createServer((req, res) => {
1853
+ const start = () => new Promise((resolveStart, rejectStart) => {
1854
+ if (process.env.LIVEDESK_DESKTOP_HOST === '1') clearSavedSession();
1855
+ let settled = false;
1856
+ server = createServer((req, res) => {
1937
1857
  void handleRequest(req, res).catch(error => {
1938
- if (!res.headersSent) sendJson(res, 500, { ok: false, error: normalizeString(error?.message || error) }, req.headers.origin, port, trustedWebOrigins);
1858
+ if (!res.headersSent) sendJson(res, 500, { ok: false, error: normalizeString(error?.message || error) }, req.headers.origin, port, trustedWebOrigins);
1939
1859
  else res.end();
1940
- });
1941
- });
1942
- const bindTimer = setTimeout(() => {
1943
- if (settled) return;
1944
- settled = true;
1945
- try { server.close(); } catch { /* bind never completed */ }
1946
- rejectStart(new Error(`client-runtime-bind-timeout:${host}:${port}`));
1947
- }, CLIENT_RUNTIME_BIND_TIMEOUT_MS);
1948
- bindTimer.unref?.();
1949
- server.once('error', error => {
1950
- if (settled) return;
1951
- settled = true;
1952
- clearTimeout(bindTimer);
1953
- rejectStart(error);
1954
- });
1955
- server.listen(port, host, () => {
1956
- if (settled) {
1957
- try { server.close(); } catch { /* timed-out bind already owns cleanup */ }
1958
- return;
1959
- }
1960
- settled = true;
1961
- clearTimeout(bindTimer);
1962
- runtime.emit('runtime.http.started', { host, port });
1963
- if (initialChoice) {
1964
- setImmediate(() => {
1965
- complete(initialChoice, initialChoiceMessage);
1966
- });
1967
- } else {
1968
- const saved = normalizeRuntimeAuthSession(savedSession, { requireRefreshToken: true });
1969
- if (saved.ok) {
1970
- setImmediate(() => {
1971
- void (async () => {
1972
- try {
1973
- // Older persisted sessions kept only id/email on `user`, while
1974
- // Supabase still carries Google name/avatar claims in the
1975
- // access-token payload. Recover those display-only claims
1976
- // locally before any best-effort network hydration.
1977
- const normalizedSession = mergeClientAccountProfile(saved.session, savedSession);
1978
- const hydratedSession = await hydrateClientAccountProfile(normalizedSession);
1979
- if (!writeSavedSession(hydratedSession)) throw new Error('refresh-token-required');
1980
- state.auth.persisted = true;
1981
- complete({ type: 'google', session: hydratedSession }, 'Saved sign-in found. Finding the LiveDesk Hub.');
1982
- } catch (error) {
1983
- recordAuthAttempt('rejected', `session-persistence-failed:${normalizeString(error?.message || error, 160)}`);
1984
- }
1985
- })();
1986
- });
1987
- }
1988
- }
1989
- resolveStart();
1990
- });
1991
- });
1992
-
1993
- const closeHttpServer = timeoutMs => new Promise(resolveClose => {
1994
- if (!server) {
1995
- resolveClose({ ok: true, listening: false });
1996
- return;
1997
- }
1998
- let settled = false;
1999
- const finish = ok => {
2000
- if (settled) return;
2001
- settled = true;
2002
- clearTimeout(timer);
2003
- resolveClose({ ok, listening: Boolean(server?.listening) });
2004
- };
2005
- const timer = setTimeout(() => {
2006
- try { server.closeAllConnections?.(); } catch { /* exact HTTP owner already closed */ }
2007
- finish(!server.listening);
2008
- }, Math.max(100, Number(timeoutMs) || CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS));
2009
- try {
2010
- server.close(error => finish(!error));
2011
- server.closeIdleConnections?.();
2012
- } catch {
2013
- finish(true);
2014
- }
2015
- });
2016
-
2017
- const closeRuntime = async () => {
2018
- const closeStartedAt = Date.now();
2019
- closed = true;
2020
- runtime.emit('runtime.http.stopping', { port });
2021
-
2022
- const taskOwners = [...diagnosticTasks];
2023
- const [diagnosticClose, httpClose] = await Promise.all([
2024
- diagnosticCommandOwner.close(CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS),
2025
- closeHttpServer(CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS)
2026
- ]);
2027
- if (taskOwners.length > 0) {
2028
- const remainingCloseMs = Math.max(
2029
- 0,
2030
- CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS - (Date.now() - closeStartedAt)
2031
- );
2032
- if (remainingCloseMs > 0) {
2033
- await Promise.race([
2034
- Promise.allSettled(taskOwners),
2035
- new Promise(resolve => setTimeout(resolve, remainingCloseMs))
2036
- ]);
2037
- }
2038
- }
2039
- const finalDiagnostics = diagnosticCommandOwner.getSnapshot();
2040
- const result = {
2041
- ok: diagnosticClose.ok === true
2042
- && httpClose.ok === true
2043
- && diagnosticTasks.size === 0
2044
- && finalDiagnostics.activeCount === 0
2045
- && finalDiagnostics.redirectedStreamCount === 0,
2046
- diagnostics: {
2047
- ...diagnosticClose,
2048
- taskCount: diagnosticTasks.size
2049
- },
2050
- http: httpClose
2051
- };
2052
- runtime.emit(
2053
- result.ok ? 'client.diagnostics.closed' : 'client.diagnostics.close-timeout',
2054
- {
2055
- ownerId: finalDiagnostics.ownerId,
2056
- activeCount: finalDiagnostics.activeCount,
2057
- redirectedStreamCount: finalDiagnostics.redirectedStreamCount,
2058
- taskCount: diagnosticTasks.size
2059
- }
2060
- );
2061
- return result;
2062
- };
2063
-
2064
- return {
1860
+ });
1861
+ });
1862
+ const bindTimer = setTimeout(() => {
1863
+ if (settled) return;
1864
+ settled = true;
1865
+ try { server.close(); } catch { /* bind never completed */ }
1866
+ rejectStart(new Error(`client-runtime-bind-timeout:${host}:${port}`));
1867
+ }, CLIENT_RUNTIME_BIND_TIMEOUT_MS);
1868
+ bindTimer.unref?.();
1869
+ server.once('error', error => {
1870
+ if (settled) return;
1871
+ settled = true;
1872
+ clearTimeout(bindTimer);
1873
+ rejectStart(error);
1874
+ });
1875
+ server.listen(port, host, () => {
1876
+ if (settled) {
1877
+ try { server.close(); } catch { /* timed-out bind already owns cleanup */ }
1878
+ return;
1879
+ }
1880
+ settled = true;
1881
+ clearTimeout(bindTimer);
1882
+ runtime.emit('runtime.http.started', { host, port });
1883
+ if (initialChoice) {
1884
+ setImmediate(() => {
1885
+ complete(initialChoice, initialChoiceMessage);
1886
+ });
1887
+ } else {
1888
+ const saved = normalizeRuntimeAuthSession(savedSession, { requireRefreshToken: true });
1889
+ if (saved.ok) {
1890
+ setImmediate(() => {
1891
+ void (async () => {
1892
+ try {
1893
+ // Older persisted sessions kept only id/email on `user`, while
1894
+ // Supabase still carries Google name/avatar claims in the
1895
+ // access-token payload. Recover those display-only claims
1896
+ // locally before any best-effort network hydration.
1897
+ const normalizedSession = mergeClientAccountProfile(saved.session, savedSession);
1898
+ const hydratedSession = await hydrateClientAccountProfile(normalizedSession);
1899
+ if (!writeSavedSession(hydratedSession)) throw new Error('refresh-token-required');
1900
+ state.auth.persisted = true;
1901
+ complete({ type: 'google', session: hydratedSession }, 'Saved sign-in found. Finding the LiveDesk Hub.');
1902
+ } catch (error) {
1903
+ recordAuthAttempt('rejected', `session-persistence-failed:${normalizeString(error?.message || error, 160)}`);
1904
+ }
1905
+ })();
1906
+ });
1907
+ }
1908
+ }
1909
+ resolveStart();
1910
+ });
1911
+ });
1912
+
1913
+ const closeHttpServer = timeoutMs => new Promise(resolveClose => {
1914
+ if (!server) {
1915
+ resolveClose({ ok: true, listening: false });
1916
+ return;
1917
+ }
1918
+ let settled = false;
1919
+ const finish = ok => {
1920
+ if (settled) return;
1921
+ settled = true;
1922
+ clearTimeout(timer);
1923
+ resolveClose({ ok, listening: Boolean(server?.listening) });
1924
+ };
1925
+ const timer = setTimeout(() => {
1926
+ try { server.closeAllConnections?.(); } catch { /* exact HTTP owner already closed */ }
1927
+ finish(!server.listening);
1928
+ }, Math.max(100, Number(timeoutMs) || CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS));
1929
+ try {
1930
+ server.close(error => finish(!error));
1931
+ server.closeIdleConnections?.();
1932
+ } catch {
1933
+ finish(true);
1934
+ }
1935
+ });
1936
+
1937
+ const closeRuntime = async () => {
1938
+ const closeStartedAt = Date.now();
1939
+ closed = true;
1940
+ runtime.emit('runtime.http.stopping', { port });
1941
+
1942
+ const taskOwners = [...diagnosticTasks];
1943
+ const [diagnosticClose, httpClose] = await Promise.all([
1944
+ diagnosticCommandOwner.close(CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS),
1945
+ closeHttpServer(CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS)
1946
+ ]);
1947
+ if (taskOwners.length > 0) {
1948
+ const remainingCloseMs = Math.max(
1949
+ 0,
1950
+ CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS - (Date.now() - closeStartedAt)
1951
+ );
1952
+ if (remainingCloseMs > 0) {
1953
+ await Promise.race([
1954
+ Promise.allSettled(taskOwners),
1955
+ new Promise(resolve => setTimeout(resolve, remainingCloseMs))
1956
+ ]);
1957
+ }
1958
+ }
1959
+ const finalDiagnostics = diagnosticCommandOwner.getSnapshot();
1960
+ const result = {
1961
+ ok: diagnosticClose.ok === true
1962
+ && httpClose.ok === true
1963
+ && diagnosticTasks.size === 0
1964
+ && finalDiagnostics.activeCount === 0
1965
+ && finalDiagnostics.redirectedStreamCount === 0,
1966
+ diagnostics: {
1967
+ ...diagnosticClose,
1968
+ taskCount: diagnosticTasks.size
1969
+ },
1970
+ http: httpClose
1971
+ };
1972
+ runtime.emit(
1973
+ result.ok ? 'client.diagnostics.closed' : 'client.diagnostics.close-timeout',
1974
+ {
1975
+ ownerId: finalDiagnostics.ownerId,
1976
+ activeCount: finalDiagnostics.activeCount,
1977
+ redirectedStreamCount: finalDiagnostics.redirectedStreamCount,
1978
+ taskCount: diagnosticTasks.size
1979
+ }
1980
+ );
1981
+ return result;
1982
+ };
1983
+
1984
+ return {
2065
1985
  runtime,
2066
- waitForChoice,
2067
- start,
2068
- get url() { return `http://${host}:${port}/`; },
2069
- getLastChoice() { return lastChoice; },
2070
- acceptChoice(choice, message) {
2071
- complete(choice, message);
2072
- return lastChoice;
2073
- },
2074
- update(patch = {}) {
1986
+ waitForChoice,
1987
+ start,
1988
+ get url() { return `http://${host}:${port}/`; },
1989
+ getLastChoice() { return lastChoice; },
1990
+ acceptChoice(choice, message) {
1991
+ complete(choice, message);
1992
+ return lastChoice;
1993
+ },
1994
+ update(patch = {}) {
2075
1995
  Object.assign(state, patch);
2076
1996
  if (patch.agent && typeof patch.agent === 'object') {
2077
1997
  state.agent = { ...state.agent, ...patch.agent };
2078
1998
  runtime.setAgentPid(Number.isInteger(patch.agent.pid) ? patch.agent.pid : runtime.getSnapshot().agentPid);
2079
1999
  if (patch.agent.state === 'running') runtime.update({ state: RuntimeState.RUNNING }, 'agent-running');
2080
2000
  }
2081
- if (patch.manager) state.manager = normalizeString(patch.manager, 256);
2082
- if (patch.pairToken) state.pairToken = normalizeString(patch.pairToken, 256);
2083
- if (normalizeSlotNumber(patch.slotNumber)) state.slotNumber = normalizeSlotNumber(patch.slotNumber);
2084
- if (Object.prototype.hasOwnProperty.call(patch, 'assignedHubId')) {
2085
- state.assignedHubId = normalizeString(patch.assignedHubId, 160);
2086
- }
2001
+ if (patch.manager) state.manager = normalizeString(patch.manager, 256);
2002
+ if (patch.pairToken) state.pairToken = normalizeString(patch.pairToken, 256);
2003
+ if (normalizeSlotNumber(patch.slotNumber)) state.slotNumber = normalizeSlotNumber(patch.slotNumber);
2004
+ if (Object.prototype.hasOwnProperty.call(patch, 'assignedHubId')) {
2005
+ state.assignedHubId = normalizeString(patch.assignedHubId, 160);
2006
+ }
2087
2007
  if (patch.message) state.message = normalizeString(patch.message, 1000);
2088
2008
  if (Number.isInteger(patch.roleVersion)) state.roleVersion = patch.roleVersion;
2089
2009
  if (Array.isArray(patch.endpointCandidates)) state.endpointCandidates = patch.endpointCandidates;
2090
2010
  },
2091
- close() {
2092
- if (!runtimeClosePromise) runtimeClosePromise = closeRuntime();
2093
- return runtimeClosePromise;
2094
- },
2011
+ close() {
2012
+ if (!runtimeClosePromise) runtimeClosePromise = closeRuntime();
2013
+ return runtimeClosePromise;
2014
+ },
2095
2015
  getRoleRestartRequest() { return options.getRoleRestartRequest?.() || null; }
2096
2016
  };
2097
2017
  }