@livedesk/hub 0.1.59 → 0.1.61

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/src/server.js CHANGED
@@ -30,7 +30,10 @@ import {
30
30
  observeRemoteAudioStopConfirmation,
31
31
  remoteAudioSubscriberOwns
32
32
  } from './remote-audio-subscription-contract.mjs';
33
- import { isReusedLiveStreamFrameReady } from './live-stream-monitor-contract.js';
33
+ import {
34
+ createReadOnlyControlPresentationReconcileCoordinator,
35
+ isReusedLiveStreamFrameReady
36
+ } from './live-stream-monitor-contract.js';
34
37
  import { createLiveCaptureTransitionRetryCoordinator } from './live-capture-transition-retry.mjs';
35
38
  import { buildMode4AtlasSessionKey, Mode4AtlasPool, planMode4AtlasInputTransitions } from './mode4-atlas-pool.js';
36
39
  import { resolveMode4AtlasTileSize } from './mode4-atlas-sizing.js';
@@ -309,6 +312,52 @@ function broadcastRemoteInputRouteState(deviceId, reason = '') {
309
312
  }
310
313
  }
311
314
 
315
+ function reconcileReadOnlyControlPresentationSubscribers(deviceId) {
316
+ const normalizedDeviceId = String(deviceId || '').trim();
317
+ if (!normalizedDeviceId) return;
318
+ const clients = new Set([
319
+ ...(frameClientsByDeviceId.get(normalizedDeviceId) || []),
320
+ ...frameWildcardClients
321
+ ]);
322
+ for (const ws of clients) {
323
+ const liveOptions = ws.liveDeskLiveOptions;
324
+ if (ws.readyState !== 1
325
+ || ws.liveDeskAutoStart !== true
326
+ || !(ws.liveDeskDeviceIds instanceof Set)
327
+ || !ws.liveDeskDeviceIds.has(normalizedDeviceId)
328
+ || liveOptions?.allowReadOnlyControlBorrow !== true
329
+ || String(liveOptions?.streamPurpose || '').trim().toLowerCase() !== 'wall') {
330
+ continue;
331
+ }
332
+ startFrameSubscriptionLive(ws, 'control-borrow-reconcile', normalizedDeviceId, {
333
+ ...liveOptions,
334
+ forceRestart: false,
335
+ reuseExisting: true
336
+ });
337
+ }
338
+ }
339
+
340
+ const readOnlyControlPresentationReconcileCoordinator =
341
+ createReadOnlyControlPresentationReconcileCoordinator({
342
+ onReconcile: reconcileReadOnlyControlPresentationSubscribers
343
+ });
344
+
345
+ function readRemoteLiveStreamEventPurpose(event) {
346
+ const explicitPurpose = String(event?.streamPurpose || '').trim().toLowerCase();
347
+ if (explicitPurpose) return explicitPurpose;
348
+ const commandId = String(event?.commandId || '').trim();
349
+ const activeStream = event?.device?.activeLiveStream;
350
+ if (!commandId || !activeStream) return '';
351
+ const pendingDescriptor = activeStream.pendingDescriptor;
352
+ if (String(pendingDescriptor?.commandId || '').trim() === commandId) {
353
+ return String(pendingDescriptor?.streamPurpose || '').trim().toLowerCase();
354
+ }
355
+ if (String(activeStream.commandId || '').trim() === commandId) {
356
+ return String(activeStream.streamPurpose || '').trim().toLowerCase();
357
+ }
358
+ return '';
359
+ }
360
+
312
361
  function handleRemoteHubEvent(type, event) {
313
362
  liveDeskUpdateManager?.handleRemoteEvent(type, event);
314
363
  hubTransferJobs?.handleRemoteEvent(type, event);
@@ -363,9 +412,32 @@ function handleRemoteHubEvent(type, event) {
363
412
  broadcastRemoteInputRouteState(deviceId, event?.reason || type);
364
413
  return;
365
414
  }
415
+ if (type === 'RemoteLiveStreamStarted'
416
+ || type === 'RemoteLiveStreamOpened'
417
+ || type === 'RemoteLiveStreamReady'
418
+ || type === 'RemoteLiveStreamStopped') {
419
+ const liveStreamEventDeviceId = String(event?.deviceId || event?.device?.deviceId || '').trim();
420
+ const liveStreamEventPurpose = readRemoteLiveStreamEventPurpose(event);
421
+ if ((type === 'RemoteLiveStreamStarted' || type === 'RemoteLiveStreamOpened')
422
+ && liveStreamEventPurpose === 'control') {
423
+ readOnlyControlPresentationReconcileCoordinator
424
+ .cancelForControlTransition(liveStreamEventDeviceId);
425
+ } else if (type === 'RemoteLiveStreamReady' && liveStreamEventPurpose === 'control') {
426
+ readOnlyControlPresentationReconcileCoordinator
427
+ .reconcileReadyControl(liveStreamEventDeviceId);
428
+ } else if (type === 'RemoteLiveStreamStopped'
429
+ && liveStreamEventPurpose === 'control'
430
+ && event?.captureStopConfirmed === true) {
431
+ readOnlyControlPresentationReconcileCoordinator
432
+ .scheduleAfterConfirmedStop(liveStreamEventDeviceId);
433
+ }
434
+ }
366
435
  if (type === 'RemoteDeviceConnected' || type === 'RemoteDeviceDisconnected') {
367
436
  connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
368
437
  const deviceId = String(event?.deviceId || event?.device?.deviceId || '').trim();
438
+ if (type === 'RemoteDeviceDisconnected') {
439
+ readOnlyControlPresentationReconcileCoordinator.cancelForControlTransition(deviceId);
440
+ }
369
441
  broadcastRemoteInputRouteState(deviceId, event?.reason || type);
370
442
  }
371
443
  if (type !== 'RemoteDeviceConnected') {
@@ -2212,7 +2284,7 @@ function normalizeTransferChunk(body = {}) {
2212
2284
  };
2213
2285
  }
2214
2286
 
2215
- function normalizeLiveOptions(payload = {}) {
2287
+ function normalizeLiveOptions(payload = {}) {
2216
2288
  const mode = String(payload.frameMode || payload.mode || 'mode3-h264-hw').trim() || 'mode3-h264-hw';
2217
2289
  const streamPurpose = String(payload.streamPurpose || payload.purpose || 'wall').trim().slice(0, 24) || 'wall';
2218
2290
  const maxFps = streamPurpose === 'control' ? 60 : 30;
@@ -2222,10 +2294,11 @@ function normalizeLiveOptions(payload = {}) {
2222
2294
  maxHeight: clampNumber(payload.maxHeight, 180, 2160, 360),
2223
2295
  quality: clampNumber(payload.quality, 20, 95, 45),
2224
2296
  monitorIndex: normalizeMonitorIndex(payload.monitorIndex ?? payload.screenIndex ?? payload.displayIndex),
2225
- monitorSelections: normalizeMonitorSelections(payload.monitorSelections),
2226
- forceRestart: /^(1|true|yes|on)$/i.test(String(payload.forceRestart ?? '')),
2227
- reuseExisting: /^(1|true|yes|on)$/i.test(String(payload.reuseExisting ?? '')),
2228
- streamPurpose,
2297
+ monitorSelections: normalizeMonitorSelections(payload.monitorSelections),
2298
+ forceRestart: /^(1|true|yes|on)$/i.test(String(payload.forceRestart ?? '')),
2299
+ reuseExisting: /^(1|true|yes|on)$/i.test(String(payload.reuseExisting ?? '')),
2300
+ allowReadOnlyControlBorrow: /^(1|true|yes|on)$/i.test(String(payload.allowReadOnlyControlBorrow ?? '')),
2301
+ streamPurpose,
2229
2302
  mode,
2230
2303
  frameMode: mode
2231
2304
  };
@@ -2326,15 +2399,23 @@ function unregisterFrameClient(ws) {
2326
2399
  retireFrameClientSendLane(ws);
2327
2400
  }
2328
2401
 
2329
- function hasOtherFrameStreamOwner(ws, deviceId, streamId, streamPurpose = '') {
2402
+ function hasOtherFrameStreamOwner(ws, deviceId, streamId, streamPurpose = '') {
2330
2403
  if (!streamId) {
2331
2404
  return false;
2332
2405
  }
2333
- for (const candidate of frameClients) {
2334
- if (candidate === ws || candidate.readyState !== candidate.OPEN) {
2335
- continue;
2336
- }
2337
- if (candidate.liveDeskStreamIdsByDeviceId instanceof Map
2406
+ for (const candidate of frameClients) {
2407
+ if (candidate === ws || candidate.readyState !== candidate.OPEN) {
2408
+ continue;
2409
+ }
2410
+ const expectedBinding = candidate.liveDeskExpectedStreamBindingsByDeviceId instanceof Map
2411
+ ? candidate.liveDeskExpectedStreamBindingsByDeviceId.get(deviceId)
2412
+ : null;
2413
+ if (expectedBinding?.readOnlyControlBorrow === true) {
2414
+ // A presentation observer can share immutable packets, but it must never
2415
+ // keep the native Control owner alive after the real controller leaves.
2416
+ continue;
2417
+ }
2418
+ if (candidate.liveDeskStreamIdsByDeviceId instanceof Map
2338
2419
  && candidate.liveDeskStreamIdsByDeviceId.get(deviceId) === streamId) {
2339
2420
  return true;
2340
2421
  }
@@ -2425,17 +2506,28 @@ function scheduleFrameStreamStop(deviceId, streamId, streamPurpose) {
2425
2506
  pendingFrameStreamStops.set(key, { timer });
2426
2507
  }
2427
2508
 
2428
- function stopFrameClientStreams(ws) {
2509
+ function stopFrameClientStreams(ws) {
2429
2510
  const streams = ws.liveDeskStreamIdsByDeviceId instanceof Map
2430
2511
  ? ws.liveDeskStreamIdsByDeviceId
2431
2512
  : null;
2432
2513
  if (!streams) {
2433
2514
  return;
2434
2515
  }
2435
- const streamPurpose = String(ws.liveDeskLiveOptions?.streamPurpose || 'wall');
2436
- for (const [deviceId, streamId] of streams.entries()) {
2437
- if (!streamId || hasOtherFrameStreamOwner(ws, deviceId, streamId, streamPurpose)) {
2438
- continue;
2516
+ const requestedStreamPurpose = String(ws.liveDeskLiveOptions?.streamPurpose || 'wall');
2517
+ for (const [deviceId, streamId] of streams.entries()) {
2518
+ const expectedBinding = ws.liveDeskExpectedStreamBindingsByDeviceId instanceof Map
2519
+ ? ws.liveDeskExpectedStreamBindingsByDeviceId.get(deviceId)
2520
+ : null;
2521
+ if (expectedBinding?.readOnlyControlBorrow === true) {
2522
+ // Borrowers own only browser presentation resources. The real Control
2523
+ // subscriber remains the sole native stop owner.
2524
+ continue;
2525
+ }
2526
+ const streamPurpose = expectedBinding?.streamId === streamId
2527
+ ? String(expectedBinding.streamPurpose || requestedStreamPurpose)
2528
+ : requestedStreamPurpose;
2529
+ if (!streamId || hasOtherFrameStreamOwner(ws, deviceId, streamId, streamPurpose)) {
2530
+ continue;
2439
2531
  }
2440
2532
  scheduleFrameStreamStop(deviceId, streamId, streamPurpose);
2441
2533
  }
@@ -3468,21 +3560,21 @@ function startFrameSubscriptionLive(
3468
3560
  const monitorIndex = Object.prototype.hasOwnProperty.call(liveOptions.monitorSelections || {}, deviceId)
3469
3561
  ? liveOptions.monitorSelections[deviceId]
3470
3562
  : liveOptions.monitorIndex;
3471
- const result = remoteHub.startLiveStream(deviceId, {
3472
- ...liveOptions,
3473
- monitorIndex,
3474
- reuseExisting: liveOptions.forceRestart !== true
3475
- && (liveOptions.reuseExisting === true || reason === 'subscribe' || reason === 'watchdog'),
3476
- // Wall capture is one shared native encoder per device. Two open browser
3477
- // views may ask for different soft profiles (for example mobile 5 fps at
3478
- // 1080p and desktop 20 fps at 540p). Once a healthy shared owner exists,
3479
- // those preferences must bind to that owner instead of replacing it back
3480
- // and forth every time either view refreshes its subscription.
3481
- reuseSharedExisting: liveOptions.forceRestart !== true
3482
- && String(liveOptions.streamPurpose || '').trim().toLowerCase() === 'wall'
3483
- && hasOtherFrameStreamDemand(ws, deviceId, 'wall'),
3484
- silentReuse: reason === 'watchdog' && liveOptions.forceRestart !== true
3485
- });
3563
+ const result = remoteHub.startLiveStream(deviceId, {
3564
+ ...liveOptions,
3565
+ monitorIndex,
3566
+ reuseExisting: liveOptions.forceRestart !== true
3567
+ && (liveOptions.reuseExisting === true || reason === 'subscribe' || reason === 'watchdog'),
3568
+ // Wall capture is one shared native encoder per device. Two open browser
3569
+ // views may ask for different soft profiles (for example mobile 5 fps at
3570
+ // 1080p and desktop 20 fps at 540p). Once a healthy shared owner exists,
3571
+ // those preferences must bind to that owner instead of replacing it back
3572
+ // and forth every time either view refreshes its subscription.
3573
+ reuseSharedExisting: liveOptions.forceRestart !== true
3574
+ && String(liveOptions.streamPurpose || '').trim().toLowerCase() === 'wall'
3575
+ && hasOtherFrameStreamDemand(ws, deviceId, 'wall'),
3576
+ silentReuse: reason === 'watchdog' && liveOptions.forceRestart !== true
3577
+ });
3486
3578
  if (result?.ok) {
3487
3579
  if (result.sharedProfileReused === true) {
3488
3580
  ws.liveDeskSharedProfileReuseCount = Math.max(
@@ -3491,18 +3583,25 @@ function startFrameSubscriptionLive(
3491
3583
  ) + 1;
3492
3584
  }
3493
3585
  frameCaptureTransitionRetries.complete(ws, deviceId, intentGeneration, 'capture-started');
3494
- const expectedBinding = {
3586
+ const expectedBinding = {
3495
3587
  deviceId,
3496
3588
  sessionId: String(result.sessionId || device.sessionId || ''),
3497
3589
  streamId: String(result.streamId || ''),
3498
3590
  streamPurpose: String(result.streamPurpose || liveOptions.streamPurpose || 'wall'),
3499
3591
  commandId: String(result.commandId || ''),
3500
- captureGeneration: Number(result.captureGeneration || 0),
3501
- monitorIndex: Number(result.monitorIndex || 0),
3502
- readySent: false
3592
+ captureGeneration: Number(result.captureGeneration || 0),
3593
+ monitorIndex: Number(result.monitorIndex || 0),
3594
+ readOnlyControlBorrow: result.readOnlyControlBorrow === true,
3595
+ presentationPurpose: result.presentationPurpose === 'wall' ? 'wall' : '',
3596
+ effectiveProfile: result.effectiveProfile || null,
3597
+ readySent: false
3503
3598
  };
3504
3599
  const activeStream = device?.activeLiveStream;
3505
- const reusedFrameReady = isReusedLiveStreamFrameReady(result, activeStream, expectedBinding);
3600
+ // A new read-only browser presentation has no decoder history from the
3601
+ // already-running Control stream. It must enter through the next exact
3602
+ // key frame even when the native owner itself is already ready.
3603
+ const reusedFrameReady = expectedBinding.readOnlyControlBorrow !== true
3604
+ && isReusedLiveStreamFrameReady(result, activeStream, expectedBinding);
3506
3605
  expectedBinding.readySent = reusedFrameReady;
3507
3606
  ws.liveDeskStreamIdsByDeviceId.set(deviceId, result.streamId);
3508
3607
  const installedBindingIdentity = replaceExpectedFrameBindingForClient(
@@ -3514,19 +3613,24 @@ function startFrameSubscriptionLive(
3514
3613
  skipped.push({ deviceId, reason: 'invalid-live-stream-binding' });
3515
3614
  continue;
3516
3615
  }
3517
- cancelPendingFrameStreamStop(deviceId, result.streamId, String(liveOptions.streamPurpose || 'wall'));
3518
- started.push({
3616
+ if (expectedBinding.readOnlyControlBorrow !== true) {
3617
+ cancelPendingFrameStreamStop(deviceId, result.streamId, expectedBinding.streamPurpose);
3618
+ }
3619
+ started.push({
3519
3620
  deviceId: expectedBinding.deviceId,
3520
3621
  sessionId: expectedBinding.sessionId,
3521
3622
  streamId: expectedBinding.streamId,
3522
3623
  streamPurpose: expectedBinding.streamPurpose,
3523
3624
  commandId: expectedBinding.commandId,
3524
3625
  captureGeneration: expectedBinding.captureGeneration,
3525
- monitorIndex: expectedBinding.monitorIndex,
3526
- ready: reusedFrameReady,
3527
- fps: result.fps,
3528
- reused: result.reused === true
3529
- });
3626
+ monitorIndex: expectedBinding.monitorIndex,
3627
+ readOnlyControlBorrow: expectedBinding.readOnlyControlBorrow,
3628
+ presentationPurpose: expectedBinding.presentationPurpose,
3629
+ effectiveProfile: expectedBinding.effectiveProfile,
3630
+ ready: reusedFrameReady,
3631
+ fps: result.fps,
3632
+ reused: result.reused === true
3633
+ });
3530
3634
  } else {
3531
3635
  ws.liveDeskStreamIdsByDeviceId.delete(deviceId);
3532
3636
  replaceExpectedFrameBindingForClient(ws, deviceId, null);
@@ -3598,9 +3702,10 @@ function startFrameSubscriptionLive(
3598
3702
  }
3599
3703
  }
3600
3704
  }
3601
- if (reason === 'watchdog' && !started.some(item => item.reused !== true)) {
3602
- return;
3603
- }
3705
+ if ((reason === 'watchdog' && !started.some(item => item.reused !== true))
3706
+ || (reason === 'control-borrow-reconcile' && started.length === 0)) {
3707
+ return;
3708
+ }
3604
3709
  sendJson(ws, {
3605
3710
  type: 'RemoteFrameLiveAutoStart',
3606
3711
  timestamp: new Date().toISOString(),
@@ -3877,21 +3982,30 @@ function broadcastRemoteBinaryFrame(frameEvent) {
3877
3982
  continue;
3878
3983
  }
3879
3984
  const requiresReadyKeyFrame = String(expectedBinding.streamPurpose || '').toLowerCase() === 'control' && isH264;
3880
- if (!expectedBinding.readySent && (!requiresReadyKeyFrame || isKeyFrame)) {
3881
- expectedBinding.readySent = sendJson(client, {
3882
- type: 'RemoteFrameStreamReady',
3883
- deviceId,
3884
- sessionId: expectedBinding.sessionId,
3885
- streamId: expectedBinding.streamId,
3886
- streamPurpose: expectedBinding.streamPurpose,
3887
- commandId: expectedBinding.commandId,
3888
- captureGeneration: expectedBinding.captureGeneration,
3889
- monitorIndex: expectedBinding.monitorIndex
3890
- });
3891
- if (!expectedBinding.readySent) {
3892
- continue;
3893
- }
3894
- }
3985
+ if (!expectedBinding.readySent) {
3986
+ // A fresh H.264 decoder cannot consume dependent Control deltas. Keep
3987
+ // the browser lane closed until the first exact key frame; otherwise a
3988
+ // newly attached PWA can remain on "Preparing video" until a later IDR.
3989
+ if (requiresReadyKeyFrame && !isKeyFrame) {
3990
+ continue;
3991
+ }
3992
+ expectedBinding.readySent = sendJson(client, {
3993
+ type: 'RemoteFrameStreamReady',
3994
+ deviceId,
3995
+ sessionId: expectedBinding.sessionId,
3996
+ streamId: expectedBinding.streamId,
3997
+ streamPurpose: expectedBinding.streamPurpose,
3998
+ commandId: expectedBinding.commandId,
3999
+ captureGeneration: expectedBinding.captureGeneration,
4000
+ monitorIndex: expectedBinding.monitorIndex,
4001
+ readOnlyControlBorrow: expectedBinding.readOnlyControlBorrow === true,
4002
+ presentationPurpose: expectedBinding.presentationPurpose || '',
4003
+ effectiveProfile: expectedBinding.effectiveProfile || null
4004
+ });
4005
+ if (!expectedBinding.readySent) {
4006
+ continue;
4007
+ }
4008
+ }
3895
4009
  const lane = ensureFrameClientSendLane(client);
3896
4010
  if (client.liveDeskFrameBackpressured
3897
4011
  && lane.backpressuredDeviceIds.has(deviceId)
@@ -4728,9 +4842,9 @@ app.post('/api/settings/agent/run', async (req, res) => {
4728
4842
  return;
4729
4843
  }
4730
4844
  try {
4731
- if (!(await synchronizeAgentEnablement())) {
4732
- throw new AgentRuntimeError('agent-disabled', 'Enable Codex Agent in Settings before running commands.', { status: 409 });
4733
- }
4845
+ if (!(await synchronizeAgentEnablement())) {
4846
+ throw new AgentRuntimeError('agent-disabled', 'Codex Agent was explicitly turned off in Settings.', { status: 409 });
4847
+ }
4734
4848
  const instruction = typeof req.body?.instruction === 'string' ? req.body.instruction.slice(0, 4000) : '';
4735
4849
  const deviceIds = normalizeDeviceIds(req.body?.deviceIds).slice(0, 500);
4736
4850
  const connectedDeviceIds = new Set(connectedAgentDeviceIds());
@@ -6916,9 +7030,10 @@ function shutdownHub(signal) {
6916
7030
  hubShutdownPromise = (async () => {
6917
7031
  const startedAt = Date.now();
6918
7032
  console.log(`[VuvoDesk Hub] Shutdown started signal=${signal} grace=${hubShutdownGraceMs}ms timeout=${hubShutdownTimeoutMs}ms.`);
6919
- if (roleWatchTimer) clearInterval(roleWatchTimer);
6920
- clearInterval(browserWebSocketHeartbeatTimer);
6921
- runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
7033
+ if (roleWatchTimer) clearInterval(roleWatchTimer);
7034
+ clearInterval(browserWebSocketHeartbeatTimer);
7035
+ runSynchronousShutdownStep('control presentation reconcile close', () => readOnlyControlPresentationReconcileCoordinator.close());
7036
+ runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
6922
7037
  runSynchronousShutdownStep('mobile console direct close', () => hubConsoleDirect?.close());
6923
7038
  atlasClients.clear();
6924
7039
  runSynchronousShutdownStep('shared folder close', () => hubSharedFolders.close());
@@ -1,7 +1,7 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
3
 
4
- export const SETTINGS_SCHEMA_VERSION = 1;
4
+ export const SETTINGS_SCHEMA_VERSION = 2;
5
5
 
6
6
  export const DEFAULT_LIVEDESK_SETTINGS = Object.freeze({
7
7
  settingsSchemaVersion: SETTINGS_SCHEMA_VERSION,
@@ -80,9 +80,9 @@ export const DEFAULT_LIVEDESK_SETTINGS = Object.freeze({
80
80
  includeRemoteCursor: true,
81
81
  captureSaveLocation: 'VuvoDesk Captures',
82
82
  captureAutoDelete: 'never'
83
- },
84
- agent: {
85
- enabled: false,
83
+ },
84
+ agent: {
85
+ enabled: true,
86
86
  defaultPermissionMode: 'safe-auto',
87
87
  askBeforeDestructive: true,
88
88
  allowProcessManagement: true,
@@ -156,8 +156,27 @@ function normalizeSection(source, defaults, rules = {}) {
156
156
  return result;
157
157
  }
158
158
 
159
- const bools = keys => Object.fromEntries(keys.map(key => [key, { type: 'boolean' }]));
160
- const numbers = (entries) => Object.fromEntries(entries.map(([key, min, max]) => [key, { type: 'number', min, max }]));
159
+ const bools = keys => Object.fromEntries(keys.map(key => [key, { type: 'boolean' }]));
160
+ const numbers = (entries) => Object.fromEntries(entries.map(([key, min, max]) => [key, { type: 'number', min, max }]));
161
+
162
+ export function migrateLiveDeskSettings(value = {}) {
163
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
164
+ const storedVersion = Math.max(0, Math.trunc(Number(source.settingsSchemaVersion) || 0));
165
+ if (storedVersion >= SETTINGS_SCHEMA_VERSION) return source;
166
+ const agent = source.agent && typeof source.agent === 'object' && !Array.isArray(source.agent)
167
+ ? source.agent
168
+ : {};
169
+ return {
170
+ ...source,
171
+ settingsSchemaVersion: SETTINGS_SCHEMA_VERSION,
172
+ agent: {
173
+ ...agent,
174
+ // Schema 1 shipped disabled-by-default. Schema 2 treats that old value
175
+ // as the retired default; a later explicit opt-out is stored as schema 2.
176
+ enabled: true
177
+ }
178
+ };
179
+ }
161
180
 
162
181
  const RULES = {
163
182
  connection: {
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { DEFAULT_LIVEDESK_SETTINGS, defaultSettingsPath, normalizeLiveDeskSettings, publicSettings } from './settings-schema.js';
3
+ import { DEFAULT_LIVEDESK_SETTINGS, defaultSettingsPath, migrateLiveDeskSettings, normalizeLiveDeskSettings, publicSettings } from './settings-schema.js';
4
4
 
5
5
  export class SettingsConflictError extends Error {
6
6
  constructor(settings) {
@@ -27,13 +27,14 @@ export class LiveDeskSettingsStore {
27
27
 
28
28
  async getRecord() {
29
29
  if (this.record) return structuredClone(this.record);
30
- try {
31
- const raw = JSON.parse(await readFile(this.filePath, 'utf8'));
32
- this.record = {
33
- revision: Math.max(0, Number(raw?.revision) || 0),
34
- updatedAt: String(raw?.updatedAt || ''),
35
- settings: normalizeLiveDeskSettings(raw?.settings || raw)
36
- };
30
+ try {
31
+ const raw = JSON.parse(await readFile(this.filePath, 'utf8'));
32
+ const migratedSettings = migrateLiveDeskSettings(raw?.settings || raw);
33
+ this.record = {
34
+ revision: Math.max(0, Number(raw?.revision) || 0),
35
+ updatedAt: String(raw?.updatedAt || ''),
36
+ settings: normalizeLiveDeskSettings(migratedSettings)
37
+ };
37
38
  } catch {
38
39
  this.record = { revision: 0, updatedAt: '', settings: normalizeLiveDeskSettings(DEFAULT_LIVEDESK_SETTINGS) };
39
40
  }
@@ -0,0 +1,48 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFile } from 'node:fs/promises';
3
+ import test from 'node:test';
4
+
5
+ const [remoteHubSource, serverSource] = await Promise.all([
6
+ readFile(new URL('./remote-hub.js', import.meta.url), 'utf8'),
7
+ readFile(new URL('./server.js', import.meta.url), 'utf8')
8
+ ]);
9
+
10
+ function sourceSlice(source, startMarker, endMarker) {
11
+ const start = source.indexOf(startMarker);
12
+ const end = source.indexOf(endMarker, start + startMarker.length);
13
+ assert.ok(start >= 0 && end > start, `missing source slice: ${startMarker}`);
14
+ return source.slice(start, end);
15
+ }
16
+
17
+ test('a recovered frame subscription asks the Hub to reuse its healthy source', () => {
18
+ const subscriptionStart = sourceSlice(
19
+ serverSource,
20
+ 'function startFrameSubscriptionLive(',
21
+ 'function restartFrameSubscriptionLive('
22
+ );
23
+ assert.match(
24
+ subscriptionStart,
25
+ /reuseExisting: liveOptions\.forceRestart !== true[\s\S]{0,180}reason === 'subscribe' \|\| reason === 'watchdog'/
26
+ );
27
+ });
28
+
29
+ test('the atomic Hub start decision reuses only a matching live source', () => {
30
+ const liveStart = sourceSlice(
31
+ remoteHubSource,
32
+ 'function startLiveStream(deviceId, options = {})',
33
+ 'function stopLiveStream('
34
+ );
35
+ assert.match(
36
+ liveStart,
37
+ /options\.reuseExisting === true[\s\S]{0,160}liveStreamMatchesOptions\(activeLiveStream, normalized\)[\s\S]{0,160}liveStreamIsReusable\(activeLiveStream\)[\s\S]{0,900}reused: true/
38
+ );
39
+ assert.match(
40
+ liveStart,
41
+ /!liveStreamIsReusable\(activeLiveStream\)[\s\S]{0,900}RemoteLiveStreamStaleRestart[\s\S]{0,900}const captureGeneration = nextCaptureGeneration\(device\)/
42
+ );
43
+ });
44
+
45
+ test('Wall lane recovery has no special native-restart wire option', () => {
46
+ assert.doesNotMatch(serverSource, /restartIfSourceStale|sourceFreshRestartSuppressed/);
47
+ assert.doesNotMatch(remoteHubSource, /restartIfSourceStale|sourceFreshRestartSuppressed/);
48
+ });
@@ -0,0 +1,146 @@
1
+ import assert from 'node:assert/strict';
2
+ import net from 'node:net';
3
+ import { once } from 'node:events';
4
+ import test from 'node:test';
5
+ import { createRemoteHub } from './remote-hub.js';
6
+
7
+ test('a new Wall frame lane reuses a healthy exact capture', async () => {
8
+ const hub = createRemoteHub({
9
+ env: {
10
+ ...process.env,
11
+ LIVEDESK_REMOTE_HUB: '1',
12
+ REMOTE_HUB_HOST: '127.0.0.1',
13
+ REMOTE_HUB_PORT: '0'
14
+ },
15
+ pairToken: 'wall-source-reuse-token'
16
+ });
17
+ let socket;
18
+ try {
19
+ const status = await hub.start();
20
+ socket = net.createConnection({ host: '127.0.0.1', port: status.port });
21
+ await once(socket, 'connect');
22
+
23
+ const messages = [];
24
+ let buffer = '';
25
+ socket.on('data', chunk => {
26
+ buffer += chunk.toString('utf8');
27
+ let newline = buffer.indexOf('\n');
28
+ while (newline >= 0) {
29
+ const line = buffer.slice(0, newline).trim();
30
+ buffer = buffer.slice(newline + 1);
31
+ if (line) messages.push(JSON.parse(line));
32
+ newline = buffer.indexOf('\n');
33
+ }
34
+ });
35
+ const waitUntil = async predicate => {
36
+ const deadline = Date.now() + 2_000;
37
+ while (Date.now() < deadline) {
38
+ if (predicate()) return;
39
+ await new Promise(resolve => setTimeout(resolve, 10));
40
+ }
41
+ throw new Error('timed out waiting for Wall source reuse fixture');
42
+ };
43
+ const send = message => socket.write(`${JSON.stringify(message)}\n`);
44
+
45
+ send({
46
+ type: 'hello',
47
+ pairToken: 'wall-source-reuse-token',
48
+ deviceId: 'wall-source-device',
49
+ deviceName: 'Wall source reuse fixture',
50
+ hostname: 'wall-source-reuse',
51
+ platform: 'darwin',
52
+ arch: 'arm64',
53
+ protocol: 'mindexec.remote.agent',
54
+ protocolVersion: 2,
55
+ capabilities: { liveStream: true, frameProtocol: {}, frameModes: [] }
56
+ });
57
+ await waitUntil(() => messages.some(message => message.type === 'welcome'));
58
+
59
+ const streamId = 'wall-wall-source-device';
60
+ const options = {
61
+ streamId,
62
+ streamPurpose: 'wall',
63
+ mode: 'mode3-h264-hw',
64
+ frameMode: 'mode3-h264-hw',
65
+ fps: 30,
66
+ maxWidth: 960,
67
+ maxHeight: 540,
68
+ quality: 68,
69
+ monitorIndex: 0
70
+ };
71
+ const first = hub.startLiveStream('wall-source-device', {
72
+ ...options,
73
+ commandId: 'wall-command-1',
74
+ forceRestart: true,
75
+ restartToken: 'initial-wall-start'
76
+ });
77
+ assert.equal(first.ok, true);
78
+ await waitUntil(() => messages.some(message => (
79
+ message.type === 'command'
80
+ && message.command === 'stream.start'
81
+ && message.commandId === 'wall-command-1'
82
+ )));
83
+
84
+ send({
85
+ type: 'stream.open',
86
+ streamId,
87
+ commandId: 'wall-command-1',
88
+ captureGeneration: first.captureGeneration,
89
+ monitorIndex: 0,
90
+ streamPurpose: 'wall',
91
+ mode: 'mode3-h264-hw',
92
+ frameMode: 'mode3-h264-hw',
93
+ width: 960,
94
+ height: 540
95
+ });
96
+ send({
97
+ type: 'stream.frame',
98
+ streamId,
99
+ commandId: 'wall-command-1',
100
+ captureGeneration: first.captureGeneration,
101
+ monitorIndex: 0,
102
+ streamPurpose: 'wall',
103
+ frameSeq: 1,
104
+ frameMode: 'mode3-h264-hw',
105
+ mimeType: 'video/h264',
106
+ isKeyFrame: true,
107
+ chunkType: 'key',
108
+ width: 960,
109
+ height: 540,
110
+ data: Buffer.from([
111
+ 0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1f,
112
+ 0x00, 0x00, 0x00, 0x01, 0x68, 0xce, 0x06, 0xe2,
113
+ 0x00, 0x00, 0x00, 0x01, 0x65, 0x88, 0x84
114
+ ]).toString('base64')
115
+ });
116
+ await waitUntil(() => hub.getDeviceLiveFrame('wall-source-device')?.currentGenerationVerified === true);
117
+
118
+ const commandCountBeforeReuse = messages.filter(message => message.command === 'stream.start').length;
119
+ const reused = hub.startLiveStream('wall-source-device', {
120
+ ...options,
121
+ reuseExisting: true
122
+ });
123
+ assert.equal(reused.ok, true);
124
+ assert.equal(reused.reused, true);
125
+ assert.equal(reused.commandId, first.commandId);
126
+ assert.equal(reused.captureGeneration, first.captureGeneration);
127
+ await new Promise(resolve => setTimeout(resolve, 20));
128
+ assert.equal(
129
+ messages.filter(message => message.command === 'stream.start').length,
130
+ commandCountBeforeReuse,
131
+ 'a replacement browser lane must not restart a healthy shared capture'
132
+ );
133
+
134
+ const hardRestart = hub.startLiveStream('wall-source-device', {
135
+ ...options,
136
+ commandId: 'wall-command-hard-restart',
137
+ forceRestart: true,
138
+ restartToken: 'explicit-owner-restart'
139
+ });
140
+ assert.equal(hardRestart.ok, true);
141
+ assert.ok(hardRestart.captureGeneration > first.captureGeneration);
142
+ } finally {
143
+ socket?.destroy();
144
+ await hub.close();
145
+ }
146
+ });