@livedesk/hub 0.1.49 → 0.1.51

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/src/remote-hub.js CHANGED
@@ -3,18 +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';
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';
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';
18
18
 
19
19
  const DEFAULT_REMOTE_HUB_PORT = 5197;
20
20
  const DEFAULT_REMOTE_HUB_HOST = '0.0.0.0';
@@ -236,13 +236,13 @@ const REMOTE_FRAME_MODE_ALIASES = new Map([
236
236
  ]);
237
237
  const MAX_SYNTHETIC_DEVICES = 1000;
238
238
  const DEFAULT_HOST_TARGET_LEASE_MS = 30000;
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;
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;
246
246
  const AGENT_BINARY_INGRESS_QUEUE_MAX_BYTES = 12 * 1024 * 1024;
247
247
  const AGENT_BINARY_INGRESS_QUEUE_MAX_AGE_MS = 120;
248
248
  const MODE5_AGENT_BINARY_INGRESS_QUEUE_PACKETS = 2;
@@ -1089,7 +1089,7 @@ async function fetchTextWithTimeout(url, timeoutMs) {
1089
1089
  signal: controller.signal,
1090
1090
  headers: {
1091
1091
  accept: 'application/json,text/plain;q=0.9,*/*;q=0.1',
1092
- 'user-agent': 'LiveDesk-Hub/0.1'
1092
+ 'user-agent': 'VuvoDesk-Hub/0.1'
1093
1093
  }
1094
1094
  });
