@livedesk/hub 0.1.66 → 0.1.67

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 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",
@@ -12,11 +12,11 @@
12
12
  "dev": "node src/server.js",
13
13
  "start": "node src/server.js",
14
14
  "check": "node --check src/server.js && node --check src/remote-hub.js && node --check src/remote-clipboard-contract.mjs && node --check src/console-direct.js",
15
- "prepublishOnly": "node ../../scripts/livedesk-release-git-gate.mjs"
15
+ "prepublishOnly": "node ../../scripts/vuvodesk-release-git-gate.mjs"
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",
@@ -86,7 +86,7 @@ export class CaptureStore {
86
86
  throw Object.assign(new Error('capture-mime-type-unsupported'), { status: 415 });
87
87
  }
88
88
  const extension = MIME_EXTENSIONS.get(mimeType) || (kind === 'screenshot' ? '.png' : '.webm');
89
- const fileNameBase = safeName(metadata.fileName, `LiveDesk_${kind}`);
89
+ const fileNameBase = safeName(metadata.fileName, `VuvoDesk_${kind}`);
90
90
  const fileName = fileNameBase.toLowerCase().endsWith(extension) ? fileNameBase : `${fileNameBase}${extension}`;
91
91
  const id = randomUUID();
92
92
  const folder = kind === 'screenshot' ? 'images' : kind === 'timelapse' ? 'timelapses' : 'recordings';
