livedesk 0.1.691 → 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 (25) hide show
  1. package/client/package.json +5 -5
  2. package/hub/package.json +1 -1
  3. package/hub/src/monitor-capture-ownership.mjs +15 -0
  4. package/hub/src/monitor-capture-ownership.test.mjs +16 -0
  5. package/hub/src/monitor-capture-runtime.test.mjs +121 -0
  6. package/hub/src/monitor-capture-viewers.test.mjs +229 -0
  7. package/hub/src/remote-hub.js +134 -45
  8. package/hub/src/server.js +11 -6
  9. package/package.json +6 -6
  10. package/web/dist/app.html +1 -1
  11. package/web/dist/app.webmanifest +1 -1
  12. package/web/dist/assets/{AgentSettingsTab-CV_0za4F.js → AgentSettingsTab-DO2Z6NkJ.js} +1 -1
  13. package/web/dist/assets/{AgentsPage-BtrLYt9x.js → AgentsPage-_shsjGZi.js} +1 -1
  14. package/web/dist/assets/{CaptureGallery-CBc58XJD.js → CaptureGallery-YQRtdPdf.js} +1 -1
  15. package/web/dist/assets/{HubApp-D_DdfH3v.js → HubApp-Cl6KXmZZ.js} +12 -12
  16. package/web/dist/assets/{MonitorStackIcon-NxxMF_Ag.js → MonitorStackIcon-BCZRKGg9.js} +1 -1
  17. package/web/dist/assets/{SettingsPage-Dlkeg_u1.js → SettingsPage-fj5cHPfI.js} +1 -1
  18. package/web/dist/assets/{ShareFilesPage-DBmE6Ewf.js → ShareFilesPage-DwpF_ImR.js} +1 -1
  19. package/web/dist/assets/{SupportPage-BfijaHuW.js → SupportPage-FMuoakR7.js} +1 -1
  20. package/web/dist/assets/{VuvoDeskApp-CFq0Z-xx.js → VuvoDeskApp-BG169yB2.js} +1 -1
  21. package/web/dist/assets/{app-CVjARKwD.js → app-Cy9L1xCR.js} +2 -2
  22. package/web/dist/assets/{main-B_PtY6Hx.js → main-BL1yIEvm.js} +2 -2
  23. package/web/dist/index.html +2 -2
  24. package/web/dist/sw.js +2 -2
  25. package/web/dist/vuvodesk-build-evidence.json +36 -36
@@ -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.72",
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",
@@ -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
+ });