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