@@ -122,7 +122,7 @@ export class CaptureStore {
122
122
  const sessionId = randomUUID();
123
123
  const tempPath = path.join(this.root, '.tmp', `${sessionId}.webm.part`);
124
124
  await writeFile(tempPath, Buffer.alloc(0), { mode: 0o600 });
125
- const fileNameBase = safeName(metadata.fileName, `LiveDesk_${kind}`);
125
+ const fileNameBase = safeName(metadata.fileName, `VuvoDesk_${kind}`);
126
126
  const session = {
127
127
  sessionId,
128
128
  kind,
@@ -29,6 +29,7 @@ const MAX_SHARED_ASSEMBLY_BYTES = 12 * 1024 * 1024;
29
29
  const MAX_FRAME_MESSAGE_BYTES = (8 * 1024 * 1024) + DIRECT_CONSOLE_WIRE_CHUNK_BYTES;
30
30
  const MAX_PENDING_MEDIA_WIRE_MESSAGES = 8;
31
31
  const MAX_DATA_CHANNEL_BUFFERED_BYTES = 12 * 1024 * 1024;
32
+ const CONTROL_HEADROOM_BUFFERED_BYTES = 4 * 1024 * 1024;
32
33
  const MAX_LOCAL_WS_BUFFERED_BYTES = 4 * 1024 * 1024;
33
34
  const HTTP_TIMEOUT_MS = 15_000;
34
35
  const ICE_CONNECT_TIMEOUT_MS = 10_000;
@@ -169,6 +170,7 @@ function allowedHttpRequest(method, pathname) {
169
170
  || pathname === '/api/auth/session'
170
171
  || pathname === '/api/remote/status'
171
172
  || pathname === '/api/hub/status'
173
+ || pathname === '/api/remote/pwa-diagnostics'
172
174
  || pathname === '/api/update/status'
173
175
  || pathname === '/api/update/apply') {
174
176
  return true;
@@ -369,6 +371,8 @@ export function createHubConsoleDirect(options = {}) {
369
371
  let lastHttpStatus = 0;
370
372
  let rejectedRelayCandidates = 0;
371
373
  let dataChannelBackpressureCloses = 0;
374
+ let dataChannelSendFailures = 0;
375
+ let lastDataChannelSendFailure = null;
372
376
  let malformedWireCloses = 0;
373
377
  let wireBudgetCleanupFailures = 0;
374
378
 
@@ -651,7 +655,7 @@ export function createHubConsoleDirect(options = {}) {
651
655
  || channelBufferedAmount(right) - channelBufferedAmount(left)
652
656
  ));
653
657
  for (const state of candidates) {
654
- if (ownerBufferedAmount(owner) + requiredBytes <= MAX_DATA_CHANNEL_BUFFERED_BYTES) break;
658
+ if (ownerBufferedAmount(owner) + requiredBytes <= CONTROL_HEADROOM_BUFFERED_BYTES) break;
655
659
  dataChannelBackpressureCloses += 1;
656
660
  closeLogicalChannel(owner, state, {
657
661
  code: 1013,
@@ -680,8 +684,39 @@ export function createHubConsoleDirect(options = {}) {
680
684
  return false;
681
685
  }
682
686
  const wireBytes = chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
687
+ const partialMedia = channelState.purpose === 'frame' || channelState.purpose === 'atlas';
688
+ if (partialMedia
689
+ && (wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES
690
+ || ownerBufferedAmount(owner) + wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES)) {
691
+ dataChannelBackpressureCloses += 1;
692
+ if (options.closeOnFailure !== false) {
693
+ closeLogicalChannel(owner, channelState, {
694
+ code: 1013,
695
+ reason: 'console-direct-datachannel-backpressure',
696
+ notify: false
697
+ });
698
+ }
699
+ return false;
700
+ }
701
+ const mediaBufferedBytes = partialMedia ? channelBufferedAmount(channelState) : 0;
702
+ if (partialMedia
703
+ && mediaBufferedBytes > 0
704
+ && mediaBufferedBytes + wireBytes > CONTROL_HEADROOM_BUFFERED_BYTES) {
705
+ // Retire only the media lane that owns the stale SCTP backlog. Its
706
+ // browser socket recovery requests a current key frame while the
707
+ // reliable peer and every healthy lane keep their own owners.
708
+ dataChannelBackpressureCloses += 1;
709
+ if (options.closeOnFailure !== false) {
710
+ closeLogicalChannel(owner, channelState, {
711
+ code: 1013,
712
+ reason: 'console-direct-media-headroom',
713
+ notify: false
714
+ });
715
+ }
716
+ return false;
717
+ }
683
718
  if (channelState === owner.control
684
- && ownerBufferedAmount(owner) + wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES) {
719
+ && ownerBufferedAmount(owner) + wireBytes > CONTROL_HEADROOM_BUFFERED_BYTES) {
685
720
  releaseLogicalBacklogForControl(owner, wireBytes);
686
721
  }
687
722
  if (wireBytes > MAX_DATA_CHANNEL_BUFFERED_BYTES
@@ -697,11 +732,28 @@ export function createHubConsoleDirect(options = {}) {
697
732
  }
698
733
  return false;
699
734
  }
700
- for (const chunk of chunks) {
735
+ for (const [chunkIndex, chunk] of chunks.entries()) {
701
736
  if (!isOwnerActive(owner) || channelState.closed || !channelIsOpen(channelState.channel)) return false;
702
737
  try {
703
- if (channelState.channel.sendMessageBinary(chunk) !== true) throw new Error('send-rejected');
704
- } 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
+ });
705
757
  if (options.closeOnFailure !== false) {
706
758
  if (channelState === owner.control) retirePeer(owner, 'console-direct-control-send-failed');
707
759
  else closeLogicalChannel(owner, channelState, {
@@ -1950,6 +2002,8 @@ export function createHubConsoleDirect(options = {}) {
1950
2002
  + Number(signalHeartbeatTimerActive),
1951
2003
  rejectedRelayCandidates,
1952
2004
  dataChannelBackpressureCloses,
2005
+ dataChannelSendFailures,
2006
+ lastDataChannelSendFailure,
1953
2007
  malformedWireCloses,
1954
2008
  wireBudgetCleanupFailures,
1955
2009
  lastConnectedAt,
@@ -1982,6 +2036,7 @@ export const consoleDirectContract = Object.freeze({
1982
2036
  maxFrameMessageBytes: MAX_FRAME_MESSAGE_BYTES,
1983
2037
  maxPendingMediaWireMessages: MAX_PENDING_MEDIA_WIRE_MESSAGES,
1984
2038
  maxBufferedBytes: MAX_DATA_CHANNEL_BUFFERED_BYTES,
2039
+ controlHeadroomBufferedBytes: CONTROL_HEADROOM_BUFFERED_BYTES,
1985
2040
  maxLocalWebSocketBufferedBytes: MAX_LOCAL_WS_BUFFERED_BYTES,
1986
2041
  maxDefaultRetryDelayMs: DEFAULT_RETRY_DELAYS_MS.at(-1),
1987
2042
  accessTokenTimeoutMs: ACCESS_TOKEN_TIMEOUT_MS,
@@ -23,6 +23,7 @@ const CONSOLE_ID = '22222222-2222-4222-8222-222222222222';
23
23
  const CONNECTION_ONE = '33333333-3333-4333-8333-333333333333';
24
24
  const CONNECTION_TWO = '44444444-4444-4444-8444-444444444444';
25
25
  const CHANNEL_ID = '55555555-5555-4555-8555-555555555555';
26
+ const CHANNEL_ID_TWO = '55555555-5555-4555-8555-555555555556';
26
27
  const REQUEST_ID = '66666666-6666-4666-8666-666666666666';
27
28
  const TEAM_WORKSPACE_ID = '88888888-8888-4888-8888-888888888888';
28
29
  const TEAM_OWNER_USER_ID = '99999999-9999-4999-8999-999999999999';
@@ -123,6 +124,7 @@ class FakeDataChannel {
123
124
 
124
125
  sendMessageBinary(value) {
125
126
  if (!this.isOpen()) return false;
127
+ if (!Buffer.isBuffer(value)) throw new TypeError('Buffer expected');
126
128
  this.sent.push(new Uint8Array(value));
127
129
  return true;
128
130
  }
@@ -627,6 +629,48 @@ test('reliable control channel bridges bounded HTTP over fragmented wire message
627
629
  direct.close();
628
630
  });
629
631
 
632
+ test('reliable control channel admits the authenticated PWA diagnostic route', async () => {
633
+ let fetchCalls = 0;
634
+ const diagnosticBody = JSON.stringify({ schema: 'livedesk.pwa-runtime-diagnostics.v1', events: [] });
635
+ const { direct, signal } = await connectedDirect({
636
+ fetchImpl: async (url, init) => {
637
+ fetchCalls += 1;
638
+ assert.equal(url, 'http://127.0.0.1:5179/api/remote/pwa-diagnostics');
639
+ assert.equal(init.method, 'POST');
640
+ assert.equal(init.headers['content-type'], 'application/json');
641
+ assert.equal(Buffer.from(init.body).toString('utf8'), diagnosticBody);
642
+ return new Response('{"ok":true,"accepted":true}', {
643
+ status: 202,
644
+ statusText: 'Accepted',
645
+ headers: { 'Content-Type': 'application/json' }
646
+ });
647
+ }
648
+ });
649
+ const peer = offer(signal);
650
+ const control = new FakeDataChannel('livedesk-control-v1');
651
+ peer.emitDataChannel(control);
652
+ control.open();
653
+
654
+ sendWire(control, {
655
+ type: 'http-request',
656
+ requestId: REQUEST_ID,
657
+ method: 'POST',
658
+ path: '/api/remote/pwa-diagnostics',
659
+ headers: { 'content-type': 'application/json' },
660
+ bodyBase64: Buffer.from(diagnosticBody).toString('base64')
661
+ });
662
+ await settle();
663
+ const response = decodeWireMessages(control.sent)
664
+ .map(message => JSON.parse(message.data))
665
+ .find(message => message.type === 'http-response');
666
+ assert.equal(fetchCalls, 1, 'The PWA diagnostic request must reach the local Hub API.');
667
+ assert.equal(response.requestId, REQUEST_ID);
668
+ assert.equal(response.status, 202);
669
+ assert.equal(Buffer.from(response.bodyBase64, 'base64').toString('utf8'), '{"ok":true,"accepted":true}');
670
+ assert.equal(direct.inspect().pendingHttpRequests, 0);
671
+ direct.close();
672
+ });
673
+
630
674
  test('Team Operator can use Wall and Control but cannot mutate the Hub owner runtime', async () => {
631
675
  const teamWorkspaceAccess = {
632
676
  workspaceId: TEAM_WORKSPACE_ID,
@@ -1184,6 +1228,56 @@ test('the peer-wide DataChannel buffer cap closes only the overflowing logical l
1184
1228
  direct.close();
1185
1229
  });
1186
1230
 
1231
+ test('media pressure retires only its stale logical lane while preserving the control peer and a healthy lane', async () => {
1232
+ const { direct, signal } = await connectedDirect();
1233
+ const peer = offer(signal);
1234
+ const control = new FakeDataChannel('livedesk-control-v1');
1235
+ peer.emitDataChannel(control);
1236
+ control.open();
1237
+ const lane = new FakeDataChannel(`livedesk-ws-v1:${CHANNEL_ID}:frame`);
1238
+ peer.emitDataChannel(lane);
1239
+ lane.open();
1240
+ sendWire(control, {
1241
+ type: 'ws-open',
1242
+ connectionId: CONNECTION_ONE,
1243
+ hubEpoch: HUB_EPOCH,
1244
+ channelId: CHANNEL_ID,
1245
+ purpose: 'frame',
1246
+ path: '/api/remote/frames/ws'
1247
+ });
1248
+ const local = FakeLocalSocket.instances[0];
1249
+ local.open();
1250
+ const healthyLane = new FakeDataChannel(`livedesk-ws-v1:${CHANNEL_ID_TWO}:frame`);
1251
+ peer.emitDataChannel(healthyLane);
1252
+ healthyLane.open();
1253
+ sendWire(control, {
1254
+ type: 'ws-open',
1255
+ connectionId: CONNECTION_ONE,
1256
+ hubEpoch: HUB_EPOCH,
1257
+ channelId: CHANNEL_ID_TWO,
1258
+ purpose: 'frame',
1259
+ path: '/api/remote/frames/ws'
1260
+ });
1261
+ const healthyLocal = FakeLocalSocket.instances[1];
1262
+ healthyLocal.open();
1263
+ lane.buffered = consoleDirectContract.controlHeadroomBufferedBytes;
1264
+
1265
+ healthyLocal.receive('healthy-frame');
1266
+ assert.ok(healthyLane.sent.length > 0);
1267
+ assert.equal(healthyLane.closed, false);
1268
+ assert.equal(lane.closed, false);
1269
+
1270
+ local.receive('newest-frame');
1271
+
1272
+ assert.equal(lane.sent.length, 0);
1273
+ assert.equal(lane.closed, true);
1274
+ assert.equal(healthyLane.closed, false);
1275
+ assert.equal(control.closed, false);
1276
+ assert.equal(peer.closed, false);
1277
+ assert.equal(direct.inspect().dataChannelBackpressureCloses, 1);
1278
+ direct.close();
1279
+ });
1280
+
1187
1281
  test('a reliable control response drops stale media backlog without retiring the peer', async () => {
1188
1282
  const { direct, signal } = await connectedDirect({
1189
1283
  fetchImpl: async () => new Response('{"online":true}', {
@@ -1405,7 +1499,7 @@ test('native node-datachannel peers carry the fragmented control wire over loopb
1405
1499
  kind: 'control'
1406
1500
  });
1407
1501
  assert.ok(chunks.length > 1);
1408
- 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);
1409
1503
  const message = await received;
1410
1504
  clearTimeout(messageTimer);
1411
1505
  messageTimer = null;
package/src/remote-hub.js CHANGED
@@ -17,14 +17,15 @@ import {
17
17
  BoundedSegmentedBuffer,
18
18
  createBoundedAgentBinaryIngressLane
19
19
  } from './transport/agent-binary-ingress.js';
20
- import {
21
- REMOTE_CLIPBOARD_FILE_ACTIONS,
20
+ import {
21
+ REMOTE_CLIPBOARD_FILE_ACTIONS,
22
22
  REMOTE_CLIPBOARD_LIMITS,
23
23
  REMOTE_CLIPBOARD_PROTOCOL,
24
24
  normalizeRemoteClipboardCommandResult,
25
25
  normalizeRemoteClipboardManifest,
26
26
  remoteClipboardContentNeedsFileTransfer
27
- } from './remote-clipboard-contract.mjs';
27
+ } from './remote-clipboard-contract.mjs';
28
+ import { liveProfileSatisfiesDemand } from './shared-wall-profile-contract.mjs';
28
29
 
29
30
  const DEFAULT_REMOTE_HUB_PORT = 5197;
30
31
  const DEFAULT_REMOTE_HUB_HOST = '0.0.0.0';
@@ -10923,23 +10924,29 @@ export function createRemoteHub(options = {}) {
10923
10924
  ensureLiveStreamReplacementTimers(device).set(stream.streamId, timer);
10924
10925
  }
10925
10926
 
10926
- function failPendingLiveStream(device, commandId, error = 'stream-start-failed') {
10927
+ function failPendingLiveStream(device, commandId, error = 'stream-start-failed') {
10927
10928
  const key = safeString(commandId, 128);
10928
10929
  if (!device || !key) {
10929
10930
  return false;
10930
10931
  }
10931
10932
  const streams = ensureDeviceLiveStreams(device);
10932
10933
  for (const stream of streams.values()) {
10933
- const pending = getPendingLiveStreamDescriptor(stream);
10934
- if (safeString(pending?.commandId, 128) !== key) {
10935
- continue;
10936
- }
10937
- clearPendingLiveStreamDescriptor(device, stream);
10938
- emitRemoteEvent('RemoteLiveStreamRestartFailed', device, {
10939
- streamId: stream.streamId,
10940
- commandId: key,
10941
- error: safeString(error, 500) || 'stream-start-failed'
10942
- });
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
+ });
10943
10950
  return true;
10944
10951
  }
10945
10952
  for (const stream of streams.values()) {
@@ -11276,9 +11283,23 @@ export function createRemoteHub(options = {}) {
11276
11283
  === normalized.streamPurpose;
11277
11284
  }
11278
11285
 
11279
- function sharedLiveProfileResult(device, activeLiveStream, normalized, extra = {}) {
11280
- return {
11281
- ok: true,
11286
+ function sharedLiveProfileResult(
11287
+ device,
11288
+ activeLiveStream,
11289
+ normalized,
11290
+ extra = {},
11291
+ subscriberRequestedProfile = null
11292
+ ) {
11293
+ const requested = subscriberRequestedProfile && typeof subscriberRequestedProfile === 'object'
11294
+ ? {
11295
+ fps: clampNumber(subscriberRequestedProfile.fps, 1, normalized.streamPurpose === 'control' ? 60 : 30, normalized.fps),
11296
+ maxWidth: clampNumber(subscriberRequestedProfile.maxWidth, 320, 3840, normalized.maxWidth),
11297
+ maxHeight: clampNumber(subscriberRequestedProfile.maxHeight, 180, 2160, normalized.maxHeight),
11298
+ quality: clampNumber(subscriberRequestedProfile.quality, 20, 95, normalized.quality)
11299
+ }
11300
+ : normalized;
11301
+ return {
11302
+ ok: true,
11282
11303
  commandId: activeLiveStream.commandId,
11283
11304
  sessionId: device.sessionId,
11284
11305
  streamId: activeLiveStream.streamId,
@@ -11290,12 +11311,12 @@ export function createRemoteHub(options = {}) {
11290
11311
  captureGeneration: Number(activeLiveStream.captureGeneration || 0),
11291
11312
  ready: liveStreamHasCurrentFrame(activeLiveStream),
11292
11313
  reused: true,
11293
- sharedProfileReused: true,
11294
- requestedProfile: {
11295
- fps: normalized.fps,
11296
- maxWidth: normalized.maxWidth,
11297
- maxHeight: normalized.maxHeight,
11298
- quality: normalized.quality
11314
+ sharedProfileReused: true,
11315
+ requestedProfile: {
11316
+ fps: requested.fps,
11317
+ maxWidth: requested.maxWidth,
11318
+ maxHeight: requested.maxHeight,
11319
+ quality: requested.quality
11299
11320
  },
11300
11321
  effectiveProfile: {
11301
11322
  fps: Number(activeLiveStream.fps || normalized.fps),
@@ -11506,10 +11527,11 @@ export function createRemoteHub(options = {}) {
11506
11527
  const pendingDescriptor = getPendingLiveStreamDescriptor(activeLiveStream);
11507
11528
  if (pendingDescriptor?.commandId) {
11508
11529
  const pendingMatchesRequest = liveStreamMatchesOptions(pendingDescriptor, normalized);
11509
- const pendingMatchesSharedOwner = options.forceRestart !== true
11510
- && options.reuseExisting === true
11511
- && options.reuseSharedExisting === true
11512
- && liveStreamMatchesOwnerIdentity(pendingDescriptor, normalized);
11530
+ const pendingMatchesSharedOwner = options.forceRestart !== true
11531
+ && options.reuseExisting === true
11532
+ && options.reuseSharedExisting === true
11533
+ && liveStreamMatchesOwnerIdentity(pendingDescriptor, normalized)
11534
+ && liveProfileSatisfiesDemand(pendingDescriptor, normalized);
11513
11535
  if ((pendingMatchesRequest || pendingMatchesSharedOwner)
11514
11536
  && liveStreamReplacementStillPending(activeLiveStream)) {
11515
11537
  emitRemoteEvent('RemoteLiveStreamRestartPending', device, {
@@ -11519,10 +11541,10 @@ export function createRemoteHub(options = {}) {
11519
11541
  reason: safeString(options.restartReason || 'duplicate-restart', 128)
11520
11542
  });
11521
11543
  if (pendingMatchesSharedOwner && !pendingMatchesRequest) {
11522
- return sharedLiveProfileResult(device, pendingDescriptor, normalized, {
11523
- ready: false,
11524
- pending: true
11525
- });
11544
+ return sharedLiveProfileResult(device, pendingDescriptor, normalized, {
11545
+ ready: false,
11546
+ pending: true
11547
+ }, options.subscriberRequestedProfile);
11526
11548
  }
11527
11549
  return {
11528
11550
  ok: true,
@@ -11599,10 +11621,11 @@ export function createRemoteHub(options = {}) {
11599
11621
  }
11600
11622
  if (activeLiveStream
11601
11623
  && options.forceRestart !== true
11602
- && options.reuseExisting === true
11603
- && options.reuseSharedExisting === true
11604
- && liveStreamMatchesOwnerIdentity(activeLiveStream, normalized)
11605
- && liveStreamIsReusable(activeLiveStream)) {
11624
+ && options.reuseExisting === true
11625
+ && options.reuseSharedExisting === true
11626
+ && liveStreamMatchesOwnerIdentity(activeLiveStream, normalized)
11627
+ && liveStreamIsReusable(activeLiveStream)
11628
+ && liveProfileSatisfiesDemand(activeLiveStream, normalized)) {
11606
11629
  emitRemoteEvent('RemoteLiveStreamSharedProfileReused', device, {
11607
11630
  streamId,
11608
11631
  commandId: activeLiveStream.commandId,
@@ -11614,7 +11637,13 @@ export function createRemoteHub(options = {}) {
11614
11637
  effectiveMaxWidth: Number(activeLiveStream.maxWidth || normalized.maxWidth),
11615
11638
  effectiveMaxHeight: Number(activeLiveStream.maxHeight || normalized.maxHeight)
11616
11639
  });
11617
- return sharedLiveProfileResult(device, activeLiveStream, normalized);
11640
+ return sharedLiveProfileResult(
11641
+ device,
11642
+ activeLiveStream,
11643
+ normalized,
11644
+ {},
11645
+ options.subscriberRequestedProfile
11646
+ );
11618
11647
  }
11619
11648
  if (activeLiveStream
11620
11649
  && options.forceRestart !== true
@@ -11779,10 +11808,11 @@ export function createRemoteHub(options = {}) {
11779
11808
  fps,
11780
11809
  mode: transfer.mode,
11781
11810
  frameMode: transfer.frameMode,
11782
- monitorIndex,
11783
- captureGeneration,
11784
- ready: false
11785
- };
11811
+ monitorIndex,
11812
+ captureGeneration,
11813
+ ready: false,
11814
+ pending: replacingActiveStream
11815
+ };
11786
11816
  }
11787
11817
 
11788
11818
  function stopLiveStream(deviceId, options = {}) {
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();