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