livedesk 0.1.367 → 0.1.369

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;
@@ -5139,59 +5646,6 @@ export function createRemoteHub(options = {}) {
5139
5646
  safeString(stream?.streamPurpose, 24).toLowerCase() === 'control') || null;
5140
5647
  }
5141
5648
 
5142
- function stopCompetingMacCaptureStreams(deviceId, device, nextStreamId, nextPurpose) {
5143
- if (normalizeLivePlatform(device?.platform) !== 'macos') {
5144
- return { ok: true, stopped: [] };
5145
- }
5146
-
5147
- const activeControl = getActiveControlStream(device);
5148
- if (nextPurpose !== 'control'
5149
- && activeControl
5150
- && activeControl.streamId !== nextStreamId) {
5151
- return {
5152
- ok: true,
5153
- suppressed: true,
5154
- reason: 'CONTROL_CAPTURE_OWNS_DEVICE',
5155
- ownerStreamId: activeControl.streamId,
5156
- ownerCommandId: activeControl.commandId,
5157
- captureGeneration: Number(activeControl.captureGeneration || 0)
5158
- };
5159
- }
5160
-
5161
- const stopped = [];
5162
- for (const stream of getActiveLiveStreams(device)) {
5163
- if (!stream?.streamId || stream.streamId === nextStreamId) {
5164
- continue;
5165
- }
5166
- const stopCommandId = crypto.randomUUID();
5167
- const result = sendCommand(deviceId, {
5168
- command: 'stream.stop',
5169
- commandId: stopCommandId,
5170
- payload: {
5171
- streamId: stream.streamId,
5172
- requestedAt: new Date().toISOString(),
5173
- reason: `mac-capture-owner-transition:${nextPurpose}`
5174
- }
5175
- });
5176
- if (!result.ok) {
5177
- return {
5178
- ok: false,
5179
- error: 'CAPTURE_TRANSITION_IN_PROGRESS',
5180
- streamId: stream.streamId,
5181
- detail: result.error || 'stream-stop-dispatch-failed'
5182
- };
5183
- }
5184
- stream.active = false;
5185
- stream.pendingCommandId = '';
5186
- stream.pendingStartedAt = '';
5187
- stream.pendingRestartToken = '';
5188
- stream.stoppedAt = new Date().toISOString();
5189
- stream.stopReason = `mac-capture-owner-transition:${nextPurpose}`;
5190
- stopped.push({ streamId: stream.streamId, commandId: stopCommandId });
5191
- }
5192
- return { ok: true, stopped };
5193
- }
5194
-
5195
5649
  function nextCaptureGeneration(device) {
5196
5650
  const current = Number(device?.captureGenerationCounter || 0);
5197
5651
  const next = Number.isSafeInteger(current) && current > 0 ? current + 1 : 1;
@@ -5208,18 +5662,17 @@ export function createRemoteHub(options = {}) {
5208
5662
  }
5209
5663
 
5210
5664
  function normalizeLiveStreamStartOptions(options = {}) {
5211
- const transfer = buildRemoteFrameTransferDescriptor(options.mode || options.frameMode || DEFAULT_REMOTE_FRAME_MODE);
5212
- const streamPurpose = safeString(options.streamPurpose || options.purpose || 'wall', 24) || 'wall';
5213
- const platform = normalizeLivePlatform(options.platform);
5214
- const macWallProfile = platform === 'macos' && streamPurpose !== 'control';
5215
- const maxFps = streamPurpose === 'control' ? 60 : macWallProfile ? 8 : 30;
5216
- const defaultFps = macWallProfile && options.fps === undefined ? 4 : 8;
5217
- const maxWidth = clampNumber(options.maxWidth, 320, macWallProfile ? 432 : 3840, macWallProfile ? 432 : 640);
5218
- const maxHeight = clampNumber(options.maxHeight, 180, macWallProfile ? 243 : 2160, macWallProfile ? 243 : 360);
5219
- const fps = clampNumber(options.fps, 1, maxFps, defaultFps);
5220
- const quality = clampNumber(options.quality, 20, 95, 45);
5221
- const monitorIndex = normalizeMonitorIndex(options.monitorIndex ?? options.screenIndex ?? options.displayIndex);
5222
- return { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose, platform, macWallProfile };
5665
+ const transfer = buildRemoteFrameTransferDescriptor(options.mode || options.frameMode || DEFAULT_REMOTE_FRAME_MODE);
5666
+ const streamPurpose = safeString(options.streamPurpose || options.purpose || 'wall', 24) || 'wall';
5667
+ const platform = normalizeLivePlatform(options.platform);
5668
+ const maxFps = streamPurpose === 'control' ? 60 : 30;
5669
+ const defaultFps = 8;
5670
+ const maxWidth = clampNumber(options.maxWidth, 320, 3840, 640);
5671
+ const maxHeight = clampNumber(options.maxHeight, 180, 2160, 360);
5672
+ const fps = clampNumber(options.fps, 1, maxFps, defaultFps);
5673
+ const quality = clampNumber(options.quality, 20, 95, 45);
5674
+ const monitorIndex = normalizeMonitorIndex(options.monitorIndex ?? options.screenIndex ?? options.displayIndex);
5675
+ return { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose, platform };
5223
5676
  }
5224
5677
 
5225
5678
  function liveStreamStartStillPending(activeLiveStream) {
@@ -5230,13 +5683,35 @@ export function createRemoteHub(options = {}) {
5230
5683
  return Number.isFinite(startedAt) && Date.now() - startedAt < LIVE_STREAM_PENDING_REUSE_MS;
5231
5684
  }
5232
5685
 
5233
- function liveStreamReplacementStillPending(activeLiveStream) {
5234
- if (!activeLiveStream?.active || !safeString(activeLiveStream.pendingCommandId, 128)) {
5235
- return false;
5236
- }
5237
- const startedAt = Date.parse(activeLiveStream.pendingStartedAt || '');
5238
- return Number.isFinite(startedAt) && Date.now() - startedAt < LIVE_STREAM_REPLACEMENT_PENDING_MS;
5239
- }
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
+ }
5240
5715
 
5241
5716
  function getLiveStreamFreshWindowMs(activeLiveStream) {
5242
5717
  const fps = Number(activeLiveStream?.fps || 0);
@@ -5296,17 +5771,20 @@ export function createRemoteHub(options = {}) {
5296
5771
  const activeLiveStream = getDeviceLiveStream(device, streamId);
5297
5772
  const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
5298
5773
  if (activeLiveStream
5299
- && liveStreamMatchesOptions(activeLiveStream, normalized)
5300
- && (activeLiveStream.open === true || liveStreamStartStillPending(activeLiveStream))) {
5301
- return {
5302
- ok: true,
5303
- commandId: activeLiveStream.commandId,
5304
- streamId,
5305
- 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,
5306
5783
  mode: transfer.mode,
5307
- frameMode: transfer.frameMode,
5784
+ frameMode: transfer.frameMode,
5308
5785
  monitorIndex,
5309
5786
  captureGeneration: Number(activeLiveStream.captureGeneration || 0),
5787
+ ready: liveStreamHasCurrentFrame(activeLiveStream),
5310
5788
  synthetic: true,
5311
5789
  reused: true
5312
5790
  };
@@ -5359,7 +5837,20 @@ export function createRemoteHub(options = {}) {
5359
5837
  mode: transfer.mode,
5360
5838
  synthetic: true
5361
5839
  });
5362
- 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
+ };
5363
5854
  }
5364
5855
 
5365
5856
  if (!device?.socket || device.socket.destroyed || !device.connected) {
@@ -5374,70 +5865,57 @@ export function createRemoteHub(options = {}) {
5374
5865
  const { fps, maxWidth, maxHeight, quality, monitorIndex, transfer, streamPurpose } = normalized;
5375
5866
  const streamId = safeString(options.streamId, 128) || makeStableLiveStreamId(deviceId, streamPurpose);
5376
5867
  const activeLiveStream = getDeviceLiveStream(device, streamId);
5377
- const macTransition = stopCompetingMacCaptureStreams(deviceId, device, streamId, streamPurpose);
5378
- if (!macTransition.ok) {
5379
- return macTransition;
5380
- }
5381
- if (macTransition.suppressed) {
5382
- emitRemoteEvent('RemoteLiveStreamStartSuppressed', device, {
5383
- streamId,
5384
- streamPurpose,
5385
- reason: macTransition.reason,
5386
- ownerStreamId: macTransition.ownerStreamId,
5387
- ownerCommandId: macTransition.ownerCommandId,
5388
- captureGeneration: macTransition.captureGeneration
5389
- });
5390
- return {
5391
- ok: true,
5392
- suppressed: true,
5393
- streamId,
5394
- streamPurpose,
5395
- reason: macTransition.reason,
5396
- ownerStreamId: macTransition.ownerStreamId,
5397
- ownerCommandId: macTransition.ownerCommandId,
5398
- captureGeneration: macTransition.captureGeneration
5399
- };
5400
- }
5401
- if (activeLiveStream?.pendingCommandId) {
5402
- if (liveStreamReplacementStillPending(activeLiveStream)) {
5403
- emitRemoteEvent('RemoteLiveStreamRestartPending', device, {
5404
- streamId,
5405
- commandId: activeLiveStream.pendingCommandId,
5406
- activeCommandId: activeLiveStream.commandId,
5407
- reason: safeString(options.restartReason || 'duplicate-restart', 128)
5408
- });
5409
- return {
5410
- ok: true,
5411
- commandId: activeLiveStream.pendingCommandId,
5412
- streamId,
5413
- fps: Number(activeLiveStream.fps || fps),
5414
- mode: activeLiveStream.mode || transfer.mode,
5415
- frameMode: activeLiveStream.frameMode || transfer.frameMode,
5416
- monitorIndex: Number(activeLiveStream.monitorIndex || monitorIndex),
5417
- 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,
5418
5890
  pending: true,
5419
5891
  reused: true
5420
5892
  };
5421
- }
5422
- activeLiveStream.pendingCommandId = '';
5423
- activeLiveStream.pendingStartedAt = '';
5424
- activeLiveStream.pendingRestartToken = '';
5425
- }
5893
+ }
5894
+ failPendingLiveStream(
5895
+ device,
5896
+ pendingDescriptor.commandId,
5897
+ liveStreamReplacementStillPending(activeLiveStream)
5898
+ ? 'stream-restart-superseded'
5899
+ : 'stream-start-timeout');
5900
+ }
5426
5901
  const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
5427
5902
  if (activeLiveStream
5428
5903
  && options.forceRestart !== true
5429
5904
  && options.reuseExisting === true
5430
5905
  && liveStreamMatchesOptions(activeLiveStream, normalized)
5431
5906
  && liveStreamIsReusable(activeLiveStream)) {
5432
- return {
5433
- ok: true,
5434
- commandId: activeLiveStream.commandId,
5435
- streamId,
5907
+ return {
5908
+ ok: true,
5909
+ commandId: activeLiveStream.commandId,
5910
+ sessionId: device.sessionId,
5911
+ streamId,
5912
+ streamPurpose,
5436
5913
  fps: Number(activeLiveStream.fps || fps),
5437
5914
  mode: activeLiveStream.mode || transfer.mode,
5438
5915
  frameMode: activeLiveStream.frameMode || transfer.frameMode,
5439
5916
  monitorIndex: Number(activeLiveStream.monitorIndex || monitorIndex),
5440
5917
  captureGeneration: Number(activeLiveStream.captureGeneration || 0),
5918
+ ready: liveStreamHasCurrentFrame(activeLiveStream),
5441
5919
  reused: true
5442
5920
  };
5443
5921
  }
@@ -5454,15 +5932,18 @@ export function createRemoteHub(options = {}) {
5454
5932
  monitorIndex
5455
5933
  });
5456
5934
  }
