livedesk 0.1.368 → 0.1.370

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.
@@ -24,9 +24,10 @@ const RECENT_LIVE_FRAME_CACHE_LIMIT = 1;
24
24
  const RECENT_THUMBNAIL_FRAME_CACHE_MAX_BYTES = 2 * 1024 * 1024;
25
25
  const RECENT_LIVE_FRAME_CACHE_MAX_BYTES = 8 * 1024 * 1024;
26
26
  const LIVE_STREAM_PENDING_REUSE_MS = 5000;
27
- const LIVE_STREAM_REPLACEMENT_PENDING_MS = 7000;
28
- const LIVE_STREAM_MIN_FRESH_MS = 3000;
29
- const LIVE_STREAM_MAX_FRESH_MS = 12000;
27
+ const LIVE_STREAM_REPLACEMENT_PENDING_MS = 7000;
28
+ const LIVE_STREAM_MIN_FRESH_MS = 3000;
29
+ const LIVE_STREAM_MAX_FRESH_MS = 12000;
30
+ const DEFAULT_REMOTE_INPUT_ACK_TIMEOUT_MS = 5000;
30
31
  const REMOTE_PROTOCOL_VERSION = 2;
31
32
  const REMOTE_AGENT_PROTOCOL = 'mindexec.remote.agent';
32
33
  const REMOTE_FRAME_PROTOCOL = 'mindexec.remote.frame.v2';
