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.
Files changed (27) hide show
  1. package/client/package.json +5 -5
  2. package/hub/package.json +1 -1
  3. package/hub/src/console-direct.js +5 -1
  4. package/hub/src/console-direct.test.mjs +18 -9
  5. package/hub/src/monitor-capture-ownership.mjs +15 -0
  6. package/hub/src/monitor-capture-ownership.test.mjs +16 -0
  7. package/hub/src/monitor-capture-runtime.test.mjs +121 -0
  8. package/hub/src/monitor-capture-viewers.test.mjs +229 -0
  9. package/hub/src/remote-hub.js +134 -45
  10. package/hub/src/server.js +11 -6
  11. package/package.json +6 -6
  12. package/web/dist/app.html +1 -1
  13. package/web/dist/app.webmanifest +1 -1
  14. package/web/dist/assets/{AgentSettingsTab-Cl9KYjS7.js → AgentSettingsTab-DO2Z6NkJ.js} +1 -1
  15. package/web/dist/assets/{AgentsPage-Y_SSQ_XG.js → AgentsPage-_shsjGZi.js} +1 -1
  16. package/web/dist/assets/{CaptureGallery-3vGYG6xD.js → CaptureGallery-YQRtdPdf.js} +1 -1
  17. package/web/dist/assets/{HubApp-CDv_1fSd.js → HubApp-Cl6KXmZZ.js} +12 -12
  18. package/web/dist/assets/{MonitorStackIcon-B-za6UQS.js → MonitorStackIcon-BCZRKGg9.js} +1 -1
  19. package/web/dist/assets/{SettingsPage-BsBbyNcu.js → SettingsPage-fj5cHPfI.js} +1 -1
  20. package/web/dist/assets/{ShareFilesPage-DepVIlkU.js → ShareFilesPage-DwpF_ImR.js} +1 -1
  21. package/web/dist/assets/{SupportPage-CoPtEeAj.js → SupportPage-FMuoakR7.js} +1 -1
  22. package/web/dist/assets/{VuvoDeskApp-DHTLitJO.js → VuvoDeskApp-BG169yB2.js} +1 -1
  23. package/web/dist/assets/{app-pSkBesmR.js → app-Cy9L1xCR.js} +2 -2
  24. package/web/dist/assets/{main-BW89RvBN.js → main-BL1yIEvm.js} +2 -2
  25. package/web/dist/index.html +2 -2
  26. package/web/dist/sw.js +2 -2
  27. package/web/dist/vuvodesk-build-evidence.json +39 -39
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/client",
3
- "version": "0.1.275",
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.470",
46
- "@livedesk/fast-osx-arm64": "0.1.470",
47
- "@livedesk/fast-osx-x64": "0.1.470",
48
- "@livedesk/fast-win-x64": "0.1.470"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.71",
3
+ "version": "0.1.73",
4
4
  "description": "VuvoDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
@@ -1343,7 +1343,11 @@ export function createHubConsoleDirect(options = {}) {
1343
1343
  iceTransportPolicy: 'all',
1344
1344
  disableAutoNegotiation: false,
1345
1345
  disableFingerprintVerification: false,
1346
- maxMessageSize: MAX_FRAME_MESSAGE_BYTES
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 node-datachannel buffered sends deliver the complete fragmented wire over loopback', { timeout: 8_000 }, async t => {
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
- offerer = new nodeDataChannel.PeerConnection('console-direct-native-offerer', { iceServers: [] });
1580
- answerer = new nodeDataChannel.PeerConnection('console-direct-native-answerer', { iceServers: [] });
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
- const payload = JSON.stringify({ type: 'direct-ready', connectionId: CONNECTION_ONE, hubEpoch: HUB_EPOCH });
1615
- const expected = payload.repeat(20_000);
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: 'control'
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, 'control');
1630
- assert.equal(message.data, expected);
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
+ });