1095
1095
  if (!response.ok) {
@@ -1244,11 +1244,11 @@ const SUPPORTED_AGENT_OPERATIONS = new Set([
1244
1244
  'application.close',
1245
1245
  'file.read',
1246
1246
  'file.write',
1247
- 'file.delete',
1248
- 'file.list',
1249
- 'file.search',
1250
- 'directory.create',
1251
- 'command.run',
1247
+ 'file.delete',
1248
+ 'file.list',
1249
+ 'file.search',
1250
+ 'directory.create',
1251
+ 'command.run',
1252
1252
  'script.run',
1253
1253
  'software.install',
1254
1254
  'network.status',
@@ -1280,7 +1280,7 @@ function readRawRemoteInputMonitorIndex(input) {
1280
1280
  return undefined;
1281
1281
  }
1282
1282
 
1283
- function normalizeRemoteInputEvent(value = {}) {
1283
+ function normalizeRemoteInputEvent(value = {}) {
1284
1284
  const input = value && typeof value === 'object' ? value : {};
1285
1285
  const type = safeString(input.type || input.Type, 48);
1286
1286
  const normalizedX = Number(input.normalizedX ?? input.NormalizedX);
@@ -1301,14 +1301,14 @@ function normalizeRemoteInputEvent(value = {}) {
1301
1301
  .filter(Boolean))]
1302
1302
  .slice(0, 64)
1303
1303
  : null;
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;
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;
1312
1312
  let code = safeString(input.code ?? input.Code, 80);
1313
1313
  if ((!code || code === 'Unidentified') && (key === ' ' || key === 'Spacebar' || keyCode === 32)) {
1314
1314
  code = 'Space';
@@ -1333,13 +1333,13 @@ function normalizeRemoteInputEvent(value = {}) {
1333
1333
  shiftKey: input.shiftKey === true || input.ShiftKey === true,
1334
1334
  ctrlKey: input.ctrlKey === true || input.ControlKey === true || input.CtrlKey === true,
1335
1335
  altKey: input.altKey === true || input.AltKey === 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,
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,
1343
1343
  controlLeaseId: safeString(input.controlLeaseId || input.ControlLeaseId, 128),
1344
1344
  controlSessionId: safeString(input.controlSessionId || input.sessionId || input.ControlSessionId || input.SessionId, 160),
1345
1345
  controlCommandId: safeString(input.controlCommandId || input.commandId || input.ControlCommandId || input.CommandId, 128),
@@ -2331,29 +2331,29 @@ export function createRemoteHub(options = {}) {
2331
2331
 
2332
2332
  const enabled = isEnabledValue(env.LIVEDESK_REMOTE_HUB ?? env.MINDEXEC_REMOTE_HUB ?? env.REMOTE_HUB_ENABLED, true)
2333
2333
  && !isDisabledValue(env.REMOTE_HUB_DISABLED);
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,
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,
2357
2357
  30 * 60 * 1000,
2358
2358
  DEFAULT_AGENT_TASK_TIMEOUT_MS);
2359
2359
  const inputAckTimeoutMs = clampNumber(
@@ -2416,18 +2416,18 @@ export function createRemoteHub(options = {}) {
2416
2416
  const audioSockets = new Map();
2417
2417
  const fileSockets = new Map();
2418
2418
  const allSockets = new Set();
2419
- const agentBinaryIngressStates = new Set();
2420
- const pendingCommandResultWaiters = new Map();
2421
- const clipboardOperations = new Map();
2419
+ const agentBinaryIngressStates = new Set();
2420
+ const pendingCommandResultWaiters = new Map();
2421
+ const clipboardOperations = new Map();
2422
2422
  const transportDiagnostics = new Map();
2423
2423
  const transportDiagnosticStartingDevices = new Set();
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;
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;
2431
2431
  let publicIPv4Cache = {
2432
2432
  address: safeString(env.LIVEDESK_REMOTE_PUBLIC_IPV4 || env.LIVEDESK_PUBLIC_IPV4 || env.REMOTE_HUB_PUBLIC_IPV4, 64),
2433
2433
  updatedAt: 0,
@@ -2748,14 +2748,14 @@ export function createRemoteHub(options = {}) {
2748
2748
  protocol: 'tcp-jsonl',
2749
2749
  protocolVersion: REMOTE_PROTOCOL_VERSION,
2750
2750
  agentProtocol: REMOTE_AGENT_PROTOCOL,
2751
- frameProtocol: buildRemoteFrameProtocolDescriptor(),
2752
- frameModes: getSupportedRemoteFrameModeProfiles(),
2753
- heartbeatMs,
2754
- deviceConnectionStaleMs,
2755
- deviceConnectionSweepMs,
2756
- taskTimeoutMs,
2757
- managerPackage,
2758
- managerVersion,
2751
+ frameProtocol: buildRemoteFrameProtocolDescriptor(),
2752
+ frameModes: getSupportedRemoteFrameModeProfiles(),
2753
+ heartbeatMs,
2754
+ deviceConnectionStaleMs,
2755
+ deviceConnectionSweepMs,
2756
+ taskTimeoutMs,
2757
+ managerPackage,
2758
+ managerVersion,
2759
2759
  hostInstanceId,
2760
2760
  agentPackage: '@livedesk/client',
2761
2761
  agentEndpoint: routeInfo.endpoint,
@@ -3018,7 +3018,7 @@ export function createRemoteHub(options = {}) {
3018
3018
  return true;
3019
3019
  }
3020
3020
 
3021
- function failPendingCommandResultWaiters(device, error = 'device-disconnected') {
3021
+ function failPendingCommandResultWaiters(device, error = 'device-disconnected') {
3022
3022
  if (!device?.deviceId) {
3023
3023
  return;
3024
3024
  }
@@ -3035,31 +3035,31 @@ export function createRemoteHub(options = {}) {
3035
3035
  result: null,
3036
3036
  error: safeString(error, 500) || 'command-result-unavailable'
3037
3037
  });
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') {
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') {
3063
3063
  const normalizedCommandId = safeString(commandId, 128);
3064
3064
  const waiterKey = buildPendingCommandResultWaiterKey(
3065
3065
  device?.deviceId,
@@ -3080,7 +3080,7 @@ export function createRemoteHub(options = {}) {
3080
3080
  return true;
3081
3081
  }
3082
3082
 
3083
- function waitForCommandResult(device, commandId, timeoutMs = liveCaptureStopAckTimeoutMs, metadata = {}) {
3083
+ function waitForCommandResult(device, commandId, timeoutMs = liveCaptureStopAckTimeoutMs, metadata = {}) {
3084
3084
  const normalizedCommandId = safeString(commandId, 128);
3085
3085
  const waiterKey = buildPendingCommandResultWaiterKey(
3086
3086
  device?.deviceId,
@@ -3106,13 +3106,13 @@ export function createRemoteHub(options = {}) {
3106
3106
  error: 'command-result-wait-replaced'
3107
3107
  });
3108
3108
  }
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,
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,
3116
3116
  deadlineImmediate: null,
3117
3117
  resolve
3118
3118
  };
@@ -3145,246 +3145,246 @@ export function createRemoteHub(options = {}) {
3145
3145
  });
3146
3146
  }, timeoutMs);
3147
3147
  waiterRecord.timer.unref?.();
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
- }
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
+ }
3388
3388
 
3389
3389
  function inspectAnnexBH264(payload) {
3390
3390
  if (!Buffer.isBuffer(payload) || payload.length < 5) {
@@ -4266,15 +4266,15 @@ export function createRemoteHub(options = {}) {
4266
4266
  }
4267
4267
  }
4268
4268
 
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;
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;
4278
4278
  fileSockets.delete(socket);
4279
4279
  try {
4280
4280
  writeJsonLine(socket, { type: 'disconnect', channel: 'file', reason });
@@ -4288,22 +4288,24 @@ export function createRemoteHub(options = {}) {
4288
4288
  }
4289
4289
  }
4290
4290
 
4291
- function closeInputSocket(device, reason = 'input-socket-closed') {
4291
+ function closeInputSocket(device, reason = 'input-socket-closed') {
4292
4292
  const socket = device?.inputSocket;
4293
4293
  if (!socket) {
4294
4294
  return;
4295
4295
  }
4296
4296
 
4297
- const writeOwner = device.inputWriteOwner;
4298
- clearClipboardOperationsForDevice(device, reason);
4297
+ const writeOwner = device.inputWriteOwner;
4298
+ clearClipboardOperationsForDevice(device, reason);
4299
4299
  device.inputSocket = null;
4300
4300
  device.inputWriteOwner = null;
4301
4301
  device.inputSocketConnectionId = '';
4302
4302
  device.inputOwnerConnectionId = '';
4303
- device.inputOwnerBindingKey = '';
4304
- inputSockets.delete(socket);
4305
- writeOwner?.close?.(reason);
4306
- try {
4303
+ device.inputOwnerBindingKey = '';
4304
+ inputSockets.delete(socket);
4305
+ writeOwner?.close?.(reason);
4306
+ device.inputLastSeenAt = new Date().toISOString();
4307
+ emitRemoteEvent('RemoteInputSocketDisconnected', device, { reason });
4308
+ try {
4307
4309
  writeJsonLine(socket, { type: 'disconnect', reason });
4308
4310
  } catch {
4309
4311
  // Best-effort notice before closing the side channel.
@@ -4315,7 +4317,7 @@ export function createRemoteHub(options = {}) {
4315
4317
  }
4316
4318
  }
4317
4319
 
4318
- function detachInputSocket(socket, reason = 'input-socket-closed') {
4320
+ function detachInputSocket(socket, reason = 'input-socket-closed') {
4319
4321
  const deviceId = inputSockets.get(socket);
4320
4322
  inputSockets.delete(socket);
4321
4323
  if (!deviceId) {
@@ -4327,8 +4329,8 @@ export function createRemoteHub(options = {}) {
4327
4329
  return;
4328
4330
  }
4329
4331
 
4330
- const writeOwner = device.inputWriteOwner;
4331
- clearClipboardOperationsForDevice(device, reason);
4332
+ const writeOwner = device.inputWriteOwner;
4333
+ clearClipboardOperationsForDevice(device, reason);
4332
4334
  device.inputSocket = null;
4333
4335
  device.inputWriteOwner = null;
4334
4336
  device.inputSocketConnectionId = '';
@@ -4373,7 +4375,7 @@ export function createRemoteHub(options = {}) {
4373
4375
  emitRemoteEvent('RemoteAudioSocketDisconnected', device, { reason });
4374
4376
  }
4375
4377
 
4376
- function detachFileSocket(socket, reason = 'file-socket-closed') {
4378
+ function detachFileSocket(socket, reason = 'file-socket-closed') {
4377
4379
  const deviceId = fileSockets.get(socket);
4378
4380
  fileSockets.delete(socket);
4379
4381
  if (!deviceId) {
@@ -4381,107 +4383,107 @@ export function createRemoteHub(options = {}) {
4381
4383
  }
4382
4384
 
4383
4385
  const device = devices.get(deviceId);
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;
4386
+ if (!device || device.fileSocket !== socket) {
4387
+ return;
4388
+ }
4389
+
4390
+ clearClipboardOperationsForDevice(device, reason);
4391
+ failPendingFileCommandResultWaiters(device, 'clipboard-file-channel-disconnected');
4392
+ device.fileSocket = null;
4391
4393
  device.fileLastSeenAt = new Date().toISOString();
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()) {
4394
+ emitRemoteEvent('RemoteFileSocketDisconnected', device, { reason });
4395
+ }
4396
+
4397
+ function getDeviceConnectionHeartbeatMs(device) {
4398
+ return Date.parse(device?.lastStatusAt || '')
4399
+ || Date.parse(device?.connectedAt || '')
4400
+ || 0;
4401
+ }
4402
+
4403
+ function isDeviceConnectionFresh(device, nowMs = Date.now()) {
4402
4404
  if (!device?.connected || !device.socket || device.socket.destroyed) {
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) {
4405
+ return false;
4406
+ }
4407
+
4408
+ const lastHeartbeatMs = getDeviceConnectionHeartbeatMs(device);
4409
+ if (!lastHeartbeatMs) {
4410
+ return true;
4411
+ }
4412
+
4413
+ return nowMs - lastHeartbeatMs <= deviceConnectionStaleMs;
4414
+ }
4415
+
4416
+ function retireStaleDeviceConnection(device, reason = 'status-heartbeat-timeout') {
4417
+ const deviceId = safeString(device?.deviceId, 160);
4418
+ const sessionId = safeString(device?.sessionId, 160);
4419
+ const socket = device?.socket;
4420
+ if (!deviceId
4421
+ || !sessionId
4422
+ || !socket
4423
+ || socket.destroyed
4424
+ || devices.get(deviceId) !== device
4425
+ || device.socket !== socket
4426
+ || device.sessionId !== sessionId) {
4427
+ return false;
4428
+ }
4429
+
4430
+ closeInputSocket(device, reason);
4431
+ closeFrameSocket(device, reason);
4432
+ closeAudioSocket(device, reason);
4433
+ closeFileSocket(device, reason);
4434
+ try {
4435
+ writeJsonLine(socket, { type: 'disconnect', reason, sessionId });
4436
+ } catch {
4437
+ // The stale connection is retired below even if the final notice fails.
4438
+ }
4439
+ detachSocket(socket, reason);
4440
+ try {
4441
+ socket.destroy?.();
4442
+ } catch {
4443
+ // detachSocket already made the exact session terminal.
4444
+ }
4445
+ return true;
4446
+ }
4447
+
4448
+ function sweepStaleDeviceConnections(nowMs = Date.now()) {
4449
+ pruneClipboardOperations(nowMs);
4450
+ let retired = 0;
4451
+ for (const device of devices.values()) {
4452
+ if (device?.synthetic === true || isDeviceConnectionFresh(device, nowMs)) {
4453
+ continue;
4454
+ }
4455
+ if (!device?.connected || !device.socket || device.socket.destroyed) {
4456
+ continue;
4457
+ }
4458
+ const ageMs = Math.max(0, nowMs - getDeviceConnectionHeartbeatMs(device));
4459
+ if (retireStaleDeviceConnection(device, 'status-heartbeat-timeout')) {
4460
+ retired += 1;
4461
+ logWarn(
4462
+ 'remote',
4463
+ `retired stale device session ${device.deviceName} (${device.deviceId}) `
4464
+ + `session=${device.sessionId} heartbeatAgeMs=${ageMs} `
4465
+ + `staleAfterMs=${deviceConnectionStaleMs}`);
4466
+ }
4467
+ }
4468
+ return retired;
4469
+ }
4470
+
4471
+ function scheduleDeviceConnectionSweep() {
4472
+ if (!started || deviceConnectionSweepTimer) {
4473
+ return;
4474
+ }
4475
+ deviceConnectionSweepTimer = setTimeout(() => {
4476
+ deviceConnectionSweepTimer = null;
4477
+ if (!started) {
4478
+ return;
4479
+ }
4480
+ sweepStaleDeviceConnections();
4481
+ scheduleDeviceConnectionSweep();
4482
+ }, deviceConnectionSweepMs);
4483
+ deviceConnectionSweepTimer.unref?.();
4484
+ }
4485
+
4486
+ function rejectDuplicateActiveDevice(socket, existing, attemptedSessionId) {
4485
4487
  const retryAfterMs = Math.max(heartbeatMs, duplicateDeviceRetryAfterMs);
4486
4488
  writeJsonLine(socket, {
4487
4489
  type: 'disconnect',
@@ -4933,20 +4935,20 @@ export function createRemoteHub(options = {}) {
4933
4935
  return device;
4934
4936
  }
4935
4937
 
4936
- function attachInputSocket(socket, hello) {
4937
- const deviceId = normalizeDeviceId(hello.deviceId);
4938
- const device = devices.get(deviceId);
4938
+ function attachInputSocket(socket, hello) {
4939
+ const deviceId = normalizeDeviceId(hello.deviceId);
4940
+ const device = devices.get(deviceId);
4939
4941
  if (!device || !device.connected || !device.socket || device.socket.destroyed) {
4940
4942
  writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
4941
4943
  socket.destroy();
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');
4944
+ return null;
4945
+ }
4946
+ if (safeString(hello.parentSessionId, 160) !== safeString(device.sessionId, 160)) {
4947
+ writeJsonLineAndClose(socket, { type: 'error', error: 'side-channel-parent-session-stale' });
4948
+ return null;
4949
+ }
4950
+
4951
+ closeInputSocket(device, 'replaced-by-new-input-socket');
4950
4952
 
4951
4953
  const now = new Date().toISOString();
4952
4954
  const inputSocketConnectionId = crypto.randomUUID();
@@ -5060,20 +5062,20 @@ export function createRemoteHub(options = {}) {
5060
5062
  return device;
5061
5063
  }
5062
5064
 
5063
- function attachFileSocket(socket, hello) {
5064
- const deviceId = normalizeDeviceId(hello.deviceId);
5065
- const device = devices.get(deviceId);
5065
+ function attachFileSocket(socket, hello) {
5066
+ const deviceId = normalizeDeviceId(hello.deviceId);
5067
+ const device = devices.get(deviceId);
5066
5068
  if (!device || !device.connected || !device.socket || device.socket.destroyed) {
5067
5069
  writeJsonLine(socket, { type: 'error', error: 'device-not-connected' });
5068
5070
  socket.destroy();
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');
5071
+ return null;
5072
+ }
5073
+ if (safeString(hello.parentSessionId, 160) !== safeString(device.sessionId, 160)) {
5074
+ writeJsonLineAndClose(socket, { type: 'error', error: 'side-channel-parent-session-stale' });
5075
+ return null;
5076
+ }
5077
+
5078
+ closeFileSocket(device, 'replaced-by-new-file-socket');
5077
5079
  const now = new Date().toISOString();
5078
5080
  device.fileSocket = socket;
5079
5081
  device.fileConnectedAt = now;
@@ -8376,9 +8378,9 @@ export function createRemoteHub(options = {}) {
8376
8378
  started = true;
8377
8379
  boundPort = candidateServer.address()?.port || port;
8378
8380
  lastError = '';
8379
- logEvent('remote', `LiveDesk Hub client endpoint listening on tcp://${host}:${boundPort}`, 'success');
8381
+ logEvent('remote', `VuvoDesk Hub client endpoint listening on tcp://${host}:${boundPort}`, 'success');
8380
8382
  if (host === '0.0.0.0' || host === '::') {
8381
- logWarn('remote', 'LiveDesk Hub client endpoint is externally reachable. Use a strong pairing token and trusted network.');
8383
+ logWarn('remote', 'VuvoDesk Hub client endpoint is externally reachable. Use a strong pairing token and trusted network.');
8382
8384
  }
8383
8385
  emitRemoteEvent('RemoteHubStarted', null);
8384
8386
  resolve();
@@ -8396,14 +8398,14 @@ export function createRemoteHub(options = {}) {
8396
8398
  await listenOnPort(requestedPort);
8397
8399
  try {
8398
8400
  await relayControl?.start?.();
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(
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.`);
8401
+ } catch {
8402
+ logWarn('relay', 'Encrypted relay control could not start; direct TCP remains available.');
8403
+ }
8404
+ scheduleDeviceConnectionSweep();
8405
+ } catch (error) {
8406
+ if (error?.code === 'EADDRINUSE') {
8407
+ const conflict = new Error(
8408
+ `VuvoDesk 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.`);
8407
8409
  conflict.code = 'EADDRINUSE';
8408
8410
  conflict.port = requestedPort;
8409
8411
  conflict.cause = error;
@@ -8414,16 +8416,16 @@ export function createRemoteHub(options = {}) {
8414
8416
  }
8415
8417
 
8416
8418
  return getStatus({ includeSecrets: false });
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
- }
8419
+ }
8420
+
8421
+ async function close() {
8422
+ if (deviceConnectionSweepTimer) {
8423
+ clearTimeout(deviceConnectionSweepTimer);
8424
+ deviceConnectionSweepTimer = null;
8425
+ }
8426
+ for (const state of [...agentBinaryIngressStates]) {
8427
+ closeAgentBinaryIngress(state, 'hub-shutdown');
8428
+ }
8427
8429
 
8428
8430
  for (const device of devices.values()) {
8429
8431
  abortTransportDiagnosticsForDevice(device, 'hub-shutdown');
@@ -8558,12 +8560,12 @@ export function createRemoteHub(options = {}) {
8558
8560
  };
8559
8561
  }
8560
8562
 
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
- }
8563
+ function sendCommand(deviceId, command) {
8564
+ const device = devices.get(String(deviceId || ''));
8565
+ const commandName = safeString(command?.command || 'ping', 80);
8566
+ if (REMOTE_CLIPBOARD_FILE_ACTIONS.has(commandName)) {
8567
+ return { ok: false, error: 'clipboard-command-requires-awaited-file-channel' };
8568
+ }
8567
8569
  const requiredPermission = commandName === 'input.control'
8568
8570
  ? 'allowControl'
8569
8571
  : commandName === 'system.power'
@@ -8599,16 +8601,16 @@ export function createRemoteHub(options = {}) {
8599
8601
  result: { ok: true, synthetic: true },
8600
8602
  error: ''
8601
8603
  });
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
- }
8604
+ return { ok: true, commandId, synthetic: true };
8605
+ }
8606
+
8607
+ if (device?.connected && !isDeviceConnectionFresh(device)) {
8608
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
8609
+ return { ok: false, error: 'device-not-connected' };
8610
+ }
8611
+ if (!device?.socket || device.socket.destroyed || !device.connected) {
8612
+ return { ok: false, error: 'device-not-connected' };
8613
+ }
8612
8614
 
8613
8615
  const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
8614
8616
  const payload = {
@@ -8621,300 +8623,300 @@ export function createRemoteHub(options = {}) {
8621
8623
 
8622
8624
  const dedicatedFileSocket = commandName.startsWith('file.transfer')
8623
8625
  && device.fileSocket
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,
8626
+ && !device.fileSocket.destroyed
8627
+ ? device.fileSocket
8628
+ : null;
8629
+ try {
8630
+ writeJsonLine(dedicatedFileSocket || device.socket, payload);
8631
+ } catch {
8632
+ retireStaleDeviceConnection(device, 'command-channel-write-failed');
8633
+ return { ok: false, error: 'device-not-connected' };
8634
+ }
8635
+ device.counters.commandsSent += 1;
8636
+ emitRemoteEvent('RemoteCommandQueued', device, {
8637
+ commandId,
8636
8638
  command: payload.command,
8637
8639
  channel: dedicatedFileSocket ? 'file' : 'control'
8638
8640
  });
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) {
8641
+ return { ok: true, commandId };
8642
+ }
8643
+
8644
+ async function sendClipboardCommand(deviceId, request = {}) {
8645
+ pruneClipboardOperations();
8646
+ const action = safeString(request.action, 80).toLowerCase();
8647
+ if (!REMOTE_CLIPBOARD_FILE_ACTIONS.has(action)) {
8648
+ return { ok: false, error: 'clipboard-file-action-invalid' };
8649
+ }
8650
+ const operationId = safeString(request.operationId, 128);
8651
+ if (!operationId) {
8652
+ return { ok: false, error: 'clipboard-operation-id-invalid' };
8653
+ }
8654
+
8655
+ const direction = action.startsWith('clipboard.paste.')
8656
+ ? 'paste'
8657
+ : action.startsWith('clipboard.copy.')
8658
+ ? 'copy'
8659
+ : '';
8660
+ const operationKey = buildClipboardOperationKey(deviceId, request.binding?.controlSessionId, operationId);
8661
+ let operation = clipboardOperations.get(operationKey);
8662
+ const beginsOperation = action === 'clipboard.copy.begin' || action === 'clipboard.paste.begin';
8663
+ const endsOperation = action === 'clipboard.copy.end' || action === 'clipboard.paste.cancel';
8664
+ const requestedManifest = request.payload?.manifest || null;
8665
+
8666
+ if (beginsOperation) {
8667
+ if (operation) {
8668
+ return { ok: false, error: 'clipboard-operation-already-exists' };
8669
+ }
8670
+ const activeForDevice = [...clipboardOperations.values()].filter(candidate =>
8671
+ candidate.deviceId === String(deviceId || '')
8672
+ && candidate.sessionId === request.binding?.controlSessionId);
8673
+ if (activeForDevice.some(candidate => candidate.direction === direction)) {
8674
+ return { ok: false, error: 'clipboard-operation-direction-busy' };
8675
+ }
8676
+ if (activeForDevice.length >= REMOTE_CLIPBOARD_LIMITS.maxOperationsPerDevice) {
8677
+ return { ok: false, error: 'clipboard-operation-capacity-reached' };
8678
+ }
8679
+ operation = {
8680
+ deviceId: String(deviceId || ''),
8681
+ sessionId: safeString(request.binding?.controlSessionId, 160),
8682
+ operationId,
8683
+ direction,
8684
+ hubConnectionId: safeString(request.hubConnectionId, 128),
8685
+ controlCommandId: safeString(request.binding?.controlCommandId, 128),
8686
+ captureGeneration: Number(request.binding?.captureGeneration || 0),
8687
+ monitorIndex: Number(request.binding?.monitorIndex),
8688
+ contentKind: requestedManifest?.contentKind || 'text',
8689
+ manifest: requestedManifest,
8690
+ offsets: new Map(),
8691
+ status: action === 'clipboard.paste.begin' ? 'preparing' : 'copying',
8692
+ inFlight: false,
8693
+ touchedAt: Date.now()
8694
+ };
8695
+ clipboardOperations.set(operationKey, operation);
8696
+ } else {
8697
+ if (!operation) {
8698
+ return { ok: false, error: 'clipboard-operation-not-found' };
8699
+ }
8700
+ const requestedDirection = direction || operation.direction;
8701
+ if (operation.direction !== requestedDirection
8702
+ || operation.hubConnectionId !== safeString(request.hubConnectionId, 128)
8703
+ || operation.controlCommandId !== safeString(request.binding?.controlCommandId, 128)
8704
+ || operation.captureGeneration !== Number(request.binding?.captureGeneration || 0)
8705
+ || operation.monitorIndex !== Number(request.binding?.monitorIndex)) {
8706
+ return { ok: false, error: 'clipboard-operation-owner-stale' };
8707
+ }
8708
+ }
8709
+
8710
+ if (operation.inFlight) {
8711
+ return { ok: false, error: 'clipboard-operation-busy' };
8712
+ }
8713
+
8714
+ const contentKind = operation.contentKind || requestedManifest?.contentKind || 'text';
8715
+ const bindingResult = validateCurrentClipboardBinding(
8716
+ deviceId,
8717
+ { ...request, clipboardDirection: operation.direction },
8718
+ contentKind,
8719
+ { requireFileSocket: true });
8720
+ if (!bindingResult.ok) {
8721
+ if (beginsOperation || bindingResult.error?.startsWith('STALE_') || bindingResult.error === 'input-owner-not-current') {
8722
+ clipboardOperations.delete(operationKey);
8723
+ }
8724
+ return bindingResult;
8725
+ }
8726
+
8727
+ const { device } = bindingResult;
8728
+ const fileSocket = device.fileSocket;
8729
+ const itemIndex = request.payload?.itemIndex;
8730
+ const expectedOffset = Number.isInteger(itemIndex)
8731
+ ? Number(operation.offsets.get(itemIndex) || 0)
8732
+ : 0;
8733
+ let outgoingChunkBytes = 0;
8734
+ if (action === 'clipboard.copy.chunk' || action === 'clipboard.paste.chunk') {
8735
+ const item = operation.manifest?.items?.[itemIndex];
8736
+ if (!item) {
8737
+ return { ok: false, error: 'clipboard-item-not-found' };
8738
+ }
8739
+ if (request.payload.offset !== expectedOffset) {
8740
+ return { ok: false, error: 'clipboard-chunk-offset-stale', expectedOffset };
8741
+ }
8742
+ if (action === 'clipboard.copy.chunk'
8743
+ && request.payload.maxBytes > Number(device.capabilities?.clipboardMaxChunkBytes || 0)) {
8744
+ return { ok: false, error: 'device-clipboard-chunk-limit-exceeded' };
8745
+ }
8746
+ if (action === 'clipboard.paste.chunk') {
8747
+ outgoingChunkBytes = Buffer.from(request.payload.dataBase64, 'base64').length;
8748
+ if (outgoingChunkBytes > Number(device.capabilities?.clipboardMaxChunkBytes || 0)) {
8749
+ return { ok: false, error: 'device-clipboard-chunk-limit-exceeded' };
8750
+ }
8751
+ const nextOffset = expectedOffset + outgoingChunkBytes;
8752
+ if (outgoingChunkBytes === 0
8753
+ && !(item.size === 0 && expectedOffset === 0 && request.payload.final === true)) {
8754
+ return { ok: false, error: 'clipboard-empty-chunk-invalid' };
8755
+ }
8756
+ if (nextOffset > item.size
8757
+ || request.payload.final !== (nextOffset === item.size)) {
8758
+ return { ok: false, error: 'clipboard-chunk-boundary-invalid' };
8759
+ }
8760
+ }
8761
+ }
8762
+
8763
+ operation.inFlight = true;
8764
+ operation.touchedAt = Date.now();
8765
+ let commandCompleted = false;
8766
+ const commandId = crypto.randomUUID();
8767
+ const resultPromise = waitForCommandResult(
8768
+ device,
8769
+ commandId,
8770
+ REMOTE_CLIPBOARD_LIMITS.commandTimeoutMs,
8771
+ { channel: 'file', operationId });
8772
+ const commandPayload = {
8773
+ type: 'command',
8774
+ commandId,
8775
+ command: action,
8776
+ payload: {
8777
+ operationId,
8778
+ controlSessionId: operation.sessionId,
8779
+ controlCommandId: operation.controlCommandId,
8780
+ captureGeneration: operation.captureGeneration,
8781
+ monitorIndex: operation.monitorIndex,
8782
+ hubConnectionId: operation.hubConnectionId,
8783
+ ...(request.payload || {})
8784
+ },
8785
+ issuedAt: new Date().toISOString()
8786
+ };
8787
+
8788
+ try {
8789
+ try {
8790
+ // Clipboard payloads are never allowed onto the concurrent
8791
+ // main command socket. The dedicated file side channel owns
8792
+ // the one bounded command and its exact-session waiter.
8793
+ writeJsonLine(fileSocket, commandPayload);
8794
+ } catch {
8795
+ failPendingCommandResultWaiter(device, commandId, 'clipboard-file-channel-write-failed');
8796
+ if (device.fileSocket === fileSocket) {
8797
+ closeFileSocket(device, 'clipboard-file-channel-write-failed');
8798
+ }
8799
+ return { ok: false, error: 'clipboard-file-channel-unavailable' };
8800
+ }
8801
+ device.counters.commandsSent += 1;
8802
+ emitRemoteEvent('RemoteCommandQueued', device, {
8803
+ commandId,
8804
+ command: action,
8805
+ channel: 'file',
8806
+ operationId
8807
+ });
8808
+
8809
+ const awaited = await resultPromise;
8810
+ if (!awaited.ok) {
8811
+ return {
8812
+ ok: false,
8813
+ error: awaited.error || 'clipboard-command-failed',
8814
+ commandId
8815
+ };
8816
+ }
8817
+ if (device.fileSocket !== fileSocket) {
8818
+ return { ok: false, error: 'clipboard-file-channel-owner-changed' };
8819
+ }
8820
+ const currentBinding = validateCurrentClipboardBinding(
8821
+ deviceId,
8822
+ { ...request, clipboardDirection: operation.direction },
8823
+ operation.contentKind || 'text',
8824
+ { requireFileSocket: true });
8825
+ if (!currentBinding.ok) {
8826
+ return currentBinding;
8827
+ }
8828
+
8829
+ let response;
8830
+ try {
8831
+ response = normalizeRemoteClipboardCommandResult(action, operationId, awaited.result);
8832
+ } catch (error) {
8833
+ return {
8834
+ ok: false,
8835
+ error: safeString(error?.code || error?.message, 160) || 'clipboard-response-invalid'
8836
+ };
8837
+ }
8838
+ if (response.manifest) {
8839
+ const responseManifestLimitError = clipboardManifestCapabilityError(device, response.manifest);
8840
+ if (responseManifestLimitError) {
8841
+ clipboardOperations.delete(operationKey);
8842
+ return { ok: false, error: responseManifestLimitError };
8843
+ }
8844
+ const responsePolicy = validateCurrentClipboardBinding(
8845
+ deviceId,
8846
+ { ...request, clipboardDirection: operation.direction },
8847
+ response.manifest.contentKind,
8848
+ { requireFileSocket: true });
8849
+ if (!responsePolicy.ok) {
8850
+ clipboardOperations.delete(operationKey);
8851
+ return responsePolicy;
8852
+ }
8853
+ operation.manifest = response.manifest;
8854
+ operation.contentKind = response.manifest.contentKind;
8855
+ }
8856
+ if (action === 'clipboard.copy.chunk') {
8857
+ const item = operation.manifest?.items?.[response.itemIndex];
8858
+ const nextOffset = response.offset + response.bytes;
8859
+ if (!item
8860
+ || response.itemIndex !== itemIndex
8861
+ || response.offset !== expectedOffset
8862
+ || (response.bytes === 0
8863
+ && !(item.size === 0 && response.offset === 0 && response.final === true))
8864
+ || nextOffset > item.size
8865
+ || response.final !== (nextOffset === item.size)) {
8866
+ return { ok: false, error: 'clipboard-chunk-response-boundary-invalid' };
8867
+ }
8868
+ if (response.bytes > Number(device.capabilities?.clipboardMaxChunkBytes || 0)) {
8869
+ return { ok: false, error: 'device-clipboard-chunk-limit-exceeded' };
8870
+ }
8871
+ operation.offsets.set(response.itemIndex, nextOffset);
8872
+ } else if (action === 'clipboard.paste.chunk') {
8873
+ if (response.itemIndex !== itemIndex
8874
+ || response.offset !== expectedOffset
8875
+ || response.bytes !== outgoingChunkBytes
8876
+ || response.totalBytes !== operation.manifest.items[itemIndex].size
8877
+ || response.final !== request.payload.final) {
8878
+ return { ok: false, error: 'clipboard-chunk-response-boundary-invalid' };
8879
+ }
8880
+ operation.offsets.set(itemIndex, expectedOffset + outgoingChunkBytes);
8881
+ }
8882
+ operation.status = response.status || operation.status;
8883
+ operation.touchedAt = Date.now();
8884
+ if (action === 'clipboard.operation.status' && operation.status === 'not-found') {
8885
+ clipboardOperations.delete(operationKey);
8886
+ return { ok: false, error: 'clipboard-operation-not-found' };
8887
+ }
8888
+ if (action === 'clipboard.operation.status'
8889
+ && ['completed', 'complete', 'applied', 'pasted', 'cancelled', 'cleaned', 'failed', 'expired', 'already-ended']
8890
+ .includes(operation.status)) {
8891
+ clipboardOperations.delete(operationKey);
8892
+ }
8893
+ commandCompleted = true;
8894
+ return response;
8895
+ } finally {
8896
+ const currentOperation = clipboardOperations.get(operationKey);
8897
+ if (currentOperation === operation) {
8898
+ operation.inFlight = false;
8899
+ if (endsOperation || (beginsOperation && !commandCompleted)) {
8900
+ clipboardOperations.delete(operationKey);
8901
+ }
8902
+ }
8903
+ }
8904
+ }
8905
+
8906
+ function refreshDevicePolicies(deviceIds = undefined) {
8905
8907
  const requestedIds = Array.isArray(deviceIds) && deviceIds.length > 0
8906
8908
  ? new Set(deviceIds.map(deviceId => safeString(deviceId, 160)).filter(Boolean))
8907
8909
  : null;
8908
8910
  let updated = 0;
8909
8911
  let total = 0;
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;
8912
+ for (const device of devices.values()) {
8913
+ if (requestedIds && !requestedIds.has(device.deviceId)) continue;
8914
+ if (!device?.socket || device.socket.destroyed || !device.connected) continue;
8915
+ if (device.synthetic !== true && !isDeviceConnectionFresh(device)) {
8916
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
8917
+ continue;
8918
+ }
8919
+ total += 1;
8918
8920
  const effectivePolicy = getDevicePolicy(device);
8919
8921
  if (writeJsonLine(device.socket, {
8920
8922
  type: 'policy.update',
@@ -8933,14 +8935,14 @@ export function createRemoteHub(options = {}) {
8933
8935
  const device = devices.get(String(deviceId || ''));
8934
8936
  const denied = policyError(device);
8935
8937
  if (denied) return { ok: false, error: denied };
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) {
8938
+ if (!device) return { ok: false, error: 'device-not-found' };
8939
+ if (device.synthetic !== true
8940
+ && device.connected
8941
+ && !isDeviceConnectionFresh(device)) {
8942
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
8943
+ return { ok: false, error: 'device-not-connected' };
8944
+ }
8945
+ if (!device.socket || device.socket.destroyed || !device.connected) {
8944
8946
  return { ok: false, error: 'device-not-connected' };
8945
8947
  }
8946
8948
 
@@ -9026,40 +9028,65 @@ export function createRemoteHub(options = {}) {
9026
9028
  };
9027
9029
  }
9028
9030
 
9029
- function getCurrentInputWriteOwner(device) {
9031
+ function getCurrentInputWriteOwner(device) {
9030
9032
  if (!device?.inputSocket
9031
9033
  || device.inputSocket.destroyed
9032
9034
  || !device.inputWriteOwner
9033
9035
  || !safeString(device.inputSocketConnectionId, 128)) {
9034
9036
  return null;
9035
9037
  }
9036
- return device.inputWriteOwner;
9037
- }
9038
-
9039
- function sendInputControl(deviceId, input = {}) {
9038
+ return device.inputWriteOwner;
9039
+ }
9040
+
9041
+ function getInputRouteState(deviceId) {
9040
9042
  const device = devices.get(String(deviceId || ''));
9041
- if (!device) {
9042
- return { ok: false, error: 'device-not-found' };
9043
+ if (!device || !device.connected || !device.socket || device.socket.destroyed) {
9044
+ return {
9045
+ deviceId: safeString(deviceId, 160),
9046
+ ready: false,
9047
+ dedicatedInputChannel: false,
9048
+ reason: 'device-not-connected'
9049
+ };
9043
9050
  }
9044
9051
 
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
- }
9052
+ const dedicatedInputChannel = readCapabilityFlag(
9053
+ device.capabilities,
9054
+ 'dedicatedInputChannel');
9055
+ const ready = dedicatedInputChannel
9056
+ ? !!getCurrentInputWriteOwner(device)
9057
+ : true;
9058
+ return {
9059
+ deviceId: device.deviceId,
9060
+ ready,
9061
+ dedicatedInputChannel,
9062
+ reason: ready ? 'ready' : 'input-channel-not-connected'
9063
+ };
9064
+ }
9065
+
9066
+ function sendInputControl(deviceId, input = {}) {
9067
+ const device = devices.get(String(deviceId || ''));
9068
+ if (!device) {
9069
+ return { ok: false, error: 'device-not-found' };
9070
+ }
9071
+
9072
+ if (device.synthetic !== true
9073
+ && device.connected
9074
+ && !isDeviceConnectionFresh(device)) {
9075
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
9076
+ return { ok: false, error: 'device-not-connected' };
9077
+ }
9078
+ if (!device.connected) {
9079
+ return { ok: false, error: 'device-not-connected' };
9080
+ }
9054
9081
 
9055
9082
  const normalized = normalizeRemoteInputEvent(input);
9056
9083
  if (!normalized.type) {
9057
9084
  return { ok: false, error: 'missing-input-type' };
9058
9085
  }
9059
9086
 
9060
- // A reset only releases native key state. The current browser input
9061
- // owner must be able to send it even when its former Control binding
9062
- // has just become stale during a monitor/capture-generation switch.
9087
+ // A reset releases native mouse-button and keyboard state. The
9088
+ // current browser input owner must be able to send it even when its
9089
+ // former Control binding has just become stale during a monitor/capture-generation switch.
9063
9090
  const currentInputOwner = safeString(device.inputOwnerConnectionId, 128);
9064
9091
  const isCurrentOwnerKeyboardReset = normalized.type.toLowerCase() === 'keyboard.reset'
9065
9092
  && normalized.hubConnectionId
@@ -9116,51 +9143,51 @@ export function createRemoteHub(options = {}) {
9116
9143
  || device.capabilities?.accessibilityPermission === false) {
9117
9144
  return { ok: false, error: 'INPUT_PERMISSION_DENIED' };
9118
9145
  }
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
- }
9146
+ if (!readCapabilityFlag(device.capabilities, 'control')) {
9147
+ return { ok: false, error: 'device-input-control-unavailable' };
9148
+ }
9149
+
9150
+ const normalizedInputType = normalized.type.toLowerCase();
9151
+ const isClipboardInput = normalizedInputType === 'clipboard.prepare'
9152
+ || normalizedInputType === 'clipboard.copy'
9153
+ || normalizedInputType === 'clipboard.paste';
9154
+ let clipboardManifest = null;
9155
+ if (isClipboardInput) {
9156
+ if (!normalized.clipboardOperationId) {
9157
+ return { ok: false, error: 'clipboard-operation-id-invalid' };
9158
+ }
9159
+ if (!['copy', 'paste'].includes(normalized.clipboardDirection)) {
9160
+ return { ok: false, error: 'clipboard-direction-required' };
9161
+ }
9162
+ if ((normalizedInputType === 'clipboard.copy' && normalized.clipboardDirection !== 'copy')
9163
+ || (normalizedInputType === 'clipboard.paste' && normalized.clipboardDirection !== 'paste')) {
9164
+ return { ok: false, error: 'clipboard-direction-mismatch' };
9165
+ }
9166
+ try {
9167
+ clipboardManifest = normalized.manifest
9168
+ ? normalizeRemoteClipboardManifest(normalized.manifest, normalized.clipboardOperationId)
9169
+ : null;
9170
+ } catch (error) {
9171
+ return { ok: false, error: safeString(error?.code || error?.message, 160) || 'clipboard-manifest-invalid' };
9172
+ }
9173
+ const clipboardPolicy = getDevicePolicy(device);
9174
+ if (clipboardPolicy.allowClipboardText !== true) {
9175
+ return { ok: false, error: 'allowClipboardText-blocked-by-settings' };
9176
+ }
9177
+ const contentKind = clipboardManifest?.contentKind || normalized.contentKind || 'text';
9178
+ if (remoteClipboardContentNeedsFileTransfer(contentKind)
9179
+ && clipboardPolicy.allowFileTransfer !== true) {
9180
+ return { ok: false, error: 'allowFileTransfer-blocked-by-settings' };
9181
+ }
9182
+ const clipboardCapabilityDenied = clipboardCapabilityError(
9183
+ device,
9184
+ normalized.clipboardDirection,
9185
+ contentKind);
9186
+ if (clipboardCapabilityDenied) {
9187
+ return { ok: false, error: clipboardCapabilityDenied };
9188
+ }
9189
+ normalized.manifest = clipboardManifest;
9190
+ }
9164
9191
 
9165
9192
  const controlStream = getActiveControlStream(device);
9166
9193
  if (!controlStream
@@ -9196,32 +9223,32 @@ export function createRemoteHub(options = {}) {
9196
9223
  };
9197
9224
  }
9198
9225
  const activeMonitorIndex = normalizeMonitorIndex(controlStream.monitorIndex);
9199
- if (!Number.isInteger(normalized.monitorIndex)
9200
- || normalized.monitorIndex !== activeMonitorIndex) {
9226
+ if (!Number.isInteger(normalized.monitorIndex)
9227
+ || normalized.monitorIndex !== activeMonitorIndex) {
9201
9228
  return {
9202
9229
  ok: false,
9203
9230
  error: 'STALE_CONTROL_MONITOR',
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
- }
9231
+ activeMonitorIndex
9232
+ };
9233
+ }
9234
+
9235
+ let clipboardOperationKey = '';
9236
+ if (normalizedInputType === 'clipboard.paste') {
9237
+ clipboardOperationKey = buildClipboardOperationKey(
9238
+ device.deviceId,
9239
+ device.sessionId,
9240
+ normalized.clipboardOperationId);
9241
+ const operation = clipboardOperations.get(clipboardOperationKey);
9242
+ if (!operation
9243
+ || operation.direction !== 'paste'
9244
+ || operation.hubConnectionId !== normalized.hubConnectionId
9245
+ || operation.controlCommandId !== normalized.controlCommandId
9246
+ || operation.captureGeneration !== normalized.captureGeneration
9247
+ || operation.monitorIndex !== normalized.monitorIndex
9248
+ || operation.status !== 'ready') {
9249
+ return { ok: false, error: 'clipboard-paste-not-ready' };
9250
+ }
9251
+ }
9225
9252
 
9226
9253
  const inputSocket = device.inputSocket;
9227
9254
  const inputWriteOwner = getCurrentInputWriteOwner(device);
@@ -9238,17 +9265,17 @@ export function createRemoteHub(options = {}) {
9238
9265
  && normalized.hubConnectionId !== previousOwnerConnectionId;
9239
9266
  const bindingChanged = previousInputBindingKey
9240
9267
  && previousInputBindingKey !== activeInputBindingKey;
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(
9268
+ const writeBindingChanged = inputWriteOwner.getBindingKey() !== activeInputBindingKey;
9269
+ if (ownerChanged || bindingChanged || writeBindingChanged) {
9270
+ if (ownerChanged) {
9271
+ clearClipboardOperationsForDevice(
9272
+ device,
9273
+ 'browser-input-owner-changed',
9274
+ previousOwnerConnectionId);
9275
+ } else if (bindingChanged || writeBindingChanged) {
9276
+ clearClipboardOperationsForDevice(device, 'control-input-binding-changed');
9277
+ }
9278
+ const resetSent = inputWriteOwner.replaceBinding(
9252
9279
  activeInputBindingKey,
9253
9280
  buildRemoteInputResetMessage(
9254
9281
  device,
@@ -9278,16 +9305,16 @@ export function createRemoteHub(options = {}) {
9278
9305
  device.inputOwnerConnectionId = normalized.hubConnectionId;
9279
9306
  }
9280
9307
  device.inputOwnerBindingKey = activeInputBindingKey;
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 {
9308
+ device.counters.commandsSent += 1;
9309
+ device.inputLastSeenAt = new Date().toISOString();
9310
+ if (clipboardOperationKey) {
9311
+ const operation = clipboardOperations.get(clipboardOperationKey);
9312
+ if (operation) {
9313
+ operation.status = 'pasting';
9314
+ operation.touchedAt = Date.now();
9315
+ }
9316
+ }
9317
+ return {
9291
9318
  ok: true,
9292
9319
  inputSocket: true,
9293
9320
  sessionId: device.sessionId,
@@ -9322,7 +9349,7 @@ export function createRemoteHub(options = {}) {
9322
9349
  issuedAt: normalized.issuedAt || new Date().toISOString()
9323
9350
  }
9324
9351
  });
9325
- if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
9352
+ if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
9326
9353
  schedulePendingInputFallback(device, {
9327
9354
  commandId: fallback.commandId,
9328
9355
  inputSeq: normalized.inputSeq,
@@ -9334,16 +9361,16 @@ export function createRemoteHub(options = {}) {
9334
9361
  hubReceivedAtEpochMs: normalized.hubReceivedAtEpochMs,
9335
9362
  hubForwardedAtEpochMs,
9336
9363
  fallback: true,
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
- }
9364
+ timeoutTimer: null
9365
+ });
9366
+ }
9367
+ if (fallback.ok && clipboardOperationKey) {
9368
+ const operation = clipboardOperations.get(clipboardOperationKey);
9369
+ if (operation) {
9370
+ operation.status = 'pasting';
9371
+ operation.touchedAt = Date.now();
9372
+ }
9373
+ }
9347
9374
  return fallback.ok
9348
9375
  ? {
9349
9376
  ...fallback,
@@ -9363,15 +9390,15 @@ export function createRemoteHub(options = {}) {
9363
9390
  };
9364
9391
  }
9365
9392
 
9366
- function releaseInputOwner(deviceId, ownerConnectionId, reason = 'browser-input-owner-closed') {
9393
+ function releaseInputOwner(deviceId, ownerConnectionId, reason = 'browser-input-owner-closed') {
9367
9394
  const device = devices.get(String(deviceId || ''));
9368
9395
  const owner = safeString(ownerConnectionId, 128);
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 = '';
9396
+ if (!device || !owner || safeString(device.inputOwnerConnectionId, 128) !== owner) {
9397
+ return { ok: false, error: 'input-owner-not-current' };
9398
+ }
9399
+
9400
+ clearClipboardOperationsForDevice(device, reason, owner);
9401
+ device.inputOwnerConnectionId = '';
9375
9402
  device.inputOwnerBindingKey = '';
9376
9403
  const inputSocket = device.inputSocket;
9377
9404
  const inputWriteOwner = getCurrentInputWriteOwner(device);
@@ -9415,12 +9442,12 @@ export function createRemoteHub(options = {}) {
9415
9442
  };
9416
9443
  const results = targets.map(deviceId => {
9417
9444
  const device = devices.get(deviceId);
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) {
9445
+ if (device?.synthetic === true && device.connected) return { deviceId, ok: true, synthetic: true };
9446
+ if (device?.connected && !isDeviceConnectionFresh(device)) {
9447
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
9448
+ return { deviceId, ok: false, error: 'device-not-connected' };
9449
+ }
9450
+ if (!device?.connected || !device.socket || device.socket.destroyed) {
9424
9451
  return { deviceId, ok: false, error: 'device-not-connected' };
9425
9452
  }
9426
9453
  return writeJsonLine(device.socket, payload)
@@ -9452,7 +9479,7 @@ export function createRemoteHub(options = {}) {
9452
9479
  const denied = policyError(device, 'allowAgent');
9453
9480
  if (denied) return { ok: false, error: denied };
9454
9481
 
9455
- if (device?.synthetic === true && device.connected) {
9482
+ if (device?.synthetic === true && device.connected) {
9456
9483
  const instruction = safeText(options.instruction, MAX_AGENT_TASK_CHARS);
9457
9484
  if (!instruction) {
9458
9485
  return { ok: false, error: 'missing-instruction' };
@@ -9512,14 +9539,14 @@ export function createRemoteHub(options = {}) {
9512
9539
  error: '',
9513
9540
  synthetic: true
9514
9541
  });
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) {
9542
+ return { ok: true, commandId, taskId, approvalLevel, synthetic: true };
9543
+ }
9544
+
9545
+ if (device.connected && !isDeviceConnectionFresh(device)) {
9546
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
9547
+ return { ok: false, error: 'device-not-connected' };
9548
+ }
9549
+ if (!device?.socket || device.socket.destroyed || !device.connected) {
9523
9550
  return { ok: false, error: 'device-not-connected' };
9524
9551
  }
9525
9552
 
@@ -10380,17 +10407,17 @@ export function createRemoteHub(options = {}) {
10380
10407
  };
10381
10408
  }
10382
10409
 
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) {
10410
+ function startLiveStream(deviceId, options = {}) {
10411
+ const device = devices.get(String(deviceId || ''));
10412
+ const denied = policyError(device);
10413
+ if (denied) return { ok: false, error: denied };
10414
+ if (device?.synthetic !== true
10415
+ && device?.connected
10416
+ && !isDeviceConnectionFresh(device)) {
10417
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
10418
+ return { ok: false, error: 'device-not-connected' };
10419
+ }
10420
+ if (device?.liveCapturePause?.token) {
10394
10421
  return {
10395
10422
  ok: false,
10396
10423
  error: 'LIVE_CAPTURE_PAUSED',
@@ -10821,15 +10848,15 @@ export function createRemoteHub(options = {}) {
10821
10848
  };
10822
10849
  }
10823
10850
 
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;
10851
+ function stopLiveStream(deviceId, options = {}) {
10852
+ const device = devices.get(String(deviceId || ''));
10853
+ if (device?.synthetic !== true
10854
+ && device?.connected
10855
+ && !isDeviceConnectionFresh(device)) {
10856
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
10857
+ return { ok: false, error: 'device-not-connected' };
10858
+ }
10859
+ const stopAll = options.stopAll === true;
10833
10860
  const streamPurpose = safeString(options.streamPurpose || options.purpose, 24).toLowerCase();
10834
10861
  const requestedStreamId = safeString(options.streamId, 128);
10835
10862
  const purposeStreams = !stopAll && !requestedStreamId && streamPurpose && device
@@ -11430,18 +11457,18 @@ export function createRemoteHub(options = {}) {
11430
11457
  };
11431
11458
  }
11432
11459
 
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) {
11460
+ function startAudioStream(deviceId, options = {}) {
11461
+ const device = devices.get(String(deviceId || ''));
11462
+ if (!device) {
11463
+ return { ok: false, error: 'device-not-found' };
11464
+ }
11465
+ if (device.synthetic !== true
11466
+ && device.connected
11467
+ && !isDeviceConnectionFresh(device)) {
11468
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
11469
+ return { ok: false, error: 'device-not-connected' };
11470
+ }
11471
+ if (!device.connected) {
11445
11472
  return { ok: false, error: 'device-not-connected' };
11446
11473
  }
11447
11474
  const denied = policyError(device, 'allowRemoteAudio');
@@ -11518,18 +11545,18 @@ export function createRemoteHub(options = {}) {
11518
11545
  return { ok: true, commandId, streamId };
11519
11546
  }
11520
11547
 
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) {
11548
+ function stopAudioStream(deviceId, options = {}) {
11549
+ const device = devices.get(String(deviceId || ''));
11550
+ if (!device) {
11551
+ return { ok: false, error: 'device-not-found' };
11552
+ }
11553
+ if (device.synthetic !== true
11554
+ && device.connected
11555
+ && !isDeviceConnectionFresh(device)) {
11556
+ retireStaleDeviceConnection(device, 'status-heartbeat-timeout');
11557
+ return { ok: false, error: 'device-not-connected' };
11558
+ }
11559
+ if (!device.connected) {
11533
11560
  return { ok: false, error: 'device-not-connected' };
11534
11561
  }
11535
11562
  if (device.synthetic === true || !device.socket || device.socket.destroyed) {
@@ -11742,14 +11769,14 @@ export function createRemoteHub(options = {}) {
11742
11769
  replacementTimerCount: device.liveStreamReplacementTimers instanceof Map
11743
11770
  ? device.liveStreamReplacementTimers.size
11744
11771
  : 0,
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,
11772
+ commandResultWaiterCount: [...pendingCommandResultWaiters.values()]
11773
+ .filter(waiter => waiter.deviceId === device.deviceId
11774
+ && waiter.sessionId === device.sessionId)
11775
+ .length,
11776
+ clipboardOperationCount: [...clipboardOperations.values()]
11777
+ .filter(operation => operation.deviceId === device.deviceId
11778
+ && operation.sessionId === device.sessionId)
11779
+ .length,
11753
11780
  retainedPayloadCount: retainedPayloads.size,
11754
11781
  retainedPayloadBytes: [...retainedPayloads].reduce(
11755
11782
  (total, payload) => total + payload.length,
@@ -11759,9 +11786,9 @@ export function createRemoteHub(options = {}) {
11759
11786
  && snapshot.stopPendingCount === 0
11760
11787
  && snapshot.pendingStartCount === 0
11761
11788
  && snapshot.pendingStopPromiseCount === 0
11762
- && snapshot.replacementTimerCount === 0
11763
- && snapshot.commandResultWaiterCount === 0
11764
- && snapshot.clipboardOperationCount === 0
11789
+ && snapshot.replacementTimerCount === 0
11790
+ && snapshot.commandResultWaiterCount === 0
11791
+ && snapshot.clipboardOperationCount === 0
11765
11792
  && snapshot.retainedPayloadCount === 0
11766
11793
  && snapshot.retainedPayloadBytes === 0;
11767
11794
  return snapshot;
@@ -11773,9 +11800,9 @@ export function createRemoteHub(options = {}) {
11773
11800
  'stopPendingCount',
11774
11801
  'pendingStartCount',
11775
11802
  'pendingStopPromiseCount',
11776
- 'replacementTimerCount',
11777
- 'commandResultWaiterCount',
11778
- 'clipboardOperationCount',
11803
+ 'replacementTimerCount',
11804
+ 'commandResultWaiterCount',
11805
+ 'clipboardOperationCount',
11779
11806
  'retainedPayloadCount',
11780
11807
  'retainedPayloadBytes'
11781
11808
  ]) {
@@ -11788,10 +11815,10 @@ export function createRemoteHub(options = {}) {
11788
11815
  stopPendingCount: 0,
11789
11816
  pendingStartCount: 0,
11790
11817
  pendingStopPromiseCount: 0,
11791
- replacementTimerCount: 0,
11792
- commandResultWaiterCount: 0,
11793
- clipboardOperationCount: 0,
11794
- retainedPayloadCount: 0,
11818
+ replacementTimerCount: 0,
11819
+ commandResultWaiterCount: 0,
11820
+ clipboardOperationCount: 0,
11821
+ retainedPayloadCount: 0,
11795
11822
  retainedPayloadBytes: 0
11796
11823
  });
11797
11824
  return {
@@ -11831,12 +11858,13 @@ export function createRemoteHub(options = {}) {
11831
11858
  listDeviceFrames,
11832
11859
  disconnectDevice,
11833
11860
  assignDeviceSlot,
11834
- sendCommand,
11835
- sendClipboardCommand,
11836
- refreshDevicePolicies,
11861
+ sendCommand,
11862
+ sendClipboardCommand,
11863
+ refreshDevicePolicies,
11837
11864
  sendLegacyClientUpdate,
11838
- sendInputControl,
11839
- releaseInputOwner,
11865
+ sendInputControl,
11866
+ getInputRouteState,
11867
+ releaseInputOwner,
11840
11868
  notifyAgentProgress,
11841
11869
  requestAgentTask,
11842
11870
  requestAgentTaskBatch,