@desklink/react-native 0.5.3 → 0.5.6

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.
@@ -10,6 +10,7 @@ import type {
10
10
  SessionFailure,
11
11
  SessionOpenRequest,
12
12
  SessionOpenResult,
13
+ SessionEvent,
13
14
  SessionSnapshot,
14
15
  Signaling,
15
16
  SurfaceGeometry,
@@ -55,8 +56,41 @@ const STALL_AFTER_MS = 1200;
55
56
  * trigger another restart while the window lasts.
56
57
  */
57
58
  const RESTART_GRACE_MS = 2500;
59
+ /**
60
+ * How long a moving picture outlasts silent heartbeats, from the later of the
61
+ * last heartbeat and the picture's return from a freeze: after an outage the
62
+ * data channel lags the video, as SCTP's retransmissions back off (to 15 s,
63
+ * then 31 s, after the first loss).
64
+ */
65
+ const QUIET_PICTURE_MS = 20_000;
58
66
  /** How long a restart offer may go unanswered before the next attempt. */
59
67
  const RESTART_REPLY_MS = 5000;
68
+ /** A restart request unanswered this long means its carrier is dead, even if it never closed. */
69
+ const RESTART_REQUEST_DEADLINE_MS = 1500;
70
+ /** A dial that failed is retried this soon: the path may be back any moment. */
71
+ const DIAL_RETRY_MS = 250;
72
+ /** One dial's bound. Not shorter: Chromium throttles refused WebSocket handshakes. */
73
+ const DIAL_TIMEOUT_MS = 5000;
74
+ /** How long the host holds a session with no carrier (the bridge's `reattachMs`); past it, reopen. */
75
+ const REATTACH_WINDOW_MS = 30_000;
76
+ /**
77
+ * How long a reattached carrier waits for the old ICE pair before asking for
78
+ * a restart — the wait for a brief blip: on an unchanged path the old pair
79
+ * resumes in ~100 ms and only the carrier was missing, so the wait keeps the
80
+ * restart unspent while the picture comes back on its own.
81
+ */
82
+ const REATTACH_GRACE_MS = 200;
83
+ /**
84
+ * A picture still this long is a real outage, not a blip: the old pair is
85
+ * not resuming, so the wait that protects a blip would only cost the recovery
86
+ * its 2 s bar. The line separates the two measured regimes — blips of about
87
+ * a second heal on the old pair in ~100 ms; by 12 s the pair is gone. The
88
+ * picture's own last motion is the clock: a re-dialled carrier's age resets
89
+ * with every refused attempt, so it cannot tell the two apart.
90
+ */
91
+ const REATTACH_GRACE_BLIP_MAX_MS = 4000;
92
+ /** A real outage's wait: one stats beat — enough to see a picture already moving. */
93
+ const REATTACH_GRACE_SETTLE_MS = 50;
60
94
  /**
61
95
  * How long the media path may carry nothing at all before a session that says
62
96
  * `live` stops saying so. The engine's transport state reaches the client over
@@ -98,6 +132,16 @@ export const REOPEN_BACKOFF_MS = [1000, 2000, 4000, 8000, 8000, 8000, 8000, 8000
98
132
  * A return after that reopens at once instead of spending restarts on it.
99
133
  */
100
134
  export const BACKGROUND_REOPEN_MS = 30_000;
135
+ /**
136
+ * A peer that reaches ICE `failed` has lost its media path, not its session:
137
+ * the engine keeps the session alive for about 28 s after the path dies (its
138
+ * ICE `Failed` timer). So a client-side ICE failure holds the session — and
139
+ * its native handle — this long from the failure, whatever the carrier is
140
+ * doing, instead of throwing it away at once. A roam longer than the client's
141
+ * own ~15 s ICE `failed` then still returns to the same session. An SDP or
142
+ * candidate failure is not a lost path and stays terminal as it always was.
143
+ */
144
+ export const HOLD_AFTER_FAILED_MS = 30_000;
101
145
  /**
102
146
  * A resumed app hears what queued up while it was suspended just after it
103
147
  * hears it is active again. A revocation among it decides whether a reopen is
@@ -105,6 +149,19 @@ export const BACKGROUND_REOPEN_MS = 30_000;
105
149
  */
106
150
  const RESUME_DRAIN_MS = 750;
107
151
 
152
+ const DEADLINE = new Error('no reply in time');
153
+
154
+ /** `promise`, or a DEADLINE rejection after `ms`; a value that lands later goes to `late`. */
155
+ function withDeadline<T>(promise: Promise<T>, ms: number, late?: (value: T) => void): Promise<T> {
156
+ let expired = false;
157
+ return Promise.race([
158
+ promise.then((value) => { if (expired) late?.(value); return value; }),
159
+ new Promise<never>((_, reject) => setTimeout(() => { expired = true; reject(DEADLINE); }, ms)),
160
+ ]);
161
+ }
162
+
163
+ const errorCode = (error: unknown): unknown => (error as { code?: unknown } | null)?.code;
164
+
108
165
  /** The delay before attempt `n` (0-based), or null when the schedule is spent. */
109
166
  export function backoffDelay(schedule: readonly number[], attempt: number): number | null {
110
167
  return attempt < schedule.length ? schedule[attempt]! : null;
@@ -336,6 +393,8 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
336
393
  const mediaMovingAt = useRef<number | null>(null);
337
394
  /** Whether the picture of an open session has stopped advancing. */
338
395
  const frozen = useRef(false);
396
+ /** When a frozen picture last moved again, or 0 while it has not. */
397
+ const pictureBackAt = useRef(0);
339
398
  /** Renegotiation transients are not a new outage before this time. */
340
399
  const restartGraceUntil = useRef(0);
341
400
  /** ICE-restart attempts spent on the current drop. */
@@ -348,6 +407,12 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
348
407
  const restartCycles = useRef(0);
349
408
  /** The last ICE state the peer or the engine reported. */
350
409
  const iceConnected = useRef(false);
410
+ /**
411
+ * When the peer's own ICE last failed while the session was held, or null
412
+ * when the current failure is not an ICE one. Kept across the hold so the
413
+ * reopen effect can measure the remaining window.
414
+ */
415
+ const heldIceFailureAt = useRef<number | null>(null);
351
416
  /** The engine's restore token, for a consent-free reopen. Unset by hand. */
352
417
  const restoreToken = useRef<string | null>(null);
353
418
  /** The latest status and presentation, read by event handlers. */
@@ -360,6 +425,13 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
360
425
  const awaySince = useRef<number | null>(null);
361
426
  /** Guards every asynchronous callback against a session that already ended. */
362
427
  const generationToken = useRef(0);
428
+ /** Bumped per carrier adopted or dropped: an older carrier's events and replies are ignored. */
429
+ const carrierEpoch = useRef(0);
430
+ const unsubscribeCarrier = useRef<(() => void) | null>(null);
431
+ /** While the carrier is dead, whatever the status: the dial in flight and its retry. */
432
+ const dialing = useRef<{ inFlight: boolean; since: number; retry?: ReturnType<typeof setTimeout> } | null>(null);
433
+ /** Set with a reopen that skips its backoff: the host no longer holds the session at all. */
434
+ const reopenAtOnce = useRef(false);
363
435
  const optionsRef = useRef(options);
364
436
  optionsRef.current = options;
365
437
 
@@ -375,21 +447,30 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
375
447
  optionsRef.current.onStateChange?.(next);
376
448
  }, []);
