@livedesk/client 0.1.213 → 0.1.215

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/client",
3
- "version": "0.1.213",
3
+ "version": "0.1.215",
4
4
  "description": "LiveDesk local remote client",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,9 +10,10 @@
10
10
  "livedesk-client-fast": "bin/livedesk-client-fast.js"
11
11
  },
12
12
  "files": [
13
- "bin/",
14
- "src/",
15
- "README.md",
13
+ "bin/",
14
+ "src/",
15
+ "tests/",
16
+ "README.md",
16
17
  "THIRD_PARTY_NOTICES.md"
17
18
  ],
18
19
  "scripts": {
@@ -40,10 +41,10 @@
40
41
  "ws": "^8.18.3"
41
42
  },
42
43
  "optionalDependencies": {
43
- "@livedesk/fast-linux-x64": "0.1.417",
44
- "@livedesk/fast-osx-arm64": "0.1.417",
45
- "@livedesk/fast-osx-x64": "0.1.417",
46
- "@livedesk/fast-win-x64": "0.1.417"
44
+ "@livedesk/fast-linux-x64": "0.1.419",
45
+ "@livedesk/fast-osx-arm64": "0.1.419",
46
+ "@livedesk/fast-osx-x64": "0.1.419",
47
+ "@livedesk/fast-win-x64": "0.1.419"
47
48
  },
48
49
  "publishConfig": {
49
50
  "access": "public"
@@ -165,6 +165,12 @@ function readCpuTotals() {
165
165
  }
166
166
 
167
167
  function finiteNumber(value) {
168
+ if (value === null
169
+ || value === undefined
170
+ || typeof value === 'boolean'
171
+ || (typeof value === 'string' && value.trim() === '')) {
172
+ return null;
173
+ }
168
174
  const number = Number(value);
169
175
  return Number.isFinite(number) && number >= 0 ? number : null;
170
176
  }
@@ -300,6 +306,53 @@ function normalizeGpu(value) {
300
306
  };
301
307
  }
302
308
 