5457
- return {
5458
- ok: true,
5459
- commandId: activeLiveStream.commandId,
5460
- streamId,
5935
+ return {
5936
+ ok: true,
5937
+ commandId: activeLiveStream.commandId,
5938
+ sessionId: device.sessionId,
5939
+ streamId,
5940
+ streamPurpose,
5461
5941
  fps,
5462
5942
  mode: transfer.mode,
5463
5943
  frameMode: transfer.frameMode,
5464
5944
  monitorIndex,
5465
5945
  captureGeneration: Number(activeLiveStream.captureGeneration || 0),
5946
+ ready: liveStreamHasCurrentFrame(activeLiveStream),
5466
5947
  reused: true
5467
5948
  };
5468
5949
  }
@@ -5522,24 +6003,20 @@ export function createRemoteHub(options = {}) {
5522
6003
  return result;
5523
6004
  }
5524
6005
 
5525
- const previousCommandId = safeString(activeLiveStream?.commandId, 128);
5526
- const replacingActiveStream = activeLiveStream?.active === true
5527
- && !!previousCommandId
5528
- && previousCommandId !== commandId;
5529
- setDeviceLiveStream(device, {
5530
- ...(activeLiveStream || {}),
5531
- streamId,
5532
- commandId: replacingActiveStream ? previousCommandId : commandId,
5533
- pendingCommandId: replacingActiveStream ? commandId : '',
5534
- pendingStartedAt: replacingActiveStream ? now : '',
5535
- pendingRestartToken: replacingActiveStream ? safeString(options.restartToken, 128) : '',
5536
- active: true,
5537
- open: replacingActiveStream ? activeLiveStream.open === true : false,
5538
- openedAt: replacingActiveStream ? activeLiveStream.openedAt || '' : '',
5539
- openTransport: replacingActiveStream ? activeLiveStream.openTransport || '' : '',
5540
- mode: transfer.mode,
5541
- frameMode: transfer.frameMode,
5542
- 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,
5543
6020
  encoding: transfer.encoding,
5544
6021
  codec: transfer.codec,
5545
6022
  compression: transfer.compression,
@@ -5550,17 +6027,32 @@ export function createRemoteHub(options = {}) {
5550
6027
  maxWidth,
5551
6028
  maxHeight,
5552
6029
  quality,
5553
- monitorIndex,
5554
- monitorCount: 1,
5555
- startedAt: replacingActiveStream ? activeLiveStream.startedAt || now : now,
5556
- stoppedAt: '',
5557
- stopReason: '',
5558
- lastFrameAt: '',
6030
+ monitorIndex,
6031
+ monitorCount: 1,
6032
+ startedAt: now,
6033
+ restartToken: safeString(options.restartToken, 128),
6034
+ stoppedAt: '',
6035
+ stopReason: '',
6036
+ lastFrameAt: '',
5559
6037
  lastFrameSeq: 0,
5560
6038
  framesReceived: 0,
5561
6039
  streamPurpose,
5562
- captureGeneration
5563
- });
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
+ }
5564
6056
  device.counters.liveStreamsStarted += 1;