377
449
 
378
- const fail = useCallback((failure: SessionFailure) => {
450
+ const fail = useCallback((failure: SessionFailure, ice = false) => {
451
+ heldIceFailureAt.current = failure.code === 'transport' && ice && nativeRef.current != null ? Date.now() : null;
379
452
  update({ status: 'failed', failure });
380
453
  optionsRef.current.onError?.(failure);
381
454
  }, [update]);
382
455
 
383
- const refuse = useCallback((message: string, code: SessionFailure['code'] = 'platform') => {
384
- fail({ code, message });
456
+ const refuse = useCallback((message: string, code: SessionFailure['code'] = 'platform', ice = false) => {
457
+ fail({ code, message }, ice);
385
458
  }, [fail]);
386
459
 
387
460
  /** A transport loss is one event no matter how many sides report it: the
388
461
  * peer's `failed` state and its `failure` event would otherwise spend the
389
- * reopen budget twice for the same outage. */
390
- const transportFailed = useCallback((message: string = UNREACHABLE_DESKTOP) => {
391
- if (statusRef.current === 'failed') return;
392
- refuse(message, 'transport');
462
+ * reopen budget twice for the same outage. While dialing, it stays
463
+ * `reconnecting`.
464
+ *
465
+ * `ice` marks a failure of the peer's own media path (its ICE `failed`
466
+ * state, or a `failure` event whose reason is `ice`). While the session is
467
+ * held and has not been revoked, that is not the end of the session: the
468
+ * reopen effect keeps it for `HOLD_AFTER_FAILED_MS` first. */
469
+ const transportFailed = useCallback((message: string = UNREACHABLE_DESKTOP, ice = false): boolean => {
470
+ if (dialing.current !== null) return false;
471
+ if (ice && statusRef.current === 'failed') return false;
472
+ refuse(message, 'transport', ice);
473
+ return true;
393
474
  }, [refuse]);
394
475
 
395
476
  const cancelRestart = useCallback(() => {
@@ -412,20 +493,69 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
412
493
  firstFrameTimer.current = null;
413
494
  }, []);
414
495
 
496
+ const stopDialing = useCallback(() => { clearTimeout(dialing.current?.retry); dialing.current = null; }, []);
497
+
415
498
  /**
416
499
  * A first connection that stays frameless for this long is not going to
417
500
  * arrive: report why instead of leaving an empty picture on screen.
418
501
  */
419
502
  const armFirstFrame = useCallback(() => {
420
503
  cancelFirstFrame();
421
- firstFrameTimer.current = setTimeout(() => {
422
- firstFrameTimer.current = null;
423
- if (presentedRef.current || statusRef.current !== 'connecting') return;
424
- transportFailed(NO_PICTURE);
425
- }, FIRST_FRAME_AFTER_MS);
426
- }, [cancelFirstFrame, transportFailed]);
504
+ const watch: () => void = () => {
505
+ firstFrameTimer.current = setTimeout(() => {
506
+ firstFrameTimer.current = null;
507
+ const status = statusRef.current;
508
+ if (!presentedRef.current && status === 'failed' && heldIceFailureAt.current !== null) {
509
+ watch();
510
+ return;
511
+ }
512
+ if (presentedRef.current || (status !== 'connecting' && status !== 'reconnecting')) return;
513
+ stopDialing();
514
+ transportFailed(NO_PICTURE);
515
+ }, FIRST_FRAME_AFTER_MS);
516
+ };
517
+ watch();
518
+ }, [cancelFirstFrame, stopDialing, transportFailed]);
427
519
 
428
520
  const scheduleRestartRef = useRef<(delay: number) => void>(() => undefined);
521
+ const dialRef = useRef<() => void>(() => undefined);
522
+ const carrierEventRef = useRef<(event: SessionEvent) => void>(() => undefined);
523
+
524
+ /** Stop listening to the carrier; a dead one is also closed, so nothing more is sent on it. */
525
+ const dropCarrier = useCallback((dead: boolean) => {
526
+ carrierEpoch.current += 1;
527
+ unsubscribeCarrier.current?.();
528
+ unsubscribeCarrier.current = null;
529
+ const channel = signaling.current;
530
+ signaling.current = null;
531
+ if (dead) channel?.close?.();
532
+ }, []);
533
+
534
+ const adoptCarrier = useCallback((channel: Signaling) => {
535
+ dropCarrier(false);
536
+ const epoch = carrierEpoch.current;
537
+ signaling.current = channel;
538
+ unsubscribeCarrier.current = channel.subscribe((event) => {
539
+ if (epoch === carrierEpoch.current) carrierEventRef.current(event);
540
+ });
541
+ }, [dropCarrier]);
542
+
543
+ /** Carrier `epoch` is dead: drop it hard and, while a session is held, dial a new one. */
544
+ const carrierLost = useCallback((epoch: number) => {
545
+ if (epoch !== carrierEpoch.current || signaling.current == null) return;
546
+ dropCarrier(true);
547
+ if (dialing.current !== null || opened.current == null) return;
548
+ dialing.current = { inFlight: false, since: Date.now() };
549
+ dialRef.current();
550
+ }, [dropCarrier]);
551
+
552
+ /** Whether a request on carrier `epoch` failed because that carrier is gone; the dial loop then owns it. */
553
+ const carrierFailed = useCallback((epoch: number, error: unknown): boolean => {
554
+ if (epoch !== carrierEpoch.current) return true;
555
+ if (error !== DEADLINE && errorCode(error) !== 'transport') return false;
556
+ carrierLost(epoch);
557
+ return true;
558
+ }, [carrierLost]);
429
559
 
430
560
  /**
431
561
  * Spend one restart from the backoff schedule, or reopen when the schedule
@@ -465,20 +595,33 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
465
595
  if (statusRef.current !== 'reconnecting') return;
466
596
  const openedRef = opened.current;
467
597
  const channel = signaling.current;
468
- if (openedRef == null || channel == null) {
598
+ if (openedRef == null) {
469
599
  spendRestartAttempt();
470
600
  return;
471
601
  }
602
+ // No carrier: the dial loop restarts once it has a new one.
603
+ if (channel == null) return;
604
+ const epoch = carrierEpoch.current;
605
+ const token = generationToken.current;
472
606
  void (async () => {
473
607
  try {
474
- await channel.request('session.restart_ice', {
608
+ await withDeadline(channel.request('session.restart_ice', {
475
609
  session_id: openedRef.sessionId,
476
610
  generation: openedRef.generation,
477
- });
478
- } catch {
611
+ }), RESTART_REQUEST_DEADLINE_MS);
612
+ } catch (error) {
613
+ if (token !== generationToken.current || carrierFailed(epoch, error)) return;
614
+ // The host no longer holds this session: reopen without waiting out a backoff.
615
+ if (errorCode(error) === 'session' || errorCode(error) === 'generation') {
616
+ attempts.current = 0;
617
+ reopenAtOnce.current = transportFailed('the desktop no longer holds this session; reopening it');
618
+ return;
619
+ }
479
620
  spendRestartAttempt();
480
621
  return;
481
622
  }
623
+ // A late reply from a replaced carrier or session says nothing about this one.
624
+ if (epoch !== carrierEpoch.current || token !== generationToken.current) return;
482
625
  if (statusRef.current !== 'reconnecting') return;
483
626
  restartGraceUntil.current = Date.now() + RESTART_GRACE_MS;
484
627
  restartCycles.current += 1;
@@ -501,10 +644,12 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
501
644
  }, replyMs);
502
645
  })();
503
646
  }, delay);
504
- }, [spendRestartAttempt, transportFailed]);
647
+ }, [spendRestartAttempt, transportFailed, carrierFailed]);
505
648
 
506
649
  useEffect(() => {
507
650
  scheduleRestartRef.current = scheduleRestart;
651
+ dialRef.current = dial;
652
+ carrierEventRef.current = onCarrierEvent;
508
653
  checkStallRef.current = checkStall;
509
654
  mediaWatchRef.current = checkMedia;
510
655
  });
@@ -526,6 +671,11 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
526
671
  const onTransportState = useCallback((state: string, trackIce = true, skipGrace = false) => {
527
672
  // Synthetic stall reports say nothing about ICE itself.
528
673
  if (trackIce) iceConnected.current = state === 'connected' || state === 'completed';
674
+ if (heldIceFailureAt.current !== null && statusRef.current === 'failed'
675
+ && (state === 'connected' || state === 'completed')) {
676
+ heldIceFailureAt.current = null;
677
+ update({ status: 'reconnecting' });
678
+ }
529
679
  const mapped = connectionStatusFor(state, statusRef.current, presentedRef.current && !frozen.current);
530
680
  if (mapped === null) return;
531
681
  // A transport that answers while the picture is frozen is not a
@@ -596,9 +746,10 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
596
746
  mediaMovingAt.current = Date.now();
597
747
  if (!frozen.current) return;
598
748
  frozen.current = false;
749
+ pictureBackAt.current = Date.now();
599
750
  // The picture advances again, so the transport can be believed
600
751
  // for this session: live returns without waiting for it to say so.
601
- if (statusRef.current === 'reconnecting') onTransportState('connected', false);
752
+ if (statusRef.current === 'reconnecting' || (statusRef.current === 'failed' && heldIceFailureAt.current !== null)) onTransportState('connected', false);
602
753
  return;
603
754
  }
604
755
  if (mediaMovingAt.current === null) return;
@@ -612,6 +763,8 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
612
763
  }, () => undefined);
613
764
  }, [armMedia, onTransportState]);
614
765
 
766
+ const pictureMoving = useCallback((within: number): boolean => mediaMovingAt.current !== null && Date.now() - mediaMovingAt.current < within, []);
767
+
615
768
  /**
616
769
  * One stall check: quiet too long while the session should be talking
617
770
  * means the path is stalled, even when ICE has not said so yet. Only
@@ -634,8 +787,19 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
634
787
  stallTimer.current = setTimeout(() => checkStallRef.current(), restartGraceUntil.current - Date.now());
635
788
  return;
636
789
  }
790
+ // The outage's own silence is not the data channel lagging the
791
+ // picture: that lag starts when the picture comes back. Until the
792
+ // heartbeat catches up, the picture is held to the freeze bar: a
793
+ // still desktop's keepalive frames, a second apart, read up to
794
+ // 1.5 s apart through the stats poll.
795
+ const lagging = pictureBackAt.current > lastPingAt.current;
796
+ const lagMs = Date.now() - Math.max(lastPingAt.current, pictureBackAt.current);
797
+ if (pictureMoving(lagging ? FROZEN_AFTER_MS : STALL_AFTER_MS) && !frozen.current && lagMs < QUIET_PICTURE_MS) {
798
+ stallTimer.current = setTimeout(() => checkStallRef.current(), STALL_AFTER_MS);
799
+ return;
800
+ }
637
801
  onTransportState('disconnected', false);
638
- }, [onTransportState]);
802
+ }, [onTransportState, pictureMoving]);
639
803
 
640
804
  /**
641
805
  * One engine heartbeat: the path works. It clears a stall the watchdog
@@ -733,6 +897,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
733
897
  /** Drop everything this process holds for a session the engine has ended. */
734
898
  const discardSession = useCallback(() => {
735
899
  generationToken.current += 1;
900
+ cancelFirstFrame();
736
901
  const id = nativeRef.current;
737
902
  nativeRef.current = null;
738
903
  inputEnabled.current = false;
@@ -741,6 +906,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
741
906
  pendingOffer.current = null;
742
907
  pendingCandidates.current = [];
743
908
  pendingCursorEvents.current = [];
909
+ heldIceFailureAt.current = null;
744
910
  restartGraceUntil.current = 0;
745
911
  restartCycles.current = 0;
746
912
  // A new session has its own media path: the last one's counts and its
@@ -750,14 +916,15 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
750
916
  mediaRef.current = null;
751
917
  mediaMovingAt.current = null;
752
918
  frozen.current = false;
919
+ pictureBackAt.current = 0;
753
920
  opened.current = null;
754
- signaling.current = null;
921
+ dropCarrier(false);
755
922
  setNativeId(null);
756
923
  // An armed modifier belongs to the session it was armed in.
757
924
  modifiersRef.current = NO_MODIFIERS;
758
925
  setModifiers(NO_MODIFIERS);
759
926
  if (id != null) nativeDesklink?.closeSession(id);
760
- }, []);
927
+ }, [cancelFirstFrame, dropCarrier]);
761
928
 
762
929
  /** Tell the host a session is over; a failure is nothing the user can act on. */
763
930
  const endRemote = useCallback(async (
@@ -780,6 +947,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
780
947
  cancelRestart();
781
948
  cancelStall();
782
949
  cancelFirstFrame();
950
+ stopDialing();
783
951
  restoreToken.current = null;
784
952
  const openedRef = opened.current;
785
953
  const owner = signaling.current;
@@ -787,10 +955,98 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
787
955
  if (closeRemote) await endRemote(openedRef, owner);
788
956
  update({ status: 'ended', presented: false, failure: null });
789
957
  void reason;
790
- }, [cancelReconnect, cancelRestart, cancelStall, cancelFirstFrame, endRemote, discardSession, update]);
958
+ }, [cancelReconnect, cancelRestart, cancelStall, cancelFirstFrame, stopDialing, endRemote, discardSession, update]);
959
+
960
+ /**
961
+ * One engine event from the live carrier. Subscribed before the open,
962
+ * because the engine's offer and its first candidates can beat the open
963
+ * result back: anything that arrives before the native session exists is
964
+ * stashed and replayed into it.
965
+ */
966
+ const onCarrierEvent = useCallback((event: SessionEvent) => {
967
+ const native = nativeRef.current;
968
+ const held = opened.current;
969
+ const current = event.sessionId === undefined || held == null || held.sessionId === event.sessionId;
970
+ switch (event.kind) {
971
+ case 'carrier-closed':
972
+ carrierLost(carrierEpoch.current);
973
+ return;
974
+ case 'description':
975
+ if (event.description.type !== 'offer') return;
976
+ if (native == null || held == null) {
977
+ pendingOffer.current = { sdp: event.description.sdp, sessionId: event.sessionId };
978
+ return;
979
+ }
980
+ if (!current) return;
981
+ // A mid-session re-offer is a restart this session asked
982
+ // for, through its policy or directly: renegotiation
983
+ // transients after it are expected, not a new outage.
984
+ if (presentedRef.current) restartGraceUntil.current = Date.now() + RESTART_GRACE_MS;
985
+ nativeDesklink?.setRemoteDescription(native, 'offer', event.description.sdp);
986
+ return;
987
+ case 'candidate':
988
+ if (native == null || held == null) {
989
+ pendingCandidates.current.push({
990
+ candidate: event.candidate.candidate,
991
+ sdpMid: event.candidate.sdpMid ?? null,
992
+ sdpMLineIndex: event.candidate.sdpMLineIndex ?? null,
993
+ sessionId: event.sessionId,
994
+ });
995
+ return;
996
+ }
997
+ if (!current) return;
998
+ nativeDesklink?.addRemoteCandidate(
999
+ native,
1000
+ event.candidate.candidate,
1001
+ event.candidate.sdpMid ?? null,
1002
+ event.candidate.sdpMLineIndex ?? null,
1003
+ );
1004
+ return;
1005
+ case 'cursor':
1006
+ if (held == null) {
1007
+ pendingCursorEvents.current.push(event);
1008
+ return;
1009
+ }
1010
+ if (current) optionsRef.current.onCursor?.(event);
1011
+ return;
1012
+ case 'state':
1013
+ if (!current) return;
1014
+ diagnostics.current.transport = event.transport;
1015
+ // Before the session exists there is nothing to map onto.
1016
+ if (held != null) onTransportState(String(event.transport ?? '').toLowerCase());
1017
+ update({ diagnostics: { ...diagnostics.current } });
1018
+ return;
1019
+ case 'restoreToken':
1020
+ // The live session's grant for a consent-free reopen.
1021
+ if (current && held != null) restoreToken.current = event.token;
1022
+ return;
1023
+ case 'revoked':
1024
+ // Nothing held: the reopen policy owns recovery, and a
1025
+ // dead session's late revocation must not end it.
1026
+ if (held == null || !current) return;
1027
+ // A lost path is an outage, not a verdict: the reopen
1028
+ // policy asks for fresh authority. Any other code, or
1029
+ // none, is the session's authority ending.
1030
+ if (event.code === 'transport') {
1031
+ transportFailed(event.reason);
1032
+ return;
1033
+ }
1034
+ void teardown(event.reason, false);
1035
+ update({ status: 'ended', failure: { code: 'revoked', message: event.reason } });
1036
+ return;
1037
+ default:
1038
+ return;
1039
+ }
1040
+ }, [carrierLost, onTransportState, teardown, transportFailed, update]);
791
1041
 
