livedesk 0.1.664 → 0.1.666

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.
Files changed (33) hide show
  1. package/client/package.json +6 -6
  2. package/electron/installer.nsh +11 -0
  3. package/electron/main.mjs +6 -0
  4. package/hub/package.json +2 -2
  5. package/hub/src/console-direct.js +24 -3
  6. package/hub/src/console-direct.test.mjs +2 -1
  7. package/hub/src/remote-hub.js +23 -16
  8. package/hub/src/server.js +73 -18
  9. package/hub/src/shared-wall-profile-contract.mjs +29 -0
  10. package/hub/src/shared-wall-profile-contract.test.mjs +62 -1
  11. package/hub/src/wall-source-restart-runtime.test.mjs +10 -0
  12. package/package.json +6 -6
  13. package/runtime-core/package.json +1 -1
  14. package/runtime-core/src/console-direct-wire.d.ts +3 -0
  15. package/runtime-core/src/console-direct-wire.js +46 -1
  16. package/runtime-core/src/console-direct-wire.test.mjs +30 -0
  17. package/web/dist/app.html +1 -1
  18. package/web/dist/app.webmanifest +1 -1
  19. package/web/dist/assets/{AgentSettingsTab-Bqxc9_mG.js → AgentSettingsTab-DjFapAQa.js} +1 -1
  20. package/web/dist/assets/{AgentsPage-DITlguZQ.js → AgentsPage-DPxAo4lH.js} +1 -1
  21. package/web/dist/assets/{CaptureGallery-DAoUS3vW.js → CaptureGallery-BKaV8fHM.js} +1 -1
  22. package/web/dist/assets/{HubApp-B20gAaN5.js → HubApp-CN4KlXdX.js} +3 -3
  23. package/web/dist/assets/MonitorStackIcon-C00Ab3UB.js +8 -0
  24. package/web/dist/assets/{SettingsPage-BwYJVIBX.js → SettingsPage-Bw4ZpJO5.js} +1 -1
  25. package/web/dist/assets/{ShareFilesPage-D03s0dCJ.js → ShareFilesPage-Cd68XlxN.js} +1 -1
  26. package/web/dist/assets/{SupportPage-DAx_R97y.js → SupportPage-BgigefDF.js} +1 -1
  27. package/web/dist/assets/{VuvoDeskApp-DqQO5Vk7.js → VuvoDeskApp-DWDosB-Z.js} +1 -1
  28. package/web/dist/assets/{app-Bkwt8WPz.js → app-BxZ_Nrck.js} +2 -2
  29. package/web/dist/assets/{main-CZfqMuZu.js → main-cLVo_p6g.js} +2 -2
  30. package/web/dist/index.html +2 -2
  31. package/web/dist/sw.js +2 -2
  32. package/web/dist/vuvodesk-build-evidence.json +40 -40
  33. package/web/dist/assets/MonitorStackIcon-DX8imUSU.js +0 -8
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/client",
3
- "version": "0.1.271",
3
+ "version": "0.1.272",
4
4
  "description": "VuvoDesk local remote client",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,16 +36,16 @@
36
36
  "dependencies": {
37
37
  "@ffmpeg-installer/ffmpeg": "^1.1.0",
38
38
  "ffmpeg-static": "^5.3.0",
39
- "@livedesk/runtime-core": "0.1.8",
39
+ "@livedesk/runtime-core": "0.1.9",
40
40
  "@supabase/supabase-js": "^2.110.0",
41
41
  "node-screenshots": "^0.2.8",
42
42
  "ws": "^8.18.3"
43
43
  },
44
44
  "optionalDependencies": {
45
- "@livedesk/fast-linux-x64": "0.1.466",
46
- "@livedesk/fast-osx-arm64": "0.1.466",
47
- "@livedesk/fast-osx-x64": "0.1.466",
48
- "@livedesk/fast-win-x64": "0.1.466"
45
+ "@livedesk/fast-linux-x64": "0.1.467",
46
+ "@livedesk/fast-osx-arm64": "0.1.467",
47
+ "@livedesk/fast-osx-x64": "0.1.467",
48
+ "@livedesk/fast-win-x64": "0.1.467"
49
49
  },
