@livedesk/hub 0.1.72 → 0.1.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json 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
+ });
package/src/remote-hub.js CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  remoteClipboardContentNeedsFileTransfer
27
27
  } from './remote-clipboard-contract.mjs';
28
28
  import { liveProfileSatisfiesDemand } from './shared-wall-profile-contract.mjs';
29
+ import { captureOwnersCompete, supportsMonitorCapture } from './monitor-capture-ownership.mjs';
29
30
 
30
31
  const DEFAULT_REMOTE_HUB_PORT = 5197;
31
32
  const DEFAULT_REMOTE_HUB_HOST = '0.0.0.0';
@@ -1888,7 +1889,8 @@ function serializeDevice(device, options = {}) {
1888
1889
  channels: {
1889
1890
  control: !!device.socket && !device.socket.destroyed,
1890
1891
  input: !!device.inputSocket && !device.inputSocket.destroyed,
1891
- frame: !!device.frameSocket && !device.frameSocket.destroyed,
1892
+ frame: (!!device.frameSocket && !device.frameSocket.destroyed)
1893
+ || [...(device.monitorFrameSockets?.values() || [])].some(socket => !socket.destroyed),
1892
1894
  audio: !!device.audioSocket && !device.audioSocket.destroyed,
1893
1895
  file: !!device.fileSocket && !device.fileSocket.destroyed
1894
1896
  },
@@ -4506,7 +4508,14 @@ export function createRemoteHub(options = {}) {
4506
4508
  existing.socket.destroy();
4507
4509
  }
4508
4510
 
4509
- function closeFrameSocket(device, reason = 'frame-socket-closed') {
4511
+ function closeFrameSocket(device, reason = 'frame-socket-closed', { legacyOnly = false } = {}) {
4512
+ if (!legacyOnly) {
4513
+ for (const scopedSocket of device?.monitorFrameSockets?.values() || []) {
4514
+ frameSockets.delete(scopedSocket);
4515
+ scopedSocket.destroy();
4516
+ }
4517
+ device?.monitorFrameSockets?.clear();
4518
+ }
4510
4519
  const socket = device?.frameSocket;
4511
4520
  if (!socket) {
4512
4521
  return;
@@ -4630,8 +4639,16 @@ export function createRemoteHub(options = {}) {
4630
4639
  return;
4631
4640
  }
4632
4641
 
4633
- const device = devices.get(deviceId);
4634
- if (!device || device.frameSocket !== socket) {
4642
+ const device = devices.get(deviceId);
4643
+ const monitor = socket.__liveDeskMonitorFrameOwner?.monitorIndex;
4644
+ if (Number.isInteger(monitor)) {
4645
+ if (device?.monitorFrameSockets?.get(monitor) === socket) {
4646
+ device.monitorFrameSockets.delete(monitor);
4647
+ emitRemoteEvent('RemoteFrameSocketDisconnected', device, { reason, monitorIndex: monitor });
4648
+ }
4649
+ return;
4650
+ }
4651
+ if (!device || device.frameSocket !== socket) {
4635
4652
  return;
4636
4653
  }
4637
4654
 
@@ -5289,9 +5306,10 @@ export function createRemoteHub(options = {}) {
5289
5306
 
5290
5307
  devices.set(deviceId, device);
5291
5308
  sockets.set(socket, deviceId);
5292
- writeJsonLine(socket, {
5293
- type: 'welcome',
5294
- protocol: REMOTE_AGENT_PROTOCOL,
5309
+ writeJsonLine(socket, {
5310
+ type: 'welcome',
5311
+ monitorFrameChannels: true,
5312
+ protocol: REMOTE_AGENT_PROTOCOL,
5295
5313
  protocolVersion: REMOTE_PROTOCOL_VERSION,
5296
5314
  sessionId,
5297
5315
  deviceId,
@@ -5411,17 +5429,41 @@ export function createRemoteHub(options = {}) {
5411
5429
  emitRemoteEvent('RemoteLegacyMediaSideChannelAccepted', device, { channel: 'frame' });
5412
5430
  }
5413
5431
 
5414
- closeFrameSocket(device, 'replaced-by-new-frame-socket');
5415
-
5416
- const now = new Date().toISOString();
5417
- device.frameSocket = socket;
5432
+ const now = new Date().toISOString();
5433
+ const frameOwner = hello.frameOwner;
5434
+ if (frameOwner !== undefined && frameOwner !== null) {
5435
+ const stream = getDeviceLiveStream(device, frameOwner.streamId);
5436
+ const pending = getPendingLiveStreamDescriptor(stream);
5437
+ const matches = owner => owner?.active === true
5438
+ && owner.commandId === frameOwner.commandId
5439
+ && owner.captureGeneration === frameOwner.captureGeneration
5440
+ && owner.monitorIndex === frameOwner.monitorIndex;
5441
+ if (!supportsMonitorCapture(device) || !parentSessionId
5442
+ || !Number.isInteger(frameOwner.monitorIndex) || frameOwner.monitorIndex < 0 || frameOwner.monitorIndex > 63
5443
+ || !(matches(pending) || matches(stream))) {
5444
+ writeJsonLineAndClose(socket, { type: 'error', error: 'frame-channel-owner-stale' });
5445
+ return null;
5446
+ }
5447
+ if (!device.monitorFrameSockets) device.monitorFrameSockets = new Map();
5448
+ const previous = device.monitorFrameSockets.get(frameOwner.monitorIndex);
5449
+ if (previous) { frameSockets.delete(previous); previous.destroy(); }
5450
+ socket.__liveDeskMonitorFrameOwner = {
5451
+ streamId: stream.streamId, commandId: frameOwner.commandId,
5452
+ captureGeneration: frameOwner.captureGeneration, monitorIndex: frameOwner.monitorIndex
5453
+ };
5454
+ device.monitorFrameSockets.set(frameOwner.monitorIndex, socket);
5455
+ } else {
5456
+ closeFrameSocket(device, 'replaced-by-new-frame-socket', { legacyOnly: true });
5457
+ device.frameSocket = socket;
5458
+ }
5418
5459
  device.frameConnectedAt = now;
5419
5460
  device.frameLastSeenAt = now;
5420
5461
  frameSockets.set(socket, deviceId);
5421
5462
 
5422
5463
  writeJsonLine(socket, {
5423
5464
  type: 'welcome',
5424
- channel: 'frame',
5465
+ channel: 'frame',
5466
+ frameOwner: socket.__liveDeskMonitorFrameOwner || null,
5425
5467
  protocol: REMOTE_AGENT_PROTOCOL,
5426
5468
  protocolVersion: REMOTE_PROTOCOL_VERSION,
5427
5469
  sessionId: device.sessionId,
@@ -5431,7 +5473,11 @@ export function createRemoteHub(options = {}) {
5431
5473
  serverTime: now
5432
5474
  });
5433
5475
 
5434
- emitRemoteEvent('RemoteFrameSocketConnected', device);
5476
+ emitRemoteEvent('RemoteFrameSocketConnected', device, {
5477
+ monitorIndex: socket.__liveDeskMonitorFrameOwner?.monitorIndex,
5478
+ streamId: socket.__liveDeskMonitorFrameOwner?.streamId,
5479
+ captureGeneration: socket.__liveDeskMonitorFrameOwner?.captureGeneration
5480
+ });
5435
5481
  return device;
5436
5482
  }
5437
5483
 
@@ -8313,7 +8359,11 @@ export function createRemoteHub(options = {}) {
8313
8359
  return false;
8314
8360
  }
8315
8361
  if (state.inputOnly) return device.inputSocket === socket;
8316
- if (state.frameOnly) return device.frameSocket === socket;
8362
+ if (state.frameOnly) {
8363
+ const owner = socket.__liveDeskMonitorFrameOwner;
8364
+ return owner ? device.monitorFrameSockets?.get(owner.monitorIndex) === socket
8365
+ : device.frameSocket === socket;
8366
+ }
8317
8367
  if (state.audioOnly) return device.audioSocket === socket;
8318
8368
  if (state.fileOnly) return device.fileSocket === socket;
8319
8369
  return device.socket === socket;
@@ -8327,6 +8377,12 @@ export function createRemoteHub(options = {}) {
8327
8377
  return 'control';
8328
8378
  }
8329
8379
 
8380
+ function frameSocketMatchesPacket(socket, message) {
8381
+ const owner = socket.__liveDeskMonitorFrameOwner;
8382
+ return !owner || (message.streamId === owner.streamId && message.commandId === owner.commandId
8383
+ && message.captureGeneration === owner.captureGeneration && message.monitorIndex === owner.monitorIndex);
8384
+ }
8385
+
8330
8386
  function failCloseMediaSideChannel(socket, state, reason, error = 'media-side-channel-message-not-supported') {
8331
8387
  const device = state?.device;
8332
8388
  if (device) {
@@ -8375,6 +8431,10 @@ export function createRemoteHub(options = {}) {
8375
8431
  socket.destroy();
8376
8432
  return;
8377
8433
  }
8434
+ if (state.frameOnly && !frameSocketMatchesPacket(socket, header)) {
8435
+ failCloseMediaSideChannel(socket, state, 'frame-channel-owner-mismatch');
8436
+ return;
8437
+ }
8378
8438
 
8379
8439
  const hasDeclaredFrameKind = header.frameKind !== undefined
8380
8440
  && header.frameKind !== null
@@ -8778,6 +8838,10 @@ export function createRemoteHub(options = {}) {
8778
8838
  case 'thumbnail.frame':
8779
8839
  case 'stream.open':
8780
8840
  case 'stream.frame': {
8841
+ if (!frameSocketMatchesPacket(socket, message)) {
8842
+ failCloseMediaSideChannel(socket, state, 'frame-channel-owner-mismatch');
8843
+ return;
8844
+ }
8781
8845
  const receivedAt = new Date().toISOString();
8782
8846
  device.counters.messagesReceived += 1;
8783
8847
  device.frameLastSeenAt = receivedAt;
@@ -11182,10 +11246,10 @@ export function createRemoteHub(options = {}) {
11182
11246
  pruneDeviceLiveStreams(device);
11183
11247
  }
11184
11248
 
11185
- function makeStableLiveStreamId(deviceId, purpose = 'wall') {
11249
+ function makeStableLiveStreamId(deviceId, purpose = 'wall', monitorIndex = null) {
11186
11250
  const hash = crypto.createHash('sha1').update(String(deviceId || 'device')).digest('hex').slice(0, 16);
11187
11251
  const role = safeString(purpose, 24).toLowerCase().replace(/[^a-z0-9_-]+/g, '-') || 'wall';
11188
- return `${role}-${hash}`;
11252
+ return `${role}-${hash}${Number.isInteger(monitorIndex) ? `-m${monitorIndex}` : ''}`;
11189
11253
  }
11190
11254
 
11191
11255
  function getActiveLiveStreams(device) {
@@ -11260,11 +11324,14 @@ export function createRemoteHub(options = {}) {
11260
11324
  return result;
11261
11325
  }
11262
11326
 
11263
- function claimSingleLiveCapture(deviceId, device, streamId, streamPurpose) {
11264
- const purpose = safeString(streamPurpose, 24).toLowerCase() || 'wall';
11265
- const activeControlStream = getActiveControlStream(device);
11266
- if (purpose !== 'control'
11267
- && activeControlStream) {
11327
+ function claimSingleLiveCapture(deviceId, device, streamId, streamPurpose, monitorIndex) {
11328
+ const purpose = safeString(streamPurpose, 24).toLowerCase() || 'wall';
11329
+ const perMonitor = supportsMonitorCapture(device);
11330
+ const requestedOwner = { monitorIndex, streamPurpose: purpose };
11331
+ const activeControlStream = getActiveControlStream(device);
11332
+ if (purpose !== 'control'
11333
+ && activeControlStream
11334
+ && captureOwnersCompete(activeControlStream, requestedOwner, perMonitor)) {
11268
11335
  return {
11269
11336
  ok: false,
11270
11337
  error: 'CONTROL_CAPTURE_OWNS_DEVICE',
@@ -11276,10 +11343,11 @@ export function createRemoteHub(options = {}) {
11276
11343
  }
11277
11344
 
11278
11345
  const competingStreams = getActiveLiveStreams(device)
11279
- .filter(stream => (
11280
- stream.streamId !== streamId
11281
- || (safeString(stream?.streamPurpose, 24).toLowerCase() || 'wall') !== purpose
11282
- ));
11346
+ .filter(stream => (
11347
+ (stream.streamId !== streamId
11348
+ || (safeString(stream?.streamPurpose, 24).toLowerCase() || 'wall') !== purpose)
11349
+ && captureOwnersCompete(stream, requestedOwner, perMonitor)
11350
+ ));
11283
11351
  let stopIssued = false;
11284
11352
  for (const competingStream of competingStreams) {
11285
11353
  if (competingStream.stopPending === true
@@ -11523,7 +11591,7 @@ export function createRemoteHub(options = {}) {
11523
11591
 
11524
11592
  function readOnlyControlPresentationBorrowResult(device, normalized, options = {}) {
11525
11593
  const borrowed = resolveReadOnlyControlPresentationBorrow({
11526
- device,
11594
+ device: { sessionId: device.sessionId, activeLiveStream: getActiveControlStream(device) },
11527
11595
  liveOptions: {
11528
11596
  allowReadOnlyControlBorrow: options.allowReadOnlyControlBorrow === true,
11529
11597
  streamPurpose: normalized.streamPurpose,
@@ -11582,7 +11650,8 @@ export function createRemoteHub(options = {}) {
11582
11650
  const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
11583
11651
  const borrowedControl = readOnlyControlPresentationBorrowResult(device, normalized, options);
11584
11652
  if (borrowedControl) return { ...borrowedControl, synthetic: true };
11585
- const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
11653
+ const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(
11654
+ deviceId, streamPurpose, streamPurpose === 'wall' && supportsMonitorCapture(device) ? monitorIndex : null);
11586
11655
  if (!reserveDeviceLiveStreamDescriptor(device, streamId)) {
11587
11656
  return {
11588
11657
  ok: false,
@@ -11590,7 +11659,7 @@ export function createRemoteHub(options = {}) {
11590
11659
  descriptorCap: LIVE_STREAM_DESCRIPTOR_CAP
11591
11660
  };
11592
11661
  }
11593
- const captureClaim = claimSingleLiveCapture(deviceId, device, streamId, streamPurpose);
11662
+ const captureClaim = claimSingleLiveCapture(deviceId, device, streamId, streamPurpose, monitorIndex);
11594
11663
  if (!captureClaim.ok) return captureClaim;
11595
11664
  const activeLiveStream = getDeviceLiveStream(device, streamId);
11596
11665
  const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
@@ -11706,7 +11775,9 @@ export function createRemoteHub(options = {}) {
11706
11775
  }
11707
11776
  const borrowedControl = readOnlyControlPresentationBorrowResult(device, normalized, options);
11708
11777
  if (borrowedControl) return borrowedControl;
11709
- const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
11778
+ const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(
11779
+ deviceId, streamPurpose,
11780
+ supportsMonitorCapture(device) && streamPurpose === 'wall' ? monitorIndex : null);
11710
11781
  if (!reserveDeviceLiveStreamDescriptor(device, streamId)) {
11711
11782
  return {
11712
11783
  ok: false,
@@ -11714,7 +11785,7 @@ export function createRemoteHub(options = {}) {
11714
11785
  descriptorCap: LIVE_STREAM_DESCRIPTOR_CAP
11715
11786
  };
11716
11787
  }
11717
- const captureClaim = claimSingleLiveCapture(deviceId, device, streamId, streamPurpose);
11788
+ const captureClaim = claimSingleLiveCapture(deviceId, device, streamId, streamPurpose, monitorIndex);
11718
11789
  if (!captureClaim.ok) return captureClaim;
11719
11790
  const activeLiveStream = getDeviceLiveStream(device, streamId);
11720
11791
  const pendingDescriptor = getPendingLiveStreamDescriptor(activeLiveStream);
@@ -12018,10 +12089,15 @@ export function createRemoteHub(options = {}) {
12018
12089
  }
12019
12090
  const stopAll = options.stopAll === true;
12020
12091
  const streamPurpose = safeString(options.streamPurpose || options.purpose, 24).toLowerCase();
12021
- const requestedStreamId = safeString(options.streamId, 128);
12022
- const purposeStreams = !stopAll && !requestedStreamId && streamPurpose && device
12023
- ? getActiveLiveStreams(device).filter(stream =>
12024
- (safeString(stream?.streamPurpose, 24).toLowerCase() || 'wall') === streamPurpose)
12092
+ const requestedStreamId = safeString(options.streamId, 128);
12093
+ const selectedMonitor = parseExactLiveStreamMonitorIndex(options.monitorIndex);
12094
+ if (Object.prototype.hasOwnProperty.call(options, 'monitorIndex') && selectedMonitor === null) {
12095
+ return { ok: false, error: 'LIVE_STREAM_STOP_MONITOR_INVALID' };
12096
+ }
12097
+ const purposeStreams = !stopAll && !requestedStreamId && streamPurpose && device
12098
+ ? getActiveLiveStreams(device).filter(stream =>
12099
+ (safeString(stream?.streamPurpose, 24).toLowerCase() || 'wall') === streamPurpose
12100
+ && (selectedMonitor === null || stream.monitorIndex === selectedMonitor))
12025
12101
  : [];
12026
12102
  if (purposeStreams.length > 1) {
12027
12103
  return {
@@ -12033,8 +12109,9 @@ export function createRemoteHub(options = {}) {
12033
12109
  }
12034
12110
  const retainedPurposeStream = purposeStreams[0]
12035
12111
  || (!stopAll && !requestedStreamId && streamPurpose && device
12036
- ? [...ensureDeviceLiveStreams(device).values()].reverse().find(stream =>
12037
- (safeString(stream?.streamPurpose, 24).toLowerCase() || 'wall') === streamPurpose)
12112
+ ? [...ensureDeviceLiveStreams(device).values()].reverse().find(stream =>
12113
+ (safeString(stream?.streamPurpose, 24).toLowerCase() || 'wall') === streamPurpose
12114
+ && (selectedMonitor === null || stream.monitorIndex === selectedMonitor))
12038
12115
  : null);
12039
12116
  if (!stopAll
12040
12117
  && !requestedStreamId
@@ -12056,8 +12133,11 @@ export function createRemoteHub(options = {}) {
12056
12133
  || safeString(retainedPurposeStream?.streamId, 128)
12057
12134
  || device?.activeLiveStream?.streamId
12058
12135
  || '';
12059
- const streamState = getDeviceLiveStream(device, streamId);
12060
- const pendingStreamState = getPendingLiveStreamDescriptor(streamState);
12136
+ const streamState = getDeviceLiveStream(device, streamId);
12137
+ const pendingStreamState = getPendingLiveStreamDescriptor(streamState);
12138
+ if (selectedMonitor !== null && streamState && streamState.monitorIndex !== selectedMonitor) {
12139
+ return { ok: false, error: 'LIVE_STREAM_STOP_MONITOR_MISMATCH', streamId };
12140
+ }
12061
12141
  const expectedCommandId = safeString(options.expectedCommandId, 128);
12062
12142
  const expectedCaptureGeneration = Number(options.expectedCaptureGeneration || 0);
12063
12143
  const expectedSessionId = safeString(options.expectedSessionId, 160);
@@ -12874,12 +12954,20 @@ export function createRemoteHub(options = {}) {
12874
12954
  return stopResult;
12875
12955
  }
12876
12956
 
12877
- function getDeviceLiveFrame(deviceId, options = {}) {
12878
- const device = devices.get(String(deviceId || ''));
12879
- return serializeRemoteFrame(device?.latestLiveFrame, device?.deviceId, 'live', {
12880
- includeDataUrl: options.includeDataUrl === true
12881
- });
12882
- }
12957
+ function getDeviceLiveFrame(deviceId, options = {}) {
12958
+ const device = devices.get(String(deviceId || ''));
12959
+ const frame = options.streamId
12960
+ ? getDeviceLiveStream(device, options.streamId)?.latestFrame
12961
+ : device?.latestLiveFrame;
12962
+ return serializeRemoteFrame(frame, device?.deviceId, 'live', {
12963
+ includeDataUrl: options.includeDataUrl === true
12964
+ });
12965
+ }
12966
+
12967
+ function getDeviceLiveStreamSnapshot(deviceId, streamId) {
12968
+ const device = devices.get(String(deviceId || ''));
12969
+ return serializeActiveLiveStream(getDeviceLiveStream(device, streamId), device?.deviceId);
12970
+ }
12883
12971
 
12884
12972
  function getDeviceThumbnail(deviceId, options = {}) {
12885
12973
  const device = devices.get(String(deviceId || ''));
@@ -13054,7 +13142,8 @@ export function createRemoteHub(options = {}) {
13054
13142
  startAudioStream,
13055
13143
  stopAudioStream,
13056
13144
  getLiveCaptureResourceSnapshot,
13057
- getDeviceLiveFrame,
13145
+ getDeviceLiveFrame,
13146
+ getDeviceLiveStreamSnapshot,
13058
13147
  getDeviceThumbnail,
13059
13148
  getFramePayload,
13060
13149
  handleUdpFrame,
package/src/server.js CHANGED
@@ -368,7 +368,9 @@ function readRemoteLiveStreamEventPurpose(event) {
368
368
  const explicitPurpose = String(event?.streamPurpose || '').trim().toLowerCase();
369
369
  if (explicitPurpose) return explicitPurpose;
370
370
  const commandId = String(event?.commandId || '').trim();
371
- const activeStream = event?.device?.activeLiveStream;
371
+ const activeStream = remoteHub.getDeviceLiveStreamSnapshot(
372
+ event?.deviceId || event?.device?.deviceId, event?.streamId
373
+ );
372
374
  if (!commandId || !activeStream) return '';
373
375
  const pendingDescriptor = activeStream.pendingDescriptor;
374
376
  if (String(pendingDescriptor?.commandId || '').trim() === commandId) {
@@ -441,7 +443,7 @@ function handleRemoteHubEvent(type, event) {
441
443
  const liveStreamEventDeviceId = String(event?.deviceId || event?.device?.deviceId || '').trim();
442
444
  const liveStreamEventPurpose = readRemoteLiveStreamEventPurpose(event);
443
445
  if (type === 'RemoteLiveStreamOpened' && liveStreamEventPurpose === 'wall') {
444
- const committedOwner = event?.device?.activeLiveStream;
446
+ const committedOwner = remoteHub.getDeviceLiveStreamSnapshot(liveStreamEventDeviceId, event?.streamId);
445
447
  if (String(committedOwner?.commandId || '').trim() === String(event?.commandId || '').trim()
446
448
  && Number(committedOwner?.captureGeneration || 0) === Number(event?.captureGeneration || 0)) {
447
449
  rebindSharedWallSubscribersToOwner(liveStreamEventDeviceId, {
@@ -474,7 +476,7 @@ function handleRemoteHubEvent(type, event) {
474
476
  }
475
477
  if (type === 'RemoteLiveStreamRestartFailed') {
476
478
  const failedDeviceId = String(event?.deviceId || event?.device?.deviceId || '').trim();
477
- const retainedOwner = event?.device?.activeLiveStream;
479
+ const retainedOwner = remoteHub.getDeviceLiveStreamSnapshot(failedDeviceId, event?.streamId);
478
480
  if (String(retainedOwner?.streamPurpose || '').trim().toLowerCase() === 'wall') {
479
481
  const rebound = rebindSharedWallSubscribersToOwner(failedDeviceId, {
480
482
  ...retainedOwner,
@@ -2616,6 +2618,7 @@ function hasOtherFrameStreamOwner(ws, deviceId, streamId, streamPurpose = '') {
2616
2618
  if (!streamId) {
2617
2619
  return false;
2618
2620
  }
2621
+ const ownerMonitor = remoteHub.getDeviceLiveStreamSnapshot(deviceId, streamId)?.monitorIndex;
2619
2622
  for (const candidate of frameClients) {
2620
2623
  if (candidate === ws || candidate.readyState !== candidate.OPEN) {
2621
2624
  continue;
@@ -2637,8 +2640,10 @@ function hasOtherFrameStreamOwner(ws, deviceId, streamId, streamPurpose = '') {
2637
2640
  // socket's close cleanup stop the replacement generation in that window.
2638
2641
  if (candidate.liveDeskAutoStart === true
2639
2642
  && candidate.liveDeskDeviceIds instanceof Set
2640
- && candidate.liveDeskDeviceIds.has(deviceId)
2641
- && String(candidate.liveDeskLiveOptions?.streamPurpose || '') === String(streamPurpose || '')) {
2643
+ && candidate.liveDeskDeviceIds.has(deviceId)
2644
+ && String(candidate.liveDeskLiveOptions?.streamPurpose || '') === String(streamPurpose || '')
2645
+ && Number.isInteger(ownerMonitor)
2646
+ && frameClientMonitorIndex(candidate, deviceId) === ownerMonitor) {
2642
2647
  return true;
2643
2648
  }
2644
2649
  }
@@ -4041,7 +4046,7 @@ function startFrameSubscriptionLive(
4041
4046
  );
4042
4047
  }
4043
4048
  if (result?.ok) {
4044
- const activeStream = device?.activeLiveStream;
4049
+ const activeStream = remoteHub.getDeviceLiveStreamSnapshot(deviceId, result.streamId);
4045
4050
  const retainWallOwner = shouldRetainWallOwnerUntilPendingStartCommits(
4046
4051
  result,
4047
4052
  activeStream