@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/package.json +1 -1
- package/src/control-presentation-borrow-contract.test.mjs +999 -0
- package/src/live-stream-monitor-contract.js +195 -3
- package/src/remote-hub.js +108 -56
- package/src/server.js +183 -68
- package/src/settings/settings-schema.js +25 -6
- package/src/settings/settings-store.js +9 -8
- package/src/wall-source-restart-contract.test.mjs +48 -0
- package/src/wall-source-restart-runtime.test.mjs +146 -0
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2436
|
-
for (const [deviceId, streamId] of streams.entries()) {
|
|
2437
|
-
|
|
2438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3518
|
-
|
|
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
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
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
|
-
|
|
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
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
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', '
|
|
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('
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
});
|