@livedesk/hub 0.1.47 → 0.1.49
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 +2 -2
- package/src/agents/agent-tool-registry.js +34 -8
- package/src/filesystem/roots.js +20 -2
- package/src/remote-clipboard-contract.mjs +482 -0
- package/src/remote-hub.js +1145 -224
- package/src/server.js +142 -12
- package/src/settings/effective-device-policy.js +31 -9
package/src/remote-hub.js
CHANGED
|
@@ -3,10 +3,18 @@ import os from 'os';
|
|
|
3
3
|
import crypto from 'crypto';
|
|
4
4
|
import { createHubRelayControl } from './transport/relay-hub-control.js';
|
|
5
5
|
import { parseExactLiveStreamMonitorIndex } from './live-stream-monitor-contract.js';
|
|
6
|
-
import {
|
|
7
|
-
BoundedSegmentedBuffer,
|
|
8
|
-
createBoundedAgentBinaryIngressLane
|
|
9
|
-
} from './transport/agent-binary-ingress.js';
|
|
6
|
+
import {
|
|
7
|
+
BoundedSegmentedBuffer,
|
|
8
|
+
createBoundedAgentBinaryIngressLane
|
|
9
|
+
} from './transport/agent-binary-ingress.js';
|
|
10
|
+
import {
|
|
11
|
+
REMOTE_CLIPBOARD_FILE_ACTIONS,
|
|
12
|
+
REMOTE_CLIPBOARD_LIMITS,
|
|
13
|
+
REMOTE_CLIPBOARD_PROTOCOL,
|
|
14
|
+
normalizeRemoteClipboardCommandResult,
|
|
15
|
+
normalizeRemoteClipboardManifest,
|
|
16
|
+
remoteClipboardContentNeedsFileTransfer
|
|
17
|
+
} from './remote-clipboard-contract.mjs';
|
|
10
18
|
|
|
11
19
|
const DEFAULT_REMOTE_HUB_PORT = 5197;
|
|
12
20
|
const DEFAULT_REMOTE_HUB_HOST = '0.0.0.0';
|
|
@@ -228,12 +236,13 @@ const REMOTE_FRAME_MODE_ALIASES = new Map([
|
|
|
228
236
|
]);
|
|
229
237
|
const MAX_SYNTHETIC_DEVICES = 1000;
|
|
230
238
|
const DEFAULT_HOST_TARGET_LEASE_MS = 30000;
|
|
231
|
-
const DEFAULT_PUBLIC_IP_REFRESH_MS = 5 * 60 * 1000;
|
|
232
|
-
const DEFAULT_PUBLIC_IP_TIMEOUT_MS = 1800;
|
|
233
|
-
const
|
|
234
|
-
const
|
|
235
|
-
const
|
|
236
|
-
const
|
|
239
|
+
const DEFAULT_PUBLIC_IP_REFRESH_MS = 5 * 60 * 1000;
|
|
240
|
+
const DEFAULT_PUBLIC_IP_TIMEOUT_MS = 1800;
|
|
241
|
+
const DEFAULT_DEVICE_CONNECTION_STALE_MS = 20000;
|
|
242
|
+
const DEFAULT_DEVICE_CONNECTION_SWEEP_MS = 5000;
|
|
243
|
+
const DUPLICATE_DEVICE_LOG_THROTTLE_MS = 60000;
|
|
244
|
+
const DUPLICATE_DEVICE_RETRY_AFTER_MS = 30000;
|
|
245
|
+
const AGENT_BINARY_INGRESS_QUEUE_PACKETS = 16;
|
|
237
246
|
const AGENT_BINARY_INGRESS_QUEUE_MAX_BYTES = 12 * 1024 * 1024;
|
|
238
247
|
const AGENT_BINARY_INGRESS_QUEUE_MAX_AGE_MS = 120;
|
|
239
248
|
const MODE5_AGENT_BINARY_INGRESS_QUEUE_PACKETS = 2;
|
|
@@ -797,6 +806,25 @@ function normalizeIncomingFrameModeProfile(value) {
|
|
|
797
806
|
};
|
|
798
807
|
}
|
|
799
808
|
|
|
809
|
+
function deviceExplicitlyRejectsFrameMode(device, frameMode) {
|
|
810
|
+
const requestedMode = safeString(frameMode, 80).toLowerCase();
|
|
811
|
+
if (!requestedMode) {
|
|
812
|
+
return false;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const advertisedModes = [
|
|
816
|
+
...(Array.isArray(device?.frameModes) ? device.frameModes : []),
|
|
817
|
+
...(Array.isArray(device?.frameProtocol?.modes) ? device.frameProtocol.modes : []),
|
|
818
|
+
...(Array.isArray(device?.capabilities?.frameModes) ? device.capabilities.frameModes : []),
|
|
819
|
+
...(Array.isArray(device?.capabilities?.frameProtocol?.modes)
|
|
820
|
+
? device.capabilities.frameProtocol.modes
|
|
821
|
+
: [])
|
|
822
|
+
];
|
|
823
|
+
return advertisedModes.some(profile =>
|
|
824
|
+
safeString(profile?.mode || profile?.frameMode, 80).toLowerCase() === requestedMode
|
|
825
|
+
&& profile?.implemented === false);
|
|
826
|
+
}
|
|
827
|
+
|
|
800
828
|
function isLoopbackHost(value) {
|
|
801
829
|
const host = String(value || '').trim().toLowerCase();
|
|
802
830
|
return host === 'localhost'
|
|
@@ -1164,12 +1192,12 @@ function normalizeRemoteKeyboardKey(value) {
|
|
|
1164
1192
|
return raw === ' ' ? ' ' : safeString(raw, 80);
|
|
1165
1193
|
}
|
|
1166
1194
|
|
|
1167
|
-
function normalizeRemoteKeyboardText(value) {
|
|
1168
|
-
const raw = String(value ?? '').replace(/\0/g, '');
|
|
1169
|
-
// Remote text is input data, not a label. Preserve leading/trailing spaces
|
|
1170
|
-
// and IME whitespace while keeping every ordered message bounded.
|
|
1171
|
-
return raw.slice(0, 256);
|
|
1172
|
-
}
|
|
1195
|
+
function normalizeRemoteKeyboardText(value) {
|
|
1196
|
+
const raw = String(value ?? '').replace(/\0/g, '');
|
|
1197
|
+
// Remote text is input data, not a label. Preserve leading/trailing spaces
|
|
1198
|
+
// and IME whitespace while keeping every ordered message bounded.
|
|
1199
|
+
return raw.slice(0, 256);
|
|
1200
|
+
}
|
|
1173
1201
|
|
|
1174
1202
|
function safeTaskData(value) {
|
|
1175
1203
|
if (value === undefined || value === null) return undefined;
|
|
@@ -1216,9 +1244,11 @@ const SUPPORTED_AGENT_OPERATIONS = new Set([
|
|
|
1216
1244
|
'application.close',
|
|
1217
1245
|
'file.read',
|
|
1218
1246
|
'file.write',
|
|
1219
|
-
'file.delete',
|
|
1220
|
-
'file.list',
|
|
1221
|
-
'
|
|
1247
|
+
'file.delete',
|
|
1248
|
+
'file.list',
|
|
1249
|
+
'file.search',
|
|
1250
|
+
'directory.create',
|
|
1251
|
+
'command.run',
|
|
1222
1252
|
'script.run',
|
|
1223
1253
|
'software.install',
|
|
1224
1254
|
'network.status',
|
|
@@ -1250,7 +1280,7 @@ function readRawRemoteInputMonitorIndex(input) {
|
|
|
1250
1280
|
return undefined;
|
|
1251
1281
|
}
|
|
1252
1282
|
|
|
1253
|
-
function normalizeRemoteInputEvent(value = {}) {
|
|
1283
|
+
function normalizeRemoteInputEvent(value = {}) {
|
|
1254
1284
|
const input = value && typeof value === 'object' ? value : {};
|
|
1255
1285
|
const type = safeString(input.type || input.Type, 48);
|
|
1256
1286
|
const normalizedX = Number(input.normalizedX ?? input.NormalizedX);
|
|
@@ -1271,7 +1301,14 @@ function normalizeRemoteInputEvent(value = {}) {
|
|
|
1271
1301
|
.filter(Boolean))]
|
|
1272
1302
|
.slice(0, 64)
|
|
1273
1303
|
: null;
|
|
1274
|
-
const key = normalizeRemoteKeyboardKey(input.key ?? input.Key);
|
|
1304
|
+
const key = normalizeRemoteKeyboardKey(input.key ?? input.Key);
|
|
1305
|
+
const clipboardOperationId = safeString(
|
|
1306
|
+
input.clipboardOperationId || input.operationId || input.ClipboardOperationId || input.OperationId,
|
|
1307
|
+
128);
|
|
1308
|
+
const clipboardDirection = safeString(
|
|
1309
|
+
input.clipboardDirection || input.ClipboardDirection,
|
|
1310
|
+
16).toLowerCase();
|
|
1311
|
+
const clipboardManifest = input.clipboardManifest || input.manifest || input.ClipboardManifest || input.Manifest;
|
|
1275
1312
|
let code = safeString(input.code ?? input.Code, 80);
|
|
1276
1313
|
if ((!code || code === 'Unidentified') && (key === ' ' || key === 'Spacebar' || keyCode === 32)) {
|
|
1277
1314
|
code = 'Space';
|
|
@@ -1296,7 +1333,13 @@ function normalizeRemoteInputEvent(value = {}) {
|
|
|
1296
1333
|
shiftKey: input.shiftKey === true || input.ShiftKey === true,
|
|
1297
1334
|
ctrlKey: input.ctrlKey === true || input.ControlKey === true || input.CtrlKey === true,
|
|
1298
1335
|
altKey: input.altKey === true || input.AltKey === true,
|
|
1299
|
-
metaKey: input.metaKey === true || input.MetaKey === true || input.commandKey === true || input.CommandKey === true,
|
|
1336
|
+
metaKey: input.metaKey === true || input.MetaKey === true || input.commandKey === true || input.CommandKey === true,
|
|
1337
|
+
clipboardOperationId,
|
|
1338
|
+
operationId: clipboardOperationId,
|
|
1339
|
+
clipboardDirection,
|
|
1340
|
+
manifest: clipboardManifest && typeof clipboardManifest === 'object' && !Array.isArray(clipboardManifest)
|
|
1341
|
+
? clipboardManifest
|
|
1342
|
+
: null,
|
|
1300
1343
|
controlLeaseId: safeString(input.controlLeaseId || input.ControlLeaseId, 128),
|
|
1301
1344
|
controlSessionId: safeString(input.controlSessionId || input.sessionId || input.ControlSessionId || input.SessionId, 160),
|
|
1302
1345
|
controlCommandId: safeString(input.controlCommandId || input.commandId || input.ControlCommandId || input.CommandId, 128),
|
|
@@ -1718,6 +1761,26 @@ function writeJsonLine(socket, payload) {
|
|
|
1718
1761
|
return true;
|
|
1719
1762
|
}
|
|
1720
1763
|
|
|
1764
|
+
function writeJsonLineAndClose(socket, payload) {
|
|
1765
|
+
if (!socket || socket.destroyed) {
|
|
1766
|
+
return false;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
if (socket.__remoteHubWebSocket === true) {
|
|
1770
|
+
const written = writeJsonLine(socket, payload);
|
|
1771
|
+
socket.destroy();
|
|
1772
|
+
return written;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
try {
|
|
1776
|
+
socket.end(`${JSON.stringify(payload)}\n`);
|
|
1777
|
+
return true;
|
|
1778
|
+
} catch {
|
|
1779
|
+
socket.destroy();
|
|
1780
|
+
return false;
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1721
1784
|
function isRemoteHubWebSocketUpgradeStart(buffer) {
|
|
1722
1785
|
if (!Buffer.isBuffer(buffer) || buffer.length < 4) {
|
|
1723
1786
|
return false;
|
|
@@ -2268,12 +2331,29 @@ export function createRemoteHub(options = {}) {
|
|
|
2268
2331
|
|
|
2269
2332
|
const enabled = isEnabledValue(env.LIVEDESK_REMOTE_HUB ?? env.MINDEXEC_REMOTE_HUB ?? env.REMOTE_HUB_ENABLED, true)
|
|
2270
2333
|
&& !isDisabledValue(env.REMOTE_HUB_DISABLED);
|
|
2271
|
-
const host = safeString(env.REMOTE_HUB_HOST || DEFAULT_REMOTE_HUB_HOST, 128);
|
|
2272
|
-
const requestedPort = normalizePort(env.REMOTE_HUB_PORT || DEFAULT_REMOTE_HUB_PORT);
|
|
2273
|
-
const heartbeatMs = clampNumber(env.REMOTE_HUB_HEARTBEAT_MS, 1000, 60000, DEFAULT_HEARTBEAT_MS);
|
|
2274
|
-
const
|
|
2275
|
-
|
|
2276
|
-
|
|
2334
|
+
const host = safeString(env.REMOTE_HUB_HOST || DEFAULT_REMOTE_HUB_HOST, 128);
|
|
2335
|
+
const requestedPort = normalizePort(env.REMOTE_HUB_PORT || DEFAULT_REMOTE_HUB_PORT);
|
|
2336
|
+
const heartbeatMs = clampNumber(env.REMOTE_HUB_HEARTBEAT_MS, 1000, 60000, DEFAULT_HEARTBEAT_MS);
|
|
2337
|
+
const defaultDeviceConnectionStaleMs = Math.max(
|
|
2338
|
+
DEFAULT_DEVICE_CONNECTION_STALE_MS,
|
|
2339
|
+
heartbeatMs * 4);
|
|
2340
|
+
const deviceConnectionStaleMs = Number.isFinite(Number(options.deviceConnectionStaleMs))
|
|
2341
|
+
? clampNumber(options.deviceConnectionStaleMs, 100, 10 * 60 * 1000, defaultDeviceConnectionStaleMs)
|
|
2342
|
+
: clampNumber(
|
|
2343
|
+
env.REMOTE_HUB_DEVICE_CONNECTION_STALE_MS,
|
|
2344
|
+
defaultDeviceConnectionStaleMs,
|
|
2345
|
+
10 * 60 * 1000,
|
|
2346
|
+
defaultDeviceConnectionStaleMs);
|
|
2347
|
+
const deviceConnectionSweepMs = Number.isFinite(Number(options.deviceConnectionSweepMs))
|
|
2348
|
+
? clampNumber(options.deviceConnectionSweepMs, 25, 60000, DEFAULT_DEVICE_CONNECTION_SWEEP_MS)
|
|
2349
|
+
: clampNumber(
|
|
2350
|
+
env.REMOTE_HUB_DEVICE_CONNECTION_SWEEP_MS,
|
|
2351
|
+
1000,
|
|
2352
|
+
60000,
|
|
2353
|
+
Math.min(DEFAULT_DEVICE_CONNECTION_SWEEP_MS, heartbeatMs));
|
|
2354
|
+
const taskTimeoutMs = clampNumber(
|
|
2355
|
+
options.taskTimeoutMs ?? env.REMOTE_HUB_TASK_TIMEOUT_MS,
|
|
2356
|
+
50,
|
|
2277
2357
|
30 * 60 * 1000,
|
|
2278
2358
|
DEFAULT_AGENT_TASK_TIMEOUT_MS);
|
|
2279
2359
|
const inputAckTimeoutMs = clampNumber(
|
|
@@ -2336,16 +2416,18 @@ export function createRemoteHub(options = {}) {
|
|
|
2336
2416
|
const audioSockets = new Map();
|
|
2337
2417
|
const fileSockets = new Map();
|
|
2338
2418
|
const allSockets = new Set();
|
|
2339
|
-
const agentBinaryIngressStates = new Set();
|
|
2340
|
-
const pendingCommandResultWaiters = new Map();
|
|
2419
|
+
const agentBinaryIngressStates = new Set();
|
|
2420
|
+
const pendingCommandResultWaiters = new Map();
|
|
2421
|
+
const clipboardOperations = new Map();
|
|
2341
2422
|
const transportDiagnostics = new Map();
|
|
2342
2423
|
const transportDiagnosticStartingDevices = new Set();
|
|
2343
|
-
const duplicateDeviceLogAt = new Map();
|
|
2344
|
-
let server = null;
|
|
2345
|
-
let started = false;
|
|
2346
|
-
let
|
|
2347
|
-
let
|
|
2348
|
-
let
|
|
2424
|
+
const duplicateDeviceLogAt = new Map();
|
|
2425
|
+
let server = null;
|
|
2426
|
+
let started = false;
|
|
2427
|
+
let deviceConnectionSweepTimer = null;
|
|
2428
|
+
let boundPort = requestedPort;
|
|
2429
|
+
let lastError = '';
|
|
2430
|
+
let hostTarget = null;
|
|
2349
2431
|
let publicIPv4Cache = {
|
|
2350
2432
|
address: safeString(env.LIVEDESK_REMOTE_PUBLIC_IPV4 || env.LIVEDESK_PUBLIC_IPV4 || env.REMOTE_HUB_PUBLIC_IPV4, 64),
|
|
2351
2433
|
updatedAt: 0,
|
|
@@ -2666,12 +2748,14 @@ export function createRemoteHub(options = {}) {
|
|
|
2666
2748
|
protocol: 'tcp-jsonl',
|
|
2667
2749
|
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
2668
2750
|
agentProtocol: REMOTE_AGENT_PROTOCOL,
|
|
2669
|
-
frameProtocol: buildRemoteFrameProtocolDescriptor(),
|
|
2670
|
-
frameModes: getSupportedRemoteFrameModeProfiles(),
|
|
2671
|
-
heartbeatMs,
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2751
|
+
frameProtocol: buildRemoteFrameProtocolDescriptor(),
|
|
2752
|
+
frameModes: getSupportedRemoteFrameModeProfiles(),
|
|
2753
|
+
heartbeatMs,
|
|
2754
|
+
deviceConnectionStaleMs,
|
|
2755
|
+
deviceConnectionSweepMs,
|
|
2756
|
+
taskTimeoutMs,
|
|
2757
|
+
managerPackage,
|
|
2758
|
+
managerVersion,
|
|
2675
2759
|
hostInstanceId,
|
|
2676
2760
|
agentPackage: '@livedesk/client',
|
|
2677
2761
|
agentEndpoint: routeInfo.endpoint,
|
|
@@ -2934,7 +3018,7 @@ export function createRemoteHub(options = {}) {
|
|
|
2934
3018
|
return true;
|
|
2935
3019
|
}
|
|
2936
3020
|
|
|
2937
|
-
function failPendingCommandResultWaiters(device, error = 'device-disconnected') {
|
|
3021
|
+
function failPendingCommandResultWaiters(device, error = 'device-disconnected') {
|
|
2938
3022
|
if (!device?.deviceId) {
|
|
2939
3023
|
return;
|
|
2940
3024
|
}
|
|
@@ -2951,10 +3035,31 @@ export function createRemoteHub(options = {}) {
|
|
|
2951
3035
|
result: null,
|
|
2952
3036
|
error: safeString(error, 500) || 'command-result-unavailable'
|
|
2953
3037
|
});
|
|
2954
|
-
}
|
|
2955
|
-
}
|
|
2956
|
-
|
|
2957
|
-
function
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
function failPendingFileCommandResultWaiters(device, error = 'file-channel-disconnected') {
|
|
3042
|
+
if (!device?.deviceId) {
|
|
3043
|
+
return;
|
|
3044
|
+
}
|
|
3045
|
+
for (const [waiterKey, waiter] of pendingCommandResultWaiters) {
|
|
3046
|
+
if (waiter.deviceId !== device.deviceId
|
|
3047
|
+
|| waiter.sessionId !== device.sessionId
|
|
3048
|
+
|| waiter.channel !== 'file') {
|
|
3049
|
+
continue;
|
|
3050
|
+
}
|
|
3051
|
+
pendingCommandResultWaiters.delete(waiterKey);
|
|
3052
|
+
clearPendingCommandResultWaiterDeadline(waiter);
|
|
3053
|
+
waiter.resolve({
|
|
3054
|
+
ok: false,
|
|
3055
|
+
commandId: waiter.commandId,
|
|
3056
|
+
result: null,
|
|
3057
|
+
error: safeString(error, 500) || 'file-channel-disconnected'
|
|
3058
|
+
});
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
function failPendingCommandResultWaiter(device, commandId, error = 'command-not-sent') {
|
|
2958
3063
|
const normalizedCommandId = safeString(commandId, 128);
|
|
2959
3064
|
const waiterKey = buildPendingCommandResultWaiterKey(
|
|
2960
3065
|
device?.deviceId,
|
|
@@ -2975,7 +3080,7 @@ export function createRemoteHub(options = {}) {
|
|
|
2975
3080
|
return true;
|
|
2976
3081
|
}
|
|
2977
3082
|
|
|
2978
|
-
function waitForCommandResult(device, commandId, timeoutMs = liveCaptureStopAckTimeoutMs) {
|
|
3083
|
+
function waitForCommandResult(device, commandId, timeoutMs = liveCaptureStopAckTimeoutMs, metadata = {}) {
|
|
2979
3084
|
const normalizedCommandId = safeString(commandId, 128);
|
|
2980
3085
|
const waiterKey = buildPendingCommandResultWaiterKey(
|
|
2981
3086
|
device?.deviceId,
|
|
@@ -3001,11 +3106,13 @@ export function createRemoteHub(options = {}) {
|
|
|
3001
3106
|
error: 'command-result-wait-replaced'
|
|
3002
3107
|
});
|
|
3003
3108
|
}
|
|
3004
|
-
const waiterRecord = {
|
|
3005
|
-
deviceId: device.deviceId,
|
|
3006
|
-
sessionId: device.sessionId,
|
|
3007
|
-
commandId: normalizedCommandId,
|
|
3008
|
-
|
|
3109
|
+
const waiterRecord = {
|
|
3110
|
+
deviceId: device.deviceId,
|
|
3111
|
+
sessionId: device.sessionId,
|
|
3112
|
+
commandId: normalizedCommandId,
|
|
3113
|
+
channel: safeString(metadata.channel, 24),
|
|
3114
|
+
operationId: safeString(metadata.operationId, 128),
|
|
3115
|
+
timer: null,
|
|
3009
3116
|
deadlineImmediate: null,
|
|
3010
3117
|
resolve
|
|
3011
3118
|
};
|
|
@@ -3038,9 +3145,246 @@ export function createRemoteHub(options = {}) {
|
|
|
3038
3145
|
});
|
|
3039
3146
|
}, timeoutMs);
|
|
3040
3147
|
waiterRecord.timer.unref?.();
|
|
3041
|
-
pendingCommandResultWaiters.set(waiterKey, waiterRecord);
|
|
3042
|
-
});
|
|
3043
|
-
}
|
|
3148
|
+
pendingCommandResultWaiters.set(waiterKey, waiterRecord);
|
|
3149
|
+
});
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
function buildClipboardOperationKey(deviceId, sessionId, operationId) {
|
|
3153
|
+
const normalizedDeviceId = safeString(deviceId, 160);
|
|
3154
|
+
const normalizedSessionId = safeString(sessionId, 160);
|
|
3155
|
+
const normalizedOperationId = safeString(operationId, 128);
|
|
3156
|
+
return normalizedDeviceId && normalizedSessionId && normalizedOperationId
|
|
3157
|
+
? `${normalizedDeviceId.length}:${normalizedDeviceId}`
|
|
3158
|
+
+ `${normalizedSessionId.length}:${normalizedSessionId}`
|
|
3159
|
+
+ `${normalizedOperationId.length}:${normalizedOperationId}`
|
|
3160
|
+
: '';
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
function queueClipboardAgentCleanup(device, operation, reason) {
|
|
3164
|
+
if (operation?.inFlight || !device?.fileSocket || device.fileSocket.destroyed) {
|
|
3165
|
+
return false;
|
|
3166
|
+
}
|
|
3167
|
+
const cleanupCommand = operation.direction === 'paste'
|
|
3168
|
+
? 'clipboard.paste.cancel'
|
|
3169
|
+
: 'clipboard.copy.end';
|
|
3170
|
+
const commandId = crypto.randomUUID();
|
|
3171
|
+
try {
|
|
3172
|
+
writeJsonLine(device.fileSocket, {
|
|
3173
|
+
type: 'command',
|
|
3174
|
+
commandId,
|
|
3175
|
+
command: cleanupCommand,
|
|
3176
|
+
payload: {
|
|
3177
|
+
operationId: operation.operationId,
|
|
3178
|
+
reason: safeString(reason, 120) || 'clipboard-owner-closed'
|
|
3179
|
+
},
|
|
3180
|
+
issuedAt: new Date().toISOString()
|
|
3181
|
+
});
|
|
3182
|
+
device.counters.commandsSent += 1;
|
|
3183
|
+
emitRemoteEvent('RemoteCommandQueued', device, {
|
|
3184
|
+
commandId,
|
|
3185
|
+
command: cleanupCommand,
|
|
3186
|
+
channel: 'file',
|
|
3187
|
+
operationId: operation.operationId,
|
|
3188
|
+
cleanup: true
|
|
3189
|
+
});
|
|
3190
|
+
return true;
|
|
3191
|
+
} catch {
|
|
3192
|
+
// The exact side channel is already closing. Native session
|
|
3193
|
+
// teardown remains the authoritative cleanup.
|
|
3194
|
+
return false;
|
|
3195
|
+
}
|
|
3196
|
+
}
|
|
3197
|
+
|
|
3198
|
+
function clearClipboardOperationsForDevice(device, reason = 'clipboard-owner-closed', ownerConnectionId = '') {
|
|
3199
|
+
const requestedOwner = safeString(ownerConnectionId, 128);
|
|
3200
|
+
let cleared = 0;
|
|
3201
|
+
for (const [key, operation] of clipboardOperations) {
|
|
3202
|
+
if (operation.deviceId !== device?.deviceId
|
|
3203
|
+
|| operation.sessionId !== device?.sessionId
|
|
3204
|
+
|| (requestedOwner && operation.hubConnectionId !== requestedOwner)) {
|
|
3205
|
+
continue;
|
|
3206
|
+
}
|
|
3207
|
+
queueClipboardAgentCleanup(device, operation, reason);
|
|
3208
|
+
clipboardOperations.delete(key);
|
|
3209
|
+
cleared += 1;
|
|
3210
|
+
}
|
|
3211
|
+
if (cleared > 0) {
|
|
3212
|
+
emitRemoteEvent('RemoteClipboardOperationsCleared', device, {
|
|
3213
|
+
reason,
|
|
3214
|
+
ownerConnectionId: requestedOwner,
|
|
3215
|
+
count: cleared
|
|
3216
|
+
});
|
|
3217
|
+
}
|
|
3218
|
+
return cleared;
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
function pruneClipboardOperations(nowMs = Date.now()) {
|
|
3222
|
+
for (const [key, operation] of clipboardOperations) {
|
|
3223
|
+
const currentDevice = devices.get(operation.deviceId);
|
|
3224
|
+
const expired = nowMs - Number(operation.touchedAt || 0)
|
|
3225
|
+
> REMOTE_CLIPBOARD_LIMITS.operationIdleTimeoutMs;
|
|
3226
|
+
const staleOwner = !currentDevice
|
|
3227
|
+
|| currentDevice.sessionId !== operation.sessionId
|
|
3228
|
+
|| safeString(currentDevice.inputOwnerConnectionId, 128) !== operation.hubConnectionId;
|
|
3229
|
+
if (expired || staleOwner) {
|
|
3230
|
+
if (currentDevice?.sessionId === operation.sessionId) {
|
|
3231
|
+
queueClipboardAgentCleanup(
|
|
3232
|
+
currentDevice,
|
|
3233
|
+
operation,
|
|
3234
|
+
expired ? 'clipboard-operation-expired' : 'clipboard-owner-stale');
|
|
3235
|
+
}
|
|
3236
|
+
clipboardOperations.delete(key);
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
function clipboardCapabilityError(device, direction, contentKind = 'text') {
|
|
3242
|
+
const capabilities = device?.capabilities || {};
|
|
3243
|
+
if (capabilities.clipboardProtocol !== REMOTE_CLIPBOARD_PROTOCOL
|
|
3244
|
+
|| capabilities.clipboardText !== true) {
|
|
3245
|
+
return 'device-clipboard-text-unavailable';
|
|
3246
|
+
}
|
|
3247
|
+
if (capabilities.sessionBoundSideChannels !== true) {
|
|
3248
|
+
return 'device-clipboard-session-bound-channel-unavailable';
|
|
3249
|
+
}
|
|
3250
|
+
if (direction === 'copy' && capabilities.clipboardCopy !== true) {
|
|
3251
|
+
return 'device-clipboard-copy-unavailable';
|
|
3252
|
+
}
|
|
3253
|
+
if (direction === 'paste' && capabilities.clipboardPaste !== true) {
|
|
3254
|
+
return 'device-clipboard-paste-unavailable';
|
|
3255
|
+
}
|
|
3256
|
+
if (contentKind === 'image' && capabilities.clipboardImage !== true) {
|
|
3257
|
+
return 'device-clipboard-image-unavailable';
|
|
3258
|
+
}
|
|
3259
|
+
if (contentKind === 'files' && capabilities.clipboardFiles !== true) {
|
|
3260
|
+
return 'device-clipboard-files-unavailable';
|
|
3261
|
+
}
|
|
3262
|
+
for (const key of ['clipboardMaxTextBytes', 'clipboardMaxTotalBytes', 'clipboardMaxChunkBytes']) {
|
|
3263
|
+
if (!Number.isSafeInteger(Number(capabilities[key])) || Number(capabilities[key]) <= 0) {
|
|
3264
|
+
return 'device-clipboard-limits-unavailable';
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
if (Number(capabilities.clipboardMaxChunkBytes) < REMOTE_CLIPBOARD_LIMITS.maxChunkBytes) {
|
|
3268
|
+
return 'device-clipboard-chunk-limit-unavailable';
|
|
3269
|
+
}
|
|
3270
|
+
if (contentKind === 'image'
|
|
3271
|
+
&& (!Number.isSafeInteger(Number(
|
|
3272
|
+
capabilities.clipboardMaxImageBytes ?? capabilities.clipboardMaxPngBytes))
|
|
3273
|
+
|| Number(capabilities.clipboardMaxImageBytes ?? capabilities.clipboardMaxPngBytes) <= 0)) {
|
|
3274
|
+
return 'device-clipboard-limits-unavailable';
|
|
3275
|
+
}
|
|
3276
|
+
if (contentKind === 'files'
|
|
3277
|
+
&& (!Number.isSafeInteger(Number(capabilities.clipboardMaxFiles))
|
|
3278
|
+
|| Number(capabilities.clipboardMaxFiles) <= 0)) {
|
|
3279
|
+
return 'device-clipboard-limits-unavailable';
|
|
3280
|
+
}
|
|
3281
|
+
return '';
|
|
3282
|
+
}
|
|
3283
|
+
|
|
3284
|
+
function clipboardManifestCapabilityError(device, manifest) {
|
|
3285
|
+
if (!manifest) {
|
|
3286
|
+
return '';
|
|
3287
|
+
}
|
|
3288
|
+
const capabilities = device?.capabilities || {};
|
|
3289
|
+
if (manifest.totalBytes > Number(capabilities.clipboardMaxTotalBytes || 0)) {
|
|
3290
|
+
return 'device-clipboard-total-limit-exceeded';
|
|
3291
|
+
}
|
|
3292
|
+
if (manifest.contentKind === 'text'
|
|
3293
|
+
&& manifest.totalBytes > Number(capabilities.clipboardMaxTextBytes || 0)) {
|
|
3294
|
+
return 'device-clipboard-text-limit-exceeded';
|
|
3295
|
+
}
|
|
3296
|
+
if (manifest.contentKind === 'image'
|
|
3297
|
+
&& manifest.totalBytes > Number(
|
|
3298
|
+
capabilities.clipboardMaxImageBytes ?? capabilities.clipboardMaxPngBytes ?? 0)) {
|
|
3299
|
+
return 'device-clipboard-image-limit-exceeded';
|
|
3300
|
+
}
|
|
3301
|
+
if (manifest.contentKind === 'files'
|
|
3302
|
+
&& manifest.items.length > Number(capabilities.clipboardMaxFiles || 0)) {
|
|
3303
|
+
return 'device-clipboard-file-count-limit-exceeded';
|
|
3304
|
+
}
|
|
3305
|
+
return '';
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
function validateCurrentClipboardBinding(deviceId, request = {}, contentKind = 'text', options = {}) {
|
|
3309
|
+
const device = devices.get(String(deviceId || ''));
|
|
3310
|
+
if (!device) {
|
|
3311
|
+
return { ok: false, error: 'device-not-found' };
|
|
3312
|
+
}
|
|
3313
|
+
if (device.synthetic === true) {
|
|
3314
|
+
return { ok: false, error: 'device-clipboard-unavailable' };
|
|
3315
|
+
}
|
|
3316
|
+
if (device.connected && !isDeviceConnectionFresh(device)) {
|
|
3317
|
+
retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
|
|
3318
|
+
return { ok: false, error: 'device-not-connected' };
|
|
3319
|
+
}
|
|
3320
|
+
if (!device.connected || !device.socket || device.socket.destroyed) {
|
|
3321
|
+
return { ok: false, error: 'device-not-connected' };
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
const policy = getDevicePolicy(device);
|
|
3325
|
+
if (policy.accessMode === 'block-remote-access') {
|
|
3326
|
+
return { ok: false, error: 'remote-access-blocked-by-settings' };
|
|
3327
|
+
}
|
|
3328
|
+
if (policy.allowControl !== true) {
|
|
3329
|
+
return { ok: false, error: 'allowControl-blocked-by-settings' };
|
|
3330
|
+
}
|
|
3331
|
+
if (policy.allowClipboardText !== true) {
|
|
3332
|
+
return { ok: false, error: 'allowClipboardText-blocked-by-settings' };
|
|
3333
|
+
}
|
|
3334
|
+
if (remoteClipboardContentNeedsFileTransfer(contentKind)
|
|
3335
|
+
&& policy.allowFileTransfer !== true) {
|
|
3336
|
+
return { ok: false, error: 'allowFileTransfer-blocked-by-settings' };
|
|
3337
|
+
}
|
|
3338
|
+
|
|
3339
|
+
const direction = request.clipboardDirection
|
|
3340
|
+
|| (String(request.action || '').includes('.paste') ? 'paste' : 'copy');
|
|
3341
|
+
const capabilityError = clipboardCapabilityError(device, direction, contentKind);
|
|
3342
|
+
if (capabilityError) {
|
|
3343
|
+
return { ok: false, error: capabilityError };
|
|
3344
|
+
}
|
|
3345
|
+
const manifestCapabilityError = clipboardManifestCapabilityError(
|
|
3346
|
+
device,
|
|
3347
|
+
request.payload?.manifest || request.manifest);
|
|
3348
|
+
if (manifestCapabilityError) {
|
|
3349
|
+
return { ok: false, error: manifestCapabilityError };
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3352
|
+
const controlStream = getActiveControlStream(device);
|
|
3353
|
+
if (!controlStream
|
|
3354
|
+
|| !liveStreamHasCurrentFrame(controlStream)
|
|
3355
|
+
|| !safeString(controlStream.commandId, 128)
|
|
3356
|
+
|| Number(controlStream.captureGeneration || 0) <= 0) {
|
|
3357
|
+
return { ok: false, error: 'CONTROL_NOT_READY' };
|
|
3358
|
+
}
|
|
3359
|
+
if (getPendingLiveStreamDescriptor(controlStream)) {
|
|
3360
|
+
return { ok: false, error: 'CAPTURE_TRANSITION_IN_PROGRESS' };
|
|
3361
|
+
}
|
|
3362
|
+
const binding = request.binding || request;
|
|
3363
|
+
if (safeString(binding.controlSessionId || binding.sessionId, 160)
|
|
3364
|
+
!== safeString(device.sessionId, 160)
|
|
3365
|
+
|| safeString(binding.controlCommandId || binding.commandId, 128)
|
|
3366
|
+
!== safeString(controlStream.commandId, 128)) {
|
|
3367
|
+
return { ok: false, error: 'STALE_CONTROL_SESSION' };
|
|
3368
|
+
}
|
|
3369
|
+
if (Number(binding.captureGeneration || 0)
|
|
3370
|
+
!== Number(controlStream.captureGeneration || 0)) {
|
|
3371
|
+
return { ok: false, error: 'STALE_CAPTURE_GENERATION' };
|
|
3372
|
+
}
|
|
3373
|
+
const activeMonitorIndex = normalizeMonitorIndex(controlStream.monitorIndex);
|
|
3374
|
+
if (Number(binding.monitorIndex) !== activeMonitorIndex) {
|
|
3375
|
+
return { ok: false, error: 'STALE_CONTROL_MONITOR' };
|
|
3376
|
+
}
|
|
3377
|
+
const hubConnectionId = safeString(request.hubConnectionId, 128);
|
|
3378
|
+
if (!hubConnectionId
|
|
3379
|
+
|| hubConnectionId !== safeString(device.inputOwnerConnectionId, 128)) {
|
|
3380
|
+
return { ok: false, error: 'input-owner-not-current' };
|
|
3381
|
+
}
|
|
3382
|
+
if (options.requireFileSocket === true
|
|
3383
|
+
&& (!device.fileSocket || device.fileSocket.destroyed)) {
|
|
3384
|
+
return { ok: false, error: 'clipboard-file-channel-unavailable' };
|
|
3385
|
+
}
|
|
3386
|
+
return { ok: true, device, controlStream, activeMonitorIndex, policy };
|
|
3387
|
+
}
|
|
3044
3388
|
|
|
3045
3389
|
function inspectAnnexBH264(payload) {
|
|
3046
3390
|
if (!Buffer.isBuffer(payload) || payload.length < 5) {
|
|
@@ -3922,13 +4266,15 @@ export function createRemoteHub(options = {}) {
|
|
|
3922
4266
|
}
|
|
3923
4267
|
}
|
|
3924
4268
|
|
|
3925
|
-
function closeFileSocket(device, reason = 'file-socket-closed') {
|
|
3926
|
-
const socket = device?.fileSocket;
|
|
3927
|
-
if (!socket) {
|
|
3928
|
-
return;
|
|
3929
|
-
}
|
|
3930
|
-
|
|
3931
|
-
device
|
|
4269
|
+
function closeFileSocket(device, reason = 'file-socket-closed') {
|
|
4270
|
+
const socket = device?.fileSocket;
|
|
4271
|
+
if (!socket) {
|
|
4272
|
+
return;
|
|
4273
|
+
}
|
|
4274
|
+
|
|
4275
|
+
clearClipboardOperationsForDevice(device, reason);
|
|
4276
|
+
failPendingFileCommandResultWaiters(device, 'clipboard-file-channel-disconnected');
|
|
4277
|
+
device.fileSocket = null;
|
|
3932
4278
|
fileSockets.delete(socket);
|
|
3933
4279
|
try {
|
|
3934
4280
|
writeJsonLine(socket, { type: 'disconnect', channel: 'file', reason });
|
|
@@ -3942,13 +4288,14 @@ export function createRemoteHub(options = {}) {
|
|
|
3942
4288
|
}
|
|
3943
4289
|
}
|
|
3944
4290
|
|
|
3945
|
-
function closeInputSocket(device, reason = 'input-socket-closed') {
|
|
4291
|
+
function closeInputSocket(device, reason = 'input-socket-closed') {
|
|
3946
4292
|
const socket = device?.inputSocket;
|
|
3947
4293
|
if (!socket) {
|
|
3948
4294
|
return;
|
|
3949
4295
|
}
|
|
3950
4296
|
|
|
3951
|
-
const writeOwner = device.inputWriteOwner;
|
|
4297
|
+
const writeOwner = device.inputWriteOwner;
|
|
4298
|
+
clearClipboardOperationsForDevice(device, reason);
|
|
3952
4299
|
device.inputSocket = null;
|
|
3953
4300
|
device.inputWriteOwner = null;
|
|
3954
4301
|
device.inputSocketConnectionId = '';
|
|
@@ -3968,7 +4315,7 @@ export function createRemoteHub(options = {}) {
|
|
|
3968
4315
|
}
|
|
3969
4316
|
}
|
|
3970
4317
|
|
|
3971
|
-
function detachInputSocket(socket, reason = 'input-socket-closed') {
|
|
4318
|
+
function detachInputSocket(socket, reason = 'input-socket-closed') {
|
|
3972
4319
|
const deviceId = inputSockets.get(socket);
|
|
3973
4320
|
inputSockets.delete(socket);
|
|
3974
4321
|
if (!deviceId) {
|
|
@@ -3980,7 +4327,8 @@ export function createRemoteHub(options = {}) {
|
|
|
3980
4327
|
return;
|
|
3981
4328
|
}
|
|
3982
4329
|
|
|
3983
|
-
const writeOwner = device.inputWriteOwner;
|
|
4330
|
+
const writeOwner = device.inputWriteOwner;
|
|
4331
|
+
clearClipboardOperationsForDevice(device, reason);
|
|
3984
4332
|
device.inputSocket = null;
|
|
3985
4333
|
device.inputWriteOwner = null;
|
|
3986
4334
|
device.inputSocketConnectionId = '';
|
|
@@ -4025,7 +4373,7 @@ export function createRemoteHub(options = {}) {
|
|
|
4025
4373
|
emitRemoteEvent('RemoteAudioSocketDisconnected', device, { reason });
|
|
4026
4374
|
}
|
|
4027
4375
|
|
|
4028
|
-
function detachFileSocket(socket, reason = 'file-socket-closed') {
|
|
4376
|
+
function detachFileSocket(socket, reason = 'file-socket-closed') {
|
|
4029
4377
|
const deviceId = fileSockets.get(socket);
|
|
4030
4378
|
fileSockets.delete(socket);
|
|
4031
4379
|
if (!deviceId) {
|
|
@@ -4033,38 +4381,107 @@ export function createRemoteHub(options = {}) {
|
|
|
4033
4381
|
}
|
|
4034
4382
|
|
|
4035
4383
|
const device = devices.get(deviceId);
|
|
4036
|
-
if (!device || device.fileSocket !== socket) {
|
|
4037
|
-
return;
|
|
4038
|
-
}
|
|
4039
|
-
|
|
4040
|
-
device
|
|
4384
|
+
if (!device || device.fileSocket !== socket) {
|
|
4385
|
+
return;
|
|
4386
|
+
}
|
|
4387
|
+
|
|
4388
|
+
clearClipboardOperationsForDevice(device, reason);
|
|
4389
|
+
failPendingFileCommandResultWaiters(device, 'clipboard-file-channel-disconnected');
|
|
4390
|
+
device.fileSocket = null;
|
|
4041
4391
|
device.fileLastSeenAt = new Date().toISOString();
|
|
4042
|
-
emitRemoteEvent('RemoteFileSocketDisconnected', device, { reason });
|
|
4043
|
-
}
|
|
4044
|
-
|
|
4045
|
-
function
|
|
4046
|
-
return
|
|
4047
|
-
Date.parse(device?.
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
function isDeviceConnectionFresh(device, nowMs = Date.now()) {
|
|
4392
|
+
emitRemoteEvent('RemoteFileSocketDisconnected', device, { reason });
|
|
4393
|
+
}
|
|
4394
|
+
|
|
4395
|
+
function getDeviceConnectionHeartbeatMs(device) {
|
|
4396
|
+
return Date.parse(device?.lastStatusAt || '')
|
|
4397
|
+
|| Date.parse(device?.connectedAt || '')
|
|
4398
|
+
|| 0;
|
|
4399
|
+
}
|
|
4400
|
+
|
|
4401
|
+
function isDeviceConnectionFresh(device, nowMs = Date.now()) {
|
|
4054
4402
|
if (!device?.connected || !device.socket || device.socket.destroyed) {
|
|
4055
|
-
return false;
|
|
4056
|
-
}
|
|
4057
|
-
|
|
4058
|
-
const
|
|
4059
|
-
if (!
|
|
4060
|
-
return true;
|
|
4061
|
-
}
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4403
|
+
return false;
|
|
4404
|
+
}
|
|
4405
|
+
|
|
4406
|
+
const lastHeartbeatMs = getDeviceConnectionHeartbeatMs(device);
|
|
4407
|
+
if (!lastHeartbeatMs) {
|
|
4408
|
+
return true;
|
|
4409
|
+
}
|
|
4410
|
+
|
|
4411
|
+
return nowMs - lastHeartbeatMs <= deviceConnectionStaleMs;
|
|
4412
|
+
}
|
|
4413
|
+
|
|
4414
|
+
function retireStaleDeviceConnection(device, reason = 'status-heartbeat-timeout') {
|
|
4415
|
+
const deviceId = safeString(device?.deviceId, 160);
|
|
4416
|
+
const sessionId = safeString(device?.sessionId, 160);
|
|
4417
|
+
const socket = device?.socket;
|
|
4418
|
+
if (!deviceId
|
|
4419
|
+
|| !sessionId
|
|
4420
|
+
|| !socket
|
|
4421
|
+
|| socket.destroyed
|
|
4422
|
+
|| devices.get(deviceId) !== device
|
|
4423
|
+
|| device.socket !== socket
|
|
4424
|
+
|| device.sessionId !== sessionId) {
|
|
4425
|
+
return false;
|
|
4426
|
+
}
|
|
4427
|
+
|
|
4428
|
+
closeInputSocket(device, reason);
|
|
4429
|
+
closeFrameSocket(device, reason);
|
|
4430
|
+
closeAudioSocket(device, reason);
|
|
4431
|
+
closeFileSocket(device, reason);
|
|
4432
|
+
try {
|
|
4433
|
+
writeJsonLine(socket, { type: 'disconnect', reason, sessionId });
|
|
4434
|
+
} catch {
|
|
4435
|
+
// The stale connection is retired below even if the final notice fails.
|
|
4436
|
+
}
|
|
4437
|
+
detachSocket(socket, reason);
|
|
4438
|
+
try {
|
|
4439
|
+
socket.destroy?.();
|
|
4440
|
+
} catch {
|
|
4441
|
+
// detachSocket already made the exact session terminal.
|
|
4442
|
+
}
|
|
4443
|
+
return true;
|
|
4444
|
+
}
|
|
4445
|
+
|
|
4446
|
+
function sweepStaleDeviceConnections(nowMs = Date.now()) {
|
|
4447
|
+
pruneClipboardOperations(nowMs);
|
|
4448
|
+
let retired = 0;
|
|
4449
|
+
for (const device of devices.values()) {
|
|
4450
|
+
if (device?.synthetic === true || isDeviceConnectionFresh(device, nowMs)) {
|
|
4451
|
+
continue;
|
|
4452
|
+
}
|
|
4453
|
+
if (!device?.connected || !device.socket || device.socket.destroyed) {
|
|
4454
|
+
continue;
|
|
4455
|
+
}
|
|
4456
|
+
const ageMs = Math.max(0, nowMs - getDeviceConnectionHeartbeatMs(device));
|
|
4457
|
+
if (retireStaleDeviceConnection(device, 'status-heartbeat-timeout')) {
|
|
4458
|
+
retired += 1;
|
|
4459
|
+
logWarn(
|
|
4460
|
+
'remote',
|
|
4461
|
+
`retired stale device session ${device.deviceName} (${device.deviceId}) `
|
|
4462
|
+
+ `session=${device.sessionId} heartbeatAgeMs=${ageMs} `
|
|
4463
|
+
+ `staleAfterMs=${deviceConnectionStaleMs}`);
|
|
4464
|
+
}
|
|
4465
|
+
}
|
|
4466
|
+
return retired;
|
|
4467
|
+
}
|
|
4468
|
+
|
|
4469
|
+
function scheduleDeviceConnectionSweep() {
|
|
4470
|
+
if (!started || deviceConnectionSweepTimer) {
|
|
4471
|
+
return;
|
|
4472
|
+
}
|
|
4473
|
+
deviceConnectionSweepTimer = setTimeout(() => {
|
|
4474
|
+
deviceConnectionSweepTimer = null;
|
|
4475
|
+
if (!started) {
|
|
4476
|
+
return;
|
|
4477
|
+
}
|
|
4478
|
+
sweepStaleDeviceConnections();
|
|
4479
|
+
scheduleDeviceConnectionSweep();
|
|
4480
|
+
}, deviceConnectionSweepMs);
|
|
4481
|
+
deviceConnectionSweepTimer.unref?.();
|
|
4482
|
+
}
|
|
4483
|
+
|
|
4484
|
+
function rejectDuplicateActiveDevice(socket, existing, attemptedSessionId) {
|
|
4068
4485
|
const retryAfterMs = Math.max(heartbeatMs, duplicateDeviceRetryAfterMs);
|
|
4069
4486
|
writeJsonLine(socket, {
|
|
4070
4487
|
type: 'disconnect',
|
|
@@ -4516,16 +4933,20 @@ export function createRemoteHub(options = {}) {
|
|
|
4516
4933
|
return device;
|
|
4517
4934
|
}
|
|
4518
4935
|
|
|
4519
|
-
function attachInputSocket(socket, hello) {
|
|
4520
|
-
const deviceId = normalizeDeviceId(hello.deviceId);
|
|
4521
|
-
const device = devices.get(deviceId);
|
|
4936
|
+
function attachInputSocket(socket, hello) {
|
|
4937
|
+
const deviceId = normalizeDeviceId(hello.deviceId);
|
|
4938
|
+
const device = devices.get(deviceId);
|
|
4522
4939
|
if (!device || !device.connected || !device.socket || device.socket.destroyed) {
|
|
4523
4940
|
writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
|
|
4524
4941
|
socket.destroy();
|
|
4525
|
-
return null;
|
|
4526
|
-
}
|
|
4527
|
-
|
|
4528
|
-
|
|
4942
|
+
return null;
|
|
4943
|
+
}
|
|
4944
|
+
if (safeString(hello.parentSessionId, 160) !== safeString(device.sessionId, 160)) {
|
|
4945
|
+
writeJsonLineAndClose(socket, { type: 'error', error: 'side-channel-parent-session-stale' });
|
|
4946
|
+
return null;
|
|
4947
|
+
}
|
|
4948
|
+
|
|
4949
|
+
closeInputSocket(device, 'replaced-by-new-input-socket');
|
|
4529
4950
|
|
|
4530
4951
|
const now = new Date().toISOString();
|
|
4531
4952
|
const inputSocketConnectionId = crypto.randomUUID();
|
|
@@ -4639,16 +5060,20 @@ export function createRemoteHub(options = {}) {
|
|
|
4639
5060
|
return device;
|
|
4640
5061
|
}
|
|
4641
5062
|
|
|
4642
|
-
function attachFileSocket(socket, hello) {
|
|
4643
|
-
const deviceId = normalizeDeviceId(hello.deviceId);
|
|
4644
|
-
const device = devices.get(deviceId);
|
|
5063
|
+
function attachFileSocket(socket, hello) {
|
|
5064
|
+
const deviceId = normalizeDeviceId(hello.deviceId);
|
|
5065
|
+
const device = devices.get(deviceId);
|
|
4645
5066
|
if (!device || !device.connected || !device.socket || device.socket.destroyed) {
|
|
4646
5067
|
writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
|
|
4647
5068
|
socket.destroy();
|
|
4648
|
-
return null;
|
|
4649
|
-
}
|
|
4650
|
-
|
|
4651
|
-
|
|
5069
|
+
return null;
|
|
5070
|
+
}
|
|
5071
|
+
if (safeString(hello.parentSessionId, 160) !== safeString(device.sessionId, 160)) {
|
|
5072
|
+
writeJsonLineAndClose(socket, { type: 'error', error: 'side-channel-parent-session-stale' });
|
|
5073
|
+
return null;
|
|
5074
|
+
}
|
|
5075
|
+
|
|
5076
|
+
closeFileSocket(device, 'replaced-by-new-file-socket');
|
|
4652
5077
|
const now = new Date().toISOString();
|
|
4653
5078
|
device.fileSocket = socket;
|
|
4654
5079
|
device.fileConnectedAt = now;
|
|
@@ -7971,12 +8396,13 @@ export function createRemoteHub(options = {}) {
|
|
|
7971
8396
|
await listenOnPort(requestedPort);
|
|
7972
8397
|
try {
|
|
7973
8398
|
await relayControl?.start?.();
|
|
7974
|
-
} catch {
|
|
7975
|
-
logWarn('relay', 'Encrypted relay control could not start; direct TCP remains available.');
|
|
7976
|
-
}
|
|
7977
|
-
|
|
7978
|
-
|
|
7979
|
-
|
|
8399
|
+
} catch {
|
|
8400
|
+
logWarn('relay', 'Encrypted relay control could not start; direct TCP remains available.');
|
|
8401
|
+
}
|
|
8402
|
+
scheduleDeviceConnectionSweep();
|
|
8403
|
+
} catch (error) {
|
|
8404
|
+
if (error?.code === 'EADDRINUSE') {
|
|
8405
|
+
const conflict = new Error(
|
|
7980
8406
|
`LiveDesk Hub requires client endpoint TCP ${requestedPort}, but that port is already in use. Close the owning process or start Hub with an explicit --remote-port value.`);
|
|
7981
8407
|
conflict.code = 'EADDRINUSE';
|
|
7982
8408
|
conflict.port = requestedPort;
|
|
@@ -7988,12 +8414,16 @@ export function createRemoteHub(options = {}) {
|
|
|
7988
8414
|
}
|
|
7989
8415
|
|
|
7990
8416
|
return getStatus({ includeSecrets: false });
|
|
7991
|
-
}
|
|
7992
|
-
|
|
7993
|
-
async function close() {
|
|
7994
|
-
|
|
7995
|
-
|
|
7996
|
-
|
|
8417
|
+
}
|
|
8418
|
+
|
|
8419
|
+
async function close() {
|
|
8420
|
+
if (deviceConnectionSweepTimer) {
|
|
8421
|
+
clearTimeout(deviceConnectionSweepTimer);
|
|
8422
|
+
deviceConnectionSweepTimer = null;
|
|
8423
|
+
}
|
|
8424
|
+
for (const state of [...agentBinaryIngressStates]) {
|
|
8425
|
+
closeAgentBinaryIngress(state, 'hub-shutdown');
|
|
8426
|
+
}
|
|
7997
8427
|
|
|
7998
8428
|
for (const device of devices.values()) {
|
|
7999
8429
|
abortTransportDiagnosticsForDevice(device, 'hub-shutdown');
|
|
@@ -8128,9 +8558,12 @@ export function createRemoteHub(options = {}) {
|
|
|
8128
8558
|
};
|
|
8129
8559
|
}
|
|
8130
8560
|
|
|
8131
|
-
function sendCommand(deviceId, command) {
|
|
8132
|
-
const device = devices.get(String(deviceId || ''));
|
|
8133
|
-
const commandName = safeString(command?.command || 'ping', 80);
|
|
8561
|
+
function sendCommand(deviceId, command) {
|
|
8562
|
+
const device = devices.get(String(deviceId || ''));
|
|
8563
|
+
const commandName = safeString(command?.command || 'ping', 80);
|
|
8564
|
+
if (REMOTE_CLIPBOARD_FILE_ACTIONS.has(commandName)) {
|
|
8565
|
+
return { ok: false, error: 'clipboard-command-requires-awaited-file-channel' };
|
|
8566
|
+
}
|
|
8134
8567
|
const requiredPermission = commandName === 'input.control'
|
|
8135
8568
|
? 'allowControl'
|
|
8136
8569
|
: commandName === 'system.power'
|
|
@@ -8166,12 +8599,16 @@ export function createRemoteHub(options = {}) {
|
|
|
8166
8599
|
result: { ok: true, synthetic: true },
|
|
8167
8600
|
error: ''
|
|
8168
8601
|
});
|
|
8169
|
-
return { ok: true, commandId, synthetic: true };
|
|
8170
|
-
}
|
|
8171
|
-
|
|
8172
|
-
if (
|
|
8173
|
-
|
|
8174
|
-
|
|
8602
|
+
return { ok: true, commandId, synthetic: true };
|
|
8603
|
+
}
|
|
8604
|
+
|
|
8605
|
+
if (device?.connected && !isDeviceConnectionFresh(device)) {
|
|
8606
|
+
retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
|
|
8607
|
+
return { ok: false, error: 'device-not-connected' };
|
|
8608
|
+
}
|
|
8609
|
+
if (!device?.socket || device.socket.destroyed || !device.connected) {
|
|
8610
|
+
return { ok: false, error: 'device-not-connected' };
|
|
8611
|
+
}
|
|
8175
8612
|
|
|
8176
8613
|
const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
|
|
8177
8614
|
const payload = {
|
|
@@ -8184,29 +8621,300 @@ export function createRemoteHub(options = {}) {
|
|
|
8184
8621
|
|
|
8185
8622
|
const dedicatedFileSocket = commandName.startsWith('file.transfer')
|
|
8186
8623
|
&& device.fileSocket
|
|
8187
|
-
&& !device.fileSocket.destroyed
|
|
8188
|
-
? device.fileSocket
|
|
8189
|
-
: null;
|
|
8190
|
-
|
|
8191
|
-
|
|
8192
|
-
|
|
8193
|
-
|
|
8624
|
+
&& !device.fileSocket.destroyed
|
|
8625
|
+
? device.fileSocket
|
|
8626
|
+
: null;
|
|
8627
|
+
try {
|
|
8628
|
+
writeJsonLine(dedicatedFileSocket || device.socket, payload);
|
|
8629
|
+
} catch {
|
|
8630
|
+
retireStaleDeviceConnection(device, 'command-channel-write-failed');
|
|
8631
|
+
return { ok: false, error: 'device-not-connected' };
|
|
8632
|
+
}
|
|
8633
|
+
device.counters.commandsSent += 1;
|
|
8634
|
+
emitRemoteEvent('RemoteCommandQueued', device, {
|
|
8635
|
+
commandId,
|
|
8194
8636
|
command: payload.command,
|
|
8195
8637
|
channel: dedicatedFileSocket ? 'file' : 'control'
|
|
8196
8638
|
});
|
|
8197
|
-
return { ok: true, commandId };
|
|
8198
|
-
}
|
|
8199
|
-
|
|
8200
|
-
function
|
|
8639
|
+
return { ok: true, commandId };
|
|
8640
|
+
}
|
|
8641
|
+
|
|
8642
|
+
async function sendClipboardCommand(deviceId, request = {}) {
|
|
8643
|
+
pruneClipboardOperations();
|
|
8644
|
+
const action = safeString(request.action, 80).toLowerCase();
|
|
8645
|
+
if (!REMOTE_CLIPBOARD_FILE_ACTIONS.has(action)) {
|
|
8646
|
+
return { ok: false, error: 'clipboard-file-action-invalid' };
|
|
8647
|
+
}
|
|
8648
|
+
const operationId = safeString(request.operationId, 128);
|
|
8649
|
+
if (!operationId) {
|
|
8650
|
+
return { ok: false, error: 'clipboard-operation-id-invalid' };
|
|
8651
|
+
}
|
|
8652
|
+
|
|
8653
|
+
const direction = action.startsWith('clipboard.paste.')
|
|
8654
|
+
? 'paste'
|
|
8655
|
+
: action.startsWith('clipboard.copy.')
|
|
8656
|
+
? 'copy'
|
|
8657
|
+
: '';
|
|
8658
|
+
const operationKey = buildClipboardOperationKey(deviceId, request.binding?.controlSessionId, operationId);
|
|
8659
|
+
let operation = clipboardOperations.get(operationKey);
|
|
8660
|
+
const beginsOperation = action === 'clipboard.copy.begin' || action === 'clipboard.paste.begin';
|
|
8661
|
+
const endsOperation = action === 'clipboard.copy.end' || action === 'clipboard.paste.cancel';
|
|
8662
|
+
const requestedManifest = request.payload?.manifest || null;
|
|
8663
|
+
|
|
8664
|
+
if (beginsOperation) {
|
|
8665
|
+
if (operation) {
|
|
8666
|
+
return { ok: false, error: 'clipboard-operation-already-exists' };
|
|
8667
|
+
}
|
|
8668
|
+
const activeForDevice = [...clipboardOperations.values()].filter(candidate =>
|
|
8669
|
+
candidate.deviceId === String(deviceId || '')
|
|
8670
|
+
&& candidate.sessionId === request.binding?.controlSessionId);
|
|
8671
|
+
if (activeForDevice.some(candidate => candidate.direction === direction)) {
|
|
8672
|
+
return { ok: false, error: 'clipboard-operation-direction-busy' };
|
|
8673
|
+
}
|
|
8674
|
+
if (activeForDevice.length >= REMOTE_CLIPBOARD_LIMITS.maxOperationsPerDevice) {
|
|
8675
|
+
return { ok: false, error: 'clipboard-operation-capacity-reached' };
|
|
8676
|
+
}
|
|
8677
|
+
operation = {
|
|
8678
|
+
deviceId: String(deviceId || ''),
|
|
8679
|
+
sessionId: safeString(request.binding?.controlSessionId, 160),
|
|
8680
|
+
operationId,
|
|
8681
|
+
direction,
|
|
8682
|
+
hubConnectionId: safeString(request.hubConnectionId, 128),
|
|
8683
|
+
controlCommandId: safeString(request.binding?.controlCommandId, 128),
|
|
8684
|
+
captureGeneration: Number(request.binding?.captureGeneration || 0),
|
|
8685
|
+
monitorIndex: Number(request.binding?.monitorIndex),
|
|
8686
|
+
contentKind: requestedManifest?.contentKind || 'text',
|
|
8687
|
+
manifest: requestedManifest,
|
|
8688
|
+
offsets: new Map(),
|
|
8689
|
+
status: action === 'clipboard.paste.begin' ? 'preparing' : 'copying',
|
|
8690
|
+
inFlight: false,
|
|
8691
|
+
touchedAt: Date.now()
|
|
8692
|
+
};
|
|
8693
|
+
clipboardOperations.set(operationKey, operation);
|
|
8694
|
+
} else {
|
|
8695
|
+
if (!operation) {
|
|
8696
|
+
return { ok: false, error: 'clipboard-operation-not-found' };
|
|
8697
|
+
}
|
|
8698
|
+
const requestedDirection = direction || operation.direction;
|
|
8699
|
+
if (operation.direction !== requestedDirection
|
|
8700
|
+
|| operation.hubConnectionId !== safeString(request.hubConnectionId, 128)
|
|
8701
|
+
|| operation.controlCommandId !== safeString(request.binding?.controlCommandId, 128)
|
|
8702
|
+
|| operation.captureGeneration !== Number(request.binding?.captureGeneration || 0)
|
|
8703
|
+
|| operation.monitorIndex !== Number(request.binding?.monitorIndex)) {
|
|
8704
|
+
return { ok: false, error: 'clipboard-operation-owner-stale' };
|
|
8705
|
+
}
|
|
8706
|
+
}
|
|
8707
|
+
|
|
8708
|
+
if (operation.inFlight) {
|
|
8709
|
+
return { ok: false, error: 'clipboard-operation-busy' };
|
|
8710
|
+
}
|
|
8711
|
+
|
|
8712
|
+
const contentKind = operation.contentKind || requestedManifest?.contentKind || 'text';
|
|
8713
|
+
const bindingResult = validateCurrentClipboardBinding(
|
|
8714
|
+
deviceId,
|
|
8715
|
+
{ ...request, clipboardDirection: operation.direction },
|
|
8716
|
+
contentKind,
|
|
8717
|
+
{ requireFileSocket: true });
|
|
8718
|
+
if (!bindingResult.ok) {
|
|
8719
|
+
if (beginsOperation || bindingResult.error?.startsWith('STALE_') || bindingResult.error === 'input-owner-not-current') {
|
|
8720
|
+
clipboardOperations.delete(operationKey);
|
|
8721
|
+
}
|
|
8722
|
+
return bindingResult;
|
|
8723
|
+
}
|
|
8724
|
+
|
|
8725
|
+
const { device } = bindingResult;
|
|
8726
|
+
const fileSocket = device.fileSocket;
|
|
8727
|
+
const itemIndex = request.payload?.itemIndex;
|
|
8728
|
+
const expectedOffset = Number.isInteger(itemIndex)
|
|
8729
|
+
? Number(operation.offsets.get(itemIndex) || 0)
|
|
8730
|
+
: 0;
|
|
8731
|
+
let outgoingChunkBytes = 0;
|
|
8732
|
+
if (action === 'clipboard.copy.chunk' || action === 'clipboard.paste.chunk') {
|
|
8733
|
+
const item = operation.manifest?.items?.[itemIndex];
|
|
8734
|
+
if (!item) {
|
|
8735
|
+
return { ok: false, error: 'clipboard-item-not-found' };
|
|
8736
|
+
}
|
|
8737
|
+
if (request.payload.offset !== expectedOffset) {
|
|
8738
|
+
return { ok: false, error: 'clipboard-chunk-offset-stale', expectedOffset };
|
|
8739
|
+
}
|
|
8740
|
+
if (action === 'clipboard.copy.chunk'
|
|
8741
|
+
&& request.payload.maxBytes > Number(device.capabilities?.clipboardMaxChunkBytes || 0)) {
|
|
8742
|
+
return { ok: false, error: 'device-clipboard-chunk-limit-exceeded' };
|
|
8743
|
+
}
|
|
8744
|
+
if (action === 'clipboard.paste.chunk') {
|
|
8745
|
+
outgoingChunkBytes = Buffer.from(request.payload.dataBase64, 'base64').length;
|
|
8746
|
+
if (outgoingChunkBytes > Number(device.capabilities?.clipboardMaxChunkBytes || 0)) {
|
|
8747
|
+
return { ok: false, error: 'device-clipboard-chunk-limit-exceeded' };
|
|
8748
|
+
}
|
|
8749
|
+
const nextOffset = expectedOffset + outgoingChunkBytes;
|
|
8750
|
+
if (outgoingChunkBytes === 0
|
|
8751
|
+
&& !(item.size === 0 && expectedOffset === 0 && request.payload.final === true)) {
|
|
8752
|
+
return { ok: false, error: 'clipboard-empty-chunk-invalid' };
|
|
8753
|
+
}
|
|
8754
|
+
if (nextOffset > item.size
|
|
8755
|
+
|| request.payload.final !== (nextOffset === item.size)) {
|
|
8756
|
+
return { ok: false, error: 'clipboard-chunk-boundary-invalid' };
|
|
8757
|
+
}
|
|
8758
|
+
}
|
|
8759
|
+
}
|
|
8760
|
+
|
|
8761
|
+
operation.inFlight = true;
|
|
8762
|
+
operation.touchedAt = Date.now();
|
|
8763
|
+
let commandCompleted = false;
|
|
8764
|
+
const commandId = crypto.randomUUID();
|
|
8765
|
+
const resultPromise = waitForCommandResult(
|
|
8766
|
+
device,
|
|
8767
|
+
commandId,
|
|
8768
|
+
REMOTE_CLIPBOARD_LIMITS.commandTimeoutMs,
|
|
8769
|
+
{ channel: 'file', operationId });
|
|
8770
|
+
const commandPayload = {
|
|
8771
|
+
type: 'command',
|
|
8772
|
+
commandId,
|
|
8773
|
+
command: action,
|
|
8774
|
+
payload: {
|
|
8775
|
+
operationId,
|
|
8776
|
+
controlSessionId: operation.sessionId,
|
|
8777
|
+
controlCommandId: operation.controlCommandId,
|
|
8778
|
+
captureGeneration: operation.captureGeneration,
|
|
8779
|
+
monitorIndex: operation.monitorIndex,
|
|
8780
|
+
hubConnectionId: operation.hubConnectionId,
|
|
8781
|
+
...(request.payload || {})
|
|
8782
|
+
},
|
|
8783
|
+
issuedAt: new Date().toISOString()
|
|
8784
|
+
};
|
|
8785
|
+
|
|
8786
|
+
try {
|
|
8787
|
+
try {
|
|
8788
|
+
// Clipboard payloads are never allowed onto the concurrent
|
|
8789
|
+
// main command socket. The dedicated file side channel owns
|
|
8790
|
+
// the one bounded command and its exact-session waiter.
|
|
8791
|
+
writeJsonLine(fileSocket, commandPayload);
|
|
8792
|
+
} catch {
|
|
8793
|
+
failPendingCommandResultWaiter(device, commandId, 'clipboard-file-channel-write-failed');
|
|
8794
|
+
if (device.fileSocket === fileSocket) {
|
|
8795
|
+
closeFileSocket(device, 'clipboard-file-channel-write-failed');
|
|
8796
|
+
}
|
|
8797
|
+
return { ok: false, error: 'clipboard-file-channel-unavailable' };
|
|
8798
|
+
}
|
|
8799
|
+
device.counters.commandsSent += 1;
|
|
8800
|
+
emitRemoteEvent('RemoteCommandQueued', device, {
|
|
8801
|
+
commandId,
|
|
8802
|
+
command: action,
|
|
8803
|
+
channel: 'file',
|
|
8804
|
+
operationId
|
|
8805
|
+
});
|
|
8806
|
+
|
|
8807
|
+
const awaited = await resultPromise;
|
|
8808
|
+
if (!awaited.ok) {
|
|
8809
|
+
return {
|
|
8810
|
+
ok: false,
|
|
8811
|
+
error: awaited.error || 'clipboard-command-failed',
|
|
8812
|
+
commandId
|
|
8813
|
+
};
|
|
8814
|
+
}
|
|
8815
|
+
if (device.fileSocket !== fileSocket) {
|
|
8816
|
+
return { ok: false, error: 'clipboard-file-channel-owner-changed' };
|
|
8817
|
+
}
|
|
8818
|
+
const currentBinding = validateCurrentClipboardBinding(
|
|
8819
|
+
deviceId,
|
|
8820
|
+
{ ...request, clipboardDirection: operation.direction },
|
|
8821
|
+
operation.contentKind || 'text',
|
|
8822
|
+
{ requireFileSocket: true });
|
|
8823
|
+
if (!currentBinding.ok) {
|
|
8824
|
+
return currentBinding;
|
|
8825
|
+
}
|
|
8826
|
+
|
|
8827
|
+
let response;
|
|
8828
|
+
try {
|
|
8829
|
+
response = normalizeRemoteClipboardCommandResult(action, operationId, awaited.result);
|
|
8830
|
+
} catch (error) {
|
|
8831
|
+
return {
|
|
8832
|
+
ok: false,
|
|
8833
|
+
error: safeString(error?.code || error?.message, 160) || 'clipboard-response-invalid'
|
|
8834
|
+
};
|
|
8835
|
+
}
|
|
8836
|
+
if (response.manifest) {
|
|
8837
|
+
const responseManifestLimitError = clipboardManifestCapabilityError(device, response.manifest);
|
|
8838
|
+
if (responseManifestLimitError) {
|
|
8839
|
+
clipboardOperations.delete(operationKey);
|
|
8840
|
+
return { ok: false, error: responseManifestLimitError };
|
|
8841
|
+
}
|
|
8842
|
+
const responsePolicy = validateCurrentClipboardBinding(
|
|
8843
|
+
deviceId,
|
|
8844
|
+
{ ...request, clipboardDirection: operation.direction },
|
|
8845
|
+
response.manifest.contentKind,
|
|
8846
|
+
{ requireFileSocket: true });
|
|
8847
|
+
if (!responsePolicy.ok) {
|
|
8848
|
+
clipboardOperations.delete(operationKey);
|
|
8849
|
+
return responsePolicy;
|
|
8850
|
+
}
|
|
8851
|
+
operation.manifest = response.manifest;
|
|
8852
|
+
operation.contentKind = response.manifest.contentKind;
|
|
8853
|
+
}
|
|
8854
|
+
if (action === 'clipboard.copy.chunk') {
|
|
8855
|
+
const item = operation.manifest?.items?.[response.itemIndex];
|
|
8856
|
+
const nextOffset = response.offset + response.bytes;
|
|
8857
|
+
if (!item
|
|
8858
|
+
|| response.itemIndex !== itemIndex
|
|
8859
|
+
|| response.offset !== expectedOffset
|
|
8860
|
+
|| (response.bytes === 0
|
|
8861
|
+
&& !(item.size === 0 && response.offset === 0 && response.final === true))
|
|
8862
|
+
|| nextOffset > item.size
|
|
8863
|
+
|| response.final !== (nextOffset === item.size)) {
|
|
8864
|
+
return { ok: false, error: 'clipboard-chunk-response-boundary-invalid' };
|
|
8865
|
+
}
|
|
8866
|
+
if (response.bytes > Number(device.capabilities?.clipboardMaxChunkBytes || 0)) {
|
|
8867
|
+
return { ok: false, error: 'device-clipboard-chunk-limit-exceeded' };
|
|
8868
|
+
}
|
|
8869
|
+
operation.offsets.set(response.itemIndex, nextOffset);
|
|
8870
|
+
} else if (action === 'clipboard.paste.chunk') {
|
|
8871
|
+
if (response.itemIndex !== itemIndex
|
|
8872
|
+
|| response.offset !== expectedOffset
|
|
8873
|
+
|| response.bytes !== outgoingChunkBytes
|
|
8874
|
+
|| response.totalBytes !== operation.manifest.items[itemIndex].size
|
|
8875
|
+
|| response.final !== request.payload.final) {
|
|
8876
|
+
return { ok: false, error: 'clipboard-chunk-response-boundary-invalid' };
|
|
8877
|
+
}
|
|
8878
|
+
operation.offsets.set(itemIndex, expectedOffset + outgoingChunkBytes);
|
|
8879
|
+
}
|
|
8880
|
+
operation.status = response.status || operation.status;
|
|
8881
|
+
operation.touchedAt = Date.now();
|
|
8882
|
+
if (action === 'clipboard.operation.status' && operation.status === 'not-found') {
|
|
8883
|
+
clipboardOperations.delete(operationKey);
|
|
8884
|
+
return { ok: false, error: 'clipboard-operation-not-found' };
|
|
8885
|
+
}
|
|
8886
|
+
if (action === 'clipboard.operation.status'
|
|
8887
|
+
&& ['completed', 'complete', 'applied', 'pasted', 'cancelled', 'cleaned', 'failed', 'expired', 'already-ended']
|
|
8888
|
+
.includes(operation.status)) {
|
|
8889
|
+
clipboardOperations.delete(operationKey);
|
|
8890
|
+
}
|
|
8891
|
+
commandCompleted = true;
|
|
8892
|
+
return response;
|
|
8893
|
+
} finally {
|
|
8894
|
+
const currentOperation = clipboardOperations.get(operationKey);
|
|
8895
|
+
if (currentOperation === operation) {
|
|
8896
|
+
operation.inFlight = false;
|
|
8897
|
+
if (endsOperation || (beginsOperation && !commandCompleted)) {
|
|
8898
|
+
clipboardOperations.delete(operationKey);
|
|
8899
|
+
}
|
|
8900
|
+
}
|
|
8901
|
+
}
|
|
8902
|
+
}
|
|
8903
|
+
|
|
8904
|
+
function refreshDevicePolicies(deviceIds = undefined) {
|
|
8201
8905
|
const requestedIds = Array.isArray(deviceIds) && deviceIds.length > 0
|
|
8202
8906
|
? new Set(deviceIds.map(deviceId => safeString(deviceId, 160)).filter(Boolean))
|
|
8203
8907
|
: null;
|
|
8204
8908
|
let updated = 0;
|
|
8205
8909
|
let total = 0;
|
|
8206
|
-
for (const device of devices.values()) {
|
|
8207
|
-
if (requestedIds && !requestedIds.has(device.deviceId)) continue;
|
|
8208
|
-
if (!device?.socket || device.socket.destroyed || !device.connected) continue;
|
|
8209
|
-
|
|
8910
|
+
for (const device of devices.values()) {
|
|
8911
|
+
if (requestedIds && !requestedIds.has(device.deviceId)) continue;
|
|
8912
|
+
if (!device?.socket || device.socket.destroyed || !device.connected) continue;
|
|
8913
|
+
if (device.synthetic !== true && !isDeviceConnectionFresh(device)) {
|
|
8914
|
+
retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
|
|
8915
|
+
continue;
|
|
8916
|
+
}
|
|
8917
|
+
total += 1;
|
|
8210
8918
|
const effectivePolicy = getDevicePolicy(device);
|
|
8211
8919
|
if (writeJsonLine(device.socket, {
|
|
8212
8920
|
type: 'policy.update',
|
|
@@ -8225,8 +8933,14 @@ export function createRemoteHub(options = {}) {
|
|
|
8225
8933
|
const device = devices.get(String(deviceId || ''));
|
|
8226
8934
|
const denied = policyError(device);
|
|
8227
8935
|
if (denied) return { ok: false, error: denied };
|
|
8228
|
-
if (!device) return { ok: false, error: 'device-not-found' };
|
|
8229
|
-
if (
|
|
8936
|
+
if (!device) return { ok: false, error: 'device-not-found' };
|
|
8937
|
+
if (device.synthetic !== true
|
|
8938
|
+
&& device.connected
|
|
8939
|
+
&& !isDeviceConnectionFresh(device)) {
|
|
8940
|
+
retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
|
|
8941
|
+
return { ok: false, error: 'device-not-connected' };
|
|
8942
|
+
}
|
|
8943
|
+
if (!device.socket || device.socket.destroyed || !device.connected) {
|
|
8230
8944
|
return { ok: false, error: 'device-not-connected' };
|
|
8231
8945
|
}
|
|
8232
8946
|
|
|
@@ -8322,15 +9036,21 @@ export function createRemoteHub(options = {}) {
|
|
|
8322
9036
|
return device.inputWriteOwner;
|
|
8323
9037
|
}
|
|
8324
9038
|
|
|
8325
|
-
function sendInputControl(deviceId, input = {}) {
|
|
8326
|
-
const device = devices.get(String(deviceId || ''));
|
|
8327
|
-
if (!device) {
|
|
8328
|
-
return { ok: false, error: 'device-not-found' };
|
|
8329
|
-
}
|
|
8330
|
-
|
|
8331
|
-
if (
|
|
8332
|
-
|
|
8333
|
-
|
|
9039
|
+
function sendInputControl(deviceId, input = {}) {
|
|
9040
|
+
const device = devices.get(String(deviceId || ''));
|
|
9041
|
+
if (!device) {
|
|
9042
|
+
return { ok: false, error: 'device-not-found' };
|
|
9043
|
+
}
|
|
9044
|
+
|
|
9045
|
+
if (device.synthetic !== true
|
|
9046
|
+
&& device.connected
|
|
9047
|
+
&& !isDeviceConnectionFresh(device)) {
|
|
9048
|
+
retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
|
|
9049
|
+
return { ok: false, error: 'device-not-connected' };
|
|
9050
|
+
}
|
|
9051
|
+
if (!device.connected) {
|
|
9052
|
+
return { ok: false, error: 'device-not-connected' };
|
|
9053
|
+
}
|
|
8334
9054
|
|
|
8335
9055
|
const normalized = normalizeRemoteInputEvent(input);
|
|
8336
9056
|
if (!normalized.type) {
|
|
@@ -8396,9 +9116,51 @@ export function createRemoteHub(options = {}) {
|
|
|
8396
9116
|
|| device.capabilities?.accessibilityPermission === false) {
|
|
8397
9117
|
return { ok: false, error: 'INPUT_PERMISSION_DENIED' };
|
|
8398
9118
|
}
|
|
8399
|
-
if (!readCapabilityFlag(device.capabilities, 'control')) {
|
|
8400
|
-
return { ok: false, error: 'device-input-control-unavailable' };
|
|
8401
|
-
}
|
|
9119
|
+
if (!readCapabilityFlag(device.capabilities, 'control')) {
|
|
9120
|
+
return { ok: false, error: 'device-input-control-unavailable' };
|
|
9121
|
+
}
|
|
9122
|
+
|
|
9123
|
+
const normalizedInputType = normalized.type.toLowerCase();
|
|
9124
|
+
const isClipboardInput = normalizedInputType === 'clipboard.prepare'
|
|
9125
|
+
|| normalizedInputType === 'clipboard.copy'
|
|
9126
|
+
|| normalizedInputType === 'clipboard.paste';
|
|
9127
|
+
let clipboardManifest = null;
|
|
9128
|
+
if (isClipboardInput) {
|
|
9129
|
+
if (!normalized.clipboardOperationId) {
|
|
9130
|
+
return { ok: false, error: 'clipboard-operation-id-invalid' };
|
|
9131
|
+
}
|
|
9132
|
+
if (!['copy', 'paste'].includes(normalized.clipboardDirection)) {
|
|
9133
|
+
return { ok: false, error: 'clipboard-direction-required' };
|
|
9134
|
+
}
|
|
9135
|
+
if ((normalizedInputType === 'clipboard.copy' && normalized.clipboardDirection !== 'copy')
|
|
9136
|
+
|| (normalizedInputType === 'clipboard.paste' && normalized.clipboardDirection !== 'paste')) {
|
|
9137
|
+
return { ok: false, error: 'clipboard-direction-mismatch' };
|
|
9138
|
+
}
|
|
9139
|
+
try {
|
|
9140
|
+
clipboardManifest = normalized.manifest
|
|
9141
|
+
? normalizeRemoteClipboardManifest(normalized.manifest, normalized.clipboardOperationId)
|
|
9142
|
+
: null;
|
|
9143
|
+
} catch (error) {
|
|
9144
|
+
return { ok: false, error: safeString(error?.code || error?.message, 160) || 'clipboard-manifest-invalid' };
|
|
9145
|
+
}
|
|
9146
|
+
const clipboardPolicy = getDevicePolicy(device);
|
|
9147
|
+
if (clipboardPolicy.allowClipboardText !== true) {
|
|
9148
|
+
return { ok: false, error: 'allowClipboardText-blocked-by-settings' };
|
|
9149
|
+
}
|
|
9150
|
+
const contentKind = clipboardManifest?.contentKind || normalized.contentKind || 'text';
|
|
9151
|
+
if (remoteClipboardContentNeedsFileTransfer(contentKind)
|
|
9152
|
+
&& clipboardPolicy.allowFileTransfer !== true) {
|
|
9153
|
+
return { ok: false, error: 'allowFileTransfer-blocked-by-settings' };
|
|
9154
|
+
}
|
|
9155
|
+
const clipboardCapabilityDenied = clipboardCapabilityError(
|
|
9156
|
+
device,
|
|
9157
|
+
normalized.clipboardDirection,
|
|
9158
|
+
contentKind);
|
|
9159
|
+
if (clipboardCapabilityDenied) {
|
|
9160
|
+
return { ok: false, error: clipboardCapabilityDenied };
|
|
9161
|
+
}
|
|
9162
|
+
normalized.manifest = clipboardManifest;
|
|
9163
|
+
}
|
|
8402
9164
|
|
|
8403
9165
|
const controlStream = getActiveControlStream(device);
|
|
8404
9166
|
if (!controlStream
|
|
@@ -8434,14 +9196,32 @@ export function createRemoteHub(options = {}) {
|
|
|
8434
9196
|
};
|
|
8435
9197
|
}
|
|
8436
9198
|
const activeMonitorIndex = normalizeMonitorIndex(controlStream.monitorIndex);
|
|
8437
|
-
if (!Number.isInteger(normalized.monitorIndex)
|
|
8438
|
-
|| normalized.monitorIndex !== activeMonitorIndex) {
|
|
9199
|
+
if (!Number.isInteger(normalized.monitorIndex)
|
|
9200
|
+
|| normalized.monitorIndex !== activeMonitorIndex) {
|
|
8439
9201
|
return {
|
|
8440
9202
|
ok: false,
|
|
8441
9203
|
error: 'STALE_CONTROL_MONITOR',
|
|
8442
|
-
activeMonitorIndex
|
|
8443
|
-
};
|
|
8444
|
-
}
|
|
9204
|
+
activeMonitorIndex
|
|
9205
|
+
};
|
|
9206
|
+
}
|
|
9207
|
+
|
|
9208
|
+
let clipboardOperationKey = '';
|
|
9209
|
+
if (normalizedInputType === 'clipboard.paste') {
|
|
9210
|
+
clipboardOperationKey = buildClipboardOperationKey(
|
|
9211
|
+
device.deviceId,
|
|
9212
|
+
device.sessionId,
|
|
9213
|
+
normalized.clipboardOperationId);
|
|
9214
|
+
const operation = clipboardOperations.get(clipboardOperationKey);
|
|
9215
|
+
if (!operation
|
|
9216
|
+
|| operation.direction !== 'paste'
|
|
9217
|
+
|| operation.hubConnectionId !== normalized.hubConnectionId
|
|
9218
|
+
|| operation.controlCommandId !== normalized.controlCommandId
|
|
9219
|
+
|| operation.captureGeneration !== normalized.captureGeneration
|
|
9220
|
+
|| operation.monitorIndex !== normalized.monitorIndex
|
|
9221
|
+
|| operation.status !== 'ready') {
|
|
9222
|
+
return { ok: false, error: 'clipboard-paste-not-ready' };
|
|
9223
|
+
}
|
|
9224
|
+
}
|
|
8445
9225
|
|
|
8446
9226
|
const inputSocket = device.inputSocket;
|
|
8447
9227
|
const inputWriteOwner = getCurrentInputWriteOwner(device);
|
|
@@ -8458,9 +9238,17 @@ export function createRemoteHub(options = {}) {
|
|
|
8458
9238
|
&& normalized.hubConnectionId !== previousOwnerConnectionId;
|
|
8459
9239
|
const bindingChanged = previousInputBindingKey
|
|
8460
9240
|
&& previousInputBindingKey !== activeInputBindingKey;
|
|
8461
|
-
const writeBindingChanged = inputWriteOwner.getBindingKey() !== activeInputBindingKey;
|
|
8462
|
-
if (ownerChanged || bindingChanged || writeBindingChanged) {
|
|
8463
|
-
|
|
9241
|
+
const writeBindingChanged = inputWriteOwner.getBindingKey() !== activeInputBindingKey;
|
|
9242
|
+
if (ownerChanged || bindingChanged || writeBindingChanged) {
|
|
9243
|
+
if (ownerChanged) {
|
|
9244
|
+
clearClipboardOperationsForDevice(
|
|
9245
|
+
device,
|
|
9246
|
+
'browser-input-owner-changed',
|
|
9247
|
+
previousOwnerConnectionId);
|
|
9248
|
+
} else if (bindingChanged || writeBindingChanged) {
|
|
9249
|
+
clearClipboardOperationsForDevice(device, 'control-input-binding-changed');
|
|
9250
|
+
}
|
|
9251
|
+
const resetSent = inputWriteOwner.replaceBinding(
|
|
8464
9252
|
activeInputBindingKey,
|
|
8465
9253
|
buildRemoteInputResetMessage(
|
|
8466
9254
|
device,
|
|
@@ -8490,9 +9278,16 @@ export function createRemoteHub(options = {}) {
|
|
|
8490
9278
|
device.inputOwnerConnectionId = normalized.hubConnectionId;
|
|
8491
9279
|
}
|
|
8492
9280
|
device.inputOwnerBindingKey = activeInputBindingKey;
|
|
8493
|
-
device.counters.commandsSent += 1;
|
|
8494
|
-
device.inputLastSeenAt = new Date().toISOString();
|
|
8495
|
-
|
|
9281
|
+
device.counters.commandsSent += 1;
|
|
9282
|
+
device.inputLastSeenAt = new Date().toISOString();
|
|
9283
|
+
if (clipboardOperationKey) {
|
|
9284
|
+
const operation = clipboardOperations.get(clipboardOperationKey);
|
|
9285
|
+
if (operation) {
|
|
9286
|
+
operation.status = 'pasting';
|
|
9287
|
+
operation.touchedAt = Date.now();
|
|
9288
|
+
}
|
|
9289
|
+
}
|
|
9290
|
+
return {
|
|
8496
9291
|
ok: true,
|
|
8497
9292
|
inputSocket: true,
|
|
8498
9293
|
sessionId: device.sessionId,
|
|
@@ -8527,7 +9322,7 @@ export function createRemoteHub(options = {}) {
|
|
|
8527
9322
|
issuedAt: normalized.issuedAt || new Date().toISOString()
|
|
8528
9323
|
}
|
|
8529
9324
|
});
|
|
8530
|
-
if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
|
|
9325
|
+
if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
|
|
8531
9326
|
schedulePendingInputFallback(device, {
|
|
8532
9327
|
commandId: fallback.commandId,
|
|
8533
9328
|
inputSeq: normalized.inputSeq,
|
|
@@ -8539,9 +9334,16 @@ export function createRemoteHub(options = {}) {
|
|
|
8539
9334
|
hubReceivedAtEpochMs: normalized.hubReceivedAtEpochMs,
|
|
8540
9335
|
hubForwardedAtEpochMs,
|
|
8541
9336
|
fallback: true,
|
|
8542
|
-
timeoutTimer: null
|
|
8543
|
-
});
|
|
8544
|
-
}
|
|
9337
|
+
timeoutTimer: null
|
|
9338
|
+
});
|
|
9339
|
+
}
|
|
9340
|
+
if (fallback.ok && clipboardOperationKey) {
|
|
9341
|
+
const operation = clipboardOperations.get(clipboardOperationKey);
|
|
9342
|
+
if (operation) {
|
|
9343
|
+
operation.status = 'pasting';
|
|
9344
|
+
operation.touchedAt = Date.now();
|
|
9345
|
+
}
|
|
9346
|
+
}
|
|
8545
9347
|
return fallback.ok
|
|
8546
9348
|
? {
|
|
8547
9349
|
...fallback,
|
|
@@ -8561,14 +9363,15 @@ export function createRemoteHub(options = {}) {
|
|
|
8561
9363
|
};
|
|
8562
9364
|
}
|
|
8563
9365
|
|
|
8564
|
-
function releaseInputOwner(deviceId, ownerConnectionId, reason = 'browser-input-owner-closed') {
|
|
9366
|
+
function releaseInputOwner(deviceId, ownerConnectionId, reason = 'browser-input-owner-closed') {
|
|
8565
9367
|
const device = devices.get(String(deviceId || ''));
|
|
8566
9368
|
const owner = safeString(ownerConnectionId, 128);
|
|
8567
|
-
if (!device || !owner || safeString(device.inputOwnerConnectionId, 128) !== owner) {
|
|
8568
|
-
return { ok: false, error: 'input-owner-not-current' };
|
|
8569
|
-
}
|
|
8570
|
-
|
|
8571
|
-
device
|
|
9369
|
+
if (!device || !owner || safeString(device.inputOwnerConnectionId, 128) !== owner) {
|
|
9370
|
+
return { ok: false, error: 'input-owner-not-current' };
|
|
9371
|
+
}
|
|
9372
|
+
|
|
9373
|
+
clearClipboardOperationsForDevice(device, reason, owner);
|
|
9374
|
+
device.inputOwnerConnectionId = '';
|
|
8572
9375
|
device.inputOwnerBindingKey = '';
|
|
8573
9376
|
const inputSocket = device.inputSocket;
|
|
8574
9377
|
const inputWriteOwner = getCurrentInputWriteOwner(device);
|
|
@@ -8612,8 +9415,12 @@ export function createRemoteHub(options = {}) {
|
|
|
8612
9415
|
};
|
|
8613
9416
|
const results = targets.map(deviceId => {
|
|
8614
9417
|
const device = devices.get(deviceId);
|
|
8615
|
-
if (device?.synthetic === true && device.connected) return { deviceId, ok: true, synthetic: true };
|
|
8616
|
-
if (
|
|
9418
|
+
if (device?.synthetic === true && device.connected) return { deviceId, ok: true, synthetic: true };
|
|
9419
|
+
if (device?.connected && !isDeviceConnectionFresh(device)) {
|
|
9420
|
+
retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
|
|
9421
|
+
return { deviceId, ok: false, error: 'device-not-connected' };
|
|
9422
|
+
}
|
|
9423
|
+
if (!device?.connected || !device.socket || device.socket.destroyed) {
|
|
8617
9424
|
return { deviceId, ok: false, error: 'device-not-connected' };
|
|
8618
9425
|
}
|
|
8619
9426
|
return writeJsonLine(device.socket, payload)
|
|
@@ -8645,7 +9452,7 @@ export function createRemoteHub(options = {}) {
|
|
|
8645
9452
|
const denied = policyError(device, 'allowAgent');
|
|
8646
9453
|
if (denied) return { ok: false, error: denied };
|
|
8647
9454
|
|
|
8648
|
-
if (device?.synthetic === true && device.connected) {
|
|
9455
|
+
if (device?.synthetic === true && device.connected) {
|
|
8649
9456
|
const instruction = safeText(options.instruction, MAX_AGENT_TASK_CHARS);
|
|
8650
9457
|
if (!instruction) {
|
|
8651
9458
|
return { ok: false, error: 'missing-instruction' };
|
|
@@ -8705,10 +9512,14 @@ export function createRemoteHub(options = {}) {
|
|
|
8705
9512
|
error: '',
|
|
8706
9513
|
synthetic: true
|
|
8707
9514
|
});
|
|
8708
|
-
return { ok: true, commandId, taskId, approvalLevel, synthetic: true };
|
|
8709
|
-
}
|
|
8710
|
-
|
|
8711
|
-
if (
|
|
9515
|
+
return { ok: true, commandId, taskId, approvalLevel, synthetic: true };
|
|
9516
|
+
}
|
|
9517
|
+
|
|
9518
|
+
if (device.connected && !isDeviceConnectionFresh(device)) {
|
|
9519
|
+
retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
|
|
9520
|
+
return { ok: false, error: 'device-not-connected' };
|
|
9521
|
+
}
|
|
9522
|
+
if (!device?.socket || device.socket.destroyed || !device.connected) {
|
|
8712
9523
|
return { ok: false, error: 'device-not-connected' };
|
|
8713
9524
|
}
|
|
8714
9525
|
|
|
@@ -9528,11 +10339,58 @@ export function createRemoteHub(options = {}) {
|
|
|
9528
10339
|
=== normalized.streamPurpose;
|
|
9529
10340
|
}
|
|
9530
10341
|
|
|
9531
|
-
function
|
|
9532
|
-
|
|
9533
|
-
|
|
9534
|
-
|
|
9535
|
-
|
|
10342
|
+
function liveStreamMatchesOwnerIdentity(activeLiveStream, normalized) {
|
|
10343
|
+
if (!activeLiveStream) {
|
|
10344
|
+
return false;
|
|
10345
|
+
}
|
|
10346
|
+
return activeLiveStream.frameMode === normalized.transfer.frameMode
|
|
10347
|
+
&& Number(activeLiveStream.monitorIndex || 0) === normalized.monitorIndex
|
|
10348
|
+
&& (safeString(activeLiveStream.streamPurpose, 24).toLowerCase() || 'wall')
|
|
10349
|
+
=== normalized.streamPurpose;
|
|
10350
|
+
}
|
|
10351
|
+
|
|
10352
|
+
function sharedLiveProfileResult(device, activeLiveStream, normalized, extra = {}) {
|
|
10353
|
+
return {
|
|
10354
|
+
ok: true,
|
|
10355
|
+
commandId: activeLiveStream.commandId,
|
|
10356
|
+
sessionId: device.sessionId,
|
|
10357
|
+
streamId: activeLiveStream.streamId,
|
|
10358
|
+
streamPurpose: safeString(activeLiveStream.streamPurpose, 24) || normalized.streamPurpose,
|
|
10359
|
+
fps: Number(activeLiveStream.fps || normalized.fps),
|
|
10360
|
+
mode: activeLiveStream.mode || normalized.transfer.mode,
|
|
10361
|
+
frameMode: activeLiveStream.frameMode || normalized.transfer.frameMode,
|
|
10362
|
+
monitorIndex: Number(activeLiveStream.monitorIndex ?? normalized.monitorIndex),
|
|
10363
|
+
captureGeneration: Number(activeLiveStream.captureGeneration || 0),
|
|
10364
|
+
ready: liveStreamHasCurrentFrame(activeLiveStream),
|
|
10365
|
+
reused: true,
|
|
10366
|
+
sharedProfileReused: true,
|
|
10367
|
+
requestedProfile: {
|
|
10368
|
+
fps: normalized.fps,
|
|
10369
|
+
maxWidth: normalized.maxWidth,
|
|
10370
|
+
maxHeight: normalized.maxHeight,
|
|
10371
|
+
quality: normalized.quality
|
|
10372
|
+
},
|
|
10373
|
+
effectiveProfile: {
|
|
10374
|
+
fps: Number(activeLiveStream.fps || normalized.fps),
|
|
10375
|
+
maxWidth: Number(activeLiveStream.maxWidth || normalized.maxWidth),
|
|
10376
|
+
maxHeight: Number(activeLiveStream.maxHeight || normalized.maxHeight),
|
|
10377
|
+
quality: Number(activeLiveStream.quality || normalized.quality)
|
|
10378
|
+
},
|
|
10379
|
+
...extra
|
|
10380
|
+
};
|
|
10381
|
+
}
|
|
10382
|
+
|
|
10383
|
+
function startLiveStream(deviceId, options = {}) {
|
|
10384
|
+
const device = devices.get(String(deviceId || ''));
|
|
10385
|
+
const denied = policyError(device);
|
|
10386
|
+
if (denied) return { ok: false, error: denied };
|
|
10387
|
+
if (device?.synthetic !== true
|
|
10388
|
+
&& device?.connected
|
|
10389
|
+
&& !isDeviceConnectionFresh(device)) {
|
|
10390
|
+
retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
|
|
10391
|
+
return { ok: false, error: 'device-not-connected' };
|
|
10392
|
+
}
|
|
10393
|
+
if (device?.liveCapturePause?.token) {
|
|
9536
10394
|
return {
|
|
9537
10395
|
ok: false,
|
|
9538
10396
|
error: 'LIVE_CAPTURE_PAUSED',
|
|
@@ -9664,6 +10522,13 @@ export function createRemoteHub(options = {}) {
|
|
|
9664
10522
|
const modePolicyError = liveStreamModePolicyError(normalized);
|
|
9665
10523
|
if (modePolicyError) return { ok: false, error: modePolicyError };
|
|
9666
10524
|
const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
|
|
10525
|
+
if (deviceExplicitlyRejectsFrameMode(device, transfer.frameMode)) {
|
|
10526
|
+
return {
|
|
10527
|
+
ok: false,
|
|
10528
|
+
error: 'device-frame-mode-unavailable',
|
|
10529
|
+
frameMode: transfer.frameMode
|
|
10530
|
+
};
|
|
10531
|
+
}
|
|
9667
10532
|
const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
|
|
9668
10533
|
if (!reserveDeviceLiveStreamDescriptor(device, streamId)) {
|
|
9669
10534
|
return {
|
|
@@ -9678,13 +10543,24 @@ export function createRemoteHub(options = {}) {
|
|
|
9678
10543
|
const pendingDescriptor = getPendingLiveStreamDescriptor(activeLiveStream);
|
|
9679
10544
|
if (pendingDescriptor?.commandId) {
|
|
9680
10545
|
const pendingMatchesRequest = liveStreamMatchesOptions(pendingDescriptor, normalized);
|
|
9681
|
-
|
|
10546
|
+
const pendingMatchesSharedOwner = options.forceRestart !== true
|
|
10547
|
+
&& options.reuseExisting === true
|
|
10548
|
+
&& options.reuseSharedExisting === true
|
|
10549
|
+
&& liveStreamMatchesOwnerIdentity(pendingDescriptor, normalized);
|
|
10550
|
+
if ((pendingMatchesRequest || pendingMatchesSharedOwner)
|
|
10551
|
+
&& liveStreamReplacementStillPending(activeLiveStream)) {
|
|
9682
10552
|
emitRemoteEvent('RemoteLiveStreamRestartPending', device, {
|
|
9683
10553
|
streamId,
|
|
9684
10554
|
commandId: pendingDescriptor.commandId,
|
|
9685
10555
|
activeCommandId: activeLiveStream.commandId,
|
|
9686
10556
|
reason: safeString(options.restartReason || 'duplicate-restart', 128)
|
|
9687
10557
|
});
|
|
10558
|
+
if (pendingMatchesSharedOwner && !pendingMatchesRequest) {
|
|
10559
|
+
return sharedLiveProfileResult(device, pendingDescriptor, normalized, {
|
|
10560
|
+
ready: false,
|
|
10561
|
+
pending: true
|
|
10562
|
+
});
|
|
10563
|
+
}
|
|
9688
10564
|
return {
|
|
9689
10565
|
ok: true,
|
|
9690
10566
|
commandId: pendingDescriptor.commandId,
|
|
@@ -9758,6 +10634,25 @@ export function createRemoteHub(options = {}) {
|
|
|
9758
10634
|
reused: true
|
|
9759
10635
|
};
|
|
9760
10636
|
}
|
|
10637
|
+
if (activeLiveStream
|
|
10638
|
+
&& options.forceRestart !== true
|
|
10639
|
+
&& options.reuseExisting === true
|
|
10640
|
+
&& options.reuseSharedExisting === true
|
|
10641
|
+
&& liveStreamMatchesOwnerIdentity(activeLiveStream, normalized)
|
|
10642
|
+
&& liveStreamIsReusable(activeLiveStream)) {
|
|
10643
|
+
emitRemoteEvent('RemoteLiveStreamSharedProfileReused', device, {
|
|
10644
|
+
streamId,
|
|
10645
|
+
commandId: activeLiveStream.commandId,
|
|
10646
|
+
captureGeneration: Number(activeLiveStream.captureGeneration || 0),
|
|
10647
|
+
requestedFps: normalized.fps,
|
|
10648
|
+
effectiveFps: Number(activeLiveStream.fps || normalized.fps),
|
|
10649
|
+
requestedMaxWidth: normalized.maxWidth,
|
|
10650
|
+
requestedMaxHeight: normalized.maxHeight,
|
|
10651
|
+
effectiveMaxWidth: Number(activeLiveStream.maxWidth || normalized.maxWidth),
|
|
10652
|
+
effectiveMaxHeight: Number(activeLiveStream.maxHeight || normalized.maxHeight)
|
|
10653
|
+
});
|
|
10654
|
+
return sharedLiveProfileResult(device, activeLiveStream, normalized);
|
|
10655
|
+
}
|
|
9761
10656
|
if (activeLiveStream
|
|
9762
10657
|
&& options.forceRestart !== true
|
|
9763
10658
|
&& liveStreamMatchesOptions(activeLiveStream, normalized)
|
|
@@ -9926,9 +10821,15 @@ export function createRemoteHub(options = {}) {
|
|
|
9926
10821
|
};
|
|
9927
10822
|
}
|
|
9928
10823
|
|
|
9929
|
-
function stopLiveStream(deviceId, options = {}) {
|
|
9930
|
-
const device = devices.get(String(deviceId || ''));
|
|
9931
|
-
|
|
10824
|
+
function stopLiveStream(deviceId, options = {}) {
|
|
10825
|
+
const device = devices.get(String(deviceId || ''));
|
|
10826
|
+
if (device?.synthetic !== true
|
|
10827
|
+
&& device?.connected
|
|
10828
|
+
&& !isDeviceConnectionFresh(device)) {
|
|
10829
|
+
retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
|
|
10830
|
+
return { ok: false, error: 'device-not-connected' };
|
|
10831
|
+
}
|
|
10832
|
+
const stopAll = options.stopAll === true;
|
|
9932
10833
|
const streamPurpose = safeString(options.streamPurpose || options.purpose, 24).toLowerCase();
|
|
9933
10834
|
const requestedStreamId = safeString(options.streamId, 128);
|
|
9934
10835
|
const purposeStreams = !stopAll && !requestedStreamId && streamPurpose && device
|
|
@@ -10529,12 +11430,18 @@ export function createRemoteHub(options = {}) {
|
|
|
10529
11430
|
};
|
|
10530
11431
|
}
|
|
10531
11432
|
|
|
10532
|
-
function startAudioStream(deviceId, options = {}) {
|
|
10533
|
-
const device = devices.get(String(deviceId || ''));
|
|
10534
|
-
if (!device) {
|
|
10535
|
-
return { ok: false, error: 'device-not-found' };
|
|
10536
|
-
}
|
|
10537
|
-
if (
|
|
11433
|
+
function startAudioStream(deviceId, options = {}) {
|
|
11434
|
+
const device = devices.get(String(deviceId || ''));
|
|
11435
|
+
if (!device) {
|
|
11436
|
+
return { ok: false, error: 'device-not-found' };
|
|
11437
|
+
}
|
|
11438
|
+
if (device.synthetic !== true
|
|
11439
|
+
&& device.connected
|
|
11440
|
+
&& !isDeviceConnectionFresh(device)) {
|
|
11441
|
+
retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
|
|
11442
|
+
return { ok: false, error: 'device-not-connected' };
|
|
11443
|
+
}
|
|
11444
|
+
if (!device.connected) {
|
|
10538
11445
|
return { ok: false, error: 'device-not-connected' };
|
|
10539
11446
|
}
|
|
10540
11447
|
const denied = policyError(device, 'allowRemoteAudio');
|
|
@@ -10611,12 +11518,18 @@ export function createRemoteHub(options = {}) {
|
|
|
10611
11518
|
return { ok: true, commandId, streamId };
|
|
10612
11519
|
}
|
|
10613
11520
|
|
|
10614
|
-
function stopAudioStream(deviceId, options = {}) {
|
|
10615
|
-
const device = devices.get(String(deviceId || ''));
|
|
10616
|
-
if (!device) {
|
|
10617
|
-
return { ok: false, error: 'device-not-found' };
|
|
10618
|
-
}
|
|
10619
|
-
if (
|
|
11521
|
+
function stopAudioStream(deviceId, options = {}) {
|
|
11522
|
+
const device = devices.get(String(deviceId || ''));
|
|
11523
|
+
if (!device) {
|
|
11524
|
+
return { ok: false, error: 'device-not-found' };
|
|
11525
|
+
}
|
|
11526
|
+
if (device.synthetic !== true
|
|
11527
|
+
&& device.connected
|
|
11528
|
+
&& !isDeviceConnectionFresh(device)) {
|
|
11529
|
+
retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
|
|
11530
|
+
return { ok: false, error: 'device-not-connected' };
|
|
11531
|
+
}
|
|
11532
|
+
if (!device.connected) {
|
|
10620
11533
|
return { ok: false, error: 'device-not-connected' };
|
|
10621
11534
|
}
|
|
10622
11535
|
if (device.synthetic === true || !device.socket || device.socket.destroyed) {
|
|
@@ -10829,10 +11742,14 @@ export function createRemoteHub(options = {}) {
|
|
|
10829
11742
|
replacementTimerCount: device.liveStreamReplacementTimers instanceof Map
|
|
10830
11743
|
? device.liveStreamReplacementTimers.size
|
|
10831
11744
|
: 0,
|
|
10832
|
-
commandResultWaiterCount: [...pendingCommandResultWaiters.values()]
|
|
10833
|
-
.filter(waiter => waiter.deviceId === device.deviceId
|
|
10834
|
-
&& waiter.sessionId === device.sessionId)
|
|
10835
|
-
.length,
|
|
11745
|
+
commandResultWaiterCount: [...pendingCommandResultWaiters.values()]
|
|
11746
|
+
.filter(waiter => waiter.deviceId === device.deviceId
|
|
11747
|
+
&& waiter.sessionId === device.sessionId)
|
|
11748
|
+
.length,
|
|
11749
|
+
clipboardOperationCount: [...clipboardOperations.values()]
|
|
11750
|
+
.filter(operation => operation.deviceId === device.deviceId
|
|
11751
|
+
&& operation.sessionId === device.sessionId)
|
|
11752
|
+
.length,
|
|
10836
11753
|
retainedPayloadCount: retainedPayloads.size,
|
|
10837
11754
|
retainedPayloadBytes: [...retainedPayloads].reduce(
|
|
10838
11755
|
(total, payload) => total + payload.length,
|
|
@@ -10842,8 +11759,9 @@ export function createRemoteHub(options = {}) {
|
|
|
10842
11759
|
&& snapshot.stopPendingCount === 0
|
|
10843
11760
|
&& snapshot.pendingStartCount === 0
|
|
10844
11761
|
&& snapshot.pendingStopPromiseCount === 0
|
|
10845
|
-
&& snapshot.replacementTimerCount === 0
|
|
10846
|
-
&& snapshot.commandResultWaiterCount === 0
|
|
11762
|
+
&& snapshot.replacementTimerCount === 0
|
|
11763
|
+
&& snapshot.commandResultWaiterCount === 0
|
|
11764
|
+
&& snapshot.clipboardOperationCount === 0
|
|
10847
11765
|
&& snapshot.retainedPayloadCount === 0
|
|
10848
11766
|
&& snapshot.retainedPayloadBytes === 0;
|
|
10849
11767
|
return snapshot;
|
|
@@ -10855,8 +11773,9 @@ export function createRemoteHub(options = {}) {
|
|
|
10855
11773
|
'stopPendingCount',
|
|
10856
11774
|
'pendingStartCount',
|
|
10857
11775
|
'pendingStopPromiseCount',
|
|
10858
|
-
'replacementTimerCount',
|
|
10859
|
-
'commandResultWaiterCount',
|
|
11776
|
+
'replacementTimerCount',
|
|
11777
|
+
'commandResultWaiterCount',
|
|
11778
|
+
'clipboardOperationCount',
|
|
10860
11779
|
'retainedPayloadCount',
|
|
10861
11780
|
'retainedPayloadBytes'
|
|
10862
11781
|
]) {
|
|
@@ -10869,9 +11788,10 @@ export function createRemoteHub(options = {}) {
|
|
|
10869
11788
|
stopPendingCount: 0,
|
|
10870
11789
|
pendingStartCount: 0,
|
|
10871
11790
|
pendingStopPromiseCount: 0,
|
|
10872
|
-
replacementTimerCount: 0,
|
|
10873
|
-
commandResultWaiterCount: 0,
|
|
10874
|
-
|
|
11791
|
+
replacementTimerCount: 0,
|
|
11792
|
+
commandResultWaiterCount: 0,
|
|
11793
|
+
clipboardOperationCount: 0,
|
|
11794
|
+
retainedPayloadCount: 0,
|
|
10875
11795
|
retainedPayloadBytes: 0
|
|
10876
11796
|
});
|
|
10877
11797
|
return {
|
|
@@ -10911,8 +11831,9 @@ export function createRemoteHub(options = {}) {
|
|
|
10911
11831
|
listDeviceFrames,
|
|
10912
11832
|
disconnectDevice,
|
|
10913
11833
|
assignDeviceSlot,
|
|
10914
|
-
sendCommand,
|
|
10915
|
-
|
|
11834
|
+
sendCommand,
|
|
11835
|
+
sendClipboardCommand,
|
|
11836
|
+
refreshDevicePolicies,
|
|
10916
11837
|
sendLegacyClientUpdate,
|
|
10917
11838
|
sendInputControl,
|
|
10918
11839
|
releaseInputOwner,
|