792
- const establish = useCallback(async () => {
1042
+ /** Open a session on `given`, a carrier the dial loop just authorized, or on a fresh authorization. */
1043
+ const establish = useCallback(async (given?: { signaling: Signaling; session: SessionOpenRequest }) => {
793
1044
  if (nativeRef.current != null) return;
1045
+ // While a dead carrier is replaced, the dial loop opens on the new one.
1046
+ if (given === undefined && dialing.current !== null) {
1047
+ dialRef.current();
1048
+ return;
1049
+ }
794
1050
  const token = ++generationToken.current;
795
1051
  pendingCursorEvents.current = [];
796
1052
  if (!(await loadPlatform())) {
@@ -800,93 +1056,17 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
800
1056
  if (token !== generationToken.current) return;
801
1057
  update({ status: 'opening', failure: null, presented: false });
802
1058
 
803
- let authorization: { signaling: Signaling; session: SessionOpenRequest };
804
- try {
805
- authorization = await optionsRef.current.authorize();
806
- } catch (error) {
807
- const code = (error as { code?: unknown } | null)?.code === 'transport' ? 'transport' : 'permission';
808
- refuse(error instanceof Error ? error.message : 'the desktop was not authorized', code);
809
- return;
810
- }
811
- if (token !== generationToken.current) return;
812
- signaling.current = authorization.signaling;
813
-
814
- // Subscribed before the open, because the engine's offer and its first
815
- // candidates can beat the open result back. Anything that arrives
816
- // before the native session exists is stashed and replayed into it.
817
- authorization.signaling.subscribe((event) => {
818
- if (token !== generationToken.current) return;
819
- const native = nativeRef.current;
820
- const held = opened.current;
821
- const current = event.sessionId === undefined || held == null || held.sessionId === event.sessionId;
822
- switch (event.kind) {
823
- case 'description':
824
- if (event.description.type !== 'offer') return;
825
- if (native == null || held == null) {
826
- pendingOffer.current = { sdp: event.description.sdp, sessionId: event.sessionId };
827
- return;
828
- }
829
- if (!current) return;
830
- // A mid-session re-offer is a restart this session asked
831
- // for, through its policy or directly: renegotiation
832
- // transients after it are expected, not a new outage.
833
- if (presentedRef.current) restartGraceUntil.current = Date.now() + RESTART_GRACE_MS;
834
- nativeDesklink?.setRemoteDescription(native, 'offer', event.description.sdp);
835
- return;
836
- case 'candidate':
837
- if (native == null || held == null) {
838
- pendingCandidates.current.push({
839
- candidate: event.candidate.candidate,
840
- sdpMid: event.candidate.sdpMid ?? null,
841
- sdpMLineIndex: event.candidate.sdpMLineIndex ?? null,
842
- sessionId: event.sessionId,
843
- });
844
- return;
845
- }
846
- if (!current) return;
847
- nativeDesklink?.addRemoteCandidate(
848
- native,
849
- event.candidate.candidate,
850
- event.candidate.sdpMid ?? null,
851
- event.candidate.sdpMLineIndex ?? null,
852
- );
853
- return;
854
- case 'cursor':
855
- if (held == null) {
856
- pendingCursorEvents.current.push(event);
857
- return;
858
- }
859
- if (current) optionsRef.current.onCursor?.(event);
860
- return;
861
- case 'state':
862
- if (!current) return;
863
- diagnostics.current.transport = event.transport;
864
- // Before the session exists there is nothing to map onto.
865
- if (held != null) onTransportState(String(event.transport ?? '').toLowerCase());
866
- update({ diagnostics: { ...diagnostics.current } });
867
- return;
868
- case 'restoreToken':
869
- // The live session's grant for a consent-free reopen.
870
- if (current && held != null) restoreToken.current = event.token;
871
- return;
872
- case 'revoked':
873
- // Nothing held: the reopen policy owns recovery, and a
874
- // dead session's late revocation must not end it.
875
- if (held == null || !current) return;
876
- // A lost path is an outage, not a verdict: the reopen
877
- // policy asks for fresh authority. Any other code, or
878
- // none, is the session's authority ending.
879
- if (event.code === 'transport') {
880
- transportFailed(event.reason);
881
- return;
882
- }
883
- void teardown(event.reason, false);
884
- update({ status: 'ended', failure: { code: 'revoked', message: event.reason } });
885
- return;
886
- default:
887
- return;
1059
+ let authorization = given;
1060
+ if (authorization === undefined) {
1061
+ try {
1062
+ authorization = await optionsRef.current.authorize();
1063
+ } catch (error) {
1064
+ refuse(error instanceof Error ? error.message : 'the desktop was not authorized', errorCode(error) === 'transport' ? 'transport' : 'permission');
1065
+ return;
888
1066
  }
889
- });
1067
+ if (token !== generationToken.current) return;
1068
+ adoptCarrier(authorization.signaling);
1069
+ }
890
1070
 
891
1071
  let openedResult: SessionOpenResult;
892
1072
  try {
@@ -967,7 +1147,76 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
967
1147
  await endRemote(openedResult, authorization.signaling);
968
1148
  }
969
1149
  }
970
- }, [armFirstFrame, discardSession, endRemote, refuse, teardown, transportFailed, update, onTransportState]);
1150
+ }, [adoptCarrier, armFirstFrame, discardSession, endRemote, refuse, update]);
1151
+
1152
+ /** Dial a carrier to replace a dead one, one authorize() at a time; reattach the session or open anew. */
1153
+ const dial = useCallback(() => {
1154
+ const run = dialing.current;
1155
+ if (run === null || run.inFlight) return;
1156
+ clearTimeout(run.retry);
1157
+ run.inFlight = true;
1158
+ void (async () => {
1159
+ let authorization: { signaling: Signaling; session: SessionOpenRequest };
1160
+ try {
1161
+ authorization = await withDeadline(optionsRef.current.authorize(), DIAL_TIMEOUT_MS, (late) => late.signaling.close?.());
1162
+ } catch (error) {
1163
+ if (dialing.current !== run) return;
1164
+ run.inFlight = false;
1165
+ if (error === DEADLINE || errorCode(error) === 'transport') {
1166
+ run.retry = setTimeout(() => dialRef.current(), DIAL_RETRY_MS);
1167
+ return;
1168
+ }
1169
+ // A refused pairing is final: a revoked phone must not reattach.
1170
+ const message = error instanceof Error ? error.message : 'the desktop was not authorized';
1171
+ await teardown(message, true);
1172
+ update({ failure: { code: 'permission', message } });
1173
+ return;
1174
+ }
1175
+ if (dialing.current !== run) {
1176
+ authorization.signaling.close?.();
1177
+ return;
1178
+ }
1179
+ stopDialing();
1180
+ // Past the reattach window the host has ended the held session: open anew on this carrier.
1181
+ if (Date.now() - run.since >= REATTACH_WINDOW_MS) discardSession();
1182
+ adoptCarrier(authorization.signaling);
1183
+ if (opened.current == null) {
1184
+ attempts.current = 0;
1185
+ cancelReconnect();
1186
+ void establish(authorization);
1187
+ return;
1188
+ }
1189
+ // A picture that has only just stopped may still be riding its
1190
+ // old ICE pair back: on an unchanged path that pair resumes in
1191
+ // ~100 ms, so a brief blip waits that moment out before a restart
1192
+ // is spent. A picture still past the line is a real outage — the
1193
+ // wait shortens to one stats beat, because waiting out a blip
1194
+ // that never comes would cost the recovery its bar.
1195
+ const held = opened.current, before = mediaRef.current;
1196
+ cancelRestart();
1197
+ const stillMs = mediaMovingAt.current === null ? Infinity : Date.now() - mediaMovingAt.current;
1198
+ const graceMs = stillMs < REATTACH_GRACE_BLIP_MAX_MS ? REATTACH_GRACE_MS : REATTACH_GRACE_SETTLE_MS;
1199
+ restartTimer.current = setTimeout(() => {
1200
+ restartTimer.current = null;
1201
+ void getStats().catch(() => []).then((rows) => {
1202
+ if (opened.current !== held || dialing.current !== null) return;
1203
+ // The picture moves: only the carrier was missing.
1204
+ if ((mediaCounts(rows)?.frames ?? 0) > (before?.frames ?? Infinity)) {
1205
+ if (statusRef.current === 'reconnecting' || (statusRef.current === 'failed' && heldIceFailureAt.current !== null)) onTransportState('connected', false);
1206
+ return;
1207
+ }
1208
+ if (statusRef.current !== 'reconnecting') update({ status: 'reconnecting' });
1209
+ restartAttempts.current = restartCycles.current = 0;
1210
+ // A transport `disconnected` that landed while the stats read
1211
+ // above was in flight armed `scheduleRestart(RESTART_AFTER_MS)`;
1212
+ // without clearing it first the request here would return early
1213
+ // on the armed timer and the restart would slip by up to 2 s.
1214
+ cancelRestart();
1215
+ scheduleRestart(0);
1216
+ });
1217
+ }, graceMs);
1218
+ })();
1219
+ }, [adoptCarrier, cancelReconnect, cancelRestart, discardSession, establish, getStats, onTransportState, scheduleRestart, stopDialing, teardown, update]);
971
1220
 
972
1221
  /**
973
1222
  * Open (or reopen) the session with fresh authority. A deliberate call
@@ -979,8 +1228,15 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
979
1228
  restartAttempts.current = 0;
980
1229
  restartCycles.current = 0;
981
1230
  cancelRestart();
1231
+ if (heldIceFailureAt.current !== null && statusRef.current === 'failed') {
1232
+ const owner = signaling.current;
1233
+ const openedRef = opened.current;
1234
+ heldIceFailureAt.current = null;
1235
+ discardSession();
1236
+ void endRemote(openedRef, owner);
1237
+ }
982
1238
  await establish();
983
- }, [cancelRestart, establish]);
1239
+ }, [cancelRestart, discardSession, endRemote, establish]);
984
1240
 
985
1241
  // Native events: answer, candidates, control replies, presentation, failure.
986
1242
  useEffect(() => {
@@ -998,6 +1254,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
998
1254
  if (event.sessionId !== nativeRef.current) return;
999
1255
  const openedRef = opened.current;
1000
1256
  const channel = signaling.current;
1257
+ const epoch = carrierEpoch.current;
1001
1258
  switch (event.name) {
1002
1259
  case 'answer': {
1003
1260
  const sdp = String(event.payload.sdp ?? '');
@@ -1006,7 +1263,11 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
1006
1263
  session_id: openedRef.sessionId,
1007
1264
  generation: openedRef.generation,
1008
1265
  description: { type: 'answer', sdp },
1009
- }).catch(() => refuse('the desktop refused our answer', 'transport'));
1266
+ }).catch((error) => {
1267
+ // A dead or replaced carrier is the dial loop's, not a refusal.
1268
+ if (carrierFailed(epoch, error) || opened.current !== openedRef) return;
1269
+ refuse('the desktop refused our answer', 'transport');
1270
+ });
1010
1271
  return;
1011
1272
  }
1012
1273
  case 'candidate': {
@@ -1017,7 +1278,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
1017
1278
  candidate: String(event.payload.candidate ?? ''),
1018
1279
  sdpMid: (event.payload.sdpMid as string | null) ?? null,
1019
1280
  sdpMLineIndex: (event.payload.sdpMLineIndex as number | null) ?? null,
1020
- }).catch(() => undefined);
1281
+ }).catch((error) => { carrierFailed(epoch, error); });
1021
1282
  return;
1022
1283
  }
1023
1284
  case 'track':
@@ -1097,7 +1358,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
1097
1358
  // The peer's connection state, as it happens: Android
1098
1359
  // and iOS already emit this, and web matches them.
1099
1360
  const state = String(event.payload.state ?? '').toLowerCase();
1100
- if (state === 'failed') transportFailed();
1361
+ if (state === 'failed') transportFailed(UNREACHABLE_DESKTOP, true);
1101
1362
  else onTransportState(state);
1102
1363
  return;
1103
1364
  }
@@ -1107,7 +1368,9 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
1107
1368
  else if (typeof event.payload.text === 'string') typeText(event.payload.text);
1108
1369
  return;
1109
1370
  case 'failure':
1110
- transportFailed();
1371
+ // Only an ICE failure is held; an SDP or candidate
1372
+ // failure is terminal exactly as before.
1373
+ transportFailed(UNREACHABLE_DESKTOP, String(event.payload.reason ?? '') === 'ice');
1111
1374
  return;
1112
1375
  case 'closed':
1113
1376
  return;
@@ -1121,7 +1384,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
1121
1384
  dropped = true;
1122
1385
  subscription?.remove();
1123
1386
  };
1124
- }, [transportFailed, onTransportState, cancelRestart, spendRestartAttempt, notePing, teardown, update, pressKey, typeText, armMedia]);
1387
+ }, [transportFailed, onTransportState, cancelRestart, spendRestartAttempt, notePing, teardown, update, pressKey, typeText, armMedia, carrierFailed]);
1125
1388
 
1126
1389
  const copyRemoteToLocal = useCallback(async (writeLocal: (text: string) => Promise<void>) => {
1127
1390
  if (!inputEnabled.current) throw new Error('Desktop control is off.');
@@ -1171,26 +1434,62 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
1171
1434
  // across about a minute: a session that was revoked must not be reopened
1172
1435
  // on the strength of the old grant, and an outage that outlasts one
1173
1436
  // attempt must not spend the whole budget while the network is still down.
1437
+ //
1438
+ // An ICE failure is the one exception. It means the peer's path died, not
1439
+ // that the engine ended the session, so the held session (and its native
1440
+ // handle) is kept for HOLD_AFTER_FAILED_MS from the failure, whatever the
1441
+ // carrier watch is doing; a roam that outlasts the client's own ~15 s ICE
1442
+ // `failed` can then reattach and restart ICE on the same session. Only a
1443
+ // failure that is not ICE — SDP, candidate, a revoked transport — drops the
1444
+ // session at once, exactly as before.
1174
1445
  useEffect(() => {
1175
- if (snapshot.status !== 'failed') return;
1446
+ const immediate = reopenAtOnce.current;
1447
+ reopenAtOnce.current = false;
1448
+ if (snapshot.status !== 'failed') {
1449
+ heldIceFailureAt.current = null;
1450
+ return;
1451
+ }
1176
1452
  if (snapshot.failure?.code !== 'transport') return;
1177
- // The failed session is dead on the engine side. Drop its handle and
1178
- // close the remote session whether or not another attempt is left: a
1179
- // failure the user can retry by hand must not sit behind a stale
1180
- // native handle that makes `connect()` a no-op.
1181
- const owner = signaling.current;
1182
- const openedRef = opened.current;
1183
- cancelRestart();
1184
- discardSession();
1185
- void endRemote(openedRef, owner);
1186
- const wait = backoffDelay(REOPEN_BACKOFF_MS, attempts.current);
1187
- if (wait === null) return;
1188
- attempts.current += 1;
1189
- update({ status: 'reconnecting' });
1190
- reconnectTimer.current = setTimeout(() => {
1191
- reconnectTimer.current = null;
1192
- void establish();
1193
- }, wait);
1453
+ const reopen = () => {
1454
+ // The failed session is dead on the engine side. Drop its handle and
1455
+ // close the remote session whether or not another attempt is left: a
1456
+ // failure the user can retry by hand must not sit behind a stale
1457
+ // native handle that makes `connect()` a no-op.
1458
+ const owner = signaling.current;
1459
+ const openedRef = opened.current;
1460
+ cancelRestart();
1461
+ discardSession();
1462
+ void endRemote(openedRef, owner);
1463
+ // With the carrier dead, the dial loop opens on the first one that comes up.
1464
+ if (dialing.current !== null) {
1465
+ update({ status: 'reconnecting' });
1466
+ return;
1467
+ }
1468
+ const wait = immediate ? 0 : backoffDelay(REOPEN_BACKOFF_MS, attempts.current);
1469
+ if (wait === null) return;
1470
+ attempts.current += 1;
1471
+ update({ status: 'reconnecting' });
1472
+ reconnectTimer.current = setTimeout(() => {
1473
+ reconnectTimer.current = null;
1474
+ void establish();
1475
+ }, wait);
1476
+ };
1477
+ const failedAt = heldIceFailureAt.current;
1478
+ const remaining = failedAt === null ? 0 : HOLD_AFTER_FAILED_MS - (Date.now() - failedAt);
1479
+ if (remaining <= 0) {
1480
+ heldIceFailureAt.current = null;
1481
+ reopen();
1482
+ return;
1483
+ }
1484
+ // Hold: leave the session and its handle in place, and only drop them
1485
+ // when the window lapses with no recovery. A recovery that takes the
1486
+ // status off `failed` runs this effect's cleanup and clears the timer.
1487
+ const holdTimer = setTimeout(() => {
1488
+ if (statusRef.current !== 'failed') return;
1489
+ heldIceFailureAt.current = null;
1490
+ reopen();
1491
+ }, remaining);
1492
+ return () => clearTimeout(holdTimer);
1194
1493
  }, [establish, cancelRestart, discardSession, endRemote, snapshot.status, snapshot.failure, update]);
1195
1494
 
1196
1495
  useEffect(() => () => {
@@ -1208,9 +1507,10 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
1208
1507
  const openedRef = opened.current;
1209
1508
  const owner = signaling.current;
1210
1509
  opened.current = null;
1211
- signaling.current = null;
1510
+ stopDialing();
1511
+ dropCarrier(false);
1212
1512
  void endRemote(openedRef, owner);
1213
- }, [cancelReconnect, endRemote]);
1513
+ }, [cancelReconnect, endRemote, stopDialing, dropCarrier]);
1214
1514
 
1215
1515
  // A phone that goes to the background must not leave a remote button held:
1216
1516
  // the desktop cannot know the finger left the glass. This is the same