@livedesk/hub 0.1.7 → 0.1.9
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/live-desk-update.js +1 -1
- package/src/remote-hub.js +852 -165
- package/src/server.js +130 -47
- package/src/transport/udp-hub-transport.js +36 -21
- package/src/transport/udp-protocol.js +105 -35
package/src/remote-hub.js
CHANGED
|
@@ -28,7 +28,13 @@ const LIVE_STREAM_REPLACEMENT_PENDING_MS = 7000;
|
|
|
28
28
|
const LIVE_STREAM_MIN_FRESH_MS = 3000;
|
|
29
29
|
const LIVE_STREAM_MAX_FRESH_MS = 12000;
|
|
30
30
|
const DEFAULT_REMOTE_INPUT_ACK_TIMEOUT_MS = 5000;
|
|
31
|
-
const
|
|
31
|
+
const DEFAULT_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS = 8000;
|
|
32
|
+
const REMOTE_POLICY_BYPASS_COMMANDS = new Set([
|
|
33
|
+
'ping',
|
|
34
|
+
'stream.stop',
|
|
35
|
+
'audio.stop'
|
|
36
|
+
]);
|
|
37
|
+
const REMOTE_PROTOCOL_VERSION = 2;
|
|
32
38
|
const REMOTE_AGENT_PROTOCOL = 'mindexec.remote.agent';
|
|
33
39
|
const REMOTE_FRAME_PROTOCOL = 'mindexec.remote.frame.v2';
|
|
34
40
|
const REMOTE_FRAME_PROTOCOL_VERSION = 2;
|
|
@@ -154,7 +160,10 @@ const DUPLICATE_DEVICE_LOG_THROTTLE_MS = 60000;
|
|
|
154
160
|
const DUPLICATE_DEVICE_RETRY_AFTER_MS = 30000;
|
|
155
161
|
const AGENT_BINARY_INGRESS_QUEUE_PACKETS = 8;
|
|
156
162
|
const MODE5_AGENT_BINARY_INGRESS_QUEUE_PACKETS = 2;
|
|
157
|
-
|
|
163
|
+
// Native capture backends can emit short bursts (ScreenCaptureKit commonly
|
|
164
|
+
// queues up to eight frames). Drain one frame per event-loop turn so a burst
|
|
165
|
+
// cannot immediately overflow the smaller Hub-to-browser send lanes.
|
|
166
|
+
const AGENT_BINARY_INGRESS_DRAIN_BUDGET_PACKETS = 1;
|
|
158
167
|
const SYNTHETIC_FRAME_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAADElEQVR42mP8z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
|
|
159
168
|
const SYNTHETIC_FRAME_PAYLOAD = Buffer.from(SYNTHETIC_FRAME_DATA_URL.split(',')[1], 'base64');
|
|
160
169
|
const SYNTHETIC_FRAME_HASH = crypto.createHash('sha256').update(SYNTHETIC_FRAME_PAYLOAD).digest('hex').slice(0, 16);
|
|
@@ -683,9 +692,18 @@ function normalizeRemoteInputEvent(value = {}) {
|
|
|
683
692
|
const issuedAtEpochMs = Number(input.issuedAtEpochMs ?? input.IssuedAtEpochMs);
|
|
684
693
|
const hubReceivedAtEpochMs = Number(input.hubReceivedAtEpochMs ?? input.HubReceivedAtEpochMs);
|
|
685
694
|
const captureGeneration = Number(input.captureGeneration ?? input.CaptureGeneration);
|
|
695
|
+
const rawPressedCodes = input.pressedCodes ?? input.PressedCodes;
|
|
696
|
+
const pressedCodes = Array.isArray(rawPressedCodes)
|
|
697
|
+
? [...new Set(rawPressedCodes
|
|
698
|
+
.map(code => safeString(code, 80))
|
|
699
|
+
.filter(Boolean))]
|
|
700
|
+
.slice(0, 64)
|
|
701
|
+
: null;
|
|
686
702
|
return {
|
|
687
703
|
type,
|
|
688
|
-
|
|
704
|
+
// A missing monitor is not equivalent to the primary display. Control
|
|
705
|
+
// input must prove the exact monitor owned by its capture generation.
|
|
706
|
+
monitorIndex: Number.isFinite(monitorIndex) ? normalizeMonitorIndex(monitorIndex) : null,
|
|
689
707
|
normalizedX: Number.isFinite(normalizedX) ? Math.max(0, Math.min(1, normalizedX)) : undefined,
|
|
690
708
|
normalizedY: Number.isFinite(normalizedY) ? Math.max(0, Math.min(1, normalizedY)) : undefined,
|
|
691
709
|
button: safeString(input.button || input.Button, 24),
|
|
@@ -695,8 +713,9 @@ function normalizeRemoteInputEvent(value = {}) {
|
|
|
695
713
|
code: safeString(input.code || input.Code, 80),
|
|
696
714
|
keyCode: Number.isFinite(keyCode) ? Math.max(0, Math.min(65535, Math.trunc(keyCode))) : 0,
|
|
697
715
|
location: Number.isFinite(location) ? Math.max(0, Math.min(255, Math.trunc(location))) : 0,
|
|
698
|
-
text: safeText(input.text || input.Text, 256),
|
|
699
|
-
repeat: input.repeat === true || input.Repeat === true,
|
|
716
|
+
text: safeText(input.text || input.Text, 256),
|
|
717
|
+
repeat: input.repeat === true || input.Repeat === true,
|
|
718
|
+
pressedCodes,
|
|
700
719
|
shiftKey: input.shiftKey === true || input.ShiftKey === true,
|
|
701
720
|
ctrlKey: input.ctrlKey === true || input.ControlKey === true || input.CtrlKey === true,
|
|
702
721
|
altKey: input.altKey === true || input.AltKey === true,
|
|
@@ -708,8 +727,9 @@ function normalizeRemoteInputEvent(value = {}) {
|
|
|
708
727
|
hubConnectionId: safeString(input.hubConnectionId || input.HubConnectionId, 128),
|
|
709
728
|
inputEventId: safeString(input.inputEventId || input.InputEventId, 128) || crypto.randomUUID(),
|
|
710
729
|
inputSeq: Number.isSafeInteger(inputSeq) && inputSeq > 0 ? inputSeq : 0,
|
|
711
|
-
requestAck: input.requestAck !== false && input.RequestAck !== false,
|
|
712
|
-
|
|
730
|
+
requestAck: input.requestAck !== false && input.RequestAck !== false,
|
|
731
|
+
reason: safeString(input.reason || input.Reason, 120),
|
|
732
|
+
issuedAtEpochMs: Number.isFinite(issuedAtEpochMs) ? issuedAtEpochMs : 0,
|
|
713
733
|
hubReceivedAtEpochMs: Number.isFinite(hubReceivedAtEpochMs) ? hubReceivedAtEpochMs : 0,
|
|
714
734
|
issuedAt: safeString(input.issuedAt || input.IssuedAt, 80)
|
|
715
735
|
};
|
|
@@ -1014,10 +1034,26 @@ function serializeDevice(device, options = {}) {
|
|
|
1014
1034
|
audio: !!device.audioSocket && !device.audioSocket.destroyed,
|
|
1015
1035
|
file: !!device.fileSocket && !device.fileSocket.destroyed
|
|
1016
1036
|
},
|
|
1017
|
-
latestThumbnail: serializeRemoteFrame(device.latestThumbnail, device.deviceId, 'thumbnail', options),
|
|
1018
|
-
latestLiveFrame: serializeRemoteFrame(device.latestLiveFrame, device.deviceId, 'live', options),
|
|
1019
|
-
activeLiveStream: device.activeLiveStream ? { ...device.activeLiveStream } : null,
|
|
1020
|
-
|
|
1037
|
+
latestThumbnail: serializeRemoteFrame(device.latestThumbnail, device.deviceId, 'thumbnail', options),
|
|
1038
|
+
latestLiveFrame: serializeRemoteFrame(device.latestLiveFrame, device.deviceId, 'live', options),
|
|
1039
|
+
activeLiveStream: device.activeLiveStream ? { ...device.activeLiveStream } : null,
|
|
1040
|
+
liveCapturePause: device.liveCapturePause
|
|
1041
|
+
? {
|
|
1042
|
+
token: device.liveCapturePause.token,
|
|
1043
|
+
pausedAt: device.liveCapturePause.pausedAt,
|
|
1044
|
+
reason: device.liveCapturePause.reason,
|
|
1045
|
+
streamId: device.liveCapturePause.streamId,
|
|
1046
|
+
streamPurpose: device.liveCapturePause.streamPurpose,
|
|
1047
|
+
monitorIndex: device.liveCapturePause.monitorIndex,
|
|
1048
|
+
phase: device.liveCapturePause.phase,
|
|
1049
|
+
captureStopConfirmed: device.liveCapturePause.captureStopConfirmed === true,
|
|
1050
|
+
captureStopConfirmedAt: device.liveCapturePause.captureStopConfirmedAt || '',
|
|
1051
|
+
stopCommandId: device.liveCapturePause.stopCommandId || '',
|
|
1052
|
+
stopError: device.liveCapturePause.stopError || '',
|
|
1053
|
+
stopAttemptCount: Math.max(0, Number(device.liveCapturePause.stopAttemptCount) || 0)
|
|
1054
|
+
}
|
|
1055
|
+
: null,
|
|
1056
|
+
activeAudioStream: device.activeAudioStream ? { ...device.activeAudioStream } : null,
|
|
1021
1057
|
latestAudioFrame: device.latestAudioFrame
|
|
1022
1058
|
? {
|
|
1023
1059
|
streamId: device.latestAudioFrame.streamId,
|
|
@@ -1454,7 +1490,9 @@ class RemoteHubWebSocketAgentSocket {
|
|
|
1454
1490
|
}
|
|
1455
1491
|
|
|
1456
1492
|
export function createRemoteHub(options = {}) {
|
|
1457
|
-
const env = options.env || process.env;
|
|
1493
|
+
const env = options.env || process.env;
|
|
1494
|
+
const rejectFrameChannelForTest = isEnabledValue(env.LIVEDESK_TEST_MODE, false)
|
|
1495
|
+
&& isEnabledValue(env.LIVEDESK_TEST_REJECT_FRAME_CHANNEL, false);
|
|
1458
1496
|
const logEvent = options.logEvent || (() => {});
|
|
1459
1497
|
const logWarn = options.logWarn || (() => {});
|
|
1460
1498
|
const emitEvent = options.emitEvent || (() => {});
|
|
@@ -1482,12 +1520,16 @@ export function createRemoteHub(options = {}) {
|
|
|
1482
1520
|
}
|
|
1483
1521
|
}
|
|
1484
1522
|
|
|
1485
|
-
function policyError(device, permission = '') {
|
|
1486
|
-
const policy = getDevicePolicy(device);
|
|
1487
|
-
|
|
1488
|
-
if (
|
|
1489
|
-
|
|
1490
|
-
|
|
1523
|
+
function policyError(device, permission = '', command = '') {
|
|
1524
|
+
const policy = getDevicePolicy(device);
|
|
1525
|
+
const commandName = safeString(command, 80);
|
|
1526
|
+
if (policy.accessMode === 'block-remote-access'
|
|
1527
|
+
&& !REMOTE_POLICY_BYPASS_COMMANDS.has(commandName)) {
|
|
1528
|
+
return 'remote-access-blocked-by-settings';
|
|
1529
|
+
}
|
|
1530
|
+
if (permission && policy[permission] === false) {
|
|
1531
|
+
return `${permission}-blocked-by-settings`;
|
|
1532
|
+
}
|
|
1491
1533
|
return '';
|
|
1492
1534
|
}
|
|
1493
1535
|
|
|
@@ -1506,6 +1548,11 @@ export function createRemoteHub(options = {}) {
|
|
|
1506
1548
|
250,
|
|
1507
1549
|
30000,
|
|
1508
1550
|
DEFAULT_REMOTE_INPUT_ACK_TIMEOUT_MS);
|
|
1551
|
+
const liveCaptureStopAckTimeoutMs = clampNumber(
|
|
1552
|
+
options.liveCaptureStopAckTimeoutMs ?? env.REMOTE_HUB_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS,
|
|
1553
|
+
250,
|
|
1554
|
+
30000,
|
|
1555
|
+
DEFAULT_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS);
|
|
1509
1556
|
const managerPackage = safeString(options.managerPackage ?? env.LIVEDESK_MANAGER_PACKAGE ?? env.MINDEXEC_MANAGER_PACKAGE ?? '@livedesk/hub', 128);
|
|
1510
1557
|
const managerVersion = safeString(options.managerVersion ?? env.LIVEDESK_MANAGER_VERSION ?? env.MINDEXEC_MANAGER_VERSION ?? '', 64);
|
|
1511
1558
|
const hostInstanceId = safeString(options.hostInstanceId ?? env.LIVEDESK_HUB_INSTANCE_ID ?? env.MINDEXEC_BRIDGE_INSTANCE_ID ?? crypto.randomUUID(), 128) || crypto.randomUUID();
|
|
@@ -1534,9 +1581,10 @@ export function createRemoteHub(options = {}) {
|
|
|
1534
1581
|
const sockets = new Map();
|
|
1535
1582
|
const inputSockets = new Map();
|
|
1536
1583
|
const frameSockets = new Map();
|
|
1537
|
-
const audioSockets = new Map();
|
|
1538
|
-
const fileSockets = new Map();
|
|
1539
|
-
const allSockets = new Set();
|
|
1584
|
+
const audioSockets = new Map();
|
|
1585
|
+
const fileSockets = new Map();
|
|
1586
|
+
const allSockets = new Set();
|
|
1587
|
+
const pendingCommandResultWaiters = new Map();
|
|
1540
1588
|
const duplicateDeviceLogAt = new Map();
|
|
1541
1589
|
let server = null;
|
|
1542
1590
|
let started = false;
|
|
@@ -2044,15 +2092,109 @@ export function createRemoteHub(options = {}) {
|
|
|
2044
2092
|
});
|
|
2045
2093
|
}
|
|
2046
2094
|
|
|
2047
|
-
function emitRemoteEvent(type, device = null, extra = {}) {
|
|
2048
|
-
emitEvent(type, {
|
|
2049
|
-
...extra,
|
|
2050
|
-
remoteHub: getStatus({ includeSecrets: false }),
|
|
2051
|
-
device: serializeDevice(device)
|
|
2052
|
-
});
|
|
2053
|
-
}
|
|
2054
|
-
|
|
2055
|
-
function
|
|
2095
|
+
function emitRemoteEvent(type, device = null, extra = {}) {
|
|
2096
|
+
emitEvent(type, {
|
|
2097
|
+
...extra,
|
|
2098
|
+
remoteHub: getStatus({ includeSecrets: false }),
|
|
2099
|
+
device: serializeDevice(device)
|
|
2100
|
+
});
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
function settlePendingCommandResult(device, message = {}) {
|
|
2104
|
+
const commandId = safeString(message.commandId, 128);
|
|
2105
|
+
const waiter = pendingCommandResultWaiters.get(commandId);
|
|
2106
|
+
if (!waiter
|
|
2107
|
+
|| waiter.deviceId !== device?.deviceId
|
|
2108
|
+
|| waiter.sessionId !== device?.sessionId) {
|
|
2109
|
+
return false;
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
pendingCommandResultWaiters.delete(commandId);
|
|
2113
|
+
clearTimeout(waiter.timer);
|
|
2114
|
+
const result = message.result ?? null;
|
|
2115
|
+
const error = safeString(
|
|
2116
|
+
message.error
|
|
2117
|
+
|| result?.error
|
|
2118
|
+
|| (message.type === 'command.error' ? 'command-failed' : ''),
|
|
2119
|
+
500);
|
|
2120
|
+
waiter.resolve({
|
|
2121
|
+
ok: !error
|
|
2122
|
+
&& result?.ok !== false
|
|
2123
|
+
&& result?.status !== 'failed'
|
|
2124
|
+
&& result?.status !== 'rejected',
|
|
2125
|
+
commandId,
|
|
2126
|
+
result,
|
|
2127
|
+
error
|
|
2128
|
+
});
|
|
2129
|
+
return true;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
function failPendingCommandResultWaiters(device, error = 'device-disconnected') {
|
|
2133
|
+
if (!device?.deviceId) {
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
for (const [commandId, waiter] of pendingCommandResultWaiters) {
|
|
2137
|
+
if (waiter.deviceId !== device.deviceId
|
|
2138
|
+
|| (waiter.sessionId && waiter.sessionId !== device.sessionId)) {
|
|
2139
|
+
continue;
|
|
2140
|
+
}
|
|
2141
|
+
pendingCommandResultWaiters.delete(commandId);
|
|
2142
|
+
clearTimeout(waiter.timer);
|
|
2143
|
+
waiter.resolve({
|
|
2144
|
+
ok: false,
|
|
2145
|
+
commandId,
|
|
2146
|
+
result: null,
|
|
2147
|
+
error: safeString(error, 500) || 'command-result-unavailable'
|
|
2148
|
+
});
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
function waitForCommandResult(device, commandId, timeoutMs = liveCaptureStopAckTimeoutMs) {
|
|
2153
|
+
const key = safeString(commandId, 128);
|
|
2154
|
+
if (!device?.deviceId || !device?.sessionId || !key) {
|
|
2155
|
+
return Promise.resolve({
|
|
2156
|
+
ok: false,
|
|
2157
|
+
commandId: key,
|
|
2158
|
+
result: null,
|
|
2159
|
+
error: 'command-result-wait-invalid'
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
return new Promise(resolve => {
|
|
2164
|
+
const previous = pendingCommandResultWaiters.get(key);
|
|
2165
|
+
if (previous) {
|
|
2166
|
+
clearTimeout(previous.timer);
|
|
2167
|
+
previous.resolve({
|
|
2168
|
+
ok: false,
|
|
2169
|
+
commandId: key,
|
|
2170
|
+
result: null,
|
|
2171
|
+
error: 'command-result-wait-replaced'
|
|
2172
|
+
});
|
|
2173
|
+
}
|
|
2174
|
+
const timer = setTimeout(() => {
|
|
2175
|
+
const waiter = pendingCommandResultWaiters.get(key);
|
|
2176
|
+
if (!waiter || waiter.deviceId !== device.deviceId || waiter.sessionId !== device.sessionId) {
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
pendingCommandResultWaiters.delete(key);
|
|
2180
|
+
resolve({
|
|
2181
|
+
ok: false,
|
|
2182
|
+
commandId: key,
|
|
2183
|
+
result: null,
|
|
2184
|
+
error: 'command-result-timeout'
|
|
2185
|
+
});
|
|
2186
|
+
}, timeoutMs);
|
|
2187
|
+
timer.unref?.();
|
|
2188
|
+
pendingCommandResultWaiters.set(key, {
|
|
2189
|
+
deviceId: device.deviceId,
|
|
2190
|
+
sessionId: device.sessionId,
|
|
2191
|
+
timer,
|
|
2192
|
+
resolve
|
|
2193
|
+
});
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
function makeSyntheticFrame(device, streamId, mode = 'thumbnail', options = {}) {
|
|
2056
2198
|
const now = new Date().toISOString();
|
|
2057
2199
|
const descriptor = buildRemoteFrameTransferDescriptor(mode, mode === 'thumbnail' ? 'thumbnail' : DEFAULT_REMOTE_FRAME_MODE);
|
|
2058
2200
|
const frameSeq = Number(device?.counters?.thumbnailFramesReceived || 0)
|
|
@@ -2144,9 +2286,12 @@ export function createRemoteHub(options = {}) {
|
|
|
2144
2286
|
byteLength: frame.byteLength
|
|
2145
2287
|
});
|
|
2146
2288
|
streamState.latestFrame = frame;
|
|
2147
|
-
streamState.lastFrameAt = frame.receivedAt;
|
|
2148
|
-
streamState.lastFrameSeq = frame.frameSeq;
|
|
2149
|
-
streamState.framesReceived = (streamState.framesReceived || 0) + 1;
|
|
2289
|
+
streamState.lastFrameAt = frame.receivedAt;
|
|
2290
|
+
streamState.lastFrameSeq = frame.frameSeq;
|
|
2291
|
+
streamState.framesReceived = (streamState.framesReceived || 0) + 1;
|
|
2292
|
+
// A synthetic PNG is a complete independently decodable surface and
|
|
2293
|
+
// therefore key-frame equivalent for Control readiness.
|
|
2294
|
+
streamState.readyFrameReceived = true;
|
|
2150
2295
|
device.lastSeenAt = frame.receivedAt;
|
|
2151
2296
|
device.counters.liveFramesReceived += 1;
|
|
2152
2297
|
emitRemoteEvent('RemoteFrameReceived', device, {
|
|
@@ -2170,6 +2315,7 @@ export function createRemoteHub(options = {}) {
|
|
|
2170
2315
|
existing.lastDisconnectReason = 'replaced-by-new-session';
|
|
2171
2316
|
failAllPendingTasks(existing, 'replaced-by-new-session');
|
|
2172
2317
|
failAllPendingInputFallbacks(existing, 'replaced-by-new-session');
|
|
2318
|
+
failPendingCommandResultWaiters(existing, 'replaced-by-new-session');
|
|
2173
2319
|
writeJsonLine(existing.socket, {
|
|
2174
2320
|
type: 'disconnect',
|
|
2175
2321
|
reason: 'replaced-by-new-session',
|
|
@@ -2709,9 +2855,11 @@ export function createRemoteHub(options = {}) {
|
|
|
2709
2855
|
: getSupportedRemoteFrameModeProfiles();
|
|
2710
2856
|
}
|
|
2711
2857
|
|
|
2712
|
-
const device = {
|
|
2713
|
-
socket,
|
|
2714
|
-
inputSocket: null,
|
|
2858
|
+
const device = {
|
|
2859
|
+
socket,
|
|
2860
|
+
inputSocket: null,
|
|
2861
|
+
inputOwnerConnectionId: '',
|
|
2862
|
+
inputOwnerBindingKey: '',
|
|
2715
2863
|
frameSocket: null,
|
|
2716
2864
|
audioSocket: null,
|
|
2717
2865
|
fileSocket: null,
|
|
@@ -2752,9 +2900,13 @@ export function createRemoteHub(options = {}) {
|
|
|
2752
2900
|
recentFramePayloads: {
|
|
2753
2901
|
thumbnail: [],
|
|
2754
2902
|
live: []
|
|
2755
|
-
},
|
|
2756
|
-
activeLiveStream: null,
|
|
2757
|
-
|
|
2903
|
+
},
|
|
2904
|
+
activeLiveStream: null,
|
|
2905
|
+
// A manual pause is a Hub-owned capture gate, not an Agent-session
|
|
2906
|
+
// detail. Preserve the same lease across a transient Client
|
|
2907
|
+
// reconnect so no subscriber or watchdog can reacquire FFmpeg.
|
|
2908
|
+
liveCapturePause: existing?.liveCapturePause || null,
|
|
2909
|
+
activeAudioStream: null,
|
|
2758
2910
|
latestAudioFrame: null,
|
|
2759
2911
|
latestAudioStatus: null,
|
|
2760
2912
|
udp: {
|
|
@@ -2943,10 +3095,11 @@ export function createRemoteHub(options = {}) {
|
|
|
2943
3095
|
closeInputSocket(device, reason);
|
|
2944
3096
|
closeFrameSocket(device, reason);
|
|
2945
3097
|
device.disconnectedAt = new Date().toISOString();
|
|
2946
|
-
device.lastDisconnectReason = reason;
|
|
3098
|
+
device.lastDisconnectReason = reason;
|
|
2947
3099
|
deactivateDeviceLiveStreams(device, reason, device.disconnectedAt);
|
|
2948
3100
|
failAllPendingTasks(device, 'device-disconnected');
|
|
2949
3101
|
failAllPendingInputFallbacks(device, 'device-disconnected');
|
|
3102
|
+
failPendingCommandResultWaiters(device, 'device-disconnected');
|
|
2950
3103
|
logWarn('remote', `device disconnected ${device.deviceName} (${deviceId}): ${reason}`);
|
|
2951
3104
|
emitRemoteEvent('RemoteDeviceDisconnected', device, { reason });
|
|
2952
3105
|
}
|
|
@@ -3329,6 +3482,20 @@ export function createRemoteHub(options = {}) {
|
|
|
3329
3482
|
return {
|
|
3330
3483
|
deviceId: device.deviceId,
|
|
3331
3484
|
commandId: safeString(message.commandId || message.CommandId || pending?.commandId, 128),
|
|
3485
|
+
hubConnectionId: safeString(
|
|
3486
|
+
message.hubConnectionId
|
|
3487
|
+
|| message.HubConnectionId
|
|
3488
|
+
|| result.hubConnectionId
|
|
3489
|
+
|| result.HubConnectionId
|
|
3490
|
+
|| pending?.hubConnectionId,
|
|
3491
|
+
128),
|
|
3492
|
+
inputEventId: safeString(
|
|
3493
|
+
message.inputEventId
|
|
3494
|
+
|| message.InputEventId
|
|
3495
|
+
|| result.inputEventId
|
|
3496
|
+
|| result.InputEventId
|
|
3497
|
+
|| pending?.inputEventId,
|
|
3498
|
+
128),
|
|
3332
3499
|
inputSeq,
|
|
3333
3500
|
inputType: safeString(
|
|
3334
3501
|
message.inputType
|
|
@@ -3337,6 +3504,13 @@ export function createRemoteHub(options = {}) {
|
|
|
3337
3504
|
|| result.InputType
|
|
3338
3505
|
|| pending?.inputType,
|
|
3339
3506
|
48),
|
|
3507
|
+
monitorIndex: Number(
|
|
3508
|
+
message.monitorIndex
|
|
3509
|
+
?? message.MonitorIndex
|
|
3510
|
+
?? result.monitorIndex
|
|
3511
|
+
?? result.MonitorIndex
|
|
3512
|
+
?? pending?.monitorIndex
|
|
3513
|
+
?? 0) || 0,
|
|
3340
3514
|
issuedAtEpochMs: Number(
|
|
3341
3515
|
message.issuedAtEpochMs
|
|
3342
3516
|
?? message.IssuedAtEpochMs
|
|
@@ -3602,13 +3776,35 @@ export function createRemoteHub(options = {}) {
|
|
|
3602
3776
|
: 0;
|
|
3603
3777
|
}
|
|
3604
3778
|
|
|
3605
|
-
function readFrameStageMs(message, property) {
|
|
3606
|
-
const value = Number(message?.[property]);
|
|
3607
|
-
return Number.isFinite(value) && value >= 0
|
|
3608
|
-
? Math.round(value)
|
|
3609
|
-
: 0;
|
|
3610
|
-
}
|
|
3611
|
-
|
|
3779
|
+
function readFrameStageMs(message, property) {
|
|
3780
|
+
const value = Number(message?.[property]);
|
|
3781
|
+
return Number.isFinite(value) && value >= 0
|
|
3782
|
+
? Math.round(value)
|
|
3783
|
+
: 0;
|
|
3784
|
+
}
|
|
3785
|
+
|
|
3786
|
+
function readOptionalFrameStageMs(message, property) {
|
|
3787
|
+
const rawValue = message?.[property];
|
|
3788
|
+
if (rawValue === undefined || rawValue === null || rawValue === '') {
|
|
3789
|
+
return undefined;
|
|
3790
|
+
}
|
|
3791
|
+
const value = Number(rawValue);
|
|
3792
|
+
return Number.isFinite(value) && value >= 0
|
|
3793
|
+
? Math.round(value)
|
|
3794
|
+
: undefined;
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3797
|
+
function hasFrameCaptureTiming(message) {
|
|
3798
|
+
if (message?.captureTimingAvailable === false) {
|
|
3799
|
+
return false;
|
|
3800
|
+
}
|
|
3801
|
+
if (message?.captureTimingAvailable === true) {
|
|
3802
|
+
return true;
|
|
3803
|
+
}
|
|
3804
|
+
return ['captureP95Ms', 'captureAverageMs', 'captureStageMs']
|
|
3805
|
+
.some(property => readOptionalFrameStageMs(message, property) !== undefined);
|
|
3806
|
+
}
|
|
3807
|
+
|
|
3612
3808
|
function computeSameContentStreak(previousFrame, contentHash) {
|
|
3613
3809
|
if (!contentHash || !previousFrame?.contentHash || previousFrame.contentHash !== contentHash) {
|
|
3614
3810
|
return 0;
|
|
@@ -3682,7 +3878,7 @@ export function createRemoteHub(options = {}) {
|
|
|
3682
3878
|
byteLength,
|
|
3683
3879
|
transport,
|
|
3684
3880
|
contentHash,
|
|
3685
|
-
captureMs: readFrameCaptureMs(message),
|
|
3881
|
+
captureMs: readFrameCaptureMs(message),
|
|
3686
3882
|
captureStageMs: readFrameStageMs(message, 'captureStageMs'),
|
|
3687
3883
|
convertMs: readFrameStageMs(message, 'convertMs'),
|
|
3688
3884
|
compressMs: readFrameStageMs(message, 'compressMs'),
|
|
@@ -3774,6 +3970,7 @@ export function createRemoteHub(options = {}) {
|
|
|
3774
3970
|
lastFrameAt: '',
|
|
3775
3971
|
lastFrameSeq: 0,
|
|
3776
3972
|
framesReceived: 0,
|
|
3973
|
+
readyFrameReceived: false,
|
|
3777
3974
|
latestFrame: null,
|
|
3778
3975
|
stoppedAt: '',
|
|
3779
3976
|
stopReason: ''
|
|
@@ -3991,6 +4188,13 @@ export function createRemoteHub(options = {}) {
|
|
|
3991
4188
|
&& (activeCaptureGeneration <= 0
|
|
3992
4189
|
|| (frameCaptureGeneration > 0
|
|
3993
4190
|
&& frameCaptureGeneration === activeCaptureGeneration));
|
|
4191
|
+
const readyFrameReceivedBeforeFrame = streamState.readyFrameReceived === true;
|
|
4192
|
+
const captureTimingAvailable = hasFrameCaptureTiming(message);
|
|
4193
|
+
const videoTransport = transport === 'udp-p2p'
|
|
4194
|
+
? 'p2p-udp'
|
|
4195
|
+
: transport === 'ws-binary'
|
|
4196
|
+
? 'direct-ws'
|
|
4197
|
+
: 'direct-tcp';
|
|
3994
4198
|
device.latestLiveFrame = {
|
|
3995
4199
|
streamId,
|
|
3996
4200
|
frameSeq,
|
|
@@ -4027,9 +4231,10 @@ export function createRemoteHub(options = {}) {
|
|
|
4027
4231
|
agentPaceMs: Number.isFinite(Number(message.agentPaceMs)) ? Number(message.agentPaceMs) : 0,
|
|
4028
4232
|
agentQueueBeforePaceMs: Number.isFinite(Number(message.agentQueueBeforePaceMs)) ? Number(message.agentQueueBeforePaceMs) : 0,
|
|
4029
4233
|
agentFrameAgeMs: Number.isFinite(Number(message.agentFrameAgeMs)) ? Number(message.agentFrameAgeMs) : 0,
|
|
4030
|
-
droppedByAgent: Number.isFinite(Number(message.droppedByAgent)) ? Number(message.droppedByAgent) : 0,
|
|
4031
|
-
hubIngressDropped: Number.isFinite(Number(message.hubIngressDropped)) ? Number(message.hubIngressDropped) : 0,
|
|
4032
|
-
|
|
4234
|
+
droppedByAgent: Number.isFinite(Number(message.droppedByAgent)) ? Number(message.droppedByAgent) : 0,
|
|
4235
|
+
hubIngressDropped: Number.isFinite(Number(message.hubIngressDropped)) ? Number(message.hubIngressDropped) : 0,
|
|
4236
|
+
udpIncompleteFrames: Number.isFinite(Number(message.udpIncompleteFrames)) ? Number(message.udpIncompleteFrames) : 0,
|
|
4237
|
+
hardwareEncoder: safeString(message.hardwareEncoder, 80),
|
|
4033
4238
|
platformProfile: safeString(message.platformProfile, 80),
|
|
4034
4239
|
monitorIndex: Number.isFinite(Number(message.monitorIndex)) ? normalizeMonitorIndex(message.monitorIndex) : Number(streamState.monitorIndex || 0),
|
|
4035
4240
|
monitorCount: Number.isFinite(Number(message.monitorCount)) ? clampNumber(message.monitorCount, 1, 64, 1) : Number(streamState.monitorCount || 1),
|
|
@@ -4037,29 +4242,40 @@ export function createRemoteHub(options = {}) {
|
|
|
4037
4242
|
transferProtocolVersion: transfer.transferProtocolVersion,
|
|
4038
4243
|
handshake: transfer.handshake,
|
|
4039
4244
|
fps: Number.isFinite(Number(message.fps)) ? Number(message.fps) : streamState.fps,
|
|
4040
|
-
requestedFps: Number.isFinite(Number(message.requestedFps)) ? Number(message.requestedFps) : Number(streamState.fps || 0),
|
|
4041
|
-
effectiveFps: Number.isFinite(Number(message.effectiveFps)) ? Number(message.effectiveFps) : Number(message.fps || streamState.fps || 0),
|
|
4042
|
-
|
|
4043
|
-
|
|
4245
|
+
requestedFps: Number.isFinite(Number(message.requestedFps)) ? Number(message.requestedFps) : Number(streamState.fps || 0),
|
|
4246
|
+
effectiveFps: Number.isFinite(Number(message.effectiveFps)) ? Number(message.effectiveFps) : Number(message.fps || streamState.fps || 0),
|
|
4247
|
+
captureTimingAvailable,
|
|
4248
|
+
captureAverageMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureAverageMs') : undefined,
|
|
4249
|
+
captureP95Ms: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureP95Ms') : undefined,
|
|
4044
4250
|
slowFrameCount: Number.isFinite(Number(message.slowFrameCount)) ? Number(message.slowFrameCount) : 0,
|
|
4045
|
-
captureHelperRestarts: Number.isFinite(Number(message.captureHelperRestarts)) ? Number(message.captureHelperRestarts) : 0,
|
|
4046
|
-
capturedAt,
|
|
4047
|
-
receivedAt: device.lastSeenAt,
|
|
4048
|
-
byteLength,
|
|
4049
|
-
transport,
|
|
4050
|
-
contentHash,
|
|
4051
|
-
captureMs:
|
|
4052
|
-
captureStageMs:
|
|
4251
|
+
captureHelperRestarts: Number.isFinite(Number(message.captureHelperRestarts)) ? Number(message.captureHelperRestarts) : 0,
|
|
4252
|
+
capturedAt,
|
|
4253
|
+
receivedAt: device.lastSeenAt,
|
|
4254
|
+
byteLength,
|
|
4255
|
+
transport,
|
|
4256
|
+
contentHash,
|
|
4257
|
+
captureMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureMs') : undefined,
|
|
4258
|
+
captureStageMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureStageMs') : undefined,
|
|
4053
4259
|
convertMs: readFrameStageMs(message, 'convertMs'),
|
|
4054
4260
|
compressMs: readFrameStageMs(message, 'compressMs'),
|
|
4055
4261
|
captureSourceSerial: Number.isFinite(Number(message.captureSourceSerial)) ? Number(message.captureSourceSerial) : 0,
|
|
4056
4262
|
captureSourceSkipped: Number.isFinite(Number(message.captureSourceSkipped)) ? Number(message.captureSourceSkipped) : 0,
|
|
4057
4263
|
deltaTileSize: Number.isFinite(Number(message.deltaTileSize)) ? Number(message.deltaTileSize) : 0,
|
|
4058
4264
|
deltaTileCount: Number.isFinite(Number(message.deltaTileCount)) ? Number(message.deltaTileCount) : 0,
|
|
4059
|
-
deltaVisualTileCount: Number.isFinite(Number(message.deltaVisualTileCount)) ? Number(message.deltaVisualTileCount) : 0,
|
|
4060
|
-
deltaRefreshTileCount: Number.isFinite(Number(message.deltaRefreshTileCount)) ? Number(message.deltaRefreshTileCount) : 0,
|
|
4061
|
-
deltaTotalTiles: Number.isFinite(Number(message.deltaTotalTiles)) ? Number(message.deltaTotalTiles) : 0,
|
|
4062
|
-
|
|
4265
|
+
deltaVisualTileCount: Number.isFinite(Number(message.deltaVisualTileCount)) ? Number(message.deltaVisualTileCount) : 0,
|
|
4266
|
+
deltaRefreshTileCount: Number.isFinite(Number(message.deltaRefreshTileCount)) ? Number(message.deltaRefreshTileCount) : 0,
|
|
4267
|
+
deltaTotalTiles: Number.isFinite(Number(message.deltaTotalTiles)) ? Number(message.deltaTotalTiles) : 0,
|
|
4268
|
+
videoTransport,
|
|
4269
|
+
frameTransportActive: safeString(message.frameTransportActive, 20) || (videoTransport === 'p2p-udp' ? 'p2p' : 'direct'),
|
|
4270
|
+
frameTransportProtocol: safeString(message.frameTransportProtocol, 20)
|
|
4271
|
+
|| (videoTransport === 'p2p-udp' ? 'udp' : videoTransport === 'direct-ws' ? 'ws' : 'tcp'),
|
|
4272
|
+
frameTransportState: safeString(message.frameTransportState, 20) || 'active',
|
|
4273
|
+
frameTransportP2pReady: message.frameTransportP2pReady === true,
|
|
4274
|
+
frameTransportReason: safeString(message.frameTransportReason, 240),
|
|
4275
|
+
frameTransportChangedAt: safeString(message.frameTransportChangedAt, 80),
|
|
4276
|
+
frameTransportSwitchCount: Number.isFinite(Number(message.frameTransportSwitchCount)) ? Number(message.frameTransportSwitchCount) : 0,
|
|
4277
|
+
frameTransportTcpFailureCount: Number.isFinite(Number(message.frameTransportTcpFailureCount)) ? Number(message.frameTransportTcpFailureCount) : 0,
|
|
4278
|
+
sameContentStreak,
|
|
4063
4279
|
payload,
|
|
4064
4280
|
accessToken: createFrameAccessToken()
|
|
4065
4281
|
};
|
|
@@ -4073,9 +4289,12 @@ export function createRemoteHub(options = {}) {
|
|
|
4073
4289
|
mimeType: device.latestLiveFrame.mimeType,
|
|
4074
4290
|
byteLength: device.latestLiveFrame.byteLength
|
|
4075
4291
|
});
|
|
4076
|
-
streamState.lastFrameAt = device.lastSeenAt;
|
|
4077
|
-
streamState.lastFrameSeq = frameSeq;
|
|
4078
|
-
streamState.framesReceived = (streamState.framesReceived || 0) + 1;
|
|
4292
|
+
streamState.lastFrameAt = device.lastSeenAt;
|
|
4293
|
+
streamState.lastFrameSeq = frameSeq;
|
|
4294
|
+
streamState.framesReceived = (streamState.framesReceived || 0) + 1;
|
|
4295
|
+
streamState.readyFrameReceived = readyFrameReceivedBeforeFrame
|
|
4296
|
+
|| (currentGenerationVerified
|
|
4297
|
+
&& liveFrameSatisfiesReadiness(streamState, device.latestLiveFrame));
|
|
4079
4298
|
streamState.mode = transfer.mode;
|
|
4080
4299
|
streamState.frameMode = transfer.frameMode;
|
|
4081
4300
|
streamState.frameProfile = transfer.frameProfile;
|
|
@@ -4093,7 +4312,9 @@ export function createRemoteHub(options = {}) {
|
|
|
4093
4312
|
streamState.monitorCount = device.latestLiveFrame.monitorCount;
|
|
4094
4313
|
ensureDeviceLiveStreams(device).set(streamId, streamState);
|
|
4095
4314
|
device.counters.liveFramesReceived += 1;
|
|
4096
|
-
if (
|
|
4315
|
+
if (!readyFrameReceivedBeforeFrame
|
|
4316
|
+
&& streamState.readyFrameReceived === true
|
|
4317
|
+
&& liveStreamHasCurrentFrame(streamState)) {
|
|
4097
4318
|
emitRemoteEvent('RemoteLiveStreamReady', device, {
|
|
4098
4319
|
streamId,
|
|
4099
4320
|
commandId: streamState.commandId,
|
|
@@ -4217,7 +4438,7 @@ export function createRemoteHub(options = {}) {
|
|
|
4217
4438
|
return true;
|
|
4218
4439
|
}
|
|
4219
4440
|
|
|
4220
|
-
function handleAgentBinaryFrame(socket, state, header, framePayload) {
|
|
4441
|
+
function handleAgentBinaryFrame(socket, state, header, framePayload) {
|
|
4221
4442
|
if (!state.authenticated || !state.device) {
|
|
4222
4443
|
writeJsonLine(socket, { type: 'error', error: 'hello-required' });
|
|
4223
4444
|
socket.destroy();
|
|
@@ -4241,28 +4462,29 @@ export function createRemoteHub(options = {}) {
|
|
|
4241
4462
|
}
|
|
4242
4463
|
|
|
4243
4464
|
const frameKind = safeString(header.frameKind || header.kind || header.frameType || '', 40).toLowerCase();
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4465
|
+
const binaryTransport = state.binaryTransport === 'ws-binary' ? 'ws-binary' : 'binary';
|
|
4466
|
+
if (frameKind === 'thumbnail' || header.type === 'thumbnail.binary') {
|
|
4467
|
+
applyThumbnailFrame(device, header, framePayload, binaryTransport);
|
|
4468
|
+
return;
|
|
4469
|
+
}
|
|
4470
|
+
|
|
4471
|
+
if (frameKind === 'stream' || frameKind === 'live' || header.type === 'stream.binary') {
|
|
4472
|
+
applyLiveFrame(device, {
|
|
4473
|
+
...header,
|
|
4474
|
+
hubIngressDropped: Number(state.binaryQueueDrops || 0)
|
|
4475
|
+
}, framePayload, binaryTransport);
|
|
4476
|
+
return;
|
|
4477
|
+
}
|
|
4478
|
+
|
|
4479
|
+
if (frameKind === 'audio' || header.type === 'audio.binary') {
|
|
4480
|
+
applyAudioFrame(device, header, framePayload, binaryTransport);
|
|
4481
|
+
return;
|
|
4482
|
+
}
|
|
4483
|
+
|
|
4484
|
+
if (frameKind === 'audio-status' || header.type === 'audio.status.binary') {
|
|
4485
|
+
applyAudioStatus(device, header, framePayload, binaryTransport);
|
|
4486
|
+
return;
|
|
4487
|
+
}
|
|
4266
4488
|
|
|
4267
4489
|
emitRemoteEvent('RemoteAgentMessageIgnored', device, {
|
|
4268
4490
|
messageType: safeString(header.type, 80),
|
|
@@ -4440,9 +4662,14 @@ export function createRemoteHub(options = {}) {
|
|
|
4440
4662
|
state.device = device;
|
|
4441
4663
|
state.inputOnly = true;
|
|
4442
4664
|
return;
|
|
4443
|
-
}
|
|
4444
|
-
if (channel === 'frame') {
|
|
4445
|
-
|
|
4665
|
+
}
|
|
4666
|
+
if (channel === 'frame') {
|
|
4667
|
+
if (rejectFrameChannelForTest) {
|
|
4668
|
+
writeJsonLine(socket, { type: 'error', error: 'test-frame-channel-unavailable' });
|
|
4669
|
+
socket.end();
|
|
4670
|
+
return;
|
|
4671
|
+
}
|
|
4672
|
+
const device = attachFrameSocket(socket, message);
|
|
4446
4673
|
if (!device) {
|
|
4447
4674
|
return;
|
|
4448
4675
|
}
|
|
@@ -4493,23 +4720,12 @@ export function createRemoteHub(options = {}) {
|
|
|
4493
4720
|
|
|
4494
4721
|
if (state.inputOnly) {
|
|
4495
4722
|
device.inputLastSeenAt = new Date().toISOString();
|
|
4496
|
-
if (message.type === 'input.applied') {
|
|
4723
|
+
if (message.type === 'input.applied' || message.type === 'input.error') {
|
|
4497
4724
|
handleRemoteInputOutcome(device, {
|
|
4498
4725
|
...message,
|
|
4499
4726
|
agentApplyMs: Number(message.agentApplyMs || 0) || 0
|
|
4500
4727
|
}, 'input');
|
|
4501
4728
|
}
|
|
4502
|
-
if (message.type === 'input.error') {
|
|
4503
|
-
const pending = takePendingInputFallback(
|
|
4504
|
-
device,
|
|
4505
|
-
message.commandId || message.CommandId,
|
|
4506
|
-
message.inputSeq ?? message.InputSeq);
|
|
4507
|
-
emitEvent('RemoteInputError', {
|
|
4508
|
-
...buildRemoteInputOutcome(device, message, pending),
|
|
4509
|
-
error: safeString(message.error || message.Error, 600) || 'remote-input-failed',
|
|
4510
|
-
failedAtEpochMs: Number(message.failedAtEpochMs ?? message.FailedAtEpochMs ?? 0) || 0
|
|
4511
|
-
});
|
|
4512
|
-
}
|
|
4513
4729
|
return;
|
|
4514
4730
|
}
|
|
4515
4731
|
if (state.frameOnly) {
|
|
@@ -4553,6 +4769,7 @@ export function createRemoteHub(options = {}) {
|
|
|
4553
4769
|
message.error || (message.type === 'command.error' ? 'command-failed' : ''),
|
|
4554
4770
|
500);
|
|
4555
4771
|
const result = message.result ?? null;
|
|
4772
|
+
settlePendingCommandResult(device, message);
|
|
4556
4773
|
handleInputFallbackCommandResult(device, message);
|
|
4557
4774
|
if (error || result?.ok === false || result?.status === 'failed') {
|
|
4558
4775
|
failPendingLiveStream(device, commandId, error || result?.error || 'stream-start-failed');
|
|
@@ -4606,10 +4823,11 @@ export function createRemoteHub(options = {}) {
|
|
|
4606
4823
|
socket.setNoDelay(true);
|
|
4607
4824
|
socket.setKeepAlive(true, heartbeatMs);
|
|
4608
4825
|
|
|
4609
|
-
const state = {
|
|
4610
|
-
authenticated: false,
|
|
4611
|
-
device: null,
|
|
4612
|
-
|
|
4826
|
+
const state = {
|
|
4827
|
+
authenticated: false,
|
|
4828
|
+
device: null,
|
|
4829
|
+
binaryTransport: 'ws-binary',
|
|
4830
|
+
inputOnly: false,
|
|
4613
4831
|
frameOnly: false,
|
|
4614
4832
|
audioOnly: false,
|
|
4615
4833
|
fileOnly: false,
|
|
@@ -4763,10 +4981,11 @@ export function createRemoteHub(options = {}) {
|
|
|
4763
4981
|
socket.setNoDelay(true);
|
|
4764
4982
|
socket.setKeepAlive(true, heartbeatMs);
|
|
4765
4983
|
|
|
4766
|
-
const state = {
|
|
4767
|
-
authenticated: false,
|
|
4768
|
-
device: null,
|
|
4769
|
-
|
|
4984
|
+
const state = {
|
|
4985
|
+
authenticated: false,
|
|
4986
|
+
device: null,
|
|
4987
|
+
binaryTransport: 'binary',
|
|
4988
|
+
inputOnly: false,
|
|
4770
4989
|
frameOnly: false,
|
|
4771
4990
|
audioOnly: false,
|
|
4772
4991
|
fileOnly: false,
|
|
@@ -5000,6 +5219,7 @@ export function createRemoteHub(options = {}) {
|
|
|
5000
5219
|
for (const device of devices.values()) {
|
|
5001
5220
|
failAllPendingTasks(device, 'hub-shutdown');
|
|
5002
5221
|
failAllPendingInputFallbacks(device, 'hub-shutdown');
|
|
5222
|
+
failPendingCommandResultWaiters(device, 'hub-shutdown');
|
|
5003
5223
|
if (device.socket && !device.socket.destroyed) {
|
|
5004
5224
|
writeJsonLine(device.socket, { type: 'disconnect', reason: 'hub-shutdown' });
|
|
5005
5225
|
device.socket.destroy();
|
|
@@ -5137,7 +5357,7 @@ export function createRemoteHub(options = {}) {
|
|
|
5137
5357
|
: commandName.startsWith('agent.')
|
|
5138
5358
|
? 'allowAgent'
|
|
5139
5359
|
: '';
|
|
5140
|
-
const denied = policyError(device, requiredPermission);
|
|
5360
|
+
const denied = policyError(device, requiredPermission, commandName);
|
|
5141
5361
|
if (denied) return { ok: false, error: denied };
|
|
5142
5362
|
if (device?.synthetic === true && device.connected) {
|
|
5143
5363
|
const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
|
|
@@ -5240,10 +5460,58 @@ export function createRemoteHub(options = {}) {
|
|
|
5240
5460
|
return { ok: false, error: 'device-not-found' };
|
|
5241
5461
|
}
|
|
5242
5462
|
|
|
5243
|
-
if (!device.connected) {
|
|
5244
|
-
return { ok: false, error: 'device-not-connected' };
|
|
5245
|
-
}
|
|
5246
|
-
|
|
5463
|
+
if (!device.connected) {
|
|
5464
|
+
return { ok: false, error: 'device-not-connected' };
|
|
5465
|
+
}
|
|
5466
|
+
|
|
5467
|
+
const normalized = normalizeRemoteInputEvent(input);
|
|
5468
|
+
if (!normalized.type) {
|
|
5469
|
+
return { ok: false, error: 'missing-input-type' };
|
|
5470
|
+
}
|
|
5471
|
+
|
|
5472
|
+
// A reset only releases native key state. The current browser input
|
|
5473
|
+
// owner must be able to send it even when its former Control binding
|
|
5474
|
+
// has just become stale during a monitor/capture-generation switch.
|
|
5475
|
+
const currentInputOwner = safeString(device.inputOwnerConnectionId, 128);
|
|
5476
|
+
const isCurrentOwnerKeyboardReset = normalized.type.toLowerCase() === 'keyboard.reset'
|
|
5477
|
+
&& normalized.hubConnectionId
|
|
5478
|
+
&& normalized.hubConnectionId === currentInputOwner;
|
|
5479
|
+
if (isCurrentOwnerKeyboardReset) {
|
|
5480
|
+
const inputSocket = device.inputSocket;
|
|
5481
|
+
if (inputSocket && !inputSocket.destroyed) {
|
|
5482
|
+
const sent = writeJsonLine(inputSocket, {
|
|
5483
|
+
type: 'input.control',
|
|
5484
|
+
payload: {
|
|
5485
|
+
...normalized,
|
|
5486
|
+
reason: normalized.reason || 'control-keyboard-owner-changed',
|
|
5487
|
+
pressedCodes: [],
|
|
5488
|
+
shiftKey: false,
|
|
5489
|
+
ctrlKey: false,
|
|
5490
|
+
altKey: false,
|
|
5491
|
+
metaKey: false,
|
|
5492
|
+
repeat: false,
|
|
5493
|
+
hubForwardedAtEpochMs: Date.now(),
|
|
5494
|
+
issuedAt: normalized.issuedAt || new Date().toISOString()
|
|
5495
|
+
}
|
|
5496
|
+
});
|
|
5497
|
+
if (sent) {
|
|
5498
|
+
device.inputOwnerBindingKey = '';
|
|
5499
|
+
device.counters.commandsSent += 1;
|
|
5500
|
+
device.inputLastSeenAt = new Date().toISOString();
|
|
5501
|
+
return {
|
|
5502
|
+
ok: true,
|
|
5503
|
+
inputSocket: true,
|
|
5504
|
+
releaseOnly: true,
|
|
5505
|
+
staleBindingAccepted: true
|
|
5506
|
+
};
|
|
5507
|
+
}
|
|
5508
|
+
detachInputSocket(inputSocket, 'input-owner-reset-write-failed');
|
|
5509
|
+
}
|
|
5510
|
+
if (readCapabilityFlag(device.capabilities, 'dedicatedInputChannel')) {
|
|
5511
|
+
return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
|
|
5512
|
+
}
|
|
5513
|
+
}
|
|
5514
|
+
|
|
5247
5515
|
const denied = policyError(device, 'allowControl');
|
|
5248
5516
|
if (denied) return { ok: false, error: denied };
|
|
5249
5517
|
|
|
@@ -5255,10 +5523,6 @@ export function createRemoteHub(options = {}) {
|
|
|
5255
5523
|
return { ok: false, error: 'device-input-control-unavailable' };
|
|
5256
5524
|
}
|
|
5257
5525
|
|
|
5258
|
-
const normalized = normalizeRemoteInputEvent(input);
|
|
5259
|
-
if (!normalized.type) {
|
|
5260
|
-
return { ok: false, error: 'missing-input-type' };
|
|
5261
|
-
}
|
|
5262
5526
|
const controlStream = getActiveControlStream(device);
|
|
5263
5527
|
if (!controlStream
|
|
5264
5528
|
|| !liveStreamHasCurrentFrame(controlStream)
|
|
@@ -5292,18 +5556,74 @@ export function createRemoteHub(options = {}) {
|
|
|
5292
5556
|
activeCaptureGeneration: Number(controlStream.captureGeneration || 0)
|
|
5293
5557
|
};
|
|
5294
5558
|
}
|
|
5559
|
+
const activeMonitorIndex = normalizeMonitorIndex(controlStream.monitorIndex);
|
|
5560
|
+
if (!Number.isInteger(normalized.monitorIndex)
|
|
5561
|
+
|| normalized.monitorIndex !== activeMonitorIndex) {
|
|
5562
|
+
return {
|
|
5563
|
+
ok: false,
|
|
5564
|
+
error: 'STALE_CONTROL_MONITOR',
|
|
5565
|
+
activeMonitorIndex
|
|
5566
|
+
};
|
|
5567
|
+
}
|
|
5295
5568
|
|
|
5296
5569
|
const inputSocket = device.inputSocket;
|
|
5297
5570
|
if (inputSocket && !inputSocket.destroyed) {
|
|
5298
|
-
const
|
|
5571
|
+
const previousOwnerConnectionId = safeString(device.inputOwnerConnectionId, 128);
|
|
5572
|
+
const activeInputBindingKey = [
|
|
5573
|
+
safeString(device.sessionId, 160),
|
|
5574
|
+
safeString(controlStream.commandId, 128),
|
|
5575
|
+
Number(controlStream.captureGeneration || 0),
|
|
5576
|
+
activeMonitorIndex
|
|
5577
|
+
].join(':');
|
|
5578
|
+
const previousInputBindingKey = safeString(device.inputOwnerBindingKey, 512);
|
|
5579
|
+
const ownerChanged = normalized.hubConnectionId
|
|
5580
|
+
&& previousOwnerConnectionId
|
|
5581
|
+
&& normalized.hubConnectionId !== previousOwnerConnectionId;
|
|
5582
|
+
const bindingChanged = previousInputBindingKey
|
|
5583
|
+
&& previousInputBindingKey !== activeInputBindingKey;
|
|
5584
|
+
if (ownerChanged || bindingChanged) {
|
|
5585
|
+
const resetSent = writeJsonLine(inputSocket, {
|
|
5586
|
+
type: 'input.control',
|
|
5587
|
+
payload: {
|
|
5588
|
+
type: 'keyboard.reset',
|
|
5589
|
+
reason: ownerChanged
|
|
5590
|
+
? 'browser-input-owner-changed'
|
|
5591
|
+
: 'control-input-binding-changed',
|
|
5592
|
+
pressedCodes: [],
|
|
5593
|
+
shiftKey: false,
|
|
5594
|
+
ctrlKey: false,
|
|
5595
|
+
altKey: false,
|
|
5596
|
+
metaKey: false,
|
|
5597
|
+
repeat: false,
|
|
5598
|
+
requestAck: false,
|
|
5599
|
+
inputSeq: 0,
|
|
5600
|
+
hubConnectionId: previousOwnerConnectionId,
|
|
5601
|
+
monitorIndex: activeMonitorIndex,
|
|
5602
|
+
controlSessionId: safeString(device.sessionId, 160),
|
|
5603
|
+
controlCommandId: safeString(controlStream.commandId, 128),
|
|
5604
|
+
captureGeneration: Number(controlStream.captureGeneration || 0),
|
|
5605
|
+
hubForwardedAtEpochMs: Date.now(),
|
|
5606
|
+
issuedAt: new Date().toISOString()
|
|
5607
|
+
}
|
|
5608
|
+
});
|
|
5609
|
+
if (!resetSent) {
|
|
5610
|
+
detachInputSocket(inputSocket, 'input-owner-reset-write-failed');
|
|
5611
|
+
return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
|
|
5612
|
+
}
|
|
5613
|
+
}
|
|
5614
|
+
const sent = writeJsonLine(inputSocket, {
|
|
5299
5615
|
type: 'input.control',
|
|
5300
5616
|
payload: {
|
|
5301
5617
|
...normalized,
|
|
5302
5618
|
hubForwardedAtEpochMs: Date.now(),
|
|
5303
5619
|
issuedAt: normalized.issuedAt || new Date().toISOString()
|
|
5304
5620
|
}
|
|
5305
|
-
});
|
|
5306
|
-
if (sent) {
|
|
5621
|
+
});
|
|
5622
|
+
if (sent) {
|
|
5623
|
+
if (normalized.hubConnectionId) {
|
|
5624
|
+
device.inputOwnerConnectionId = normalized.hubConnectionId;
|
|
5625
|
+
}
|
|
5626
|
+
device.inputOwnerBindingKey = activeInputBindingKey;
|
|
5307
5627
|
device.counters.commandsSent += 1;
|
|
5308
5628
|
device.inputLastSeenAt = new Date().toISOString();
|
|
5309
5629
|
return {
|
|
@@ -5311,13 +5631,21 @@ export function createRemoteHub(options = {}) {
|
|
|
5311
5631
|
inputSocket: true,
|
|
5312
5632
|
sessionId: device.sessionId,
|
|
5313
5633
|
commandId: controlStream.commandId,
|
|
5314
|
-
captureGeneration: controlStream.captureGeneration
|
|
5634
|
+
captureGeneration: controlStream.captureGeneration,
|
|
5635
|
+
monitorIndex: activeMonitorIndex
|
|
5315
5636
|
};
|
|
5316
5637
|
}
|
|
5317
5638
|
|
|
5318
|
-
detachInputSocket(inputSocket, 'input-socket-write-failed');
|
|
5319
|
-
}
|
|
5320
|
-
|
|
5639
|
+
detachInputSocket(inputSocket, 'input-socket-write-failed');
|
|
5640
|
+
}
|
|
5641
|
+
|
|
5642
|
+
// Current Agents advertise this ordered side channel. Crossing to the
|
|
5643
|
+
// concurrent main command socket during a reconnect can invert
|
|
5644
|
+
// keydown/keyup, so only legacy Agents use the compatibility fallback.
|
|
5645
|
+
if (readCapabilityFlag(device.capabilities, 'dedicatedInputChannel')) {
|
|
5646
|
+
return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
|
|
5647
|
+
}
|
|
5648
|
+
|
|
5321
5649
|
const hubForwardedAtEpochMs = Date.now();
|
|
5322
5650
|
const fallback = sendCommand(deviceId, {
|
|
5323
5651
|
command: 'input.control',
|
|
@@ -5332,6 +5660,9 @@ export function createRemoteHub(options = {}) {
|
|
|
5332
5660
|
commandId: fallback.commandId,
|
|
5333
5661
|
inputSeq: normalized.inputSeq,
|
|
5334
5662
|
inputType: normalized.type,
|
|
5663
|
+
monitorIndex: normalized.monitorIndex,
|
|
5664
|
+
hubConnectionId: normalized.hubConnectionId,
|
|
5665
|
+
inputEventId: normalized.inputEventId,
|
|
5335
5666
|
issuedAtEpochMs: normalized.issuedAtEpochMs,
|
|
5336
5667
|
hubReceivedAtEpochMs: normalized.hubReceivedAtEpochMs,
|
|
5337
5668
|
hubForwardedAtEpochMs,
|
|
@@ -5348,6 +5679,7 @@ export function createRemoteHub(options = {}) {
|
|
|
5348
5679
|
deliveryCommandId: fallback.commandId,
|
|
5349
5680
|
commandId: controlStream.commandId,
|
|
5350
5681
|
captureGeneration: controlStream.captureGeneration,
|
|
5682
|
+
monitorIndex: activeMonitorIndex,
|
|
5351
5683
|
inputSeq: normalized.inputSeq
|
|
5352
5684
|
}
|
|
5353
5685
|
: {
|
|
@@ -5357,6 +5689,49 @@ export function createRemoteHub(options = {}) {
|
|
|
5357
5689
|
};
|
|
5358
5690
|
}
|
|
5359
5691
|
|
|
5692
|
+
function releaseInputOwner(deviceId, ownerConnectionId, reason = 'browser-input-owner-closed') {
|
|
5693
|
+
const device = devices.get(String(deviceId || ''));
|
|
5694
|
+
const owner = safeString(ownerConnectionId, 128);
|
|
5695
|
+
if (!device || !owner || safeString(device.inputOwnerConnectionId, 128) !== owner) {
|
|
5696
|
+
return { ok: false, error: 'input-owner-not-current' };
|
|
5697
|
+
}
|
|
5698
|
+
|
|
5699
|
+
device.inputOwnerConnectionId = '';
|
|
5700
|
+
device.inputOwnerBindingKey = '';
|
|
5701
|
+
const inputSocket = device.inputSocket;
|
|
5702
|
+
if (!inputSocket || inputSocket.destroyed) {
|
|
5703
|
+
return { ok: true, queued: false };
|
|
5704
|
+
}
|
|
5705
|
+
const controlStream = getActiveControlStream(device);
|
|
5706
|
+
const sent = writeJsonLine(inputSocket, {
|
|
5707
|
+
type: 'input.control',
|
|
5708
|
+
payload: {
|
|
5709
|
+
type: 'keyboard.reset',
|
|
5710
|
+
reason: safeString(reason, 120) || 'browser-input-owner-closed',
|
|
5711
|
+
pressedCodes: [],
|
|
5712
|
+
shiftKey: false,
|
|
5713
|
+
ctrlKey: false,
|
|
5714
|
+
altKey: false,
|
|
5715
|
+
metaKey: false,
|
|
5716
|
+
repeat: false,
|
|
5717
|
+
requestAck: false,
|
|
5718
|
+
inputSeq: 0,
|
|
5719
|
+
hubConnectionId: owner,
|
|
5720
|
+
monitorIndex: normalizeMonitorIndex(controlStream?.monitorIndex),
|
|
5721
|
+
controlSessionId: safeString(device.sessionId, 160),
|
|
5722
|
+
controlCommandId: safeString(controlStream?.commandId, 128),
|
|
5723
|
+
captureGeneration: Number(controlStream?.captureGeneration || 0),
|
|
5724
|
+
hubForwardedAtEpochMs: Date.now(),
|
|
5725
|
+
issuedAt: new Date().toISOString()
|
|
5726
|
+
}
|
|
5727
|
+
});
|
|
5728
|
+
if (!sent) {
|
|
5729
|
+
detachInputSocket(inputSocket, 'input-owner-release-write-failed');
|
|
5730
|
+
return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
|
|
5731
|
+
}
|
|
5732
|
+
return { ok: true, queued: true };
|
|
5733
|
+
}
|
|
5734
|
+
|
|
5360
5735
|
function notifyAgentProgress(deviceIds, progress = {}) {
|
|
5361
5736
|
const targets = [...new Set((deviceIds || [])
|
|
5362
5737
|
.map(deviceId => safeString(deviceId, 128))
|
|
@@ -5762,24 +6137,42 @@ export function createRemoteHub(options = {}) {
|
|
|
5762
6137
|
streamId: stream.streamId,
|
|
5763
6138
|
commandId: key,
|
|
5764
6139
|
error: safeString(error, 500) || 'stream-start-failed'
|
|
5765
|
-
});
|
|
5766
|
-
return true;
|
|
5767
|
-
}
|
|
5768
|
-
|
|
5769
|
-
|
|
6140
|
+
});
|
|
6141
|
+
return true;
|
|
6142
|
+
}
|
|
6143
|
+
for (const stream of streams.values()) {
|
|
6144
|
+
if (safeString(stream?.commandId, 128) !== key || stream?.active !== true) {
|
|
6145
|
+
continue;
|
|
6146
|
+
}
|
|
6147
|
+
clearPendingLiveStreamDescriptor(device, stream);
|
|
6148
|
+
stream.active = false;
|
|
6149
|
+
stream.open = false;
|
|
6150
|
+
stream.stoppedAt = new Date().toISOString();
|
|
6151
|
+
stream.stopReason = safeString(error, 500) || 'stream-start-failed';
|
|
6152
|
+
emitRemoteEvent('RemoteLiveStreamStartFailed', device, {
|
|
6153
|
+
streamId: stream.streamId,
|
|
6154
|
+
commandId: key,
|
|
6155
|
+
error: stream.stopReason
|
|
6156
|
+
});
|
|
6157
|
+
return true;
|
|
6158
|
+
}
|
|
6159
|
+
return false;
|
|
6160
|
+
}
|
|
5770
6161
|
|
|
5771
6162
|
function deactivateDeviceLiveStreams(device, reason, stoppedAt = new Date().toISOString()) {
|
|
5772
6163
|
const streams = ensureDeviceLiveStreams(device);
|
|
5773
6164
|
for (const stream of streams.values()) {
|
|
5774
6165
|
clearPendingLiveStreamDescriptor(device, stream);
|
|
5775
6166
|
stream.active = false;
|
|
6167
|
+
stream.open = false;
|
|
5776
6168
|
stream.stoppedAt = stoppedAt;
|
|
5777
6169
|
stream.stopReason = reason;
|
|
5778
|
-
}
|
|
5779
|
-
if (device?.activeLiveStream) {
|
|
5780
|
-
device.activeLiveStream.active = false;
|
|
5781
|
-
device.activeLiveStream.
|
|
5782
|
-
device.activeLiveStream.
|
|
6170
|
+
}
|
|
6171
|
+
if (device?.activeLiveStream) {
|
|
6172
|
+
device.activeLiveStream.active = false;
|
|
6173
|
+
device.activeLiveStream.open = false;
|
|
6174
|
+
device.activeLiveStream.stoppedAt = stoppedAt;
|
|
6175
|
+
device.activeLiveStream.stopReason = reason;
|
|
5783
6176
|
}
|
|
5784
6177
|
}
|
|
5785
6178
|
|
|
@@ -5801,6 +6194,48 @@ export function createRemoteHub(options = {}) {
|
|
|
5801
6194
|
safeString(stream?.streamPurpose, 24).toLowerCase() === 'control') || null;
|
|
5802
6195
|
}
|
|
5803
6196
|
|
|
6197
|
+
function claimSingleLiveCapture(deviceId, device, streamId, streamPurpose) {
|
|
6198
|
+
const purpose = safeString(streamPurpose, 24).toLowerCase() || 'wall';
|
|
6199
|
+
const activeControlStream = getActiveControlStream(device);
|
|
6200
|
+
if (purpose !== 'control'
|
|
6201
|
+
&& activeControlStream
|
|
6202
|
+
&& activeControlStream.streamId !== streamId) {
|
|
6203
|
+
return {
|
|
6204
|
+
ok: false,
|
|
6205
|
+
error: 'CONTROL_CAPTURE_OWNS_DEVICE',
|
|
6206
|
+
activeStreamId: activeControlStream.streamId,
|
|
6207
|
+
activeStreamPurpose: 'control',
|
|
6208
|
+
activeCommandId: safeString(activeControlStream.commandId, 128),
|
|
6209
|
+
captureGeneration: Number(activeControlStream.captureGeneration || 0)
|
|
6210
|
+
};
|
|
6211
|
+
}
|
|
6212
|
+
|
|
6213
|
+
const competingStreams = getActiveLiveStreams(device)
|
|
6214
|
+
.filter(stream => stream.streamId !== streamId);
|
|
6215
|
+
for (const competingStream of competingStreams) {
|
|
6216
|
+
const result = stopLiveStream(deviceId, {
|
|
6217
|
+
streamId: competingStream.streamId,
|
|
6218
|
+
streamPurpose: competingStream.streamPurpose,
|
|
6219
|
+
reason: `${purpose}-capture-handoff`
|
|
6220
|
+
});
|
|
6221
|
+
if (result?.ok !== true) {
|
|
6222
|
+
return {
|
|
6223
|
+
ok: false,
|
|
6224
|
+
error: result?.error || 'capture-handoff-stop-failed',
|
|
6225
|
+
activeStreamId: competingStream.streamId,
|
|
6226
|
+
activeStreamPurpose: safeString(competingStream.streamPurpose, 24)
|
|
6227
|
+
};
|
|
6228
|
+
}
|
|
6229
|
+
emitRemoteEvent('RemoteLiveStreamCapturePreempted', device, {
|
|
6230
|
+
streamId: competingStream.streamId,
|
|
6231
|
+
streamPurpose: safeString(competingStream.streamPurpose, 24) || 'wall',
|
|
6232
|
+
replacementStreamId: streamId,
|
|
6233
|
+
replacementStreamPurpose: purpose
|
|
6234
|
+
});
|
|
6235
|
+
}
|
|
6236
|
+
return { ok: true };
|
|
6237
|
+
}
|
|
6238
|
+
|
|
5804
6239
|
function nextCaptureGeneration(device) {
|
|
5805
6240
|
const current = Number(device?.captureGenerationCounter || 0);
|
|
5806
6241
|
const next = Number.isSafeInteger(current) && current > 0 ? current + 1 : 1;
|
|
@@ -5855,12 +6290,31 @@ export function createRemoteHub(options = {}) {
|
|
|
5855
6290
|
return Number.isFinite(startedAt) && Date.now() - startedAt < LIVE_STREAM_REPLACEMENT_PENDING_MS;
|
|
5856
6291
|
}
|
|
5857
6292
|
|
|
6293
|
+
function liveStreamRequiresReadyKeyFrame(activeLiveStream) {
|
|
6294
|
+
if (safeString(activeLiveStream?.streamPurpose, 24).toLowerCase() !== 'control') {
|
|
6295
|
+
return false;
|
|
6296
|
+
}
|
|
6297
|
+
const codec = safeString(activeLiveStream?.codec, 80).toLowerCase();
|
|
6298
|
+
const mode = safeString(activeLiveStream?.frameMode || activeLiveStream?.mode, 80).toLowerCase();
|
|
6299
|
+
return codec.includes('h264') || mode === 'mode3-h264-hw';
|
|
6300
|
+
}
|
|
6301
|
+
|
|
6302
|
+
function liveFrameSatisfiesReadiness(activeLiveStream, frame) {
|
|
6303
|
+
return !liveStreamRequiresReadyKeyFrame(activeLiveStream)
|
|
6304
|
+
|| frame?.isKeyFrame === true
|
|
6305
|
+
|| safeString(frame?.chunkType, 20).toLowerCase() === 'key';
|
|
6306
|
+
}
|
|
6307
|
+
|
|
5858
6308
|
function liveStreamHasCurrentFrame(activeLiveStream) {
|
|
5859
6309
|
if (!activeLiveStream?.active
|
|
5860
6310
|
|| activeLiveStream.open !== true
|
|
5861
6311
|
|| Number(activeLiveStream.framesReceived || 0) < 1) {
|
|
5862
6312
|
return false;
|
|
5863
6313
|
}
|
|
6314
|
+
if (liveStreamRequiresReadyKeyFrame(activeLiveStream)
|
|
6315
|
+
&& activeLiveStream.readyFrameReceived !== true) {
|
|
6316
|
+
return false;
|
|
6317
|
+
}
|
|
5864
6318
|
const latestFrame = activeLiveStream.latestFrame;
|
|
5865
6319
|
if (!latestFrame || latestFrame.currentGenerationVerified !== true) {
|
|
5866
6320
|
return false;
|
|
@@ -5918,11 +6372,19 @@ export function createRemoteHub(options = {}) {
|
|
|
5918
6372
|
&& Number(activeLiveStream.monitorIndex || 0) === normalized.monitorIndex;
|
|
5919
6373
|
}
|
|
5920
6374
|
|
|
5921
|
-
function startLiveStream(deviceId, options = {}) {
|
|
5922
|
-
const device = devices.get(String(deviceId || ''));
|
|
5923
|
-
const denied = policyError(device);
|
|
5924
|
-
if (denied) return { ok: false, error: denied };
|
|
5925
|
-
if (device?.
|
|
6375
|
+
function startLiveStream(deviceId, options = {}) {
|
|
6376
|
+
const device = devices.get(String(deviceId || ''));
|
|
6377
|
+
const denied = policyError(device);
|
|
6378
|
+
if (denied) return { ok: false, error: denied };
|
|
6379
|
+
if (device?.liveCapturePause?.token) {
|
|
6380
|
+
return {
|
|
6381
|
+
ok: false,
|
|
6382
|
+
error: 'LIVE_CAPTURE_PAUSED',
|
|
6383
|
+
pauseToken: device.liveCapturePause.token,
|
|
6384
|
+
pausedAt: device.liveCapturePause.pausedAt
|
|
6385
|
+
};
|
|
6386
|
+
}
|
|
6387
|
+
if (device?.synthetic === true && device.connected) {
|
|
5926
6388
|
if (!readCapabilityFlag(device.capabilities, 'liveStream')) {
|
|
5927
6389
|
return { ok: false, error: 'device-live-stream-unavailable' };
|
|
5928
6390
|
}
|
|
@@ -5933,6 +6395,8 @@ export function createRemoteHub(options = {}) {
|
|
|
5933
6395
|
if (modePolicyError) return { ok: false, error: modePolicyError };
|
|
5934
6396
|
const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
|
|
5935
6397
|
const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
|
|
6398
|
+
const captureClaim = claimSingleLiveCapture(deviceId, device, streamId, streamPurpose);
|
|
6399
|
+
if (!captureClaim.ok) return captureClaim;
|
|
5936
6400
|
const activeLiveStream = getDeviceLiveStream(device, streamId);
|
|
5937
6401
|
const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
|
|
5938
6402
|
if (activeLiveStream
|
|
@@ -5984,6 +6448,7 @@ export function createRemoteHub(options = {}) {
|
|
|
5984
6448
|
lastFrameAt: '',
|
|
5985
6449
|
lastFrameSeq: 0,
|
|
5986
6450
|
framesReceived: 0,
|
|
6451
|
+
readyFrameReceived: false,
|
|
5987
6452
|
streamPurpose,
|
|
5988
6453
|
captureGeneration
|
|
5989
6454
|
});
|
|
@@ -6031,6 +6496,8 @@ export function createRemoteHub(options = {}) {
|
|
|
6031
6496
|
if (modePolicyError) return { ok: false, error: modePolicyError };
|
|
6032
6497
|
const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
|
|
6033
6498
|
const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
|
|
6499
|
+
const captureClaim = claimSingleLiveCapture(deviceId, device, streamId, streamPurpose);
|
|
6500
|
+
if (!captureClaim.ok) return captureClaim;
|
|
6034
6501
|
const activeLiveStream = getDeviceLiveStream(device, streamId);
|
|
6035
6502
|
const pendingDescriptor = getPendingLiveStreamDescriptor(activeLiveStream);
|
|
6036
6503
|
if (pendingDescriptor?.commandId) {
|
|
@@ -6201,8 +6668,9 @@ export function createRemoteHub(options = {}) {
|
|
|
6201
6668
|
stoppedAt: '',
|
|
6202
6669
|
stopReason: '',
|
|
6203
6670
|
lastFrameAt: '',
|
|
6204
|
-
lastFrameSeq: 0,
|
|
6671
|
+
lastFrameSeq: 0,
|
|
6205
6672
|
framesReceived: 0,
|
|
6673
|
+
readyFrameReceived: false,
|
|
6206
6674
|
streamPurpose,
|
|
6207
6675
|
captureGeneration,
|
|
6208
6676
|
latestFrame: null,
|
|
@@ -6247,14 +6715,16 @@ export function createRemoteHub(options = {}) {
|
|
|
6247
6715
|
};
|
|
6248
6716
|
}
|
|
6249
6717
|
|
|
6250
|
-
function stopLiveStream(deviceId, options = {}) {
|
|
6718
|
+
function stopLiveStream(deviceId, options = {}) {
|
|
6251
6719
|
const device = devices.get(String(deviceId || ''));
|
|
6252
|
-
const streamPurpose = safeString(options.streamPurpose || options.purpose, 24);
|
|
6253
|
-
const requestedStreamId = safeString(options.streamId, 128);
|
|
6254
|
-
const purposeStreamId = streamPurpose ? makeStableLiveStreamId(deviceId, streamPurpose) : '';
|
|
6255
|
-
const streamId =
|
|
6720
|
+
const streamPurpose = safeString(options.streamPurpose || options.purpose, 24);
|
|
6721
|
+
const requestedStreamId = safeString(options.streamId, 128);
|
|
6722
|
+
const purposeStreamId = streamPurpose ? makeStableLiveStreamId(deviceId, streamPurpose) : '';
|
|
6723
|
+
const streamId = options.stopAll === true
|
|
6724
|
+
? ''
|
|
6725
|
+
: requestedStreamId || purposeStreamId || device?.activeLiveStream?.streamId || '';
|
|
6256
6726
|
const streamState = getDeviceLiveStream(device, streamId);
|
|
6257
|
-
if (streamState && streamState.active !== true) {
|
|
6727
|
+
if (streamState && streamState.active !== true && options.forceCommand !== true) {
|
|
6258
6728
|
clearPendingLiveStreamDescriptor(device, streamState);
|
|
6259
6729
|
return {
|
|
6260
6730
|
ok: true,
|
|
@@ -6271,9 +6741,10 @@ export function createRemoteHub(options = {}) {
|
|
|
6271
6741
|
device.counters.commandsSent += 1;
|
|
6272
6742
|
if (streamState) {
|
|
6273
6743
|
streamState.active = false;
|
|
6744
|
+
streamState.open = false;
|
|
6274
6745
|
streamState.stoppedAt = new Date().toISOString();
|
|
6275
|
-
streamState.stopReason = 'manager-request';
|
|
6276
|
-
}
|
|
6746
|
+
streamState.stopReason = safeString(options.reason, 128) || 'manager-request';
|
|
6747
|
+
}
|
|
6277
6748
|
device.counters.liveStreamsStopped += 1;
|
|
6278
6749
|
emitRemoteEvent('RemoteLiveStreamStopped', device, {
|
|
6279
6750
|
streamId,
|
|
@@ -6303,17 +6774,230 @@ export function createRemoteHub(options = {}) {
|
|
|
6303
6774
|
|
|
6304
6775
|
if (streamState) {
|
|
6305
6776
|
streamState.active = false;
|
|
6777
|
+
streamState.open = false;
|
|
6306
6778
|
streamState.stoppedAt = new Date().toISOString();
|
|
6307
|
-
streamState.stopReason = 'manager-request';
|
|
6308
|
-
}
|
|
6779
|
+
streamState.stopReason = safeString(options.reason, 128) || 'manager-request';
|
|
6780
|
+
}
|
|
6309
6781
|
device.counters.liveStreamsStopped += 1;
|
|
6310
6782
|
emitRemoteEvent('RemoteLiveStreamStopped', device, {
|
|
6311
6783
|
streamId,
|
|
6312
6784
|
commandId
|
|
6313
6785
|
});
|
|
6314
6786
|
|
|
6315
|
-
return { ok: true, commandId, streamId };
|
|
6316
|
-
}
|
|
6787
|
+
return { ok: true, commandId, streamId };
|
|
6788
|
+
}
|
|
6789
|
+
|
|
6790
|
+
function buildLiveCapturePauseResult(pause, extra = {}) {
|
|
6791
|
+
return {
|
|
6792
|
+
paused: true,
|
|
6793
|
+
pauseToken: pause.token,
|
|
6794
|
+
pausedAt: pause.pausedAt,
|
|
6795
|
+
streamId: pause.streamId,
|
|
6796
|
+
streamPurpose: pause.streamPurpose,
|
|
6797
|
+
monitorIndex: pause.monitorIndex,
|
|
6798
|
+
phase: pause.phase,
|
|
6799
|
+
captureStopConfirmed: pause.captureStopConfirmed === true,
|
|
6800
|
+
captureStopConfirmedAt: pause.captureStopConfirmedAt || '',
|
|
6801
|
+
stopCommandId: pause.stopCommandId || '',
|
|
6802
|
+
stopError: pause.stopError || '',
|
|
6803
|
+
stopAttemptCount: Math.max(0, Number(pause.stopAttemptCount) || 0),
|
|
6804
|
+
...extra
|
|
6805
|
+
};
|
|
6806
|
+
}
|
|
6807
|
+
|
|
6808
|
+
async function confirmLiveCaptureStopped(deviceId, pause) {
|
|
6809
|
+
let device = devices.get(String(deviceId || ''));
|
|
6810
|
+
if (!device || device.liveCapturePause?.token !== pause.token) {
|
|
6811
|
+
return buildLiveCapturePauseResult(pause, {
|
|
6812
|
+
ok: false,
|
|
6813
|
+
error: 'LIVE_CAPTURE_PAUSE_LEASE_STALE'
|
|
6814
|
+
});
|
|
6815
|
+
}
|
|
6816
|
+
|
|
6817
|
+
pause.phase = 'pausing';
|
|
6818
|
+
pause.captureStopConfirmed = false;
|
|
6819
|
+
pause.captureStopConfirmedAt = '';
|
|
6820
|
+
pause.stopError = '';
|
|
6821
|
+
pause.stopAttemptCount = Math.max(0, Number(pause.stopAttemptCount) || 0) + 1;
|
|
6822
|
+
|
|
6823
|
+
let stopResult = { ok: true, alreadyStopped: true, streamId: pause.streamId };
|
|
6824
|
+
if (device.synthetic !== true) {
|
|
6825
|
+
stopResult = stopLiveStream(deviceId, {
|
|
6826
|
+
reason: pause.reason,
|
|
6827
|
+
// Empty streamId is an intentional Agent contract: stop every
|
|
6828
|
+
// active/quarantined capture, including a native helper that
|
|
6829
|
+
// outlived the Hub's last stream descriptor.
|
|
6830
|
+
stopAll: true,
|
|
6831
|
+
forceCommand: true
|
|
6832
|
+
});
|
|
6833
|
+
}
|
|
6834
|
+
|
|
6835
|
+
if (stopResult?.ok === false) {
|
|
6836
|
+
pause.phase = 'stop-unconfirmed';
|
|
6837
|
+
pause.stopError = safeString(stopResult.error, 500) || 'stream-stop-command-failed';
|
|
6838
|
+
emitRemoteEvent('RemoteLiveCapturePauseFailed', device, {
|
|
6839
|
+
pauseToken: pause.token,
|
|
6840
|
+
streamId: pause.streamId,
|
|
6841
|
+
stopError: pause.stopError
|
|
6842
|
+
});
|
|
6843
|
+
return buildLiveCapturePauseResult(pause, {
|
|
6844
|
+
ok: false,
|
|
6845
|
+
error: 'LIVE_CAPTURE_STOP_UNCONFIRMED'
|
|
6846
|
+
});
|
|
6847
|
+
}
|
|
6848
|
+
|
|
6849
|
+
pause.stopCommandId = safeString(stopResult.commandId, 128);
|
|
6850
|
+
if (pause.stopCommandId
|
|
6851
|
+
&& stopResult.alreadyStopped !== true
|
|
6852
|
+
&& stopResult.synthetic !== true) {
|
|
6853
|
+
const acknowledgement = await waitForCommandResult(
|
|
6854
|
+
device,
|
|
6855
|
+
pause.stopCommandId,
|
|
6856
|
+
liveCaptureStopAckTimeoutMs);
|
|
6857
|
+
device = devices.get(String(deviceId || '')) || device;
|
|
6858
|
+
if (device.liveCapturePause?.token !== pause.token) {
|
|
6859
|
+
return buildLiveCapturePauseResult(pause, {
|
|
6860
|
+
ok: false,
|
|
6861
|
+
error: 'LIVE_CAPTURE_PAUSE_LEASE_STALE'
|
|
6862
|
+
});
|
|
6863
|
+
}
|
|
6864
|
+
if (!acknowledgement.ok
|
|
6865
|
+
|| acknowledgement.result?.stream !== true
|
|
6866
|
+
|| acknowledgement.result?.stopped !== true) {
|
|
6867
|
+
pause.phase = 'stop-unconfirmed';
|
|
6868
|
+
pause.stopError = safeString(
|
|
6869
|
+
acknowledgement.error
|
|
6870
|
+
|| acknowledgement.result?.error
|
|
6871
|
+
|| 'stream-stop-not-confirmed',
|
|
6872
|
+
500);
|
|
6873
|
+
emitRemoteEvent('RemoteLiveCapturePauseFailed', device, {
|
|
6874
|
+
pauseToken: pause.token,
|
|
6875
|
+
streamId: pause.streamId,
|
|
6876
|
+
stopCommandId: pause.stopCommandId,
|
|
6877
|
+
stopError: pause.stopError
|
|
6878
|
+
});
|
|
6879
|
+
return buildLiveCapturePauseResult(pause, {
|
|
6880
|
+
ok: false,
|
|
6881
|
+
error: 'LIVE_CAPTURE_STOP_UNCONFIRMED'
|
|
6882
|
+
});
|
|
6883
|
+
}
|
|
6884
|
+
}
|
|
6885
|
+
|
|
6886
|
+
pause.phase = 'paused';
|
|
6887
|
+
pause.captureStopConfirmed = true;
|
|
6888
|
+
pause.captureStopConfirmedAt = new Date().toISOString();
|
|
6889
|
+
pause.stopError = '';
|
|
6890
|
+
deactivateDeviceLiveStreams(device, pause.reason, pause.captureStopConfirmedAt);
|
|
6891
|
+
emitRemoteEvent('RemoteLiveCapturePaused', device, {
|
|
6892
|
+
pauseToken: pause.token,
|
|
6893
|
+
pausedAt: pause.pausedAt,
|
|
6894
|
+
streamId: pause.streamId,
|
|
6895
|
+
streamPurpose: pause.streamPurpose,
|
|
6896
|
+
monitorIndex: pause.monitorIndex,
|
|
6897
|
+
stopCommandId: pause.stopCommandId,
|
|
6898
|
+
captureStopConfirmedAt: pause.captureStopConfirmedAt
|
|
6899
|
+
});
|
|
6900
|
+
return buildLiveCapturePauseResult(pause, {
|
|
6901
|
+
ok: true,
|
|
6902
|
+
alreadyStopped: stopResult.alreadyStopped === true
|
|
6903
|
+
});
|
|
6904
|
+
}
|
|
6905
|
+
|
|
6906
|
+
async function pauseLiveCapture(deviceId, options = {}) {
|
|
6907
|
+
const device = devices.get(String(deviceId || ''));
|
|
6908
|
+
if (!device) {
|
|
6909
|
+
return { ok: false, paused: false, error: 'device-not-found' };
|
|
6910
|
+
}
|
|
6911
|
+
const denied = policyError(device, '', 'stream.stop');
|
|
6912
|
+
if (denied) return { ok: false, paused: false, error: denied };
|
|
6913
|
+
|
|
6914
|
+
let pause = device.liveCapturePause;
|
|
6915
|
+
if (pause?.token && pause.captureStopConfirmed === true) {
|
|
6916
|
+
return buildLiveCapturePauseResult(pause, {
|
|
6917
|
+
ok: true,
|
|
6918
|
+
alreadyPaused: true
|
|
6919
|
+
});
|
|
6920
|
+
}
|
|
6921
|
+
if (pause?.stopPromise) {
|
|
6922
|
+
return pause.stopPromise;
|
|
6923
|
+
}
|
|
6924
|
+
|
|
6925
|
+
if (!pause?.token) {
|
|
6926
|
+
const activeLiveStream = device.activeLiveStream?.active === true
|
|
6927
|
+
? device.activeLiveStream
|
|
6928
|
+
: null;
|
|
6929
|
+
pause = {
|
|
6930
|
+
token: crypto.randomUUID(),
|
|
6931
|
+
pausedAt: new Date().toISOString(),
|
|
6932
|
+
reason: safeString(options.reason, 128) || 'user-video-pause',
|
|
6933
|
+
streamId: safeString(activeLiveStream?.streamId, 128),
|
|
6934
|
+
streamPurpose: safeString(activeLiveStream?.streamPurpose, 24),
|
|
6935
|
+
monitorIndex: Math.max(0, Math.floor(Number(activeLiveStream?.monitorIndex) || 0)),
|
|
6936
|
+
phase: 'pausing',
|
|
6937
|
+
captureStopConfirmed: false,
|
|
6938
|
+
captureStopConfirmedAt: '',
|
|
6939
|
+
stopCommandId: '',
|
|
6940
|
+
stopError: '',
|
|
6941
|
+
stopAttemptCount: 0
|
|
6942
|
+
};
|
|
6943
|
+
// Install the authoritative gate before asking the Agent to stop.
|
|
6944
|
+
// A late subscriber or watchdog therefore cannot reacquire FFmpeg
|
|
6945
|
+
// in the pause transition window.
|
|
6946
|
+
device.liveCapturePause = pause;
|
|
6947
|
+
}
|
|
6948
|
+
|
|
6949
|
+
const operation = confirmLiveCaptureStopped(deviceId, pause);
|
|
6950
|
+
pause.stopPromise = operation;
|
|
6951
|
+
try {
|
|
6952
|
+
return await operation;
|
|
6953
|
+
} finally {
|
|
6954
|
+
if (pause.stopPromise === operation) {
|
|
6955
|
+
delete pause.stopPromise;
|
|
6956
|
+
}
|
|
6957
|
+
}
|
|
6958
|
+
}
|
|
6959
|
+
|
|
6960
|
+
function resumeLiveCapture(deviceId, options = {}) {
|
|
6961
|
+
const device = devices.get(String(deviceId || ''));
|
|
6962
|
+
const denied = policyError(device);
|
|
6963
|
+
if (denied) return { ok: false, error: denied };
|
|
6964
|
+
const pause = device?.liveCapturePause;
|
|
6965
|
+
if (!pause?.token) {
|
|
6966
|
+
return { ok: true, paused: false, alreadyResumed: true };
|
|
6967
|
+
}
|
|
6968
|
+
if (pause.phase === 'pausing') {
|
|
6969
|
+
return {
|
|
6970
|
+
ok: false,
|
|
6971
|
+
paused: true,
|
|
6972
|
+
error: 'LIVE_CAPTURE_STOP_IN_PROGRESS',
|
|
6973
|
+
pauseToken: pause.token
|
|
6974
|
+
};
|
|
6975
|
+
}
|
|
6976
|
+
const pauseToken = safeString(options.pauseToken, 128);
|
|
6977
|
+
if (!pauseToken || pauseToken !== pause.token) {
|
|
6978
|
+
return {
|
|
6979
|
+
ok: false,
|
|
6980
|
+
error: 'LIVE_CAPTURE_PAUSE_TOKEN_MISMATCH'
|
|
6981
|
+
};
|
|
6982
|
+
}
|
|
6983
|
+
|
|
6984
|
+
device.liveCapturePause = null;
|
|
6985
|
+
emitRemoteEvent('RemoteLiveCaptureResumed', device, {
|
|
6986
|
+
pauseToken,
|
|
6987
|
+
resumedAt: new Date().toISOString(),
|
|
6988
|
+
streamId: pause.streamId,
|
|
6989
|
+
streamPurpose: pause.streamPurpose,
|
|
6990
|
+
monitorIndex: pause.monitorIndex
|
|
6991
|
+
});
|
|
6992
|
+
return {
|
|
6993
|
+
ok: true,
|
|
6994
|
+
paused: false,
|
|
6995
|
+
resumed: true,
|
|
6996
|
+
streamId: pause.streamId,
|
|
6997
|
+
streamPurpose: pause.streamPurpose,
|
|
6998
|
+
monitorIndex: pause.monitorIndex
|
|
6999
|
+
};
|
|
7000
|
+
}
|
|
6317
7001
|
|
|
6318
7002
|
function startAudioStream(deviceId, options = {}) {
|
|
6319
7003
|
const device = devices.get(String(deviceId || ''));
|
|
@@ -6475,15 +7159,18 @@ export function createRemoteHub(options = {}) {
|
|
|
6475
7159
|
sendCommand,
|
|
6476
7160
|
sendLegacyClientUpdate,
|
|
6477
7161
|
sendInputControl,
|
|
7162
|
+
releaseInputOwner,
|
|
6478
7163
|
notifyAgentProgress,
|
|
6479
7164
|
requestAgentTask,
|
|
6480
7165
|
requestAgentTaskBatch,
|
|
6481
7166
|
setHostTarget,
|
|
6482
7167
|
rotatePairingPin,
|
|
6483
|
-
requestThumbnail,
|
|
6484
|
-
startLiveStream,
|
|
6485
|
-
stopLiveStream,
|
|
6486
|
-
|
|
7168
|
+
requestThumbnail,
|
|
7169
|
+
startLiveStream,
|
|
7170
|
+
stopLiveStream,
|
|
7171
|
+
pauseLiveCapture,
|
|
7172
|
+
resumeLiveCapture,
|
|
7173
|
+
startAudioStream,
|
|
6487
7174
|
stopAudioStream,
|
|
6488
7175
|
getDeviceLiveFrame,
|
|
6489
7176
|
getDeviceThumbnail,
|