309
+ export function normalizeGpuIdentityName(value) {
310
+ return normalizeString(value, 160)
311
+ .normalize('NFKC')
312
+ .replace(/\((?:r|tm)\)/giu, '')
313
+ .replace(/[®™]/gu, '')
314
+ .toLocaleLowerCase('en-US')
315
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
316
+ .trim();
317
+ }
318
+
319
+ export function mergeGpuSnapshots(platformGpus = [], telemetryGpus = []) {
320
+ const merged = [];
321
+ const indexesByIdentity = new Map();
322
+ for (const value of Array.isArray(platformGpus) ? platformGpus : []) {
323
+ const gpu = normalizeGpu(value);
324
+ const identity = normalizeGpuIdentityName(gpu?.name);
325
+ if (!gpu || !identity) continue;
326
+ const index = merged.length;
327
+ merged.push(gpu);
328
+ const indexes = indexesByIdentity.get(identity) || [];
329
+ indexes.push(index);
330
+ indexesByIdentity.set(identity, indexes);
331
+ }
332
+
333
+ const matchedIndexes = new Set();
334
+ for (const value of Array.isArray(telemetryGpus) ? telemetryGpus : []) {
335
+ const gpu = normalizeGpu(value);
336
+ const identity = normalizeGpuIdentityName(gpu?.name);
337
+ if (!gpu || !identity) continue;
338
+ const existingIndex = (indexesByIdentity.get(identity) || [])
339
+ .find(index => !matchedIndexes.has(index));
340
+ if (existingIndex === undefined) {
341
+ merged.push(gpu);
342
+ continue;
343
+ }
344
+ matchedIndexes.add(existingIndex);
345
+ const existing = merged[existingIndex];
346
+ merged[existingIndex] = {
347
+ ...existing,
348
+ usagePercent: gpu.usagePercent ?? existing.usagePercent,
349
+ memoryTotalBytes: gpu.memoryTotalBytes ?? existing.memoryTotalBytes,
350
+ memoryUsedBytes: gpu.memoryUsedBytes ?? existing.memoryUsedBytes
351
+ };
352
+ }
353
+ return merged;
354
+ }
355
+
303
356
  function normalizeDisk(value) {
304
357
  if (!value || typeof value !== 'object') return null;
305
358
  const mount = normalizeString(value.mount, 80);
@@ -430,7 +483,10 @@ async function collectHardwareSnapshot() {
430
483
 
431
484
  const nvidiaGpus = await nvidiaPromise;
432
485
  return {
433
- gpus: nvidiaGpus.length > 0 ? nvidiaGpus : platformSnapshot.gpus,
486
+ // Preserve the platform adapter inventory on hybrid-GPU computers. The
487
+ // NVIDIA sampler enriches its matching adapter instead of replacing every
488
+ // non-NVIDIA display adapter with the telemetry subset.
489
+ gpus: mergeGpuSnapshots(platformSnapshot.gpus, nvidiaGpus),
434
490
  disks: platformSnapshot.disks,
435
491
  collectedAt: new Date().toISOString()
436
492
  };
@@ -730,7 +786,11 @@ export function createClientRuntimeServer(options = {}) {
730
786
  try {
731
787
  const gpus = await collectNvidiaGpuSnapshot();
732
788
  if (gpus.length > 0) {
733
- hardwareSnapshot = { ...hardwareSnapshot, gpus, collectedAt: new Date().toISOString() };
789
+ hardwareSnapshot = {
790
+ ...hardwareSnapshot,
791
+ gpus: mergeGpuSnapshots(hardwareSnapshot.gpus, gpus),
792
+ collectedAt: new Date().toISOString()
793
+ };
734
794
  }
735
795
  } finally {
736
796
  gpuRefreshInFlight = false;
@@ -1,6 +1,7 @@
1
- import { randomBytes } from 'node:crypto';
1
+ import { createHash, randomBytes } from 'node:crypto';
2
2
  import {
3
3
  chmodSync,
4
+ linkSync,
4
5
  mkdirSync,
5
6
  readFileSync,
6
7
  renameSync,
@@ -85,6 +86,10 @@ function normalizeRecords(values) {
85
86
  return [...unique.values()];
86
87
  }
87
88
 
89
+ function sourceRevision(sourceText) {
90
+ return createHash('sha256').update(String(sourceText || ''), 'utf8').digest('hex');
91
+ }
92
+
88
93
  function writeJsonAtomic(path, value) {
89
94
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
90
95
  const temporaryPath = `${path}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
@@ -157,19 +162,16 @@ export function writeWindowsOwnedProcessManifest({
157
162
  }
158
163
  }
159
164
 
160
- /**
161
- * Reads only a fresh manifest belonging to the exact runtime-lock generation.
162
- * Owner mismatch, malformed identity, expiry, and PID reuse all fail closed.
163
- */
164
- export function readWindowsOwnedProcessManifest({
165
+ function readExactWindowsOwnedProcessManifest({
165
166
  stateDir,
166
167
  owner,
167
- maxAgeMs = DEFAULT_WINDOWS_OWNED_PROCESS_MANIFEST_MAX_AGE_MS,
168
+ maxAgeMs = null,
169
+ includeRevision = false,
168
170
  now
169
171
  } = {}) {
170
172
  const exactOwner = normalizeOwner(owner);
171
173
  const nowMs = resolveNow(now);
172
- const maximumAge = Number(maxAgeMs);
174
+ const maximumAge = maxAgeMs === null ? null : Number(maxAgeMs);
173
175
  const path = getWindowsOwnedProcessManifestPath(stateDir);
174
176
  if (!exactOwner) {
175
177
  return {
@@ -179,8 +181,8 @@ export function readWindowsOwnedProcessManifest({
179
181
  };
180
182
  }
181
183
  if (!Number.isFinite(nowMs)
182
- || !Number.isFinite(maximumAge)
183
- || maximumAge < 0) {
184
+ || (maximumAge !== null
185
+ && (!Number.isFinite(maximumAge) || maximumAge < 0))) {
184
186
  return {
185
187
  ok: false,
186
188
  records: [],
@@ -189,10 +191,17 @@ export function readWindowsOwnedProcessManifest({
189
191
  }
190
192
 
191
193
  let parsed;
194
+ let sourceText;
192
195
  try {
193
- parsed = JSON.parse(readFileSync(path, 'utf8'));
196
+ sourceText = readFileSync(path, 'utf8');
197
+ parsed = JSON.parse(sourceText);
194
198
  } catch (error) {
195
- return { ok: false, records: [], error };
199
+ return {
200
+ ok: false,
201
+ records: [],
202
+ missing: error?.code === 'ENOENT',
203
+ error
204
+ };
196
205
  }
197
206
  const updatedAt = String(parsed?.updatedAt || '');
198
207
  const updatedAtMs = Date.parse(updatedAt);
@@ -214,7 +223,7 @@ export function readWindowsOwnedProcessManifest({
214
223
  }
215
224
  if (!Number.isFinite(updatedAtMs)
216
225
  || updatedAtMs > nowMs + MAX_FUTURE_CLOCK_SKEW_MS
217
- || nowMs - updatedAtMs > maximumAge) {
226
+ || (maximumAge !== null && nowMs - updatedAtMs > maximumAge)) {
218
227
  return {
219
228
  ok: false,
220
229
  records: [],
@@ -231,5 +240,163 @@ export function readWindowsOwnedProcessManifest({
231
240
  error: resultError('Windows LiveDesk process manifest contains an invalid process identity.')
232
241
  };
233
242
  }
234
- return { ok: true, records: exactRecords, updatedAt };
243
+ return {
244
+ ok: true,
245
+ records: exactRecords,
246
+ updatedAt,
247
+ ...(includeRevision ? { revision: sourceRevision(sourceText) } : {})
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Reads only a fresh manifest belonging to the exact runtime-lock generation.
253
+ * Owner mismatch, malformed identity, expiry, and PID reuse all fail closed.
254
+ */
255
+ export function readWindowsOwnedProcessManifest({
256
+ stateDir,
257
+ owner,
258
+ maxAgeMs = DEFAULT_WINDOWS_OWNED_PROCESS_MANIFEST_MAX_AGE_MS,
259
+ now
260
+ } = {}) {
261
+ return readExactWindowsOwnedProcessManifest({
262
+ stateDir,
263
+ owner,
264
+ maxAgeMs,
265
+ now
266
+ });
267
+ }
268
+
269
+ /**
270
+ * Recovers only the exact manifest generation returned by a successful
271
+ * stale-lock compare-and-swap. Its age is intentionally unbounded because a
272
+ * machine can be restarted long after the launcher and Agent died. Malformed,
273
+ * mismatched, or future-dated manifests still fail closed.
274
+ */
275
+ export function readDeadWindowsOwnedProcessManifest({
276
+ stateDir,
277
+ owner,
278
+ now
279
+ } = {}) {
280
+ return readExactWindowsOwnedProcessManifest({
281
+ stateDir,
282
+ owner,
283
+ maxAgeMs: null,
284
+ includeRevision: true,
285
+ now
286
+ });
287
+ }
288
+
289
+ /**
290
+ * Atomically quarantines and consumes only the exact dead-owner manifest
291
+ * revision that was drained. If another generation replaces the canonical
292
+ * path between validation and rename, the moved file is restored or preserved
293
+ * instead of ever being deleted.
294
+ */
295
+ export function consumeDeadWindowsOwnedProcessManifest({
296
+ stateDir,
297
+ owner,
298
+ revision,
299
+ now
300
+ } = {}) {
301
+ const exactOwner = normalizeOwner(owner);
302
+ const expectedRevision = String(revision || '').trim().toLowerCase();
303
+ const nowMs = resolveNow(now);
304
+ const path = getWindowsOwnedProcessManifestPath(stateDir);
305
+ if (!exactOwner || !/^[a-f0-9]{64}$/.test(expectedRevision)) {
306
+ return {
307
+ ok: false,
308
+ error: resultError('An exact Windows manifest owner and revision are required for consumption.')
309
+ };
310
+ }
311
+ if (!Number.isFinite(nowMs)) {
312
+ return { ok: false, error: resultError('A valid manifest consumption time is required.') };
313
+ }
314
+
315
+ const current = readDeadWindowsOwnedProcessManifest({
316
+ stateDir,
317
+ owner: exactOwner,
318
+ now: nowMs
319
+ });
320
+ if (!current.ok) {
321
+ return current.missing === true
322
+ ? { ok: true, path, consumed: false, missing: true }
323
+ : { ...current, path, consumed: false };
324
+ }
325
+ if (current.revision !== expectedRevision) {
326
+ return {
327
+ ok: false,
328
+ path,
329
+ consumed: false,
330
+ error: resultError('Windows LiveDesk process manifest changed after its exact process set was drained.')
331
+ };
332
+ }
333
+
334
+ const quarantinePath = `${path}.consume.${process.pid}.${randomBytes(8).toString('hex')}`;
335
+ try {
336
+ renameSync(path, quarantinePath);
337
+ } catch (error) {
338
+ return { ok: false, path, quarantinePath, consumed: false, error };
339
+ }
340
+
341
+ let movedSourceText = '';
342
+ let movedParsed = null;
343
+ try {
344
+ movedSourceText = readFileSync(quarantinePath, 'utf8');
345
+ movedParsed = JSON.parse(movedSourceText);
346
+ } catch {
347
+ // The exact post-rename validation below restores or preserves this
348
+ // file; unreadable content is never evidence that deletion is safe.
349
+ }
350
+ const movedUpdatedAtMs = Date.parse(String(movedParsed?.updatedAt || ''));
351
+ const movedRecords = normalizeRecords(movedParsed?.records);
352
+ const movedIsExact = movedParsed?.protocolVersion === WINDOWS_OWNED_PROCESS_MANIFEST_VERSION
353
+ && ownersMatch(movedParsed?.owner, exactOwner)
354
+ && Number.isFinite(movedUpdatedAtMs)
355
+ && movedUpdatedAtMs <= nowMs + MAX_FUTURE_CLOCK_SKEW_MS
356
+ && movedRecords !== null
357
+ && sourceRevision(movedSourceText) === expectedRevision;
358
+ if (!movedIsExact) {
359
+ let restored = false;
360
+ let restoreError = null;
361
+ try {
362
+ // Hard-link publication is create-if-absent. Unlike rename, it
363
+ // can never overwrite a newer canonical generation that appears
364
+ // during restoration.
365
+ linkSync(quarantinePath, path);
366
+ restored = true;
367
+ try { unlinkSync(quarantinePath); } catch { /* both exact links safely preserve the file */ }
368
+ } catch (error) {
369
+ restoreError = error;
370
+ }
371
+ return {
372
+ ok: false,
373
+ path,
374
+ quarantinePath: restored ? undefined : quarantinePath,
375
+ consumed: false,
376
+ error: resultError(
377
+ 'Windows LiveDesk process manifest changed during consumption; '
378
+ + `${restored ? 'the moved generation was restored.' : 'the moved generation was preserved.'}`
379
+ + `${restoreError?.message ? ` ${restoreError.message}` : ''}`
380
+ )
381
+ };
382
+ }
383
+
384
+ try {
385
+ unlinkSync(quarantinePath);
386
+ return {
387
+ ok: true,
388
+ path,
389
+ revision: expectedRevision,
390
+ records: movedRecords,
391
+ consumed: true
392
+ };
393
+ } catch (error) {
394
+ return {
395
+ ok: false,
396
+ path,
397
+ quarantinePath,
398
+ consumed: false,
399
+ error
400
+ };
401
+ }
235
402
  }
@@ -0,0 +1,27 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import test from 'node:test';
4
+ import { formatClientVersionBanner, readClientPackageVersion } from '../bin/client-version.js';
5
+
6
+ const packageInfo = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
7
+
8
+ test('reads the version shipped in the @livedesk/client npm package', () => {
9
+ assert.equal(readClientPackageVersion(), packageInfo.version);
10
+ });
11
+
12
+ test('prints the public launcher version in the user-facing banner', () => {
13
+ assert.equal(
14
+ formatClientVersionBanner({
15
+ LIVEDESK_NPM_LAUNCHER_NAME: 'livedesk',
16
+ LIVEDESK_NPM_LAUNCHER_VERSION: '9.8.7'
17
+ }),
18
+ '[LiveDesk Client] livedesk@9.8.7'
19
+ );
20
+ });
21
+
22
+ test('uses the product name for direct client launches', () => {
23
+ assert.equal(
24
+ formatClientVersionBanner({}),
25
+ `[LiveDesk Client] livedesk@${packageInfo.version}`
26
+ );
27
+ });