@@ -643,6 +644,7 @@ function normalizeRemoteInputEvent(value = {}) {
643
644
  controlCommandId: safeString(input.controlCommandId || input.commandId || input.ControlCommandId || input.CommandId, 128),
644
645
  captureGeneration: Number.isSafeInteger(captureGeneration) && captureGeneration > 0 ? captureGeneration : 0,
645
646
  hubConnectionId: safeString(input.hubConnectionId || input.HubConnectionId, 128),
647
+ inputEventId: safeString(input.inputEventId || input.InputEventId, 128) || crypto.randomUUID(),
646
648
  inputSeq: Number.isSafeInteger(inputSeq) && inputSeq > 0 ? inputSeq : 0,
647
649
  requestAck: input.requestAck !== false && input.RequestAck !== false,
648
650
  issuedAtEpochMs: Number.isFinite(issuedAtEpochMs) ? issuedAtEpochMs : 0,
@@ -1430,11 +1432,16 @@ export function createRemoteHub(options = {}) {
1430
1432
  const host = safeString(env.REMOTE_HUB_HOST || DEFAULT_REMOTE_HUB_HOST, 128);
1431
1433
  const requestedPort = normalizePort(env.REMOTE_HUB_PORT || DEFAULT_REMOTE_HUB_PORT);
1432
1434
  const heartbeatMs = clampNumber(env.REMOTE_HUB_HEARTBEAT_MS, 1000, 60000, DEFAULT_HEARTBEAT_MS);
1433
- const taskTimeoutMs = clampNumber(
1434
- options.taskTimeoutMs ?? env.REMOTE_HUB_TASK_TIMEOUT_MS,
1435
- 50,
1436
- 30 * 60 * 1000,
1437
- DEFAULT_AGENT_TASK_TIMEOUT_MS);
1435
+ const taskTimeoutMs = clampNumber(
1436
+ options.taskTimeoutMs ?? env.REMOTE_HUB_TASK_TIMEOUT_MS,
1437
+ 50,
1438
+ 30 * 60 * 1000,
1439
+ DEFAULT_AGENT_TASK_TIMEOUT_MS);
1440
+ const inputAckTimeoutMs = clampNumber(
1441
+ options.inputAckTimeoutMs ?? env.REMOTE_HUB_INPUT_ACK_TIMEOUT_MS,
1442
+ 250,
1443
+ 30000,
1444
+ DEFAULT_REMOTE_INPUT_ACK_TIMEOUT_MS);
1438
1445
  const managerPackage = safeString(options.managerPackage ?? env.LIVEDESK_MANAGER_PACKAGE ?? env.MINDEXEC_MANAGER_PACKAGE ?? '@livedesk/hub', 128);
1439
1446
  const managerVersion = safeString(options.managerVersion ?? env.LIVEDESK_MANAGER_VERSION ?? env.MINDEXEC_MANAGER_VERSION ?? '', 64);
1440
1447
  const hostInstanceId = safeString(options.hostInstanceId ?? env.LIVEDESK_HUB_INSTANCE_ID ?? env.MINDEXEC_BRIDGE_INSTANCE_ID ?? crypto.randomUUID(), 128) || crypto.randomUUID();
@@ -1976,9 +1983,11 @@ export function createRemoteHub(options = {}) {
1976
1983
  + Number(device?.counters?.liveFramesReceived || 0)
1977
1984
  + 1;
1978
1985
  return {
1979
- streamId: safeString(streamId, 128) || `${mode}-${Date.now()}`,
1980
- frameSeq,
1981
- commandId: safeString(options.commandId, 128),
1986
+ streamId: safeString(streamId, 128) || `${mode}-${Date.now()}`,
1987
+ frameSeq,
1988
+ commandId: safeString(options.commandId, 128),
1989
+ captureGeneration: Number(options.captureGeneration || 0),
1990
+ currentGenerationVerified: true,
1982
1991
  width: clampNumber(options.width, 2, 3840, mode === 'thumbnail' ? 360 : 640),
1983
1992
  height: clampNumber(options.height, 1, 2160, mode === 'thumbnail' ? 220 : 360),
1984
1993
  mimeType: 'image/png',
@@ -2043,10 +2052,11 @@ export function createRemoteHub(options = {}) {
2043
2052
  commandId: options.commandId || streamState.commandId,
2044
2053
  width: options.maxWidth || 960,
2045
2054
  height: options.maxHeight || 540,
2046
- monitorIndex: options.monitorIndex ?? streamState.monitorIndex ?? 0,
2047
- monitorCount: options.monitorCount ?? streamState.monitorCount ?? 1,
2048
- fps: options.fps || streamState.fps
2049
- });
2055
+ monitorIndex: options.monitorIndex ?? streamState.monitorIndex ?? 0,
2056
+ monitorCount: options.monitorCount ?? streamState.monitorCount ?? 1,
2057
+ fps: options.fps || streamState.fps,
2058
+ captureGeneration: streamState.captureGeneration
2059
+ });
2050
2060
  device.latestLiveFrame = frame;
2051
2061
  rememberRecentFramePayload(device, 'live', frame);
2052
2062
  emitFrame({
@@ -2081,9 +2091,10 @@ export function createRemoteHub(options = {}) {
2081
2091
  return;
2082
2092
  }
2083
2093
 
2084
- existing.lastDisconnectReason = 'replaced-by-new-session';
2085
- failAllPendingTasks(existing, 'replaced-by-new-session');
2086
- writeJsonLine(existing.socket, {
2094
+ existing.lastDisconnectReason = 'replaced-by-new-session';
2095
+ failAllPendingTasks(existing, 'replaced-by-new-session');
2096
+ failAllPendingInputFallbacks(existing, 'replaced-by-new-session');
2097
+ writeJsonLine(existing.socket, {
2087
2098
  type: 'disconnect',
2088
2099
  reason: 'replaced-by-new-session',
2089
2100
  nextSessionId
@@ -2856,9 +2867,10 @@ export function createRemoteHub(options = {}) {
2856
2867
  closeFrameSocket(device, reason);
2857
2868
  device.disconnectedAt = new Date().toISOString();
2858
2869
  device.lastDisconnectReason = reason;
2859
- deactivateDeviceLiveStreams(device, reason, device.disconnectedAt);
2860
- failAllPendingTasks(device, 'device-disconnected');
2861
- logWarn('remote', `device disconnected ${device.deviceName} (${deviceId}): ${reason}`);
2870
+ deactivateDeviceLiveStreams(device, reason, device.disconnectedAt);
2871
+ failAllPendingTasks(device, 'device-disconnected');
2872
+ failAllPendingInputFallbacks(device, 'device-disconnected');
2873
+ logWarn('remote', `device disconnected ${device.deviceName} (${deviceId}): ${reason}`);
2862
2874
  emitRemoteEvent('RemoteDeviceDisconnected', device, { reason });
2863
2875
  }
2864
2876
 
@@ -3116,10 +3128,10 @@ export function createRemoteHub(options = {}) {
3116
3128
  return task;
3117
3129
  }
3118
3130
 
3119
- function failAllPendingTasks(device, error = 'device-disconnected') {
3120
- if (!device?.pendingTaskCommands) {
3121
- return 0;
3122
- }
3131
+ function failAllPendingTasks(device, error = 'device-disconnected') {
3132
+ if (!device?.pendingTaskCommands) {
3133
+ return 0;
3134
+ }
3123
3135
 
3124
3136
  const commandIds = [...device.pendingTaskCommands.keys()];
3125
3137
  let failed = 0;
@@ -3127,11 +3139,263 @@ export function createRemoteHub(options = {}) {
3127
3139
  if (failPendingTask(device, commandId, error)) {
3128
3140
  failed += 1;
3129
3141
  }
3130
- }
3131
- return failed;
3132
- }
3133
-
3134
- function scheduleTaskTimeout(device, task) {
3142
+ }
3143
+ return failed;
3144
+ }
3145
+
3146
+ function ensurePendingInputFallbacks(device) {
3147
+ if (!(device?.pendingInputFallbacks instanceof Map)) {
3148
+ Object.defineProperty(device, 'pendingInputFallbacks', {
3149
+ value: new Map(),
3150
+ enumerable: false,
3151
+ configurable: true,
3152
+ writable: true
3153
+ });
3154
+ }
3155
+ if (!(device?.completedInputFallbacks instanceof Map)) {
3156
+ Object.defineProperty(device, 'completedInputFallbacks', {
3157
+ value: new Map(),
3158
+ enumerable: false,
3159
+ configurable: true,
3160
+ writable: true
3161
+ });
3162
+ }
3163
+ return device.pendingInputFallbacks;
3164
+ }
3165
+
3166
+ function pruneCompletedInputFallbacks(device, now = Date.now()) {
3167
+ ensurePendingInputFallbacks(device);
3168
+ for (const [key, expiresAt] of device.completedInputFallbacks.entries()) {
3169
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) {
3170
+ device.completedInputFallbacks.delete(key);
3171
+ }
3172
+ }
3173
+ }
3174
+
3175
+ function getInputFallbackCompletionKeys(commandId, inputSeq) {
3176
+ const keys = [];
3177
+ const normalizedCommandId = safeString(commandId, 128);
3178
+ const normalizedInputSeq = Number(inputSeq || 0);
3179
+ if (normalizedCommandId) {
3180
+ keys.push(`command:${normalizedCommandId}`);
3181
+ }
3182
+ if (Number.isSafeInteger(normalizedInputSeq) && normalizedInputSeq > 0) {
3183
+ keys.push(`seq:${normalizedInputSeq}`);
3184
+ }
3185
+ return keys;
3186
+ }
3187
+
3188
+ function rememberCompletedInputFallback(device, pending) {
3189
+ pruneCompletedInputFallbacks(device);
3190
+ const expiresAt = Date.now() + Math.max(inputAckTimeoutMs * 2, 10000);
3191
+ for (const key of getInputFallbackCompletionKeys(pending?.commandId, pending?.inputSeq)) {
3192
+ device.completedInputFallbacks.set(key, expiresAt);
3193
+ }
3194
+ while (device.completedInputFallbacks.size > 256) {
3195
+ const oldestKey = device.completedInputFallbacks.keys().next().value;
3196
+ if (!oldestKey) {
3197
+ break;
3198
+ }
3199
+ device.completedInputFallbacks.delete(oldestKey);
3200
+ }
3201
+ }
3202
+
3203
+ function wasInputFallbackCompleted(device, commandId, inputSeq) {
3204
+ pruneCompletedInputFallbacks(device);
3205
+ return getInputFallbackCompletionKeys(commandId, inputSeq)
3206
+ .some(key => device.completedInputFallbacks.has(key));
3207
+ }
3208
+
3209
+ function findPendingInputFallback(device, commandId, inputSeq) {
3210
+ const pendingFallbacks = ensurePendingInputFallbacks(device);
3211
+ const key = safeString(commandId, 128);
3212
+ if (key && pendingFallbacks.has(key)) {
3213
+ return pendingFallbacks.get(key);
3214
+ }
3215
+ const sequence = Number(inputSeq || 0);
3216
+ if (!Number.isSafeInteger(sequence) || sequence <= 0) {
3217
+ return null;
3218
+ }
3219
+ for (const pending of pendingFallbacks.values()) {
3220
+ if (Number(pending?.inputSeq || 0) === sequence) {
3221
+ return pending;
3222
+ }
3223
+ }
3224
+ return null;
3225
+ }
3226
+
3227
+ function takePendingInputFallback(device, commandId, inputSeq) {
3228
+ const pending = findPendingInputFallback(device, commandId, inputSeq);
3229
+ if (!pending) {
3230
+ return null;
3231
+ }
3232
+ if (pending.timeoutTimer) {
3233
+ clearTimeout(pending.timeoutTimer);
3234
+ pending.timeoutTimer = null;
3235
+ }
3236
+ ensurePendingInputFallbacks(device).delete(pending.commandId);
3237
+ rememberCompletedInputFallback(device, pending);
3238
+ return pending;
3239
+ }
3240
+
3241
+ function buildRemoteInputOutcome(device, message = {}, pending = null) {
3242
+ const result = message?.result && typeof message.result === 'object'
3243
+ ? message.result
3244
+ : {};
3245
+ const inputSeq = Number(
3246
+ message.inputSeq
3247
+ ?? message.InputSeq
3248
+ ?? result.inputSeq
3249
+ ?? result.InputSeq
3250
+ ?? pending?.inputSeq
3251
+ ?? 0) || 0;
3252
+ return {
3253
+ deviceId: device.deviceId,
3254
+ commandId: safeString(message.commandId || message.CommandId || pending?.commandId, 128),
3255
+ inputSeq,
3256
+ inputType: safeString(
3257
+ message.inputType
3258
+ || message.InputType
3259
+ || result.inputType
3260
+ || result.InputType
3261
+ || pending?.inputType,
3262
+ 48),
3263
+ issuedAtEpochMs: Number(
3264
+ message.issuedAtEpochMs
3265
+ ?? message.IssuedAtEpochMs
3266
+ ?? result.issuedAtEpochMs
3267
+ ?? result.IssuedAtEpochMs
3268
+ ?? pending?.issuedAtEpochMs
3269
+ ?? 0) || 0,
3270
+ hubReceivedAtEpochMs: Number(
3271
+ message.hubReceivedAtEpochMs
3272
+ ?? message.HubReceivedAtEpochMs
3273
+ ?? result.hubReceivedAtEpochMs
3274
+ ?? result.HubReceivedAtEpochMs
3275
+ ?? pending?.hubReceivedAtEpochMs
3276
+ ?? 0) || 0,
3277
+ hubForwardedAtEpochMs: Number(
3278
+ message.hubForwardedAtEpochMs
3279
+ ?? message.HubForwardedAtEpochMs
3280
+ ?? result.hubForwardedAtEpochMs
3281
+ ?? result.HubForwardedAtEpochMs
3282
+ ?? pending?.hubForwardedAtEpochMs
3283
+ ?? 0) || 0,
3284
+ fallback: pending?.fallback === true,
3285
+ hubAcknowledgedAtEpochMs: Date.now()
3286
+ };
3287
+ }
3288
+
3289
+ function emitRemoteInputApplied(device, message = {}, pending = null) {
3290
+ const outcome = buildRemoteInputOutcome(device, message, pending);
3291
+ emitEvent('RemoteInputApplied', {
3292
+ ...outcome,
3293
+ agentApplyMs: Number(
3294
+ message.agentApplyMs
3295
+ ?? message.AgentApplyMs
3296
+ ?? message.result?.agentApplyMs
3297
+ ?? message.result?.AgentApplyMs
3298
+ ?? 0) || 0
3299
+ });
3300
+ }
3301
+
3302
+ function emitRemoteInputError(device, message = {}, pending = null, fallbackError = 'remote-input-failed') {
3303
+ const outcome = buildRemoteInputOutcome(device, message, pending);
3304
+ emitEvent('RemoteInputError', {
3305
+ ...outcome,
3306
+ error: safeString(
3307
+ message.error
3308
+ || message.Error
3309
+ || message.result?.error
3310
+ || message.result?.Error
3311
+ || fallbackError,
3312
+ 600) || fallbackError,
3313
+ failedAtEpochMs: Number(
3314
+ message.failedAtEpochMs
3315
+ ?? message.FailedAtEpochMs
3316
+ ?? message.result?.failedAtEpochMs
3317
+ ?? message.result?.FailedAtEpochMs
3318
+ ?? 0) || 0
3319
+ });
3320
+ }
3321
+
3322
+ function handleRemoteInputOutcome(device, message = {}, source = 'input') {
3323
+ const commandId = safeString(message.commandId || message.CommandId, 128);
3324
+ const inputSeq = Number(message.inputSeq ?? message.InputSeq ?? 0) || 0;
3325
+ const pending = takePendingInputFallback(device, commandId, inputSeq);
3326
+ if (!pending
3327
+ && source === 'main'
3328
+ && wasInputFallbackCompleted(device, commandId, inputSeq)) {
3329
+ return false;
3330
+ }
3331
+ // Main-channel input acknowledgements only exist for a tracked
3332
+ // compatibility fallback. Ignore stale/unmatched messages so an old
3333
+ // session cannot acknowledge input from the current control session.
3334
+ if (!pending && source === 'main') {
3335
+ return false;
3336
+ }
3337
+ if (message.type === 'input.error') {
3338
+ emitRemoteInputError(device, message, pending);
3339
+ } else {
3340
+ emitRemoteInputApplied(device, message, pending);
3341
+ }
3342
+ return true;
3343
+ }
3344
+
3345
+ function handleInputFallbackCommandResult(device, message = {}) {
3346
+ const commandId = safeString(message.commandId || message.CommandId, 128);
3347
+ const pending = findPendingInputFallback(device, commandId, 0);
3348
+ if (!pending) {
3349
+ return false;
3350
+ }
3351
+ const completed = takePendingInputFallback(device, commandId, pending.inputSeq);
3352
+ const result = message.result && typeof message.result === 'object' ? message.result : {};
3353
+ const error = safeString(message.error || message.Error || result.error || result.Error, 600);
3354
+ const failed = message.type === 'command.error'
3355
+ || !!error
3356
+ || result.ok === false
3357
+ || safeString(result.status, 40).toLowerCase() === 'failed';
3358
+ if (failed) {
3359
+ emitRemoteInputError(device, { ...message, error: error || 'remote-input-failed' }, completed);
3360
+ } else {
3361
+ emitRemoteInputApplied(device, message, completed);
3362
+ }
3363
+ return true;
3364
+ }
3365
+
3366
+ function schedulePendingInputFallback(device, pending) {
3367
+ const pendingFallbacks = ensurePendingInputFallbacks(device);
3368
+ const existing = pendingFallbacks.get(pending.commandId);
3369
+ if (existing?.timeoutTimer) {
3370
+ clearTimeout(existing.timeoutTimer);
3371
+ }
3372
+ pendingFallbacks.set(pending.commandId, pending);
3373
+ pending.timeoutTimer = setTimeout(() => {
3374
+ const timedOut = takePendingInputFallback(device, pending.commandId, pending.inputSeq);
3375
+ if (timedOut) {
3376
+ emitRemoteInputError(device, { error: 'remote-input-timeout' }, timedOut, 'remote-input-timeout');
3377
+ }
3378
+ }, inputAckTimeoutMs);
3379
+ pending.timeoutTimer.unref?.();
3380
+ }
3381
+
3382
+ function failAllPendingInputFallbacks(device, error = 'device-disconnected') {
3383
+ if (!device || typeof device !== 'object') {
3384
+ return 0;
3385
+ }
3386
+ const pendingFallbacks = ensurePendingInputFallbacks(device);
3387
+ const pending = [...pendingFallbacks.values()];
3388
+ for (const item of pending) {
3389
+ const failed = takePendingInputFallback(device, item.commandId, item.inputSeq);
3390
+ if (failed) {
3391
+ emitRemoteInputError(device, { error }, failed, error);
3392
+ }
3393
+ }
3394
+ device.completedInputFallbacks.clear();
3395
+ return pending.length;
3396
+ }
3397
+
3398
+ function scheduleTaskTimeout(device, task) {
3135
3399
  const commandId = safeString(task?.commandId, 128);
3136
3400
  if (!device?.pendingTaskTimers || !commandId) {
3137
3401
  return;
@@ -3370,10 +3634,108 @@ export function createRemoteHub(options = {}) {
3370
3634
  return true;
3371
3635
  }
3372
3636
 
3373
- function applyLiveStreamOpen(device, message, transport = 'json') {
3374
- const streamId = safeString(message.streamId, 128) || 'live';
3375
- const streamState = getDeviceLiveStream(device, streamId);
3376
- if (!streamState?.active) {
3637
+ function promotePendingLiveStream(device, streamState, message, transport) {
3638
+ const pending = getPendingLiveStreamDescriptor(streamState);
3639
+ const commandId = safeString(message.commandId, 128);
3640
+ if (!pending || !commandId || commandId !== safeString(pending.commandId, 128)) {
3641
+ return null;
3642
+ }
3643
+ const messageCaptureGeneration = Number(message.captureGeneration || 0);
3644
+ const pendingCaptureGeneration = Number(pending.captureGeneration || 0);
3645
+ if (messageCaptureGeneration > 0
3646
+ && pendingCaptureGeneration > 0
3647
+ && messageCaptureGeneration !== pendingCaptureGeneration) {
3648
+ emitRemoteEvent('RemoteLiveStreamOpenIgnored', device, {
3649
+ reason: 'stale-pending-capture-generation',
3650
+ streamId: streamState.streamId,
3651
+ commandId,
3652
+ captureGeneration: messageCaptureGeneration,
3653
+ pendingCaptureGeneration,
3654
+ transport
3655
+ });
3656
+ return null;
3657
+ }
3658
+
3659
+ const previousCommandId = safeString(streamState.commandId, 128);
3660
+ const transfer = buildRemoteFrameTransferDescriptorFromMessage(
3661
+ message,
3662
+ pending.mode || pending.frameMode || DEFAULT_REMOTE_FRAME_MODE);
3663
+ const openedAt = safeString(message.openedAt, 80) || device.lastSeenAt || new Date().toISOString();
3664
+ const nextStreamState = {
3665
+ ...streamState,
3666
+ ...pending,
3667
+ streamId: streamState.streamId,
3668
+ commandId,
3669
+ active: true,
3670
+ open: true,
3671
+ openedAt,
3672
+ openTransport: transport,
3673
+ mode: transfer.mode,
3674
+ frameMode: transfer.frameMode,
3675
+ frameProfile: transfer.frameProfile,
3676
+ encoding: transfer.encoding,
3677
+ codec: transfer.codec,
3678
+ compression: transfer.compression,
3679
+ transferProtocol: transfer.transferProtocol,
3680
+ transferProtocolVersion: transfer.transferProtocolVersion,
3681
+ handshake: transfer.handshake,
3682
+ width: Number.isFinite(Number(message.width)) ? Number(message.width) : Number(pending.width || 0),
3683
+ height: Number.isFinite(Number(message.height)) ? Number(message.height) : Number(pending.height || 0),
3684
+ sourceWidth: Number.isFinite(Number(message.sourceWidth)) ? Number(message.sourceWidth) : Number(pending.sourceWidth || 0),
3685
+ sourceHeight: Number.isFinite(Number(message.sourceHeight)) ? Number(message.sourceHeight) : Number(pending.sourceHeight || 0),
3686
+ monitorIndex: Number.isFinite(Number(message.monitorIndex))
3687
+ ? normalizeMonitorIndex(message.monitorIndex)
3688
+ : Number(pending.monitorIndex || 0),
3689
+ monitorCount: Number.isFinite(Number(message.monitorCount))
3690
+ ? clampNumber(message.monitorCount, 1, 64, 1)
3691
+ : Number(pending.monitorCount || 1),
3692
+ streamPurpose: safeString(message.streamPurpose || pending.streamPurpose, 24) || 'wall',
3693
+ captureGeneration: messageCaptureGeneration > 0
3694
+ ? messageCaptureGeneration
3695
+ : pendingCaptureGeneration,
3696
+ openedFrameSeq: Number.isFinite(Number(message.frameSeq)) ? Number(message.frameSeq) : 0,
3697
+ lastFrameAt: '',
3698
+ lastFrameSeq: 0,
3699
+ framesReceived: 0,
3700
+ latestFrame: null,
3701
+ stoppedAt: '',
3702
+ stopReason: ''
3703
+ };
3704
+ clearPendingLiveStreamDescriptor(device, streamState);
3705
+ setPendingLiveStreamDescriptor(nextStreamState, null);
3706
+ if (device.latestLiveFrame?.streamId === streamState.streamId
3707
+ && safeString(device.latestLiveFrame.commandId, 128) === previousCommandId) {
3708
+ device.latestLiveFrame = null;
3709
+ }
3710
+ setDeviceLiveStream(device, nextStreamState);
3711
+ return {
3712
+ streamState: nextStreamState,
3713
+ transfer,
3714
+ previousCommandId
3715
+ };
3716
+ }
3717
+
3718
+ function emitLiveStreamOpened(device, streamState, transfer, transport, previousCommandId = '') {
3719
+ emitRemoteEvent('RemoteLiveStreamOpened', device, {
3720
+ streamId: streamState.streamId,
3721
+ commandId: streamState.commandId,
3722
+ previousCommandId,
3723
+ handoff: !!previousCommandId,
3724
+ mode: transfer.mode,
3725
+ frameMode: transfer.frameMode,
3726
+ frameProfile: transfer.frameProfile,
3727
+ encoding: transfer.encoding,
3728
+ compression: transfer.compression,
3729
+ transport,
3730
+ captureGeneration: Number(streamState.captureGeneration || 0),
3731
+ streamPurpose: safeString(streamState.streamPurpose, 24) || 'wall'
3732
+ });
3733
+ }
3734
+
3735
+ function applyLiveStreamOpen(device, message, transport = 'json') {
3736
+ const streamId = safeString(message.streamId, 128) || 'live';
3737
+ const streamState = getDeviceLiveStream(device, streamId);
3738
+ if (!streamState?.active) {
3377
3739
  emitRemoteEvent('RemoteLiveStreamOpenIgnored', device, {
3378
3740
  reason: 'stale-live-stream-open',
3379
3741
  streamId,
@@ -3381,12 +3743,13 @@ export function createRemoteHub(options = {}) {
3381
3743
  });
3382
3744
  return false;
3383
3745
  }
3384
-
3385
- const commandId = safeString(message.commandId, 128);
3386
- const activeCommandId = safeString(streamState.commandId, 128);
3387
- const pendingCommandId = safeString(streamState.pendingCommandId, 128);
3388
- const isActiveGeneration = !commandId || !activeCommandId || commandId === activeCommandId;
3389
- const isPendingGeneration = !!pendingCommandId && commandId === pendingCommandId;
3746
+
3747
+ const commandId = safeString(message.commandId, 128);
3748
+ const activeCommandId = safeString(streamState.commandId, 128);
3749
+ const pendingDescriptor = getPendingLiveStreamDescriptor(streamState);
3750
+ const pendingCommandId = safeString(pendingDescriptor?.commandId, 128);
3751
+ const isActiveGeneration = !commandId || !activeCommandId || commandId === activeCommandId;
3752
+ const isPendingGeneration = !!pendingCommandId && commandId === pendingCommandId;
3390
3753
  if (!isActiveGeneration && !isPendingGeneration) {
3391
3754
  emitRemoteEvent('RemoteLiveStreamOpenIgnored', device, {
3392
3755
  reason: 'stale-live-stream-generation',
@@ -3396,21 +3759,31 @@ export function createRemoteHub(options = {}) {
3396
3759
  pendingCommandId,
3397
3760
  transport
3398
3761
  });
3399
- return false;
3400
- }
3401
-
3402
- const activatingPendingGeneration = isPendingGeneration && commandId !== activeCommandId;
3403
- const transfer = buildRemoteFrameTransferDescriptorFromMessage(message, streamState.mode || DEFAULT_REMOTE_FRAME_MODE);
3404
- const openedAt = safeString(message.openedAt, 80) || device.lastSeenAt || new Date().toISOString();
3762
+ return false;
3763
+ }
3764
+
3765
+ const activatingPendingGeneration = isPendingGeneration && commandId !== activeCommandId;
3766
+ if (activatingPendingGeneration) {
3767
+ const promoted = promotePendingLiveStream(device, streamState, message, transport);
3768
+ if (!promoted) {
3769
+ return false;
3770
+ }
3771
+ emitLiveStreamOpened(
3772
+ device,
3773
+ promoted.streamState,
3774
+ promoted.transfer,
3775
+ transport,
3776
+ promoted.previousCommandId);
3777
+ return true;
3778
+ }
3779
+
3780
+ const transfer = buildRemoteFrameTransferDescriptorFromMessage(message, streamState.mode || DEFAULT_REMOTE_FRAME_MODE);
3781
+ const openedAt = safeString(message.openedAt, 80) || device.lastSeenAt || new Date().toISOString();
3405
3782
  const nextStreamState = {
3406
3783
  ...streamState,
3407
- commandId: activatingPendingGeneration ? commandId : streamState.commandId,
3408
- pendingCommandId: activatingPendingGeneration ? '' : streamState.pendingCommandId,
3409
- pendingStartedAt: activatingPendingGeneration ? '' : streamState.pendingStartedAt,
3410
- pendingRestartToken: activatingPendingGeneration ? '' : streamState.pendingRestartToken,
3411
- open: true,
3412
- openedAt,
3413
- openTransport: transport,
3784
+ open: true,
3785
+ openedAt,
3786
+ openTransport: transport,
3414
3787
  mode: transfer.mode,
3415
3788
  frameMode: transfer.frameMode,
3416
3789
  frameProfile: transfer.frameProfile,
@@ -3432,42 +3805,45 @@ export function createRemoteHub(options = {}) {
3432
3805
  : Number(streamState.captureGeneration || 0),
3433
3806
  openedFrameSeq: Number.isFinite(Number(message.frameSeq)) ? Number(message.frameSeq) : Number(streamState.openedFrameSeq || 0)
3434
3807
  };
3435
- setDeviceLiveStream(device, nextStreamState);
3436
- emitRemoteEvent('RemoteLiveStreamOpened', device, {
3437
- streamId,
3438
- commandId: nextStreamState.commandId,
3439
- previousCommandId: activatingPendingGeneration ? activeCommandId : '',
3440
- handoff: activatingPendingGeneration,
3441
- mode: transfer.mode,
3442
- frameMode: transfer.frameMode,
3443
- frameProfile: transfer.frameProfile,
3444
- encoding: transfer.encoding,
3445
- compression: transfer.compression,
3446
- transport
3447
- });
3448
- return true;
3449
- }
3808
+ setDeviceLiveStream(device, nextStreamState);
3809
+ emitLiveStreamOpened(device, nextStreamState, transfer, transport);
3810
+ return true;
3811
+ }
3450
3812
 
3451
3813
  function applyLiveFrame(device, message, framePayload, transport = 'json-base64') {
3452
3814
  const frameData = Buffer.isBuffer(framePayload)
3453
3815
  ? ''
3454
3816
  : safeString(framePayload, MAX_STREAM_BASE64_CHARS + 1);
3455
- const frameSeq = Number(message.frameSeq);
3456
- const streamId = safeString(message.streamId, 128) || 'live';
3457
- const commandId = safeString(message.commandId, 128);
3458
- const streamState = getDeviceLiveStream(device, streamId);
3459
- if (!streamState?.active) {
3817
+ const frameSeq = Number(message.frameSeq);
3818
+ const streamId = safeString(message.streamId, 128) || 'live';
3819
+ const commandId = safeString(message.commandId, 128);
3820
+ let streamState = getDeviceLiveStream(device, streamId);
3821
+ if (!streamState?.active) {
3460
3822
  device.counters.liveFramesDropped += 1;
3461
3823
  emitRemoteEvent('RemoteFrameDropped', device, {
3462
3824
  reason: 'stale-live-stream-frame',
3463
3825
  streamId,
3464
3826
  frameSeq: Number.isFinite(frameSeq) ? frameSeq : null,
3465
3827
  transport
3466
- });
3467
- return false;
3468
- }
3469
- const activeCommandId = safeString(streamState.commandId, 128);
3470
- const pendingCommandId = safeString(streamState.pendingCommandId, 128);
3828
+ });
3829
+ return false;
3830
+ }
3831
+ let activeCommandId = safeString(streamState.commandId, 128);
3832
+ let pendingCommandId = safeString(getPendingLiveStreamDescriptor(streamState)?.commandId, 128);
3833
+ if (pendingCommandId && commandId === pendingCommandId && commandId !== activeCommandId) {
3834
+ const promoted = promotePendingLiveStream(device, streamState, message, `${transport}-implicit`);
3835
+ if (promoted) {
3836
+ streamState = promoted.streamState;
3837
+ activeCommandId = safeString(streamState.commandId, 128);
3838
+ pendingCommandId = '';
3839
+ emitLiveStreamOpened(
3840
+ device,
3841
+ streamState,
3842
+ promoted.transfer,
3843
+ `${transport}-implicit`,
3844
+ promoted.previousCommandId);
3845
+ }
3846
+ }
3471
3847
  const isActiveGeneration = !commandId || !activeCommandId || commandId === activeCommandId;
3472
3848
  if (!isActiveGeneration) {
3473
3849
  device.counters.liveFramesDropped += 1;
@@ -3520,7 +3896,7 @@ export function createRemoteHub(options = {}) {
3520
3896
  const contentHash = buildFrameContentHash(message, payload);
3521
3897
  const sameContentStreak = computeSameContentStreak(streamState.latestFrame, contentHash);
3522
3898
  const transfer = buildRemoteFrameTransferDescriptorFromMessage(message, streamState.mode || DEFAULT_REMOTE_FRAME_MODE);
3523
- if (streamState.open !== true) {
3899
+ if (streamState.open !== true) {
3524
3900
  streamState.open = true;
3525
3901
  streamState.openedAt = device.lastSeenAt;
3526
3902
  streamState.openTransport = `${transport}-implicit`;
@@ -3532,15 +3908,20 @@ export function createRemoteHub(options = {}) {
3532
3908
  streamState.compression = transfer.compression;
3533
3909
  streamState.transferProtocol = transfer.transferProtocol;
3534
3910
  streamState.transferProtocolVersion = transfer.transferProtocolVersion;
3535
- streamState.handshake = transfer.handshake;
3536
- }
3537
- device.latestLiveFrame = {
3538
- streamId,
3539
- frameSeq,
3911
+ streamState.handshake = transfer.handshake;
3912
+ }
3913
+ const currentGenerationVerified = (!activeCommandId || commandId === activeCommandId)
3914
+ && (activeCaptureGeneration <= 0
3915
+ || (frameCaptureGeneration > 0
3916
+ && frameCaptureGeneration === activeCaptureGeneration));
3917
+ device.latestLiveFrame = {
3918
+ streamId,
3919
+ frameSeq,
3540
3920
  streamFrameSeq: Number.isFinite(Number(message.streamFrameSeq)) ? Number(message.streamFrameSeq) : frameSeq,
3541
- commandId,
3921
+ commandId: commandId || activeCommandId,
3542
3922
  sessionId: device.sessionId,
3543
3923
  captureGeneration: activeCaptureGeneration || frameCaptureGeneration,
3924
+ currentGenerationVerified,
3544
3925
  streamPurpose: safeString(message.streamPurpose || streamState.streamPurpose, 24) || 'wall',
3545
3926
  width: Number.isFinite(Number(message.width)) ? Number(message.width) : 0,
3546
3927
  height: Number.isFinite(Number(message.height)) ? Number(message.height) : 0,
@@ -3632,11 +4013,21 @@ export function createRemoteHub(options = {}) {
3632
4013
  streamState.sourceWidth = device.latestLiveFrame.sourceWidth;
3633
4014
  streamState.sourceHeight = device.latestLiveFrame.sourceHeight;
3634
4015
  streamState.monitorIndex = device.latestLiveFrame.monitorIndex;
3635
- streamState.monitorCount = device.latestLiveFrame.monitorCount;
3636
- ensureDeviceLiveStreams(device).set(streamId, streamState);
3637
- device.counters.liveFramesReceived += 1;
3638
- return true;
3639
- }
4016
+ streamState.monitorCount = device.latestLiveFrame.monitorCount;
4017
+ ensureDeviceLiveStreams(device).set(streamId, streamState);
4018
+ device.counters.liveFramesReceived += 1;
4019
+ if (streamState.framesReceived === 1 && liveStreamHasCurrentFrame(streamState)) {
4020
+ emitRemoteEvent('RemoteLiveStreamReady', device, {
4021
+ streamId,
4022
+ commandId: streamState.commandId,
4023
+ captureGeneration: Number(streamState.captureGeneration || 0),
4024
+ streamPurpose: safeString(streamState.streamPurpose, 24) || 'wall',
4025
+ monitorIndex: Number(streamState.monitorIndex || 0),
4026
+ frameSeq
4027
+ });
4028
+ }
4029
+ return true;
4030
+ }
3640
4031
 
3641
4032
  function applyAudioFrame(device, message, framePayload, transport = 'json-base64') {
3642
4033
  const audioData = Buffer.isBuffer(framePayload)
@@ -4010,32 +4401,27 @@ export function createRemoteHub(options = {}) {
4010
4401
  return;
4011
4402
  }
4012
4403
 
4013
- if (state.inputOnly) {
4014
- device.inputLastSeenAt = new Date().toISOString();
4015
- if (message.type === 'input.applied') {
4016
- emitEvent('RemoteInputApplied', {
4017
- deviceId: device.deviceId,
4018
- inputSeq: Number(message.inputSeq || 0) || 0,
4019
- inputType: safeString(message.inputType, 48),
4020
- issuedAtEpochMs: Number(message.issuedAtEpochMs || 0) || 0,
4021
- hubReceivedAtEpochMs: Number(message.hubReceivedAtEpochMs || 0) || 0,
4022
- hubForwardedAtEpochMs: Number(message.hubForwardedAtEpochMs || 0) || 0,
4023
- agentApplyMs: Number(message.agentApplyMs || 0) || 0,
4024
- hubAcknowledgedAtEpochMs: Date.now()
4025
- });
4026
- }
4027
- if (message.type === 'input.error') {
4028
- emitEvent('RemoteInputError', {
4029
- deviceId: device.deviceId,
4030
- inputSeq: Number(message.inputSeq || 0) || 0,
4031
- inputType: safeString(message.inputType, 48),
4032
- error: safeString(message.error, 600) || 'remote-input-failed',
4033
- failedAtEpochMs: Number(message.failedAtEpochMs || 0) || 0,
4034
- hubAcknowledgedAtEpochMs: Date.now()
4035
- });
4036
- }
4037
- return;
4038
- }
4404
+ if (state.inputOnly) {
4405
+ device.inputLastSeenAt = new Date().toISOString();
4406
+ if (message.type === 'input.applied') {
4407
+ handleRemoteInputOutcome(device, {
4408
+ ...message,
4409
+ agentApplyMs: Number(message.agentApplyMs || 0) || 0
4410
+ }, 'input');
4411
+ }
4412
+ if (message.type === 'input.error') {
4413
+ const pending = takePendingInputFallback(
4414
+ device,
4415
+ message.commandId || message.CommandId,
4416
+ message.inputSeq ?? message.InputSeq);
4417
+ emitEvent('RemoteInputError', {
4418
+ ...buildRemoteInputOutcome(device, message, pending),
4419
+ error: safeString(message.error || message.Error, 600) || 'remote-input-failed',
4420
+ failedAtEpochMs: Number(message.failedAtEpochMs ?? message.FailedAtEpochMs ?? 0) || 0
4421
+ });
4422
+ }
4423
+ return;
4424
+ }
4039
4425
  if (state.frameOnly) {
4040
4426
  device.frameLastSeenAt = new Date().toISOString();
4041
4427
  return;
@@ -4067,15 +4453,19 @@ export function createRemoteHub(options = {}) {
4067
4453
  device.counters.statusReceived += 1;
4068
4454
  emitRemoteEvent('RemoteDeviceStatus', device);
4069
4455
  break;
4070
- case 'command.result':
4071
- device.counters.commandResultsReceived += 1;
4072
- {
4073
- const commandId = safeString(message.commandId, 128);
4074
- const error = safeString(message.error, 500);
4075
- const result = message.result ?? null;
4076
- if (error || result?.ok === false || result?.status === 'failed') {
4077
- failPendingLiveStream(device, commandId, error || result?.error || 'stream-start-failed');
4078
- }
4456
+ case 'command.result':
4457
+ case 'command.error':
4458
+ device.counters.commandResultsReceived += 1;
4459
+ {
4460
+ const commandId = safeString(message.commandId, 128);
4461
+ const error = safeString(
4462
+ message.error || (message.type === 'command.error' ? 'command-failed' : ''),
4463
+ 500);
4464
+ const result = message.result ?? null;
4465
+ handleInputFallbackCommandResult(device, message);
4466
+ if (error || result?.ok === false || result?.status === 'failed') {
4467
+ failPendingLiveStream(device, commandId, error || result?.error || 'stream-start-failed');
4468
+ }
4079
4469
  const task = applyTaskResult(device, commandId, result, error);
4080
4470
  if (task) {
4081
4471
  emitRemoteEvent('RemoteTaskResult', device, {
@@ -4090,8 +4480,12 @@ export function createRemoteHub(options = {}) {
4090
4480
  commandId: safeString(message.commandId, 128),
4091
4481
  result: message.result ?? null,
4092
4482
  error: safeString(message.error, 500)
4093
- });
4094
- break;
4483
+ });
4484
+ break;
4485
+ case 'input.applied':
4486
+ case 'input.error':
4487
+ handleRemoteInputOutcome(device, message, 'main');
4488
+ break;
4095
4489
  case 'thumbnail.frame': {
4096
4490
  applyThumbnailFrame(device, message, message.data, 'json-base64');
4097
4491
  break;
@@ -4512,9 +4906,10 @@ export function createRemoteHub(options = {}) {
4512
4906
  }
4513
4907
 
4514
4908
  async function close() {
4515
- for (const device of devices.values()) {
4516
- failAllPendingTasks(device, 'hub-shutdown');
4517
- if (device.socket && !device.socket.destroyed) {
4909
+ for (const device of devices.values()) {
4910
+ failAllPendingTasks(device, 'hub-shutdown');
4911
+ failAllPendingInputFallbacks(device, 'hub-shutdown');
4912
+ if (device.socket && !device.socket.destroyed) {
4518
4913
  writeJsonLine(device.socket, { type: 'disconnect', reason: 'hub-shutdown' });
4519
4914
  device.socket.destroy();
4520
4915
  }
@@ -4565,9 +4960,10 @@ export function createRemoteHub(options = {}) {
4565
4960
  device.connected = false;
4566
4961
  device.disconnectedAt = new Date().toISOString();
4567
4962
  device.lastDisconnectReason = reason;
4568
- deactivateDeviceLiveStreams(device, reason, device.disconnectedAt);
4569
- failAllPendingTasks(device, 'device-disconnected');
4570
- emitRemoteEvent('RemoteDeviceDisconnected', device, { reason, synthetic: true });
4963
+ deactivateDeviceLiveStreams(device, reason, device.disconnectedAt);
4964
+ failAllPendingTasks(device, 'device-disconnected');
4965
+ failAllPendingInputFallbacks(device, 'device-disconnected');
4966
+ emitRemoteEvent('RemoteDeviceDisconnected', device, { reason, synthetic: true });
4571
4967
  return true;
4572
4968
  }
4573
4969
 
@@ -4693,13 +5089,15 @@ export function createRemoteHub(options = {}) {
4693
5089
  return { ok: false, error: 'device-not-connected' };
4694
5090
  }
4695
5091
 
4696
- const denied = policyError(device, 'allowControl');
4697
- if (denied) return { ok: false, error: denied };
4698
-
5092
+ const denied = policyError(device, 'allowControl');
5093
+ if (denied) return { ok: false, error: denied };
5094
+
5095
+ if (device.capabilities?.controlPermission === false
5096
+ || device.capabilities?.accessibilityPermission === false) {
5097
+ return { ok: false, error: 'INPUT_PERMISSION_DENIED' };
5098
+ }
4699
5099
  if (!readCapabilityFlag(device.capabilities, 'control')) {
4700
- const permissionMissing = device.capabilities?.controlPermission === false
4701
- || device.capabilities?.accessibilityPermission === false;
4702
- return { ok: false, error: permissionMissing ? 'INPUT_PERMISSION_DENIED' : 'device-input-control-unavailable' };
5100
+ return { ok: false, error: 'device-input-control-unavailable' };
4703
5101
  }
4704
5102
 
4705
5103
  const normalized = normalizeRemoteInputEvent(input);
@@ -4708,11 +5106,14 @@ export function createRemoteHub(options = {}) {
4708
5106
  }
4709
5107
  const controlStream = getActiveControlStream(device);
4710
5108
  if (!controlStream
4711
- || controlStream.open !== true
5109
+ || !liveStreamHasCurrentFrame(controlStream)
4712
5110
  || !safeString(controlStream.commandId, 128)
4713
5111
  || Number(controlStream.captureGeneration || 0) <= 0) {
4714
5112
  return { ok: false, error: 'CONTROL_NOT_READY' };
4715
5113
  }
5114
+ if (getPendingLiveStreamDescriptor(controlStream)) {
5115
+ return { ok: false, error: 'CAPTURE_TRANSITION_IN_PROGRESS' };
5116
+ }
4716
5117
  if (!normalized.controlSessionId
4717
5118
  || normalized.controlSessionId !== safeString(device.sessionId, 160)) {
4718
5119
  return {
@@ -4762,7 +5163,43 @@ export function createRemoteHub(options = {}) {
4762
5163
  detachInputSocket(inputSocket, 'input-socket-write-failed');
4763
5164
  }
4764
5165
 
4765
- return { ok: false, error: 'CONTROL_NOT_READY', detail: 'input-channel-not-connected' };
5166
+ const hubForwardedAtEpochMs = Date.now();
5167
+ const fallback = sendCommand(deviceId, {
5168
+ command: 'input.control',
5169
+ payload: {
5170
+ ...normalized,
5171
+ hubForwardedAtEpochMs,
5172
+ issuedAt: normalized.issuedAt || new Date().toISOString()
5173
+ }
5174
+ });
5175
+ if (fallback.ok && normalized.requestAck && normalized.inputSeq > 0) {
5176
+ schedulePendingInputFallback(device, {
5177
+ commandId: fallback.commandId,
5178
+ inputSeq: normalized.inputSeq,
5179
+ inputType: normalized.type,
5180
+ issuedAtEpochMs: normalized.issuedAtEpochMs,
5181
+ hubReceivedAtEpochMs: normalized.hubReceivedAtEpochMs,
5182
+ hubForwardedAtEpochMs,
5183
+ fallback: true,
5184
+ timeoutTimer: null
5185
+ });
5186
+ }
5187
+ return fallback.ok
5188
+ ? {
5189
+ ...fallback,
5190
+ inputSocket: false,
5191
+ fallback: true,
5192
+ sessionId: device.sessionId,
5193
+ deliveryCommandId: fallback.commandId,
5194
+ commandId: controlStream.commandId,
5195
+ captureGeneration: controlStream.captureGeneration,
5196
+ inputSeq: normalized.inputSeq
5197
+ }
5198
+ : {
5199
+ ...fallback,
5200
+ error: fallback.error || 'CONTROL_NOT_READY',
5201
+ detail: 'input-channel-not-connected'
5202
+ };
4766
5203
  }
4767
5204
 
4768
5205
  function notifyAgentProgress(deviceIds, progress = {}) {
@@ -5075,31 +5512,100 @@ export function createRemoteHub(options = {}) {
5075
5512
  || (device?.activeLiveStream?.streamId === id ? device.activeLiveStream : null);
5076
5513
  }
5077
5514
 
5078
- function setDeviceLiveStream(device, stream) {
5079
- if (!device || !stream?.streamId) {
5080
- return stream;
5081
- }
5082
- ensureDeviceLiveStreams(device).set(stream.streamId, stream);
5083
- device.activeLiveStream = stream;
5084
- return stream;
5085
- }
5086
-
5087
- function failPendingLiveStream(device, commandId, error = 'stream-start-failed') {
5088
- const key = safeString(commandId, 128);
5089
- if (!device || !key) {
5090
- return false;
5091
- }
5092
- const streams = ensureDeviceLiveStreams(device);
5093
- for (const stream of streams.values()) {
5094
- if (safeString(stream?.pendingCommandId, 128) !== key) {
5095
- continue;
5096
- }
5097
- stream.pendingCommandId = '';
5098
- stream.pendingStartedAt = '';
5099
- stream.pendingRestartToken = '';
5100
- emitRemoteEvent('RemoteLiveStreamRestartFailed', device, {
5101
- streamId: stream.streamId,
5102
- commandId: key,
5515
+ function setDeviceLiveStream(device, stream) {
5516
+ if (!device || !stream?.streamId) {
5517
+ return stream;
5518
+ }
5519
+ ensureDeviceLiveStreams(device).set(stream.streamId, stream);
5520
+ device.activeLiveStream = stream;
5521
+ return stream;
5522
+ }
5523
+
5524
+ function ensureLiveStreamReplacementTimers(device) {
5525
+ if (!(device?.liveStreamReplacementTimers instanceof Map)) {
5526
+ Object.defineProperty(device, 'liveStreamReplacementTimers', {
5527
+ value: new Map(),
5528
+ enumerable: false,
5529
+ configurable: true,
5530
+ writable: true
5531
+ });
5532
+ }
5533
+ return device.liveStreamReplacementTimers;
5534
+ }
5535
+
5536
+ function getPendingLiveStreamDescriptor(stream) {
5537
+ return stream?.pendingDescriptor && typeof stream.pendingDescriptor === 'object'
5538
+ ? stream.pendingDescriptor
5539
+ : null;
5540
+ }
5541
+
5542
+ function setPendingLiveStreamDescriptor(stream, descriptor) {
5543
+ const pending = descriptor && typeof descriptor === 'object'
5544
+ ? { ...descriptor }
5545
+ : null;
5546
+ stream.pendingDescriptor = pending;
5547
+ // Keep the scalar fields for older status consumers while the
5548
+ // descriptor remains the canonical pending-generation state.
5549
+ stream.pendingCommandId = safeString(pending?.commandId, 128);
5550
+ stream.pendingCaptureGeneration = Number(pending?.captureGeneration || 0);
5551
+ stream.pendingStartedAt = safeString(pending?.startedAt, 80);
5552
+ stream.pendingRestartToken = safeString(pending?.restartToken, 128);
5553
+ return pending;
5554
+ }
5555
+
5556
+ function clearLiveStreamReplacementTimer(device, streamId) {
5557
+ const timers = ensureLiveStreamReplacementTimers(device);
5558
+ const key = safeString(streamId, 128);
5559
+ const timer = timers.get(key);
5560
+ if (timer) {
5561
+ clearTimeout(timer);
5562
+ }
5563
+ timers.delete(key);
5564
+ }
5565
+
5566
+ function clearPendingLiveStreamDescriptor(device, stream) {
5567
+ if (!stream) {
5568
+ return null;
5569
+ }
5570
+ const pending = getPendingLiveStreamDescriptor(stream);
5571
+ clearLiveStreamReplacementTimer(device, stream.streamId);
5572
+ setPendingLiveStreamDescriptor(stream, null);
5573
+ return pending;
5574
+ }
5575
+
5576
+ function scheduleLiveStreamReplacementTimeout(device, stream) {
5577
+ const pending = getPendingLiveStreamDescriptor(stream);
5578
+ if (!pending?.commandId) {
5579
+ clearLiveStreamReplacementTimer(device, stream?.streamId);
5580
+ return;
5581
+ }
5582
+ clearLiveStreamReplacementTimer(device, stream.streamId);
5583
+ const timer = setTimeout(() => {
5584
+ const current = getDeviceLiveStream(device, stream.streamId);
5585
+ const currentPending = getPendingLiveStreamDescriptor(current);
5586
+ if (currentPending?.commandId === pending.commandId) {
5587
+ failPendingLiveStream(device, pending.commandId, 'stream-start-timeout');
5588
+ }
5589
+ }, LIVE_STREAM_REPLACEMENT_PENDING_MS);
5590
+ timer.unref?.();
5591
+ ensureLiveStreamReplacementTimers(device).set(stream.streamId, timer);
5592
+ }
5593
+
5594
+ function failPendingLiveStream(device, commandId, error = 'stream-start-failed') {
5595
+ const key = safeString(commandId, 128);
5596
+ if (!device || !key) {
5597
+ return false;
5598
+ }
5599
+ const streams = ensureDeviceLiveStreams(device);
5600
+ for (const stream of streams.values()) {
5601
+ const pending = getPendingLiveStreamDescriptor(stream);
5602
+ if (safeString(pending?.commandId, 128) !== key) {
5603
+ continue;
5604
+ }
5605
+ clearPendingLiveStreamDescriptor(device, stream);
5606
+ emitRemoteEvent('RemoteLiveStreamRestartFailed', device, {
5607
+ streamId: stream.streamId,
5608
+ commandId: key,
5103
5609
  error: safeString(error, 500) || 'stream-start-failed'
5104
5610
  });
5105
5611
  return true;
@@ -5107,12 +5613,13 @@ export function createRemoteHub(options = {}) {
5107
5613
  return false;
5108
5614
  }
5109
5615
 
5110
- function deactivateDeviceLiveStreams(device, reason, stoppedAt = new Date().toISOString()) {
5111
- const streams = ensureDeviceLiveStreams(device);
5112
- for (const stream of streams.values()) {
5113
- stream.active = false;
5114
- stream.stoppedAt = stoppedAt;
5115
- stream.stopReason = reason;
5616
+ function deactivateDeviceLiveStreams(device, reason, stoppedAt = new Date().toISOString()) {
5617
+ const streams = ensureDeviceLiveStreams(device);
5618
+ for (const stream of streams.values()) {
5619
+ clearPendingLiveStreamDescriptor(device, stream);
5620
+ stream.active = false;
5621
+ stream.stoppedAt = stoppedAt;
5622
+ stream.stopReason = reason;
5116
5623
  }
5117
5624
  if (device?.activeLiveStream) {
5118
5625
  device.activeLiveStream.active = false;
@@ -5176,13 +5683,35 @@ export function createRemoteHub(options = {}) {
5176
5683
  return Number.isFinite(startedAt) && Date.now() - startedAt < LIVE_STREAM_PENDING_REUSE_MS;
5177
5684
  }
5178
5685
 
5179
- function liveStreamReplacementStillPending(activeLiveStream) {
5180
- if (!activeLiveStream?.active || !safeString(activeLiveStream.pendingCommandId, 128)) {
5181
- return false;
5182
- }
5183
- const startedAt = Date.parse(activeLiveStream.pendingStartedAt || '');
5184
- return Number.isFinite(startedAt) && Date.now() - startedAt < LIVE_STREAM_REPLACEMENT_PENDING_MS;
5185
- }
5686
+ function liveStreamReplacementStillPending(activeLiveStream) {
5687
+ const pending = getPendingLiveStreamDescriptor(activeLiveStream);
5688
+ if (!activeLiveStream?.active || !safeString(pending?.commandId, 128)) {
5689
+ return false;
5690
+ }
5691
+ const startedAt = Date.parse(pending.startedAt || '');
5692
+ return Number.isFinite(startedAt) && Date.now() - startedAt < LIVE_STREAM_REPLACEMENT_PENDING_MS;
5693
+ }
5694
+
5695
+ function liveStreamHasCurrentFrame(activeLiveStream) {
5696
+ if (!activeLiveStream?.active
5697
+ || activeLiveStream.open !== true
5698
+ || Number(activeLiveStream.framesReceived || 0) < 1) {
5699
+ return false;
5700
+ }
5701
+ const latestFrame = activeLiveStream.latestFrame;
5702
+ if (!latestFrame || latestFrame.currentGenerationVerified !== true) {
5703
+ return false;
5704
+ }
5705
+ const activeCommandId = safeString(activeLiveStream.commandId, 128);
5706
+ const frameCommandId = safeString(latestFrame.commandId, 128);
5707
+ if (activeCommandId && frameCommandId !== activeCommandId) {
5708
+ return false;
5709
+ }
5710
+ const activeCaptureGeneration = Number(activeLiveStream.captureGeneration || 0);
5711
+ const frameCaptureGeneration = Number(latestFrame.captureGeneration || 0);
5712
+ return activeCaptureGeneration <= 0
5713
+ || (frameCaptureGeneration > 0 && frameCaptureGeneration === activeCaptureGeneration);
5714
+ }
5186
5715
 
5187
5716
  function getLiveStreamFreshWindowMs(activeLiveStream) {
5188
5717
  const fps = Number(activeLiveStream?.fps || 0);
@@ -5242,17 +5771,20 @@ export function createRemoteHub(options = {}) {
5242
5771
  const activeLiveStream = getDeviceLiveStream(device, streamId);
5243
5772
  const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
5244
5773
  if (activeLiveStream
5245
- && liveStreamMatchesOptions(activeLiveStream, normalized)
5246
- && (activeLiveStream.open === true || liveStreamStartStillPending(activeLiveStream))) {
5247
- return {
5248
- ok: true,
5249
- commandId: activeLiveStream.commandId,
5250
- streamId,
5251
- fps,
5774
+ && liveStreamMatchesOptions(activeLiveStream, normalized)
5775
+ && (activeLiveStream.open === true || liveStreamStartStillPending(activeLiveStream))) {
5776
+ return {
5777
+ ok: true,
5778
+ commandId: activeLiveStream.commandId,
5779
+ sessionId: device.sessionId,
5780
+ streamId,
5781
+ streamPurpose,
5782
+ fps,
5252
5783
  mode: transfer.mode,
5253
- frameMode: transfer.frameMode,
5784
+ frameMode: transfer.frameMode,
5254
5785
  monitorIndex,
5255
5786
  captureGeneration: Number(activeLiveStream.captureGeneration || 0),
5787
+ ready: liveStreamHasCurrentFrame(activeLiveStream),
5256
5788
  synthetic: true,
5257
5789
  reused: true
5258
5790
  };
@@ -5305,7 +5837,20 @@ export function createRemoteHub(options = {}) {
5305
5837
  mode: transfer.mode,
5306
5838
  synthetic: true
5307
5839
  });
5308
- return { ok: true, commandId, streamId, fps, mode: transfer.mode, frameMode: transfer.frameMode, monitorIndex, captureGeneration, synthetic: true };
5840
+ return {
5841
+ ok: true,
5842
+ commandId,
5843
+ sessionId: device.sessionId,
5844
+ streamId,
5845
+ streamPurpose,
5846
+ fps,
5847
+ mode: transfer.mode,
5848
+ frameMode: transfer.frameMode,
5849
+ monitorIndex,
5850
+ captureGeneration,
5851
+ ready: true,
5852
+ synthetic: true
5853
+ };
5309
5854
  }
5310
5855
 
5311
5856
  if (!device?.socket || device.socket.destroyed || !device.connected) {
@@ -5320,46 +5865,57 @@ export function createRemoteHub(options = {}) {
5320
5865
  const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
5321
5866
  const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
5322
5867
  const activeLiveStream = getDeviceLiveStream(device, streamId);
5323
- if (activeLiveStream?.pendingCommandId) {
5324
- if (liveStreamReplacementStillPending(activeLiveStream)) {
5325
- emitRemoteEvent('RemoteLiveStreamRestartPending', device, {
5326
- streamId,
5327
- commandId: activeLiveStream.pendingCommandId,
5328
- activeCommandId: activeLiveStream.commandId,
5329
- reason: safeString(options.restartReason || 'duplicate-restart', 128)
5330
- });
5331
- return {
5332
- ok: true,
5333
- commandId: activeLiveStream.pendingCommandId,
5334
- streamId,
5335
- fps: Number(activeLiveStream.fps || fps),
5336
- mode: activeLiveStream.mode || transfer.mode,
5337
- frameMode: activeLiveStream.frameMode || transfer.frameMode,
5338
- monitorIndex: Number(activeLiveStream.monitorIndex || monitorIndex),
5339
- captureGeneration: Number(activeLiveStream.captureGeneration || 0),
5868
+ const pendingDescriptor = getPendingLiveStreamDescriptor(activeLiveStream);
5869
+ if (pendingDescriptor?.commandId) {
5870
+ const pendingMatchesRequest = liveStreamMatchesOptions(pendingDescriptor, normalized);
5871
+ if (pendingMatchesRequest && liveStreamReplacementStillPending(activeLiveStream)) {
5872
+ emitRemoteEvent('RemoteLiveStreamRestartPending', device, {
5873
+ streamId,
5874
+ commandId: pendingDescriptor.commandId,
5875
+ activeCommandId: activeLiveStream.commandId,
5876
+ reason: safeString(options.restartReason || 'duplicate-restart', 128)
5877
+ });
5878
+ return {
5879
+ ok: true,
5880
+ commandId: pendingDescriptor.commandId,
5881
+ sessionId: device.sessionId,
5882
+ streamId,
5883
+ streamPurpose: safeString(pendingDescriptor.streamPurpose, 24) || streamPurpose,
5884
+ fps: Number(pendingDescriptor.fps || fps),
5885
+ mode: pendingDescriptor.mode || transfer.mode,
5886
+ frameMode: pendingDescriptor.frameMode || transfer.frameMode,
5887
+ monitorIndex: Number(pendingDescriptor.monitorIndex ?? monitorIndex),
5888
+ captureGeneration: Number(pendingDescriptor.captureGeneration || 0),
5889
+ ready: false,
5340
5890
  pending: true,
5341
5891
  reused: true
5342
5892
  };
5343
- }
5344
- activeLiveStream.pendingCommandId = '';
5345
- activeLiveStream.pendingStartedAt = '';
5346
- activeLiveStream.pendingRestartToken = '';
5347
- }
5893
+ }
5894
+ failPendingLiveStream(
5895
+ device,
5896
+ pendingDescriptor.commandId,
5897
+ liveStreamReplacementStillPending(activeLiveStream)
5898
+ ? 'stream-restart-superseded'
5899
+ : 'stream-start-timeout');
5900
+ }
5348
5901
  const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
5349
5902
  if (activeLiveStream
5350
5903
  && options.forceRestart !== true
5351
5904
  && options.reuseExisting === true
5352
5905
  && liveStreamMatchesOptions(activeLiveStream, normalized)
5353
5906
  && liveStreamIsReusable(activeLiveStream)) {
5354
- return {
5355
- ok: true,
5356
- commandId: activeLiveStream.commandId,
5357
- streamId,
5907
+ return {
5908
+ ok: true,
5909
+ commandId: activeLiveStream.commandId,
5910
+ sessionId: device.sessionId,
5911
+ streamId,
5912
+ streamPurpose,
5358
5913
  fps: Number(activeLiveStream.fps || fps),
5359
5914
  mode: activeLiveStream.mode || transfer.mode,
5360
5915
  frameMode: activeLiveStream.frameMode || transfer.frameMode,
5361
5916
  monitorIndex: Number(activeLiveStream.monitorIndex || monitorIndex),
5362
5917
  captureGeneration: Number(activeLiveStream.captureGeneration || 0),
5918
+ ready: liveStreamHasCurrentFrame(activeLiveStream),
5363
5919
  reused: true
5364
5920
  };
5365
5921
  }
@@ -5376,15 +5932,18 @@ export function createRemoteHub(options = {}) {
5376
5932
  monitorIndex
5377
5933
  });
5378
5934
  }
5379
- return {
5380
- ok: true,
5381
- commandId: activeLiveStream.commandId,
5382
- streamId,
5935
+ return {
5936
+ ok: true,
5937
+ commandId: activeLiveStream.commandId,
5938
+ sessionId: device.sessionId,
5939
+ streamId,
5940
+ streamPurpose,
5383
5941
  fps,
5384
5942
  mode: transfer.mode,
5385
5943
  frameMode: transfer.frameMode,
5386
5944
  monitorIndex,
5387
5945
  captureGeneration: Number(activeLiveStream.captureGeneration || 0),
5946
+ ready: liveStreamHasCurrentFrame(activeLiveStream),
5388
5947
  reused: true
5389
5948
  };
5390
5949
  }
@@ -5444,24 +6003,20 @@ export function createRemoteHub(options = {}) {
5444
6003
  return result;
5445
6004
  }
5446
6005
 
5447
- const previousCommandId = safeString(activeLiveStream?.commandId, 128);
5448
- const replacingActiveStream = activeLiveStream?.active === true
5449
- && !!previousCommandId
5450
- && previousCommandId !== commandId;
5451
- setDeviceLiveStream(device, {
5452
- ...(activeLiveStream || {}),
5453
- streamId,
5454
- commandId: replacingActiveStream ? previousCommandId : commandId,
5455
- pendingCommandId: replacingActiveStream ? commandId : '',
5456
- pendingStartedAt: replacingActiveStream ? now : '',
5457
- pendingRestartToken: replacingActiveStream ? safeString(options.restartToken, 128) : '',
5458
- active: true,
5459
- open: replacingActiveStream ? activeLiveStream.open === true : false,
5460
- openedAt: replacingActiveStream ? activeLiveStream.openedAt || '' : '',
5461
- openTransport: replacingActiveStream ? activeLiveStream.openTransport || '' : '',
5462
- mode: transfer.mode,
5463
- frameMode: transfer.frameMode,
5464
- frameProfile: transfer.frameProfile,
6006
+ const previousCommandId = safeString(activeLiveStream?.commandId, 128);
6007
+ const replacingActiveStream = activeLiveStream?.active === true
6008
+ && !!previousCommandId
6009
+ && previousCommandId !== commandId;
6010
+ const nextDescriptor = {
6011
+ streamId,
6012
+ commandId,
6013
+ active: true,
6014
+ open: false,
6015
+ openedAt: '',
6016
+ openTransport: '',
6017
+ mode: transfer.mode,
6018
+ frameMode: transfer.frameMode,
6019
+ frameProfile: transfer.frameProfile,
5465
6020
  encoding: transfer.encoding,
5466
6021
  codec: transfer.codec,
5467
6022
  compression: transfer.compression,
@@ -5472,17 +6027,32 @@ export function createRemoteHub(options = {}) {
5472
6027
  maxWidth,
5473
6028
  maxHeight,
5474
6029
  quality,
5475
- monitorIndex,
5476
- monitorCount: 1,
5477
- startedAt: replacingActiveStream ? activeLiveStream.startedAt || now : now,
5478
- stoppedAt: '',
5479
- stopReason: '',
5480
- lastFrameAt: '',
6030
+ monitorIndex,
6031
+ monitorCount: 1,
6032
+ startedAt: now,
6033
+ restartToken: safeString(options.restartToken, 128),
6034
+ stoppedAt: '',
6035
+ stopReason: '',
6036
+ lastFrameAt: '',
5481
6037
  lastFrameSeq: 0,
5482
6038
  framesReceived: 0,
5483
6039
  streamPurpose,
5484
- captureGeneration
5485
- });
6040
+ captureGeneration,
6041
+ latestFrame: null,
6042
+ width: 0,
6043
+ height: 0,
6044
+ sourceWidth: 0,
6045
+ sourceHeight: 0
6046
+ };
6047
+ if (replacingActiveStream) {
6048
+ setPendingLiveStreamDescriptor(activeLiveStream, nextDescriptor);
6049
+ scheduleLiveStreamReplacementTimeout(device, activeLiveStream);
6050
+ setDeviceLiveStream(device, activeLiveStream);
6051
+ } else {
6052
+ const nextStreamState = { ...nextDescriptor };
6053
+ setPendingLiveStreamDescriptor(nextStreamState, null);
6054
+ setDeviceLiveStream(device, nextStreamState);
6055
+ }
5486
6056
  device.counters.liveStreamsStarted += 1;
5487
6057
  emitRemoteEvent('RemoteLiveStreamStarted', device, {
5488
6058
  streamId,
@@ -5495,7 +6065,19 @@ export function createRemoteHub(options = {}) {
5495
6065
  monitorIndex
5496
6066
  });
5497
6067
 
5498
- return { ok: true, commandId, streamId, fps, mode: transfer.mode, frameMode: transfer.frameMode, monitorIndex, captureGeneration };
6068
+ return {
6069
+ ok: true,
6070
+ commandId,
6071
+ sessionId: device.sessionId,
6072
+ streamId,
6073
+ streamPurpose,
6074
+ fps,
6075
+ mode: transfer.mode,
6076
+ frameMode: transfer.frameMode,
6077
+ monitorIndex,
6078
+ captureGeneration,
6079
+ ready: false
6080
+ };
5499
6081
  }
5500
6082
 
5501
6083
  function stopLiveStream(deviceId, options = {}) {
@@ -5505,24 +6087,25 @@ export function createRemoteHub(options = {}) {
5505
6087
  const purposeStreamId = streamPurpose ? makeStableLiveStreamId(deviceId, streamPurpose) : '';
5506
6088
  const streamId = requestedStreamId || purposeStreamId || device?.activeLiveStream?.streamId || '';
5507
6089
  const streamState = getDeviceLiveStream(device, streamId);
5508
- if (streamState && streamState.active !== true) {
5509
- return {
6090
+ if (streamState && streamState.active !== true) {
6091
+ clearPendingLiveStreamDescriptor(device, streamState);
6092
+ return {
5510
6093
  ok: true,
5511
6094
  commandId: safeString(options.commandId, 128),
5512
6095
  streamId,
5513
- alreadyStopped: true
5514
- };
5515
- }
5516
- if (device?.synthetic === true && device.connected) {
5517
- const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
5518
- device.counters.commandsSent += 1;
5519
- if (streamState) {
5520
- streamState.active = false;
5521
- streamState.pendingCommandId = '';
5522
- streamState.pendingStartedAt = '';
5523
- streamState.pendingRestartToken = '';
5524
- streamState.stoppedAt = new Date().toISOString();
5525
- streamState.stopReason = 'manager-request';
6096
+ alreadyStopped: true
6097
+ };
6098
+ }
6099
+ if (streamState) {
6100
+ clearPendingLiveStreamDescriptor(device, streamState);
6101
+ }
6102
+ if (device?.synthetic === true && device.connected) {
6103
+ const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
6104
+ device.counters.commandsSent += 1;
6105
+ if (streamState) {
6106
+ streamState.active = false;
6107
+ streamState.stoppedAt = new Date().toISOString();
6108
+ streamState.stopReason = 'manager-request';
5526
6109
  }
5527
6110
  device.counters.liveStreamsStopped += 1;
5528
6111
  emitRemoteEvent('RemoteLiveStreamStopped', device, {
@@ -5550,14 +6133,11 @@ export function createRemoteHub(options = {}) {
5550
6133
  if (!result.ok) {
5551
6134
  return result;
5552
6135
  }
5553
-
5554
- if (streamState) {
5555
- streamState.active = false;
5556
- streamState.pendingCommandId = '';
5557
- streamState.pendingStartedAt = '';
5558
- streamState.pendingRestartToken = '';
5559
- streamState.stoppedAt = new Date().toISOString();
5560
- streamState.stopReason = 'manager-request';
6136
+
6137
+ if (streamState) {
6138
+ streamState.active = false;
6139
+ streamState.stoppedAt = new Date().toISOString();
6140
+ streamState.stopReason = 'manager-request';
5561
6141
  }
5562
6142
  device.counters.liveStreamsStopped += 1;
5563
6143
  emitRemoteEvent('RemoteLiveStreamStopped', device, {