@livedesk/hub 0.1.65 → 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.65",
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';
@@ -9784,25 +9785,31 @@ export function createRemoteHub(options = {}) {
9784
9785
  return { ok: false, error: 'device-not-connected' };
9785
9786
  }
9786
9787
 
9787
- const command = safeString(options.command, 20000);
9788
- if (!command) return { ok: false, error: 'client-update-command-missing' };
9789
- const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
9790
- const timeoutMs = clampNumber(options.timeoutMs, 1000, 120000, 30000);
9791
- const sent = writeJsonLine(device.socket, {
9788
+ const command = safeString(options.command, 20000);
9789
+ if (!command) return { ok: false, error: 'client-update-command-missing' };
9790
+ const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
9791
+ const timeoutMs = clampNumber(options.timeoutMs, 1000, 120000, 30000);
9792
+ const legacyCommandShell = ['win32', 'windows']
9793
+ .includes(safeString(device.platform, 32).toLowerCase())
9794
+ ? 'cmd'
9795
+ : 'auto';
9796
+ const sent = writeJsonLine(device.socket, {
9792
9797
  type: 'command',
9793
9798
  commandId,
9794
- command: 'command.run',
9795
- payload: {
9796
- command,
9797
- timeoutMs,
9798
- permissionMode: 'full-access',
9799
+ command: 'command.run',
9800
+ payload: {
9801
+ command,
9802
+ shell: legacyCommandShell,
9803
+ timeoutMs,
9804
+ permissionMode: 'full-access',
9799
9805
  // RemoteFast releases before 0.1.170 read command.run inputs
9800
9806
  // from toolArguments, while Node and current Agents accept the
9801
9807
  // direct payload fields. Carry both during the update bridge.
9802
- toolArguments: {
9803
- command,
9804
- timeoutMs
9805
- }
9808
+ toolArguments: {
9809
+ command,
9810
+ shell: legacyCommandShell,
9811
+ timeoutMs
9812
+ }
9806
9813
  },
9807
9814
  issuedAt: new Date().toISOString()
9808
9815
  });
@@ -10917,23 +10924,29 @@ export function createRemoteHub(options = {}) {
10917
10924
  ensureLiveStreamReplacementTimers(device).set(stream.streamId, timer);
10918
10925
  }
10919
10926
 
10920
- function failPendingLiveStream(device, commandId, error = 'stream-start-failed') {
10927
+ function failPendingLiveStream(device, commandId, error = 'stream-start-failed') {
10921
10928
  const key = safeString(commandId, 128);
10922
10929
  if (!device || !key) {
10923
10930
  return false;
10924
10931
  }
10925
10932
  const streams = ensureDeviceLiveStreams(device);
10926
10933
  for (const stream of streams.values()) {
10927
- const pending = getPendingLiveStreamDescriptor(stream);
10928
- if (safeString(pending?.commandId, 128) !== key) {
10929
- continue;
10930
- }
10931
- clearPendingLiveStreamDescriptor(device, stream);
10932
- emitRemoteEvent('RemoteLiveStreamRestartFailed', device, {
10933
- streamId: stream.streamId,
10934
- commandId: key,
10935
- error: safeString(error, 500) || 'stream-start-failed'
10936
- });
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
+ });
10937
10950
  return true;
10938
10951
  }
10939
10952
  for (const stream of streams.values()) {
@@ -11270,9 +11283,23 @@ export function createRemoteHub(options = {}) {
11270
11283
  === normalized.streamPurpose;
11271
11284
  }
11272
11285
 
11273
- function sharedLiveProfileResult(device, activeLiveStream, normalized, extra = {}) {
11274
- return {
11275
- 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,
11276
11303
  commandId: activeLiveStream.commandId,
11277
11304
  sessionId: device.sessionId,
11278
11305
  streamId: activeLiveStream.streamId,
@@ -11284,12 +11311,12 @@ export function createRemoteHub(options = {}) {
11284
11311
  captureGeneration: Number(activeLiveStream.captureGeneration || 0),
11285
11312
  ready: liveStreamHasCurrentFrame(activeLiveStream),
11286
11313
  reused: true,
11287
- sharedProfileReused: true,
11288
- requestedProfile: {
11289
- fps: normalized.fps,
11290
- maxWidth: normalized.maxWidth,
11291
- maxHeight: normalized.maxHeight,
11292
- quality: normalized.quality
11314
+ sharedProfileReused: true,
11315
+ requestedProfile: {
11316
+ fps: requested.fps,
11317
+ maxWidth: requested.maxWidth,
11318
+ maxHeight: requested.maxHeight,
11319
+ quality: requested.quality
11293
11320
  },
11294
11321
  effectiveProfile: {
11295
11322
  fps: Number(activeLiveStream.fps || normalized.fps),
@@ -11500,10 +11527,11 @@ export function createRemoteHub(options = {}) {
11500
11527
  const pendingDescriptor = getPendingLiveStreamDescriptor(activeLiveStream);
11501
11528
  if (pendingDescriptor?.commandId) {
11502
11529
  const pendingMatchesRequest = liveStreamMatchesOptions(pendingDescriptor, normalized);
11503
- const pendingMatchesSharedOwner = options.forceRestart !== true
11504
- && options.reuseExisting === true
11505
- && options.reuseSharedExisting === true
11506
- && 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);
11507
11535
  if ((pendingMatchesRequest || pendingMatchesSharedOwner)
11508
11536
  && liveStreamReplacementStillPending(activeLiveStream)) {
11509
11537
  emitRemoteEvent('RemoteLiveStreamRestartPending', device, {
@@ -11513,10 +11541,10 @@ export function createRemoteHub(options = {}) {
11513
11541
  reason: safeString(options.restartReason || 'duplicate-restart', 128)
11514
11542
  });
11515
11543
  if (pendingMatchesSharedOwner && !pendingMatchesRequest) {
11516
- return sharedLiveProfileResult(device, pendingDescriptor, normalized, {
11517
- ready: false,
11518
- pending: true
11519
- });
11544
+ return sharedLiveProfileResult(device, pendingDescriptor, normalized, {
11545
+ ready: false,
11546
+ pending: true
11547
+ }, options.subscriberRequestedProfile);
11520
11548
  }
11521
11549
  return {
11522
11550
  ok: true,
@@ -11593,10 +11621,11 @@ export function createRemoteHub(options = {}) {
11593
11621
  }
11594
11622
  if (activeLiveStream
11595
11623
  && options.forceRestart !== true
11596
- && options.reuseExisting === true
11597
- && options.reuseSharedExisting === true
11598
- && liveStreamMatchesOwnerIdentity(activeLiveStream, normalized)
11599
- && liveStreamIsReusable(activeLiveStream)) {
11624
+ && options.reuseExisting === true
11625
+ && options.reuseSharedExisting === true
11626
+ && liveStreamMatchesOwnerIdentity(activeLiveStream, normalized)
11627
+ && liveStreamIsReusable(activeLiveStream)
11628
+ && liveProfileSatisfiesDemand(activeLiveStream, normalized)) {
11600
11629
  emitRemoteEvent('RemoteLiveStreamSharedProfileReused', device, {
11601
11630
  streamId,
11602
11631
  commandId: activeLiveStream.commandId,
@@ -11608,7 +11637,13 @@ export function createRemoteHub(options = {}) {
11608
11637
  effectiveMaxWidth: Number(activeLiveStream.maxWidth || normalized.maxWidth),
11609
11638
  effectiveMaxHeight: Number(activeLiveStream.maxHeight || normalized.maxHeight)
11610
11639
  });
11611
- return sharedLiveProfileResult(device, activeLiveStream, normalized);
11640
+ return sharedLiveProfileResult(
11641
+ device,
11642
+ activeLiveStream,
11643
+ normalized,
11644
+ {},
11645
+ options.subscriberRequestedProfile
11646
+ );
11612
11647
  }
11613
11648
  if (activeLiveStream
11614
11649
  && options.forceRestart !== true
@@ -11773,10 +11808,11 @@ export function createRemoteHub(options = {}) {
11773
11808
  fps,
11774
11809
  mode: transfer.mode,
11775
11810
  frameMode: transfer.frameMode,
11776
- monitorIndex,
11777
- captureGeneration,
11778
- ready: false
11779
- };
11811
+ monitorIndex,
11812
+ captureGeneration,
11813
+ ready: false,
11814
+ pending: replacingActiveStream
11815
+ };
11780
11816
  }
11781
11817
 
11782
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
+ });
@@ -71,6 +71,22 @@ function isDedicatedBootstrapFailure(value) {
71
71
  return /^Update worker exited before terminal proof\b/i.test(failure)
72
72
  || failure === 'invalid-update-host-environment-entry';
73
73
  }
74
+
75
+ function describeCommandResultEvidence(result) {
76
+ const data = result?.data;
77
+ if (!data || typeof data !== 'object') return '';
78
+ const evidence = [];
79
+ const shell = String(data.shell || '').trim().toLowerCase();
80
+ if (/^[a-z0-9._-]{1,32}$/.test(shell)) evidence.push(`shell=${shell}`);
81
+ const exitCode = Number(data.exitCode);
82
+ if (Number.isInteger(exitCode) && Math.abs(exitCode) <= 0x7fffffff) evidence.push(`exit=${exitCode}`);
83
+ if (typeof data.timedOut === 'boolean') evidence.push(`timedOut=${data.timedOut}`);
84
+ const durationMs = Number(data.durationMs);
85
+ if (Number.isFinite(durationMs) && durationMs >= 0 && durationMs <= 24 * 60 * 60_000) {
86
+ evidence.push(`durationMs=${Math.round(durationMs)}`);
87
+ }
88
+ return evidence.length > 0 ? ` [${evidence.join(' ')}]` : '';
89
+ }
74
90
 
75
91
  function encodePowerShell(value) {
76
92
  return Buffer.from(String(value || ''), 'utf16le').toString('base64');
@@ -243,7 +259,7 @@ function resetReconnectCandidate(target) {
243
259
  target.stabilityDeadlineAt = '';
244
260
  }
245
261
 
246
- export function createLiveDeskUpdateManager({
262
+ export function createVuvoDeskUpdateManager({
247
263
  remoteHub,
248
264
  currentManagerVersion,
249
265
  currentClientVersion,
@@ -799,12 +815,18 @@ export function createLiveDeskUpdateManager({
799
815
  if (!target
800
816
  || target.state !== 'waiting'
801
817
  || !['dedicated', 'legacy-command-run'].includes(target.method)) return;
802
- const result = event?.result;
803
- if (event?.error || result?.ok === false || result?.status === 'failed' || result?.status === 'rejected') {
804
- const failure = String(event.error || result?.error || result?.message || 'client-update-command-failed').slice(0, 500);
805
- if (target.method === 'dedicated'
806
- && target.bootstrapRetryCount === 0
807
- && isDedicatedBootstrapFailure(failure)) {
818
+ const result = event?.result;
819
+ if (event?.error || result?.ok === false || result?.status === 'failed' || result?.status === 'rejected') {
820
+ const failureReason = String(
821
+ event.error || result?.error || result?.message || 'client-update-command-failed'
822
+ ).slice(0, 500);
823
+ const failure = (
824
+ failureReason
825
+ + describeCommandResultEvidence(result)
826
+ ).slice(0, 500);
827
+ if (target.method === 'dedicated'
828
+ && target.bootstrapRetryCount === 0
829
+ && isDedicatedBootstrapFailure(failureReason)) {
808
830
  const retryDevice = connectedClientDevices()
809
831
  .find(device => String(device.deviceId || '') === target.deviceId);
810
832
  const remainingMs = Date.parse(target.deadlineAt || '') - now();
@@ -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();