livedesk 0.1.665 → 0.1.667
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/client/package.json +6 -6
- package/electron/electron-builder.win.yml +4 -3
- package/hub/package.json +2 -2
- package/hub/src/console-direct.js +24 -3
- package/hub/src/console-direct.test.mjs +2 -1
- package/hub/src/remote-hub.js +23 -16
- package/hub/src/server.js +73 -18
- package/hub/src/shared-wall-profile-contract.mjs +29 -0
- package/hub/src/shared-wall-profile-contract.test.mjs +62 -1
- package/hub/src/wall-source-restart-runtime.test.mjs +10 -0
- package/package.json +6 -6
- package/runtime-core/package.json +1 -1
- package/runtime-core/src/console-direct-wire.d.ts +3 -0
- package/runtime-core/src/console-direct-wire.js +46 -1
- package/runtime-core/src/console-direct-wire.test.mjs +30 -0
- package/web/dist/app.html +1 -1
- package/web/dist/app.webmanifest +1 -1
- package/web/dist/assets/{AgentSettingsTab-DGWfBrPc.js → AgentSettingsTab-BKLo1tjd.js} +1 -1
- package/web/dist/assets/{AgentsPage-B_tbuIro.js → AgentsPage-BISBICq1.js} +1 -1
- package/web/dist/assets/{CaptureGallery-DApK2pbr.js → CaptureGallery-C5sSt0WW.js} +1 -1
- package/web/dist/assets/{HubApp-VN-7Brek.js → HubApp-BkcFlqC2.js} +3 -3
- package/web/dist/assets/MonitorStackIcon-BZ7lHp45.js +8 -0
- package/web/dist/assets/{SettingsPage-DvmlbA6o.js → SettingsPage-CTj-K4i_.js} +1 -1
- package/web/dist/assets/{ShareFilesPage-D_PtItnv.js → ShareFilesPage-lrZrHWEj.js} +1 -1
- package/web/dist/assets/{SupportPage-C4euqOt-.js → SupportPage-CWRAjBtK.js} +1 -1
- package/web/dist/assets/{VuvoDeskApp-Bf9VyAzt.js → VuvoDeskApp-DGBO5xZQ.js} +1 -1
- package/web/dist/assets/{app-B4eVvvU5.js → app-BQD2DRTB.js} +2 -2
- package/web/dist/assets/{main-Sgd68vDz.js → main-DiA_rrRt.js} +2 -2
- package/web/dist/index.html +2 -2
- package/web/dist/sw.js +2 -2
- package/web/dist/vuvodesk-build-evidence.json +36 -36
- package/web/dist/assets/MonitorStackIcon-CMi3iIyv.js +0 -8
package/client/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livedesk/client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.272",
|
|
4
4
|
"description": "VuvoDesk local remote client",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -36,16 +36,16 @@
|
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
38
38
|
"ffmpeg-static": "^5.3.0",
|
|
39
|
-
"@livedesk/runtime-core": "0.1.
|
|
39
|
+
"@livedesk/runtime-core": "0.1.9",
|
|
40
40
|
"@supabase/supabase-js": "^2.110.0",
|
|
41
41
|
"node-screenshots": "^0.2.8",
|
|
42
42
|
"ws": "^8.18.3"
|
|
43
43
|
},
|
|
44
44
|
"optionalDependencies": {
|
|
45
|
-
"@livedesk/fast-linux-x64": "0.1.
|
|
46
|
-
"@livedesk/fast-osx-arm64": "0.1.
|
|
47
|
-
"@livedesk/fast-osx-x64": "0.1.
|
|
48
|
-
"@livedesk/fast-win-x64": "0.1.
|
|
45
|
+
"@livedesk/fast-linux-x64": "0.1.467",
|
|
46
|
+
"@livedesk/fast-osx-arm64": "0.1.467",
|
|
47
|
+
"@livedesk/fast-osx-x64": "0.1.467",
|
|
48
|
+
"@livedesk/fast-win-x64": "0.1.467"
|
|
49
49
|
},
|
|
50
50
|
"publishConfig": {
|
|
51
51
|
"access": "public"
|
|
@@ -10,9 +10,10 @@ files:
|
|
|
10
10
|
- web/**
|
|
11
11
|
- package.json
|
|
12
12
|
- README.md
|
|
13
|
-
- "!client/fast/linux-x64/**"
|
|
14
|
-
- "!client/fast/osx-x64/**"
|
|
15
|
-
- "!client/fast/osx-arm64/**"
|
|
13
|
+
- "!client/fast/linux-x64/**"
|
|
14
|
+
- "!client/fast/osx-x64/**"
|
|
15
|
+
- "!client/fast/osx-arm64/**"
|
|
16
|
+
- "!node_modules/ffmpeg-static/ffmpeg"
|
|
16
17
|
asarUnpack:
|
|
17
18
|
- bin/**
|
|
18
19
|
- bootstrap/**
|
package/hub/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",
|
|
@@ -16,7 +16,7 @@
|
|
|
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",
|
|
@@ -371,6 +371,8 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
371
371
|
let lastHttpStatus = 0;
|
|
372
372
|
let rejectedRelayCandidates = 0;
|
|
373
373
|
let dataChannelBackpressureCloses = 0;
|
|
374
|
+
let dataChannelSendFailures = 0;
|
|
375
|
+
let lastDataChannelSendFailure = null;
|
|
374
376
|
let malformedWireCloses = 0;
|
|
375
377
|
let wireBudgetCleanupFailures = 0;
|
|
376
378
|
|
|
@@ -730,11 +732,28 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
730
732
|
}
|
|
731
733
|
return false;
|
|
732
734
|
}
|
|
733
|
-
for (const chunk of chunks) {
|
|
735
|
+
for (const [chunkIndex, chunk] of chunks.entries()) {
|
|
734
736
|
if (!isOwnerActive(owner) || channelState.closed || !channelIsOpen(channelState.channel)) return false;
|
|
735
737
|
try {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
+
// Normalize the node-datachannel native boundary to its documented
|
|
739
|
+
// Node Buffer shape. Share the exact encoded bytes without copying or
|
|
740
|
+
// retaining a second application queue.
|
|
741
|
+
const nativeChunk = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
742
|
+
if (channelState.channel.sendMessageBinary(nativeChunk) !== true) throw new Error('send-rejected');
|
|
743
|
+
} catch (error) {
|
|
744
|
+
dataChannelSendFailures += 1;
|
|
745
|
+
lastDataChannelSendFailure = Object.freeze({
|
|
746
|
+
purpose: channelState.purpose,
|
|
747
|
+
channelId: channelState.channelId,
|
|
748
|
+
generation: owner.generation,
|
|
749
|
+
messageId: (channelState.nextSendMessageId - 1) >>> 0,
|
|
750
|
+
chunkIndex,
|
|
751
|
+
chunkCount: chunks.length,
|
|
752
|
+
chunkBytes: chunk.byteLength,
|
|
753
|
+
bufferedBytes: channelBufferedAmount(channelState),
|
|
754
|
+
error: String(error instanceof Error ? error.message : error).slice(0, 120),
|
|
755
|
+
occurredAt: Date.now()
|
|
756
|
+
});
|
|
738
757
|
if (options.closeOnFailure !== false) {
|
|
739
758
|
if (channelState === owner.control) retirePeer(owner, 'console-direct-control-send-failed');
|
|
740
759
|
else closeLogicalChannel(owner, channelState, {
|
|
@@ -1983,6 +2002,8 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1983
2002
|
+ Number(signalHeartbeatTimerActive),
|
|
1984
2003
|
rejectedRelayCandidates,
|
|
1985
2004
|
dataChannelBackpressureCloses,
|
|
2005
|
+
dataChannelSendFailures,
|
|
2006
|
+
lastDataChannelSendFailure,
|
|
1986
2007
|
malformedWireCloses,
|
|
1987
2008
|
wireBudgetCleanupFailures,
|
|
1988
2009
|
lastConnectedAt,
|
|
@@ -124,6 +124,7 @@ class FakeDataChannel {
|
|
|
124
124
|
|
|
125
125
|
sendMessageBinary(value) {
|
|
126
126
|
if (!this.isOpen()) return false;
|
|
127
|
+
if (!Buffer.isBuffer(value)) throw new TypeError('Buffer expected');
|
|
127
128
|
this.sent.push(new Uint8Array(value));
|
|
128
129
|
return true;
|
|
129
130
|
}
|
|
@@ -1498,7 +1499,7 @@ test('native node-datachannel peers carry the fragmented control wire over loopb
|
|
|
1498
1499
|
kind: 'control'
|
|
1499
1500
|
});
|
|
1500
1501
|
assert.ok(chunks.length > 1);
|
|
1501
|
-
for (const chunk of chunks) assert.equal(offerChannel.sendMessageBinary(chunk), true);
|
|
1502
|
+
for (const chunk of chunks) assert.equal(offerChannel.sendMessageBinary(Buffer.from(chunk)), true);
|
|
1502
1503
|
const message = await received;
|
|
1503
1504
|
clearTimeout(messageTimer);
|
|
1504
1505
|
messageTimer = null;
|
package/hub/src/remote-hub.js
CHANGED
|
@@ -10924,23 +10924,29 @@ export function createRemoteHub(options = {}) {
|
|
|
10924
10924
|
ensureLiveStreamReplacementTimers(device).set(stream.streamId, timer);
|
|
10925
10925
|
}
|
|
10926
10926
|
|
|
10927
|
-
function failPendingLiveStream(device, commandId, error = 'stream-start-failed') {
|
|
10927
|
+
function failPendingLiveStream(device, commandId, error = 'stream-start-failed') {
|
|
10928
10928
|
const key = safeString(commandId, 128);
|
|
10929
10929
|
if (!device || !key) {
|
|
10930
10930
|
return false;
|
|
10931
10931
|
}
|
|
10932
10932
|
const streams = ensureDeviceLiveStreams(device);
|
|
10933
10933
|
for (const stream of streams.values()) {
|
|
10934
|
-
const pending = getPendingLiveStreamDescriptor(stream);
|
|
10935
|
-
if (safeString(pending?.commandId, 128) !== key) {
|
|
10936
|
-
continue;
|
|
10937
|
-
}
|
|
10938
|
-
|
|
10939
|
-
|
|
10940
|
-
|
|
10941
|
-
|
|
10942
|
-
|
|
10943
|
-
|
|
10934
|
+
const pending = getPendingLiveStreamDescriptor(stream);
|
|
10935
|
+
if (safeString(pending?.commandId, 128) !== key) {
|
|
10936
|
+
continue;
|
|
10937
|
+
}
|
|
10938
|
+
const failedOwner = { ...pending };
|
|
10939
|
+
clearPendingLiveStreamDescriptor(device, stream);
|
|
10940
|
+
emitRemoteEvent('RemoteLiveStreamRestartFailed', device, {
|
|
10941
|
+
streamId: stream.streamId,
|
|
10942
|
+
commandId: key,
|
|
10943
|
+
streamPurpose: safeString(failedOwner.streamPurpose, 24) || 'wall',
|
|
10944
|
+
captureGeneration: Number(failedOwner.captureGeneration || 0),
|
|
10945
|
+
monitorIndex: Number(failedOwner.monitorIndex || 0),
|
|
10946
|
+
retainedCommandId: safeString(stream.commandId, 128),
|
|
10947
|
+
retainedCaptureGeneration: Number(stream.captureGeneration || 0),
|
|
10948
|
+
error: safeString(error, 500) || 'stream-start-failed'
|
|
10949
|
+
});
|
|
10944
10950
|
return true;
|
|
10945
10951
|
}
|
|
10946
10952
|
for (const stream of streams.values()) {
|
|
@@ -11293,7 +11299,7 @@ export function createRemoteHub(options = {}) {
|
|
|
11293
11299
|
}
|
|
11294
11300
|
: normalized;
|
|
11295
11301
|
return {
|
|
11296
|
-
ok: true,
|
|
11302
|
+
ok: true,
|
|
11297
11303
|
commandId: activeLiveStream.commandId,
|
|
11298
11304
|
sessionId: device.sessionId,
|
|
11299
11305
|
streamId: activeLiveStream.streamId,
|
|
@@ -11802,10 +11808,11 @@ export function createRemoteHub(options = {}) {
|
|
|
11802
11808
|
fps,
|
|
11803
11809
|
mode: transfer.mode,
|
|
11804
11810
|
frameMode: transfer.frameMode,
|
|
11805
|
-
monitorIndex,
|
|
11806
|
-
captureGeneration,
|
|
11807
|
-
ready: false
|
|
11808
|
-
|
|
11811
|
+
monitorIndex,
|
|
11812
|
+
captureGeneration,
|
|
11813
|
+
ready: false,
|
|
11814
|
+
pending: replacingActiveStream
|
|
11815
|
+
};
|
|
11809
11816
|
}
|
|
11810
11817
|
|
|
11811
11818
|
function stopLiveStream(deviceId, options = {}) {
|
package/hub/src/server.js
CHANGED
|
@@ -35,7 +35,11 @@ import {
|
|
|
35
35
|
isReusedLiveStreamFrameReady
|
|
36
36
|
} from './live-stream-monitor-contract.js';
|
|
37
37
|
import { createLiveCaptureTransitionRetryCoordinator } from './live-capture-transition-retry.mjs';
|
|
38
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
selectSharedWallSourceProfile,
|
|
40
|
+
shouldRetainWallOwnerUntilPendingStartCommits,
|
|
41
|
+
wallBindingMatchesFailedOwner
|
|
42
|
+
} from './shared-wall-profile-contract.mjs';
|
|
39
43
|
import {
|
|
40
44
|
createPlanDeviceAccessSnapshot,
|
|
41
45
|
partitionPlanDeviceIds,
|
|
@@ -436,7 +440,25 @@ function handleRemoteHubEvent(type, event) {
|
|
|
436
440
|
|| type === 'RemoteLiveStreamStopped') {
|
|
437
441
|
const liveStreamEventDeviceId = String(event?.deviceId || event?.device?.deviceId || '').trim();
|
|
438
442
|
const liveStreamEventPurpose = readRemoteLiveStreamEventPurpose(event);
|
|
439
|
-
if (
|
|
443
|
+
if (type === 'RemoteLiveStreamOpened' && liveStreamEventPurpose === 'wall') {
|
|
444
|
+
const committedOwner = event?.device?.activeLiveStream;
|
|
445
|
+
if (String(committedOwner?.commandId || '').trim() === String(event?.commandId || '').trim()
|
|
446
|
+
&& Number(committedOwner?.captureGeneration || 0) === Number(event?.captureGeneration || 0)) {
|
|
447
|
+
rebindSharedWallSubscribersToOwner(liveStreamEventDeviceId, {
|
|
448
|
+
...committedOwner,
|
|
449
|
+
sessionId: event?.device?.sessionId || ''
|
|
450
|
+
}, {
|
|
451
|
+
effectiveProfile: {
|
|
452
|
+
fps: Number(committedOwner?.fps || 0),
|
|
453
|
+
maxWidth: Number(committedOwner?.maxWidth || 0),
|
|
454
|
+
maxHeight: Number(committedOwner?.maxHeight || 0),
|
|
455
|
+
quality: Number(committedOwner?.quality || 0)
|
|
456
|
+
},
|
|
457
|
+
readySent: false,
|
|
458
|
+
reason: 'shared-wall-profile-upgrade-committed'
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
} else if ((type === 'RemoteLiveStreamStarted' || type === 'RemoteLiveStreamOpened')
|
|
440
462
|
&& liveStreamEventPurpose === 'control') {
|
|
441
463
|
readOnlyControlPresentationReconcileCoordinator
|
|
442
464
|
.cancelForControlTransition(liveStreamEventDeviceId);
|
|
@@ -454,7 +476,7 @@ function handleRemoteHubEvent(type, event) {
|
|
|
454
476
|
const failedDeviceId = String(event?.deviceId || event?.device?.deviceId || '').trim();
|
|
455
477
|
const retainedOwner = event?.device?.activeLiveStream;
|
|
456
478
|
if (String(retainedOwner?.streamPurpose || '').trim().toLowerCase() === 'wall') {
|
|
457
|
-
rebindSharedWallSubscribersToOwner(failedDeviceId, {
|
|
479
|
+
const rebound = rebindSharedWallSubscribersToOwner(failedDeviceId, {
|
|
458
480
|
...retainedOwner,
|
|
459
481
|
sessionId: event?.device?.sessionId || ''
|
|
460
482
|
}, {
|
|
@@ -465,8 +487,20 @@ function handleRemoteHubEvent(type, event) {
|
|
|
465
487
|
quality: Number(retainedOwner?.quality || 0)
|
|
466
488
|
},
|
|
467
489
|
readySent: true,
|
|
490
|
+
replaceOnlyOwner: {
|
|
491
|
+
commandId: event?.commandId || '',
|
|
492
|
+
captureGeneration: Number(event?.captureGeneration || 0)
|
|
493
|
+
},
|
|
468
494
|
reason: 'shared-wall-profile-upgrade-failed'
|
|
469
495
|
});
|
|
496
|
+
console.warn(
|
|
497
|
+
`[VuvoDesk Hub] Wall replacement failed device=${failedDeviceId} `
|
|
498
|
+
+ `attemptedCommand=${String(event?.commandId || '').slice(0, 8) || '-'} `
|
|
499
|
+
+ `attemptedGeneration=${Number(event?.captureGeneration || 0)} `
|
|
500
|
+
+ `retainedCommand=${String(event?.retainedCommandId || retainedOwner?.commandId || '').slice(0, 8) || '-'} `
|
|
501
|
+
+ `retainedGeneration=${Number(event?.retainedCaptureGeneration || retainedOwner?.captureGeneration || 0)} `
|
|
502
|
+
+ `rebound=${rebound} error=${String(event?.error || 'stream-start-failed').slice(0, 160)}`
|
|
503
|
+
);
|
|
470
504
|
}
|
|
471
505
|
}
|
|
472
506
|
if (type === 'RemoteDeviceConnected' || type === 'RemoteDeviceDisconnected') {
|
|
@@ -2681,6 +2715,7 @@ function resolveSharedWallSourceDemand(ws, deviceId, liveOptions, monitorIndex)
|
|
|
2681
2715
|
function rebindSharedWallSubscribersToOwner(deviceId, owner, {
|
|
2682
2716
|
effectiveProfile = null,
|
|
2683
2717
|
readySent = false,
|
|
2718
|
+
replaceOnlyOwner = null,
|
|
2684
2719
|
reason = 'shared-wall-profile-owner-changed'
|
|
2685
2720
|
} = {}) {
|
|
2686
2721
|
const normalizedDeviceId = String(deviceId || '').trim();
|
|
@@ -2721,6 +2756,7 @@ function rebindSharedWallSubscribersToOwner(deviceId, owner, {
|
|
|
2721
2756
|
? candidate.liveDeskExpectedStreamBindingsByDeviceId.get(normalizedDeviceId)
|
|
2722
2757
|
: null;
|
|
2723
2758
|
if (!previousBinding || previousBinding.readOnlyControlBorrow === true) continue;
|
|
2759
|
+
if (replaceOnlyOwner && !wallBindingMatchesFailedOwner(previousBinding, replaceOnlyOwner)) continue;
|
|
2724
2760
|
const nextBinding = {
|
|
2725
2761
|
deviceId: normalizedDeviceId,
|
|
2726
2762
|
sessionId,
|
|
@@ -4005,6 +4041,11 @@ function startFrameSubscriptionLive(
|
|
|
4005
4041
|
);
|
|
4006
4042
|
}
|
|
4007
4043
|
if (result?.ok) {
|
|
4044
|
+
const activeStream = device?.activeLiveStream;
|
|
4045
|
+
const retainWallOwner = shouldRetainWallOwnerUntilPendingStartCommits(
|
|
4046
|
+
result,
|
|
4047
|
+
activeStream
|
|
4048
|
+
);
|
|
4008
4049
|
const effectiveWallProfile = String(result.streamPurpose || liveOptions.streamPurpose || '')
|
|
4009
4050
|
.trim().toLowerCase() === 'wall'
|
|
4010
4051
|
? {
|
|
@@ -4015,6 +4056,7 @@ function startFrameSubscriptionLive(
|
|
|
4015
4056
|
}
|
|
4016
4057
|
: result.effectiveProfile || null;
|
|
4017
4058
|
if (result.reused !== true
|
|
4059
|
+
&& retainWallOwner !== true
|
|
4018
4060
|
&& String(result.streamPurpose || '').trim().toLowerCase() === 'wall') {
|
|
4019
4061
|
rebindSharedWallSubscribersToOwner(deviceId, result, {
|
|
4020
4062
|
effectiveProfile: effectiveWallProfile,
|
|
@@ -4029,27 +4071,40 @@ function startFrameSubscriptionLive(
|
|
|
4029
4071
|
) + 1;
|
|
4030
4072
|
}
|
|
4031
4073
|
frameCaptureTransitionRetries.complete(ws, deviceId, intentGeneration, 'capture-started');
|
|
4074
|
+
const bindingOwner = retainWallOwner
|
|
4075
|
+
? {
|
|
4076
|
+
...activeStream,
|
|
4077
|
+
sessionId: String(device?.sessionId || result.sessionId || '')
|
|
4078
|
+
}
|
|
4079
|
+
: result;
|
|
4080
|
+
const bindingEffectiveWallProfile = retainWallOwner
|
|
4081
|
+
? {
|
|
4082
|
+
fps: Number(activeStream?.fps || 0),
|
|
4083
|
+
maxWidth: Number(activeStream?.maxWidth || 0),
|
|
4084
|
+
maxHeight: Number(activeStream?.maxHeight || 0),
|
|
4085
|
+
quality: Number(activeStream?.quality || 0)
|
|
4086
|
+
}
|
|
4087
|
+
: effectiveWallProfile;
|
|
4032
4088
|
const expectedBinding = {
|
|
4033
|
-
deviceId,
|
|
4034
|
-
sessionId: String(
|
|
4035
|
-
streamId: String(
|
|
4036
|
-
streamPurpose: String(
|
|
4037
|
-
commandId: String(
|
|
4038
|
-
captureGeneration: Number(
|
|
4039
|
-
monitorIndex: Number(
|
|
4040
|
-
readOnlyControlBorrow:
|
|
4041
|
-
presentationPurpose:
|
|
4042
|
-
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,
|
|
4043
4099
|
readySent: false
|
|
4044
|
-
};
|
|
4045
|
-
const activeStream = device?.activeLiveStream;
|
|
4100
|
+
};
|
|
4046
4101
|
// A new read-only browser presentation has no decoder history from the
|
|
4047
4102
|
// already-running Control stream. It must enter through the next exact
|
|
4048
4103
|
// key frame even when the native owner itself is already ready.
|
|
4049
4104
|
const reusedFrameReady = expectedBinding.readOnlyControlBorrow !== true
|
|
4050
4105
|
&& isReusedLiveStreamFrameReady(result, activeStream, expectedBinding);
|
|
4051
4106
|
expectedBinding.readySent = reusedFrameReady;
|
|
4052
|
-
ws.liveDeskStreamIdsByDeviceId.set(deviceId,
|
|
4107
|
+
ws.liveDeskStreamIdsByDeviceId.set(deviceId, expectedBinding.streamId);
|
|
4053
4108
|
const installedBindingIdentity = replaceExpectedFrameBindingForClient(
|
|
4054
4109
|
ws,
|
|
4055
4110
|
deviceId,
|
|
@@ -4058,9 +4113,9 @@ function startFrameSubscriptionLive(
|
|
|
4058
4113
|
if (!installedBindingIdentity) {
|
|
4059
4114
|
skipped.push({ deviceId, reason: 'invalid-live-stream-binding' });
|
|
4060
4115
|
continue;
|
|
4061
|
-
}
|
|
4116
|
+
}
|
|
4062
4117
|
if (expectedBinding.readOnlyControlBorrow !== true) {
|
|
4063
|
-
cancelPendingFrameStreamStop(deviceId,
|
|
4118
|
+
cancelPendingFrameStreamStop(deviceId, expectedBinding.streamId, expectedBinding.streamPurpose);
|
|
4064
4119
|
}
|
|
4065
4120
|
started.push({
|
|
4066
4121
|
deviceId: expectedBinding.deviceId,
|
|
@@ -38,3 +38,32 @@ export function liveProfileSatisfiesDemand(activeProfile, requestedProfile) {
|
|
|
38
38
|
&& finiteProfileNumber(activeProfile.maxHeight) >= finiteProfileNumber(requestedProfile.maxHeight)
|
|
39
39
|
&& finiteProfileNumber(activeProfile.quality) >= finiteProfileNumber(requestedProfile.quality);
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
export function shouldRetainWallOwnerUntilPendingStartCommits(startResult, activeOwner) {
|
|
43
|
+
if (!startResult || startResult.pending !== true || !activeOwner) return false;
|
|
44
|
+
const startPurpose = String(startResult.streamPurpose || '').trim().toLowerCase();
|
|
45
|
+
const activePurpose = String(activeOwner.streamPurpose || '').trim().toLowerCase();
|
|
46
|
+
const startStreamId = String(startResult.streamId || '').trim();
|
|
47
|
+
const activeStreamId = String(activeOwner.streamId || '').trim();
|
|
48
|
+
const activeCommandId = String(activeOwner.commandId || '').trim();
|
|
49
|
+
const activeGeneration = Number(activeOwner.captureGeneration || 0);
|
|
50
|
+
return startPurpose === 'wall'
|
|
51
|
+
&& activePurpose === 'wall'
|
|
52
|
+
&& !!startStreamId
|
|
53
|
+
&& startStreamId === activeStreamId
|
|
54
|
+
&& !!activeCommandId
|
|
55
|
+
&& Number.isSafeInteger(activeGeneration)
|
|
56
|
+
&& activeGeneration > 0
|
|
57
|
+
&& activeOwner.active === true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function wallBindingMatchesFailedOwner(binding, failedOwner) {
|
|
61
|
+
const commandId = String(failedOwner?.commandId || '').trim();
|
|
62
|
+
const captureGeneration = Number(failedOwner?.captureGeneration || 0);
|
|
63
|
+
return !!binding
|
|
64
|
+
&& !!commandId
|
|
65
|
+
&& Number.isSafeInteger(captureGeneration)
|
|
66
|
+
&& captureGeneration > 0
|
|
67
|
+
&& String(binding.commandId || '').trim() === commandId
|
|
68
|
+
&& Number(binding.captureGeneration || 0) === captureGeneration;
|
|
69
|
+
}
|
|
@@ -3,7 +3,9 @@ import test from 'node:test';
|
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
liveProfileSatisfiesDemand,
|
|
6
|
-
selectSharedWallSourceProfile
|
|
6
|
+
selectSharedWallSourceProfile,
|
|
7
|
+
shouldRetainWallOwnerUntilPendingStartCommits,
|
|
8
|
+
wallBindingMatchesFailedOwner
|
|
7
9
|
} from './shared-wall-profile-contract.mjs';
|
|
8
10
|
|
|
9
11
|
test('shared Wall source keeps the strongest field from every current viewer', () => {
|
|
@@ -36,3 +38,62 @@ test('a shared source is reused only when it satisfies every requested profile f
|
|
|
36
38
|
assert.equal(liveProfileSatisfiesDemand(low, high), false);
|
|
37
39
|
assert.equal(liveProfileSatisfiesDemand({ ...high, fps: 5 }, high), false);
|
|
38
40
|
});
|
|
41
|
+
|
|
42
|
+
test('a pending Wall replacement keeps the current browser owner until a real frame commits it', () => {
|
|
43
|
+
const activeOwner = {
|
|
44
|
+
active: true,
|
|
45
|
+
streamId: 'wall-device',
|
|
46
|
+
streamPurpose: 'wall',
|
|
47
|
+
commandId: 'wall-command-current',
|
|
48
|
+
captureGeneration: 41
|
|
49
|
+
};
|
|
50
|
+
const pendingStart = {
|
|
51
|
+
ok: true,
|
|
52
|
+
pending: true,
|
|
53
|
+
streamId: 'wall-device',
|
|
54
|
+
streamPurpose: 'wall',
|
|
55
|
+
commandId: 'wall-command-pending',
|
|
56
|
+
captureGeneration: 42
|
|
57
|
+
};
|
|
58
|
+
assert.equal(
|
|
59
|
+
shouldRetainWallOwnerUntilPendingStartCommits(pendingStart, activeOwner),
|
|
60
|
+
true
|
|
61
|
+
);
|
|
62
|
+
assert.equal(
|
|
63
|
+
shouldRetainWallOwnerUntilPendingStartCommits({ ...pendingStart, pending: false }, activeOwner),
|
|
64
|
+
false
|
|
65
|
+
);
|
|
66
|
+
assert.equal(
|
|
67
|
+
shouldRetainWallOwnerUntilPendingStartCommits(pendingStart, {
|
|
68
|
+
...activeOwner,
|
|
69
|
+
streamPurpose: 'control'
|
|
70
|
+
}),
|
|
71
|
+
false
|
|
72
|
+
);
|
|
73
|
+
assert.equal(
|
|
74
|
+
shouldRetainWallOwnerUntilPendingStartCommits({
|
|
75
|
+
...pendingStart,
|
|
76
|
+
streamId: 'wall-other'
|
|
77
|
+
}, activeOwner),
|
|
78
|
+
false
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('a late failed upgrade cannot rewind a newer Wall binding', () => {
|
|
83
|
+
const failedOwner = {
|
|
84
|
+
commandId: 'wall-command-42',
|
|
85
|
+
captureGeneration: 42
|
|
86
|
+
};
|
|
87
|
+
assert.equal(wallBindingMatchesFailedOwner({
|
|
88
|
+
commandId: 'wall-command-42',
|
|
89
|
+
captureGeneration: 42
|
|
90
|
+
}, failedOwner), true);
|
|
91
|
+
assert.equal(wallBindingMatchesFailedOwner({
|
|
92
|
+
commandId: 'wall-command-43',
|
|
93
|
+
captureGeneration: 43
|
|
94
|
+
}, failedOwner), false);
|
|
95
|
+
assert.equal(wallBindingMatchesFailedOwner({
|
|
96
|
+
commandId: 'wall-command-42',
|
|
97
|
+
captureGeneration: 43
|
|
98
|
+
}, failedOwner), false);
|
|
99
|
+
});
|
|
@@ -138,7 +138,17 @@ test('a new Wall frame lane reuses a healthy exact capture', async () => {
|
|
|
138
138
|
restartToken: 'explicit-owner-restart'
|
|
139
139
|
});
|
|
140
140
|
assert.equal(hardRestart.ok, true);
|
|
141
|
+
assert.equal(hardRestart.pending, true);
|
|
141
142
|
assert.ok(hardRestart.captureGeneration > first.captureGeneration);
|
|
143
|
+
const pendingDevice = hub.listDevices({ includeDataUrl: false })
|
|
144
|
+
.find(device => device.deviceId === 'wall-source-device');
|
|
145
|
+
assert.equal(pendingDevice.activeLiveStream.commandId, first.commandId);
|
|
146
|
+
assert.equal(pendingDevice.activeLiveStream.captureGeneration, first.captureGeneration);
|
|
147
|
+
assert.equal(pendingDevice.activeLiveStream.pendingCommandId, hardRestart.commandId);
|
|
148
|
+
assert.equal(
|
|
149
|
+
pendingDevice.activeLiveStream.pendingCaptureGeneration,
|
|
150
|
+
hardRestart.captureGeneration
|
|
151
|
+
);
|
|
142
152
|
} finally {
|
|
143
153
|
socket?.destroy();
|
|
144
154
|
await hub.close();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livedesk",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"livedeskClientVersion": "0.1.
|
|
3
|
+
"version": "0.1.667",
|
|
4
|
+
"livedeskClientVersion": "0.1.272",
|
|
5
5
|
"buildFlavor": "production",
|
|
6
6
|
"description": "VuvoDesk Hub and client launcher",
|
|
7
7
|
"type": "module",
|
|
@@ -54,10 +54,10 @@
|
|
|
54
54
|
"ws": "^8.18.3"
|
|
55
55
|
},
|
|
56
56
|
"optionalDependencies": {
|
|
57
|
-
"@livedesk/fast-linux-x64": "0.1.
|
|
58
|
-
"@livedesk/fast-osx-arm64": "0.1.
|
|
59
|
-
"@livedesk/fast-osx-x64": "0.1.
|
|
60
|
-
"@livedesk/fast-win-x64": "0.1.
|
|
57
|
+
"@livedesk/fast-linux-x64": "0.1.467",
|
|
58
|
+
"@livedesk/fast-osx-arm64": "0.1.467",
|
|
59
|
+
"@livedesk/fast-osx-x64": "0.1.467",
|
|
60
|
+
"@livedesk/fast-win-x64": "0.1.467"
|
|
61
61
|
},
|
|
62
62
|
"publishConfig": {
|
|
63
63
|
"access": "public"
|
|
@@ -64,10 +64,13 @@ export interface DirectConsoleWireAssemblerInspection {
|
|
|
64
64
|
readonly maxPendingMessages: number;
|
|
65
65
|
readonly pendingMessages: number;
|
|
66
66
|
readonly pendingBytes: number;
|
|
67
|
+
readonly abandonedMessages: number;
|
|
68
|
+
readonly abandonedChunksRemaining: number;
|
|
67
69
|
readonly budget: DirectConsoleWireBudgetInspection;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
export interface DirectConsoleWireAssembler {
|
|
73
|
+
abandonPending(): DirectConsoleWireAssemblerInspection;
|
|
71
74
|
push(chunk: DirectConsoleWireChunk): DirectConsoleWireResult | null;
|
|
72
75
|
inspect(): DirectConsoleWireAssemblerInspection;
|
|
73
76
|
dispose(): DirectConsoleWireAssemblerInspection;
|
|
@@ -239,6 +239,7 @@ export function createDirectConsoleWireAssembler(options = undefined) {
|
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
const pending = new Map();
|
|
242
|
+
const abandoned = new Map();
|
|
242
243
|
let disposed = false;
|
|
243
244
|
|
|
244
245
|
const releasePending = () => {
|
|
@@ -246,15 +247,23 @@ export function createDirectConsoleWireAssembler(options = undefined) {
|
|
|
246
247
|
pending.clear();
|
|
247
248
|
};
|
|
248
249
|
|
|
250
|
+
const releaseAbandoned = () => {
|
|
251
|
+
abandoned.clear();
|
|
252
|
+
};
|
|
253
|
+
|
|
249
254
|
const inspect = () => {
|
|
250
255
|
let pendingBytes = 0;
|
|
251
256
|
for (const state of pending.values()) pendingBytes += state.totalLength;
|
|
257
|
+
let abandonedChunksRemaining = 0;
|
|
258
|
+
for (const state of abandoned.values()) abandonedChunksRemaining += state.remainingChunks;
|
|
252
259
|
return Object.freeze({
|
|
253
260
|
disposed,
|
|
254
261
|
maxMessageBytes,
|
|
255
262
|
maxPendingMessages,
|
|
256
263
|
pendingMessages: pending.size,
|
|
257
264
|
pendingBytes,
|
|
265
|
+
abandonedMessages: abandoned.size,
|
|
266
|
+
abandonedChunksRemaining,
|
|
258
267
|
budget: budget.inspect()
|
|
259
268
|
});
|
|
260
269
|
};
|
|
@@ -263,6 +272,7 @@ export function createDirectConsoleWireAssembler(options = undefined) {
|
|
|
263
272
|
if (!disposed) {
|
|
264
273
|
disposed = true;
|
|
265
274
|
releasePending();
|
|
275
|
+
releaseAbandoned();
|
|
266
276
|
}
|
|
267
277
|
return inspect();
|
|
268
278
|
};
|
|
@@ -270,6 +280,7 @@ export function createDirectConsoleWireAssembler(options = undefined) {
|
|
|
270
280
|
const closeWithError = error => {
|
|
271
281
|
disposed = true;
|
|
272
282
|
releasePending();
|
|
283
|
+
releaseAbandoned();
|
|
273
284
|
if (error instanceof DirectConsoleWireError) throw error;
|
|
274
285
|
throw new DirectConsoleWireError('wire-chunk-malformed');
|
|
275
286
|
};
|
|
@@ -278,6 +289,20 @@ export function createDirectConsoleWireAssembler(options = undefined) {
|
|
|
278
289
|
if (disposed) fail('assembler-disposed');
|
|
279
290
|
try {
|
|
280
291
|
const header = parseHeader(input, maxMessageBytes);
|
|
292
|
+
const abandonedState = abandoned.get(header.messageId);
|
|
293
|
+
if (abandonedState) {
|
|
294
|
+
if (abandonedState.kind !== header.kind
|
|
295
|
+
|| abandonedState.chunkCount !== header.chunkCount
|
|
296
|
+
|| abandonedState.totalLength !== header.totalLength) {
|
|
297
|
+
fail('wire-message-metadata-mismatch');
|
|
298
|
+
}
|
|
299
|
+
if (abandonedState.receivedChunks[header.chunkIndex] === 0) {
|
|
300
|
+
abandonedState.receivedChunks[header.chunkIndex] = 1;
|
|
301
|
+
abandonedState.remainingChunks -= 1;
|
|
302
|
+
}
|
|
303
|
+
if (abandonedState.remainingChunks <= 0) abandoned.delete(header.messageId);
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
281
306
|
let state = pending.get(header.messageId);
|
|
282
307
|
if (!state) {
|
|
283
308
|
if (pending.size >= maxPendingMessages) {
|
|
@@ -347,5 +372,25 @@ export function createDirectConsoleWireAssembler(options = undefined) {
|
|
|
347
372
|
}
|
|
348
373
|
};
|
|
349
374
|
|
|
350
|
-
|
|
375
|
+
const abandonPending = () => {
|
|
376
|
+
if (disposed) fail('assembler-disposed');
|
|
377
|
+
for (const [messageId, state] of pending.entries()) {
|
|
378
|
+
if (abandoned.size >= DIRECT_CONSOLE_MAX_CONFIGURED_PENDING_MESSAGES) {
|
|
379
|
+
const oldestMessageId = abandoned.keys().next().value;
|
|
380
|
+
abandoned.delete(oldestMessageId);
|
|
381
|
+
}
|
|
382
|
+
abandoned.set(messageId, {
|
|
383
|
+
kind: state.kind,
|
|
384
|
+
chunkCount: state.chunkCount,
|
|
385
|
+
totalLength: state.totalLength,
|
|
386
|
+
receivedChunks: new Uint8Array(state.receivedChunks),
|
|
387
|
+
remainingChunks: Math.max(0, state.chunkCount - state.receivedChunkCount)
|
|
388
|
+
});
|
|
389
|
+
state.reservation.release();
|
|
390
|
+
}
|
|
391
|
+
pending.clear();
|
|
392
|
+
return inspect();
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
return Object.freeze({ abandonPending, dispose, inspect, push });
|
|
351
396
|
}
|