@livedesk/client 0.1.233 → 0.1.235
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/livedesk-client.js +8 -2
- package/package.json +5 -5
- package/src/runtime/client-runtime-server.js +1920 -1906
|
@@ -1,1046 +1,1046 @@
|
|
|
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
|
+
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 {
|
|
958
958
|
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 };
|
|
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 };
|
|
976
976
|
} catch {
|
|
977
977
|
return null;
|
|
978
978
|
}
|
|
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
|
-
}
|
|
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
|
+
}
|
|
1044
1044
|
|
|
1045
1045
|
function readBody(req) {
|
|
1046
1046
|
return new Promise((resolveBody, reject) => {
|
|
@@ -1077,82 +1077,82 @@ function contentType(pathname) {
|
|
|
1077
1077
|
return 'text/html; charset=utf-8';
|
|
1078
1078
|
}
|
|
1079
1079
|
|
|
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
|
-
});
|
|
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
|
+
});
|
|
1156
1156
|
res.end(body);
|
|
1157
1157
|
}
|
|
1158
1158
|
|
|
@@ -1161,122 +1161,125 @@ function sendText(res, status, body, type = 'text/plain; charset=utf-8') {
|
|
|
1161
1161
|
res.end(body);
|
|
1162
1162
|
}
|
|
1163
1163
|
|
|
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'));
|
|
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'));
|
|
1176
1176
|
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
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
const
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
let
|
|
1207
|
-
let
|
|
1208
|
-
let
|
|
1209
|
-
let
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
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 hydrateAccountProfile = typeof options.hydrateAccountProfile === 'function'
|
|
1188
|
+
? options.hydrateAccountProfile
|
|
1189
|
+
: hydrateClientAccountProfile;
|
|
1190
|
+
const diagnosticPlatform = normalizeString(options.diagnosticPlatform || process.platform, 20);
|
|
1191
|
+
const diagnosticMonotonicNow = typeof options.diagnosticMonotonicNow === 'function'
|
|
1192
|
+
? options.diagnosticMonotonicNow
|
|
1193
|
+
: defaultDiagnosticMonotonicNow;
|
|
1194
|
+
const diagnosticProbeTtlMs = Math.max(
|
|
1195
|
+
CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
|
|
1196
|
+
Number(options.diagnosticProbeTtlMs) || CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS
|
|
1197
|
+
);
|
|
1198
|
+
let previousCpuTotals = readCpuTotals();
|
|
1199
|
+
let hardwareSnapshot = {
|
|
1200
|
+
gpus: [],
|
|
1201
|
+
disks: [],
|
|
1202
|
+
collectedAt: '',
|
|
1203
|
+
collecting: false,
|
|
1204
|
+
lastError: options.diagnosticsEnabled === false ? 'diagnostics-disabled' : 'not-requested'
|
|
1205
|
+
};
|
|
1206
|
+
let networkSnapshot = { interfaces: activeNetworkInterfaces(), receivedBytes: 0, sentBytes: 0, receiveBytesPerSecond: 0, sendBytesPerSecond: 0, sampledAt: '' };
|
|
1207
|
+
let previousNetworkTotals = null;
|
|
1208
|
+
let lastDiagnosticRequestAt = '';
|
|
1209
|
+
let lastDiagnosticCompletedAt = '';
|
|
1210
|
+
let diagnosticRefreshInFlight = null;
|
|
1211
|
+
let closed = false;
|
|
1212
|
+
let runtimeClosePromise = null;
|
|
1213
|
+
const diagnosticCommandOwner = options.diagnosticCommandOwner
|
|
1214
|
+
|| createClientDiagnosticCommandOwner();
|
|
1215
|
+
if (typeof diagnosticCommandOwner.capture !== 'function'
|
|
1216
|
+
|| typeof diagnosticCommandOwner.close !== 'function'
|
|
1217
|
+
|| typeof diagnosticCommandOwner.getSnapshot !== 'function') {
|
|
1218
|
+
throw new TypeError('invalid-client-diagnostic-command-owner');
|
|
1219
|
+
}
|
|
1220
|
+
const diagnosticTasks = new Set();
|
|
1221
|
+
const trackDiagnosticTask = operation => {
|
|
1222
|
+
if (closed) return Promise.resolve();
|
|
1223
|
+
const task = Promise.resolve()
|
|
1224
|
+
.then(operation)
|
|
1225
|
+
.finally(() => diagnosticTasks.delete(task));
|
|
1226
|
+
diagnosticTasks.add(task);
|
|
1227
|
+
return task;
|
|
1228
|
+
};
|
|
1229
|
+
const hardwareProbeCache = createClientDiagnosticProbeCache({
|
|
1230
|
+
probeId: 'platform-hardware',
|
|
1231
|
+
ttlMs: Math.max(CLIENT_DIAGNOSTIC_HARDWARE_TTL_MS, diagnosticProbeTtlMs),
|
|
1232
|
+
negativeTtlMs: CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS,
|
|
1233
|
+
monotonicNow: diagnosticMonotonicNow,
|
|
1234
|
+
fallbackValue: { gpus: [], disks: [] },
|
|
1235
|
+
refresh: () => collectPlatformHardwareProbe(diagnosticCommandOwner, diagnosticPlatform)
|
|
1236
|
+
});
|
|
1237
|
+
const nvidiaProbeCache = createClientDiagnosticProbeCache({
|
|
1238
|
+
probeId: 'nvidia-gpu',
|
|
1239
|
+
ttlMs: diagnosticProbeTtlMs,
|
|
1240
|
+
negativeTtlMs: CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS,
|
|
1241
|
+
monotonicNow: diagnosticMonotonicNow,
|
|
1242
|
+
fallbackValue: [],
|
|
1243
|
+
refresh: () => collectNvidiaGpuProbe(diagnosticCommandOwner, diagnosticPlatform)
|
|
1244
|
+
});
|
|
1245
|
+
const networkProbeCache = createClientDiagnosticProbeCache({
|
|
1246
|
+
probeId: 'network-totals',
|
|
1247
|
+
ttlMs: diagnosticProbeTtlMs,
|
|
1248
|
+
negativeTtlMs: CLIENT_DIAGNOSTIC_PROBE_NEGATIVE_TTL_MS,
|
|
1249
|
+
monotonicNow: diagnosticMonotonicNow,
|
|
1250
|
+
fallbackValue: { receivedBytes: 0, sentBytes: 0 },
|
|
1251
|
+
refresh: () => readNetworkTotalsProbe(diagnosticCommandOwner, diagnosticPlatform)
|
|
1252
|
+
});
|
|
1253
|
+
const csrfToken = randomBytes(32).toString('hex');
|
|
1254
|
+
const state = {
|
|
1255
|
+
route: '/computer',
|
|
1256
|
+
manager: '',
|
|
1257
|
+
pairToken: '',
|
|
1258
|
+
slotNumber: normalizeSlotNumber(options.slot),
|
|
1259
|
+
assignedHubId: normalizeString(options.assignedHubId ?? process.env.LIVEDESK_ASSIGNED_HUB_ID, 160),
|
|
1260
|
+
endpointCandidates: [],
|
|
1261
|
+
roleVersion: normalizeRoleVersion(options.roleVersion ?? process.env.LIVEDESK_ROLE_VERSION),
|
|
1259
1262
|
connectedAt: '',
|
|
1260
1263
|
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
|
-
}
|
|
1264
|
+
startup: false,
|
|
1265
|
+
agent: { requestedEngine: normalizeString(options.engine, 40), state: 'waiting', pid: null, engine: '' },
|
|
1266
|
+
videoAcceleration: options.videoAcceleration && typeof options.videoAcceleration === 'object'
|
|
1267
|
+
? { ...options.videoAcceleration, busy: false }
|
|
1268
|
+
: null,
|
|
1269
|
+
lastError: '',
|
|
1270
|
+
auth: {
|
|
1271
|
+
lastAttemptAt: '',
|
|
1272
|
+
lastResult: '',
|
|
1273
|
+
lastError: '',
|
|
1274
|
+
persisted: savedSessionPersisted,
|
|
1275
|
+
email: savedAccountProfile.email,
|
|
1276
|
+
name: savedAccountProfile.name,
|
|
1277
|
+
avatarUrl: savedAccountProfile.avatarUrl
|
|
1278
|
+
}
|
|
1276
1279
|
};
|
|
1277
|
-
let completed = false;
|
|
1278
|
-
let loggedOut = false;
|
|
1279
|
-
let lastChoice = null;
|
|
1280
|
+
let completed = false;
|
|
1281
|
+
let loggedOut = false;
|
|
1282
|
+
let lastChoice = null;
|
|
1280
1283
|
let settleChoice;
|
|
1281
1284
|
let rejectChoice;
|
|
1282
1285
|
let server;
|
|
@@ -1287,219 +1290,219 @@ export function createClientRuntimeServer(options = {}) {
|
|
|
1287
1290
|
const runtime = createRuntimeManager({
|
|
1288
1291
|
role: 'client',
|
|
1289
1292
|
state: RuntimeState.WAITING_AUTH,
|
|
1290
|
-
authenticated: Boolean(savedSession?.access_token),
|
|
1291
|
-
userId: savedSession?.user?.id || '',
|
|
1293
|
+
authenticated: Boolean(savedSession?.access_token),
|
|
1294
|
+
userId: savedSession?.user?.id || '',
|
|
1292
1295
|
deviceId,
|
|
1293
1296
|
appVersion,
|
|
1294
1297
|
startedAt: new Date().toISOString(),
|
|
1295
1298
|
runtimePid: process.pid
|
|
1296
1299
|
});
|
|
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
|
-
}
|
|
1300
|
+
runtime.emit('runtime.waiting-auth', { role: 'client' });
|
|
1301
|
+
|
|
1302
|
+
const requestDiagnosticRefresh = () => {
|
|
1303
|
+
if (closed || options.diagnosticsEnabled === false) return Promise.resolve();
|
|
1304
|
+
if (diagnosticRefreshInFlight) return diagnosticRefreshInFlight;
|
|
1305
|
+
lastDiagnosticRequestAt = new Date().toISOString();
|
|
1306
|
+
hardwareSnapshot = { ...hardwareSnapshot, collecting: true };
|
|
1307
|
+
diagnosticRefreshInFlight = trackDiagnosticTask(async () => {
|
|
1308
|
+
const [platformResult, nvidiaResult, networkResult] = await Promise.all([
|
|
1309
|
+
hardwareProbeCache.read(),
|
|
1310
|
+
nvidiaProbeCache.read(),
|
|
1311
|
+
networkProbeCache.read()
|
|
1312
|
+
]);
|
|
1313
|
+
if (closed) return;
|
|
1314
|
+
|
|
1315
|
+
const platformSnapshot = platformResult.value && typeof platformResult.value === 'object'
|
|
1316
|
+
? platformResult.value
|
|
1317
|
+
: { gpus: [], disks: [] };
|
|
1318
|
+
const nvidiaGpus = Array.isArray(nvidiaResult.value) ? nvidiaResult.value : [];
|
|
1319
|
+
const errors = [platformResult, nvidiaResult, networkResult]
|
|
1320
|
+
.filter(result => result.ok !== true && result.error)
|
|
1321
|
+
.map(result => normalizeString(result.error, 160));
|
|
1322
|
+
hardwareSnapshot = {
|
|
1323
|
+
// Preserve the platform adapter inventory on hybrid-GPU computers. The
|
|
1324
|
+
// NVIDIA sampler enriches its matching adapter instead of replacing
|
|
1325
|
+
// every non-NVIDIA display adapter with the telemetry subset.
|
|
1326
|
+
gpus: mergeGpuSnapshots(platformSnapshot.gpus, nvidiaGpus),
|
|
1327
|
+
disks: Array.isArray(platformSnapshot.disks) ? platformSnapshot.disks : [],
|
|
1328
|
+
collectedAt: new Date().toISOString(),
|
|
1329
|
+
collecting: false,
|
|
1330
|
+
lastError: errors.join(', ')
|
|
1331
|
+
};
|
|
1332
|
+
|
|
1333
|
+
const totals = networkResult.value && typeof networkResult.value === 'object'
|
|
1334
|
+
? networkResult.value
|
|
1335
|
+
: { receivedBytes: 0, sentBytes: 0 };
|
|
1336
|
+
if (networkResult.cached !== true || !networkSnapshot.sampledAt) {
|
|
1337
|
+
const sampledAtMs = Date.now();
|
|
1338
|
+
const elapsedSeconds = previousNetworkTotals
|
|
1339
|
+
? Math.max(0.25, (sampledAtMs - previousNetworkTotals.sampledAtMs) / 1000)
|
|
1340
|
+
: 0;
|
|
1341
|
+
networkSnapshot = {
|
|
1342
|
+
interfaces: activeNetworkInterfaces(),
|
|
1343
|
+
receivedBytes: totals.receivedBytes,
|
|
1344
|
+
sentBytes: totals.sentBytes,
|
|
1345
|
+
receiveBytesPerSecond: elapsedSeconds > 0
|
|
1346
|
+
? Math.max(0, (totals.receivedBytes - previousNetworkTotals.receivedBytes) / elapsedSeconds)
|
|
1347
|
+
: 0,
|
|
1348
|
+
sendBytesPerSecond: elapsedSeconds > 0
|
|
1349
|
+
? Math.max(0, (totals.sentBytes - previousNetworkTotals.sentBytes) / elapsedSeconds)
|
|
1350
|
+
: 0,
|
|
1351
|
+
sampledAt: new Date(sampledAtMs).toISOString()
|
|
1352
|
+
};
|
|
1353
|
+
previousNetworkTotals = { ...totals, sampledAtMs };
|
|
1354
|
+
}
|
|
1355
|
+
lastDiagnosticCompletedAt = new Date().toISOString();
|
|
1356
|
+
runtime.emit('client.system.diagnostics', {
|
|
1357
|
+
gpuCount: hardwareSnapshot.gpus.length,
|
|
1358
|
+
diskCount: hardwareSnapshot.disks.length,
|
|
1359
|
+
platformHardwareGeneration: platformResult.generation,
|
|
1360
|
+
nvidiaGeneration: nvidiaResult.generation,
|
|
1361
|
+
networkGeneration: networkResult.generation
|
|
1362
|
+
});
|
|
1363
|
+
});
|
|
1364
|
+
void diagnosticRefreshInFlight.finally(() => {
|
|
1365
|
+
diagnosticRefreshInFlight = null;
|
|
1366
|
+
if (!closed && hardwareSnapshot.collecting) {
|
|
1367
|
+
hardwareSnapshot = {
|
|
1368
|
+
...hardwareSnapshot,
|
|
1369
|
+
collecting: false,
|
|
1370
|
+
lastError: hardwareSnapshot.lastError || 'diagnostic-refresh-failed'
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
});
|
|
1374
|
+
return diagnosticRefreshInFlight;
|
|
1375
|
+
};
|
|
1376
|
+
|
|
1377
|
+
const recordAuthAttempt = (result, error = '') => {
|
|
1378
|
+
const message = normalizeString(error, 240);
|
|
1379
|
+
state.auth = { ...state.auth, lastAttemptAt: new Date().toISOString(), lastResult: normalizeString(result, 80), lastError: message };
|
|
1380
|
+
state.lastError = message;
|
|
1381
|
+
if (message) state.message = `Client authentication needs attention: ${message}`;
|
|
1382
|
+
runtime.emit('client.auth.session', { result: state.auth.lastResult, error: message });
|
|
1383
|
+
};
|
|
1384
|
+
|
|
1385
|
+
const complete = (choice, message = 'Client credentials accepted. Finding the Hub.') => {
|
|
1386
|
+
lastChoice = choice || lastChoice;
|
|
1387
|
+
if (completed) {
|
|
1388
|
+
if (choice?.session?.access_token) {
|
|
1389
|
+
const accountProfile = readClientAccountProfile(choice.session);
|
|
1390
|
+
runtime.setAuthenticated(true, choice.session.user?.id || '');
|
|
1391
|
+
state.auth = {
|
|
1392
|
+
...state.auth,
|
|
1393
|
+
lastAttemptAt: new Date().toISOString(),
|
|
1394
|
+
lastResult: 'accepted',
|
|
1395
|
+
lastError: '',
|
|
1396
|
+
persisted: true,
|
|
1397
|
+
email: accountProfile.email || state.auth.email,
|
|
1398
|
+
name: accountProfile.name || state.auth.name,
|
|
1399
|
+
avatarUrl: accountProfile.avatarUrl || state.auth.avatarUrl
|
|
1400
|
+
};
|
|
1401
|
+
state.lastError = '';
|
|
1402
|
+
if (state.message.startsWith('Client authentication needs attention:')) {
|
|
1403
|
+
state.message = state.agent.state === 'running'
|
|
1404
|
+
? 'Connected to the LiveDesk Hub.'
|
|
1405
|
+
: normalizeString(message, 1000) || 'Client credentials accepted. Finding the Hub.';
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1407
1410
|
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
|
-
};
|
|
1411
|
+
loggedOut = false;
|
|
1412
|
+
state.connectedAt = new Date().toISOString();
|
|
1413
|
+
state.manager = normalizeString(choice?.manager, 256);
|
|
1414
|
+
if (Object.prototype.hasOwnProperty.call(choice || {}, 'hubDeviceId')) {
|
|
1415
|
+
state.assignedHubId = normalizeString(choice?.hubDeviceId, 160);
|
|
1416
|
+
}
|
|
1417
|
+
state.endpointCandidates = Array.isArray(choice?.endpointCandidates)
|
|
1418
|
+
? choice.endpointCandidates.map(value => normalizeString(value, 256)).filter(Boolean)
|
|
1419
|
+
: [];
|
|
1420
|
+
state.message = message;
|
|
1421
|
+
state.lastError = '';
|
|
1422
|
+
const accountProfile = readClientAccountProfile(choice?.session);
|
|
1423
|
+
state.auth = {
|
|
1424
|
+
...state.auth,
|
|
1425
|
+
lastAttemptAt: state.connectedAt,
|
|
1426
|
+
lastResult: 'accepted',
|
|
1427
|
+
lastError: '',
|
|
1428
|
+
persisted: Boolean(choice?.session?.refresh_token) || state.auth.persisted,
|
|
1429
|
+
email: accountProfile.email || state.auth.email,
|
|
1430
|
+
name: accountProfile.name || state.auth.name,
|
|
1431
|
+
avatarUrl: accountProfile.avatarUrl || state.auth.avatarUrl
|
|
1432
|
+
};
|
|
1430
1433
|
runtime.setAuthenticated(Boolean(choice?.session?.access_token), choice?.session?.user?.id || '');
|
|
1431
1434
|
runtime.update({ state: RuntimeState.RESOLVING_ROLE }, 'credentials-accepted');
|
|
1432
1435
|
settleChoice?.(choice);
|
|
1433
1436
|
settleChoice = null;
|
|
1434
1437
|
};
|
|
1435
1438
|
|
|
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 {
|
|
1439
|
+
const getState = () => {
|
|
1440
|
+
const snapshot = runtime.getSnapshot();
|
|
1441
|
+
const currentCpuTotals = readCpuTotals();
|
|
1442
|
+
const cpuTotalDelta = Math.max(0, currentCpuTotals.total - previousCpuTotals.total);
|
|
1443
|
+
const cpuIdleDelta = Math.max(0, currentCpuTotals.idle - previousCpuTotals.idle);
|
|
1444
|
+
const cpuUsagePercent = cpuTotalDelta > 0 ? Math.max(0, Math.min(100, (1 - cpuIdleDelta / cpuTotalDelta) * 100)) : 0;
|
|
1445
|
+
previousCpuTotals = currentCpuTotals;
|
|
1446
|
+
const totalMemoryBytes = os.totalmem();
|
|
1447
|
+
const freeMemoryBytes = os.freemem();
|
|
1448
|
+
return {
|
|
1446
1449
|
ok: true,
|
|
1447
1450
|
role: 'client',
|
|
1448
1451
|
state: snapshot.state,
|
|
1449
1452
|
deviceId,
|
|
1450
1453
|
deviceName,
|
|
1451
1454
|
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,
|
|
1455
|
+
authenticated: snapshot.authenticated,
|
|
1456
|
+
userId: snapshot.userId || '',
|
|
1457
|
+
userEmail: state.auth.email || '',
|
|
1458
|
+
userName: state.auth.name || '',
|
|
1459
|
+
userAvatarUrl: state.auth.avatarUrl || '',
|
|
1460
|
+
appVersion,
|
|
1461
|
+
runtimeId: `client-${snapshot.runtimePid}-${snapshot.startedAt}`,
|
|
1462
|
+
runtimePid: snapshot.runtimePid,
|
|
1460
1463
|
startedAt: snapshot.startedAt,
|
|
1461
|
-
route: state.route,
|
|
1462
|
-
manager: state.manager,
|
|
1463
|
-
slotNumber: state.slotNumber,
|
|
1464
|
-
assignedHubId: state.assignedHubId,
|
|
1465
|
-
endpointCandidates: state.endpointCandidates,
|
|
1464
|
+
route: state.route,
|
|
1465
|
+
manager: state.manager,
|
|
1466
|
+
slotNumber: state.slotNumber,
|
|
1467
|
+
assignedHubId: state.assignedHubId,
|
|
1468
|
+
endpointCandidates: state.endpointCandidates,
|
|
1466
1469
|
roleVersion: state.roleVersion,
|
|
1467
1470
|
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
|
-
},
|
|
1471
|
+
connectionState: state.agent.state === 'running' ? 'connected' : completed ? 'connecting' : 'waiting-auth',
|
|
1472
|
+
needsAuth: !snapshot.authenticated,
|
|
1473
|
+
statusLabel: loggedOut ? 'Signed out' : state.agent.state === 'running' ? 'Agent running' : completed ? 'Finding Hub' : 'Waiting for auth',
|
|
1474
|
+
message: state.message,
|
|
1475
|
+
lastError: state.lastError,
|
|
1476
|
+
auth: { ...state.auth },
|
|
1477
|
+
startup: state.startup,
|
|
1478
|
+
agent: { ...state.agent, pid: runtime.getSnapshot().agentPid },
|
|
1479
|
+
videoAcceleration: state.videoAcceleration ? { ...state.videoAcceleration } : null,
|
|
1480
|
+
system: {
|
|
1481
|
+
platform: process.platform,
|
|
1482
|
+
arch: process.arch,
|
|
1483
|
+
osVersion: os.release(),
|
|
1484
|
+
pid: process.pid,
|
|
1485
|
+
cpuUsagePercent: Math.round(cpuUsagePercent * 10) / 10,
|
|
1486
|
+
cpuLogicalCores: Math.max(1, os.cpus().length),
|
|
1487
|
+
memoryTotalBytes: totalMemoryBytes,
|
|
1488
|
+
memoryUsedBytes: Math.max(0, totalMemoryBytes - freeMemoryBytes),
|
|
1489
|
+
uptimeSeconds: Math.max(0, Math.floor(os.uptime())),
|
|
1490
|
+
gpus: hardwareSnapshot.gpus,
|
|
1491
|
+
disks: hardwareSnapshot.disks,
|
|
1492
|
+
network: networkSnapshot,
|
|
1493
|
+
hardwareCollectedAt: hardwareSnapshot.collectedAt,
|
|
1494
|
+
hardwareCollecting: hardwareSnapshot.collecting,
|
|
1495
|
+
hardwareLastError: hardwareSnapshot.lastError || '',
|
|
1496
|
+
diagnosticProbes: {
|
|
1497
|
+
requestDriven: true,
|
|
1498
|
+
minimumTtlMs: CLIENT_DIAGNOSTIC_PROBE_MIN_TTL_MS,
|
|
1499
|
+
requestedAt: lastDiagnosticRequestAt,
|
|
1500
|
+
completedAt: lastDiagnosticCompletedAt,
|
|
1501
|
+
platformHardware: hardwareProbeCache.getSnapshot(),
|
|
1502
|
+
nvidiaGpu: nvidiaProbeCache.getSnapshot(),
|
|
1503
|
+
networkTotals: networkProbeCache.getSnapshot()
|
|
1504
|
+
}
|
|
1505
|
+
},
|
|
1503
1506
|
permissions: {
|
|
1504
1507
|
screenCapture: state.agent.state === 'running',
|
|
1505
1508
|
remoteInput: state.agent.state === 'running',
|
|
@@ -1528,385 +1531,385 @@ export function createClientRuntimeServer(options = {}) {
|
|
|
1528
1531
|
}
|
|
1529
1532
|
};
|
|
1530
1533
|
|
|
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;
|
|
1534
|
+
const handleRequest = async (req, res) => {
|
|
1535
|
+
const requestUrl = new URL(req.url || '/', `http://${host}:${port}`);
|
|
1536
|
+
const pathname = requestUrl.pathname;
|
|
1537
|
+
const requestOrigin = normalizeString(req.headers.origin, 300);
|
|
1538
|
+
const respondJson = (status, payload) => sendJson(res, status, payload, requestOrigin, port, trustedWebOrigins);
|
|
1539
|
+
const isStaticAppRequest = !pathname.startsWith('/api/');
|
|
1540
|
+
const allowReadOnlyCrossSite = READ_ONLY_CLIENT_API_PATHS.has(pathname);
|
|
1541
|
+
if (!isTrustedLocalRequest(req, host, port, { allowStaticCrossSite: isStaticAppRequest, allowReadOnlyCrossSite, trustedWebOrigins })) {
|
|
1542
|
+
runtime.emit('client.http.rejected', {
|
|
1543
|
+
method: normalizeString(req.method, 12),
|
|
1544
|
+
pathname: normalizeString(pathname, 160),
|
|
1545
|
+
reason: 'untrusted-local-origin',
|
|
1546
|
+
origin: requestOrigin,
|
|
1547
|
+
fetchSite: normalizeString(req.headers['sec-fetch-site'], 40)
|
|
1548
|
+
});
|
|
1549
|
+
if (pathname === '/api/auth/session') recordAuthAttempt('rejected', 'untrusted-local-origin');
|
|
1550
|
+
respondJson(403, { ok: false, error: 'untrusted-local-origin' });
|
|
1551
|
+
return;
|
|
1560
1552
|
}
|
|
1561
|
-
if (
|
|
1562
|
-
|
|
1563
|
-
|
|
1553
|
+
if (req.method === 'OPTIONS') {
|
|
1554
|
+
res.writeHead(204, {
|
|
1555
|
+
'Access-Control-Allow-Origin': resolveCorsOrigin(requestOrigin, port, trustedWebOrigins),
|
|
1556
|
+
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
|
1557
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-LiveDesk-CSRF',
|
|
1558
|
+
'Access-Control-Allow-Private-Network': 'true',
|
|
1559
|
+
Vary: 'Origin'
|
|
1560
|
+
});
|
|
1561
|
+
res.end();
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method || '') && req.headers['x-livedesk-csrf'] !== csrfToken) {
|
|
1565
|
+
if (pathname === '/api/auth/session') recordAuthAttempt('rejected', 'csrf-token-required');
|
|
1566
|
+
respondJson(403, { ok: false, error: 'csrf-token-required' });
|
|
1564
1567
|
return;
|
|
1565
1568
|
}
|
|
1566
1569
|
if (pathname === '/api/health') {
|
|
1567
|
-
respondJson(200, { ok: true, product: 'LiveDesk', role: 'client', timestamp: new Date().toISOString() });
|
|
1570
|
+
respondJson(200, { ok: true, product: 'LiveDesk', role: 'client', timestamp: new Date().toISOString() });
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
if (pathname === '/api/client/diagnostics') {
|
|
1574
|
+
await requestDiagnosticRefresh();
|
|
1575
|
+
respondJson(200, getState());
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
if (pathname === '/api/runtime' || pathname === '/api/runtime/status' || pathname === '/api/client/status' || pathname === '/api/client/computer' || pathname === '/api/client/system' || pathname === '/api/client/permissions') {
|
|
1579
|
+
respondJson(200, getState());
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
if (pathname === '/api/runtime/events') {
|
|
1583
|
+
respondJson(200, { ok: true, events: runtime.getEvents() });
|
|
1568
1584
|
return;
|
|
1569
1585
|
}
|
|
1570
|
-
if (pathname === '/api/
|
|
1571
|
-
|
|
1572
|
-
respondJson(200,
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1586
|
+
if (pathname === '/api/auth/status') {
|
|
1587
|
+
const snapshot = runtime.getSnapshot();
|
|
1588
|
+
respondJson(200, {
|
|
1589
|
+
ok: true,
|
|
1590
|
+
authenticated: snapshot.authenticated,
|
|
1591
|
+
userId: snapshot.userId || null,
|
|
1592
|
+
userEmail: state.auth.email || null,
|
|
1593
|
+
userName: state.auth.name || null,
|
|
1594
|
+
userAvatarUrl: state.auth.avatarUrl || null,
|
|
1595
|
+
role: 'client'
|
|
1596
|
+
});
|
|
1581
1597
|
return;
|
|
1582
1598
|
}
|
|
1583
|
-
if (pathname === '/
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
if (!
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
}
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
const
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
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 === '/auth/google' && req.method === 'GET') {
|
|
1600
|
+
try {
|
|
1601
|
+
if (runtime.getSnapshot().authenticated) {
|
|
1602
|
+
res.writeHead(303, { Location: '/', 'Cache-Control': 'no-store' });
|
|
1603
|
+
res.end();
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
if (typeof options.beginGoogleSignIn !== 'function') {
|
|
1607
|
+
throw new Error('client-google-sign-in-unavailable');
|
|
1608
|
+
}
|
|
1609
|
+
recordAuthAttempt('oauth-starting');
|
|
1610
|
+
const redirectTo = `http://${host}:${port}/callback`;
|
|
1611
|
+
const result = await options.beginGoogleSignIn(redirectTo);
|
|
1612
|
+
const authorizationUrl = normalizeString(result?.url || result, 4000);
|
|
1613
|
+
if (!authorizationUrl) throw new Error(result?.error || 'google-authorization-url-missing');
|
|
1614
|
+
recordAuthAttempt('oauth-waiting');
|
|
1615
|
+
res.writeHead(302, {
|
|
1616
|
+
Location: authorizationUrl,
|
|
1617
|
+
'Cache-Control': 'no-store',
|
|
1618
|
+
'Referrer-Policy': 'no-referrer'
|
|
1619
|
+
});
|
|
1620
|
+
res.end();
|
|
1621
|
+
} catch (error) {
|
|
1622
|
+
const message = normalizeString(error?.message || error, 240);
|
|
1623
|
+
recordAuthAttempt('rejected', message);
|
|
1624
|
+
res.writeHead(303, { Location: `/?auth_error=${encodeURIComponent(message)}`, 'Cache-Control': 'no-store' });
|
|
1625
|
+
res.end();
|
|
1626
|
+
}
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
if (pathname === '/callback' && req.method === 'GET') {
|
|
1630
|
+
const providerError = normalizeString(
|
|
1631
|
+
requestUrl.searchParams.get('error_description') || requestUrl.searchParams.get('error'),
|
|
1632
|
+
240
|
|
1633
|
+
);
|
|
1634
|
+
const code = normalizeString(requestUrl.searchParams.get('code'), 2000);
|
|
1635
|
+
try {
|
|
1636
|
+
if (providerError) throw new Error(providerError);
|
|
1637
|
+
if (!code) throw new Error('google-auth-code-missing');
|
|
1638
|
+
if (typeof options.exchangeGoogleCode !== 'function') {
|
|
1639
|
+
throw new Error('client-google-callback-unavailable');
|
|
1640
|
+
}
|
|
1641
|
+
recordAuthAttempt('oauth-exchanging');
|
|
1642
|
+
const exchanged = await options.exchangeGoogleCode(code);
|
|
1643
|
+
const sessionCandidate = exchanged?.session || exchanged;
|
|
1644
|
+
const normalized = normalizeRuntimeAuthSession(sessionCandidate, { requireRefreshToken: true });
|
|
1645
|
+
if (!normalized.ok) throw new Error(normalized.error);
|
|
1646
|
+
const session = {
|
|
1647
|
+
...sessionCandidate,
|
|
1648
|
+
...mergeClientAccountProfile(normalized.session, sessionCandidate?.user)
|
|
1649
|
+
};
|
|
1650
|
+
if (!writeSavedSession(session)) throw new Error('refresh-token-required');
|
|
1651
|
+
state.auth.persisted = true;
|
|
1652
|
+
complete({ type: 'google', session }, 'Signed in. Finding the LiveDesk Hub.');
|
|
1653
|
+
res.writeHead(303, { Location: '/', 'Cache-Control': 'no-store' });
|
|
1654
|
+
res.end();
|
|
1655
|
+
} catch (error) {
|
|
1656
|
+
const message = normalizeString(error?.message || error, 240);
|
|
1657
|
+
recordAuthAttempt('rejected', message);
|
|
1658
|
+
res.writeHead(303, { Location: `/?auth_error=${encodeURIComponent(message)}`, 'Cache-Control': 'no-store' });
|
|
1659
|
+
res.end();
|
|
1660
|
+
}
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
if (pathname === '/api/auth/session' && req.method === 'POST') {
|
|
1664
|
+
const body = parseJsonBody(await readBody(req));
|
|
1665
|
+
const normalized = normalizeRuntimeAuthSession(body, { requireRefreshToken: true });
|
|
1666
|
+
if (!normalized.ok) {
|
|
1667
|
+
recordAuthAttempt('rejected', normalized.error);
|
|
1668
|
+
respondJson(400, { ok: false, error: normalized.error });
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
const accessToken = normalized.session.access_token;
|
|
1672
|
+
recordAuthAttempt('verifying');
|
|
1673
|
+
const verifyResponse = await fetch(`${SUPABASE_URL.replace(/\/+$/, '')}/auth/v1/user`, {
|
|
1671
1674
|
headers: { apikey: SUPABASE_PUBLISHABLE_KEY, Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }
|
|
1672
1675
|
});
|
|
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}` });
|
|
1676
|
+
if (!verifyResponse.ok) {
|
|
1677
|
+
recordAuthAttempt('rejected', `supabase-user-verification-failed:${verifyResponse.status}`);
|
|
1678
|
+
respondJson(401, { ok: false, error: `supabase-user-verification-failed:${verifyResponse.status}` });
|
|
1676
1679
|
return;
|
|
1677
1680
|
}
|
|
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' });
|
|
1681
|
+
const verifiedUser = await verifyResponse.json().catch(() => ({}));
|
|
1682
|
+
const verifiedSession = {
|
|
1683
|
+
...normalized.session,
|
|
1684
|
+
user: {
|
|
1685
|
+
id: normalizeString(verifiedUser?.id || body.user?.id || body.userId, 160),
|
|
1686
|
+
email: normalizeString(verifiedUser?.email || body.user?.email || body.email, 320)
|
|
1687
|
+
}
|
|
1688
|
+
};
|
|
1689
|
+
const session = mergeClientAccountProfile(verifiedSession, verifiedUser);
|
|
1690
|
+
try {
|
|
1691
|
+
if (!writeSavedSession(session)) throw new Error('refresh-token-required');
|
|
1692
|
+
state.auth.persisted = true;
|
|
1693
|
+
} catch (error) {
|
|
1694
|
+
const message = `session-persistence-failed:${normalizeString(error?.message || error, 160)}`;
|
|
1695
|
+
recordAuthAttempt('rejected', message);
|
|
1696
|
+
respondJson(500, { ok: false, error: message });
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
complete({ type: 'google', session }, 'Signed in. Finding the LiveDesk Hub.');
|
|
1700
|
+
respondJson(200, { ok: true, authenticated: true, persisted: true, role: 'client' });
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
if (pathname === '/api/auth/session' && req.method === 'DELETE') {
|
|
1704
|
+
const activeSession = lastChoice?.session || savedSession || readSavedSession();
|
|
1705
|
+
const providerLogout = await revokeClientProviderSession(activeSession, {
|
|
1706
|
+
revokeProviderSession: options.revokeProviderSession,
|
|
1707
|
+
fetchImpl: options.fetchImpl
|
|
1708
|
+
});
|
|
1709
|
+
clearSavedSession();
|
|
1710
|
+
loggedOut = true;
|
|
1711
|
+
completed = false;
|
|
1712
|
+
lastChoice = null;
|
|
1713
|
+
state.manager = '';
|
|
1714
|
+
state.message = 'Saved sign-in was cleared. Sign in again to start the Client.';
|
|
1715
|
+
state.lastError = '';
|
|
1716
|
+
state.auth = {
|
|
1717
|
+
lastAttemptAt: new Date().toISOString(),
|
|
1718
|
+
lastResult: 'cleared',
|
|
1719
|
+
lastError: '',
|
|
1720
|
+
persisted: false,
|
|
1721
|
+
email: '',
|
|
1722
|
+
name: '',
|
|
1723
|
+
avatarUrl: ''
|
|
1724
|
+
};
|
|
1725
|
+
runtime.setAuthenticated(false);
|
|
1726
|
+
respondJson(providerLogout.ok ? 200 : 502, {
|
|
1727
|
+
ok: providerLogout.ok,
|
|
1728
|
+
authenticated: false,
|
|
1729
|
+
localCleared: true,
|
|
1730
|
+
role: 'client',
|
|
1731
|
+
providerLogout
|
|
1732
|
+
});
|
|
1698
1733
|
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
1734
|
}
|
|
1732
1735
|
if (pathname === '/api/runtime/restart' && req.method === 'POST') {
|
|
1733
1736
|
runtime.emit('runtime.restart.requested', { role: 'client' });
|
|
1734
|
-
respondJson(200, { ok: true, restarting: true, role: 'client' });
|
|
1737
|
+
respondJson(200, { ok: true, restarting: true, role: 'client' });
|
|
1735
1738
|
setTimeout(() => options.onRestart?.(), 150);
|
|
1736
1739
|
return;
|
|
1737
1740
|
}
|
|
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') {
|
|
1741
|
+
if (pathname === '/api/runtime/shutdown' && req.method === 'POST') {
|
|
1742
|
+
runtime.emit('runtime.shutdown.requested', { role: 'client' });
|
|
1743
|
+
respondJson(200, { ok: true, shuttingDown: true, role: 'client' });
|
|
1744
|
+
setTimeout(() => options.onShutdown?.(), 150);
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
if (pathname === '/api/client/slot' && req.method === 'POST') {
|
|
1748
|
+
const body = parseJsonBody(await readBody(req));
|
|
1749
|
+
const slotNumber = normalizeSlotNumber(body.slotNumber ?? body.slot);
|
|
1750
|
+
if (!slotNumber) {
|
|
1751
|
+
respondJson(400, { ok: false, error: 'invalid-slot-number', slotNumber: state.slotNumber });
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
if (!state.manager || !state.pairToken || state.agent.state !== 'running') {
|
|
1755
|
+
respondJson(409, { ok: false, error: 'hub-not-connected', slotNumber: state.slotNumber });
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
if (typeof options.assignSlot !== 'function') {
|
|
1759
|
+
respondJson(409, { ok: false, error: 'slot-assignment-unavailable', slotNumber: state.slotNumber });
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
runtime.emit('client.slot.assignment-requested', {
|
|
1763
|
+
deviceId,
|
|
1764
|
+
previousSlotNumber: state.slotNumber,
|
|
1765
|
+
slotNumber
|
|
1766
|
+
});
|
|
1767
|
+
try {
|
|
1768
|
+
const result = await options.assignSlot({
|
|
1769
|
+
manager: state.manager,
|
|
1770
|
+
pairToken: state.pairToken,
|
|
1771
|
+
deviceId,
|
|
1772
|
+
slotNumber
|
|
1773
|
+
});
|
|
1774
|
+
if (result?.ok !== true) {
|
|
1775
|
+
const error = normalizeString(result?.error, 120) || 'slot-assignment-failed';
|
|
1776
|
+
runtime.emit('client.slot.assignment-rejected', {
|
|
1777
|
+
deviceId,
|
|
1778
|
+
slotNumber,
|
|
1779
|
+
error,
|
|
1780
|
+
conflictDeviceId: normalizeString(result?.conflict?.deviceId, 160)
|
|
1781
|
+
});
|
|
1782
|
+
respondJson(error === 'slot-taken' ? 409 : 502, {
|
|
1783
|
+
...(result && typeof result === 'object' ? result : {}),
|
|
1784
|
+
ok: false,
|
|
1785
|
+
error,
|
|
1786
|
+
slotNumber: state.slotNumber
|
|
1787
|
+
});
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
state.slotNumber = slotNumber;
|
|
1791
|
+
state.message = `Slot ${String(slotNumber).padStart(3, '0')} saved. Reconnecting this Client.`;
|
|
1792
|
+
runtime.emit('client.slot.assigned', { deviceId, slotNumber });
|
|
1793
|
+
respondJson(200, { ...getState(), ...result, ok: true, slotNumber });
|
|
1794
|
+
} catch (error) {
|
|
1795
|
+
const message = normalizeString(error?.message || error, 240) || 'slot-assignment-failed';
|
|
1796
|
+
runtime.emit('client.slot.assignment-failed', { deviceId, slotNumber, error: message });
|
|
1797
|
+
respondJson(502, { ok: false, error: message, slotNumber: state.slotNumber });
|
|
1798
|
+
}
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
if (pathname === '/api/client/video-acceleration/install' && req.method === 'POST') {
|
|
1802
|
+
if (process.platform !== 'linux') {
|
|
1803
|
+
respondJson(409, { ok: false, error: 'linux-video-acceleration-only' });
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
if (state.videoAcceleration?.busy) {
|
|
1807
|
+
respondJson(409, { ok: false, error: 'video-acceleration-install-in-progress', ...getState() });
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
if (typeof options.installVideoAcceleration !== 'function') {
|
|
1811
|
+
respondJson(409, { ok: false, error: 'video-acceleration-install-unavailable' });
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
state.videoAcceleration = {
|
|
1815
|
+
...(state.videoAcceleration || {}),
|
|
1816
|
+
supported: true,
|
|
1817
|
+
state: 'installing',
|
|
1818
|
+
busy: true,
|
|
1819
|
+
ready: false,
|
|
1820
|
+
error: '',
|
|
1821
|
+
message: 'Installing Linux hardware video support…'
|
|
1822
|
+
};
|
|
1823
|
+
runtime.emit('client.video-acceleration.installing', {
|
|
1824
|
+
packageManager: normalizeString(state.videoAcceleration.packageManager?.id, 40)
|
|
1825
|
+
});
|
|
1826
|
+
try {
|
|
1827
|
+
const result = await options.installVideoAcceleration();
|
|
1828
|
+
state.videoAcceleration = {
|
|
1829
|
+
...(result && typeof result === 'object' ? result : {}),
|
|
1830
|
+
busy: false
|
|
1831
|
+
};
|
|
1832
|
+
const ok = state.videoAcceleration.ready === true;
|
|
1833
|
+
runtime.emit(ok ? 'client.video-acceleration.ready' : 'client.video-acceleration.failed', {
|
|
1834
|
+
encoder: normalizeString(state.videoAcceleration.encoder, 80),
|
|
1835
|
+
driver: normalizeString(state.videoAcceleration.driver, 80),
|
|
1836
|
+
error: normalizeString(state.videoAcceleration.error, 240)
|
|
1837
|
+
});
|
|
1838
|
+
respondJson(ok ? 200 : 409, { ...getState(), ok });
|
|
1839
|
+
if (ok && state.videoAcceleration.restartAgent) {
|
|
1840
|
+
setTimeout(() => options.onVideoAccelerationReady?.(state.videoAcceleration), 150);
|
|
1841
|
+
}
|
|
1842
|
+
} catch (error) {
|
|
1843
|
+
state.videoAcceleration = {
|
|
1844
|
+
...(state.videoAcceleration || {}),
|
|
1845
|
+
state: 'error',
|
|
1846
|
+
busy: false,
|
|
1847
|
+
ready: false,
|
|
1848
|
+
error: normalizeString(error?.message || error, 320)
|
|
1849
|
+
};
|
|
1850
|
+
runtime.emit('client.video-acceleration.failed', { error: state.videoAcceleration.error });
|
|
1851
|
+
respondJson(409, { ok: false, ...getState(), error: state.videoAcceleration.error });
|
|
1852
|
+
}
|
|
1853
|
+
return;
|
|
1854
|
+
}
|
|
1855
|
+
if (pathname === '/api/runtime/switch-role' && req.method === 'POST') {
|
|
1853
1856
|
const body = parseJsonBody(await readBody(req));
|
|
1854
1857
|
if (String(body.role || '').trim().toLowerCase() !== 'hub') {
|
|
1855
|
-
respondJson(400, { ok: false, error: 'client-can-only-transition-to-hub' });
|
|
1858
|
+
respondJson(400, { ok: false, error: 'client-can-only-transition-to-hub' });
|
|
1856
1859
|
return;
|
|
1857
1860
|
}
|
|
1858
1861
|
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' });
|
|
1862
|
+
const result = await options.changeRole?.(
|
|
1863
|
+
'hub',
|
|
1864
|
+
runtime.getSnapshot(),
|
|
1865
|
+
lastChoice?.session || savedSession || null
|
|
1866
|
+
);
|
|
1867
|
+
respondJson(result?.ok === false ? 409 : 200, result || { ok: false, error: 'role-change-unavailable' });
|
|
1865
1868
|
} catch (error) {
|
|
1866
|
-
respondJson(409, { ok: false, error: normalizeString(error?.message || error) });
|
|
1869
|
+
respondJson(409, { ok: false, error: normalizeString(error?.message || error) });
|
|
1867
1870
|
}
|
|
1868
1871
|
return;
|
|
1869
1872
|
}
|
|
1870
1873
|
if (pathname === '/api/client/role' && req.method === 'POST') {
|
|
1871
1874
|
const body = parseJsonBody(await readBody(req));
|
|
1872
1875
|
if (String(body.role || '').trim().toLowerCase() !== 'hub') {
|
|
1873
|
-
respondJson(400, { ok: false, error: 'client-can-only-transition-to-hub' });
|
|
1876
|
+
respondJson(400, { ok: false, error: 'client-can-only-transition-to-hub' });
|
|
1874
1877
|
return;
|
|
1875
1878
|
}
|
|
1876
1879
|
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' });
|
|
1880
|
+
const result = await options.changeRole?.(
|
|
1881
|
+
'hub',
|
|
1882
|
+
runtime.getSnapshot(),
|
|
1883
|
+
lastChoice?.session || savedSession || null
|
|
1884
|
+
);
|
|
1885
|
+
respondJson(result?.ok === false ? 409 : 200, result || { ok: false, error: 'role-change-unavailable' });
|
|
1883
1886
|
} catch (error) {
|
|
1884
|
-
respondJson(409, { ok: false, error: normalizeString(error?.message || error) });
|
|
1887
|
+
respondJson(409, { ok: false, error: normalizeString(error?.message || error) });
|
|
1885
1888
|
}
|
|
1886
1889
|
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() });
|
|
1890
|
+
}
|
|
1891
|
+
if (pathname === '/api/client/reconnect' && req.method === 'POST') {
|
|
1892
|
+
const body = parseJsonBody(await readBody(req));
|
|
1893
|
+
if (body.onlyIfWaiting === true && state.agent.state === 'running') {
|
|
1894
|
+
respondJson(200, { ok: true, skipped: true, reason: 'already-connected', ...getState() });
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
const previousManager = state.manager;
|
|
1898
|
+
state.manager = '';
|
|
1899
|
+
state.agent.state = 'reconnecting';
|
|
1900
|
+
state.lastError = '';
|
|
1901
|
+
state.message = 'Refreshing the LiveDesk Hub discovery now.';
|
|
1902
|
+
runtime.update({ state: RuntimeState.RESOLVING_ROLE }, 'client-reconnect-requested');
|
|
1903
|
+
runtime.emit('client.reconnect.requested', { deviceId, previousManager: previousManager || '' });
|
|
1904
|
+
await options.onReconnect?.();
|
|
1905
|
+
respondJson(200, { ok: true, ...getState() });
|
|
1903
1906
|
return;
|
|
1904
1907
|
}
|
|
1905
1908
|
if (pathname === '/api/client/disconnect' && req.method === 'POST') {
|
|
1906
1909
|
await options.onDisconnect?.();
|
|
1907
1910
|
state.manager = '';
|
|
1908
1911
|
state.agent.state = 'waiting';
|
|
1909
|
-
respondJson(200, { ok: true, ...getState() });
|
|
1912
|
+
respondJson(200, { ok: true, ...getState() });
|
|
1910
1913
|
return;
|
|
1911
1914
|
}
|
|
1912
1915
|
if (pathname === '/api/client/pin' && req.method === 'POST') {
|
|
@@ -1917,181 +1920,192 @@ export function createClientRuntimeServer(options = {}) {
|
|
|
1917
1920
|
if (!resolved) throw new Error('pin-not-resolved');
|
|
1918
1921
|
state.message = 'PIN accepted. Starting the Client.';
|
|
1919
1922
|
complete({ type: 'pin', ...resolved }, state.message);
|
|
1920
|
-
respondJson(200, { ok: true, ...getState() });
|
|
1923
|
+
respondJson(200, { ok: true, ...getState() });
|
|
1921
1924
|
} catch (error) {
|
|
1922
|
-
respondJson(409, { ok: false, error: normalizeString(error?.message || error) });
|
|
1925
|
+
respondJson(409, { ok: false, error: normalizeString(error?.message || error) });
|
|
1923
1926
|
}
|
|
1924
1927
|
return;
|
|
1925
1928
|
}
|
|
1926
1929
|
if (pathname.startsWith('/api/')) {
|
|
1927
|
-
respondJson(409, runtimeRoleError('client', 'client'));
|
|
1930
|
+
respondJson(409, runtimeRoleError('client', 'client'));
|
|
1928
1931
|
return;
|
|
1929
1932
|
}
|
|
1930
1933
|
serveApp(req, res, pathname);
|
|
1931
1934
|
};
|
|
1932
1935
|
|
|
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) => {
|
|
1936
|
+
const start = () => new Promise((resolveStart, rejectStart) => {
|
|
1937
|
+
if (process.env.LIVEDESK_DESKTOP_HOST === '1') clearSavedSession();
|
|
1938
|
+
let settled = false;
|
|
1939
|
+
server = createServer((req, res) => {
|
|
1937
1940
|
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);
|
|
1941
|
+
if (!res.headersSent) sendJson(res, 500, { ok: false, error: normalizeString(error?.message || error) }, req.headers.origin, port, trustedWebOrigins);
|
|
1939
1942
|
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
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
}
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
1943
|
+
});
|
|
1944
|
+
});
|
|
1945
|
+
const bindTimer = setTimeout(() => {
|
|
1946
|
+
if (settled) return;
|
|
1947
|
+
settled = true;
|
|
1948
|
+
try { server.close(); } catch { /* bind never completed */ }
|
|
1949
|
+
rejectStart(new Error(`client-runtime-bind-timeout:${host}:${port}`));
|
|
1950
|
+
}, CLIENT_RUNTIME_BIND_TIMEOUT_MS);
|
|
1951
|
+
bindTimer.unref?.();
|
|
1952
|
+
server.once('error', error => {
|
|
1953
|
+
if (settled) return;
|
|
1954
|
+
settled = true;
|
|
1955
|
+
clearTimeout(bindTimer);
|
|
1956
|
+
rejectStart(error);
|
|
1957
|
+
});
|
|
1958
|
+
server.listen(port, host, () => {
|
|
1959
|
+
if (settled) {
|
|
1960
|
+
try { server.close(); } catch { /* timed-out bind already owns cleanup */ }
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
settled = true;
|
|
1964
|
+
clearTimeout(bindTimer);
|
|
1965
|
+
runtime.emit('runtime.http.started', { host, port });
|
|
1966
|
+
if (initialChoice) {
|
|
1967
|
+
setImmediate(() => {
|
|
1968
|
+
complete(initialChoice, initialChoiceMessage);
|
|
1969
|
+
});
|
|
1970
|
+
} else {
|
|
1971
|
+
const saved = normalizeRuntimeAuthSession(savedSession, { requireRefreshToken: true });
|
|
1972
|
+
if (saved.ok) {
|
|
1973
|
+
setImmediate(() => {
|
|
1974
|
+
// Hub discovery must not wait for a display-only user-profile
|
|
1975
|
+
// request. The launcher already refreshed and installed this
|
|
1976
|
+
// DPAPI/keychain-backed session before starting the runtime.
|
|
1977
|
+
const normalizedSession = mergeClientAccountProfile(saved.session, savedSession);
|
|
1978
|
+
complete(
|
|
1979
|
+
{ type: 'google', session: normalizedSession },
|
|
1980
|
+
'Saved sign-in restored. Finding the LiveDesk Hub.'
|
|
1981
|
+
);
|
|
1982
|
+
void trackDiagnosticTask(async () => {
|
|
1983
|
+
try {
|
|
1984
|
+
const hydratedSession = await hydrateAccountProfile(normalizedSession);
|
|
1985
|
+
if (closed || !hydratedSession?.access_token) return;
|
|
1986
|
+
if (writeSavedSession(hydratedSession)) {
|
|
1987
|
+
state.auth.persisted = true;
|
|
1988
|
+
}
|
|
1989
|
+
// complete() updates profile fields after startup without
|
|
1990
|
+
// restarting discovery or opening another OAuth flow.
|
|
1991
|
+
complete(
|
|
1992
|
+
{ type: 'google', session: hydratedSession },
|
|
1993
|
+
'Saved sign-in restored. Finding the LiveDesk Hub.'
|
|
1994
|
+
);
|
|
1995
|
+
} catch {
|
|
1996
|
+
// Name/avatar hydration is optional. A provider timeout must
|
|
1997
|
+
// never invalidate the local session or interrupt remote work.
|
|
1998
|
+
}
|
|
1999
|
+
});
|
|
2000
|
+
});
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
resolveStart();
|
|
2004
|
+
});
|
|
2005
|
+
});
|
|
2006
|
+
|
|
2007
|
+
const closeHttpServer = timeoutMs => new Promise(resolveClose => {
|
|
2008
|
+
if (!server) {
|
|
2009
|
+
resolveClose({ ok: true, listening: false });
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
let settled = false;
|
|
2013
|
+
const finish = ok => {
|
|
2014
|
+
if (settled) return;
|
|
2015
|
+
settled = true;
|
|
2016
|
+
clearTimeout(timer);
|
|
2017
|
+
resolveClose({ ok, listening: Boolean(server?.listening) });
|
|
2018
|
+
};
|
|
2019
|
+
const timer = setTimeout(() => {
|
|
2020
|
+
try { server.closeAllConnections?.(); } catch { /* exact HTTP owner already closed */ }
|
|
2021
|
+
finish(!server.listening);
|
|
2022
|
+
}, Math.max(100, Number(timeoutMs) || CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS));
|
|
2023
|
+
try {
|
|
2024
|
+
server.close(error => finish(!error));
|
|
2025
|
+
server.closeIdleConnections?.();
|
|
2026
|
+
} catch {
|
|
2027
|
+
finish(true);
|
|
2028
|
+
}
|
|
2029
|
+
});
|
|
2030
|
+
|
|
2031
|
+
const closeRuntime = async () => {
|
|
2032
|
+
const closeStartedAt = Date.now();
|
|
2033
|
+
closed = true;
|
|
2034
|
+
runtime.emit('runtime.http.stopping', { port });
|
|
2035
|
+
|
|
2036
|
+
const taskOwners = [...diagnosticTasks];
|
|
2037
|
+
const [diagnosticClose, httpClose] = await Promise.all([
|
|
2038
|
+
diagnosticCommandOwner.close(CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS),
|
|
2039
|
+
closeHttpServer(CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS)
|
|
2040
|
+
]);
|
|
2041
|
+
if (taskOwners.length > 0) {
|
|
2042
|
+
const remainingCloseMs = Math.max(
|
|
2043
|
+
0,
|
|
2044
|
+
CLIENT_DIAGNOSTIC_CLOSE_TIMEOUT_MS - (Date.now() - closeStartedAt)
|
|
2045
|
+
);
|
|
2046
|
+
if (remainingCloseMs > 0) {
|
|
2047
|
+
await Promise.race([
|
|
2048
|
+
Promise.allSettled(taskOwners),
|
|
2049
|
+
new Promise(resolve => setTimeout(resolve, remainingCloseMs))
|
|
2050
|
+
]);
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
const finalDiagnostics = diagnosticCommandOwner.getSnapshot();
|
|
2054
|
+
const result = {
|
|
2055
|
+
ok: diagnosticClose.ok === true
|
|
2056
|
+
&& httpClose.ok === true
|
|
2057
|
+
&& diagnosticTasks.size === 0
|
|
2058
|
+
&& finalDiagnostics.activeCount === 0
|
|
2059
|
+
&& finalDiagnostics.redirectedStreamCount === 0,
|
|
2060
|
+
diagnostics: {
|
|
2061
|
+
...diagnosticClose,
|
|
2062
|
+
taskCount: diagnosticTasks.size
|
|
2063
|
+
},
|
|
2064
|
+
http: httpClose
|
|
2065
|
+
};
|
|
2066
|
+
runtime.emit(
|
|
2067
|
+
result.ok ? 'client.diagnostics.closed' : 'client.diagnostics.close-timeout',
|
|
2068
|
+
{
|
|
2069
|
+
ownerId: finalDiagnostics.ownerId,
|
|
2070
|
+
activeCount: finalDiagnostics.activeCount,
|
|
2071
|
+
redirectedStreamCount: finalDiagnostics.redirectedStreamCount,
|
|
2072
|
+
taskCount: diagnosticTasks.size
|
|
2073
|
+
}
|
|
2074
|
+
);
|
|
2075
|
+
return result;
|
|
2076
|
+
};
|
|
2077
|
+
|
|
2078
|
+
return {
|
|
2065
2079
|
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 = {}) {
|
|
2080
|
+
waitForChoice,
|
|
2081
|
+
start,
|
|
2082
|
+
get url() { return `http://${host}:${port}/`; },
|
|
2083
|
+
getLastChoice() { return lastChoice; },
|
|
2084
|
+
acceptChoice(choice, message) {
|
|
2085
|
+
complete(choice, message);
|
|
2086
|
+
return lastChoice;
|
|
2087
|
+
},
|
|
2088
|
+
update(patch = {}) {
|
|
2075
2089
|
Object.assign(state, patch);
|
|
2076
2090
|
if (patch.agent && typeof patch.agent === 'object') {
|
|
2077
2091
|
state.agent = { ...state.agent, ...patch.agent };
|
|
2078
2092
|
runtime.setAgentPid(Number.isInteger(patch.agent.pid) ? patch.agent.pid : runtime.getSnapshot().agentPid);
|
|
2079
2093
|
if (patch.agent.state === 'running') runtime.update({ state: RuntimeState.RUNNING }, 'agent-running');
|
|
2080
2094
|
}
|
|
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
|
-
}
|
|
2095
|
+
if (patch.manager) state.manager = normalizeString(patch.manager, 256);
|
|
2096
|
+
if (patch.pairToken) state.pairToken = normalizeString(patch.pairToken, 256);
|
|
2097
|
+
if (normalizeSlotNumber(patch.slotNumber)) state.slotNumber = normalizeSlotNumber(patch.slotNumber);
|
|
2098
|
+
if (Object.prototype.hasOwnProperty.call(patch, 'assignedHubId')) {
|
|
2099
|
+
state.assignedHubId = normalizeString(patch.assignedHubId, 160);
|
|
2100
|
+
}
|
|
2087
2101
|
if (patch.message) state.message = normalizeString(patch.message, 1000);
|
|
2088
2102
|
if (Number.isInteger(patch.roleVersion)) state.roleVersion = patch.roleVersion;
|
|
2089
2103
|
if (Array.isArray(patch.endpointCandidates)) state.endpointCandidates = patch.endpointCandidates;
|
|
2090
2104
|
},
|
|
2091
|
-
close() {
|
|
2092
|
-
if (!runtimeClosePromise) runtimeClosePromise = closeRuntime();
|
|
2093
|
-
return runtimeClosePromise;
|
|
2094
|
-
},
|
|
2105
|
+
close() {
|
|
2106
|
+
if (!runtimeClosePromise) runtimeClosePromise = closeRuntime();
|
|
2107
|
+
return runtimeClosePromise;
|
|
2108
|
+
},
|
|
2095
2109
|
getRoleRestartRequest() { return options.getRoleRestartRequest?.() || null; }
|
|
2096
2110
|
};
|
|
2097
2111
|
}
|