50
50
  "publishConfig": {
51
51
  "access": "public"
@@ -93,6 +93,17 @@
93
93
  !insertmacro MUI_PAGE_DIRECTORY
94
94
  !macroend
95
95
 
96
+ !macro customInstall
97
+ # electron-builder normally launches $newStartMenuLink after a silent update.
98
+ # The updater removes and recreates that shortcut during replacement, so
99
+ # Explorer can receive a path that no longer exists. Launch the installed
100
+ # executable directly after an update; ordinary installs keep the default
101
+ # shortcut-backed finish-page behavior.
102
+ ${If} ${isUpdated}
103
+ StrCpy $launchLink "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
104
+ ${EndIf}
105
+ !macroend
106
+
96
107
  !macro customUnInstallCheck
97
108
  ${If} ${Errors}
98
109
  DetailPrint "Uninstall was not successful. The registered uninstaller could not be launched."
package/electron/main.mjs CHANGED
@@ -1538,6 +1538,12 @@ if (!app.requestSingleInstanceLock()) {
1538
1538
  log
1539
1539
  });
1540
1540
  desktopAutoStartOwner.reconcile('startup');
1541
+ if (process.argv.includes('--updated')) {
1542
+ log(
1543
+ `desktop update event=post-install-startup platform=${process.platform} version=${app.getVersion()} marker=updated`,
1544
+ { source: 'desktop-update' }
1545
+ );
1546
+ }
1541
1547
  let lastProductUpdateLogSignature = '';
