@livedesk/hub 0.1.66 → 0.1.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +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 +70 -40
- 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} +1 -1
- 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';
|
|
@@ -10923,23 +10924,29 @@ export function createRemoteHub(options = {}) {
|
|
|
10923
10924
|
ensureLiveStreamReplacementTimers(device).set(stream.streamId, timer);
|
|
10924
10925
|
}
|
|
10925
10926
|
|
|
10926
|
-
function failPendingLiveStream(device, commandId, error = 'stream-start-failed') {
|
|
10927
|
+
function failPendingLiveStream(device, commandId, error = 'stream-start-failed') {
|
|
10927
10928
|
const key = safeString(commandId, 128);
|
|
10928
10929
|
if (!device || !key) {
|
|
10929
10930
|
return false;
|
|
10930
10931
|
}
|
|
10931
10932
|
const streams = ensureDeviceLiveStreams(device);
|
|
10932
10933
|
for (const stream of streams.values()) {
|
|
10933
|
-
const pending = getPendingLiveStreamDescriptor(stream);
|
|
10934
|
-
if (safeString(pending?.commandId, 128) !== key) {
|
|
10935
|
-
continue;
|
|
10936
|
-
}
|
|
10937
|
-
|
|
10938
|
-
|
|
10939
|
-
|
|
10940
|
-
|
|
10941
|
-
|
|
10942
|
-
|
|
10934
|
+
const pending = getPendingLiveStreamDescriptor(stream);
|
|
10935
|
+
if (safeString(pending?.commandId, 128) !== key) {
|
|
10936
|
+
continue;
|
|
10937
|
+
}
|
|
10938
|
+
const failedOwner = { ...pending };
|
|
10939
|
+
clearPendingLiveStreamDescriptor(device, stream);
|
|
10940
|
+
emitRemoteEvent('RemoteLiveStreamRestartFailed', device, {
|
|
10941
|
+
streamId: stream.streamId,
|
|
10942
|
+
commandId: key,
|
|
10943
|
+
streamPurpose: safeString(failedOwner.streamPurpose, 24) || 'wall',
|
|
10944
|
+
captureGeneration: Number(failedOwner.captureGeneration || 0),
|
|
10945
|
+
monitorIndex: Number(failedOwner.monitorIndex || 0),
|
|
10946
|
+
retainedCommandId: safeString(stream.commandId, 128),
|
|
10947
|
+
retainedCaptureGeneration: Number(stream.captureGeneration || 0),
|
|
10948
|
+
error: safeString(error, 500) || 'stream-start-failed'
|
|
10949
|
+
});
|
|
10943
10950
|
return true;
|
|
10944
10951
|
}
|
|
10945
10952
|
for (const stream of streams.values()) {
|
|
@@ -11276,9 +11283,23 @@ export function createRemoteHub(options = {}) {
|
|
|
11276
11283
|
=== normalized.streamPurpose;
|
|
11277
11284
|
}
|
|
11278
11285
|
|
|
11279
|
-
function sharedLiveProfileResult(
|
|
11280
|
-
|
|
11281
|
-
|
|
11286
|
+
function sharedLiveProfileResult(
|
|
11287
|
+
device,
|
|
11288
|
+
activeLiveStream,
|
|
11289
|
+
normalized,
|
|
11290
|
+
extra = {},
|
|
11291
|
+
subscriberRequestedProfile = null
|
|
11292
|
+
) {
|
|
11293
|
+
const requested = subscriberRequestedProfile && typeof subscriberRequestedProfile === 'object'
|
|
11294
|
+
? {
|
|
11295
|
+
fps: clampNumber(subscriberRequestedProfile.fps, 1, normalized.streamPurpose === 'control' ? 60 : 30, normalized.fps),
|
|
11296
|
+
maxWidth: clampNumber(subscriberRequestedProfile.maxWidth, 320, 3840, normalized.maxWidth),
|
|
11297
|
+
maxHeight: clampNumber(subscriberRequestedProfile.maxHeight, 180, 2160, normalized.maxHeight),
|
|
11298
|
+
quality: clampNumber(subscriberRequestedProfile.quality, 20, 95, normalized.quality)
|
|
11299
|
+
}
|
|
11300
|
+
: normalized;
|
|
11301
|
+
return {
|
|
11302
|
+
ok: true,
|
|
11282
11303
|
commandId: activeLiveStream.commandId,
|
|
11283
11304
|
sessionId: device.sessionId,
|
|
11284
11305
|
streamId: activeLiveStream.streamId,
|
|
@@ -11290,12 +11311,12 @@ export function createRemoteHub(options = {}) {
|
|
|
11290
11311
|
captureGeneration: Number(activeLiveStream.captureGeneration || 0),
|
|
11291
11312
|
ready: liveStreamHasCurrentFrame(activeLiveStream),
|
|
11292
11313
|
reused: true,
|
|
11293
|
-
sharedProfileReused: true,
|
|
11294
|
-
requestedProfile: {
|
|
11295
|
-
fps:
|
|
11296
|
-
maxWidth:
|
|
11297
|
-
maxHeight:
|
|
11298
|
-
quality:
|
|
11314
|
+
sharedProfileReused: true,
|
|
11315
|
+
requestedProfile: {
|
|
11316
|
+
fps: requested.fps,
|
|
11317
|
+
maxWidth: requested.maxWidth,
|
|
11318
|
+
maxHeight: requested.maxHeight,
|
|
11319
|
+
quality: requested.quality
|
|
11299
11320
|
},
|
|
11300
11321
|
effectiveProfile: {
|
|
11301
11322
|
fps: Number(activeLiveStream.fps || normalized.fps),
|
|
@@ -11506,10 +11527,11 @@ export function createRemoteHub(options = {}) {
|
|
|
11506
11527
|
const pendingDescriptor = getPendingLiveStreamDescriptor(activeLiveStream);
|
|
11507
11528
|
if (pendingDescriptor?.commandId) {
|
|
11508
11529
|
const pendingMatchesRequest = liveStreamMatchesOptions(pendingDescriptor, normalized);
|
|
11509
|
-
const pendingMatchesSharedOwner = options.forceRestart !== true
|
|
11510
|
-
&& options.reuseExisting === true
|
|
11511
|
-
&& options.reuseSharedExisting === true
|
|
11512
|
-
&& liveStreamMatchesOwnerIdentity(pendingDescriptor, normalized)
|
|
11530
|
+
const pendingMatchesSharedOwner = options.forceRestart !== true
|
|
11531
|
+
&& options.reuseExisting === true
|
|
11532
|
+
&& options.reuseSharedExisting === true
|
|
11533
|
+
&& liveStreamMatchesOwnerIdentity(pendingDescriptor, normalized)
|
|
11534
|
+
&& liveProfileSatisfiesDemand(pendingDescriptor, normalized);
|
|
11513
11535
|
if ((pendingMatchesRequest || pendingMatchesSharedOwner)
|
|
11514
11536
|
&& liveStreamReplacementStillPending(activeLiveStream)) {
|
|
11515
11537
|
emitRemoteEvent('RemoteLiveStreamRestartPending', device, {
|
|
@@ -11519,10 +11541,10 @@ export function createRemoteHub(options = {}) {
|
|
|
11519
11541
|
reason: safeString(options.restartReason || 'duplicate-restart', 128)
|
|
11520
11542
|
});
|
|
11521
11543
|
if (pendingMatchesSharedOwner && !pendingMatchesRequest) {
|
|
11522
|
-
return sharedLiveProfileResult(device, pendingDescriptor, normalized, {
|
|
11523
|
-
ready: false,
|
|
11524
|
-
pending: true
|
|
11525
|
-
});
|
|
11544
|
+
return sharedLiveProfileResult(device, pendingDescriptor, normalized, {
|
|
11545
|
+
ready: false,
|
|
11546
|
+
pending: true
|
|
11547
|
+
}, options.subscriberRequestedProfile);
|
|
11526
11548
|
}
|
|
11527
11549
|
return {
|
|
11528
11550
|
ok: true,
|
|
@@ -11599,10 +11621,11 @@ export function createRemoteHub(options = {}) {
|
|
|
11599
11621
|
}
|
|
11600
11622
|
if (activeLiveStream
|
|
11601
11623
|
&& options.forceRestart !== true
|
|
11602
|
-
&& options.reuseExisting === true
|
|
11603
|
-
&& options.reuseSharedExisting === true
|
|
11604
|
-
&& liveStreamMatchesOwnerIdentity(activeLiveStream, normalized)
|
|
11605
|
-
&& liveStreamIsReusable(activeLiveStream)
|
|
11624
|
+
&& options.reuseExisting === true
|
|
11625
|
+
&& options.reuseSharedExisting === true
|
|
11626
|
+
&& liveStreamMatchesOwnerIdentity(activeLiveStream, normalized)
|
|
11627
|
+
&& liveStreamIsReusable(activeLiveStream)
|
|
11628
|
+
&& liveProfileSatisfiesDemand(activeLiveStream, normalized)) {
|
|
11606
11629
|
emitRemoteEvent('RemoteLiveStreamSharedProfileReused', device, {
|
|
11607
11630
|
streamId,
|
|
11608
11631
|
commandId: activeLiveStream.commandId,
|
|
@@ -11614,7 +11637,13 @@ export function createRemoteHub(options = {}) {
|
|
|
11614
11637
|
effectiveMaxWidth: Number(activeLiveStream.maxWidth || normalized.maxWidth),
|
|
11615
11638
|
effectiveMaxHeight: Number(activeLiveStream.maxHeight || normalized.maxHeight)
|
|
11616
11639
|
});
|
|
11617
|
-
return sharedLiveProfileResult(
|
|
11640
|
+
return sharedLiveProfileResult(
|
|
11641
|
+
device,
|
|
11642
|
+
activeLiveStream,
|
|
11643
|
+
normalized,
|
|
11644
|
+
{},
|
|
11645
|
+
options.subscriberRequestedProfile
|
|
11646
|
+
);
|
|
11618
11647
|
}
|
|
11619
11648
|
if (activeLiveStream
|
|
11620
11649
|
&& options.forceRestart !== true
|
|
@@ -11779,10 +11808,11 @@ export function createRemoteHub(options = {}) {
|
|
|
11779
11808
|
fps,
|
|
11780
11809
|
mode: transfer.mode,
|
|
11781
11810
|
frameMode: transfer.frameMode,
|
|
11782
|
-
monitorIndex,
|
|
11783
|
-
captureGeneration,
|
|
11784
|
-
ready: false
|
|
11785
|
-
|
|
11811
|
+
monitorIndex,
|
|
11812
|
+
captureGeneration,
|
|
11813
|
+
ready: false,
|
|
11814
|
+
pending: replacingActiveStream
|
|
11815
|
+
};
|
|
11786
11816
|
}
|
|
11787
11817
|
|
|
11788
11818
|
function stopLiveStream(deviceId, options = {}) {
|
package/src/server.js
CHANGED
|
@@ -35,6 +35,11 @@ import {
|
|
|
35
35
|
isReusedLiveStreamFrameReady
|
|
36
36
|
} from './live-stream-monitor-contract.js';
|
|
37
37
|
import { createLiveCaptureTransitionRetryCoordinator } from './live-capture-transition-retry.mjs';
|
|
38
|
+
import {
|
|
39
|
+
selectSharedWallSourceProfile,
|
|
40
|
+
shouldRetainWallOwnerUntilPendingStartCommits,
|
|
41
|
+
wallBindingMatchesFailedOwner
|
|
42
|
+
} from './shared-wall-profile-contract.mjs';
|
|
38
43
|
import {
|
|
39
44
|
createPlanDeviceAccessSnapshot,
|
|
40
45
|
partitionPlanDeviceIds,
|
|
@@ -61,7 +66,7 @@ import { LiveDeskSettingsStore, SettingsConflictError } from './settings/setting
|
|
|
61
66
|
import { effectiveDevicePolicy } from './settings/settings-schema.js';
|
|
62
67
|
import { buildEffectiveDevicePolicy } from './settings/effective-device-policy.js';
|
|
63
68
|
import { CaptureStore } from './captures/capture-store.js';
|
|
64
|
-
import {
|
|
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
|
+
});
|
|
@@ -259,7 +259,7 @@ function resetReconnectCandidate(target) {
|
|
|
259
259
|
target.stabilityDeadlineAt = '';
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
-
export function
|
|
262
|
+
export function createVuvoDeskUpdateManager({
|
|
263
263
|
remoteHub,
|
|
264
264
|
currentManagerVersion,
|
|
265
265
|
currentClientVersion,
|
|
@@ -138,7 +138,17 @@ test('a new Wall frame lane reuses a healthy exact capture', async () => {
|
|
|
138
138
|
restartToken: 'explicit-owner-restart'
|
|
139
139
|
});
|
|
140
140
|
assert.equal(hardRestart.ok, true);
|
|
141
|
+
assert.equal(hardRestart.pending, true);
|
|
141
142
|
assert.ok(hardRestart.captureGeneration > first.captureGeneration);
|
|
143
|
+
const pendingDevice = hub.listDevices({ includeDataUrl: false })
|
|
144
|
+
.find(device => device.deviceId === 'wall-source-device');
|
|
145
|
+
assert.equal(pendingDevice.activeLiveStream.commandId, first.commandId);
|
|
146
|
+
assert.equal(pendingDevice.activeLiveStream.captureGeneration, first.captureGeneration);
|
|
147
|
+
assert.equal(pendingDevice.activeLiveStream.pendingCommandId, hardRestart.commandId);
|
|
148
|
+
assert.equal(
|
|
149
|
+
pendingDevice.activeLiveStream.pendingCaptureGeneration,
|
|
150
|
+
hardRestart.captureGeneration
|
|
151
|
+
);
|
|
142
152
|
} finally {
|
|
143
153
|
socket?.destroy();
|
|
144
154
|
await hub.close();
|