@livedesk/hub 0.1.25 → 0.1.27

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
@@ -31,6 +31,17 @@ const LIVE_STREAM_MIN_FRESH_MS = 3000;
31
31
  const LIVE_STREAM_MAX_FRESH_MS = 12000;
32
32
  const DEFAULT_REMOTE_INPUT_ACK_TIMEOUT_MS = 5000;
33
33
  const DEFAULT_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS = 8000;
34
+ const TRANSPORT_DIAGNOSTIC_MIN_TTL_MS = 15_000;
35
+ const TRANSPORT_DIAGNOSTIC_MAX_TTL_MS = 180_000;
36
+ const TRANSPORT_DIAGNOSTIC_COMMAND_TIMEOUT_MS = 8_000;
37
+ const TRANSPORT_DIAGNOSTIC_RECORD_RETENTION_MS = 60_000;
38
+ const TRANSPORT_DIAGNOSTIC_MAX_RECORDS = 32;
39
+ const TRANSPORT_DIAGNOSTIC_PHASES = Object.freeze([
40
+ 'direct',
41
+ 'p2p',
42
+ 'relay',
43
+ 'recover-direct'
44
+ ]);
34
45
  const REMOTE_POLICY_BYPASS_COMMANDS = new Set([
35
46
  'ping',
36
47
  'stream.stop',
@@ -429,13 +440,23 @@ function isPublicIPv4(value) {
429
440
  return false;
430
441
  }
431
442
 
432
- if (parts[0] === 192 && (parts[1] === 0 || parts[1] === 168)) {
433
- return false;
434
- }
435
-
436
- if (parts[0] === 198 && (parts[1] === 18 || parts[1] === 19)) {
437
- return false;
438
- }
443
+ if (parts[0] === 192
444
+ && (parts[1] === 0
445
+ || parts[1] === 168
446
+ || (parts[1] === 88 && parts[2] === 99))) {
447
+ return false;
448
+ }
449
+
450
+ if (parts[0] === 198
451
+ && (parts[1] === 18
452
+ || parts[1] === 19
453
+ || (parts[1] === 51 && parts[2] === 100))) {
454
+ return false;
455
+ }
456
+
457
+ if (parts[0] === 203 && parts[1] === 0 && parts[2] === 113) {
458
+ return false;
459
+ }
439
460
 
440
461
  return true;
441
462
  }
@@ -1528,6 +1549,11 @@ class RemoteHubWebSocketAgentSocket {
1528
1549
 
1529
1550
  export function createRemoteHub(options = {}) {
1530
1551
  const env = options.env || process.env;
1552
+ const transportDiagnosticCommandTimeoutMs = clampNumber(
1553
+ options.transportDiagnosticCommandTimeoutMs,
1554
+ 25,
1555
+ TRANSPORT_DIAGNOSTIC_COMMAND_TIMEOUT_MS,
1556
+ TRANSPORT_DIAGNOSTIC_COMMAND_TIMEOUT_MS);
1531
1557
  const rejectFrameChannelForTest = isEnabledValue(env.LIVEDESK_TEST_MODE, false)
1532
1558
  && isEnabledValue(env.LIVEDESK_TEST_REJECT_FRAME_CHANNEL, false);
1533
1559
  const logEvent = options.logEvent || (() => {});
@@ -1631,7 +1657,9 @@ export function createRemoteHub(options = {}) {
1631
1657
  const fileSockets = new Map();
1632
1658
  const allSockets = new Set();
1633
1659
  const pendingCommandResultWaiters = new Map();
1634
- const duplicateDeviceLogAt = new Map();
1660
+ const transportDiagnostics = new Map();
1661
+ const transportDiagnosticStartingDevices = new Set();
1662
+ const duplicateDeviceLogAt = new Map();
1635
1663
  let server = null;
1636
1664
  let started = false;
1637
1665
  let boundPort = requestedPort;
@@ -2266,6 +2294,700 @@ export function createRemoteHub(options = {}) {
2266
2294
  });
2267
2295
  }
2268
2296
 
2297
+ function inspectAnnexBH264(payload) {
2298
+ if (!Buffer.isBuffer(payload) || payload.length < 5) {
2299
+ return {
2300
+ annexB: false,
2301
+ hasIdr: false,
2302
+ hasSps: false,
2303
+ hasPps: false,
2304
+ nalOrderValid: false
2305
+ };
2306
+ }
2307
+ let annexB = false;
2308
+ let hasIdr = false;
2309
+ let hasSps = false;
2310
+ let hasPps = false;
2311
+ let firstSpsAt = -1;
2312
+ let firstPpsAfterSpsAt = -1;
2313
+ let firstIdrAfterPpsAt = -1;
2314
+ for (let index = 0; index + 4 < payload.length; index += 1) {
2315
+ let headerIndex = -1;
2316
+ if (payload[index] === 0 && payload[index + 1] === 0 && payload[index + 2] === 1) {
2317
+ headerIndex = index + 3;
2318
+ } else if (payload[index] === 0
2319
+ && payload[index + 1] === 0
2320
+ && payload[index + 2] === 0
2321
+ && payload[index + 3] === 1) {
2322
+ headerIndex = index + 4;
2323
+ }
2324
+ if (headerIndex < 0 || headerIndex >= payload.length) continue;
2325
+ annexB = true;
2326
+ const nalType = payload[headerIndex] & 0x1f;
2327
+ if (nalType === 5) {
2328
+ hasIdr = true;
2329
+ if (firstPpsAfterSpsAt >= 0 && firstIdrAfterPpsAt < 0) {
2330
+ firstIdrAfterPpsAt = headerIndex;
2331
+ }
2332
+ }
2333
+ if (nalType === 7) {
2334
+ hasSps = true;
2335
+ if (firstSpsAt < 0) firstSpsAt = headerIndex;
2336
+ }
2337
+ if (nalType === 8) {
2338
+ hasPps = true;
2339
+ if (firstSpsAt >= 0 && firstPpsAfterSpsAt < 0) {
2340
+ firstPpsAfterSpsAt = headerIndex;
2341
+ }
2342
+ }
2343
+ index = headerIndex;
2344
+ }
2345
+ return {
2346
+ annexB,
2347
+ hasIdr,
2348
+ hasSps,
2349
+ hasPps,
2350
+ nalOrderValid: firstSpsAt >= 0
2351
+ && firstPpsAfterSpsAt > firstSpsAt
2352
+ && firstIdrAfterPpsAt > firstPpsAfterSpsAt
2353
+ };
2354
+ }
2355
+
2356
+ function expectedTransportForDiagnosticPhase(phase) {
2357
+ if (phase === 'p2p') return 'p2p';
2358
+ if (phase === 'relay') return 'relay';
2359
+ return 'direct';
2360
+ }
2361
+
2362
+ function observedTransportPath(frame) {
2363
+ const transport = safeString(frame?.transport, 32).toLowerCase();
2364
+ if (transport === 'udp-p2p') return 'p2p';
2365
+ if (transport === 'relay-binary') return 'relay';
2366
+ if (transport === 'binary' || transport === 'ws-binary') return 'direct';
2367
+ return '';
2368
+ }
2369
+
2370
+ function optionalFiniteNumber(value) {
2371
+ return value !== null && value !== undefined && value !== ''
2372
+ && Number.isFinite(Number(value))
2373
+ ? Number(value)
2374
+ : null;
2375
+ }
2376
+
2377
+ function transportDiagnosticIdentifierHash(value, salt) {
2378
+ const normalized = safeString(value, 240);
2379
+ return normalized
2380
+ ? crypto.createHash('sha256')
2381
+ .update(`${safeString(salt, 160)}:${normalized}`)
2382
+ .digest('hex')
2383
+ .slice(0, 16)
2384
+ : '';
2385
+ }
2386
+
2387
+ function currentTransportDiagnosticOwner(device) {
2388
+ const frame = device?.latestLiveFrame;
2389
+ if (!device?.connected
2390
+ || !device.sessionId
2391
+ || !frame
2392
+ || frame.currentGenerationVerified !== true
2393
+ || safeString(frame.mimeType, 80).toLowerCase() !== 'video/h264'
2394
+ || !safeString(frame.streamId, 128)
2395
+ || !safeString(frame.commandId, 128)
2396
+ || Number(frame.captureGeneration || 0) <= 0) {
2397
+ return null;
2398
+ }
2399
+ const monitorIndex = parseExactLiveStreamMonitorIndex(frame.monitorIndex);
2400
+ const streamState = getDeviceLiveStream(device, frame.streamId);
2401
+ if (monitorIndex === null
2402
+ || !streamState?.active
2403
+ || safeString(streamState.commandId, 128) !== safeString(frame.commandId, 128)
2404
+ || Number(streamState.captureGeneration || 0) !== Number(frame.captureGeneration || 0)
2405
+ || parseExactLiveStreamMonitorIndex(streamState.monitorIndex) !== monitorIndex) {
2406
+ return null;
2407
+ }
2408
+ return {
2409
+ deviceId: device.deviceId,
2410
+ sessionId: device.sessionId,
2411
+ streamId: safeString(frame.streamId, 128),
2412
+ streamCommandId: safeString(frame.commandId, 128),
2413
+ captureGeneration: Number(frame.captureGeneration),
2414
+ monitorIndex
2415
+ };
2416
+ }
2417
+
2418
+ function transportDiagnosticOwnersEqual(left, right) {
2419
+ return !!left
2420
+ && !!right
2421
+ && left.deviceId === right.deviceId
2422
+ && left.sessionId === right.sessionId
2423
+ && left.streamId === right.streamId
2424
+ && left.streamCommandId === right.streamCommandId
2425
+ && left.captureGeneration === right.captureGeneration
2426
+ && left.monitorIndex === right.monitorIndex;
2427
+ }
2428
+
2429
+ function transportDiagnosticOwnerMatches(record, device) {
2430
+ const current = currentTransportDiagnosticOwner(device);
2431
+ return transportDiagnosticOwnersEqual(current, record.owner);
2432
+ }
2433
+
2434
+ function serializeTransportDiagnostic(record) {
2435
+ const refreshedNetwork = udpTransport?.getDeviceDiagnosticNetwork?.(record.owner.deviceId, {
2436
+ salt: record.leaseId
2437
+ });
2438
+ if (refreshedNetwork?.evidence
2439
+ === 'authenticated-udp-endpoint-rendezvous-observed'
2440
+ || record.network?.evidence
2441
+ !== 'authenticated-udp-endpoint-rendezvous-observed') {
2442
+ record.network = refreshedNetwork || record.network;
2443
+ }
2444
+ return {
2445
+ ok: record.state !== 'failed',
2446
+ leaseId: record.leaseId,
2447
+ state: record.state,
2448
+ phase: record.phase,
2449
+ startedAt: record.startedAt,
2450
+ updatedAt: record.updatedAt,
2451
+ expiresAt: record.expiresAt,
2452
+ completedAt: record.completedAt || '',
2453
+ error: safeString(record.error, 240),
2454
+ owner: {
2455
+ deviceId: record.owner.deviceId,
2456
+ streamId: record.owner.streamId,
2457
+ streamCommandId: record.owner.streamCommandId,
2458
+ captureGeneration: record.owner.captureGeneration,
2459
+ monitorIndex: record.owner.monitorIndex
2460
+ },
2461
+ network: record.network,
2462
+ baseline: { ...record.baseline },
2463
+ frames: record.frames.map(frame => ({ ...frame })),
2464
+ cleanup: record.cleanup.map(entry => ({ ...entry }))
2465
+ };
2466
+ }
2467
+
2468
+ function getTransportDiagnostic(leaseId, token) {
2469
+ const record = transportDiagnostics.get(safeString(leaseId, 128));
2470
+ if (!record || !diagnosticSecretEquals(record.token, token)) {
2471
+ return { ok: false, error: 'transport-diagnostic-not-found' };
2472
+ }
2473
+ return serializeTransportDiagnostic(record);
2474
+ }
2475
+
2476
+ function diagnosticSecretEquals(expected, provided) {
2477
+ const expectedHash = crypto.createHash('sha256').update(String(expected || '')).digest();
2478
+ const providedHash = crypto.createHash('sha256').update(String(provided || '')).digest();
2479
+ return crypto.timingSafeEqual(expectedHash, providedHash);
2480
+ }
2481
+
2482
+ async function sendTransportDiagnosticCommand(record, operation, phase = record.phase) {
2483
+ const device = devices.get(record.owner.deviceId);
2484
+ if (!device?.connected || device.sessionId !== record.owner.sessionId) {
2485
+ return { ok: false, error: 'transport-diagnostic-device-disconnected' };
2486
+ }
2487
+ if (operation !== 'release' && !transportDiagnosticOwnerMatches(record, device)) {
2488
+ return { ok: false, error: 'transport-diagnostic-owner-changed' };
2489
+ }
2490
+ const commandId = crypto.randomUUID();
2491
+ const waiter = waitForCommandResult(device, commandId, transportDiagnosticCommandTimeoutMs);
2492
+ const queued = sendCommand(device.deviceId, {
2493
+ commandId,
2494
+ command: 'transport.diagnostic',
2495
+ payload: {
2496
+ operation,
2497
+ leaseId: record.leaseId,
2498
+ token: record.token,
2499
+ streamId: record.owner.streamId,
2500
+ streamCommandId: record.owner.streamCommandId,
2501
+ captureGeneration: record.owner.captureGeneration,
2502
+ monitorIndex: record.owner.monitorIndex,
2503
+ phase,
2504
+ ttlMs: Math.max(0, Date.parse(record.expiresAt) - Date.now())
2505
+ }
2506
+ });
2507
+ if (!queued.ok) {
2508
+ failPendingCommandResultWaiter(commandId, queued.error);
2509
+ }
2510
+ const outcome = await waiter;
2511
+ if (!outcome.ok || outcome.result?.ok === false) {
2512
+ return {
2513
+ ok: false,
2514
+ error: safeString(outcome.error || outcome.result?.error, 240)
2515
+ || 'transport-diagnostic-agent-rejected'
2516
+ };
2517
+ }
2518
+ return { ok: true };
2519
+ }
2520
+
2521
+ function appendTransportDiagnosticCleanup(record, state, reason = '') {
2522
+ record.cleanup.push({
2523
+ at: new Date().toISOString(),
2524
+ state,
2525
+ reason: safeString(reason, 160)
2526
+ });
2527
+ if (record.cleanup.length > 16) record.cleanup.splice(0, record.cleanup.length - 16);
2528
+ record.updatedAt = new Date().toISOString();
2529
+ }
2530
+
2531
+ function clearTransportDiagnosticTimers(record) {
2532
+ clearTimeout(record?.expiryTimer);
2533
+ clearTimeout(record?.removalTimer);
2534
+ if (record) {
2535
+ record.expiryTimer = null;
2536
+ record.removalTimer = null;
2537
+ }
2538
+ }
2539
+
2540
+ function removeTransportDiagnostic(record) {
2541
+ if (!record) return;
2542
+ clearTransportDiagnosticTimers(record);
2543
+ if (transportDiagnostics.get(record.leaseId) === record) {
2544
+ transportDiagnostics.delete(record.leaseId);
2545
+ }
2546
+ record.token = '';
2547
+ }
2548
+
2549
+ function scheduleTransportDiagnosticRemoval(record) {
2550
+ if (!record) return;
2551
+ clearTimeout(record.expiryTimer);
2552
+ clearTimeout(record.removalTimer);
2553
+ record.expiryTimer = null;
2554
+ record.removalTimer = setTimeout(() => {
2555
+ removeTransportDiagnostic(record);
2556
+ }, TRANSPORT_DIAGNOSTIC_RECORD_RETENTION_MS);
2557
+ record.removalTimer.unref?.();
2558
+ }
2559
+
2560
+ function pruneTransportDiagnosticRecords() {
2561
+ const finalized = [...transportDiagnostics.values()]
2562
+ .filter(record => record.state !== 'active'
2563
+ && record.state !== 'acquiring'
2564
+ && record.state !== 'expiring')
2565
+ .sort((left, right) => Date.parse(left.updatedAt || '') - Date.parse(right.updatedAt || ''));
2566
+ while (transportDiagnostics.size >= TRANSPORT_DIAGNOSTIC_MAX_RECORDS && finalized.length > 0) {
2567
+ removeTransportDiagnostic(finalized.shift());
2568
+ }
2569
+ }
2570
+
2571
+ async function expireTransportDiagnostic(record, reason = 'ttl-expired') {
2572
+ if (!record || record.state !== 'active') return;
2573
+ if (record.operationInFlight === true) {
2574
+ record.expiryTimer = setTimeout(() => {
2575
+ void expireTransportDiagnostic(record, reason);
2576
+ }, 250);
2577
+ record.expiryTimer.unref?.();
2578
+ return;
2579
+ }
2580
+ record.operationInFlight = true;
2581
+ record.state = 'expiring';
2582
+ appendTransportDiagnosticCleanup(record, 'release-requested', reason);
2583
+ try {
2584
+ const released = await sendTransportDiagnosticCommand(record, 'release');
2585
+ record.state = reason === 'ttl-expired' ? 'expired' : 'aborted';
2586
+ record.error = released.ok ? '' : released.error;
2587
+ record.completedAt = new Date().toISOString();
2588
+ appendTransportDiagnosticCleanup(
2589
+ record,
2590
+ released.ok ? 'agent-released' : 'agent-release-unconfirmed',
2591
+ released.error);
2592
+ } finally {
2593
+ record.operationInFlight = false;
2594
+ scheduleTransportDiagnosticRemoval(record);
2595
+ }
2596
+ }
2597
+
2598
+ function abortTransportDiagnosticsForDevice(device, reason) {
2599
+ for (const record of transportDiagnostics.values()) {
2600
+ if (!['active', 'acquiring', 'expiring'].includes(record.state)
2601
+ || record.owner.deviceId !== device?.deviceId
2602
+ || record.owner.sessionId !== device?.sessionId) {
2603
+ continue;
2604
+ }
2605
+ clearTimeout(record.expiryTimer);
2606
+ record.state = 'aborted';
2607
+ record.error = safeString(reason, 160) || 'device-disconnected';
2608
+ record.completedAt = new Date().toISOString();
2609
+ appendTransportDiagnosticCleanup(record, 'connection-closed', record.error);
2610
+ scheduleTransportDiagnosticRemoval(record);
2611
+ }
2612
+ }
2613
+
2614
+ async function startTransportDiagnostic(deviceId, ttlMs = 150_000, options = {}) {
2615
+ const requestSignal = options?.signal;
2616
+ const requestAborted = () => requestSignal?.aborted === true;
2617
+ const normalizedDeviceId = safeString(deviceId, 160);
2618
+ const device = devices.get(normalizedDeviceId);
2619
+ const owner = currentTransportDiagnosticOwner(device);
2620
+ if (!owner) {
2621
+ return { ok: false, error: 'transport-diagnostic-current-h264-stream-required' };
2622
+ }
2623
+ if (observedTransportPath(device.latestLiveFrame) !== 'direct'
2624
+ || safeString(device.latestLiveFrame?.frameTransportActive, 20).toLowerCase() !== 'direct') {
2625
+ return { ok: false, error: 'transport-diagnostic-direct-start-required' };
2626
+ }
2627
+ if (transportDiagnosticStartingDevices.has(normalizedDeviceId)) {
2628
+ return { ok: false, error: 'transport-diagnostic-device-lease-active' };
2629
+ }
2630
+ const existingLease = [...transportDiagnostics.values()].some(existing =>
2631
+ ['active', 'acquiring', 'expiring'].includes(existing.state)
2632
+ && existing.owner.deviceId === owner.deviceId);
2633
+ if (existingLease) {
2634
+ return { ok: false, error: 'transport-diagnostic-device-lease-active' };
2635
+ }
2636
+ transportDiagnosticStartingDevices.add(normalizedDeviceId);
2637
+ try {
2638
+ const refreshedDiagnosticNetwork =
2639
+ await udpTransport?.refreshDeviceDiagnosticNetwork?.(
2640
+ owner.deviceId,
2641
+ { timeoutMs: 3_000 });
2642
+ if (requestAborted()) {
2643
+ return { ok: false, error: 'transport-diagnostic-request-aborted' };
2644
+ }
2645
+ const physicalNetwork = udpTransport?.getDeviceDiagnosticNetwork?.(
2646
+ owner.deviceId,
2647
+ { salt: 'transport-diagnostic-preflight' }) || {};
2648
+ if (refreshedDiagnosticNetwork !== true
2649
+ || physicalNetwork.evidence
2650
+ !== 'authenticated-udp-endpoint-rendezvous-observed'
2651
+ || physicalNetwork.endpointAuthenticated !== true
2652
+ || physicalNetwork.clientAddressPublic !== true
2653
+ || physicalNetwork.hubAddressPublic !== true) {
2654
+ return {
2655
+ ok: false,
2656
+ error: 'transport-diagnostic-fresh-rendezvous-egress-required'
2657
+ };
2658
+ }
2659
+ if (physicalNetwork.differentPublicEgress !== true) {
2660
+ return {
2661
+ ok: false,
2662
+ error: 'transport-diagnostic-distinct-public-egress-required'
2663
+ };
2664
+ }
2665
+ const currentDevice = devices.get(normalizedDeviceId);
2666
+ const refreshedOwner = currentTransportDiagnosticOwner(currentDevice);
2667
+ if (!transportDiagnosticOwnersEqual(owner, refreshedOwner)
2668
+ || observedTransportPath(currentDevice?.latestLiveFrame) !== 'direct'
2669
+ || safeString(currentDevice?.latestLiveFrame?.frameTransportActive, 20).toLowerCase() !== 'direct') {
2670
+ return { ok: false, error: 'transport-diagnostic-owner-changed' };
2671
+ }
2672
+ const concurrentLease = [...transportDiagnostics.values()].some(existing =>
2673
+ ['active', 'acquiring', 'expiring'].includes(existing.state)
2674
+ && existing.owner.deviceId === owner.deviceId);
2675
+ if (concurrentLease) {
2676
+ return { ok: false, error: 'transport-diagnostic-device-lease-active' };
2677
+ }
2678
+ pruneTransportDiagnosticRecords();
2679
+ if (transportDiagnostics.size >= TRANSPORT_DIAGNOSTIC_MAX_RECORDS) {
2680
+ return { ok: false, error: 'transport-diagnostic-capacity-reached' };
2681
+ }
2682
+
2683
+ const boundedTtlMs = clampNumber(
2684
+ ttlMs,
2685
+ TRANSPORT_DIAGNOSTIC_MIN_TTL_MS,
2686
+ TRANSPORT_DIAGNOSTIC_MAX_TTL_MS,
2687
+ 150_000);
2688
+ const now = new Date();
2689
+ const leaseId = crypto.randomUUID();
2690
+ const token = crypto.randomBytes(32).toString('base64url');
2691
+ const currentFrame = currentDevice.latestLiveFrame;
2692
+ const udpStatus = udpTransport?.getDeviceStatus?.(owner.deviceId) || {};
2693
+ const hubAddressObservedAt = safeString(
2694
+ physicalNetwork.hubAddressObservedAt,
2695
+ 80);
2696
+ const record = {
2697
+ leaseId,
2698
+ token,
2699
+ owner,
2700
+ phase: 'direct',
2701
+ phaseStartedAt: '',
2702
+ state: 'acquiring',
2703
+ operationInFlight: true,
2704
+ startedAt: now.toISOString(),
2705
+ updatedAt: now.toISOString(),
2706
+ expiresAt: new Date(now.getTime() + boundedTtlMs).toISOString(),
2707
+ completedAt: '',
2708
+ error: '',
2709
+ hubAddressObservedAt,
2710
+ baseline: {
2711
+ observedAt: safeString(currentFrame.receivedAt, 80),
2712
+ frameSeq: optionalFiniteNumber(currentFrame.frameSeq),
2713
+ switchCount: optionalFiniteNumber(currentFrame.frameTransportSwitchCount),
2714
+ agentQueueDropped: optionalFiniteNumber(currentFrame.nativeOutputQueueDroppedCount),
2715
+ hubIngressDropped: optionalFiniteNumber(currentFrame.hubIngressDropped),
2716
+ udpIncompleteFrames: optionalFiniteNumber(udpStatus.droppedFrames),
2717
+ hubIngressCounterEpochHash: transportDiagnosticIdentifierHash(
2718
+ currentFrame.hubIngressCounterEpoch,
2719
+ leaseId),
2720
+ udpCounterEpochHash: transportDiagnosticIdentifierHash(
2721
+ udpStatus.sessionId,
2722
+ leaseId),
2723
+ agentQueueTelemetryAvailable:
2724
+ currentFrame.nativeOutputQueueTelemetryAvailable === true,
2725
+ hubIngressTelemetryAvailable:
2726
+ currentFrame.hubIngressTelemetryAvailable === true
2727
+ && !!currentFrame.hubIngressCounterEpoch,
2728
+ udpTelemetryAvailable:
2729
+ optionalFiniteNumber(udpStatus.droppedFrames) !== null
2730
+ && !!udpStatus.sessionId
2731
+ },
2732
+ frames: [],
2733
+ cleanup: [],
2734
+ network: udpTransport?.getDeviceDiagnosticNetwork?.(owner.deviceId, {
2735
+ salt: leaseId
2736
+ }) || {
2737
+ evidence: 'unavailable',
2738
+ differentPublicEgress: null,
2739
+ hubAddressObservedAt
2740
+ },
2741
+ expiryTimer: null,
2742
+ removalTimer: null
2743
+ };
2744
+ transportDiagnostics.set(leaseId, record);
2745
+ const acquired = await sendTransportDiagnosticCommand(record, 'acquire', 'direct');
2746
+ record.operationInFlight = false;
2747
+ if (!acquired.ok || record.state !== 'acquiring') {
2748
+ const acquireError = acquired.ok
2749
+ ? safeString(record.error, 240) || 'transport-diagnostic-acquire-interrupted'
2750
+ : acquired.error;
2751
+ record.error = acquireError;
2752
+ record.completedAt = new Date().toISOString();
2753
+ appendTransportDiagnosticCleanup(record, 'acquire-failed', record.error);
2754
+ appendTransportDiagnosticCleanup(
2755
+ record,
2756
+ 'release-requested',
2757
+ 'acquire-outcome-uncertain');
2758
+ record.operationInFlight = true;
2759
+ let released;
2760
+ try {
2761
+ released = await sendTransportDiagnosticCommand(record, 'release');
2762
+ } catch (error) {
2763
+ released = {
2764
+ ok: false,
2765
+ error: safeString(error instanceof Error ? error.message : error, 240)
2766
+ || 'transport-diagnostic-agent-release-failed'
2767
+ };
2768
+ } finally {
2769
+ record.operationInFlight = false;
2770
+ }
2771
+ appendTransportDiagnosticCleanup(
2772
+ record,
2773
+ released.ok ? 'agent-released' : 'agent-release-unconfirmed',
2774
+ released.error);
2775
+ record.state = 'failed';
2776
+ record.completedAt = new Date().toISOString();
2777
+ const failed = { ...serializeTransportDiagnostic(record), token: '' };
2778
+ removeTransportDiagnostic(record);
2779
+ return failed;
2780
+ }
2781
+ record.state = 'active';
2782
+ record.phaseStartedAt = new Date().toISOString();
2783
+ record.updatedAt = record.phaseStartedAt;
2784
+ appendTransportDiagnosticCleanup(record, 'lease-acquired');
2785
+ if (requestAborted()) {
2786
+ await expireTransportDiagnostic(record, 'start-request-aborted');
2787
+ return { ok: false, error: 'transport-diagnostic-request-aborted' };
2788
+ }
2789
+ const remainingTtlMs = Math.max(1, Date.parse(record.expiresAt) - Date.now());
2790
+ record.expiryTimer = setTimeout(() => {
2791
+ void expireTransportDiagnostic(record, 'ttl-expired');
2792
+ }, remainingTtlMs);
2793
+ record.expiryTimer.unref?.();
2794
+ return { ...serializeTransportDiagnostic(record), token };
2795
+ } finally {
2796
+ transportDiagnosticStartingDevices.delete(normalizedDeviceId);
2797
+ }
2798
+ }
2799
+
2800
+ async function setTransportDiagnosticPhase(leaseId, token, phase) {
2801
+ const record = transportDiagnostics.get(safeString(leaseId, 128));
2802
+ const normalizedPhase = safeString(phase, 32).toLowerCase();
2803
+ if (!record || !diagnosticSecretEquals(record.token, token)) {
2804
+ return { ok: false, error: 'transport-diagnostic-not-found' };
2805
+ }
2806
+ if (record.state !== 'active') {
2807
+ return { ok: false, error: 'transport-diagnostic-not-active' };
2808
+ }
2809
+ if (record.operationInFlight === true) {
2810
+ return { ok: false, error: 'transport-diagnostic-operation-in-flight' };
2811
+ }
2812
+ if (!TRANSPORT_DIAGNOSTIC_PHASES.includes(normalizedPhase)) {
2813
+ return { ok: false, error: 'transport-diagnostic-phase-invalid' };
2814
+ }
2815
+ const currentIndex = TRANSPORT_DIAGNOSTIC_PHASES.indexOf(record.phase);
2816
+ const nextIndex = TRANSPORT_DIAGNOSTIC_PHASES.indexOf(normalizedPhase);
2817
+ const currentObserved = record.frames.some(frame => frame.phase === record.phase && frame.accepted === true);
2818
+ if (nextIndex !== currentIndex + 1 || !currentObserved) {
2819
+ return { ok: false, error: 'transport-diagnostic-phase-order-required' };
2820
+ }
2821
+ record.operationInFlight = true;
2822
+ try {
2823
+ const updated = await sendTransportDiagnosticCommand(record, 'set-phase', normalizedPhase);
2824
+ if (!updated.ok) {
2825
+ record.error = updated.error;
2826
+ record.updatedAt = new Date().toISOString();
2827
+ return { ok: false, error: updated.error };
2828
+ }
2829
+ if (record.state !== 'active') {
2830
+ return { ok: false, error: 'transport-diagnostic-interrupted' };
2831
+ }
2832
+ record.phase = normalizedPhase;
2833
+ record.phaseStartedAt = new Date().toISOString();
2834
+ record.updatedAt = record.phaseStartedAt;
2835
+ return serializeTransportDiagnostic(record);
2836
+ } finally {
2837
+ record.operationInFlight = false;
2838
+ }
2839
+ }
2840
+
2841
+ async function stopTransportDiagnostic(leaseId, token, reason = 'runner-complete') {
2842
+ const record = transportDiagnostics.get(safeString(leaseId, 128));
2843
+ if (!record || !diagnosticSecretEquals(record.token, token)) {
2844
+ return { ok: false, error: 'transport-diagnostic-not-found' };
2845
+ }
2846
+ if (record.state !== 'active') return serializeTransportDiagnostic(record);
2847
+ if (record.operationInFlight === true) {
2848
+ return { ok: false, error: 'transport-diagnostic-operation-in-flight' };
2849
+ }
2850
+ clearTimeout(record.expiryTimer);
2851
+ record.operationInFlight = true;
2852
+ try {
2853
+ appendTransportDiagnosticCleanup(record, 'release-requested', reason);
2854
+ const released = await sendTransportDiagnosticCommand(record, 'release');
2855
+ record.state = released.ok ? 'completed' : 'failed';
2856
+ record.error = released.ok ? '' : released.error;
2857
+ record.completedAt = new Date().toISOString();
2858
+ appendTransportDiagnosticCleanup(
2859
+ record,
2860
+ released.ok ? 'agent-released' : 'agent-release-unconfirmed',
2861
+ released.error);
2862
+ const serialized = serializeTransportDiagnostic(record);
2863
+ scheduleTransportDiagnosticRemoval(record);
2864
+ return serialized;
2865
+ } finally {
2866
+ record.operationInFlight = false;
2867
+ }
2868
+ }
2869
+
2870
+ function recordTransportDiagnosticFrame(device, message, payload, frame) {
2871
+ if (transportDiagnostics.size === 0 || message.transportDiagnosticApplied !== true) {
2872
+ return;
2873
+ }
2874
+ const leaseId = safeString(message.transportDiagnosticLeaseId, 128);
2875
+ const phase = safeString(message.transportDiagnosticPhase, 32).toLowerCase();
2876
+ if (!leaseId) return;
2877
+ const record = transportDiagnostics.get(leaseId);
2878
+ if (!record
2879
+ || record.state !== 'active'
2880
+ || record.phase !== phase
2881
+ || !transportDiagnosticOwnerMatches(record, device)
2882
+ || frame.streamId !== record.owner.streamId
2883
+ || frame.commandId !== record.owner.streamCommandId
2884
+ || frame.captureGeneration !== record.owner.captureGeneration
2885
+ || frame.monitorIndex !== record.owner.monitorIndex
2886
+ || safeString(frame.mimeType, 80).toLowerCase() !== 'video/h264') {
2887
+ return;
2888
+ }
2889
+ const expectedPath = expectedTransportForDiagnosticPhase(phase);
2890
+ const h264 = inspectAnnexBH264(payload);
2891
+ const agentPath = safeString(frame.frameTransportActive, 20).toLowerCase();
2892
+ const hubObservedPath = observedTransportPath(frame);
2893
+ const frameSeq = optionalFiniteNumber(frame.frameSeq);
2894
+ const switchCount = optionalFiniteNumber(message.frameTransportSwitchCount);
2895
+ const lastAcceptedFrame = [...record.frames]
2896
+ .reverse()
2897
+ .find(item => item.accepted === true);
2898
+ const previousFrameSeq = optionalFiniteNumber(
2899
+ lastAcceptedFrame?.frameSeq ?? record.baseline?.frameSeq);
2900
+ const previousSwitchCount = optionalFiniteNumber(
2901
+ lastAcceptedFrame?.switchCount ?? record.baseline?.switchCount);
2902
+ const sequenceAdvanced = frameSeq !== null
2903
+ && (previousFrameSeq === null || frameSeq > previousFrameSeq);
2904
+ const switchAdvanced = switchCount !== null
2905
+ && (lastAcceptedFrame
2906
+ ? previousSwitchCount !== null && switchCount > previousSwitchCount
2907
+ : previousSwitchCount === null || switchCount >= previousSwitchCount);
2908
+ const agentQueueDepth = optionalFiniteNumber(message.nativeOutputQueueDepth);
2909
+ const agentQueueBytes = optionalFiniteNumber(message.nativeOutputQueueBytes);
2910
+ const agentQueueDropped = optionalFiniteNumber(message.nativeOutputQueueDroppedCount);
2911
+ const hubIngressDropped = optionalFiniteNumber(message.hubIngressDropped);
2912
+ const udpIncompleteFrames = optionalFiniteNumber(message.udpIncompleteFrames);
2913
+ const hubIngressCounterEpochHash = transportDiagnosticIdentifierHash(
2914
+ message.hubIngressCounterEpoch,
2915
+ record.leaseId);
2916
+ const agentQueueTelemetryAvailable = agentQueueDepth !== null
2917
+ && agentQueueBytes !== null
2918
+ && agentQueueDropped !== null;
2919
+ const hubIngressTelemetryAvailable = hubIngressDropped !== null
2920
+ && !!hubIngressCounterEpochHash;
2921
+ const udpTelemetryAvailable = phase !== 'p2p' || udpIncompleteFrames !== null;
2922
+ const phaseLatencyMs = Math.max(
2923
+ 0,
2924
+ Date.parse(frame.receivedAt || '') - Date.parse(record.phaseStartedAt || ''));
2925
+ const accepted = agentPath === expectedPath
2926
+ && hubObservedPath === expectedPath
2927
+ && frame.isKeyFrame === true
2928
+ && h264.annexB
2929
+ && h264.hasIdr
2930
+ && h264.hasSps
2931
+ && h264.hasPps
2932
+ && h264.nalOrderValid
2933
+ && sequenceAdvanced
2934
+ && switchAdvanced
2935
+ && agentQueueTelemetryAvailable
2936
+ && hubIngressTelemetryAvailable
2937
+ && udpTelemetryAvailable
2938
+ && Number.isFinite(phaseLatencyMs);
2939
+ if (record.frames.some(item => item.phase === phase && item.accepted === true)) return;
2940
+ const observation = {
2941
+ phase,
2942
+ expectedPath,
2943
+ actualPath: agentPath,
2944
+ hubObservedPath,
2945
+ protocol: safeString(frame.frameTransportProtocol, 20),
2946
+ transport: safeString(frame.transport, 32),
2947
+ reason: safeString(frame.frameTransportReason, 160),
2948
+ receivedAt: frame.receivedAt,
2949
+ frameSeq,
2950
+ byteLength: frame.byteLength,
2951
+ contentHash: safeString(frame.contentHash, 128),
2952
+ isKeyFrame: frame.isKeyFrame === true,
2953
+ annexB: h264.annexB,
2954
+ hasIdr: h264.hasIdr,
2955
+ hasSps: h264.hasSps,
2956
+ hasPps: h264.hasPps,
2957
+ nalOrderValid: h264.nalOrderValid,
2958
+ switchCount,
2959
+ tcpFailureCount: frame.frameTransportTcpFailureCount,
2960
+ phaseLatencyMs,
2961
+ sequenceAdvanced,
2962
+ switchAdvanced,
2963
+ agentQueueDepth,
2964
+ agentQueueBytes,
2965
+ agentQueueDropped,
2966
+ hubIngressDropped,
2967
+ udpIncompleteFrames,
2968
+ hubIngressCounterEpochHash,
2969
+ agentQueueTelemetryAvailable,
2970
+ hubIngressTelemetryAvailable,
2971
+ udpTelemetryAvailable,
2972
+ accepted
2973
+ };
2974
+ if (accepted) {
2975
+ record.frames = record.frames.filter(item => item.phase !== phase);
2976
+ record.frames.push(observation);
2977
+ } else {
2978
+ const rejectedIndex = record.frames.findIndex(item =>
2979
+ item.phase === phase && item.accepted !== true);
2980
+ if (rejectedIndex >= 0) record.frames[rejectedIndex] = observation;
2981
+ else record.frames.push(observation);
2982
+ }
2983
+ if (record.frames.length > TRANSPORT_DIAGNOSTIC_PHASES.length * 2) {
2984
+ record.frames = record.frames.filter(item => item.accepted === true)
2985
+ .concat(record.frames.filter(item => item.accepted !== true)
2986
+ .slice(-TRANSPORT_DIAGNOSTIC_PHASES.length));
2987
+ }
2988
+ record.updatedAt = new Date().toISOString();
2989
+ }
2990
+
2269
2991
  function makeSyntheticFrame(device, streamId, mode = 'thumbnail', options = {}) {
2270
2992
  const now = new Date().toISOString();
2271
2993
  const descriptor = buildRemoteFrameTransferDescriptor(mode, mode === 'thumbnail' ? 'thumbnail' : DEFAULT_REMOTE_FRAME_MODE);
@@ -3135,9 +3857,10 @@ export function createRemoteHub(options = {}) {
3135
3857
  return;
3136
3858
  }
3137
3859
 
3138
- device.connected = false;
3139
- device.socket = null;
3140
- udpTransport?.unregisterDevice?.(deviceId, device.sessionId);
3860
+ device.connected = false;
3861
+ device.socket = null;
3862
+ abortTransportDiagnosticsForDevice(device, reason);
3863
+ udpTransport?.unregisterDevice?.(deviceId, device.sessionId);
3141
3864
  closeInputSocket(device, reason);
3142
3865
  closeFrameSocket(device, reason);
3143
3866
  device.disconnectedAt = new Date().toISOString();
@@ -3578,6 +4301,48 @@ export function createRemoteHub(options = {}) {
3578
4301
  ?? result.HubForwardedAtEpochMs
3579
4302
  ?? pending?.hubForwardedAtEpochMs
3580
4303
  ?? 0) || 0,
4304
+ duplicate: message.duplicate === true
4305
+ || message.Duplicate === true
4306
+ || result.duplicate === true
4307
+ || result.Duplicate === true,
4308
+ nativeInputBackend: safeString(
4309
+ message.nativeInputBackend
4310
+ || message.NativeInputBackend
4311
+ || result.nativeInputBackend
4312
+ || result.NativeInputBackend,
4313
+ 80),
4314
+ nativeEventsRequested: Math.max(0, Number(
4315
+ message.nativeEventsRequested
4316
+ ?? message.NativeEventsRequested
4317
+ ?? result.nativeEventsRequested
4318
+ ?? result.NativeEventsRequested
4319
+ ?? 0) || 0),
4320
+ nativeEventsAccepted: Math.max(0, Number(
4321
+ message.nativeEventsAccepted
4322
+ ?? message.NativeEventsAccepted
4323
+ ?? result.nativeEventsAccepted
4324
+ ?? result.NativeEventsAccepted
4325
+ ?? 0) || 0),
4326
+ hookEventsObserved: Math.max(0, Number(
4327
+ message.hookEventsObserved
4328
+ ?? message.HookEventsObserved
4329
+ ?? result.hookEventsObserved
4330
+ ?? result.HookEventsObserved
4331
+ ?? 0) || 0),
4332
+ hookEventsBlocked: Math.max(0, Number(
4333
+ message.hookEventsBlocked
4334
+ ?? message.HookEventsBlocked
4335
+ ?? result.hookEventsBlocked
4336
+ ?? result.HookEventsBlocked
4337
+ ?? 0) || 0),
4338
+ focusChanged: message.focusChanged === true
4339
+ || message.FocusChanged === true
4340
+ || result.focusChanged === true
4341
+ || result.FocusChanged === true,
4342
+ inputDiagnostic: message.inputDiagnostic === true
4343
+ || message.InputDiagnostic === true
4344
+ || result.inputDiagnostic === true
4345
+ || result.InputDiagnostic === true,
3581
4346
  fallback: pending?.fallback === true,
3582
4347
  hubAcknowledgedAtEpochMs: Date.now()
3583
4348
  };
@@ -3924,6 +4689,10 @@ export function createRemoteHub(options = {}) {
3924
4689
  ? message.nativeEncoderHardwareAccelerated
3925
4690
  : null,
3926
4691
  nativeEncoderHardwareQueryStatus: safeString(message.nativeEncoderHardwareQueryStatus, 80),
4692
+ nativeCaptureDisplayId: Number.isSafeInteger(Number(message.nativeCaptureDisplayId))
4693
+ && Number(message.nativeCaptureDisplayId) > 0
4694
+ ? Number(message.nativeCaptureDisplayId)
4695
+ : 0,
3927
4696
  nativeCaptureFps: Number.isFinite(Number(message.nativeCaptureFps)) ? Number(message.nativeCaptureFps) : 0,
3928
4697
  nativeCompressionFps: Number.isFinite(Number(message.nativeCompressionFps)) ? Number(message.nativeCompressionFps) : 0,
3929
4698
  nativeEncodedFps: Number.isFinite(Number(message.nativeEncodedFps)) ? Number(message.nativeEncodedFps) : 0,
@@ -3981,7 +4750,7 @@ export function createRemoteHub(options = {}) {
3981
4750
  return true;
3982
4751
  }
3983
4752
 
3984
- function promotePendingLiveStream(device, streamState, message, transport) {
4753
+ function matchPendingLiveStreamOwner(device, streamState, message, transport) {
3985
4754
  const pending = getPendingLiveStreamDescriptor(streamState);
3986
4755
  const commandId = safeString(message.commandId, 128);
3987
4756
  if (!pending || !commandId || commandId !== safeString(pending.commandId, 128)) {
@@ -3989,9 +4758,11 @@ export function createRemoteHub(options = {}) {
3989
4758
  }
3990
4759
  const messageCaptureGeneration = Number(message.captureGeneration || 0);
3991
4760
  const pendingCaptureGeneration = Number(pending.captureGeneration || 0);
3992
- if (messageCaptureGeneration > 0
3993
- && pendingCaptureGeneration > 0
3994
- && messageCaptureGeneration !== pendingCaptureGeneration) {
4761
+ if (!Number.isSafeInteger(messageCaptureGeneration)
4762
+ || messageCaptureGeneration <= 0
4763
+ || !Number.isSafeInteger(pendingCaptureGeneration)
4764
+ || pendingCaptureGeneration <= 0
4765
+ || messageCaptureGeneration !== pendingCaptureGeneration) {
3995
4766
  emitRemoteEvent('RemoteLiveStreamOpenIgnored', device, {
3996
4767
  reason: 'stale-pending-capture-generation',
3997
4768
  streamId: streamState.streamId,
@@ -4030,6 +4801,27 @@ export function createRemoteHub(options = {}) {
4030
4801
  });
4031
4802
  return null;
4032
4803
  }
4804
+ return {
4805
+ pending,
4806
+ commandId,
4807
+ messageCaptureGeneration,
4808
+ pendingCaptureGeneration,
4809
+ pendingMonitorIndex
4810
+ };
4811
+ }
4812
+
4813
+ function promotePendingLiveStream(device, streamState, message, transport) {
4814
+ const owner = matchPendingLiveStreamOwner(device, streamState, message, transport);
4815
+ if (!owner) {
4816
+ return null;
4817
+ }
4818
+ const {
4819
+ pending,
4820
+ commandId,
4821
+ messageCaptureGeneration,
4822
+ pendingCaptureGeneration,
4823
+ pendingMonitorIndex
4824
+ } = owner;
4033
4825
 
4034
4826
  const previousCommandId = safeString(streamState.commandId, 128);
4035
4827
  const transfer = buildRemoteFrameTransferDescriptorFromMessage(
@@ -4138,16 +4930,22 @@ export function createRemoteHub(options = {}) {
4138
4930
 
4139
4931
  const activatingPendingGeneration = isPendingGeneration && commandId !== activeCommandId;
4140
4932
  if (activatingPendingGeneration) {
4141
- const promoted = promotePendingLiveStream(device, streamState, message, transport);
4142
- if (!promoted) {
4933
+ const owner = matchPendingLiveStreamOwner(device, streamState, message, transport);
4934
+ if (!owner) {
4143
4935
  return false;
4144
4936
  }
4145
- emitLiveStreamOpened(
4146
- device,
4147
- promoted.streamState,
4148
- promoted.transfer,
4149
- transport,
4150
- promoted.previousCommandId);
4937
+ // stream.open travels on the manager connection while the binary
4938
+ // first frame travels on a separate transport. Their arrival order
4939
+ // is not defined. Keep the current generation and its last frame
4940
+ // until the matching first real frame validates and commits the
4941
+ // pending owner atomically.
4942
+ emitRemoteEvent('RemoteLiveStreamOpenDeferred', device, {
4943
+ streamId,
4944
+ commandId,
4945
+ captureGeneration: owner.messageCaptureGeneration,
4946
+ monitorIndex: owner.pendingMonitorIndex,
4947
+ transport
4948
+ });
4151
4949
  return true;
4152
4950
  }
4153
4951
 
@@ -4224,8 +5022,8 @@ export function createRemoteHub(options = {}) {
4224
5022
  const commandId = safeString(message.commandId, 128);
4225
5023
  let streamState = getDeviceLiveStream(device, streamId);
4226
5024
  if (!streamState?.active) {
4227
- device.counters.liveFramesDropped += 1;
4228
- emitRemoteEvent('RemoteFrameDropped', device, {
5025
+ device.counters.liveFramesDropped += 1;
5026
+ emitRemoteEvent('RemoteFrameDropped', device, {
4229
5027
  reason: 'stale-live-stream-frame',
4230
5028
  streamId,
4231
5029
  frameSeq: Number.isFinite(frameSeq) ? frameSeq : null,
@@ -4233,9 +5031,91 @@ export function createRemoteHub(options = {}) {
4233
5031
  });
4234
5032
  return false;
4235
5033
  }
5034
+ const byteLength = normalizeFrameByteLength(framePayload, frameData);
5035
+ const streamFrameSeq = Number.isFinite(Number(message.streamFrameSeq))
5036
+ ? Number(message.streamFrameSeq)
5037
+ : frameSeq;
5038
+ if (byteLength <= 0
5039
+ || byteLength > MAX_STREAM_BINARY_BYTES
5040
+ || !Number.isSafeInteger(frameSeq)
5041
+ || frameSeq <= 0
5042
+ || !Number.isSafeInteger(streamFrameSeq)
5043
+ || streamFrameSeq <= 0) {
5044
+ device.counters.liveFramesDropped += 1;
5045
+ emitRemoteEvent('RemoteFrameDropped', device, {
5046
+ reason: 'invalid-live-frame',
5047
+ streamId,
5048
+ commandId,
5049
+ captureGeneration: Number(message.captureGeneration || 0),
5050
+ monitorIndex: parseExactLiveStreamMonitorIndex(message.monitorIndex),
5051
+ frameSeq: Number.isFinite(frameSeq) ? frameSeq : null,
5052
+ transport
5053
+ });
5054
+ return false;
5055
+ }
5056
+ const payload = buildFramePayloadBuffer(framePayload, frameData);
5057
+ if (payload.length <= 0 || payload.length > MAX_STREAM_BINARY_BYTES) {
5058
+ device.counters.liveFramesDropped += 1;
5059
+ emitRemoteEvent('RemoteFrameDropped', device, {
5060
+ reason: 'invalid-live-frame-payload',
5061
+ streamId,
5062
+ commandId,
5063
+ captureGeneration: Number(message.captureGeneration || 0),
5064
+ monitorIndex: parseExactLiveStreamMonitorIndex(message.monitorIndex),
5065
+ frameSeq,
5066
+ transport
5067
+ });
5068
+ return false;
5069
+ }
4236
5070
  let activeCommandId = safeString(streamState.commandId, 128);
4237
- let pendingCommandId = safeString(getPendingLiveStreamDescriptor(streamState)?.commandId, 128);
5071
+ const pendingDescriptor = getPendingLiveStreamDescriptor(streamState);
5072
+ let pendingCommandId = safeString(pendingDescriptor?.commandId, 128);
4238
5073
  if (pendingCommandId && commandId === pendingCommandId && commandId !== activeCommandId) {
5074
+ if (!matchPendingLiveStreamOwner(device, streamState, message, `${transport}-implicit`)) {
5075
+ return false;
5076
+ }
5077
+ const pendingFrameMode = safeString(
5078
+ message.frameMode
5079
+ || message.mode
5080
+ || pendingDescriptor?.frameMode
5081
+ || pendingDescriptor?.mode,
5082
+ 40).toLowerCase();
5083
+ const pendingMimeType = safeString(
5084
+ message.mimeType
5085
+ || message.format
5086
+ || pendingDescriptor?.compression,
5087
+ 80).toLowerCase();
5088
+ const pendingCodec = safeString(
5089
+ message.codec
5090
+ || pendingDescriptor?.codec
5091
+ || pendingDescriptor?.encoding,
5092
+ 40).toLowerCase();
5093
+ const requiresH264KeyFrame = pendingFrameMode === 'mode3-h264-hw'
5094
+ || pendingMimeType.includes('h264')
5095
+ || pendingMimeType.includes('avc')
5096
+ || pendingCodec.includes('h264')
5097
+ || pendingCodec.includes('avc');
5098
+ const isH264KeyFrame = message.isKeyFrame === true
5099
+ || safeString(message.chunkType, 20).toLowerCase() === 'key';
5100
+ const h264 = requiresH264KeyFrame ? inspectAnnexBH264(payload) : null;
5101
+ const independentlyDecodableH264KeyFrame = isH264KeyFrame
5102
+ && h264?.annexB === true
5103
+ && h264.hasSps === true
5104
+ && h264.hasPps === true
5105
+ && h264.hasIdr === true
5106
+ && h264.nalOrderValid === true;
5107
+ if (requiresH264KeyFrame && !independentlyDecodableH264KeyFrame) {
5108
+ device.counters.liveFramesDropped += 1;
5109
+ emitRemoteEvent('RemoteFrameDropped', device, {
5110
+ reason: 'pending-live-stream-decodable-keyframe-required',
5111
+ streamId,
5112
+ commandId,
5113
+ frameSeq,
5114
+ streamFrameSeq,
5115
+ transport
5116
+ });
5117
+ return false;
5118
+ }
4239
5119
  const promoted = promotePendingLiveStream(device, streamState, message, `${transport}-implicit`);
4240
5120
  if (promoted) {
4241
5121
  streamState = promoted.streamState;
@@ -4313,24 +5193,38 @@ export function createRemoteHub(options = {}) {
4313
5193
  return false;
4314
5194
  }
4315
5195
 
4316
- const byteLength = normalizeFrameByteLength(framePayload, frameData);
4317
- if ((!Buffer.isBuffer(framePayload) && !frameData)
4318
- || byteLength > MAX_STREAM_BINARY_BYTES
4319
- || !Number.isFinite(frameSeq)) {
4320
- device.counters.liveFramesDropped += 1;
4321
- emitRemoteEvent('RemoteFrameDropped', device, {
4322
- reason: 'invalid-live-frame',
4323
- streamId,
4324
- frameSeq: Number.isFinite(frameSeq) ? frameSeq : null,
4325
- transport
4326
- });
4327
- return false;
4328
- }
4329
-
4330
- const mimeType = safeString(message.mimeType || message.format || 'image/jpeg', 80) || 'image/jpeg';
4331
- const capturedAt = safeString(message.capturedAt, 80) || device.lastSeenAt;
4332
- const payload = buildFramePayloadBuffer(framePayload, frameData);
4333
- const contentHash = buildFrameContentHash(message, payload);
5196
+ const previousFrame = streamState.latestFrame;
5197
+ const currentOwnerCommandId = commandId || activeCommandId;
5198
+ const currentOwnerCaptureGeneration = activeCaptureGeneration || frameCaptureGeneration;
5199
+ const previousStreamFrameSeq = Number(previousFrame?.streamFrameSeq ?? previousFrame?.frameSeq);
5200
+ const sameFrameOwner = previousFrame
5201
+ && safeString(previousFrame.streamId, 128) === streamId
5202
+ && safeString(previousFrame.commandId, 128) === currentOwnerCommandId
5203
+ && Number(previousFrame.captureGeneration || 0) === currentOwnerCaptureGeneration
5204
+ && parseExactLiveStreamMonitorIndex(previousFrame.monitorIndex) === frameMonitorIndex;
5205
+ if (sameFrameOwner
5206
+ && Number.isSafeInteger(previousStreamFrameSeq)
5207
+ && streamFrameSeq <= previousStreamFrameSeq) {
5208
+ device.counters.liveFramesDropped += 1;
5209
+ emitRemoteEvent('RemoteFrameDropped', device, {
5210
+ reason: streamFrameSeq === previousStreamFrameSeq
5211
+ ? 'duplicate-live-stream-frame'
5212
+ : 'out-of-order-live-stream-frame',
5213
+ streamId,
5214
+ commandId: currentOwnerCommandId,
5215
+ captureGeneration: currentOwnerCaptureGeneration,
5216
+ monitorIndex: frameMonitorIndex,
5217
+ frameSeq,
5218
+ streamFrameSeq,
5219
+ previousStreamFrameSeq,
5220
+ transport
5221
+ });
5222
+ return false;
5223
+ }
5224
+
5225
+ const mimeType = safeString(message.mimeType || message.format || 'image/jpeg', 80) || 'image/jpeg';
5226
+ const capturedAt = safeString(message.capturedAt, 80) || device.lastSeenAt;
5227
+ const contentHash = buildFrameContentHash(message, payload);
4334
5228
  const sameContentStreak = computeSameContentStreak(streamState.latestFrame, contentHash);
4335
5229
  const transfer = buildRemoteFrameTransferDescriptorFromMessage(message, streamState.mode || DEFAULT_REMOTE_FRAME_MODE);
4336
5230
  if (streamState.open !== true) {
@@ -4364,7 +5258,7 @@ export function createRemoteHub(options = {}) {
4364
5258
  device.latestLiveFrame = {
4365
5259
  streamId,
4366
5260
  frameSeq,
4367
- streamFrameSeq: Number.isFinite(Number(message.streamFrameSeq)) ? Number(message.streamFrameSeq) : frameSeq,
5261
+ streamFrameSeq,
4368
5262
  commandId: commandId || activeCommandId,
4369
5263
  sessionId: device.sessionId,
4370
5264
  captureGeneration: activeCaptureGeneration || frameCaptureGeneration,
@@ -4410,6 +5304,10 @@ export function createRemoteHub(options = {}) {
4410
5304
  ? message.nativeEncoderHardwareAccelerated
4411
5305
  : null,
4412
5306
  nativeEncoderHardwareQueryStatus: safeString(message.nativeEncoderHardwareQueryStatus, 80),
5307
+ nativeCaptureDisplayId: Number.isSafeInteger(Number(message.nativeCaptureDisplayId))
5308
+ && Number(message.nativeCaptureDisplayId) > 0
5309
+ ? Number(message.nativeCaptureDisplayId)
5310
+ : 0,
4413
5311
  nativeCaptureFps: Number.isFinite(Number(message.nativeCaptureFps)) ? Number(message.nativeCaptureFps) : 0,
4414
5312
  nativeCompressionFps: Number.isFinite(Number(message.nativeCompressionFps)) ? Number(message.nativeCompressionFps) : 0,
4415
5313
  nativeEncodedFps: Number.isFinite(Number(message.nativeEncodedFps)) ? Number(message.nativeEncodedFps) : 0,
@@ -4426,10 +5324,17 @@ export function createRemoteHub(options = {}) {
4426
5324
  nativeOutputQueueHighWatermark: Number.isFinite(Number(message.nativeOutputQueueHighWatermark)) ? Number(message.nativeOutputQueueHighWatermark) : 0,
4427
5325
  nativeOutputQueueDroppedCount: Number.isFinite(Number(message.nativeOutputQueueDroppedCount)) ? Number(message.nativeOutputQueueDroppedCount) : 0,
4428
5326
  nativeOutputQueueAwaitingKeyFrame: message.nativeOutputQueueAwaitingKeyFrame === true,
5327
+ nativeOutputQueueTelemetryAvailable:
5328
+ optionalFiniteNumber(message.nativeOutputQueueDepth) !== null
5329
+ && optionalFiniteNumber(message.nativeOutputQueueBytes) !== null
5330
+ && optionalFiniteNumber(message.nativeOutputQueueDroppedCount) !== null,
4429
5331
  nativeCaptureTelemetryAt: safeString(message.nativeCaptureTelemetryAt, 80),
4430
5332
  droppedByAgent: Number.isFinite(Number(message.droppedByAgent)) ? Number(message.droppedByAgent) : 0,
4431
5333
  hubIngressDropped: Number.isFinite(Number(message.hubIngressDropped)) ? Number(message.hubIngressDropped) : 0,
4432
5334
  udpIncompleteFrames: Number.isFinite(Number(message.udpIncompleteFrames)) ? Number(message.udpIncompleteFrames) : 0,
5335
+ hubIngressTelemetryAvailable: optionalFiniteNumber(message.hubIngressDropped) !== null,
5336
+ udpIncompleteTelemetryAvailable: optionalFiniteNumber(message.udpIncompleteFrames) !== null,
5337
+ hubIngressCounterEpoch: safeString(message.hubIngressCounterEpoch, 160),
4433
5338
  hardwareEncoder: safeString(message.hardwareEncoder, 80),
4434
5339
  platformProfile: safeString(message.platformProfile, 80),
4435
5340
  monitorIndex: activeMonitorIndex,
@@ -4480,9 +5385,10 @@ export function createRemoteHub(options = {}) {
4480
5385
  frameTransportTcpFailureCount: Number.isFinite(Number(message.frameTransportTcpFailureCount)) ? Number(message.frameTransportTcpFailureCount) : 0,
4481
5386
  sameContentStreak,
4482
5387
  payload,
4483
- accessToken: createFrameAccessToken()
4484
- };
4485
- streamState.latestFrame = device.latestLiveFrame;
5388
+ accessToken: createFrameAccessToken()
5389
+ };
5390
+ recordTransportDiagnosticFrame(device, message, payload, device.latestLiveFrame);
5391
+ streamState.latestFrame = device.latestLiveFrame;
4486
5392
  rememberRecentFramePayload(device, 'live', device.latestLiveFrame);
4487
5393
  emitFrame({
4488
5394
  kind: 'live',
@@ -4596,7 +5502,7 @@ export function createRemoteHub(options = {}) {
4596
5502
  return true;
4597
5503
  }
4598
5504
 
4599
- function applyAudioStatus(device, message, framePayload, transport = 'binary') {
5505
+ function applyAudioStatus(device, message, framePayload, transport = 'binary') {
4600
5506
  if (!Buffer.isBuffer(framePayload) || framePayload.length < 1) {
4601
5507
  device.counters.audioFramesDropped += 1;
4602
5508
  return false;
@@ -4637,9 +5543,184 @@ export function createRemoteHub(options = {}) {
4637
5543
  payload: framePayload,
4638
5544
  byteLength: framePayload.length
4639
5545
  });
4640
- return true;
4641
- }
4642
-
5546
+ return true;
5547
+ }
5548
+
5549
+ function readFrameTransportProof(message = {}) {
5550
+ if (message.frameTransportProofId === undefined
5551
+ && message.frameTransportProofVersion === undefined) {
5552
+ return null;
5553
+ }
5554
+ const rawProofId = typeof message.frameTransportProofId === 'string'
5555
+ ? message.frameTransportProofId.trim()
5556
+ : '';
5557
+ const captureGeneration = Number(message.captureGeneration);
5558
+ const monitorIndex = parseExactLiveStreamMonitorIndex(message.monitorIndex);
5559
+ const proof = {
5560
+ proofId: rawProofId,
5561
+ streamId: safeString(message.streamId, 128),
5562
+ commandId: safeString(message.commandId, 128),
5563
+ captureGeneration,
5564
+ monitorIndex
5565
+ };
5566
+ const valid = Number(message.frameTransportProofVersion) === 1
5567
+ && /^[a-f0-9]{32}$/.test(rawProofId)
5568
+ && !!proof.streamId
5569
+ && !!proof.commandId
5570
+ && Number.isSafeInteger(captureGeneration)
5571
+ && captureGeneration > 0
5572
+ && monitorIndex !== null;
5573
+ return {
5574
+ ...proof,
5575
+ valid,
5576
+ error: valid ? '' : 'frame-proof-owner-invalid'
5577
+ };
5578
+ }
5579
+
5580
+ function applyLiveFrameWithTransportProof(
5581
+ device,
5582
+ message,
5583
+ framePayload,
5584
+ transport) {
5585
+ const proof = readFrameTransportProof(message);
5586
+ if (!proof) {
5587
+ return {
5588
+ accepted: applyLiveFrame(device, message, framePayload, transport),
5589
+ proofAccepted: false,
5590
+ proof: null,
5591
+ error: ''
5592
+ };
5593
+ }
5594
+ const frameMode = safeString(message.frameMode || message.mode, 40).toLowerCase();
5595
+ const mimeType = safeString(message.mimeType || message.format, 80).toLowerCase();
5596
+ const chunkType = safeString(message.chunkType, 20).toLowerCase();
5597
+ const proofFrameSeq = Number(message.frameSeq);
5598
+ const proofStreamFrameSeq = message.streamFrameSeq === undefined
5599
+ || message.streamFrameSeq === null
5600
+ || message.streamFrameSeq === ''
5601
+ ? proofFrameSeq
5602
+ : Number(message.streamFrameSeq);
5603
+ const proofOnly = message.frameTransportProofOnly === true;
5604
+ const h264 = inspectAnnexBH264(framePayload);
5605
+ const proofPayloadValid = proof.valid
5606
+ && (transport === 'udp-p2p' || transport === 'relay-binary')
5607
+ && Buffer.isBuffer(framePayload)
5608
+ && framePayload.length > 0
5609
+ && framePayload.length <= MAX_STREAM_BINARY_BYTES
5610
+ && Number.isSafeInteger(proofFrameSeq)
5611
+ && proofFrameSeq > 0
5612
+ && Number.isSafeInteger(proofStreamFrameSeq)
5613
+ && proofStreamFrameSeq > 0
5614
+ && frameMode === 'mode3-h264-hw'
5615
+ && mimeType === 'video/h264'
5616
+ && (message.isKeyFrame === true || chunkType === 'key')
5617
+ && safeString(message.frameTransportActive, 20).toLowerCase() === 'none'
5618
+ && safeString(message.frameTransportState, 20).toLowerCase() === 'probing'
5619
+ && h264.annexB
5620
+ && h264.hasSps
5621
+ && h264.hasPps
5622
+ && h264.hasIdr
5623
+ && h264.nalOrderValid;
5624
+ if (!proofPayloadValid) {
5625
+ device.counters.liveFramesDropped += 1;
5626
+ emitRemoteEvent('RemoteFrameDropped', device, {
5627
+ reason: proof.error || 'frame-proof-h264-invalid',
5628
+ streamId: proof.streamId,
5629
+ commandId: proof.commandId,
5630
+ captureGeneration: proof.captureGeneration,
5631
+ monitorIndex: proof.monitorIndex,
5632
+ transport
5633
+ });
5634
+ return {
5635
+ accepted: false,
5636
+ proofAccepted: false,
5637
+ proof,
5638
+ error: proof.error || 'frame-proof-h264-invalid'
5639
+ };
5640
+ }
5641
+ if (proofOnly) {
5642
+ const streamState = getDeviceLiveStream(device, proof.streamId);
5643
+ // A proof-only frame is an out-of-band path challenge. It must
5644
+ // prove the exact current stream owner and carry a complete
5645
+ // independently decodable H.264 key frame, but it must not race
5646
+ // the presentation lane's latest delta frame. WAN fragmentation
5647
+ // can legitimately deliver this challenge several relay frames
5648
+ // after the same source key was presented.
5649
+ const exactActiveStreamOwner = transport === 'udp-p2p'
5650
+ && streamState?.active === true
5651
+ && safeString(streamState.commandId, 128) === proof.commandId
5652
+ && Number(streamState.captureGeneration || 0) === proof.captureGeneration
5653
+ && parseExactLiveStreamMonitorIndex(streamState.monitorIndex) === proof.monitorIndex;
5654
+ if (exactActiveStreamOwner) {
5655
+ emitRemoteEvent('RemoteFrameTransportProofAccepted', device, {
5656
+ proofOnly: true,
5657
+ proofId: proof.proofId,
5658
+ streamId: proof.streamId,
5659
+ commandId: proof.commandId,
5660
+ captureGeneration: proof.captureGeneration,
5661
+ monitorIndex: proof.monitorIndex,
5662
+ frameSeq: proofFrameSeq,
5663
+ streamFrameSeq: proofStreamFrameSeq,
5664
+ transport
5665
+ });
5666
+ }
5667
+ return {
5668
+ accepted: exactActiveStreamOwner,
5669
+ proofAccepted: exactActiveStreamOwner,
5670
+ proofOnly: true,
5671
+ proof,
5672
+ error: exactActiveStreamOwner
5673
+ ? ''
5674
+ : 'frame-proof-active-owner-mismatch'
5675
+ };
5676
+ }
5677
+ const accepted = applyLiveFrame(device, message, framePayload, transport);
5678
+ const frame = device.latestLiveFrame;
5679
+ const exactAcceptedFrame = accepted
5680
+ && frame?.currentGenerationVerified === true
5681
+ && safeString(frame.streamId, 128) === proof.streamId
5682
+ && safeString(frame.commandId, 128) === proof.commandId
5683
+ && Number(frame.captureGeneration) === proof.captureGeneration
5684
+ && parseExactLiveStreamMonitorIndex(frame.monitorIndex) === proof.monitorIndex;
5685
+ if (exactAcceptedFrame) {
5686
+ emitRemoteEvent('RemoteFrameTransportProofAccepted', device, {
5687
+ proofOnly: false,
5688
+ proofId: proof.proofId,
5689
+ streamId: proof.streamId,
5690
+ commandId: proof.commandId,
5691
+ captureGeneration: proof.captureGeneration,
5692
+ monitorIndex: proof.monitorIndex,
5693
+ frameSeq: proofFrameSeq,
5694
+ streamFrameSeq: proofStreamFrameSeq,
5695
+ transport
5696
+ });
5697
+ }
5698
+ return {
5699
+ accepted: exactAcceptedFrame,
5700
+ proofAccepted: exactAcceptedFrame,
5701
+ proof,
5702
+ error: exactAcceptedFrame ? '' : 'frame-proof-current-generation-rejected'
5703
+ };
5704
+ }
5705
+
5706
+ function writeFrameTransportProofResponse(socket, result) {
5707
+ const proof = result?.proof;
5708
+ if (!proof?.proofId) return;
5709
+ const accepted = result.proofAccepted === true;
5710
+ writeJsonLine(socket, {
5711
+ type: accepted
5712
+ ? 'frame.transport.proof-accepted'
5713
+ : 'frame.transport.proof-rejected',
5714
+ protocol: 'livedesk.frame-transport-proof.v1',
5715
+ proofId: proof.proofId,
5716
+ streamId: proof.streamId,
5717
+ commandId: proof.commandId,
5718
+ captureGeneration: proof.captureGeneration,
5719
+ monitorIndex: proof.monitorIndex,
5720
+ ...(accepted ? {} : { error: safeString(result.error || 'frame-proof-rejected', 160) })
5721
+ });
5722
+ }
5723
+
4643
5724
  function handleAgentBinaryFrame(socket, state, header, framePayload) {
4644
5725
  if (!state.authenticated || !state.device) {
4645
5726
  writeJsonLine(socket, { type: 'error', error: 'hello-required' });
@@ -4675,10 +5756,12 @@ export function createRemoteHub(options = {}) {
4675
5756
  }
4676
5757
 
4677
5758
  if (frameKind === 'stream' || frameKind === 'live' || header.type === 'stream.binary') {
4678
- applyLiveFrame(device, {
5759
+ const result = applyLiveFrameWithTransportProof(device, {
4679
5760
  ...header,
4680
- hubIngressDropped: Number(state.binaryQueueDrops || 0)
5761
+ hubIngressDropped: Number(state.binaryQueueDrops || 0),
5762
+ hubIngressCounterEpoch: state.binaryQueueEpoch
4681
5763
  }, framePayload, binaryTransport);
5764
+ writeFrameTransportProofResponse(socket, result);
4682
5765
  return;
4683
5766
  }
4684
5767
 
@@ -4698,7 +5781,7 @@ export function createRemoteHub(options = {}) {
4698
5781
  });
4699
5782
  }
4700
5783
 
4701
- function handleUdpFrame({ deviceId, sessionId, header, payload } = {}) {
5784
+ function handleUdpFrame({ deviceId, sessionId, header, payload } = {}) {
4702
5785
  const device = devices.get(safeString(deviceId, 160));
4703
5786
  if (!device || !device.connected) {
4704
5787
  return false;
@@ -4711,18 +5794,25 @@ export function createRemoteHub(options = {}) {
4711
5794
  emitRemoteEvent('RemoteFrameDropped', device, { reason: 'udp-frame-mode-not-supported', transport: 'udp-p2p', frameMode });
4712
5795
  return false;
4713
5796
  }
4714
- device.udp = {
4715
- ...device.udp,
4716
- state: 'udp-active',
4717
- ready: true,
4718
- lastFrameAt: new Date().toISOString(),
4719
- framesReceived: Number(device.udp?.framesReceived || 0) + 1
4720
- };
4721
- return applyLiveFrame(device, {
4722
- ...header,
4723
- transport: 'udp-p2p'
4724
- }, payload, 'udp-p2p');
4725
- }
5797
+ const result = applyLiveFrameWithTransportProof(device, {
5798
+ ...header,
5799
+ transport: 'udp-p2p',
5800
+ hubIngressCounterEpoch: sessionId
5801
+ }, payload, 'udp-p2p');
5802
+ if (result.accepted) {
5803
+ device.udp = {
5804
+ ...device.udp,
5805
+ state: result.proof ? 'udp-proof-accepted' : 'udp-active',
5806
+ ready: true,
5807
+ lastFrameAt: new Date().toISOString(),
5808
+ framesReceived: Number(device.udp?.framesReceived || 0)
5809
+ + (result.proofOnly === true ? 0 : 1)
5810
+ };
5811
+ }
5812
+ return result.proof
5813
+ ? result
5814
+ : result.accepted;
5815
+ }
4726
5816
 
4727
5817
  function dropQueuedAgentBinaryFrame(state) {
4728
5818
  if (!Array.isArray(state.binaryQueue) || state.binaryQueue.length === 0) {
@@ -5051,9 +6141,10 @@ export function createRemoteHub(options = {}) {
5051
6141
  binaryQueue: [],
5052
6142
  binaryQueueDraining: false,
5053
6143
  binaryQueueScheduled: false,
5054
- binaryQueueSocket: null,
5055
- binaryQueueDrops: 0,
5056
- closed: false
6144
+ binaryQueueSocket: null,
6145
+ binaryQueueDrops: 0,
6146
+ binaryQueueEpoch: crypto.randomUUID(),
6147
+ closed: false
5057
6148
  };
5058
6149
 
5059
6150
  const helloTimer = setTimeout(() => {
@@ -5211,9 +6302,10 @@ export function createRemoteHub(options = {}) {
5211
6302
  binaryQueue: [],
5212
6303
  binaryQueueDraining: false,
5213
6304
  binaryQueueScheduled: false,
5214
- binaryQueueSocket: null,
5215
- binaryQueueDrops: 0,
5216
- closed: false
6305
+ binaryQueueSocket: null,
6306
+ binaryQueueDrops: 0,
6307
+ binaryQueueEpoch: crypto.randomUUID(),
6308
+ closed: false
5217
6309
  };
5218
6310
 
5219
6311
  const helloTimer = setTimeout(() => {
@@ -5437,8 +6529,9 @@ export function createRemoteHub(options = {}) {
5437
6529
  return getStatus({ includeSecrets: false });
5438
6530
  }
5439
6531
 
5440
- async function close() {
6532
+ async function close() {
5441
6533
  for (const device of devices.values()) {
6534
+ abortTransportDiagnosticsForDevice(device, 'hub-shutdown');
5442
6535
  failAllPendingTasks(device, 'hub-shutdown');
5443
6536
  failAllPendingInputFallbacks(device, 'hub-shutdown');
5444
6537
  failPendingCommandResultWaiters(device, 'hub-shutdown');
@@ -5490,10 +6583,11 @@ export function createRemoteHub(options = {}) {
5490
6583
 
5491
6584
  function disconnectDevice(deviceId, reason = 'manager-disconnect') {
5492
6585
  const device = devices.get(String(deviceId || ''));
5493
- if (device?.synthetic === true) {
5494
- device.connected = false;
5495
- device.disconnectedAt = new Date().toISOString();
5496
- device.lastDisconnectReason = reason;
6586
+ if (device?.synthetic === true) {
6587
+ device.connected = false;
6588
+ device.disconnectedAt = new Date().toISOString();
6589
+ device.lastDisconnectReason = reason;
6590
+ abortTransportDiagnosticsForDevice(device, reason);
5497
6591
  deactivateDeviceLiveStreams(device, reason, device.disconnectedAt);
5498
6592
  failAllPendingTasks(device, 'device-disconnected');
5499
6593
  failAllPendingInputFallbacks(device, 'device-disconnected');
@@ -7547,6 +8641,10 @@ export function createRemoteHub(options = {}) {
7547
8641
  stopLiveStream,
7548
8642
  pauseLiveCapture,
7549
8643
  resumeLiveCapture,
8644
+ startTransportDiagnostic,
8645
+ getTransportDiagnostic,
8646
+ setTransportDiagnosticPhase,
8647
+ stopTransportDiagnostic,
7550
8648
  startAudioStream,
7551
8649
  stopAudioStream,
7552
8650
  getDeviceLiveFrame,