1542
1548
  productUpdates = createProductUpdateManager({
1543
1549
  app,
package/hub/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.66",
3
+ "version": "0.1.67",
4
4
  "description": "VuvoDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@ffmpeg-installer/ffmpeg": "^1.1.0",
19
- "@livedesk/runtime-core": "0.1.8",
19
+ "@livedesk/runtime-core": "0.1.9",
20
20
  "@openai/codex-sdk": "0.145.0",
21
21
  "cors": "^2.8.5",
22
22
  "express": "^4.21.2",
@@ -371,6 +371,8 @@ export function createHubConsoleDirect(options = {}) {
371
371
  let lastHttpStatus = 0;
372
372
  let rejectedRelayCandidates = 0;
373
373
  let dataChannelBackpressureCloses = 0;
374
+ let dataChannelSendFailures = 0;
375
+ let lastDataChannelSendFailure = null;
374
376
  let malformedWireCloses = 0;
375
377
  let wireBudgetCleanupFailures = 0;
376
378
 
@@ -730,11 +732,28 @@ export function createHubConsoleDirect(options = {}) {
730
732
  }
731
733
  return false;
732
734
  }
733
- for (const chunk of chunks) {
735
+ for (const [chunkIndex, chunk] of chunks.entries()) {
734
736
  if (!isOwnerActive(owner) || channelState.closed || !channelIsOpen(channelState.channel)) return false;
735
737
  try {
736
- if (channelState.channel.sendMessageBinary(chunk) !== true) throw new Error('send-rejected');
737
- } catch {
738
+ // Normalize the node-datachannel native boundary to its documented
739
+ // Node Buffer shape. Share the exact encoded bytes without copying or
740
+ // retaining a second application queue.
741
+ const nativeChunk = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
742
+ if (channelState.channel.sendMessageBinary(nativeChunk) !== true) throw new Error('send-rejected');
743
+ } catch (error) {
744
+ dataChannelSendFailures += 1;
745
+ lastDataChannelSendFailure = Object.freeze({
746
+ purpose: channelState.purpose,
747
+ channelId: channelState.channelId,
748
+ generation: owner.generation,
749
+ messageId: (channelState.nextSendMessageId - 1) >>> 0,
750
+ chunkIndex,
751
+ chunkCount: chunks.length,
752
+ chunkBytes: chunk.byteLength,
753
+ bufferedBytes: channelBufferedAmount(channelState),
754
+ error: String(error instanceof Error ? error.message : error).slice(0, 120),
755
+ occurredAt: Date.now()
756
+ });
738
757
  if (options.closeOnFailure !== false) {
739
758
  if (channelState === owner.control) retirePeer(owner, 'console-direct-control-send-failed');
740
759
  else closeLogicalChannel(owner, channelState, {
@@ -1983,6 +2002,8 @@ export function createHubConsoleDirect(options = {}) {
1983
2002
  + Number(signalHeartbeatTimerActive),
1984
2003
  rejectedRelayCandidates,
1985
2004
  dataChannelBackpressureCloses,
2005
+ dataChannelSendFailures,
2006
+ lastDataChannelSendFailure,
1986
2007
  malformedWireCloses,
1987
2008
  wireBudgetCleanupFailures,
1988
2009
  lastConnectedAt,
@@ -124,6 +124,7 @@ class FakeDataChannel {
124
124
 
125
125
  sendMessageBinary(value) {
126
126
  if (!this.isOpen()) return false;
127
+ if (!Buffer.isBuffer(value)) throw new TypeError('Buffer expected');
127
128
  this.sent.push(new Uint8Array(value));
128
129
  return true;
129
130
  }
@@ -1498,7 +1499,7 @@ test('native node-datachannel peers carry the fragmented control wire over loopb
1498
1499
  kind: 'control'
1499
1500
  });
1500
1501
  assert.ok(chunks.length > 1);
1501
- for (const chunk of chunks) assert.equal(offerChannel.sendMessageBinary(chunk), true);
1502
+ for (const chunk of chunks) assert.equal(offerChannel.sendMessageBinary(Buffer.from(chunk)), true);
1502
1503
  const message = await received;
1503
1504
  clearTimeout(messageTimer);
1504
1505
  messageTimer = null;
@@ -10924,23 +10924,29 @@ export function createRemoteHub(options = {}) {
10924
10924
  ensureLiveStreamReplacementTimers(device).set(stream.streamId, timer);
10925
10925
  }
10926
10926
 
10927
- function failPendingLiveStream(device, commandId, error = 'stream-start-failed') {
10927
+ function failPendingLiveStream(device, commandId, error = 'stream-start-failed') {
10928
10928
  const key = safeString(commandId, 128);
10929
10929
  if (!device || !key) {
10930
10930
  return false;
10931
10931
  }
10932
10932
  const streams = ensureDeviceLiveStreams(device);
10933
10933
  for (const stream of streams.values()) {
10934
- const pending = getPendingLiveStreamDescriptor(stream);
10935
- if (safeString(pending?.commandId, 128) !== key) {
10936
- continue;
10937
- }
10938
- clearPendingLiveStreamDescriptor(device, stream);
10939
- emitRemoteEvent('RemoteLiveStreamRestartFailed', device, {
10940
- streamId: stream.streamId,
10941
- commandId: key,
10942
- error: safeString(error, 500) || 'stream-start-failed'
10943
- });
10934
+ const pending = getPendingLiveStreamDescriptor(stream);
10935
+ if (safeString(pending?.commandId, 128) !== key) {
10936
+ continue;
10937
+ }
10938
+ const failedOwner = { ...pending };
10939
+ clearPendingLiveStreamDescriptor(device, stream);
10940
+ emitRemoteEvent('RemoteLiveStreamRestartFailed', device, {
10941
+ streamId: stream.streamId,
10942
+ commandId: key,
10943
+ streamPurpose: safeString(failedOwner.streamPurpose, 24) || 'wall',
10944
+ captureGeneration: Number(failedOwner.captureGeneration || 0),
10945
+ monitorIndex: Number(failedOwner.monitorIndex || 0),
10946
+ retainedCommandId: safeString(stream.commandId, 128),
10947
+ retainedCaptureGeneration: Number(stream.captureGeneration || 0),
10948
+ error: safeString(error, 500) || 'stream-start-failed'
10949
+ });
10944
10950
  return true;
10945
10951
  }
10946
10952
  for (const stream of streams.values()) {
@@ -11293,7 +11299,7 @@ export function createRemoteHub(options = {}) {
11293
11299
  }
11294
11300
  : normalized;
11295
11301
  return {
11296
- ok: true,
11302
+ ok: true,
11297
11303
  commandId: activeLiveStream.commandId,
11298
11304
  sessionId: device.sessionId,
11299
11305
  streamId: activeLiveStream.streamId,
@@ -11802,10 +11808,11 @@ export function createRemoteHub(options = {}) {
11802
11808
  fps,
11803
11809
  mode: transfer.mode,
11804
11810
  frameMode: transfer.frameMode,
11805
- monitorIndex,
11806
- captureGeneration,
11807
- ready: false
11808
- };
11811
+ monitorIndex,
11812
+ captureGeneration,
11813
+ ready: false,
11814
+ pending: replacingActiveStream
11815
+ };
11809
11816
  }
11810
11817
 
11811
11818
  function stopLiveStream(deviceId, options = {}) {
package/hub/src/server.js CHANGED
@@ -35,7 +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 { selectSharedWallSourceProfile } from './shared-wall-profile-contract.mjs';
38
+ import {
39
+ selectSharedWallSourceProfile,
40
+ shouldRetainWallOwnerUntilPendingStartCommits,
41
+ wallBindingMatchesFailedOwner
42
+ } from './shared-wall-profile-contract.mjs';
39
43
  import {
40
44
  createPlanDeviceAccessSnapshot,
41
45
  partitionPlanDeviceIds,
@@ -436,7 +440,25 @@ function handleRemoteHubEvent(type, event) {
436
440
  || type === 'RemoteLiveStreamStopped') {
437
441
  const liveStreamEventDeviceId = String(event?.deviceId || event?.device?.deviceId || '').trim();
438
442
  const liveStreamEventPurpose = readRemoteLiveStreamEventPurpose(event);
439
- 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')
440
462
  && liveStreamEventPurpose === 'control') {
441
463
  readOnlyControlPresentationReconcileCoordinator
442
464
  .cancelForControlTransition(liveStreamEventDeviceId);
@@ -454,7 +476,7 @@ function handleRemoteHubEvent(type, event) {
454
476
  const failedDeviceId = String(event?.deviceId || event?.device?.deviceId || '').trim();
455
477
  const retainedOwner = event?.device?.activeLiveStream;
456
478
  if (String(retainedOwner?.streamPurpose || '').trim().toLowerCase() === 'wall') {
457
- rebindSharedWallSubscribersToOwner(failedDeviceId, {
479
+ const rebound = rebindSharedWallSubscribersToOwner(failedDeviceId, {
458
480
  ...retainedOwner,
459
481
  sessionId: event?.device?.sessionId || ''
460
482
  }, {
@@ -465,8 +487,20 @@ function handleRemoteHubEvent(type, event) {
465
487
  quality: Number(retainedOwner?.quality || 0)
466
488
  },
467
489
  readySent: true,
490
+ replaceOnlyOwner: {
491
+ commandId: event?.commandId || '',
492
+ captureGeneration: Number(event?.captureGeneration || 0)
493
+ },
468
494
  reason: 'shared-wall-profile-upgrade-failed'
469
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
+ );
470
504
  }
471
505
  }
472
506
  if (type === 'RemoteDeviceConnected' || type === 'RemoteDeviceDisconnected') {
@@ -2681,6 +2715,7 @@ function resolveSharedWallSourceDemand(ws, deviceId, liveOptions, monitorIndex)
2681
2715
  function rebindSharedWallSubscribersToOwner(deviceId, owner, {
2682
2716
  effectiveProfile = null,
2683
2717
  readySent = false,
2718
+ replaceOnlyOwner = null,
2684
2719
  reason = 'shared-wall-profile-owner-changed'
2685
2720
  } = {}) {
2686
2721
  const normalizedDeviceId = String(deviceId || '').trim();
@@ -2721,6 +2756,7 @@ function rebindSharedWallSubscribersToOwner(deviceId, owner, {
2721
2756
  ? candidate.liveDeskExpectedStreamBindingsByDeviceId.get(normalizedDeviceId)
2722
2757
  : null;
2723
2758
  if (!previousBinding || previousBinding.readOnlyControlBorrow === true) continue;
2759
+ if (replaceOnlyOwner && !wallBindingMatchesFailedOwner(previousBinding, replaceOnlyOwner)) continue;
2724
2760
  const nextBinding = {
2725
2761
  deviceId: normalizedDeviceId,
2726
2762
  sessionId,
@@ -4005,6 +4041,11 @@ function startFrameSubscriptionLive(
4005
4041
  );
4006
4042
  }
4007
4043
  if (result?.ok) {
4044
+ const activeStream = device?.activeLiveStream;
4045
+ const retainWallOwner = shouldRetainWallOwnerUntilPendingStartCommits(
4046
+ result,
4047
+ activeStream
4048
+ );
4008
4049
  const effectiveWallProfile = String(result.streamPurpose || liveOptions.streamPurpose || '')
4009
4050
  .trim().toLowerCase() === 'wall'
4010
4051
  ? {
@@ -4015,6 +4056,7 @@ function startFrameSubscriptionLive(
4015
4056
  }
4016
4057
  : result.effectiveProfile || null;
4017
4058
  if (result.reused !== true
4059
+ && retainWallOwner !== true
4018
4060
  && String(result.streamPurpose || '').trim().toLowerCase() === 'wall') {
4019
4061
  rebindSharedWallSubscribersToOwner(deviceId, result, {
4020
4062
  effectiveProfile: effectiveWallProfile,
@@ -4029,27 +4071,40 @@ function startFrameSubscriptionLive(
4029
4071
  ) + 1;
4030
4072
  }
4031
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;
4032
4088
  const expectedBinding = {
4033
- deviceId,
4034
- sessionId: String(result.sessionId || device.sessionId || ''),
4035
- streamId: String(result.streamId || ''),
4036
- streamPurpose: String(result.streamPurpose || liveOptions.streamPurpose || 'wall'),
4037
- commandId: String(result.commandId || ''),
4038
- captureGeneration: Number(result.captureGeneration || 0),
4039
- monitorIndex: Number(result.monitorIndex || 0),
4040
- readOnlyControlBorrow: result.readOnlyControlBorrow === true,
4041
- presentationPurpose: result.presentationPurpose === 'wall' ? 'wall' : '',
4042
- effectiveProfile: effectiveWallProfile,
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,
4043
4099
  readySent: false
4044
- };
4045
- const activeStream = device?.activeLiveStream;
4100
+ };
4046
4101
  // A new read-only browser presentation has no decoder history from the
4047
4102
  // already-running Control stream. It must enter through the next exact
4048
4103
  // key frame even when the native owner itself is already ready.
4049
4104
  const reusedFrameReady = expectedBinding.readOnlyControlBorrow !== true
4050
4105
  && isReusedLiveStreamFrameReady(result, activeStream, expectedBinding);
4051
4106
  expectedBinding.readySent = reusedFrameReady;
4052
- ws.liveDeskStreamIdsByDeviceId.set(deviceId, result.streamId);
4107
+ ws.liveDeskStreamIdsByDeviceId.set(deviceId, expectedBinding.streamId);
4053
4108
  const installedBindingIdentity = replaceExpectedFrameBindingForClient(
4054
4109
  ws,
4055
4110
  deviceId,
@@ -4058,9 +4113,9 @@ function startFrameSubscriptionLive(
4058
4113
  if (!installedBindingIdentity) {
4059
4114
  skipped.push({ deviceId, reason: 'invalid-live-stream-binding' });
4060
4115
  continue;
4061
- }
4116
+ }
4062
4117
  if (expectedBinding.readOnlyControlBorrow !== true) {
4063
- cancelPendingFrameStreamStop(deviceId, result.streamId, expectedBinding.streamPurpose);
4118
+ cancelPendingFrameStreamStop(deviceId, expectedBinding.streamId, expectedBinding.streamPurpose);
4064
4119
  }
4065
4120
  started.push({
4066
4121
  deviceId: expectedBinding.deviceId,
@@ -38,3 +38,32 @@ export function liveProfileSatisfiesDemand(activeProfile, requestedProfile) {
38
38
  && finiteProfileNumber(activeProfile.maxHeight) >= finiteProfileNumber(requestedProfile.maxHeight)
39
39
  && finiteProfileNumber(activeProfile.quality) >= finiteProfileNumber(requestedProfile.quality);
40
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
+ }
@@ -3,7 +3,9 @@ import test from 'node:test';
3
3
 
4
4
  import {
5
5
  liveProfileSatisfiesDemand,
6
- selectSharedWallSourceProfile
6
+ selectSharedWallSourceProfile,
7
+ shouldRetainWallOwnerUntilPendingStartCommits,
8
+ wallBindingMatchesFailedOwner
7
9
  } from './shared-wall-profile-contract.mjs';
8
10
 
9
11
  test('shared Wall source keeps the strongest field from every current viewer', () => {
@@ -36,3 +38,62 @@ test('a shared source is reused only when it satisfies every requested profile f
36
38
  assert.equal(liveProfileSatisfiesDemand(low, high), false);
37
39
  assert.equal(liveProfileSatisfiesDemand({ ...high, fps: 5 }, high), false);
38
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
+ });
@@ -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();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "livedesk",
3
- "version": "0.1.664",
4
- "livedeskClientVersion": "0.1.271",
3
+ "version": "0.1.666",
4
+ "livedeskClientVersion": "0.1.272",
5
5
  "buildFlavor": "production",
6
6
  "description": "VuvoDesk Hub and client launcher",
7
7
  "type": "module",
@@ -54,10 +54,10 @@
54
54
  "ws": "^8.18.3"
55
55
  },
56
56
  "optionalDependencies": {
57
- "@livedesk/fast-linux-x64": "0.1.466",
58
- "@livedesk/fast-osx-arm64": "0.1.466",
59
- "@livedesk/fast-osx-x64": "0.1.466",
60
- "@livedesk/fast-win-x64": "0.1.466"
57
+ "@livedesk/fast-linux-x64": "0.1.467",
58
+ "@livedesk/fast-osx-arm64": "0.1.467",
59
+ "@livedesk/fast-osx-x64": "0.1.467",
60
+ "@livedesk/fast-win-x64": "0.1.467"
61
61
  },
62
62
  "publishConfig": {
63
63
  "access": "public"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/runtime-core",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Shared VuvoDesk runtime role and lifecycle contracts",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -64,10 +64,13 @@ export interface DirectConsoleWireAssemblerInspection {
64
64
  readonly maxPendingMessages: number;
65
65
  readonly pendingMessages: number;
66
66
  readonly pendingBytes: number;
67
+ readonly abandonedMessages: number;
68
+ readonly abandonedChunksRemaining: number;
67
69
  readonly budget: DirectConsoleWireBudgetInspection;
68
70
  }
69
71
 
70
72
  export interface DirectConsoleWireAssembler {
73
+ abandonPending(): DirectConsoleWireAssemblerInspection;
71
74
  push(chunk: DirectConsoleWireChunk): DirectConsoleWireResult | null;
72
75
  inspect(): DirectConsoleWireAssemblerInspection;
73
76
  dispose(): DirectConsoleWireAssemblerInspection;
@@ -239,6 +239,7 @@ export function createDirectConsoleWireAssembler(options = undefined) {
239
239
  }
240
240
 
241
241
  const pending = new Map();
242
+ const abandoned = new Map();
242
243
  let disposed = false;
243
244
 
244
245
  const releasePending = () => {
@@ -246,15 +247,23 @@ export function createDirectConsoleWireAssembler(options = undefined) {
246
247
  pending.clear();
247
248
  };
248
249
 
250
+ const releaseAbandoned = () => {
251
+ abandoned.clear();
252
+ };
253
+
249
254
  const inspect = () => {
250
255
  let pendingBytes = 0;
251
256
  for (const state of pending.values()) pendingBytes += state.totalLength;
257
+ let abandonedChunksRemaining = 0;
258
+ for (const state of abandoned.values()) abandonedChunksRemaining += state.remainingChunks;
252
259
  return Object.freeze({
253
260
  disposed,
254
261
  maxMessageBytes,
255
262
  maxPendingMessages,
256
263
  pendingMessages: pending.size,
257
264
  pendingBytes,
265
+ abandonedMessages: abandoned.size,
266
+ abandonedChunksRemaining,
258
267
  budget: budget.inspect()
259
268
  });
260
269
  };
@@ -263,6 +272,7 @@ export function createDirectConsoleWireAssembler(options = undefined) {
263
272
  if (!disposed) {
264
273
  disposed = true;
265
274
  releasePending();
275
+ releaseAbandoned();
266
276
  }
267
277
  return inspect();
268
278
  };
@@ -270,6 +280,7 @@ export function createDirectConsoleWireAssembler(options = undefined) {
270
280
  const closeWithError = error => {
271
281
  disposed = true;
272
282
  releasePending();
283
+ releaseAbandoned();
273
284
  if (error instanceof DirectConsoleWireError) throw error;
274
285
  throw new DirectConsoleWireError('wire-chunk-malformed');
275
286
  };
@@ -278,6 +289,20 @@ export function createDirectConsoleWireAssembler(options = undefined) {
278
289
  if (disposed) fail('assembler-disposed');
279
290
  try {
280
291
  const header = parseHeader(input, maxMessageBytes);
292
+ const abandonedState = abandoned.get(header.messageId);
293
+ if (abandonedState) {
294
+ if (abandonedState.kind !== header.kind
295
+ || abandonedState.chunkCount !== header.chunkCount
296
+ || abandonedState.totalLength !== header.totalLength) {
297
+ fail('wire-message-metadata-mismatch');
298
+ }
299
+ if (abandonedState.receivedChunks[header.chunkIndex] === 0) {
300
+ abandonedState.receivedChunks[header.chunkIndex] = 1;
301
+ abandonedState.remainingChunks -= 1;
302
+ }
303
+ if (abandonedState.remainingChunks <= 0) abandoned.delete(header.messageId);
304
+ return null;
305
+ }
281
306
  let state = pending.get(header.messageId);
282
307
  if (!state) {
283
308
  if (pending.size >= maxPendingMessages) {
@@ -347,5 +372,25 @@ export function createDirectConsoleWireAssembler(options = undefined) {
347
372
  }
348
373
  };
349
374
 
350
- return Object.freeze({ dispose, inspect, push });
375
+ const abandonPending = () => {
376
+ if (disposed) fail('assembler-disposed');
377
+ for (const [messageId, state] of pending.entries()) {
378
+ if (abandoned.size >= DIRECT_CONSOLE_MAX_CONFIGURED_PENDING_MESSAGES) {
379
+ const oldestMessageId = abandoned.keys().next().value;
380
+ abandoned.delete(oldestMessageId);
381
+ }
382
+ abandoned.set(messageId, {
383
+ kind: state.kind,
384
+ chunkCount: state.chunkCount,
385
+ totalLength: state.totalLength,
386
+ receivedChunks: new Uint8Array(state.receivedChunks),
387
+ remainingChunks: Math.max(0, state.chunkCount - state.receivedChunkCount)
388
+ });
389
+ state.reservation.release();
390
+ }
391
+ pending.clear();
392
+ return inspect();
393
+ };
394
+
395
+ return Object.freeze({ abandonPending, dispose, inspect, push });
351
396
  }