livedesk 0.1.690 → 0.1.692
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 +5 -5
- package/hub/package.json +1 -1
- package/hub/src/console-direct.js +5 -1
- package/hub/src/console-direct.test.mjs +18 -9
- package/hub/src/monitor-capture-ownership.mjs +15 -0
- package/hub/src/monitor-capture-ownership.test.mjs +16 -0
- package/hub/src/monitor-capture-runtime.test.mjs +121 -0
- package/hub/src/monitor-capture-viewers.test.mjs +229 -0
- package/hub/src/remote-hub.js +134 -45
- package/hub/src/server.js +11 -6
- package/package.json +6 -6
- package/web/dist/app.html +1 -1
- package/web/dist/app.webmanifest +1 -1
- package/web/dist/assets/{AgentSettingsTab-Cl9KYjS7.js → AgentSettingsTab-DO2Z6NkJ.js} +1 -1
- package/web/dist/assets/{AgentsPage-Y_SSQ_XG.js → AgentsPage-_shsjGZi.js} +1 -1
- package/web/dist/assets/{CaptureGallery-3vGYG6xD.js → CaptureGallery-YQRtdPdf.js} +1 -1
- package/web/dist/assets/{HubApp-CDv_1fSd.js → HubApp-Cl6KXmZZ.js} +12 -12
- package/web/dist/assets/{MonitorStackIcon-B-za6UQS.js → MonitorStackIcon-BCZRKGg9.js} +1 -1
- package/web/dist/assets/{SettingsPage-BsBbyNcu.js → SettingsPage-fj5cHPfI.js} +1 -1
- package/web/dist/assets/{ShareFilesPage-DepVIlkU.js → ShareFilesPage-DwpF_ImR.js} +1 -1
- package/web/dist/assets/{SupportPage-CoPtEeAj.js → SupportPage-FMuoakR7.js} +1 -1
- package/web/dist/assets/{VuvoDeskApp-DHTLitJO.js → VuvoDeskApp-BG169yB2.js} +1 -1
- package/web/dist/assets/{app-pSkBesmR.js → app-Cy9L1xCR.js} +2 -2
- package/web/dist/assets/{main-BW89RvBN.js → main-BL1yIEvm.js} +2 -2
- package/web/dist/index.html +2 -2
- package/web/dist/sw.js +2 -2
- package/web/dist/vuvodesk-build-evidence.json +39 -39
package/client/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livedesk/client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.276",
|
|
4
4
|
"description": "VuvoDesk local remote client",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,10 +42,10 @@
|
|
|
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.471",
|
|
46
|
+
"@livedesk/fast-osx-arm64": "0.1.471",
|
|
47
|
+
"@livedesk/fast-osx-x64": "0.1.471",
|
|
48
|
+
"@livedesk/fast-win-x64": "0.1.471"
|
|
49
49
|
},
|
|
50
50
|
"publishConfig": {
|
|
51
51
|
"access": "public"
|
package/hub/package.json
CHANGED
|
@@ -1343,7 +1343,11 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1343
1343
|
iceTransportPolicy: 'all',
|
|
1344
1344
|
disableAutoNegotiation: false,
|
|
1345
1345
|
disableFingerprintVerification: false,
|
|
1346
|
-
|
|
1346
|
+
// Each native send is one wire chunk. libdatachannel also uses this
|
|
1347
|
+
// setting as the minimum SCTP socket buffer size, below bufferedAmount.
|
|
1348
|
+
// A whole-frame limit here hides megabytes from our admission gate.
|
|
1349
|
+
// Complete frame/HTTP limits remain enforced by the wire assemblers.
|
|
1350
|
+
maxMessageSize: DIRECT_CONSOLE_WIRE_CHUNK_BYTES
|
|
1347
1351
|
});
|
|
1348
1352
|
} catch (error) {
|
|
1349
1353
|
lastError = error instanceof Error ? error.message : String(error);
|
|
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs';
|
|
|
4
4
|
import test from 'node:test';
|
|
5
5
|
import nodeDataChannel from 'node-datachannel';
|
|
6
6
|
import {
|
|
7
|
+
DIRECT_CONSOLE_WIRE_CHUNK_BYTES,
|
|
7
8
|
createDirectConsoleWireAssembler,
|
|
8
9
|
encodeDirectConsoleWireMessage
|
|
9
10
|
} from '../../runtime-core/src/console-direct-wire.js';
|
|
@@ -358,6 +359,8 @@ test('STUN-only peer answers and rejects stale owner callbacks after replacement
|
|
|
358
359
|
assert.deepEqual(firstPeer.config.iceServers, ['stun:stun.example.test:3478']);
|
|
359
360
|
assert.equal(firstPeer.config.iceTransportPolicy, 'all');
|
|
360
361
|
assert.equal(firstPeer.config.disableFingerprintVerification, false);
|
|
362
|
+
assert.equal(firstPeer.config.maxMessageSize, DIRECT_CONSOLE_WIRE_CHUNK_BYTES,
|
|
363
|
+
'native SCTP accepts wire chunks, not whole frames; a frame-sized setting silently enlarges its hidden buffers');
|
|
361
364
|
assert.equal(signalingMessages(signal).some(message => (
|
|
362
365
|
message.type === 'rtc-answer' && message.connectionId === CONNECTION_ONE
|
|
363
366
|
)), true);
|
|
@@ -1566,18 +1569,19 @@ test('server session refresh preserves peers while logout uses the revoking clos
|
|
|
1566
1569
|
assert.doesNotMatch(logoutBlock, /hubConsoleDirect\?\.refresh\(\)/);
|
|
1567
1570
|
});
|
|
1568
1571
|
|
|
1569
|
-
test('native
|
|
1572
|
+
test('native chunk-sized SCTP accepts a full-size fragmented frame over loopback', { timeout: 8_000 }, async t => {
|
|
1570
1573
|
let offerer = null;
|
|
1571
1574
|
let answerer = null;
|
|
1572
1575
|
let offerChannel = null;
|
|
1573
1576
|
let answerChannel = null;
|
|
1574
|
-
const assembler = createDirectConsoleWireAssembler();
|
|
1577
|
+
const assembler = createDirectConsoleWireAssembler({ maxMessageBytes: consoleDirectContract.maxFrameMessageBytes });
|
|
1575
1578
|
let openTimer = null;
|
|
1576
1579
|
let messageTimer = null;
|
|
1577
1580
|
try {
|
|
1578
1581
|
nodeDataChannel.setSctpSettings({ sendBufferSize: 64 * 1024 });
|
|
1579
|
-
|
|
1580
|
-
|
|
1582
|
+
const config = { iceServers: [], maxMessageSize: DIRECT_CONSOLE_WIRE_CHUNK_BYTES };
|
|
1583
|
+
offerer = new nodeDataChannel.PeerConnection('console-direct-native-offerer', config);
|
|
1584
|
+
answerer = new nodeDataChannel.PeerConnection('console-direct-native-answerer', config);
|
|
1581
1585
|
offerer.onLocalDescription((sdp, type) => {
|
|
1582
1586
|
answerer.setRemoteDescription(sdp, type);
|
|
1583
1587
|
});
|
|
@@ -1611,11 +1615,16 @@ test('native node-datachannel buffered sends deliver the complete fragmented wir
|
|
|
1611
1615
|
await opened;
|
|
1612
1616
|
clearTimeout(openTimer);
|
|
1613
1617
|
openTimer = null;
|
|
1614
|
-
|
|
1615
|
-
|
|
1618
|
+
assert.match(offerer.localDescription().sdp, /a=max-message-size:16384(?:\r?\n|$)/);
|
|
1619
|
+
assert.match(answerer.localDescription().sdp, /a=max-message-size:16384(?:\r?\n|$)/);
|
|
1620
|
+
assert.throws(() => offerChannel.sendMessageBinary(Buffer.alloc(DIRECT_CONSOLE_WIRE_CHUNK_BYTES + 1)),
|
|
1621
|
+
/[Mm]essage.*large|[Ss]ize/);
|
|
1622
|
+
const expected = Buffer.alloc(consoleDirectContract.maxFrameMessageBytes);
|
|
1623
|
+
for (let n = 0; n < expected.length; n += 1) expected[n] = n % 251;
|
|
1616
1624
|
const chunks = encodeDirectConsoleWireMessage(expected, {
|
|
1617
1625
|
messageId: 77,
|
|
1618
|
-
kind: '
|
|
1626
|
+
kind: 'binary',
|
|
1627
|
+
maxMessageBytes: consoleDirectContract.maxFrameMessageBytes
|
|
1619
1628
|
});
|
|
1620
1629
|
assert.ok(chunks.length > 1);
|
|
1621
1630
|
let bufferedSends = 0;
|
|
@@ -1626,8 +1635,8 @@ test('native node-datachannel buffered sends deliver the complete fragmented wir
|
|
|
1626
1635
|
const message = await received;
|
|
1627
1636
|
clearTimeout(messageTimer);
|
|
1628
1637
|
messageTimer = null;
|
|
1629
|
-
assert.equal(message.kind, '
|
|
1630
|
-
assert.
|
|
1638
|
+
assert.equal(message.kind, 'binary');
|
|
1639
|
+
assert.deepEqual(Buffer.from(message.data), expected);
|
|
1631
1640
|
t.diagnostic(`native buffered sends=${bufferedSends}; complete bytes=${message.byteLength}; chunks=${chunks.length}`);
|
|
1632
1641
|
} finally {
|
|
1633
1642
|
if (openTimer) clearTimeout(openTimer);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Capture sharing is scoped to one physical monitor. Capability negotiation
|
|
2
|
+
// keeps old Clients on their existing exclusive-capture protocol.
|
|
3
|
+
export function supportsMonitorCapture(device) {
|
|
4
|
+
return device?.capabilities?.liveCaptureConcurrency === 'per-monitor';
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function captureOwnersCompete(existing, requested, perMonitor) {
|
|
8
|
+
if (!perMonitor) return true;
|
|
9
|
+
const existingMonitor = existing?.monitorIndex;
|
|
10
|
+
const requestedMonitor = requested?.monitorIndex;
|
|
11
|
+
if (!Number.isInteger(existingMonitor) || !Number.isInteger(requestedMonitor)) return true;
|
|
12
|
+
// Input ownership remains exclusive per computer, even on different screens.
|
|
13
|
+
return existingMonitor === requestedMonitor
|
|
14
|
+
|| (existing.streamPurpose === 'control' && requested.streamPurpose === 'control');
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { captureOwnersCompete, supportsMonitorCapture } from './monitor-capture-ownership.mjs';
|
|
4
|
+
|
|
5
|
+
test('different monitors keep independent capture but the same monitor shares one owner', () => {
|
|
6
|
+
const wall = { monitorIndex: 0, streamPurpose: 'wall' };
|
|
7
|
+
assert.equal(captureOwnersCompete(wall, { ...wall, monitorIndex: 1 }, true), false);
|
|
8
|
+
assert.equal(captureOwnersCompete(wall, { ...wall, streamPurpose: 'control' }, true), true);
|
|
9
|
+
assert.equal(captureOwnersCompete(wall, { monitorIndex: 1, streamPurpose: 'control' }, true), false);
|
|
10
|
+
assert.equal(captureOwnersCompete({ ...wall, streamPurpose: 'control' },
|
|
11
|
+
{ monitorIndex: 1, streamPurpose: 'control' }, true), true);
|
|
12
|
+
assert.equal(captureOwnersCompete(wall, { ...wall, monitorIndex: 1 }, false), true);
|
|
13
|
+
assert.equal(captureOwnersCompete(wall, { streamPurpose: 'wall' }, true), true);
|
|
14
|
+
assert.equal(supportsMonitorCapture({ capabilities: { liveCaptureConcurrency: 'per-monitor' } }), true);
|
|
15
|
+
assert.equal(supportsMonitorCapture({ capabilities: { liveCaptureConcurrency: 'exclusive' } }), false);
|
|
16
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { once } from 'node:events';
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import { createRemoteHub } from './remote-hub.js';
|
|
6
|
+
|
|
7
|
+
const keyFrame = Buffer.from([
|
|
8
|
+
0, 0, 0, 1, 0x67, 0x42, 0, 0x1f,
|
|
9
|
+
0, 0, 0, 1, 0x68, 0xce, 6, 0xe2,
|
|
10
|
+
0, 0, 0, 1, 0x65, 0x88, 0x84
|
|
11
|
+
]).toString('base64');
|
|
12
|
+
|
|
13
|
+
test('two monitor viewers and a Control handoff cannot replace or stop the other monitor', async () => {
|
|
14
|
+
const hub = createRemoteHub({
|
|
15
|
+
env: { ...process.env, LIVEDESK_REMOTE_HUB: '1', REMOTE_HUB_HOST: '127.0.0.1', REMOTE_HUB_PORT: '0' },
|
|
16
|
+
pairToken: 'monitor-owner-fixture'
|
|
17
|
+
});
|
|
18
|
+
let socket;
|
|
19
|
+
const commands = [];
|
|
20
|
+
const active = new Map();
|
|
21
|
+
const waitUntil = async predicate => {
|
|
22
|
+
const deadline = Date.now() + 2_000;
|
|
23
|
+
while (Date.now() < deadline) {
|
|
24
|
+
if (predicate()) return;
|
|
25
|
+
await new Promise(resolve => setTimeout(resolve, 5));
|
|
26
|
+
}
|
|
27
|
+
throw new Error('monitor owner fixture deadline');
|
|
28
|
+
};
|
|
29
|
+
try {
|
|
30
|
+
const status = await hub.start();
|
|
31
|
+
socket = net.createConnection({ host: '127.0.0.1', port: status.port });
|
|
32
|
+
await once(socket, 'connect');
|
|
33
|
+
const send = message => socket.write(`${JSON.stringify(message)}\n`);
|
|
34
|
+
let welcomed = false;
|
|
35
|
+
let input = '';
|
|
36
|
+
let frameSeq = 0;
|
|
37
|
+
const publishFrame = (streamId, payload, commandId) => {
|
|
38
|
+
send({ type: 'stream.open', ...payload, streamId, commandId, width: 960, height: 540 });
|
|
39
|
+
send({ type: 'stream.frame', ...payload, streamId, commandId, frameSeq: ++frameSeq,
|
|
40
|
+
width: 960, height: 540, mimeType: 'video/h264', isKeyFrame: true, chunkType: 'key', data: keyFrame });
|
|
41
|
+
};
|
|
42
|
+
socket.on('data', chunk => {
|
|
43
|
+
input += chunk.toString('utf8');
|
|
44
|
+
let newline;
|
|
45
|
+
while ((newline = input.indexOf('\n')) >= 0) {
|
|
46
|
+
const line = input.slice(0, newline); input = input.slice(newline + 1);
|
|
47
|
+
if (!line.trim()) continue;
|
|
48
|
+
const message = JSON.parse(line);
|
|
49
|
+
if (message.type === 'welcome') welcomed = true;
|
|
50
|
+
if (message.type !== 'command') continue;
|
|
51
|
+
commands.push(message);
|
|
52
|
+
if (message.command === 'stream.start') {
|
|
53
|
+
const p = message.payload;
|
|
54
|
+
for (const [id, owner] of active) {
|
|
55
|
+
assert.ok(id === p.streamId || owner.monitorIndex !== p.monitorIndex,
|
|
56
|
+
'same monitor cannot overlap a second native owner');
|
|
57
|
+
}
|
|
58
|
+
active.set(p.streamId, { ...p, commandId: message.commandId });
|
|
59
|
+
send({ type: 'command.result', commandId: message.commandId, result: {
|
|
60
|
+
stream: true, started: true, ...p, width: 960, height: 540
|
|
61
|
+
} });
|
|
62
|
+
publishFrame(p.streamId, p, message.commandId);
|
|
63
|
+
} else if (message.command === 'stream.stop') {
|
|
64
|
+
const streamId = message.payload.streamId;
|
|
65
|
+
if (streamId) active.delete(streamId); else active.clear();
|
|
66
|
+
send({ type: 'command.result', commandId: message.commandId,
|
|
67
|
+
result: { stream: true, stopped: true, streamId, stoppedCount: 1 } });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
send({ type: 'hello', pairToken: 'monitor-owner-fixture', deviceId: 'monitor-device',
|
|
72
|
+
deviceName: 'Monitor fixture', hostname: 'monitor-fixture', platform: 'darwin', arch: 'arm64',
|
|
73
|
+
protocol: 'mindexec.remote.agent', protocolVersion: 2,
|
|
74
|
+
capabilities: { liveStream: true, liveCaptureConcurrency: 'per-monitor', frameProtocol: {}, frameModes: [] } });
|
|
75
|
+
await waitUntil(() => welcomed);
|
|
76
|
+
const options = { streamPurpose: 'wall', mode: 'mode3-h264-hw', frameMode: 'mode3-h264-hw',
|
|
77
|
+
fps: 30, maxWidth: 960, maxHeight: 540, quality: 68, reuseExisting: true, reuseSharedExisting: true };
|
|
78
|
+
const first = hub.startLiveStream('monitor-device', { ...options, monitorIndex: 0 });
|
|
79
|
+
assert.equal(first.ok, true);
|
|
80
|
+
await waitUntil(() => hub.getDeviceLiveFrame('monitor-device', { streamId: first.streamId })?.currentGenerationVerified);
|
|
81
|
+
const second = hub.startLiveStream('monitor-device', { ...options, monitorIndex: 1 });
|
|
82
|
+
assert.equal(second.ok, true);
|
|
83
|
+
assert.notEqual(second.streamId, first.streamId, 'Wall stream ids must include monitor ownership');
|
|
84
|
+
await waitUntil(() => hub.getDeviceLiveFrame('monitor-device', { streamId: second.streamId })?.currentGenerationVerified);
|
|
85
|
+
assert.equal(active.size, 2);
|
|
86
|
+
for (let i = 0; i < 10; i++) {
|
|
87
|
+
const monitorIndex = i % 2;
|
|
88
|
+
const original = monitorIndex ? second : first;
|
|
89
|
+
const reused = hub.startLiveStream('monitor-device', { ...options, monitorIndex, fps: 20 });
|
|
90
|
+
assert.equal(reused.reused, true);
|
|
91
|
+
assert.equal(reused.commandId, original.commandId);
|
|
92
|
+
assert.equal(reused.captureGeneration, original.captureGeneration);
|
|
93
|
+
}
|
|
94
|
+
assert.equal(commands.filter(c => c.command === 'stream.start').length, 2);
|
|
95
|
+
assert.equal(hub.getDeviceLiveFrame('monitor-device', { streamId: first.streamId }).monitorIndex, 0);
|
|
96
|
+
const controlOptions = { ...options, streamPurpose: 'control', monitorIndex: 1 };
|
|
97
|
+
const transition = hub.startLiveStream('monitor-device', controlOptions);
|
|
98
|
+
assert.equal(transition.error, 'CAPTURE_TRANSITION_IN_PROGRESS');
|
|
99
|
+
await transition.transitionPromise;
|
|
100
|
+
const control = hub.startLiveStream('monitor-device', controlOptions);
|
|
101
|
+
assert.equal(control.ok, true);
|
|
102
|
+
await waitUntil(() => active.has(control.streamId));
|
|
103
|
+
assert.ok(active.has(first.streamId));
|
|
104
|
+
assert.ok(!active.has(second.streamId));
|
|
105
|
+
assert.equal(active.size, 2);
|
|
106
|
+
const wallWhileOtherControl = hub.startLiveStream('monitor-device', { ...options, monitorIndex: 0 });
|
|
107
|
+
assert.equal(wallWhileOtherControl.commandId, first.commandId);
|
|
108
|
+
assert.equal(wallWhileOtherControl.captureGeneration, first.captureGeneration);
|
|
109
|
+
const selectedStop = hub.stopLiveStream('monitor-device', { streamPurpose: 'wall', monitorIndex: 0 });
|
|
110
|
+
assert.equal((await selectedStop.stopPromise).captureStopConfirmed, true);
|
|
111
|
+
assert.ok(active.has(control.streamId));
|
|
112
|
+
assert.ok(!active.has(first.streamId));
|
|
113
|
+
const controlStop = hub.stopLiveStream('monitor-device', { streamId: control.streamId, streamPurpose: 'control' });
|
|
114
|
+
assert.equal((await controlStop.stopPromise).captureStopConfirmed, true);
|
|
115
|
+
assert.equal(active.size, 0);
|
|
116
|
+
assert.equal(hub.getLiveCaptureResourceSnapshot('monitor-device').terminal, true);
|
|
117
|
+
} finally {
|
|
118
|
+
socket?.destroy();
|
|
119
|
+
await hub.close();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { once } from 'node:events';
|
|
4
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import test from 'node:test';
|
|
10
|
+
import WebSocket from 'ws';
|
|
11
|
+
|
|
12
|
+
async function freePort() {
|
|
13
|
+
const server = net.createServer();
|
|
14
|
+
server.listen(0, '127.0.0.1');
|
|
15
|
+
await once(server, 'listening');
|
|
16
|
+
const port = server.address().port;
|
|
17
|
+
await new Promise(resolve => server.close(resolve));
|
|
18
|
+
return port;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function until(predicate, label, timeout = 6000) {
|
|
22
|
+
const deadline = Date.now() + timeout;
|
|
23
|
+
while (Date.now() < deadline) {
|
|
24
|
+
if (await predicate()) return;
|
|
25
|
+
await new Promise(resolve => setTimeout(resolve, 15));
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`Monitor viewers: ${label}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('real frame sockets keep different monitor bindings and release only the last viewer of each monitor', {
|
|
31
|
+
timeout: 25000
|
|
32
|
+
}, async () => {
|
|
33
|
+
const httpPort = await freePort();
|
|
34
|
+
let agentPort = await freePort();
|
|
35
|
+
while (agentPort === httpPort) agentPort = await freePort();
|
|
36
|
+
const dataDir = await mkdtemp(path.join(os.tmpdir(), 'vuvodesk-monitor-viewers-'));
|
|
37
|
+
const base = `http://127.0.0.1:${httpPort}`;
|
|
38
|
+
const deviceId = 'two-monitor-agent';
|
|
39
|
+
const pairToken = 'two-monitor-fixture';
|
|
40
|
+
const root = fileURLToPath(new URL('../../../', import.meta.url));
|
|
41
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL('./server.js', import.meta.url))], {
|
|
42
|
+
cwd: root, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
43
|
+
env: { ...process.env, LIVEDESK_DATA_DIR: dataDir,
|
|
44
|
+
LIVEDESK_HUB_HTTP_HOST: '127.0.0.1', LIVEDESK_HUB_HTTP_PORT: String(httpPort),
|
|
45
|
+
REMOTE_HUB_HOST: '127.0.0.1', REMOTE_HUB_PORT: String(agentPort),
|
|
46
|
+
REMOTE_HUB_PAIR_TOKEN: pairToken, REMOTE_HUB_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS: '750',
|
|
47
|
+
LIVEDESK_FRAME_STREAM_STOP_GRACE_MS: '25', LIVEDESK_PUBLIC_IP_DISCOVERY_DISABLED: '1',
|
|
48
|
+
REMOTE_HUB_PUBLIC_IP_DISCOVERY_DISABLED: '1', LIVEDESK_TEST_MODE: '1',
|
|
49
|
+
LIVEDESK_TEST_LICENSE_PLAN: 'pro', LIVEDESK_UDP_ENABLED: '0' }
|
|
50
|
+
});
|
|
51
|
+
const output = [];
|
|
52
|
+
for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => {
|
|
53
|
+
output.push(String(chunk)); while (output.length > 40) output.shift();
|
|
54
|
+
});
|
|
55
|
+
const sockets = [];
|
|
56
|
+
const commands = [];
|
|
57
|
+
const active = new Map();
|
|
58
|
+
const media = new Map();
|
|
59
|
+
const mediaSockets = [];
|
|
60
|
+
let agent, welcomed = false, sessionId = '', timer, sequence = 0;
|
|
61
|
+
const send = message => agent.write(`${JSON.stringify(message)}\n`);
|
|
62
|
+
const frameMessage = owner => ({ type: 'stream.frame', ...owner, frameSeq: ++sequence,
|
|
63
|
+
width: 960, height: 540, codec: 'h264', mimeType: 'video/h264',
|
|
64
|
+
isKeyFrame: true, chunkType: 'key', data: Buffer.from([
|
|
65
|
+
0, 0, 0, 1, 0x67, 0x42, 0, 0x1f, 0, 0, 0, 1, 0x68, 0xce, 6, 0xe2,
|
|
66
|
+
0, 0, 0, 1, 0x65, 0x88, 0x84
|
|
67
|
+
]).toString('base64') });
|
|
68
|
+
const frame = owner => {
|
|
69
|
+
const socket = media.get(owner.streamId);
|
|
70
|
+
if (socket && !socket.destroyed && socket.readyForFrames)
|
|
71
|
+
socket.write(`${JSON.stringify(frameMessage(owner))}\n`);
|
|
72
|
+
};
|
|
73
|
+
async function connectMedia(owner, expectAccepted = true) {
|
|
74
|
+
const socket = net.createConnection({ host: '127.0.0.1', port: agentPort });
|
|
75
|
+
mediaSockets.push(socket);
|
|
76
|
+
const received = [];
|
|
77
|
+
let pending = '';
|
|
78
|
+
socket.on('data', data => {
|
|
79
|
+
pending += String(data);
|
|
80
|
+
let newline;
|
|
81
|
+
while ((newline = pending.indexOf('\n')) >= 0) {
|
|
82
|
+
const raw = pending.slice(0, newline); pending = pending.slice(newline + 1);
|
|
83
|
+
if (raw.trim()) received.push(JSON.parse(raw));
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
await once(socket, 'connect');
|
|
87
|
+
socket.write(`${JSON.stringify({ type: 'hello', channel: 'frame', deviceId, pairToken,
|
|
88
|
+
parentSessionId: sessionId, frameOwner: { streamId: owner.streamId, commandId: owner.commandId,
|
|
89
|
+
captureGeneration: owner.captureGeneration, monitorIndex: owner.monitorIndex } })}\n`);
|
|
90
|
+
await until(() => received.length > 0, 'monitor channel welcome');
|
|
91
|
+
if (expectAccepted) {
|
|
92
|
+
assert.equal(received[0].type, 'welcome');
|
|
93
|
+
assert.equal(received[0].frameOwner.monitorIndex, owner.monitorIndex);
|
|
94
|
+
socket.readyForFrames = true;
|
|
95
|
+
media.set(owner.streamId, socket);
|
|
96
|
+
} else {
|
|
97
|
+
assert.equal(received[0].error, 'frame-channel-owner-stale');
|
|
98
|
+
await until(() => socket.destroyed, 'stale channel rejection');
|
|
99
|
+
}
|
|
100
|
+
return socket;
|
|
101
|
+
}
|
|
102
|
+
async function connectViewer(monitorIndex, streamPurpose = 'wall') {
|
|
103
|
+
const ws = new WebSocket(`ws://127.0.0.1:${httpPort}/api/remote/frames/ws?devices=${deviceId}`,
|
|
104
|
+
{ headers: { Origin: base }, perMessageDeflate: false });
|
|
105
|
+
sockets.push(ws);
|
|
106
|
+
const messages = [], frames = [];
|
|
107
|
+
ws.on('message', (data, binary) => {
|
|
108
|
+
if (binary) {
|
|
109
|
+
const length = data.readUInt32BE(0);
|
|
110
|
+
frames.push(JSON.parse(data.subarray(4, 4 + length).toString()));
|
|
111
|
+
} else messages.push(JSON.parse(String(data)));
|
|
112
|
+
});
|
|
113
|
+
await once(ws, 'open');
|
|
114
|
+
await until(() => messages.some(m => m.type === 'RemoteFrameSocketReady'), 'socket ready');
|
|
115
|
+
ws.send(JSON.stringify({ type: 'subscribe', deviceIds: [deviceId], autoStartLive: true,
|
|
116
|
+
allowReadOnlyControlBorrow: true, streamPurpose, monitorIndex,
|
|
117
|
+
mode: 'mode3-h264-hw', frameMode: 'mode3-h264-hw', fps: 30,
|
|
118
|
+
maxWidth: 960, maxHeight: 540, quality: 68 }));
|
|
119
|
+
await until(() => frames.length > 0, `monitor ${monitorIndex} first frame`);
|
|
120
|
+
return { ws, messages, frames, monitorIndex };
|
|
121
|
+
}
|
|
122
|
+
const starts = () => commands.filter(c => c.command === 'stream.start');
|
|
123
|
+
const close = async viewer => { viewer.ws.close(); await once(viewer.ws, 'close'); };
|
|
124
|
+
try {
|
|
125
|
+
await until(async () => {
|
|
126
|
+
if (child.exitCode !== null) throw new Error(`Hub exited: ${output.join('')}`);
|
|
127
|
+
try { return (await fetch(`${base}/api/health`)).ok; } catch { return false; }
|
|
128
|
+
}, 'isolated Hub health', 10000);
|
|
129
|
+
agent = net.createConnection({ host: '127.0.0.1', port: agentPort });
|
|
130
|
+
await once(agent, 'connect');
|
|
131
|
+
let input = '';
|
|
132
|
+
agent.on('data', chunk => {
|
|
133
|
+
input += String(chunk);
|
|
134
|
+
let newline;
|
|
135
|
+
while ((newline = input.indexOf('\n')) >= 0) {
|
|
136
|
+
const raw = input.slice(0, newline); input = input.slice(newline + 1);
|
|
137
|
+
if (!raw.trim()) continue;
|
|
138
|
+
const message = JSON.parse(raw);
|
|
139
|
+
if (message.type === 'welcome') { welcomed = true; sessionId = message.sessionId; }
|
|
140
|
+
if (message.type !== 'command') continue;
|
|
141
|
+
commands.push(message);
|
|
142
|
+
if (message.command === 'stream.start') {
|
|
143
|
+
const owner = { ...message.payload, commandId: message.commandId };
|
|
144
|
+
active.set(owner.streamId, owner);
|
|
145
|
+
send({ type: 'command.result', commandId: message.commandId,
|
|
146
|
+
result: { ...owner, stream: true, started: true } });
|
|
147
|
+
send({ type: 'stream.open', ...owner, width: 960, height: 540 });
|
|
148
|
+
void connectMedia(owner).then(() => frame(owner));
|
|
149
|
+
} else if (message.command === 'stream.stop') {
|
|
150
|
+
if (message.payload.streamId) {
|
|
151
|
+
media.get(message.payload.streamId)?.destroy();
|
|
152
|
+
media.delete(message.payload.streamId);
|
|
153
|
+
active.delete(message.payload.streamId);
|
|
154
|
+
} else {
|
|
155
|
+
for (const socket of media.values()) socket.destroy();
|
|
156
|
+
media.clear(); active.clear();
|
|
157
|
+
}
|
|
158
|
+
send({ type: 'command.result', commandId: message.commandId,
|
|
159
|
+
result: { stream: true, stopped: true, streamId: message.payload.streamId } });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
send({ type: 'hello', pairToken, deviceId, deviceName: 'Two monitor fixture', platform: 'darwin',
|
|
164
|
+
arch: 'arm64', protocol: 'mindexec.remote.agent', protocolVersion: 2,
|
|
165
|
+
capabilities: { liveStream: true, liveCaptureConcurrency: 'per-monitor', frameProtocol: {}, frameModes: [] } });
|
|
166
|
+
await until(() => welcomed, 'Agent welcome');
|
|
167
|
+
timer = setInterval(() => { for (const owner of active.values()) frame(owner); }, 40);
|
|
168
|
+
const hub = await connectViewer(0);
|
|
169
|
+
const pwa = await connectViewer(1);
|
|
170
|
+
const phone = await connectViewer(0);
|
|
171
|
+
await until(() => hub.frames.length >= 4 && pwa.frames.length >= 4 && phone.frames.length >= 4,
|
|
172
|
+
'all three viewers receive frames');
|
|
173
|
+
assert.equal(starts().length, 2, 'same monitor viewer created duplicate capture');
|
|
174
|
+
const original = [hub.frames[0], pwa.frames[0]];
|
|
175
|
+
for (const viewer of [hub, pwa, phone]) {
|
|
176
|
+
assert.ok(viewer.frames.every(f => f.monitorIndex === viewer.monitorIndex), 'cross-monitor frame delivery');
|
|
177
|
+
assert.ok(viewer.frames.every(f => f.commandId === original[viewer.monitorIndex].commandId), 'capture churn');
|
|
178
|
+
}
|
|
179
|
+
const zero = [...active.values()].find(o => o.monitorIndex === 0);
|
|
180
|
+
const one = [...active.values()].find(o => o.monitorIndex === 1);
|
|
181
|
+
await connectMedia({ ...zero, captureGeneration: zero.captureGeneration + 100 }, false);
|
|
182
|
+
assert.equal(media.get(zero.streamId).destroyed, false, 'stale hello replaced current monitor channel');
|
|
183
|
+
const wrongChannel = media.get(zero.streamId);
|
|
184
|
+
wrongChannel.write(`${JSON.stringify(frameMessage(one))}\n`);
|
|
185
|
+
await until(() => wrongChannel.destroyed, 'cross-monitor packet closes only its channel');
|
|
186
|
+
const otherCount = pwa.frames.length;
|
|
187
|
+
await until(() => pwa.frames.length >= otherCount + 3, 'healthy monitor survives peer channel failure');
|
|
188
|
+
await connectMedia(zero);
|
|
189
|
+
const resumedCount = hub.frames.length;
|
|
190
|
+
await until(() => hub.frames.length >= resumedCount + 3, 'exact monitor channel reconnects');
|
|
191
|
+
assert.equal(starts().length, 2, 'transport reconnect restarted native capture');
|
|
192
|
+
await close(hub);
|
|
193
|
+
const phoneCount = phone.frames.length;
|
|
194
|
+
await until(() => phone.frames.length >= phoneCount + 3, 'same-monitor peer continues after close');
|
|
195
|
+
assert.equal(active.size, 2);
|
|
196
|
+
await close(pwa);
|
|
197
|
+
await until(() => active.size === 1, 'last monitor-one viewer stops capture');
|
|
198
|
+
assert.equal([...active.values()][0].monitorIndex, 0);
|
|
199
|
+
assert.equal([...active.values()][0].commandId, original[0].commandId);
|
|
200
|
+
const rejoined = await connectViewer(1);
|
|
201
|
+
assert.equal(starts().length, 3);
|
|
202
|
+
assert.notEqual(rejoined.frames[0].captureGeneration, original[1].captureGeneration);
|
|
203
|
+
assert.equal([...active.values()].find(o => o.monitorIndex === 0).commandId, original[0].commandId);
|
|
204
|
+
const controller = await connectViewer(1, 'control');
|
|
205
|
+
await until(() => rejoined.frames.some(f => f.streamPurpose === 'control'), 'same-monitor Wall borrows Control');
|
|
206
|
+
assert.equal(starts().length, 4);
|
|
207
|
+
assert.equal([...active.values()].find(o => o.monitorIndex === 0).commandId, original[0].commandId);
|
|
208
|
+
const borrowedIndex = rejoined.frames.length;
|
|
209
|
+
await close(controller);
|
|
210
|
+
await until(() => rejoined.frames.slice(borrowedIndex).some(f => f.streamPurpose === 'wall'),
|
|
211
|
+
'Control exit restores only its monitor Wall');
|
|
212
|
+
assert.equal(starts().length, 5);
|
|
213
|
+
assert.equal([...active.values()].find(o => o.monitorIndex === 0).commandId, original[0].commandId);
|
|
214
|
+
await close(rejoined);
|
|
215
|
+
await close(phone);
|
|
216
|
+
await until(() => active.size === 0, 'final viewer cleanup');
|
|
217
|
+
assert.equal(commands.filter(c => c.command === 'stream.stop').length, 5);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
error.message += `\nIsolated Hub tail: ${output.join('').slice(-2500)}`;
|
|
220
|
+
throw error;
|
|
221
|
+
} finally {
|
|
222
|
+
clearInterval(timer);
|
|
223
|
+
for (const socket of sockets) socket.terminate();
|
|
224
|
+
for (const socket of mediaSockets) socket.destroy();
|
|
225
|
+
agent?.destroy();
|
|
226
|
+
child.kill();
|
|
227
|
+
if (child.exitCode === null) await once(child, 'exit');
|
|
228
|
+
}
|
|
229
|
+
});
|