@livedesk/hub 0.1.7 → 0.1.8
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 +835 -157
- 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,6 +28,7 @@ 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 DEFAULT_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS = 8000;
|
|
31
32
|
const REMOTE_PROTOCOL_VERSION = 2;
|
|
32
33
|
const REMOTE_AGENT_PROTOCOL = 'mindexec.remote.agent';
|
|
33
34
|
const REMOTE_FRAME_PROTOCOL = 'mindexec.remote.frame.v2';
|
|
@@ -154,7 +155,10 @@ const DUPLICATE_DEVICE_LOG_THROTTLE_MS = 60000;
|
|
|
154
155
|
const DUPLICATE_DEVICE_RETRY_AFTER_MS = 30000;
|
|
155
156
|
const AGENT_BINARY_INGRESS_QUEUE_PACKETS = 8;
|
|
156
157
|
const MODE5_AGENT_BINARY_INGRESS_QUEUE_PACKETS = 2;
|
|
157
|
-
|
|
158
|
+
// Native capture backends can emit short bursts (ScreenCaptureKit commonly
|
|
159
|
+
// queues up to eight frames). Drain one frame per event-loop turn so a burst
|
|
160
|
+
// cannot immediately overflow the smaller Hub-to-browser send lanes.
|
|
161
|
+
const AGENT_BINARY_INGRESS_DRAIN_BUDGET_PACKETS = 1;
|
|
158
162
|
const SYNTHETIC_FRAME_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAADElEQVR42mP8z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
|
|
159
163
|
const SYNTHETIC_FRAME_PAYLOAD = Buffer.from(SYNTHETIC_FRAME_DATA_URL.split(',')[1], 'base64');
|
|
160
164
|
const SYNTHETIC_FRAME_HASH = crypto.createHash('sha256').update(SYNTHETIC_FRAME_PAYLOAD).digest('hex').slice(0, 16);
|
|
@@ -683,9 +687,18 @@ function normalizeRemoteInputEvent(value = {}) {
|
|
|
683
687
|
const issuedAtEpochMs = Number(input.issuedAtEpochMs ?? input.IssuedAtEpochMs);
|
|
684
688
|
const hubReceivedAtEpochMs = Number(input.hubReceivedAtEpochMs ?? input.HubReceivedAtEpochMs);
|
|
685
689
|
const captureGeneration = Number(input.captureGeneration ?? input.CaptureGeneration);
|
|
690
|
+
const rawPressedCodes = input.pressedCodes ?? input.PressedCodes;
|
|
691
|
+
const pressedCodes = Array.isArray(rawPressedCodes)
|
|
692
|
+
? [...new Set(rawPressedCodes
|
|
693
|
+
.map(code => safeString(code, 80))
|
|
694
|
+
.filter(Boolean))]
|
|
695
|
+
.slice(0, 64)
|
|
696
|
+
: null;
|
|
686
697
|
return {
|
|
687
698
|
type,
|
|
688
|
-
|
|
699
|
+
// A missing monitor is not equivalent to the primary display. Control
|
|
700
|
+
// input must prove the exact monitor owned by its capture generation.
|
|
701
|
+
monitorIndex: Number.isFinite(monitorIndex) ? normalizeMonitorIndex(monitorIndex) : null,
|
|
689
702
|
normalizedX: Number.isFinite(normalizedX) ? Math.max(0, Math.min(1, normalizedX)) : undefined,
|
|
690
703
|
normalizedY: Number.isFinite(normalizedY) ? Math.max(0, Math.min(1, normalizedY)) : undefined,
|
|
691
704
|
button: safeString(input.button || input.Button, 24),
|
|
@@ -695,8 +708,9 @@ function normalizeRemoteInputEvent(value = {}) {
|
|
|
695
708
|
code: safeString(input.code || input.Code, 80),
|
|
696
709
|
keyCode: Number.isFinite(keyCode) ? Math.max(0, Math.min(65535, Math.trunc(keyCode))) : 0,
|
|
697
710
|
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,
|
|
711
|
+
text: safeText(input.text || input.Text, 256),
|
|
712
|
+
repeat: input.repeat === true || input.Repeat === true,
|
|
713
|
+
pressedCodes,
|
|
700
714
|
shiftKey: input.shiftKey === true || input.ShiftKey === true,
|
|
701
715
|
ctrlKey: input.ctrlKey === true || input.ControlKey === true || input.CtrlKey === true,
|
|
702
716
|
altKey: input.altKey === true || input.AltKey === true,
|
|
@@ -708,8 +722,9 @@ function normalizeRemoteInputEvent(value = {}) {
|
|
|
708
722
|
hubConnectionId: safeString(input.hubConnectionId || input.HubConnectionId, 128),
|
|
709
723
|
inputEventId: safeString(input.inputEventId || input.InputEventId, 128) || crypto.randomUUID(),
|
|
710
724
|
inputSeq: Number.isSafeInteger(inputSeq) && inputSeq > 0 ? inputSeq : 0,
|
|
711
|
-
requestAck: input.requestAck !== false && input.RequestAck !== false,
|
|
712
|
-
|
|
725
|
+
requestAck: input.requestAck !== false && input.RequestAck !== false,
|
|
726
|
+
reason: safeString(input.reason || input.Reason, 120),
|
|
727
|
+
issuedAtEpochMs: Number.isFinite(issuedAtEpochMs) ? issuedAtEpochMs : 0,
|
|
713
728
|
hubReceivedAtEpochMs: Number.isFinite(hubReceivedAtEpochMs) ? hubReceivedAtEpochMs : 0,
|
|
714
729
|
issuedAt: safeString(input.issuedAt || input.IssuedAt, 80)
|
|
715
730
|
};
|
|
@@ -1014,10 +1029,26 @@ function serializeDevice(device, options = {}) {
|
|
|
1014
1029
|
audio: !!device.audioSocket && !device.audioSocket.destroyed,
|
|
1015
1030
|
file: !!device.fileSocket && !device.fileSocket.destroyed
|
|
1016
1031
|
},
|
|
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
|
-
|
|
1032
|
+
latestThumbnail: serializeRemoteFrame(device.latestThumbnail, device.deviceId, 'thumbnail', options),
|
|
1033
|
+
latestLiveFrame: serializeRemoteFrame(device.latestLiveFrame, device.deviceId, 'live', options),
|
|
1034
|
+
activeLiveStream: device.activeLiveStream ? { ...device.activeLiveStream } : null,
|
|
1035
|
+
liveCapturePause: device.liveCapturePause
|
|
1036
|
+
? {
|
|
1037
|
+
token: device.liveCapturePause.token,
|
|
1038
|
+
pausedAt: device.liveCapturePause.pausedAt,
|
|
1039
|
+
reason: device.liveCapturePause.reason,
|
|
1040
|
+
streamId: device.liveCapturePause.streamId,
|
|
1041
|
+
streamPurpose: device.liveCapturePause.streamPurpose,
|
|
1042
|
+
monitorIndex: device.liveCapturePause.monitorIndex,
|
|
1043
|
+
phase: device.liveCapturePause.phase,
|
|
1044
|
+
captureStopConfirmed: device.liveCapturePause.captureStopConfirmed === true,
|
|
1045
|
+
captureStopConfirmedAt: device.liveCapturePause.captureStopConfirmedAt || '',
|
|
1046
|
+
stopCommandId: device.liveCapturePause.stopCommandId || '',
|
|
1047
|
+
stopError: device.liveCapturePause.stopError || '',
|
|
1048
|
+
stopAttemptCount: Math.max(0, Number(device.liveCapturePause.stopAttemptCount) || 0)
|
|
1049
|
+
}
|
|
1050
|
+
: null,
|
|
1051
|
+
activeAudioStream: device.activeAudioStream ? { ...device.activeAudioStream } : null,
|
|
1021
1052
|
latestAudioFrame: device.latestAudioFrame
|
|
1022
1053
|
? {
|
|
1023
1054
|
streamId: device.latestAudioFrame.streamId,
|
|
@@ -1454,7 +1485,9 @@ class RemoteHubWebSocketAgentSocket {
|
|
|
1454
1485
|
}
|
|
1455
1486
|
|
|
1456
1487
|
export function createRemoteHub(options = {}) {
|
|
1457
|
-
const env = options.env || process.env;
|
|
1488
|
+
const env = options.env || process.env;
|
|
1489
|
+
const rejectFrameChannelForTest = isEnabledValue(env.LIVEDESK_TEST_MODE, false)
|
|
1490
|
+
&& isEnabledValue(env.LIVEDESK_TEST_REJECT_FRAME_CHANNEL, false);
|
|
1458
1491
|
const logEvent = options.logEvent || (() => {});
|
|
1459
1492
|
const logWarn = options.logWarn || (() => {});
|
|
1460
1493
|
const emitEvent = options.emitEvent || (() => {});
|
|
@@ -1506,6 +1539,11 @@ export function createRemoteHub(options = {}) {
|
|
|
1506
1539
|
250,
|
|
1507
1540
|
30000,
|
|
1508
1541
|
DEFAULT_REMOTE_INPUT_ACK_TIMEOUT_MS);
|
|
1542
|
+
const liveCaptureStopAckTimeoutMs = clampNumber(
|
|
1543
|
+
options.liveCaptureStopAckTimeoutMs ?? env.REMOTE_HUB_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS,
|
|
1544
|
+
250,
|
|
1545
|
+
30000,
|
|
1546
|
+
DEFAULT_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS);
|
|
1509
1547
|
const managerPackage = safeString(options.managerPackage ?? env.LIVEDESK_MANAGER_PACKAGE ?? env.MINDEXEC_MANAGER_PACKAGE ?? '@livedesk/hub', 128);
|
|
1510
1548
|
const managerVersion = safeString(options.managerVersion ?? env.LIVEDESK_MANAGER_VERSION ?? env.MINDEXEC_MANAGER_VERSION ?? '', 64);
|
|
1511
1549
|
const hostInstanceId = safeString(options.hostInstanceId ?? env.LIVEDESK_HUB_INSTANCE_ID ?? env.MINDEXEC_BRIDGE_INSTANCE_ID ?? crypto.randomUUID(), 128) || crypto.randomUUID();
|
|
@@ -1534,9 +1572,10 @@ export function createRemoteHub(options = {}) {
|
|
|
1534
1572
|
const sockets = new Map();
|
|
1535
1573
|
const inputSockets = new Map();
|
|
1536
1574
|
const frameSockets = new Map();
|
|
1537
|
-
const audioSockets = new Map();
|
|
1538
|
-
const fileSockets = new Map();
|
|
1539
|
-
const allSockets = new Set();
|
|
1575
|
+
const audioSockets = new Map();
|
|
1576
|
+
const fileSockets = new Map();
|
|
1577
|
+
const allSockets = new Set();
|
|
1578
|
+
const pendingCommandResultWaiters = new Map();
|
|
1540
1579
|
const duplicateDeviceLogAt = new Map();
|
|
1541
1580
|
let server = null;
|
|
1542
1581
|
let started = false;
|
|
@@ -2044,15 +2083,109 @@ export function createRemoteHub(options = {}) {
|
|
|
2044
2083
|
});
|
|
2045
2084
|
}
|
|
2046
2085
|
|
|
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
|
|
2086
|
+
function emitRemoteEvent(type, device = null, extra = {}) {
|
|
2087
|
+
emitEvent(type, {
|
|
2088
|
+
...extra,
|
|
2089
|
+
remoteHub: getStatus({ includeSecrets: false }),
|
|
2090
|
+
device: serializeDevice(device)
|
|
2091
|
+
});
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
function settlePendingCommandResult(device, message = {}) {
|
|
2095
|
+
const commandId = safeString(message.commandId, 128);
|
|
2096
|
+
const waiter = pendingCommandResultWaiters.get(commandId);
|
|
2097
|
+
if (!waiter
|
|
2098
|
+
|| waiter.deviceId !== device?.deviceId
|
|
2099
|
+
|| waiter.sessionId !== device?.sessionId) {
|
|
2100
|
+
return false;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
pendingCommandResultWaiters.delete(commandId);
|
|
2104
|
+
clearTimeout(waiter.timer);
|
|
2105
|
+
const result = message.result ?? null;
|
|
2106
|
+
const error = safeString(
|
|
2107
|
+
message.error
|
|
2108
|
+
|| result?.error
|
|
2109
|
+
|| (message.type === 'command.error' ? 'command-failed' : ''),
|
|
2110
|
+
500);
|
|
2111
|
+
waiter.resolve({
|
|
2112
|
+
ok: !error
|
|
2113
|
+
&& result?.ok !== false
|
|
2114
|
+
&& result?.status !== 'failed'
|
|
2115
|
+
&& result?.status !== 'rejected',
|
|
2116
|
+
commandId,
|
|
2117
|
+
result,
|
|
2118
|
+
error
|
|
2119
|
+
});
|
|
2120
|
+
return true;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
function failPendingCommandResultWaiters(device, error = 'device-disconnected') {
|
|
2124
|
+
if (!device?.deviceId) {
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
for (const [commandId, waiter] of pendingCommandResultWaiters) {
|
|
2128
|
+
if (waiter.deviceId !== device.deviceId
|
|
2129
|
+
|| (waiter.sessionId && waiter.sessionId !== device.sessionId)) {
|
|
2130
|
+
continue;
|
|
2131
|
+
}
|
|
2132
|
+
pendingCommandResultWaiters.delete(commandId);
|
|
2133
|
+
clearTimeout(waiter.timer);
|
|
2134
|
+
waiter.resolve({
|
|
2135
|
+
ok: false,
|
|
2136
|
+
commandId,
|
|
2137
|
+
result: null,
|
|
2138
|
+
error: safeString(error, 500) || 'command-result-unavailable'
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
function waitForCommandResult(device, commandId, timeoutMs = liveCaptureStopAckTimeoutMs) {
|
|
2144
|
+
const key = safeString(commandId, 128);
|
|
2145
|
+
if (!device?.deviceId || !device?.sessionId || !key) {
|
|
2146
|
+
return Promise.resolve({
|
|
2147
|
+
ok: false,
|
|
2148
|
+
commandId: key,
|
|
2149
|
+
result: null,
|
|
2150
|
+
error: 'command-result-wait-invalid'
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
return new Promise(resolve => {
|
|
2155
|
+
const previous = pendingCommandResultWaiters.get(key);
|
|
2156
|
+
if (previous) {
|
|
2157
|
+
clearTimeout(previous.timer);
|
|
2158
|
+
previous.resolve({
|
|
2159
|
+
ok: false,
|
|
2160
|
+
commandId: key,
|
|
2161
|
+
result: null,
|
|
2162
|
+
error: 'command-result-wait-replaced'
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
const timer = setTimeout(() => {
|
|
2166
|
+
const waiter = pendingCommandResultWaiters.get(key);
|
|
2167
|
+
if (!waiter || waiter.deviceId !== device.deviceId || waiter.sessionId !== device.sessionId) {
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
pendingCommandResultWaiters.delete(key);
|
|
2171
|
+
resolve({
|
|
2172
|
+
ok: false,
|
|
2173
|
+
commandId: key,
|
|
2174
|
+
result: null,
|
|
2175
|
+
error: 'command-result-timeout'
|
|
2176
|
+
});
|
|
2177
|
+
}, timeoutMs);
|
|
2178
|
+
timer.unref?.();
|
|
2179
|
+
pendingCommandResultWaiters.set(key, {
|
|
2180
|
+
deviceId: device.deviceId,
|
|
2181
|
+
sessionId: device.sessionId,
|
|
2182
|
+
timer,
|
|
2183
|
+
resolve
|
|
2184
|
+
});
|
|
2185
|
+
});
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
function makeSyntheticFrame(device, streamId, mode = 'thumbnail', options = {}) {
|
|
2056
2189
|
const now = new Date().toISOString();
|
|
2057
2190
|
const descriptor = buildRemoteFrameTransferDescriptor(mode, mode === 'thumbnail' ? 'thumbnail' : DEFAULT_REMOTE_FRAME_MODE);
|
|
2058
2191
|
const frameSeq = Number(device?.counters?.thumbnailFramesReceived || 0)
|
|
@@ -2144,9 +2277,12 @@ export function createRemoteHub(options = {}) {
|
|
|
2144
2277
|
byteLength: frame.byteLength
|
|
2145
2278
|
});
|
|
2146
2279
|
streamState.latestFrame = frame;
|
|
2147
|
-
streamState.lastFrameAt = frame.receivedAt;
|
|
2148
|
-
streamState.lastFrameSeq = frame.frameSeq;
|
|
2149
|
-
streamState.framesReceived = (streamState.framesReceived || 0) + 1;
|
|
2280
|
+
streamState.lastFrameAt = frame.receivedAt;
|
|
2281
|
+
streamState.lastFrameSeq = frame.frameSeq;
|
|
2282
|
+
streamState.framesReceived = (streamState.framesReceived || 0) + 1;
|
|
2283
|
+
// A synthetic PNG is a complete independently decodable surface and
|
|
2284
|
+
// therefore key-frame equivalent for Control readiness.
|
|
2285
|
+
streamState.readyFrameReceived = true;
|
|
2150
2286
|
device.lastSeenAt = frame.receivedAt;
|
|
2151
2287
|
device.counters.liveFramesReceived += 1;
|
|
2152
2288
|
emitRemoteEvent('RemoteFrameReceived', device, {
|
|
@@ -2170,6 +2306,7 @@ export function createRemoteHub(options = {}) {
|
|
|
2170
2306
|
existing.lastDisconnectReason = 'replaced-by-new-session';
|
|
2171
2307
|
failAllPendingTasks(existing, 'replaced-by-new-session');
|
|
2172
2308
|
failAllPendingInputFallbacks(existing, 'replaced-by-new-session');
|
|
2309
|
+
failPendingCommandResultWaiters(existing, 'replaced-by-new-session');
|
|
2173
2310
|
writeJsonLine(existing.socket, {
|
|
2174
2311
|
type: 'disconnect',
|
|
2175
2312
|
reason: 'replaced-by-new-session',
|
|
@@ -2709,9 +2846,11 @@ export function createRemoteHub(options = {}) {
|
|
|
2709
2846
|
: getSupportedRemoteFrameModeProfiles();
|
|
2710
2847
|
}
|
|
2711
2848
|
|
|
2712
|
-
const device = {
|
|
2713
|
-
socket,
|
|
2714
|
-
inputSocket: null,
|
|
2849
|
+
const device = {
|
|
2850
|
+
socket,
|
|
2851
|
+
inputSocket: null,
|
|
2852
|
+
inputOwnerConnectionId: '',
|
|
2853
|
+
inputOwnerBindingKey: '',
|
|
2715
2854
|
frameSocket: null,
|
|
2716
2855
|
audioSocket: null,
|
|
2717
2856
|
fileSocket: null,
|
|
@@ -2752,9 +2891,13 @@ export function createRemoteHub(options = {}) {
|
|
|
2752
2891
|
recentFramePayloads: {
|
|
2753
2892
|
thumbnail: [],
|
|
2754
2893
|
live: []
|
|
2755
|
-
},
|
|
2756
|
-
activeLiveStream: null,
|
|
2757
|
-
|
|
2894
|
+
},
|
|
2895
|
+
activeLiveStream: null,
|
|
2896
|
+
// A manual pause is a Hub-owned capture gate, not an Agent-session
|
|
2897
|
+
// detail. Preserve the same lease across a transient Client
|
|
2898
|
+
// reconnect so no subscriber or watchdog can reacquire FFmpeg.
|
|
2899
|
+
liveCapturePause: existing?.liveCapturePause || null,
|
|
2900
|
+
activeAudioStream: null,
|
|
2758
2901
|
latestAudioFrame: null,
|
|
2759
2902
|
latestAudioStatus: null,
|
|
2760
2903
|
udp: {
|
|
@@ -2943,10 +3086,11 @@ export function createRemoteHub(options = {}) {
|
|
|
2943
3086
|
closeInputSocket(device, reason);
|
|
2944
3087
|
closeFrameSocket(device, reason);
|
|
2945
3088
|
device.disconnectedAt = new Date().toISOString();
|
|
2946
|
-
device.lastDisconnectReason = reason;
|
|
3089
|
+
device.lastDisconnectReason = reason;
|
|
2947
3090
|
deactivateDeviceLiveStreams(device, reason, device.disconnectedAt);
|
|
2948
3091
|
failAllPendingTasks(device, 'device-disconnected');
|
|
2949
3092
|
failAllPendingInputFallbacks(device, 'device-disconnected');
|
|
3093
|
+
failPendingCommandResultWaiters(device, 'device-disconnected');
|
|
2950
3094
|
logWarn('remote', `device disconnected ${device.deviceName} (${deviceId}): ${reason}`);
|
|
2951
3095
|
emitRemoteEvent('RemoteDeviceDisconnected', device, { reason });
|
|
2952
3096
|
}
|
|
@@ -3329,6 +3473,20 @@ export function createRemoteHub(options = {}) {
|
|
|
3329
3473
|
return {
|
|
3330
3474
|
deviceId: device.deviceId,
|
|
3331
3475
|
commandId: safeString(message.commandId || message.CommandId || pending?.commandId, 128),
|
|
3476
|
+
hubConnectionId: safeString(
|
|
3477
|
+
message.hubConnectionId
|
|
3478
|
+
|| message.HubConnectionId
|
|
3479
|
+
|| result.hubConnectionId
|
|
3480
|
+
|| result.HubConnectionId
|
|
3481
|
+
|| pending?.hubConnectionId,
|
|
3482
|
+
128),
|
|
3483
|
+
inputEventId: safeString(
|
|
3484
|
+
message.inputEventId
|
|
3485
|
+
|| message.InputEventId
|
|
3486
|
+
|| result.inputEventId
|
|
3487
|
+
|| result.InputEventId
|
|
3488
|
+
|| pending?.inputEventId,
|
|
3489
|
+
128),
|
|
3332
3490
|
inputSeq,
|
|
3333
3491
|
inputType: safeString(
|
|
3334
3492
|
message.inputType
|
|
@@ -3337,6 +3495,13 @@ export function createRemoteHub(options = {}) {
|
|
|
3337
3495
|
|| result.InputType
|
|
3338
3496
|
|| pending?.inputType,
|
|
3339
3497
|
48),
|
|
3498
|
+
monitorIndex: Number(
|
|
3499
|
+
message.monitorIndex
|
|
3500
|
+
?? message.MonitorIndex
|
|
3501
|
+
?? result.monitorIndex
|
|
3502
|
+
?? result.MonitorIndex
|
|
3503
|
+
?? pending?.monitorIndex
|
|
3504
|
+
?? 0) || 0,
|
|
3340
3505
|
issuedAtEpochMs: Number(
|
|
3341
3506
|
message.issuedAtEpochMs
|
|
3342
3507
|
?? message.IssuedAtEpochMs
|
|
@@ -3602,13 +3767,35 @@ export function createRemoteHub(options = {}) {
|
|
|
3602
3767
|
: 0;
|
|
3603
3768
|
}
|
|
3604
3769
|
|
|
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
|
-
|
|
3770
|
+
function readFrameStageMs(message, property) {
|
|
3771
|
+
const value = Number(message?.[property]);
|
|
3772
|
+
return Number.isFinite(value) && value >= 0
|
|
3773
|
+
? Math.round(value)
|
|
3774
|
+
: 0;
|
|
3775
|
+
}
|
|
3776
|
+
|
|
3777
|
+
function readOptionalFrameStageMs(message, property) {
|
|
3778
|
+
const rawValue = message?.[property];
|
|
3779
|
+
if (rawValue === undefined || rawValue === null || rawValue === '') {
|
|
3780
|
+
return undefined;
|
|
3781
|
+
}
|
|
3782
|
+
const value = Number(rawValue);
|
|
3783
|
+
return Number.isFinite(value) && value >= 0
|
|
3784
|
+
? Math.round(value)
|
|
3785
|
+
: undefined;
|
|
3786
|
+
}
|
|
3787
|
+
|
|
3788
|
+
function hasFrameCaptureTiming(message) {
|
|
3789
|
+
if (message?.captureTimingAvailable === false) {
|
|
3790
|
+
return false;
|
|
3791
|
+
}
|
|
3792
|
+
if (message?.captureTimingAvailable === true) {
|
|
3793
|
+
return true;
|
|
3794
|
+
}
|
|
3795
|
+
return ['captureP95Ms', 'captureAverageMs', 'captureStageMs']
|
|
3796
|
+
.some(property => readOptionalFrameStageMs(message, property) !== undefined);
|
|
3797
|
+
}
|
|
3798
|
+
|
|
3612
3799
|
function computeSameContentStreak(previousFrame, contentHash) {
|
|
3613
3800
|
if (!contentHash || !previousFrame?.contentHash || previousFrame.contentHash !== contentHash) {
|
|
3614
3801
|
return 0;
|
|
@@ -3682,7 +3869,7 @@ export function createRemoteHub(options = {}) {
|
|
|
3682
3869
|
byteLength,
|
|
3683
3870
|
transport,
|
|
3684
3871
|
contentHash,
|
|
3685
|
-
captureMs: readFrameCaptureMs(message),
|
|
3872
|
+
captureMs: readFrameCaptureMs(message),
|
|
3686
3873
|
captureStageMs: readFrameStageMs(message, 'captureStageMs'),
|
|
3687
3874
|
convertMs: readFrameStageMs(message, 'convertMs'),
|
|
3688
3875
|
compressMs: readFrameStageMs(message, 'compressMs'),
|
|
@@ -3774,6 +3961,7 @@ export function createRemoteHub(options = {}) {
|
|
|
3774
3961
|
lastFrameAt: '',
|
|
3775
3962
|
lastFrameSeq: 0,
|
|
3776
3963
|
framesReceived: 0,
|
|
3964
|
+
readyFrameReceived: false,
|
|
3777
3965
|
latestFrame: null,
|
|
3778
3966
|
stoppedAt: '',
|
|
3779
3967
|
stopReason: ''
|
|
@@ -3991,6 +4179,13 @@ export function createRemoteHub(options = {}) {
|
|
|
3991
4179
|
&& (activeCaptureGeneration <= 0
|
|
3992
4180
|
|| (frameCaptureGeneration > 0
|
|
3993
4181
|
&& frameCaptureGeneration === activeCaptureGeneration));
|
|
4182
|
+
const readyFrameReceivedBeforeFrame = streamState.readyFrameReceived === true;
|
|
4183
|
+
const captureTimingAvailable = hasFrameCaptureTiming(message);
|
|
4184
|
+
const videoTransport = transport === 'udp-p2p'
|
|
4185
|
+
? 'p2p-udp'
|
|
4186
|
+
: transport === 'ws-binary'
|
|
4187
|
+
? 'direct-ws'
|
|
4188
|
+
: 'direct-tcp';
|
|
3994
4189
|
device.latestLiveFrame = {
|
|
3995
4190
|
streamId,
|
|
3996
4191
|
frameSeq,
|
|
@@ -4027,9 +4222,10 @@ export function createRemoteHub(options = {}) {
|
|
|
4027
4222
|
agentPaceMs: Number.isFinite(Number(message.agentPaceMs)) ? Number(message.agentPaceMs) : 0,
|
|
4028
4223
|
agentQueueBeforePaceMs: Number.isFinite(Number(message.agentQueueBeforePaceMs)) ? Number(message.agentQueueBeforePaceMs) : 0,
|
|
4029
4224
|
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
|
-
|
|
4225
|
+
droppedByAgent: Number.isFinite(Number(message.droppedByAgent)) ? Number(message.droppedByAgent) : 0,
|
|
4226
|
+
hubIngressDropped: Number.isFinite(Number(message.hubIngressDropped)) ? Number(message.hubIngressDropped) : 0,
|
|
4227
|
+
udpIncompleteFrames: Number.isFinite(Number(message.udpIncompleteFrames)) ? Number(message.udpIncompleteFrames) : 0,
|
|
4228
|
+
hardwareEncoder: safeString(message.hardwareEncoder, 80),
|
|
4033
4229
|
platformProfile: safeString(message.platformProfile, 80),
|
|
4034
4230
|
monitorIndex: Number.isFinite(Number(message.monitorIndex)) ? normalizeMonitorIndex(message.monitorIndex) : Number(streamState.monitorIndex || 0),
|
|
4035
4231
|
monitorCount: Number.isFinite(Number(message.monitorCount)) ? clampNumber(message.monitorCount, 1, 64, 1) : Number(streamState.monitorCount || 1),
|
|
@@ -4037,29 +4233,40 @@ export function createRemoteHub(options = {}) {
|
|
|
4037
4233
|
transferProtocolVersion: transfer.transferProtocolVersion,
|
|
4038
4234
|
handshake: transfer.handshake,
|
|
4039
4235
|
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
|
-
|
|
4236
|
+
requestedFps: Number.isFinite(Number(message.requestedFps)) ? Number(message.requestedFps) : Number(streamState.fps || 0),
|
|
4237
|
+
effectiveFps: Number.isFinite(Number(message.effectiveFps)) ? Number(message.effectiveFps) : Number(message.fps || streamState.fps || 0),
|
|
4238
|
+
captureTimingAvailable,
|
|
4239
|
+
captureAverageMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureAverageMs') : undefined,
|
|
4240
|
+
captureP95Ms: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureP95Ms') : undefined,
|
|
4044
4241
|
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:
|
|
4242
|
+
captureHelperRestarts: Number.isFinite(Number(message.captureHelperRestarts)) ? Number(message.captureHelperRestarts) : 0,
|
|
4243
|
+
capturedAt,
|
|
4244
|
+
receivedAt: device.lastSeenAt,
|
|
4245
|
+
byteLength,
|
|
4246
|
+
transport,
|
|
4247
|
+
contentHash,
|
|
4248
|
+
captureMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureMs') : undefined,
|
|
4249
|
+
captureStageMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureStageMs') : undefined,
|
|
4053
4250
|
convertMs: readFrameStageMs(message, 'convertMs'),
|
|
4054
4251
|
compressMs: readFrameStageMs(message, 'compressMs'),
|
|
4055
4252
|
captureSourceSerial: Number.isFinite(Number(message.captureSourceSerial)) ? Number(message.captureSourceSerial) : 0,
|
|
4056
4253
|
captureSourceSkipped: Number.isFinite(Number(message.captureSourceSkipped)) ? Number(message.captureSourceSkipped) : 0,
|
|
4057
4254
|
deltaTileSize: Number.isFinite(Number(message.deltaTileSize)) ? Number(message.deltaTileSize) : 0,
|
|
4058
4255
|
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
|
-
|
|
4256
|
+
deltaVisualTileCount: Number.isFinite(Number(message.deltaVisualTileCount)) ? Number(message.deltaVisualTileCount) : 0,
|
|
4257
|
+
deltaRefreshTileCount: Number.isFinite(Number(message.deltaRefreshTileCount)) ? Number(message.deltaRefreshTileCount) : 0,
|
|
4258
|
+
deltaTotalTiles: Number.isFinite(Number(message.deltaTotalTiles)) ? Number(message.deltaTotalTiles) : 0,
|
|
4259
|
+
videoTransport,
|
|
4260
|
+
frameTransportActive: safeString(message.frameTransportActive, 20) || (videoTransport === 'p2p-udp' ? 'p2p' : 'direct'),
|
|
4261
|
+
frameTransportProtocol: safeString(message.frameTransportProtocol, 20)
|
|
4262
|
+
|| (videoTransport === 'p2p-udp' ? 'udp' : videoTransport === 'direct-ws' ? 'ws' : 'tcp'),
|
|
4263
|
+
frameTransportState: safeString(message.frameTransportState, 20) || 'active',
|
|
4264
|
+
frameTransportP2pReady: message.frameTransportP2pReady === true,
|
|
4265
|
+
frameTransportReason: safeString(message.frameTransportReason, 240),
|
|
4266
|
+
frameTransportChangedAt: safeString(message.frameTransportChangedAt, 80),
|
|
4267
|
+
frameTransportSwitchCount: Number.isFinite(Number(message.frameTransportSwitchCount)) ? Number(message.frameTransportSwitchCount) : 0,
|
|
4268
|
+
frameTransportTcpFailureCount: Number.isFinite(Number(message.frameTransportTcpFailureCount)) ? Number(message.frameTransportTcpFailureCount) : 0,
|
|
4269
|
+
sameContentStreak,
|
|
4063
4270
|
payload,
|
|
4064
4271
|
accessToken: createFrameAccessToken()
|
|
4065
4272
|
};
|
|
@@ -4073,9 +4280,12 @@ export function createRemoteHub(options = {}) {
|
|
|
4073
4280
|
mimeType: device.latestLiveFrame.mimeType,
|
|
4074
4281
|
byteLength: device.latestLiveFrame.byteLength
|
|
4075
4282
|
});
|
|
4076
|
-
streamState.lastFrameAt = device.lastSeenAt;
|
|
4077
|
-
streamState.lastFrameSeq = frameSeq;
|
|
4078
|
-
streamState.framesReceived = (streamState.framesReceived || 0) + 1;
|
|
4283
|
+
streamState.lastFrameAt = device.lastSeenAt;
|
|
4284
|
+
streamState.lastFrameSeq = frameSeq;
|
|
4285
|
+
streamState.framesReceived = (streamState.framesReceived || 0) + 1;
|
|
4286
|
+
streamState.readyFrameReceived = readyFrameReceivedBeforeFrame
|
|
4287
|
+
|| (currentGenerationVerified
|
|
4288
|
+
&& liveFrameSatisfiesReadiness(streamState, device.latestLiveFrame));
|
|
4079
4289
|
streamState.mode = transfer.mode;
|
|
4080
4290
|
streamState.frameMode = transfer.frameMode;
|
|
4081
4291
|
streamState.frameProfile = transfer.frameProfile;
|
|
@@ -4093,7 +4303,9 @@ export function createRemoteHub(options = {}) {
|
|
|
4093
4303
|
streamState.monitorCount = device.latestLiveFrame.monitorCount;
|
|
4094
4304
|
ensureDeviceLiveStreams(device).set(streamId, streamState);
|
|
4095
4305
|
device.counters.liveFramesReceived += 1;
|
|
4096
|
-
if (
|
|
4306
|
+
if (!readyFrameReceivedBeforeFrame
|
|
4307
|
+
&& streamState.readyFrameReceived === true
|
|
4308
|
+
&& liveStreamHasCurrentFrame(streamState)) {
|
|
4097
4309
|
emitRemoteEvent('RemoteLiveStreamReady', device, {
|
|
4098
4310
|
streamId,
|
|
4099
4311
|
commandId: streamState.commandId,
|
|
@@ -4217,7 +4429,7 @@ export function createRemoteHub(options = {}) {
|
|
|
4217
4429
|
return true;
|
|
4218
4430
|
}
|
|
4219
4431
|
|
|
4220
|
-
function handleAgentBinaryFrame(socket, state, header, framePayload) {
|
|
4432
|
+
function handleAgentBinaryFrame(socket, state, header, framePayload) {
|
|
4221
4433
|
if (!state.authenticated || !state.device) {
|
|
4222
4434
|
writeJsonLine(socket, { type: 'error', error: 'hello-required' });
|
|
4223
4435
|
socket.destroy();
|
|
@@ -4241,28 +4453,29 @@ export function createRemoteHub(options = {}) {
|
|
|
4241
4453
|
}
|
|
4242
4454
|
|
|
4243
4455
|
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
|
-
|
|
4456
|
+
const binaryTransport = state.binaryTransport === 'ws-binary' ? 'ws-binary' : 'binary';
|
|
4457
|
+
if (frameKind === 'thumbnail' || header.type === 'thumbnail.binary') {
|
|
4458
|
+
applyThumbnailFrame(device, header, framePayload, binaryTransport);
|
|
4459
|
+
return;
|
|
4460
|
+
}
|
|
4461
|
+
|
|
4462
|
+
if (frameKind === 'stream' || frameKind === 'live' || header.type === 'stream.binary') {
|
|
4463
|
+
applyLiveFrame(device, {
|
|
4464
|
+
...header,
|
|
4465
|
+
hubIngressDropped: Number(state.binaryQueueDrops || 0)
|
|
4466
|
+
}, framePayload, binaryTransport);
|
|
4467
|
+
return;
|
|
4468
|
+
}
|
|
4469
|
+
|
|
4470
|
+
if (frameKind === 'audio' || header.type === 'audio.binary') {
|
|
4471
|
+
applyAudioFrame(device, header, framePayload, binaryTransport);
|
|
4472
|
+
return;
|
|
4473
|
+
}
|
|
4474
|
+
|
|
4475
|
+
if (frameKind === 'audio-status' || header.type === 'audio.status.binary') {
|
|
4476
|
+
applyAudioStatus(device, header, framePayload, binaryTransport);
|
|
4477
|
+
return;
|
|
4478
|
+
}
|
|
4266
4479
|
|
|
4267
4480
|
emitRemoteEvent('RemoteAgentMessageIgnored', device, {
|
|
4268
4481
|
messageType: safeString(header.type, 80),
|
|
@@ -4440,9 +4653,14 @@ export function createRemoteHub(options = {}) {
|
|
|
4440
4653
|
state.device = device;
|
|
4441
4654
|
state.inputOnly = true;
|
|
4442
4655
|
return;
|
|
4443
|
-
}
|
|
4444
|
-
if (channel === 'frame') {
|
|
4445
|
-
|
|
4656
|
+
}
|
|
4657
|
+
if (channel === 'frame') {
|
|
4658
|
+
if (rejectFrameChannelForTest) {
|
|
4659
|
+
writeJsonLine(socket, { type: 'error', error: 'test-frame-channel-unavailable' });
|
|
4660
|
+
socket.end();
|
|
4661
|
+
return;
|
|
4662
|
+
}
|
|
4663
|
+
const device = attachFrameSocket(socket, message);
|
|
4446
4664
|
if (!device) {
|
|
4447
4665
|
return;
|
|
4448
4666
|
}
|
|
@@ -4493,23 +4711,12 @@ export function createRemoteHub(options = {}) {
|
|
|
4493
4711
|
|
|
4494
4712
|
if (state.inputOnly) {
|
|
4495
4713
|
device.inputLastSeenAt = new Date().toISOString();
|
|
4496
|
-
if (message.type === 'input.applied') {
|
|
4714
|
+
if (message.type === 'input.applied' || message.type === 'input.error') {
|
|
4497
4715
|
handleRemoteInputOutcome(device, {
|
|
4498
4716
|
...message,
|
|
4499
4717
|
agentApplyMs: Number(message.agentApplyMs || 0) || 0
|
|
4500
4718
|
}, 'input');
|
|
4501
4719
|
}
|
|
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
4720
|
return;
|
|
4514
4721
|
}
|
|
4515
4722
|
if (state.frameOnly) {
|
|
@@ -4553,6 +4760,7 @@ export function createRemoteHub(options = {}) {
|
|
|
4553
4760
|
message.error || (message.type === 'command.error' ? 'command-failed' : ''),
|
|
4554
4761
|
500);
|
|
4555
4762
|
const result = message.result ?? null;
|
|
4763
|
+
settlePendingCommandResult(device, message);
|
|
4556
4764
|
handleInputFallbackCommandResult(device, message);
|
|
4557
4765
|
if (error || result?.ok === false || result?.status === 'failed') {
|
|
4558
4766
|
failPendingLiveStream(device, commandId, error || result?.error || 'stream-start-failed');
|
|
@@ -4606,10 +4814,11 @@ export function createRemoteHub(options = {}) {
|
|
|
4606
4814
|
socket.setNoDelay(true);
|
|
4607
4815
|
socket.setKeepAlive(true, heartbeatMs);
|
|
4608
4816
|
|
|
4609
|
-
const state = {
|
|
4610
|
-
authenticated: false,
|
|
4611
|
-
device: null,
|
|
4612
|
-
|
|
4817
|
+
const state = {
|
|
4818
|
+
authenticated: false,
|
|
4819
|
+
device: null,
|
|
4820
|
+
binaryTransport: 'ws-binary',
|
|
4821
|
+
inputOnly: false,
|
|
4613
4822
|
frameOnly: false,
|
|
4614
4823
|
audioOnly: false,
|
|
4615
4824
|
fileOnly: false,
|
|
@@ -4763,10 +4972,11 @@ export function createRemoteHub(options = {}) {
|
|
|
4763
4972
|
socket.setNoDelay(true);
|
|
4764
4973
|
socket.setKeepAlive(true, heartbeatMs);
|
|
4765
4974
|
|
|
4766
|
-
const state = {
|
|
4767
|
-
authenticated: false,
|
|
4768
|
-
device: null,
|
|
4769
|
-
|
|
4975
|
+
const state = {
|
|
4976
|
+
authenticated: false,
|
|
4977
|
+
device: null,
|
|
4978
|
+
binaryTransport: 'binary',
|
|
4979
|
+
inputOnly: false,
|
|
4770
4980
|
frameOnly: false,
|
|
4771
4981
|
audioOnly: false,
|
|
4772
4982
|
fileOnly: false,
|
|
@@ -5000,6 +5210,7 @@ export function createRemoteHub(options = {}) {
|
|
|
5000
5210
|
for (const device of devices.values()) {
|
|
5001
5211
|
failAllPendingTasks(device, 'hub-shutdown');
|
|
5002
5212
|
failAllPendingInputFallbacks(device, 'hub-shutdown');
|
|
5213
|
+
failPendingCommandResultWaiters(device, 'hub-shutdown');
|
|
5003
5214
|
if (device.socket && !device.socket.destroyed) {
|
|
5004
5215
|
writeJsonLine(device.socket, { type: 'disconnect', reason: 'hub-shutdown' });
|
|
5005
5216
|
device.socket.destroy();
|
|
@@ -5240,10 +5451,58 @@ export function createRemoteHub(options = {}) {
|
|
|
5240
5451
|
return { ok: false, error: 'device-not-found' };
|
|
5241
5452
|
}
|
|
5242
5453
|
|
|
5243
|
-
if (!device.connected) {
|
|
5244
|
-
return { ok: false, error: 'device-not-connected' };
|
|
5245
|
-
}
|
|
5246
|
-
|
|
5454
|
+
if (!device.connected) {
|
|
5455
|
+
return { ok: false, error: 'device-not-connected' };
|
|
5456
|
+
}
|
|
5457
|
+
|
|
5458
|
+
const normalized = normalizeRemoteInputEvent(input);
|
|
5459
|
+
if (!normalized.type) {
|
|
5460
|
+
return { ok: false, error: 'missing-input-type' };
|
|
5461
|
+
}
|
|
5462
|
+
|
|
5463
|
+
// A reset only releases native key state. The current browser input
|
|
5464
|
+
// owner must be able to send it even when its former Control binding
|
|
5465
|
+
// has just become stale during a monitor/capture-generation switch.
|
|
5466
|
+
const currentInputOwner = safeString(device.inputOwnerConnectionId, 128);
|
|
5467
|
+
const isCurrentOwnerKeyboardReset = normalized.type.toLowerCase() === 'keyboard.reset'
|
|
5468
|
+
&& normalized.hubConnectionId
|
|
5469
|
+
&& normalized.hubConnectionId === currentInputOwner;
|
|
5470
|
+
if (isCurrentOwnerKeyboardReset) {
|
|
5471
|
+
const inputSocket = device.inputSocket;
|
|
5472
|
+
if (inputSocket && !inputSocket.destroyed) {
|
|
5473
|
+
const sent = writeJsonLine(inputSocket, {
|
|
5474
|
+
type: 'input.control',
|
|
5475
|
+
payload: {
|
|
5476
|
+
...normalized,
|
|
5477
|
+
reason: normalized.reason || 'control-keyboard-owner-changed',
|
|
5478
|
+
pressedCodes: [],
|
|
5479
|
+
shiftKey: false,
|
|
5480
|
+
ctrlKey: false,
|
|
5481
|
+
altKey: false,
|
|
5482
|
+
metaKey: false,
|
|
5483
|
+
repeat: false,
|
|
5484
|
+
hubForwardedAtEpochMs: Date.now(),
|
|
5485
|
+
issuedAt: normalized.issuedAt || new Date().toISOString()
|
|
5486
|
+
}
|
|
5487
|
+
});
|
|
5488
|
+
if (sent) {
|
|
5489
|
+
device.inputOwnerBindingKey = '';
|
|
5490
|
+
device.counters.commandsSent += 1;
|
|
5491
|
+
device.inputLastSeenAt = new Date().toISOString();
|
|
5492
|
+
return {
|
|
5493
|
+
ok: true,
|
|
5494
|
+
inputSocket: true,
|
|
5495
|
+
releaseOnly: true,
|
|
5496
|
+
staleBindingAccepted: true
|
|
5497
|
+
};
|
|
5498
|
+
}
|
|
5499
|
+
detachInputSocket(inputSocket, 'input-owner-reset-write-failed');
|
|
5500
|
+
}
|
|
5501
|
+
if (readCapabilityFlag(device.capabilities, 'dedicatedInputChannel')) {
|
|
5502
|
+
return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
|
|
5503
|
+
}
|
|
5504
|
+
}
|
|
5505
|
+
|
|
5247
5506
|
const denied = policyError(device, 'allowControl');
|
|
5248
5507
|
if (denied) return { ok: false, error: denied };
|
|
5249
5508
|
|
|
@@ -5255,10 +5514,6 @@ export function createRemoteHub(options = {}) {
|
|
|
5255
5514
|
return { ok: false, error: 'device-input-control-unavailable' };
|
|
5256
5515
|
}
|
|
5257
5516
|
|
|
5258
|
-
const normalized = normalizeRemoteInputEvent(input);
|
|
5259
|
-
if (!normalized.type) {
|
|
5260
|
-
return { ok: false, error: 'missing-input-type' };
|
|
5261
|
-
}
|
|
5262
5517
|
const controlStream = getActiveControlStream(device);
|
|
5263
5518
|
if (!controlStream
|
|
5264
5519
|
|| !liveStreamHasCurrentFrame(controlStream)
|
|
@@ -5292,18 +5547,74 @@ export function createRemoteHub(options = {}) {
|
|
|
5292
5547
|
activeCaptureGeneration: Number(controlStream.captureGeneration || 0)
|
|
5293
5548
|
};
|
|
5294
5549
|
}
|
|
5550
|
+
const activeMonitorIndex = normalizeMonitorIndex(controlStream.monitorIndex);
|
|
5551
|
+
if (!Number.isInteger(normalized.monitorIndex)
|
|
5552
|
+
|| normalized.monitorIndex !== activeMonitorIndex) {
|
|
5553
|
+
return {
|
|
5554
|
+
ok: false,
|
|
5555
|
+
error: 'STALE_CONTROL_MONITOR',
|
|
5556
|
+
activeMonitorIndex
|
|
5557
|
+
};
|
|
5558
|
+
}
|
|
5295
5559
|
|
|
5296
5560
|
const inputSocket = device.inputSocket;
|
|
5297
5561
|
if (inputSocket && !inputSocket.destroyed) {
|
|
5298
|
-
const
|
|
5562
|
+
const previousOwnerConnectionId = safeString(device.inputOwnerConnectionId, 128);
|
|
5563
|
+
const activeInputBindingKey = [
|
|
5564
|
+
safeString(device.sessionId, 160),
|
|
5565
|
+
safeString(controlStream.commandId, 128),
|
|
5566
|
+
Number(controlStream.captureGeneration || 0),
|
|
5567
|
+
activeMonitorIndex
|
|
5568
|
+
].join(':');
|
|
5569
|
+
const previousInputBindingKey = safeString(device.inputOwnerBindingKey, 512);
|
|
5570
|
+
const ownerChanged = normalized.hubConnectionId
|
|
5571
|
+
&& previousOwnerConnectionId
|
|
5572
|
+
&& normalized.hubConnectionId !== previousOwnerConnectionId;
|
|
5573
|
+
const bindingChanged = previousInputBindingKey
|
|
5574
|
+
&& previousInputBindingKey !== activeInputBindingKey;
|
|
5575
|
+
if (ownerChanged || bindingChanged) {
|
|
5576
|
+
const resetSent = writeJsonLine(inputSocket, {
|
|
5577
|
+
type: 'input.control',
|
|
5578
|
+
payload: {
|
|
5579
|
+
type: 'keyboard.reset',
|
|
5580
|
+
reason: ownerChanged
|
|
5581
|
+
? 'browser-input-owner-changed'
|
|
5582
|
+
: 'control-input-binding-changed',
|
|
5583
|
+
pressedCodes: [],
|
|
5584
|
+
shiftKey: false,
|
|
5585
|
+
ctrlKey: false,
|
|
5586
|
+
altKey: false,
|
|
5587
|
+
metaKey: false,
|
|
5588
|
+
repeat: false,
|
|
5589
|
+
requestAck: false,
|
|
5590
|
+
inputSeq: 0,
|
|
5591
|
+
hubConnectionId: previousOwnerConnectionId,
|
|
5592
|
+
monitorIndex: activeMonitorIndex,
|
|
5593
|
+
controlSessionId: safeString(device.sessionId, 160),
|
|
5594
|
+
controlCommandId: safeString(controlStream.commandId, 128),
|
|
5595
|
+
captureGeneration: Number(controlStream.captureGeneration || 0),
|
|
5596
|
+
hubForwardedAtEpochMs: Date.now(),
|
|
5597
|
+
issuedAt: new Date().toISOString()
|
|
5598
|
+
}
|
|
5599
|
+
});
|
|
5600
|
+
if (!resetSent) {
|
|
5601
|
+
detachInputSocket(inputSocket, 'input-owner-reset-write-failed');
|
|
5602
|
+
return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
|
|
5603
|
+
}
|
|
5604
|
+
}
|
|
5605
|
+
const sent = writeJsonLine(inputSocket, {
|
|
5299
5606
|
type: 'input.control',
|
|
5300
5607
|
payload: {
|
|
5301
5608
|
...normalized,
|
|
5302
5609
|
hubForwardedAtEpochMs: Date.now(),
|
|
5303
5610
|
issuedAt: normalized.issuedAt || new Date().toISOString()
|
|
5304
5611
|
}
|
|
5305
|
-
});
|
|
5306
|
-
if (sent) {
|
|
5612
|
+
});
|
|
5613
|
+
if (sent) {
|
|
5614
|
+
if (normalized.hubConnectionId) {
|
|
5615
|
+
device.inputOwnerConnectionId = normalized.hubConnectionId;
|
|
5616
|
+
}
|
|
5617
|
+
device.inputOwnerBindingKey = activeInputBindingKey;
|
|
5307
5618
|
device.counters.commandsSent += 1;
|
|
5308
5619
|
device.inputLastSeenAt = new Date().toISOString();
|
|
5309
5620
|
return {
|
|
@@ -5311,13 +5622,21 @@ export function createRemoteHub(options = {}) {
|
|
|
5311
5622
|
inputSocket: true,
|
|
5312
5623
|
sessionId: device.sessionId,
|
|
5313
5624
|
commandId: controlStream.commandId,
|
|
5314
|
-
captureGeneration: controlStream.captureGeneration
|
|
5625
|
+
captureGeneration: controlStream.captureGeneration,
|
|
5626
|
+
monitorIndex: activeMonitorIndex
|
|
5315
5627
|
};
|
|
5316
5628
|
}
|
|
5317
5629
|
|
|
5318
|
-
detachInputSocket(inputSocket, 'input-socket-write-failed');
|
|
5319
|
-
}
|
|
5320
|
-
|
|
5630
|
+
detachInputSocket(inputSocket, 'input-socket-write-failed');
|
|
5631
|
+
}
|
|
5632
|
+
|
|
5633
|
+
// Current Agents advertise this ordered side channel. Crossing to the
|
|
5634
|
+
// concurrent main command socket during a reconnect can invert
|
|
5635
|
+
// keydown/keyup, so only legacy Agents use the compatibility fallback.
|
|
5636
|
+
if (readCapabilityFlag(device.capabilities, 'dedicatedInputChannel')) {
|
|
5637
|
+
return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
|
|
5638
|
+
}
|
|
5639
|
+
|
|
5321
5640
|
const hubForwardedAtEpochMs = Date.now();
|
|
5322
5641
|
const fallback = sendCommand(deviceId, {
|
|
5323
5642
|
command: 'input.control',
|
|
@@ -5332,6 +5651,9 @@ export function createRemoteHub(options = {}) {
|
|
|
5332
5651
|
commandId: fallback.commandId,
|
|
5333
5652
|
inputSeq: normalized.inputSeq,
|
|
5334
5653
|
inputType: normalized.type,
|
|
5654
|
+
monitorIndex: normalized.monitorIndex,
|
|
5655
|
+
hubConnectionId: normalized.hubConnectionId,
|
|
5656
|
+
inputEventId: normalized.inputEventId,
|
|
5335
5657
|
issuedAtEpochMs: normalized.issuedAtEpochMs,
|
|
5336
5658
|
hubReceivedAtEpochMs: normalized.hubReceivedAtEpochMs,
|
|
5337
5659
|
hubForwardedAtEpochMs,
|
|
@@ -5348,6 +5670,7 @@ export function createRemoteHub(options = {}) {
|
|
|
5348
5670
|
deliveryCommandId: fallback.commandId,
|
|
5349
5671
|
commandId: controlStream.commandId,
|
|
5350
5672
|
captureGeneration: controlStream.captureGeneration,
|
|
5673
|
+
monitorIndex: activeMonitorIndex,
|
|
5351
5674
|
inputSeq: normalized.inputSeq
|
|
5352
5675
|
}
|
|
5353
5676
|
: {
|
|
@@ -5357,6 +5680,49 @@ export function createRemoteHub(options = {}) {
|
|
|
5357
5680
|
};
|
|
5358
5681
|
}
|
|
5359
5682
|
|
|
5683
|
+
function releaseInputOwner(deviceId, ownerConnectionId, reason = 'browser-input-owner-closed') {
|
|
5684
|
+
const device = devices.get(String(deviceId || ''));
|
|
5685
|
+
const owner = safeString(ownerConnectionId, 128);
|
|
5686
|
+
if (!device || !owner || safeString(device.inputOwnerConnectionId, 128) !== owner) {
|
|
5687
|
+
return { ok: false, error: 'input-owner-not-current' };
|
|
5688
|
+
}
|
|
5689
|
+
|
|
5690
|
+
device.inputOwnerConnectionId = '';
|
|
5691
|
+
device.inputOwnerBindingKey = '';
|
|
5692
|
+
const inputSocket = device.inputSocket;
|
|
5693
|
+
if (!inputSocket || inputSocket.destroyed) {
|
|
5694
|
+
return { ok: true, queued: false };
|
|
5695
|
+
}
|
|
5696
|
+
const controlStream = getActiveControlStream(device);
|
|
5697
|
+
const sent = writeJsonLine(inputSocket, {
|
|
5698
|
+
type: 'input.control',
|
|
5699
|
+
payload: {
|
|
5700
|
+
type: 'keyboard.reset',
|
|
5701
|
+
reason: safeString(reason, 120) || 'browser-input-owner-closed',
|
|
5702
|
+
pressedCodes: [],
|
|
5703
|
+
shiftKey: false,
|
|
5704
|
+
ctrlKey: false,
|
|
5705
|
+
altKey: false,
|
|
5706
|
+
metaKey: false,
|
|
5707
|
+
repeat: false,
|
|
5708
|
+
requestAck: false,
|
|
5709
|
+
inputSeq: 0,
|
|
5710
|
+
hubConnectionId: owner,
|
|
5711
|
+
monitorIndex: normalizeMonitorIndex(controlStream?.monitorIndex),
|
|
5712
|
+
controlSessionId: safeString(device.sessionId, 160),
|
|
5713
|
+
controlCommandId: safeString(controlStream?.commandId, 128),
|
|
5714
|
+
captureGeneration: Number(controlStream?.captureGeneration || 0),
|
|
5715
|
+
hubForwardedAtEpochMs: Date.now(),
|
|
5716
|
+
issuedAt: new Date().toISOString()
|
|
5717
|
+
}
|
|
5718
|
+
});
|
|
5719
|
+
if (!sent) {
|
|
5720
|
+
detachInputSocket(inputSocket, 'input-owner-release-write-failed');
|
|
5721
|
+
return { ok: false, error: 'INPUT_CHANNEL_RECONNECTING' };
|
|
5722
|
+
}
|
|
5723
|
+
return { ok: true, queued: true };
|
|
5724
|
+
}
|
|
5725
|
+
|
|
5360
5726
|
function notifyAgentProgress(deviceIds, progress = {}) {
|
|
5361
5727
|
const targets = [...new Set((deviceIds || [])
|
|
5362
5728
|
.map(deviceId => safeString(deviceId, 128))
|
|
@@ -5762,24 +6128,42 @@ export function createRemoteHub(options = {}) {
|
|
|
5762
6128
|
streamId: stream.streamId,
|
|
5763
6129
|
commandId: key,
|
|
5764
6130
|
error: safeString(error, 500) || 'stream-start-failed'
|
|
5765
|
-
});
|
|
5766
|
-
return true;
|
|
5767
|
-
}
|
|
5768
|
-
|
|
5769
|
-
|
|
6131
|
+
});
|
|
6132
|
+
return true;
|
|
6133
|
+
}
|
|
6134
|
+
for (const stream of streams.values()) {
|
|
6135
|
+
if (safeString(stream?.commandId, 128) !== key || stream?.active !== true) {
|
|
6136
|
+
continue;
|
|
6137
|
+
}
|
|
6138
|
+
clearPendingLiveStreamDescriptor(device, stream);
|
|
6139
|
+
stream.active = false;
|
|
6140
|
+
stream.open = false;
|
|
6141
|
+
stream.stoppedAt = new Date().toISOString();
|
|
6142
|
+
stream.stopReason = safeString(error, 500) || 'stream-start-failed';
|
|
6143
|
+
emitRemoteEvent('RemoteLiveStreamStartFailed', device, {
|
|
6144
|
+
streamId: stream.streamId,
|
|
6145
|
+
commandId: key,
|
|
6146
|
+
error: stream.stopReason
|
|
6147
|
+
});
|
|
6148
|
+
return true;
|
|
6149
|
+
}
|
|
6150
|
+
return false;
|
|
6151
|
+
}
|
|
5770
6152
|
|
|
5771
6153
|
function deactivateDeviceLiveStreams(device, reason, stoppedAt = new Date().toISOString()) {
|
|
5772
6154
|
const streams = ensureDeviceLiveStreams(device);
|
|
5773
6155
|
for (const stream of streams.values()) {
|
|
5774
6156
|
clearPendingLiveStreamDescriptor(device, stream);
|
|
5775
6157
|
stream.active = false;
|
|
6158
|
+
stream.open = false;
|
|
5776
6159
|
stream.stoppedAt = stoppedAt;
|
|
5777
6160
|
stream.stopReason = reason;
|
|
5778
|
-
}
|
|
5779
|
-
if (device?.activeLiveStream) {
|
|
5780
|
-
device.activeLiveStream.active = false;
|
|
5781
|
-
device.activeLiveStream.
|
|
5782
|
-
device.activeLiveStream.
|
|
6161
|
+
}
|
|
6162
|
+
if (device?.activeLiveStream) {
|
|
6163
|
+
device.activeLiveStream.active = false;
|
|
6164
|
+
device.activeLiveStream.open = false;
|
|
6165
|
+
device.activeLiveStream.stoppedAt = stoppedAt;
|
|
6166
|
+
device.activeLiveStream.stopReason = reason;
|
|
5783
6167
|
}
|
|
5784
6168
|
}
|
|
5785
6169
|
|
|
@@ -5801,6 +6185,48 @@ export function createRemoteHub(options = {}) {
|
|
|
5801
6185
|
safeString(stream?.streamPurpose, 24).toLowerCase() === 'control') || null;
|
|
5802
6186
|
}
|
|
5803
6187
|
|
|
6188
|
+
function claimSingleLiveCapture(deviceId, device, streamId, streamPurpose) {
|
|
6189
|
+
const purpose = safeString(streamPurpose, 24).toLowerCase() || 'wall';
|
|
6190
|
+
const activeControlStream = getActiveControlStream(device);
|
|
6191
|
+
if (purpose !== 'control'
|
|
6192
|
+
&& activeControlStream
|
|
6193
|
+
&& activeControlStream.streamId !== streamId) {
|
|
6194
|
+
return {
|
|
6195
|
+
ok: false,
|
|
6196
|
+
error: 'CONTROL_CAPTURE_OWNS_DEVICE',
|
|
6197
|
+
activeStreamId: activeControlStream.streamId,
|
|
6198
|
+
activeStreamPurpose: 'control',
|
|
6199
|
+
activeCommandId: safeString(activeControlStream.commandId, 128),
|
|
6200
|
+
captureGeneration: Number(activeControlStream.captureGeneration || 0)
|
|
6201
|
+
};
|
|
6202
|
+
}
|
|
6203
|
+
|
|
6204
|
+
const competingStreams = getActiveLiveStreams(device)
|
|
6205
|
+
.filter(stream => stream.streamId !== streamId);
|
|
6206
|
+
for (const competingStream of competingStreams) {
|
|
6207
|
+
const result = stopLiveStream(deviceId, {
|
|
6208
|
+
streamId: competingStream.streamId,
|
|
6209
|
+
streamPurpose: competingStream.streamPurpose,
|
|
6210
|
+
reason: `${purpose}-capture-handoff`
|
|
6211
|
+
});
|
|
6212
|
+
if (result?.ok !== true) {
|
|
6213
|
+
return {
|
|
6214
|
+
ok: false,
|
|
6215
|
+
error: result?.error || 'capture-handoff-stop-failed',
|
|
6216
|
+
activeStreamId: competingStream.streamId,
|
|
6217
|
+
activeStreamPurpose: safeString(competingStream.streamPurpose, 24)
|
|
6218
|
+
};
|
|
6219
|
+
}
|
|
6220
|
+
emitRemoteEvent('RemoteLiveStreamCapturePreempted', device, {
|
|
6221
|
+
streamId: competingStream.streamId,
|
|
6222
|
+
streamPurpose: safeString(competingStream.streamPurpose, 24) || 'wall',
|
|
6223
|
+
replacementStreamId: streamId,
|
|
6224
|
+
replacementStreamPurpose: purpose
|
|
6225
|
+
});
|
|
6226
|
+
}
|
|
6227
|
+
return { ok: true };
|
|
6228
|
+
}
|
|
6229
|
+
|
|
5804
6230
|
function nextCaptureGeneration(device) {
|
|
5805
6231
|
const current = Number(device?.captureGenerationCounter || 0);
|
|
5806
6232
|
const next = Number.isSafeInteger(current) && current > 0 ? current + 1 : 1;
|
|
@@ -5855,12 +6281,31 @@ export function createRemoteHub(options = {}) {
|
|
|
5855
6281
|
return Number.isFinite(startedAt) && Date.now() - startedAt < LIVE_STREAM_REPLACEMENT_PENDING_MS;
|
|
5856
6282
|
}
|
|
5857
6283
|
|
|
6284
|
+
function liveStreamRequiresReadyKeyFrame(activeLiveStream) {
|
|
6285
|
+
if (safeString(activeLiveStream?.streamPurpose, 24).toLowerCase() !== 'control') {
|
|
6286
|
+
return false;
|
|
6287
|
+
}
|
|
6288
|
+
const codec = safeString(activeLiveStream?.codec, 80).toLowerCase();
|
|
6289
|
+
const mode = safeString(activeLiveStream?.frameMode || activeLiveStream?.mode, 80).toLowerCase();
|
|
6290
|
+
return codec.includes('h264') || mode === 'mode3-h264-hw';
|
|
6291
|
+
}
|
|
6292
|
+
|
|
6293
|
+
function liveFrameSatisfiesReadiness(activeLiveStream, frame) {
|
|
6294
|
+
return !liveStreamRequiresReadyKeyFrame(activeLiveStream)
|
|
6295
|
+
|| frame?.isKeyFrame === true
|
|
6296
|
+
|| safeString(frame?.chunkType, 20).toLowerCase() === 'key';
|
|
6297
|
+
}
|
|
6298
|
+
|
|
5858
6299
|
function liveStreamHasCurrentFrame(activeLiveStream) {
|
|
5859
6300
|
if (!activeLiveStream?.active
|
|
5860
6301
|
|| activeLiveStream.open !== true
|
|
5861
6302
|
|| Number(activeLiveStream.framesReceived || 0) < 1) {
|
|
5862
6303
|
return false;
|
|
5863
6304
|
}
|
|
6305
|
+
if (liveStreamRequiresReadyKeyFrame(activeLiveStream)
|
|
6306
|
+
&& activeLiveStream.readyFrameReceived !== true) {
|
|
6307
|
+
return false;
|
|
6308
|
+
}
|
|
5864
6309
|
const latestFrame = activeLiveStream.latestFrame;
|
|
5865
6310
|
if (!latestFrame || latestFrame.currentGenerationVerified !== true) {
|
|
5866
6311
|
return false;
|
|
@@ -5918,11 +6363,19 @@ export function createRemoteHub(options = {}) {
|
|
|
5918
6363
|
&& Number(activeLiveStream.monitorIndex || 0) === normalized.monitorIndex;
|
|
5919
6364
|
}
|
|
5920
6365
|
|
|
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?.
|
|
6366
|
+
function startLiveStream(deviceId, options = {}) {
|
|
6367
|
+
const device = devices.get(String(deviceId || ''));
|
|
6368
|
+
const denied = policyError(device);
|
|
6369
|
+
if (denied) return { ok: false, error: denied };
|
|
6370
|
+
if (device?.liveCapturePause?.token) {
|
|
6371
|
+
return {
|
|
6372
|
+
ok: false,
|
|
6373
|
+
error: 'LIVE_CAPTURE_PAUSED',
|
|
6374
|
+
pauseToken: device.liveCapturePause.token,
|
|
6375
|
+
pausedAt: device.liveCapturePause.pausedAt
|
|
6376
|
+
};
|
|
6377
|
+
}
|
|
6378
|
+
if (device?.synthetic === true && device.connected) {
|
|
5926
6379
|
if (!readCapabilityFlag(device.capabilities, 'liveStream')) {
|
|
5927
6380
|
return { ok: false, error: 'device-live-stream-unavailable' };
|
|
5928
6381
|
}
|
|
@@ -5933,6 +6386,8 @@ export function createRemoteHub(options = {}) {
|
|
|
5933
6386
|
if (modePolicyError) return { ok: false, error: modePolicyError };
|
|
5934
6387
|
const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
|
|
5935
6388
|
const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
|
|
6389
|
+
const captureClaim = claimSingleLiveCapture(deviceId, device, streamId, streamPurpose);
|
|
6390
|
+
if (!captureClaim.ok) return captureClaim;
|
|
5936
6391
|
const activeLiveStream = getDeviceLiveStream(device, streamId);
|
|
5937
6392
|
const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
|
|
5938
6393
|
if (activeLiveStream
|
|
@@ -5984,6 +6439,7 @@ export function createRemoteHub(options = {}) {
|
|
|
5984
6439
|
lastFrameAt: '',
|
|
5985
6440
|
lastFrameSeq: 0,
|
|
5986
6441
|
framesReceived: 0,
|
|
6442
|
+
readyFrameReceived: false,
|
|
5987
6443
|
streamPurpose,
|
|
5988
6444
|
captureGeneration
|
|
5989
6445
|
});
|
|
@@ -6031,6 +6487,8 @@ export function createRemoteHub(options = {}) {
|
|
|
6031
6487
|
if (modePolicyError) return { ok: false, error: modePolicyError };
|
|
6032
6488
|
const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
|
|
6033
6489
|
const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
|
|
6490
|
+
const captureClaim = claimSingleLiveCapture(deviceId, device, streamId, streamPurpose);
|
|
6491
|
+
if (!captureClaim.ok) return captureClaim;
|
|
6034
6492
|
const activeLiveStream = getDeviceLiveStream(device, streamId);
|
|
6035
6493
|
const pendingDescriptor = getPendingLiveStreamDescriptor(activeLiveStream);
|
|
6036
6494
|
if (pendingDescriptor?.commandId) {
|
|
@@ -6201,8 +6659,9 @@ export function createRemoteHub(options = {}) {
|
|
|
6201
6659
|
stoppedAt: '',
|
|
6202
6660
|
stopReason: '',
|
|
6203
6661
|
lastFrameAt: '',
|
|
6204
|
-
lastFrameSeq: 0,
|
|
6662
|
+
lastFrameSeq: 0,
|
|
6205
6663
|
framesReceived: 0,
|
|
6664
|
+
readyFrameReceived: false,
|
|
6206
6665
|
streamPurpose,
|
|
6207
6666
|
captureGeneration,
|
|
6208
6667
|
latestFrame: null,
|
|
@@ -6247,14 +6706,16 @@ export function createRemoteHub(options = {}) {
|
|
|
6247
6706
|
};
|
|
6248
6707
|
}
|
|
6249
6708
|
|
|
6250
|
-
function stopLiveStream(deviceId, options = {}) {
|
|
6709
|
+
function stopLiveStream(deviceId, options = {}) {
|
|
6251
6710
|
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 =
|
|
6711
|
+
const streamPurpose = safeString(options.streamPurpose || options.purpose, 24);
|
|
6712
|
+
const requestedStreamId = safeString(options.streamId, 128);
|
|
6713
|
+
const purposeStreamId = streamPurpose ? makeStableLiveStreamId(deviceId, streamPurpose) : '';
|
|
6714
|
+
const streamId = options.stopAll === true
|
|
6715
|
+
? ''
|
|
6716
|
+
: requestedStreamId || purposeStreamId || device?.activeLiveStream?.streamId || '';
|
|
6256
6717
|
const streamState = getDeviceLiveStream(device, streamId);
|
|
6257
|
-
if (streamState && streamState.active !== true) {
|
|
6718
|
+
if (streamState && streamState.active !== true && options.forceCommand !== true) {
|
|
6258
6719
|
clearPendingLiveStreamDescriptor(device, streamState);
|
|
6259
6720
|
return {
|
|
6260
6721
|
ok: true,
|
|
@@ -6271,9 +6732,10 @@ export function createRemoteHub(options = {}) {
|
|
|
6271
6732
|
device.counters.commandsSent += 1;
|
|
6272
6733
|
if (streamState) {
|
|
6273
6734
|
streamState.active = false;
|
|
6735
|
+
streamState.open = false;
|
|
6274
6736
|
streamState.stoppedAt = new Date().toISOString();
|
|
6275
|
-
streamState.stopReason = 'manager-request';
|
|
6276
|
-
}
|
|
6737
|
+
streamState.stopReason = safeString(options.reason, 128) || 'manager-request';
|
|
6738
|
+
}
|
|
6277
6739
|
device.counters.liveStreamsStopped += 1;
|
|
6278
6740
|
emitRemoteEvent('RemoteLiveStreamStopped', device, {
|
|
6279
6741
|
streamId,
|
|
@@ -6303,17 +6765,230 @@ export function createRemoteHub(options = {}) {
|
|
|
6303
6765
|
|
|
6304
6766
|
if (streamState) {
|
|
6305
6767
|
streamState.active = false;
|
|
6768
|
+
streamState.open = false;
|
|
6306
6769
|
streamState.stoppedAt = new Date().toISOString();
|
|
6307
|
-
streamState.stopReason = 'manager-request';
|
|
6308
|
-
}
|
|
6770
|
+
streamState.stopReason = safeString(options.reason, 128) || 'manager-request';
|
|
6771
|
+
}
|
|
6309
6772
|
device.counters.liveStreamsStopped += 1;
|
|
6310
6773
|
emitRemoteEvent('RemoteLiveStreamStopped', device, {
|
|
6311
6774
|
streamId,
|
|
6312
6775
|
commandId
|
|
6313
6776
|
});
|
|
6314
6777
|
|
|
6315
|
-
return { ok: true, commandId, streamId };
|
|
6316
|
-
}
|
|
6778
|
+
return { ok: true, commandId, streamId };
|
|
6779
|
+
}
|
|
6780
|
+
|
|
6781
|
+
function buildLiveCapturePauseResult(pause, extra = {}) {
|
|
6782
|
+
return {
|
|
6783
|
+
paused: true,
|
|
6784
|
+
pauseToken: pause.token,
|
|
6785
|
+
pausedAt: pause.pausedAt,
|
|
6786
|
+
streamId: pause.streamId,
|
|
6787
|
+
streamPurpose: pause.streamPurpose,
|
|
6788
|
+
monitorIndex: pause.monitorIndex,
|
|
6789
|
+
phase: pause.phase,
|
|
6790
|
+
captureStopConfirmed: pause.captureStopConfirmed === true,
|
|
6791
|
+
captureStopConfirmedAt: pause.captureStopConfirmedAt || '',
|
|
6792
|
+
stopCommandId: pause.stopCommandId || '',
|
|
6793
|
+
stopError: pause.stopError || '',
|
|
6794
|
+
stopAttemptCount: Math.max(0, Number(pause.stopAttemptCount) || 0),
|
|
6795
|
+
...extra
|
|
6796
|
+
};
|
|
6797
|
+
}
|
|
6798
|
+
|
|
6799
|
+
async function confirmLiveCaptureStopped(deviceId, pause) {
|
|
6800
|
+
let device = devices.get(String(deviceId || ''));
|
|
6801
|
+
if (!device || device.liveCapturePause?.token !== pause.token) {
|
|
6802
|
+
return buildLiveCapturePauseResult(pause, {
|
|
6803
|
+
ok: false,
|
|
6804
|
+
error: 'LIVE_CAPTURE_PAUSE_LEASE_STALE'
|
|
6805
|
+
});
|
|
6806
|
+
}
|
|
6807
|
+
|
|
6808
|
+
pause.phase = 'pausing';
|
|
6809
|
+
pause.captureStopConfirmed = false;
|
|
6810
|
+
pause.captureStopConfirmedAt = '';
|
|
6811
|
+
pause.stopError = '';
|
|
6812
|
+
pause.stopAttemptCount = Math.max(0, Number(pause.stopAttemptCount) || 0) + 1;
|
|
6813
|
+
|
|
6814
|
+
let stopResult = { ok: true, alreadyStopped: true, streamId: pause.streamId };
|
|
6815
|
+
if (device.synthetic !== true) {
|
|
6816
|
+
stopResult = stopLiveStream(deviceId, {
|
|
6817
|
+
reason: pause.reason,
|
|
6818
|
+
// Empty streamId is an intentional Agent contract: stop every
|
|
6819
|
+
// active/quarantined capture, including a native helper that
|
|
6820
|
+
// outlived the Hub's last stream descriptor.
|
|
6821
|
+
stopAll: true,
|
|
6822
|
+
forceCommand: true
|
|
6823
|
+
});
|
|
6824
|
+
}
|
|
6825
|
+
|
|
6826
|
+
if (stopResult?.ok === false) {
|
|
6827
|
+
pause.phase = 'stop-unconfirmed';
|
|
6828
|
+
pause.stopError = safeString(stopResult.error, 500) || 'stream-stop-command-failed';
|
|
6829
|
+
emitRemoteEvent('RemoteLiveCapturePauseFailed', device, {
|
|
6830
|
+
pauseToken: pause.token,
|
|
6831
|
+
streamId: pause.streamId,
|
|
6832
|
+
stopError: pause.stopError
|
|
6833
|
+
});
|
|
6834
|
+
return buildLiveCapturePauseResult(pause, {
|
|
6835
|
+
ok: false,
|
|
6836
|
+
error: 'LIVE_CAPTURE_STOP_UNCONFIRMED'
|
|
6837
|
+
});
|
|
6838
|
+
}
|
|
6839
|
+
|
|
6840
|
+
pause.stopCommandId = safeString(stopResult.commandId, 128);
|
|
6841
|
+
if (pause.stopCommandId
|
|
6842
|
+
&& stopResult.alreadyStopped !== true
|
|
6843
|
+
&& stopResult.synthetic !== true) {
|
|
6844
|
+
const acknowledgement = await waitForCommandResult(
|
|
6845
|
+
device,
|
|
6846
|
+
pause.stopCommandId,
|
|
6847
|
+
liveCaptureStopAckTimeoutMs);
|
|
6848
|
+
device = devices.get(String(deviceId || '')) || device;
|
|
6849
|
+
if (device.liveCapturePause?.token !== pause.token) {
|
|
6850
|
+
return buildLiveCapturePauseResult(pause, {
|
|
6851
|
+
ok: false,
|
|
6852
|
+
error: 'LIVE_CAPTURE_PAUSE_LEASE_STALE'
|
|
6853
|
+
});
|
|
6854
|
+
}
|
|
6855
|
+
if (!acknowledgement.ok
|
|
6856
|
+
|| acknowledgement.result?.stream !== true
|
|
6857
|
+
|| acknowledgement.result?.stopped !== true) {
|
|
6858
|
+
pause.phase = 'stop-unconfirmed';
|
|
6859
|
+
pause.stopError = safeString(
|
|
6860
|
+
acknowledgement.error
|
|
6861
|
+
|| acknowledgement.result?.error
|
|
6862
|
+
|| 'stream-stop-not-confirmed',
|
|
6863
|
+
500);
|
|
6864
|
+
emitRemoteEvent('RemoteLiveCapturePauseFailed', device, {
|
|
6865
|
+
pauseToken: pause.token,
|
|
6866
|
+
streamId: pause.streamId,
|
|
6867
|
+
stopCommandId: pause.stopCommandId,
|
|
6868
|
+
stopError: pause.stopError
|
|
6869
|
+
});
|
|
6870
|
+
return buildLiveCapturePauseResult(pause, {
|
|
6871
|
+
ok: false,
|
|
6872
|
+
error: 'LIVE_CAPTURE_STOP_UNCONFIRMED'
|
|
6873
|
+
});
|
|
6874
|
+
}
|
|
6875
|
+
}
|
|
6876
|
+
|
|
6877
|
+
pause.phase = 'paused';
|
|
6878
|
+
pause.captureStopConfirmed = true;
|
|
6879
|
+
pause.captureStopConfirmedAt = new Date().toISOString();
|
|
6880
|
+
pause.stopError = '';
|
|
6881
|
+
deactivateDeviceLiveStreams(device, pause.reason, pause.captureStopConfirmedAt);
|
|
6882
|
+
emitRemoteEvent('RemoteLiveCapturePaused', device, {
|
|
6883
|
+
pauseToken: pause.token,
|
|
6884
|
+
pausedAt: pause.pausedAt,
|
|
6885
|
+
streamId: pause.streamId,
|
|
6886
|
+
streamPurpose: pause.streamPurpose,
|
|
6887
|
+
monitorIndex: pause.monitorIndex,
|
|
6888
|
+
stopCommandId: pause.stopCommandId,
|
|
6889
|
+
captureStopConfirmedAt: pause.captureStopConfirmedAt
|
|
6890
|
+
});
|
|
6891
|
+
return buildLiveCapturePauseResult(pause, {
|
|
6892
|
+
ok: true,
|
|
6893
|
+
alreadyStopped: stopResult.alreadyStopped === true
|
|
6894
|
+
});
|
|
6895
|
+
}
|
|
6896
|
+
|
|
6897
|
+
async function pauseLiveCapture(deviceId, options = {}) {
|
|
6898
|
+
const device = devices.get(String(deviceId || ''));
|
|
6899
|
+
if (!device) {
|
|
6900
|
+
return { ok: false, paused: false, error: 'device-not-found' };
|
|
6901
|
+
}
|
|
6902
|
+
const denied = policyError(device);
|
|
6903
|
+
if (denied) return { ok: false, paused: false, error: denied };
|
|
6904
|
+
|
|
6905
|
+
let pause = device.liveCapturePause;
|
|
6906
|
+
if (pause?.token && pause.captureStopConfirmed === true) {
|
|
6907
|
+
return buildLiveCapturePauseResult(pause, {
|
|
6908
|
+
ok: true,
|
|
6909
|
+
alreadyPaused: true
|
|
6910
|
+
});
|
|
6911
|
+
}
|
|
6912
|
+
if (pause?.stopPromise) {
|
|
6913
|
+
return pause.stopPromise;
|
|
6914
|
+
}
|
|
6915
|
+
|
|
6916
|
+
if (!pause?.token) {
|
|
6917
|
+
const activeLiveStream = device.activeLiveStream?.active === true
|
|
6918
|
+
? device.activeLiveStream
|
|
6919
|
+
: null;
|
|
6920
|
+
pause = {
|
|
6921
|
+
token: crypto.randomUUID(),
|
|
6922
|
+
pausedAt: new Date().toISOString(),
|
|
6923
|
+
reason: safeString(options.reason, 128) || 'user-video-pause',
|
|
6924
|
+
streamId: safeString(activeLiveStream?.streamId, 128),
|
|
6925
|
+
streamPurpose: safeString(activeLiveStream?.streamPurpose, 24),
|
|
6926
|
+
monitorIndex: Math.max(0, Math.floor(Number(activeLiveStream?.monitorIndex) || 0)),
|
|
6927
|
+
phase: 'pausing',
|
|
6928
|
+
captureStopConfirmed: false,
|
|
6929
|
+
captureStopConfirmedAt: '',
|
|
6930
|
+
stopCommandId: '',
|
|
6931
|
+
stopError: '',
|
|
6932
|
+
stopAttemptCount: 0
|
|
6933
|
+
};
|
|
6934
|
+
// Install the authoritative gate before asking the Agent to stop.
|
|
6935
|
+
// A late subscriber or watchdog therefore cannot reacquire FFmpeg
|
|
6936
|
+
// in the pause transition window.
|
|
6937
|
+
device.liveCapturePause = pause;
|
|
6938
|
+
}
|
|
6939
|
+
|
|
6940
|
+
const operation = confirmLiveCaptureStopped(deviceId, pause);
|
|
6941
|
+
pause.stopPromise = operation;
|
|
6942
|
+
try {
|
|
6943
|
+
return await operation;
|
|
6944
|
+
} finally {
|
|
6945
|
+
if (pause.stopPromise === operation) {
|
|
6946
|
+
delete pause.stopPromise;
|
|
6947
|
+
}
|
|
6948
|
+
}
|
|
6949
|
+
}
|
|
6950
|
+
|
|
6951
|
+
function resumeLiveCapture(deviceId, options = {}) {
|
|
6952
|
+
const device = devices.get(String(deviceId || ''));
|
|
6953
|
+
const denied = policyError(device);
|
|
6954
|
+
if (denied) return { ok: false, error: denied };
|
|
6955
|
+
const pause = device?.liveCapturePause;
|
|
6956
|
+
if (!pause?.token) {
|
|
6957
|
+
return { ok: true, paused: false, alreadyResumed: true };
|
|
6958
|
+
}
|
|
6959
|
+
if (pause.phase === 'pausing') {
|
|
6960
|
+
return {
|
|
6961
|
+
ok: false,
|
|
6962
|
+
paused: true,
|
|
6963
|
+
error: 'LIVE_CAPTURE_STOP_IN_PROGRESS',
|
|
6964
|
+
pauseToken: pause.token
|
|
6965
|
+
};
|
|
6966
|
+
}
|
|
6967
|
+
const pauseToken = safeString(options.pauseToken, 128);
|
|
6968
|
+
if (!pauseToken || pauseToken !== pause.token) {
|
|
6969
|
+
return {
|
|
6970
|
+
ok: false,
|
|
6971
|
+
error: 'LIVE_CAPTURE_PAUSE_TOKEN_MISMATCH'
|
|
6972
|
+
};
|
|
6973
|
+
}
|
|
6974
|
+
|
|
6975
|
+
device.liveCapturePause = null;
|
|
6976
|
+
emitRemoteEvent('RemoteLiveCaptureResumed', device, {
|
|
6977
|
+
pauseToken,
|
|
6978
|
+
resumedAt: new Date().toISOString(),
|
|
6979
|
+
streamId: pause.streamId,
|
|
6980
|
+
streamPurpose: pause.streamPurpose,
|
|
6981
|
+
monitorIndex: pause.monitorIndex
|
|
6982
|
+
});
|
|
6983
|
+
return {
|
|
6984
|
+
ok: true,
|
|
6985
|
+
paused: false,
|
|
6986
|
+
resumed: true,
|
|
6987
|
+
streamId: pause.streamId,
|
|
6988
|
+
streamPurpose: pause.streamPurpose,
|
|
6989
|
+
monitorIndex: pause.monitorIndex
|
|
6990
|
+
};
|
|
6991
|
+
}
|
|
6317
6992
|
|
|
6318
6993
|
function startAudioStream(deviceId, options = {}) {
|
|
6319
6994
|
const device = devices.get(String(deviceId || ''));
|
|
@@ -6475,15 +7150,18 @@ export function createRemoteHub(options = {}) {
|
|
|
6475
7150
|
sendCommand,
|
|
6476
7151
|
sendLegacyClientUpdate,
|
|
6477
7152
|
sendInputControl,
|
|
7153
|
+
releaseInputOwner,
|
|
6478
7154
|
notifyAgentProgress,
|
|
6479
7155
|
requestAgentTask,
|
|
6480
7156
|
requestAgentTaskBatch,
|
|
6481
7157
|
setHostTarget,
|
|
6482
7158
|
rotatePairingPin,
|
|
6483
|
-
requestThumbnail,
|
|
6484
|
-
startLiveStream,
|
|
6485
|
-
stopLiveStream,
|
|
6486
|
-
|
|
7159
|
+
requestThumbnail,
|
|
7160
|
+
startLiveStream,
|
|
7161
|
+
stopLiveStream,
|
|
7162
|
+
pauseLiveCapture,
|
|
7163
|
+
resumeLiveCapture,
|
|
7164
|
+
startAudioStream,
|
|
6487
7165
|
stopAudioStream,
|
|
6488
7166
|
getDeviceLiveFrame,
|
|
6489
7167
|
getDeviceThumbnail,
|