@livedesk/hub 0.1.66 → 0.1.68
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 +3 -3
- package/src/captures/capture-store.js +2 -2
- package/src/console-direct.js +60 -5
- package/src/console-direct.test.mjs +95 -1
- package/src/remote-hub.js +320 -97
- package/src/server.js +272 -30
- package/src/shared-wall-profile-contract.mjs +69 -0
- package/src/shared-wall-profile-contract.test.mjs +99 -0
- package/src/{live-desk-update.js → vuvodesk-update.js} +1 -1
- package/src/wall-source-restart-runtime.test.mjs +10 -0
package/src/server.js
CHANGED
|
@@ -35,6 +35,11 @@ import {
|
|
|
35
35
|
isReusedLiveStreamFrameReady
|
|
36
36
|
} from './live-stream-monitor-contract.js';
|
|
37
37
|
import { createLiveCaptureTransitionRetryCoordinator } from './live-capture-transition-retry.mjs';
|
|
38
|
+
import {
|
|
39
|
+
selectSharedWallSourceProfile,
|
|
40
|
+
shouldRetainWallOwnerUntilPendingStartCommits,
|
|
41
|
+
wallBindingMatchesFailedOwner
|
|
42
|
+
} from './shared-wall-profile-contract.mjs';
|
|
38
43
|
import {
|
|
39
44
|
createPlanDeviceAccessSnapshot,
|
|
40
45
|
partitionPlanDeviceIds,
|
|
@@ -61,7 +66,7 @@ import { LiveDeskSettingsStore, SettingsConflictError } from './settings/setting
|
|
|
61
66
|
import { effectiveDevicePolicy } from './settings/settings-schema.js';
|
|
62
67
|
import { buildEffectiveDevicePolicy } from './settings/effective-device-policy.js';
|
|
63
68
|
import { CaptureStore } from './captures/capture-store.js';
|
|
64
|
-
import {
|
|
69
|
+
import { createVuvoDeskUpdateManager } from './vuvodesk-update.js';
|
|
65
70
|
import { createHubUdpTransport } from './transport/udp-hub-transport.js';
|
|
66
71
|
import { normalizeRuntimeAuthSession, runtimeRoleError } from '../../runtime-core/src/index.js';
|
|
67
72
|
import { fetchAuthResponse, AUTH_REQUEST_TIMEOUT_MS } from '../../runtime-core/src/auth-http.js';
|
|
@@ -435,7 +440,25 @@ function handleRemoteHubEvent(type, event) {
|
|
|
435
440
|
|| type === 'RemoteLiveStreamStopped') {
|
|
436
441
|
const liveStreamEventDeviceId = String(event?.deviceId || event?.device?.deviceId || '').trim();
|
|
437
442
|
const liveStreamEventPurpose = readRemoteLiveStreamEventPurpose(event);
|
|
438
|
-
if (
|
|
443
|
+
if (type === 'RemoteLiveStreamOpened' && liveStreamEventPurpose === 'wall') {
|
|
444
|
+
const committedOwner = event?.device?.activeLiveStream;
|
|
445
|
+
if (String(committedOwner?.commandId || '').trim() === String(event?.commandId || '').trim()
|
|
446
|
+
&& Number(committedOwner?.captureGeneration || 0) === Number(event?.captureGeneration || 0)) {
|
|
447
|
+
rebindSharedWallSubscribersToOwner(liveStreamEventDeviceId, {
|
|
448
|
+
...committedOwner,
|
|
449
|
+
sessionId: event?.device?.sessionId || ''
|
|
450
|
+
}, {
|
|
451
|
+
effectiveProfile: {
|
|
452
|
+
fps: Number(committedOwner?.fps || 0),
|
|
453
|
+
maxWidth: Number(committedOwner?.maxWidth || 0),
|
|
454
|
+
maxHeight: Number(committedOwner?.maxHeight || 0),
|
|
455
|
+
quality: Number(committedOwner?.quality || 0)
|
|
456
|
+
},
|
|
457
|
+
readySent: false,
|
|
458
|
+
reason: 'shared-wall-profile-upgrade-committed'
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
} else if ((type === 'RemoteLiveStreamStarted' || type === 'RemoteLiveStreamOpened')
|
|
439
462
|
&& liveStreamEventPurpose === 'control') {
|
|
440
463
|
readOnlyControlPresentationReconcileCoordinator
|
|
441
464
|
.cancelForControlTransition(liveStreamEventDeviceId);
|
|
@@ -449,6 +472,37 @@ function handleRemoteHubEvent(type, event) {
|
|
|
449
472
|
.scheduleAfterConfirmedStop(liveStreamEventDeviceId);
|
|
450
473
|
}
|
|
451
474
|
}
|
|
475
|
+
if (type === 'RemoteLiveStreamRestartFailed') {
|
|
476
|
+
const failedDeviceId = String(event?.deviceId || event?.device?.deviceId || '').trim();
|
|
477
|
+
const retainedOwner = event?.device?.activeLiveStream;
|
|
478
|
+
if (String(retainedOwner?.streamPurpose || '').trim().toLowerCase() === 'wall') {
|
|
479
|
+
const rebound = rebindSharedWallSubscribersToOwner(failedDeviceId, {
|
|
480
|
+
...retainedOwner,
|
|
481
|
+
sessionId: event?.device?.sessionId || ''
|
|
482
|
+
}, {
|
|
483
|
+
effectiveProfile: {
|
|
484
|
+
fps: Number(retainedOwner?.fps || 0),
|
|
485
|
+
maxWidth: Number(retainedOwner?.maxWidth || 0),
|
|
486
|
+
maxHeight: Number(retainedOwner?.maxHeight || 0),
|
|
487
|
+
quality: Number(retainedOwner?.quality || 0)
|
|
488
|
+
},
|
|
489
|
+
readySent: true,
|
|
490
|
+
replaceOnlyOwner: {
|
|
491
|
+
commandId: event?.commandId || '',
|
|
492
|
+
captureGeneration: Number(event?.captureGeneration || 0)
|
|
493
|
+
},
|
|
494
|
+
reason: 'shared-wall-profile-upgrade-failed'
|
|
495
|
+
});
|
|
496
|
+
console.warn(
|
|
497
|
+
`[VuvoDesk Hub] Wall replacement failed device=${failedDeviceId} `
|
|
498
|
+
+ `attemptedCommand=${String(event?.commandId || '').slice(0, 8) || '-'} `
|
|
499
|
+
+ `attemptedGeneration=${Number(event?.captureGeneration || 0)} `
|
|
500
|
+
+ `retainedCommand=${String(event?.retainedCommandId || retainedOwner?.commandId || '').slice(0, 8) || '-'} `
|
|
501
|
+
+ `retainedGeneration=${Number(event?.retainedCaptureGeneration || retainedOwner?.captureGeneration || 0)} `
|
|
502
|
+
+ `rebound=${rebound} error=${String(event?.error || 'stream-start-failed').slice(0, 160)}`
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
452
506
|
if (type === 'RemoteDeviceConnected' || type === 'RemoteDeviceDisconnected') {
|
|
453
507
|
connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
|
|
454
508
|
refreshPlanDeviceAccess(type === 'RemoteDeviceConnected' ? 'device-connected' : 'device-disconnected');
|
|
@@ -1256,7 +1310,7 @@ function getLiveDeskUpdateStatus() {
|
|
|
1256
1310
|
}
|
|
1257
1311
|
|
|
1258
1312
|
const electronDesktopOwnsSelfUpdate = process.env.LIVEDESK_DESKTOP_HOST === '1';
|
|
1259
|
-
liveDeskUpdateManager =
|
|
1313
|
+
liveDeskUpdateManager = createVuvoDeskUpdateManager({
|
|
1260
1314
|
remoteHub,
|
|
1261
1315
|
currentManagerVersion: process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version,
|
|
1262
1316
|
currentClientVersion: process.env.LIVEDESK_CLIENT_PACKAGE_VERSION || '',
|
|
@@ -2591,7 +2645,7 @@ function hasOtherFrameStreamOwner(ws, deviceId, streamId, streamPurpose = '') {
|
|
|
2591
2645
|
return false;
|
|
2592
2646
|
}
|
|
2593
2647
|
|
|
2594
|
-
function hasOtherFrameStreamDemand(ws, deviceId, streamPurpose = '') {
|
|
2648
|
+
function hasOtherFrameStreamDemand(ws, deviceId, streamPurpose = '') {
|
|
2595
2649
|
const normalizedPurpose = String(streamPurpose || '').trim().toLowerCase();
|
|
2596
2650
|
if (!deviceId || !normalizedPurpose) {
|
|
2597
2651
|
return false;
|
|
@@ -2608,8 +2662,137 @@ function hasOtherFrameStreamDemand(ws, deviceId, streamPurpose = '') {
|
|
|
2608
2662
|
return true;
|
|
2609
2663
|
}
|
|
2610
2664
|
}
|
|
2611
|
-
return false;
|
|
2612
|
-
}
|
|
2665
|
+
return false;
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
function frameClientMonitorIndex(ws, deviceId, liveOptions = ws?.liveDeskLiveOptions || {}) {
|
|
2669
|
+
return Object.prototype.hasOwnProperty.call(liveOptions.monitorSelections || {}, deviceId)
|
|
2670
|
+
? normalizeMonitorIndex(liveOptions.monitorSelections[deviceId])
|
|
2671
|
+
: normalizeMonitorIndex(liveOptions.monitorIndex);
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
function resolveSharedWallSourceDemand(ws, deviceId, liveOptions, monitorIndex) {
|
|
2675
|
+
const requestedMode = String(liveOptions?.frameMode || liveOptions?.mode || '').trim().toLowerCase();
|
|
2676
|
+
const requestedPurpose = String(liveOptions?.streamPurpose || '').trim().toLowerCase();
|
|
2677
|
+
const requestedMonitorIndex = normalizeMonitorIndex(monitorIndex);
|
|
2678
|
+
const demands = [{
|
|
2679
|
+
fps: liveOptions?.fps,
|
|
2680
|
+
maxWidth: liveOptions?.maxWidth,
|
|
2681
|
+
maxHeight: liveOptions?.maxHeight,
|
|
2682
|
+
quality: liveOptions?.quality
|
|
2683
|
+
}];
|
|
2684
|
+
if (requestedPurpose === 'wall') {
|
|
2685
|
+
for (const candidate of frameClients) {
|
|
2686
|
+
const candidateOptions = candidate?.liveDeskLiveOptions;
|
|
2687
|
+
if (candidate === ws
|
|
2688
|
+
|| candidate?.readyState !== candidate?.OPEN
|
|
2689
|
+
|| candidate?.liveDeskAutoStart !== true
|
|
2690
|
+
|| !(candidate.liveDeskDeviceIds instanceof Set)
|
|
2691
|
+
|| !candidate.liveDeskDeviceIds.has(deviceId)
|
|
2692
|
+
|| String(candidateOptions?.streamPurpose || '').trim().toLowerCase() !== 'wall'
|
|
2693
|
+
|| String(candidateOptions?.frameMode || candidateOptions?.mode || '').trim().toLowerCase()
|
|
2694
|
+
!== requestedMode
|
|
2695
|
+
|| frameClientMonitorIndex(candidate, deviceId, candidateOptions) !== requestedMonitorIndex) {
|
|
2696
|
+
continue;
|
|
2697
|
+
}
|
|
2698
|
+
demands.push({
|
|
2699
|
+
fps: candidateOptions.fps,
|
|
2700
|
+
maxWidth: candidateOptions.maxWidth,
|
|
2701
|
+
maxHeight: candidateOptions.maxHeight,
|
|
2702
|
+
quality: candidateOptions.quality
|
|
2703
|
+
});
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
return {
|
|
2707
|
+
...selectSharedWallSourceProfile(demands),
|
|
2708
|
+
// Keep the strongest healthy generation until the final Wall viewer
|
|
2709
|
+
// releases it. This prevents a lone late low-rate refresh from undoing a
|
|
2710
|
+
// 30 fps upgrade and makes start order irrelevant.
|
|
2711
|
+
preserveDominantProfile: requestedPurpose === 'wall'
|
|
2712
|
+
};
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
function rebindSharedWallSubscribersToOwner(deviceId, owner, {
|
|
2716
|
+
effectiveProfile = null,
|
|
2717
|
+
readySent = false,
|
|
2718
|
+
replaceOnlyOwner = null,
|
|
2719
|
+
reason = 'shared-wall-profile-owner-changed'
|
|
2720
|
+
} = {}) {
|
|
2721
|
+
const normalizedDeviceId = String(deviceId || '').trim();
|
|
2722
|
+
const sessionId = String(owner?.sessionId || owner?.device?.sessionId || '').trim();
|
|
2723
|
+
const streamId = String(owner?.streamId || '').trim();
|
|
2724
|
+
const commandId = String(owner?.commandId || '').trim();
|
|
2725
|
+
const captureGeneration = Number(owner?.captureGeneration || 0);
|
|
2726
|
+
const monitorIndex = normalizeMonitorIndex(owner?.monitorIndex);
|
|
2727
|
+
const frameMode = String(owner?.frameMode || owner?.mode || '').trim().toLowerCase();
|
|
2728
|
+
const streamPurpose = String(owner?.streamPurpose || '').trim().toLowerCase();
|
|
2729
|
+
if (!normalizedDeviceId
|
|
2730
|
+
|| !sessionId
|
|
2731
|
+
|| !streamId
|
|
2732
|
+
|| !commandId
|
|
2733
|
+
|| !Number.isSafeInteger(captureGeneration)
|
|
2734
|
+
|| captureGeneration <= 0
|
|
2735
|
+
|| streamPurpose !== 'wall') {
|
|
2736
|
+
return 0;
|
|
2737
|
+
}
|
|
2738
|
+
let rebound = 0;
|
|
2739
|
+
const candidates = new Set([
|
|
2740
|
+
...(frameClientsByDeviceId.get(normalizedDeviceId) || []),
|
|
2741
|
+
...frameWildcardClients
|
|
2742
|
+
]);
|
|
2743
|
+
for (const candidate of candidates) {
|
|
2744
|
+
const candidateOptions = candidate?.liveDeskLiveOptions;
|
|
2745
|
+
if (candidate?.readyState !== candidate?.OPEN
|
|
2746
|
+
|| candidate?.liveDeskAutoStart !== true
|
|
2747
|
+
|| !(candidate.liveDeskDeviceIds instanceof Set)
|
|
2748
|
+
|| !candidate.liveDeskDeviceIds.has(normalizedDeviceId)
|
|
2749
|
+
|| String(candidateOptions?.streamPurpose || '').trim().toLowerCase() !== 'wall'
|
|
2750
|
+
|| String(candidateOptions?.frameMode || candidateOptions?.mode || '').trim().toLowerCase()
|
|
2751
|
+
!== frameMode
|
|
2752
|
+
|| frameClientMonitorIndex(candidate, normalizedDeviceId, candidateOptions) !== monitorIndex) {
|
|
2753
|
+
continue;
|
|
2754
|
+
}
|
|
2755
|
+
const previousBinding = candidate.liveDeskExpectedStreamBindingsByDeviceId instanceof Map
|
|
2756
|
+
? candidate.liveDeskExpectedStreamBindingsByDeviceId.get(normalizedDeviceId)
|
|
2757
|
+
: null;
|
|
2758
|
+
if (!previousBinding || previousBinding.readOnlyControlBorrow === true) continue;
|
|
2759
|
+
if (replaceOnlyOwner && !wallBindingMatchesFailedOwner(previousBinding, replaceOnlyOwner)) continue;
|
|
2760
|
+
const nextBinding = {
|
|
2761
|
+
deviceId: normalizedDeviceId,
|
|
2762
|
+
sessionId,
|
|
2763
|
+
streamId,
|
|
2764
|
+
streamPurpose: 'wall',
|
|
2765
|
+
commandId,
|
|
2766
|
+
captureGeneration,
|
|
2767
|
+
monitorIndex,
|
|
2768
|
+
readOnlyControlBorrow: false,
|
|
2769
|
+
presentationPurpose: '',
|
|
2770
|
+
effectiveProfile,
|
|
2771
|
+
readySent: readySent === true
|
|
2772
|
+
};
|
|
2773
|
+
const previousIdentity = frameLaneBindingIdentity(previousBinding);
|
|
2774
|
+
const nextIdentity = replaceExpectedFrameBindingForClient(
|
|
2775
|
+
candidate,
|
|
2776
|
+
normalizedDeviceId,
|
|
2777
|
+
nextBinding
|
|
2778
|
+
);
|
|
2779
|
+
if (!nextIdentity || nextIdentity === previousIdentity) continue;
|
|
2780
|
+
candidate.liveDeskStreamIdsByDeviceId?.set?.(normalizedDeviceId, streamId);
|
|
2781
|
+
candidate.liveDeskSharedProfileUpgradeCount = Math.max(
|
|
2782
|
+
0,
|
|
2783
|
+
Number(candidate.liveDeskSharedProfileUpgradeCount || 0)
|
|
2784
|
+
) + 1;
|
|
2785
|
+
rebound += 1;
|
|
2786
|
+
}
|
|
2787
|
+
if (rebound > 0) {
|
|
2788
|
+
console.log(
|
|
2789
|
+
`[VuvoDesk Hub] rebound ${rebound} Wall browser lane(s) device=${normalizedDeviceId} `
|
|
2790
|
+
+ `generation=${captureGeneration} profile=${Number(effectiveProfile?.fps || owner?.fps || 0)}fps `
|
|
2791
|
+
+ `reason=${String(reason || 'shared-wall-profile-owner-changed')}`
|
|
2792
|
+
);
|
|
2793
|
+
}
|
|
2794
|
+
return rebound;
|
|
2795
|
+
}
|
|
2613
2796
|
|
|
2614
2797
|
function frameStreamStopKey(deviceId, streamId, streamPurpose) {
|
|
2615
2798
|
return `${deviceId}\u0000${streamId}\u0000${streamPurpose}`;
|
|
@@ -3004,8 +3187,9 @@ function snapshotFrameLaneResourceHealth() {
|
|
|
3004
3187
|
maxWidth: Math.max(0, Number(ws.liveDeskLiveOptions.maxWidth || 0)),
|
|
3005
3188
|
maxHeight: Math.max(0, Number(ws.liveDeskLiveOptions.maxHeight || 0)),
|
|
3006
3189
|
quality: Math.max(0, Number(ws.liveDeskLiveOptions.quality || 0))
|
|
3007
|
-
} : null,
|
|
3008
|
-
sharedProfileReuseCount: Math.max(0, Number(ws.liveDeskSharedProfileReuseCount || 0)),
|
|
3190
|
+
} : null,
|
|
3191
|
+
sharedProfileReuseCount: Math.max(0, Number(ws.liveDeskSharedProfileReuseCount || 0)),
|
|
3192
|
+
sharedProfileUpgradeCount: Math.max(0, Number(ws.liveDeskSharedProfileUpgradeCount || 0)),
|
|
3009
3193
|
queueDepth: Math.max(0, Number(lane?.queue?.length || 0)),
|
|
3010
3194
|
queueLimit: frameClientQueueLimit(ws),
|
|
3011
3195
|
queueHighWaterPackets: Math.max(0, Number(lane?.queuedPacketsHighWater || 0)),
|
|
@@ -3810,12 +3994,33 @@ function startFrameSubscriptionLive(
|
|
|
3810
3994
|
skipped.push({ deviceId, reason: device?.connected ? 'live-unavailable' : 'not-connected' });
|
|
3811
3995
|
continue;
|
|
3812
3996
|
}
|
|
3813
|
-
const monitorIndex = Object.prototype.hasOwnProperty.call(liveOptions.monitorSelections || {}, deviceId)
|
|
3814
|
-
? liveOptions.monitorSelections[deviceId]
|
|
3815
|
-
: liveOptions.monitorIndex;
|
|
3997
|
+
const monitorIndex = Object.prototype.hasOwnProperty.call(liveOptions.monitorSelections || {}, deviceId)
|
|
3998
|
+
? liveOptions.monitorSelections[deviceId]
|
|
3999
|
+
: liveOptions.monitorIndex;
|
|
4000
|
+
const sharedWallSourceProfile = resolveSharedWallSourceDemand(
|
|
4001
|
+
ws,
|
|
4002
|
+
deviceId,
|
|
4003
|
+
liveOptions,
|
|
4004
|
+
monitorIndex
|
|
4005
|
+
);
|
|
4006
|
+
const sourceLiveOptions = sharedWallSourceProfile.preserveDominantProfile
|
|
4007
|
+
? {
|
|
4008
|
+
...liveOptions,
|
|
4009
|
+
fps: sharedWallSourceProfile.fps,
|
|
4010
|
+
maxWidth: sharedWallSourceProfile.maxWidth,
|
|
4011
|
+
maxHeight: sharedWallSourceProfile.maxHeight,
|
|
4012
|
+
quality: sharedWallSourceProfile.quality
|
|
4013
|
+
}
|
|
4014
|
+
: liveOptions;
|
|
3816
4015
|
const result = remoteHub.startLiveStream(deviceId, {
|
|
3817
|
-
...
|
|
4016
|
+
...sourceLiveOptions,
|
|
3818
4017
|
monitorIndex,
|
|
4018
|
+
subscriberRequestedProfile: {
|
|
4019
|
+
fps: liveOptions.fps,
|
|
4020
|
+
maxWidth: liveOptions.maxWidth,
|
|
4021
|
+
maxHeight: liveOptions.maxHeight,
|
|
4022
|
+
quality: liveOptions.quality
|
|
4023
|
+
},
|
|
3819
4024
|
reuseExisting: liveOptions.forceRestart !== true
|
|
3820
4025
|
&& (liveOptions.reuseExisting === true || reason === 'subscribe' || reason === 'watchdog'),
|
|
3821
4026
|
// Wall capture is one shared native encoder per device. Two open browser
|
|
@@ -3825,7 +4030,8 @@ function startFrameSubscriptionLive(
|
|
|
3825
4030
|
// and forth every time either view refreshes its subscription.
|
|
3826
4031
|
reuseSharedExisting: liveOptions.forceRestart !== true
|
|
3827
4032
|
&& String(liveOptions.streamPurpose || '').trim().toLowerCase() === 'wall'
|
|
3828
|
-
&& hasOtherFrameStreamDemand(ws, deviceId, 'wall')
|
|
4033
|
+
&& (hasOtherFrameStreamDemand(ws, deviceId, 'wall')
|
|
4034
|
+
|| sharedWallSourceProfile.preserveDominantProfile === true),
|
|
3829
4035
|
silentReuse: reason === 'watchdog' && liveOptions.forceRestart !== true
|
|
3830
4036
|
});
|
|
3831
4037
|
if (liveOptions.forceRestart === true) {
|
|
@@ -3834,35 +4040,71 @@ function startFrameSubscriptionLive(
|
|
|
3834
4040
|
`[VuvoDesk Hub] browser frame recovery client=${String(ws.liveDeskFrameClientId || '-')} device=${deviceId} purpose=${String(liveOptions.streamPurpose || 'wall')} reason=${String(liveOptions.restartReason || reason || 'browser-stale')} token=${token ? token.slice(0, 12) : '-'} result=${result?.ok ? 'accepted' : String(result?.error || 'failed')} session=${String(result?.sessionId || device?.sessionId || '').slice(0, 8)} stream=${String(result?.streamId || '-')} generation=${Number(result?.captureGeneration || 0)} reused=${result?.reused === true}`
|
|
3835
4041
|
);
|
|
3836
4042
|
}
|
|
3837
|
-
if (result?.ok) {
|
|
3838
|
-
|
|
4043
|
+
if (result?.ok) {
|
|
4044
|
+
const activeStream = device?.activeLiveStream;
|
|
4045
|
+
const retainWallOwner = shouldRetainWallOwnerUntilPendingStartCommits(
|
|
4046
|
+
result,
|
|
4047
|
+
activeStream
|
|
4048
|
+
);
|
|
4049
|
+
const effectiveWallProfile = String(result.streamPurpose || liveOptions.streamPurpose || '')
|
|
4050
|
+
.trim().toLowerCase() === 'wall'
|
|
4051
|
+
? {
|
|
4052
|
+
fps: Number(result.effectiveProfile?.fps || sourceLiveOptions.fps || result.fps || 0),
|
|
4053
|
+
maxWidth: Number(result.effectiveProfile?.maxWidth || sourceLiveOptions.maxWidth || 0),
|
|
4054
|
+
maxHeight: Number(result.effectiveProfile?.maxHeight || sourceLiveOptions.maxHeight || 0),
|
|
4055
|
+
quality: Number(result.effectiveProfile?.quality || sourceLiveOptions.quality || 0)
|
|
4056
|
+
}
|
|
4057
|
+
: result.effectiveProfile || null;
|
|
4058
|
+
if (result.reused !== true
|
|
4059
|
+
&& retainWallOwner !== true
|
|
4060
|
+
&& String(result.streamPurpose || '').trim().toLowerCase() === 'wall') {
|
|
4061
|
+
rebindSharedWallSubscribersToOwner(deviceId, result, {
|
|
4062
|
+
effectiveProfile: effectiveWallProfile,
|
|
4063
|
+
readySent: false,
|
|
4064
|
+
reason: 'shared-wall-profile-upgraded'
|
|
4065
|
+
});
|
|
4066
|
+
}
|
|
4067
|
+
if (result.sharedProfileReused === true) {
|
|
3839
4068
|
ws.liveDeskSharedProfileReuseCount = Math.max(
|
|
3840
4069
|
0,
|
|
3841
4070
|
Number(ws.liveDeskSharedProfileReuseCount || 0)
|
|
3842
4071
|
) + 1;
|
|
3843
4072
|
}
|
|
3844
4073
|
frameCaptureTransitionRetries.complete(ws, deviceId, intentGeneration, 'capture-started');
|
|
4074
|
+
const bindingOwner = retainWallOwner
|
|
4075
|
+
? {
|
|
4076
|
+
...activeStream,
|
|
4077
|
+
sessionId: String(device?.sessionId || result.sessionId || '')
|
|
4078
|
+
}
|
|
4079
|
+
: result;
|
|
4080
|
+
const bindingEffectiveWallProfile = retainWallOwner
|
|
4081
|
+
? {
|
|
4082
|
+
fps: Number(activeStream?.fps || 0),
|
|
4083
|
+
maxWidth: Number(activeStream?.maxWidth || 0),
|
|
4084
|
+
maxHeight: Number(activeStream?.maxHeight || 0),
|
|
4085
|
+
quality: Number(activeStream?.quality || 0)
|
|
4086
|
+
}
|
|
4087
|
+
: effectiveWallProfile;
|
|
3845
4088
|
const expectedBinding = {
|
|
3846
|
-
deviceId,
|
|
3847
|
-
sessionId: String(
|
|
3848
|
-
streamId: String(
|
|
3849
|
-
streamPurpose: String(
|
|
3850
|
-
commandId: String(
|
|
3851
|
-
captureGeneration: Number(
|
|
3852
|
-
monitorIndex: Number(
|
|
3853
|
-
readOnlyControlBorrow:
|
|
3854
|
-
presentationPurpose:
|
|
3855
|
-
effectiveProfile:
|
|
4089
|
+
deviceId,
|
|
4090
|
+
sessionId: String(bindingOwner?.sessionId || device.sessionId || ''),
|
|
4091
|
+
streamId: String(bindingOwner?.streamId || ''),
|
|
4092
|
+
streamPurpose: String(bindingOwner?.streamPurpose || liveOptions.streamPurpose || 'wall'),
|
|
4093
|
+
commandId: String(bindingOwner?.commandId || ''),
|
|
4094
|
+
captureGeneration: Number(bindingOwner?.captureGeneration || 0),
|
|
4095
|
+
monitorIndex: Number(bindingOwner?.monitorIndex || 0),
|
|
4096
|
+
readOnlyControlBorrow: bindingOwner?.readOnlyControlBorrow === true,
|
|
4097
|
+
presentationPurpose: bindingOwner?.presentationPurpose === 'wall' ? 'wall' : '',
|
|
4098
|
+
effectiveProfile: bindingEffectiveWallProfile,
|
|
3856
4099
|
readySent: false
|
|
3857
|
-
};
|
|
3858
|
-
const activeStream = device?.activeLiveStream;
|
|
4100
|
+
};
|
|
3859
4101
|
// A new read-only browser presentation has no decoder history from the
|
|
3860
4102
|
// already-running Control stream. It must enter through the next exact
|
|
3861
4103
|
// key frame even when the native owner itself is already ready.
|
|
3862
4104
|
const reusedFrameReady = expectedBinding.readOnlyControlBorrow !== true
|
|
3863
4105
|
&& isReusedLiveStreamFrameReady(result, activeStream, expectedBinding);
|
|
3864
4106
|
expectedBinding.readySent = reusedFrameReady;
|
|
3865
|
-
ws.liveDeskStreamIdsByDeviceId.set(deviceId,
|
|
4107
|
+
ws.liveDeskStreamIdsByDeviceId.set(deviceId, expectedBinding.streamId);
|
|
3866
4108
|
const installedBindingIdentity = replaceExpectedFrameBindingForClient(
|
|
3867
4109
|
ws,
|
|
3868
4110
|
deviceId,
|
|
@@ -3871,9 +4113,9 @@ function startFrameSubscriptionLive(
|
|
|
3871
4113
|
if (!installedBindingIdentity) {
|
|
3872
4114
|
skipped.push({ deviceId, reason: 'invalid-live-stream-binding' });
|
|
3873
4115
|
continue;
|
|
3874
|
-
}
|
|
4116
|
+
}
|
|
3875
4117
|
if (expectedBinding.readOnlyControlBorrow !== true) {
|
|
3876
|
-
cancelPendingFrameStreamStop(deviceId,
|
|
4118
|
+
cancelPendingFrameStreamStop(deviceId, expectedBinding.streamId, expectedBinding.streamPurpose);
|
|
3877
4119
|
}
|
|
3878
4120
|
started.push({
|
|
3879
4121
|
deviceId: expectedBinding.deviceId,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
function finiteProfileNumber(value, fallback = 0) {
|
|
2
|
+
const numeric = Number(value);
|
|
3
|
+
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function selectSharedWallSourceProfile(demands = []) {
|
|
7
|
+
const profiles = Array.isArray(demands) ? demands.filter(Boolean) : [];
|
|
8
|
+
if (profiles.length === 0) {
|
|
9
|
+
return Object.freeze({
|
|
10
|
+
fps: 0,
|
|
11
|
+
maxWidth: 0,
|
|
12
|
+
maxHeight: 0,
|
|
13
|
+
quality: 0,
|
|
14
|
+
demandCount: 0
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
const selected = profiles.reduce((profile, demand) => ({
|
|
18
|
+
fps: Math.max(profile.fps, finiteProfileNumber(demand.fps)),
|
|
19
|
+
maxWidth: Math.max(profile.maxWidth, finiteProfileNumber(demand.maxWidth)),
|
|
20
|
+
maxHeight: Math.max(profile.maxHeight, finiteProfileNumber(demand.maxHeight)),
|
|
21
|
+
quality: Math.max(profile.quality, finiteProfileNumber(demand.quality))
|
|
22
|
+
}), {
|
|
23
|
+
fps: 0,
|
|
24
|
+
maxWidth: 0,
|
|
25
|
+
maxHeight: 0,
|
|
26
|
+
quality: 0
|
|
27
|
+
});
|
|
28
|
+
return Object.freeze({
|
|
29
|
+
...selected,
|
|
30
|
+
demandCount: profiles.length
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function liveProfileSatisfiesDemand(activeProfile, requestedProfile) {
|
|
35
|
+
if (!activeProfile || !requestedProfile) return false;
|
|
36
|
+
return finiteProfileNumber(activeProfile.fps) >= finiteProfileNumber(requestedProfile.fps)
|
|
37
|
+
&& finiteProfileNumber(activeProfile.maxWidth) >= finiteProfileNumber(requestedProfile.maxWidth)
|
|
38
|
+
&& finiteProfileNumber(activeProfile.maxHeight) >= finiteProfileNumber(requestedProfile.maxHeight)
|
|
39
|
+
&& finiteProfileNumber(activeProfile.quality) >= finiteProfileNumber(requestedProfile.quality);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function shouldRetainWallOwnerUntilPendingStartCommits(startResult, activeOwner) {
|
|
43
|
+
if (!startResult || startResult.pending !== true || !activeOwner) return false;
|
|
44
|
+
const startPurpose = String(startResult.streamPurpose || '').trim().toLowerCase();
|
|
45
|
+
const activePurpose = String(activeOwner.streamPurpose || '').trim().toLowerCase();
|
|
46
|
+
const startStreamId = String(startResult.streamId || '').trim();
|
|
47
|
+
const activeStreamId = String(activeOwner.streamId || '').trim();
|
|
48
|
+
const activeCommandId = String(activeOwner.commandId || '').trim();
|
|
49
|
+
const activeGeneration = Number(activeOwner.captureGeneration || 0);
|
|
50
|
+
return startPurpose === 'wall'
|
|
51
|
+
&& activePurpose === 'wall'
|
|
52
|
+
&& !!startStreamId
|
|
53
|
+
&& startStreamId === activeStreamId
|
|
54
|
+
&& !!activeCommandId
|
|
55
|
+
&& Number.isSafeInteger(activeGeneration)
|
|
56
|
+
&& activeGeneration > 0
|
|
57
|
+
&& activeOwner.active === true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function wallBindingMatchesFailedOwner(binding, failedOwner) {
|
|
61
|
+
const commandId = String(failedOwner?.commandId || '').trim();
|
|
62
|
+
const captureGeneration = Number(failedOwner?.captureGeneration || 0);
|
|
63
|
+
return !!binding
|
|
64
|
+
&& !!commandId
|
|
65
|
+
&& Number.isSafeInteger(captureGeneration)
|
|
66
|
+
&& captureGeneration > 0
|
|
67
|
+
&& String(binding.commandId || '').trim() === commandId
|
|
68
|
+
&& Number(binding.captureGeneration || 0) === captureGeneration;
|
|
69
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
liveProfileSatisfiesDemand,
|
|
6
|
+
selectSharedWallSourceProfile,
|
|
7
|
+
shouldRetainWallOwnerUntilPendingStartCommits,
|
|
8
|
+
wallBindingMatchesFailedOwner
|
|
9
|
+
} from './shared-wall-profile-contract.mjs';
|
|
10
|
+
|
|
11
|
+
test('shared Wall source keeps the strongest field from every current viewer', () => {
|
|
12
|
+
assert.deepEqual(selectSharedWallSourceProfile([
|
|
13
|
+
{ fps: 5, maxWidth: 960, maxHeight: 540, quality: 68 },
|
|
14
|
+
{ fps: 30, maxWidth: 1920, maxHeight: 1080, quality: 75 }
|
|
15
|
+
]), {
|
|
16
|
+
fps: 30,
|
|
17
|
+
maxWidth: 1920,
|
|
18
|
+
maxHeight: 1080,
|
|
19
|
+
quality: 75,
|
|
20
|
+
demandCount: 2
|
|
21
|
+
});
|
|
22
|
+
assert.deepEqual(selectSharedWallSourceProfile([
|
|
23
|
+
{ fps: 5, maxWidth: 3840, maxHeight: 2160, quality: 70 },
|
|
24
|
+
{ fps: 30, maxWidth: 1920, maxHeight: 1080, quality: 80 }
|
|
25
|
+
]), {
|
|
26
|
+
fps: 30,
|
|
27
|
+
maxWidth: 3840,
|
|
28
|
+
maxHeight: 2160,
|
|
29
|
+
quality: 80,
|
|
30
|
+
demandCount: 2
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('a shared source is reused only when it satisfies every requested profile field', () => {
|
|
35
|
+
const high = { fps: 30, maxWidth: 1920, maxHeight: 1080, quality: 75 };
|
|
36
|
+
const low = { fps: 5, maxWidth: 960, maxHeight: 540, quality: 68 };
|
|
37
|
+
assert.equal(liveProfileSatisfiesDemand(high, low), true);
|
|
38
|
+
assert.equal(liveProfileSatisfiesDemand(low, high), false);
|
|
39
|
+
assert.equal(liveProfileSatisfiesDemand({ ...high, fps: 5 }, high), false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a pending Wall replacement keeps the current browser owner until a real frame commits it', () => {
|
|
43
|
+
const activeOwner = {
|
|
44
|
+
active: true,
|
|
45
|
+
streamId: 'wall-device',
|
|
46
|
+
streamPurpose: 'wall',
|
|
47
|
+
commandId: 'wall-command-current',
|
|
48
|
+
captureGeneration: 41
|
|
49
|
+
};
|
|
50
|
+
const pendingStart = {
|
|
51
|
+
ok: true,
|
|
52
|
+
pending: true,
|
|
53
|
+
streamId: 'wall-device',
|
|
54
|
+
streamPurpose: 'wall',
|
|
55
|
+
commandId: 'wall-command-pending',
|
|
56
|
+
captureGeneration: 42
|
|
57
|
+
};
|
|
58
|
+
assert.equal(
|
|
59
|
+
shouldRetainWallOwnerUntilPendingStartCommits(pendingStart, activeOwner),
|
|
60
|
+
true
|
|
61
|
+
);
|
|
62
|
+
assert.equal(
|
|
63
|
+
shouldRetainWallOwnerUntilPendingStartCommits({ ...pendingStart, pending: false }, activeOwner),
|
|
64
|
+
false
|
|
65
|
+
);
|
|
66
|
+
assert.equal(
|
|
67
|
+
shouldRetainWallOwnerUntilPendingStartCommits(pendingStart, {
|
|
68
|
+
...activeOwner,
|
|
69
|
+
streamPurpose: 'control'
|
|
70
|
+
}),
|
|
71
|
+
false
|
|
72
|
+
);
|
|
73
|
+
assert.equal(
|
|
74
|
+
shouldRetainWallOwnerUntilPendingStartCommits({
|
|
75
|
+
...pendingStart,
|
|
76
|
+
streamId: 'wall-other'
|
|
77
|
+
}, activeOwner),
|
|
78
|
+
false
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('a late failed upgrade cannot rewind a newer Wall binding', () => {
|
|
83
|
+
const failedOwner = {
|
|
84
|
+
commandId: 'wall-command-42',
|
|
85
|
+
captureGeneration: 42
|
|
86
|
+
};
|
|
87
|
+
assert.equal(wallBindingMatchesFailedOwner({
|
|
88
|
+
commandId: 'wall-command-42',
|
|
89
|
+
captureGeneration: 42
|
|
90
|
+
}, failedOwner), true);
|
|
91
|
+
assert.equal(wallBindingMatchesFailedOwner({
|
|
92
|
+
commandId: 'wall-command-43',
|
|
93
|
+
captureGeneration: 43
|
|
94
|
+
}, failedOwner), false);
|
|
95
|
+
assert.equal(wallBindingMatchesFailedOwner({
|
|
96
|
+
commandId: 'wall-command-42',
|
|
97
|
+
captureGeneration: 43
|
|
98
|
+
}, failedOwner), false);
|
|
99
|
+
});
|
|
@@ -259,7 +259,7 @@ function resetReconnectCandidate(target) {
|
|
|
259
259
|
target.stabilityDeadlineAt = '';
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
-
export function
|
|
262
|
+
export function createVuvoDeskUpdateManager({
|
|
263
263
|
remoteHub,
|
|
264
264
|
currentManagerVersion,
|
|
265
265
|
currentClientVersion,
|
|
@@ -138,7 +138,17 @@ test('a new Wall frame lane reuses a healthy exact capture', async () => {
|
|
|
138
138
|
restartToken: 'explicit-owner-restart'
|
|
139
139
|
});
|
|
140
140
|
assert.equal(hardRestart.ok, true);
|
|
141
|
+
assert.equal(hardRestart.pending, true);
|
|
141
142
|
assert.ok(hardRestart.captureGeneration > first.captureGeneration);
|
|
143
|
+
const pendingDevice = hub.listDevices({ includeDataUrl: false })
|
|
144
|
+
.find(device => device.deviceId === 'wall-source-device');
|
|
145
|
+
assert.equal(pendingDevice.activeLiveStream.commandId, first.commandId);
|
|
146
|
+
assert.equal(pendingDevice.activeLiveStream.captureGeneration, first.captureGeneration);
|
|
147
|
+
assert.equal(pendingDevice.activeLiveStream.pendingCommandId, hardRestart.commandId);
|
|
148
|
+
assert.equal(
|
|
149
|
+
pendingDevice.activeLiveStream.pendingCaptureGeneration,
|
|
150
|
+
hardRestart.captureGeneration
|
|
151
|
+
);
|
|
142
152
|
} finally {
|
|
143
153
|
socket?.destroy();
|
|
144
154
|
await hub.close();
|