@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/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 { createLiveDeskUpdateManager } from './live-desk-update.js';
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 ((type === 'RemoteLiveStreamStarted' || type === 'RemoteLiveStreamOpened')
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 = createLiveDeskUpdateManager({
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
- ...liveOptions,
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
- if (result.sharedProfileReused === true) {
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(result.sessionId || device.sessionId || ''),
3848
- streamId: String(result.streamId || ''),
3849
- streamPurpose: String(result.streamPurpose || liveOptions.streamPurpose || 'wall'),
3850
- commandId: String(result.commandId || ''),
3851
- captureGeneration: Number(result.captureGeneration || 0),
3852
- monitorIndex: Number(result.monitorIndex || 0),
3853
- readOnlyControlBorrow: result.readOnlyControlBorrow === true,
3854
- presentationPurpose: result.presentationPurpose === 'wall' ? 'wall' : '',
3855
- effectiveProfile: result.effectiveProfile || null,
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, result.streamId);
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, result.streamId, expectedBinding.streamPurpose);
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 createLiveDeskUpdateManager({
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();