5565
6057
  emitRemoteEvent('RemoteLiveStreamStarted', device, {
5566
6058
  streamId,
@@ -5573,7 +6065,19 @@ export function createRemoteHub(options = {}) {
5573
6065
  monitorIndex
5574
6066
  });
5575
6067
 
5576
- 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
+ };
5577
6081
  }
5578
6082
 
5579
6083
  function stopLiveStream(deviceId, options = {}) {
@@ -5583,24 +6087,25 @@ export function createRemoteHub(options = {}) {
5583
6087
  const purposeStreamId = streamPurpose ? makeStableLiveStreamId(deviceId, streamPurpose) : '';
5584
6088
  const streamId = requestedStreamId || purposeStreamId || device?.activeLiveStream?.streamId || '';
5585
6089
  const streamState = getDeviceLiveStream(device, streamId);
5586
- if (streamState && streamState.active !== true) {
5587
- return {
6090
+ if (streamState && streamState.active !== true) {
6091
+ clearPendingLiveStreamDescriptor(device, streamState);
6092
+ return {
5588
6093
  ok: true,
5589
6094
  commandId: safeString(options.commandId, 128),
5590
6095
  streamId,
5591
- alreadyStopped: true
5592
- };
5593
- }
5594
- if (device?.synthetic === true && device.connected) {
5595
- const commandId = safeString(options.commandId, 128) || crypto.randomUUID();
5596
- device.counters.commandsSent += 1;
5597
- if (streamState) {
5598
- streamState.active = false;
5599
- streamState.pendingCommandId = '';
5600
- streamState.pendingStartedAt = '';
5601
- streamState.pendingRestartToken = '';
5602
- streamState.stoppedAt = new Date().toISOString();
5603
- 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';
5604
6109
  }
5605
6110
  device.counters.liveStreamsStopped += 1;
5606
6111
  emitRemoteEvent('RemoteLiveStreamStopped', device, {
@@ -5628,14 +6133,11 @@ export function createRemoteHub(options = {}) {
5628
6133
  if (!result.ok) {
5629
6134
  return result;
5630
6135
  }
5631
-
5632
- if (streamState) {
5633
- streamState.active = false;
5634
- streamState.pendingCommandId = '';
5635
- streamState.pendingStartedAt = '';
5636
- streamState.pendingRestartToken = '';
5637
- streamState.stoppedAt = new Date().toISOString();
5638
- streamState.stopReason = 'manager-request';
6136
+
6137
+ if (streamState) {
6138
+ streamState.active = false;
6139
+ streamState.stoppedAt = new Date().toISOString();
6140
+ streamState.stopReason = 'manager-request';
5639
6141
  }
5640
6142
  device.counters.liveStreamsStopped += 1;
5641
6143
  emitRemoteEvent('RemoteLiveStreamStopped', device, {