@desklink/react-native 0.5.3 → 0.5.4

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,20 @@ const STALL_AFTER_MS = 1200;
55
56
  * trigger another restart while the window lasts.
56
57
  */
57
58
  const RESTART_GRACE_MS = 2500;
59
+ /** How long a moving picture outlasts silent heartbeats: after an outage the data channel lags the video. */
60
+ const QUIET_PICTURE_MS = 20_000;
58
61
  /** How long a restart offer may go unanswered before the next attempt. */
59
62
  const RESTART_REPLY_MS = 5000;
63
+ /** A restart request unanswered this long means its carrier is dead, even if it never closed. */
64
+ const RESTART_REQUEST_DEADLINE_MS = 1500;
65
+ /** A dial that failed is retried this soon: the path may be back any moment. */
66
+ const DIAL_RETRY_MS = 250;
67
+ /** One dial's bound. Not shorter: Chromium throttles refused WebSocket handshakes. */
68
+ const DIAL_TIMEOUT_MS = 5000;
69
+ /** How long the host holds a session with no carrier (the bridge's `reattachMs`); past it, reopen. */
70
+ const REATTACH_WINDOW_MS = 30_000;
71
+ /** How long a reattached carrier waits for the old ICE pair before asking for a restart. */
72
+ const REATTACH_GRACE_MS = 300;
60
73
  /**
61
74
  * How long the media path may carry nothing at all before a session that says
62
75
  * `live` stops saying so. The engine's transport state reaches the client over
@@ -98,6 +111,16 @@ export const REOPEN_BACKOFF_MS = [1000, 2000, 4000, 8000, 8000, 8000, 8000, 8000
98
111
  * A return after that reopens at once instead of spending restarts on it.
99
112
  */
100
113
  export const BACKGROUND_REOPEN_MS = 30_000;
114
+ /**
115
+ * A peer that reaches ICE `failed` has lost its media path, not its session:
116
+ * the engine keeps the session alive for about 28 s after the path dies (its
117
+ * ICE `Failed` timer). So a client-side ICE failure holds the session — and
118
+ * its native handle — this long from the failure, whatever the carrier is
119
+ * doing, instead of throwing it away at once. A roam longer than the client's
120
+ * own ~15 s ICE `failed` then still returns to the same session. An SDP or
121
+ * candidate failure is not a lost path and stays terminal as it always was.
122
+ */
123
+ export const HOLD_AFTER_FAILED_MS = 30_000;
101
124
  /**
102
125
  * A resumed app hears what queued up while it was suspended just after it
103
126
  * hears it is active again. A revocation among it decides whether a reopen is
@@ -105,6 +128,19 @@ export const BACKGROUND_REOPEN_MS = 30_000;
105
128
  */
106
129
  const RESUME_DRAIN_MS = 750;
107
130
 
131
+ const DEADLINE = new Error('no reply in time');
132
+
133
+ /** `promise`, or a DEADLINE rejection after `ms`; a value that lands later goes to `late`. */
134
+ function withDeadline<T>(promise: Promise<T>, ms: number, late?: (value: T) => void): Promise<T> {
135
+ let expired = false;
136
+ return Promise.race([
137
+ promise.then((value) => { if (expired) late?.(value); return value; }),
138
+ new Promise<never>((_, reject) => setTimeout(() => { expired = true; reject(DEADLINE); }, ms)),
139
+ ]);
140
+ }
141
+
142
+ const errorCode = (error: unknown): unknown => (error as { code?: unknown } | null)?.code;
143
+
108
144
  /** The delay before attempt `n` (0-based), or null when the schedule is spent. */
109
145
  export function backoffDelay(schedule: readonly number[], attempt: number): number | null {
110
146
  return attempt < schedule.length ? schedule[attempt]! : null;
@@ -348,6 +384,12 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
348
384
  const restartCycles = useRef(0);
349
385
  /** The last ICE state the peer or the engine reported. */
350
386
  const iceConnected = useRef(false);
387
+ /**
388
+ * When the peer's own ICE last failed while the session was held, or null
389
+ * when the current failure is not an ICE one. Kept across the hold so the
390
+ * reopen effect can measure the remaining window.
391
+ */
392
+ const heldIceFailureAt = useRef<number | null>(null);
351
393
  /** The engine's restore token, for a consent-free reopen. Unset by hand. */
352
394
  const restoreToken = useRef<string | null>(null);
353
395
  /** The latest status and presentation, read by event handlers. */
@@ -360,6 +402,13 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
360
402
  const awaySince = useRef<number | null>(null);
361
403
  /** Guards every asynchronous callback against a session that already ended. */
362
404
  const generationToken = useRef(0);
405
+ /** Bumped per carrier adopted or dropped: an older carrier's events and replies are ignored. */
406
+ const carrierEpoch = useRef(0);
407
+ const unsubscribeCarrier = useRef<(() => void) | null>(null);
408
+ /** While the carrier is dead, whatever the status: the dial in flight and its retry. */
409
+ const dialing = useRef<{ inFlight: boolean; since: number; retry?: ReturnType<typeof setTimeout> } | null>(null);
410
+ /** Set with a reopen that skips its backoff: the host no longer holds the session at all. */
411
+ const reopenAtOnce = useRef(false);
363
412
  const optionsRef = useRef(options);
364
413
  optionsRef.current = options;
365
414
 
@@ -375,21 +424,30 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
375
424
  optionsRef.current.onStateChange?.(next);
376
425
  }, []);
377
426
 
378
- const fail = useCallback((failure: SessionFailure) => {
427
+ const fail = useCallback((failure: SessionFailure, ice = false) => {
428
+ heldIceFailureAt.current = failure.code === 'transport' && ice && nativeRef.current != null ? Date.now() : null;
379
429
  update({ status: 'failed', failure });
380
430
  optionsRef.current.onError?.(failure);
381
431
  }, [update]);
382
432
 
383
- const refuse = useCallback((message: string, code: SessionFailure['code'] = 'platform') => {
384
- fail({ code, message });
433
+ const refuse = useCallback((message: string, code: SessionFailure['code'] = 'platform', ice = false) => {
434
+ fail({ code, message }, ice);
385
435
  }, [fail]);
386
436
 
387
437
  /** A transport loss is one event no matter how many sides report it: the
388
438
  * 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');
439
+ * reopen budget twice for the same outage. While dialing, it stays
440
+ * `reconnecting`.
441
+ *
442
+ * `ice` marks a failure of the peer's own media path (its ICE `failed`
443
+ * state, or a `failure` event whose reason is `ice`). While the session is
444
+ * held and has not been revoked, that is not the end of the session: the
445
+ * reopen effect keeps it for `HOLD_AFTER_FAILED_MS` first. */
446
+ const transportFailed = useCallback((message: string = UNREACHABLE_DESKTOP, ice = false): boolean => {
447
+ if (dialing.current !== null) return false;
448
+ if (ice && statusRef.current === 'failed') return false;
449
+ refuse(message, 'transport', ice);
450
+ return true;
393
451
  }, [refuse]);
394
452
 
395
453
  const cancelRestart = useCallback(() => {
@@ -412,20 +470,69 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
412
470
  firstFrameTimer.current = null;
413
471
  }, []);
414
472
 
473
+ const stopDialing = useCallback(() => { clearTimeout(dialing.current?.retry); dialing.current = null; }, []);
474
+
415
475
  /**
416
476
  * A first connection that stays frameless for this long is not going to
417
477
  * arrive: report why instead of leaving an empty picture on screen.
418
478
  */
419
479
  const armFirstFrame = useCallback(() => {
420
480
  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]);
481
+ const watch: () => void = () => {
482
+ firstFrameTimer.current = setTimeout(() => {
483
+ firstFrameTimer.current = null;
484
+ const status = statusRef.current;
485
+ if (!presentedRef.current && status === 'failed' && heldIceFailureAt.current !== null) {
486
+ watch();
487
+ return;
488
+ }
489
+ if (presentedRef.current || (status !== 'connecting' && status !== 'reconnecting')) return;
490
+ stopDialing();
491
+ transportFailed(NO_PICTURE);
492
+ }, FIRST_FRAME_AFTER_MS);
493
+ };
494
+ watch();
495
+ }, [cancelFirstFrame, stopDialing, transportFailed]);
427
496
 
428
497
  const scheduleRestartRef = useRef<(delay: number) => void>(() => undefined);
498
+ const dialRef = useRef<() => void>(() => undefined);
499
+ const carrierEventRef = useRef<(event: SessionEvent) => void>(() => undefined);
500
+
501
+ /** Stop listening to the carrier; a dead one is also closed, so nothing more is sent on it. */
502
+ const dropCarrier = useCallback((dead: boolean) => {
503
+ carrierEpoch.current += 1;
504
+ unsubscribeCarrier.current?.();
505
+ unsubscribeCarrier.current = null;
506
+ const channel = signaling.current;
507
+ signaling.current = null;
508
+ if (dead) channel?.close?.();
509
+ }, []);
510
+
511
+ const adoptCarrier = useCallback((channel: Signaling) => {
512
+ dropCarrier(false);
513
+ const epoch = carrierEpoch.current;
514
+ signaling.current = channel;
515
+ unsubscribeCarrier.current = channel.subscribe((event) => {
516
+ if (epoch === carrierEpoch.current) carrierEventRef.current(event);
517
+ });
518
+ }, [dropCarrier]);
519
+
520
+ /** Carrier `epoch` is dead: drop it hard and, while a session is held, dial a new one. */
521
+ const carrierLost = useCallback((epoch: number) => {
522
+ if (epoch !== carrierEpoch.current || signaling.current == null) return;
523
+ dropCarrier(true);
524
+ if (dialing.current !== null || opened.current == null) return;
525
+ dialing.current = { inFlight: false, since: Date.now() };
526
+ dialRef.current();
527
+ }, [dropCarrier]);
528
+
529
+ /** Whether a request on carrier `epoch` failed because that carrier is gone; the dial loop then owns it. */
530
+ const carrierFailed = useCallback((epoch: number, error: unknown): boolean => {
531
+ if (epoch !== carrierEpoch.current) return true;
532
+ if (error !== DEADLINE && errorCode(error) !== 'transport') return false;
533
+ carrierLost(epoch);
534
+ return true;
535
+ }, [carrierLost]);
429
536
 
430
537
  /**
431
538
  * Spend one restart from the backoff schedule, or reopen when the schedule
@@ -465,20 +572,33 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
465
572
  if (statusRef.current !== 'reconnecting') return;
466
573
  const openedRef = opened.current;
467
574
  const channel = signaling.current;
468
- if (openedRef == null || channel == null) {
575
+ if (openedRef == null) {
469
576
  spendRestartAttempt();
470
577
  return;
471
578
  }
579
+ // No carrier: the dial loop restarts once it has a new one.
580
+ if (channel == null) return;
581
+ const epoch = carrierEpoch.current;
582
+ const token = generationToken.current;
472
583
  void (async () => {
473
584
  try {
474
- await channel.request('session.restart_ice', {
585
+ await withDeadline(channel.request('session.restart_ice', {
475
586
  session_id: openedRef.sessionId,
476
587
  generation: openedRef.generation,
477
- });
478
- } catch {
588
+ }), RESTART_REQUEST_DEADLINE_MS);
589
+ } catch (error) {
590
+ if (token !== generationToken.current || carrierFailed(epoch, error)) return;
591
+ // The host no longer holds this session: reopen without waiting out a backoff.
592
+ if (errorCode(error) === 'session' || errorCode(error) === 'generation') {
593
+ attempts.current = 0;
594
+ reopenAtOnce.current = transportFailed('the desktop no longer holds this session; reopening it');
595
+ return;
596
+ }
479
597
  spendRestartAttempt();
480
598
  return;
481
599
  }
600
+ // A late reply from a replaced carrier or session says nothing about this one.
601
+ if (epoch !== carrierEpoch.current || token !== generationToken.current) return;
482
602
  if (statusRef.current !== 'reconnecting') return;
483
603
  restartGraceUntil.current = Date.now() + RESTART_GRACE_MS;
484
604
  restartCycles.current += 1;
@@ -501,10 +621,12 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
501
621
  }, replyMs);
502
622
  })();
503
623
  }, delay);
504
- }, [spendRestartAttempt, transportFailed]);
624
+ }, [spendRestartAttempt, transportFailed, carrierFailed]);
505
625
 
506
626
  useEffect(() => {
507
627
  scheduleRestartRef.current = scheduleRestart;
628
+ dialRef.current = dial;
629
+ carrierEventRef.current = onCarrierEvent;
508
630
  checkStallRef.current = checkStall;
509
631
  mediaWatchRef.current = checkMedia;
510
632
  });
@@ -526,6 +648,11 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
526
648
  const onTransportState = useCallback((state: string, trackIce = true, skipGrace = false) => {
527
649
  // Synthetic stall reports say nothing about ICE itself.
528
650
  if (trackIce) iceConnected.current = state === 'connected' || state === 'completed';
651
+ if (heldIceFailureAt.current !== null && statusRef.current === 'failed'
652
+ && (state === 'connected' || state === 'completed')) {
653
+ heldIceFailureAt.current = null;
654
+ update({ status: 'reconnecting' });
655
+ }
529
656
  const mapped = connectionStatusFor(state, statusRef.current, presentedRef.current && !frozen.current);
530
657
  if (mapped === null) return;
531
658
  // A transport that answers while the picture is frozen is not a
@@ -598,7 +725,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
598
725
  frozen.current = false;
599
726
  // The picture advances again, so the transport can be believed
600
727
  // for this session: live returns without waiting for it to say so.
601
- if (statusRef.current === 'reconnecting') onTransportState('connected', false);
728
+ if (statusRef.current === 'reconnecting' || (statusRef.current === 'failed' && heldIceFailureAt.current !== null)) onTransportState('connected', false);
602
729
  return;
603
730
  }
604
731
  if (mediaMovingAt.current === null) return;
@@ -612,6 +739,8 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
612
739
  }, () => undefined);
613
740
  }, [armMedia, onTransportState]);
614
741
 
742
+ const pictureMoving = useCallback((within: number): boolean => mediaMovingAt.current !== null && Date.now() - mediaMovingAt.current < within, []);
743
+
615
744
  /**
616
745
  * One stall check: quiet too long while the session should be talking
617
746
  * means the path is stalled, even when ICE has not said so yet. Only
@@ -634,8 +763,12 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
634
763
  stallTimer.current = setTimeout(() => checkStallRef.current(), restartGraceUntil.current - Date.now());
635
764
  return;
636
765
  }
766
+ if (pictureMoving(STALL_AFTER_MS) && !frozen.current && quietMs < QUIET_PICTURE_MS) {
767
+ stallTimer.current = setTimeout(() => checkStallRef.current(), STALL_AFTER_MS);
768
+ return;
769
+ }
637
770
  onTransportState('disconnected', false);
638
- }, [onTransportState]);
771
+ }, [onTransportState, pictureMoving]);
639
772
 
640
773
  /**
641
774
  * One engine heartbeat: the path works. It clears a stall the watchdog
@@ -733,6 +866,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
733
866
  /** Drop everything this process holds for a session the engine has ended. */
734
867
  const discardSession = useCallback(() => {
735
868
  generationToken.current += 1;
869
+ cancelFirstFrame();
736
870
  const id = nativeRef.current;
737
871
  nativeRef.current = null;
738
872
  inputEnabled.current = false;
@@ -741,6 +875,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
741
875
  pendingOffer.current = null;
742
876
  pendingCandidates.current = [];
743
877
  pendingCursorEvents.current = [];
878
+ heldIceFailureAt.current = null;
744
879
  restartGraceUntil.current = 0;
745
880
  restartCycles.current = 0;
746
881
  // A new session has its own media path: the last one's counts and its
@@ -751,13 +886,13 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
751
886
  mediaMovingAt.current = null;
752
887
  frozen.current = false;
753
888
  opened.current = null;
754
- signaling.current = null;
889
+ dropCarrier(false);
755
890
  setNativeId(null);
756
891
  // An armed modifier belongs to the session it was armed in.
757
892
  modifiersRef.current = NO_MODIFIERS;
758
893
  setModifiers(NO_MODIFIERS);
759
894
  if (id != null) nativeDesklink?.closeSession(id);
760
- }, []);
895
+ }, [cancelFirstFrame, dropCarrier]);
761
896
 
762
897
  /** Tell the host a session is over; a failure is nothing the user can act on. */
763
898
  const endRemote = useCallback(async (
@@ -780,6 +915,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
780
915
  cancelRestart();
781
916
  cancelStall();
782
917
  cancelFirstFrame();
918
+ stopDialing();
783
919
  restoreToken.current = null;
784
920
  const openedRef = opened.current;
785
921
  const owner = signaling.current;
@@ -787,10 +923,98 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
787
923
  if (closeRemote) await endRemote(openedRef, owner);
788
924
  update({ status: 'ended', presented: false, failure: null });
789
925
  void reason;
790
- }, [cancelReconnect, cancelRestart, cancelStall, cancelFirstFrame, endRemote, discardSession, update]);
926
+ }, [cancelReconnect, cancelRestart, cancelStall, cancelFirstFrame, stopDialing, endRemote, discardSession, update]);
791
927
 
792
- const establish = useCallback(async () => {
928
+ /**
929
+ * One engine event from the live carrier. Subscribed before the open,
930
+ * because the engine's offer and its first candidates can beat the open
931
+ * result back: anything that arrives before the native session exists is
932
+ * stashed and replayed into it.
933
+ */
934
+ const onCarrierEvent = useCallback((event: SessionEvent) => {
935
+ const native = nativeRef.current;
936
+ const held = opened.current;
937
+ const current = event.sessionId === undefined || held == null || held.sessionId === event.sessionId;
938
+ switch (event.kind) {
939
+ case 'carrier-closed':
940
+ carrierLost(carrierEpoch.current);
941
+ return;
942
+ case 'description':
943
+ if (event.description.type !== 'offer') return;
944
+ if (native == null || held == null) {
945
+ pendingOffer.current = { sdp: event.description.sdp, sessionId: event.sessionId };
946
+ return;
947
+ }
948
+ if (!current) return;
949
+ // A mid-session re-offer is a restart this session asked
950
+ // for, through its policy or directly: renegotiation
951
+ // transients after it are expected, not a new outage.
952
+ if (presentedRef.current) restartGraceUntil.current = Date.now() + RESTART_GRACE_MS;
953
+ nativeDesklink?.setRemoteDescription(native, 'offer', event.description.sdp);
954
+ return;
955
+ case 'candidate':
956
+ if (native == null || held == null) {
957
+ pendingCandidates.current.push({
958
+ candidate: event.candidate.candidate,
959
+ sdpMid: event.candidate.sdpMid ?? null,
960
+ sdpMLineIndex: event.candidate.sdpMLineIndex ?? null,
961
+ sessionId: event.sessionId,
962
+ });
963
+ return;
964
+ }
965
+ if (!current) return;
966
+ nativeDesklink?.addRemoteCandidate(
967
+ native,
968
+ event.candidate.candidate,
969
+ event.candidate.sdpMid ?? null,
970
+ event.candidate.sdpMLineIndex ?? null,
971
+ );
972
+ return;
973
+ case 'cursor':
974
+ if (held == null) {
975
+ pendingCursorEvents.current.push(event);
976
+ return;
977
+ }
978
+ if (current) optionsRef.current.onCursor?.(event);
979
+ return;
980
+ case 'state':
981
+ if (!current) return;
982
+ diagnostics.current.transport = event.transport;
983
+ // Before the session exists there is nothing to map onto.
984
+ if (held != null) onTransportState(String(event.transport ?? '').toLowerCase());
985
+ update({ diagnostics: { ...diagnostics.current } });
986
+ return;
987
+ case 'restoreToken':
988
+ // The live session's grant for a consent-free reopen.
989
+ if (current && held != null) restoreToken.current = event.token;
990
+ return;
991
+ case 'revoked':
992
+ // Nothing held: the reopen policy owns recovery, and a
993
+ // dead session's late revocation must not end it.
994
+ if (held == null || !current) return;
995
+ // A lost path is an outage, not a verdict: the reopen
996
+ // policy asks for fresh authority. Any other code, or
997
+ // none, is the session's authority ending.
998
+ if (event.code === 'transport') {
999
+ transportFailed(event.reason);
1000
+ return;
1001
+ }
1002
+ void teardown(event.reason, false);
1003
+ update({ status: 'ended', failure: { code: 'revoked', message: event.reason } });
1004
+ return;
1005
+ default:
1006
+ return;
1007
+ }
1008
+ }, [carrierLost, onTransportState, teardown, transportFailed, update]);
1009
+
1010
+ /** Open a session on `given`, a carrier the dial loop just authorized, or on a fresh authorization. */
1011
+ const establish = useCallback(async (given?: { signaling: Signaling; session: SessionOpenRequest }) => {
793
1012
  if (nativeRef.current != null) return;
1013
+ // While a dead carrier is replaced, the dial loop opens on the new one.
1014
+ if (given === undefined && dialing.current !== null) {
1015
+ dialRef.current();
1016
+ return;
1017
+ }
794
1018
  const token = ++generationToken.current;
795
1019
  pendingCursorEvents.current = [];
796
1020
  if (!(await loadPlatform())) {
@@ -800,93 +1024,17 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
800
1024
  if (token !== generationToken.current) return;
801
1025
  update({ status: 'opening', failure: null, presented: false });
802
1026
 
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;
1027
+ let authorization = given;
1028
+ if (authorization === undefined) {
1029
+ try {
1030
+ authorization = await optionsRef.current.authorize();
1031
+ } catch (error) {
1032
+ refuse(error instanceof Error ? error.message : 'the desktop was not authorized', errorCode(error) === 'transport' ? 'transport' : 'permission');
1033
+ return;
888
1034
  }
889
- });
1035
+ if (token !== generationToken.current) return;
1036
+ adoptCarrier(authorization.signaling);
1037
+ }
890
1038
 
891
1039
  let openedResult: SessionOpenResult;
892
1040
  try {
@@ -967,7 +1115,64 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
967
1115
  await endRemote(openedResult, authorization.signaling);
968
1116
  }
969
1117
  }
970
- }, [armFirstFrame, discardSession, endRemote, refuse, teardown, transportFailed, update, onTransportState]);
1118
+ }, [adoptCarrier, armFirstFrame, discardSession, endRemote, refuse, update]);
1119
+
1120
+ /** Dial a carrier to replace a dead one, one authorize() at a time; reattach the session or open anew. */
1121
+ const dial = useCallback(() => {
1122
+ const run = dialing.current;
1123
+ if (run === null || run.inFlight) return;
1124
+ clearTimeout(run.retry);
1125
+ run.inFlight = true;
1126
+ void (async () => {
1127
+ let authorization: { signaling: Signaling; session: SessionOpenRequest };
1128
+ try {
1129
+ authorization = await withDeadline(optionsRef.current.authorize(), DIAL_TIMEOUT_MS, (late) => late.signaling.close?.());
1130
+ } catch (error) {
1131
+ if (dialing.current !== run) return;
1132
+ run.inFlight = false;
1133
+ if (error === DEADLINE || errorCode(error) === 'transport') {
1134
+ run.retry = setTimeout(() => dialRef.current(), DIAL_RETRY_MS);
1135
+ return;
1136
+ }
1137
+ // A refused pairing is final: a revoked phone must not reattach.
1138
+ const message = error instanceof Error ? error.message : 'the desktop was not authorized';
1139
+ await teardown(message, true);
1140
+ update({ failure: { code: 'permission', message } });
1141
+ return;
1142
+ }
1143
+ if (dialing.current !== run) {
1144
+ authorization.signaling.close?.();
1145
+ return;
1146
+ }
1147
+ stopDialing();
1148
+ // Past the reattach window the host has ended the held session: open anew on this carrier.
1149
+ if (Date.now() - run.since >= REATTACH_WINDOW_MS) discardSession();
1150
+ adoptCarrier(authorization.signaling);
1151
+ if (opened.current == null) {
1152
+ attempts.current = 0;
1153
+ cancelReconnect();
1154
+ void establish(authorization);
1155
+ return;
1156
+ }
1157
+ // The old pair on an unchanged path resumes in ~100 ms: give it a moment before spending a restart.
1158
+ const held = opened.current, before = mediaRef.current;
1159
+ cancelRestart();
1160
+ restartTimer.current = setTimeout(() => {
1161
+ restartTimer.current = null;
1162
+ void getStats().catch(() => []).then((rows) => {
1163
+ if (opened.current !== held || dialing.current !== null) return;
1164
+ // The picture moves: only the carrier was missing.
1165
+ if ((mediaCounts(rows)?.frames ?? 0) > (before?.frames ?? Infinity)) {
1166
+ if (statusRef.current === 'reconnecting' || (statusRef.current === 'failed' && heldIceFailureAt.current !== null)) onTransportState('connected', false);
1167
+ return;
1168
+ }
1169
+ if (statusRef.current !== 'reconnecting') update({ status: 'reconnecting' });
1170
+ restartAttempts.current = restartCycles.current = 0;
1171
+ scheduleRestart(0);
1172
+ });
1173
+ }, REATTACH_GRACE_MS);
1174
+ })();
1175
+ }, [adoptCarrier, cancelReconnect, cancelRestart, discardSession, establish, getStats, onTransportState, scheduleRestart, stopDialing, teardown, update]);
971
1176
 
972
1177
  /**
973
1178
  * Open (or reopen) the session with fresh authority. A deliberate call
@@ -979,8 +1184,15 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
979
1184
  restartAttempts.current = 0;
980
1185
  restartCycles.current = 0;
981
1186
  cancelRestart();
1187
+ if (heldIceFailureAt.current !== null && statusRef.current === 'failed') {
1188
+ const owner = signaling.current;
1189
+ const openedRef = opened.current;
1190
+ heldIceFailureAt.current = null;
1191
+ discardSession();
1192
+ void endRemote(openedRef, owner);
1193
+ }
982
1194
  await establish();
983
- }, [cancelRestart, establish]);
1195
+ }, [cancelRestart, discardSession, endRemote, establish]);
984
1196
 
985
1197
  // Native events: answer, candidates, control replies, presentation, failure.
986
1198
  useEffect(() => {
@@ -998,6 +1210,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
998
1210
  if (event.sessionId !== nativeRef.current) return;
999
1211
  const openedRef = opened.current;
1000
1212
  const channel = signaling.current;
1213
+ const epoch = carrierEpoch.current;
1001
1214
  switch (event.name) {
1002
1215
  case 'answer': {
1003
1216
  const sdp = String(event.payload.sdp ?? '');
@@ -1006,7 +1219,11 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
1006
1219
  session_id: openedRef.sessionId,
1007
1220
  generation: openedRef.generation,
1008
1221
  description: { type: 'answer', sdp },
1009
- }).catch(() => refuse('the desktop refused our answer', 'transport'));
1222
+ }).catch((error) => {
1223
+ // A dead or replaced carrier is the dial loop's, not a refusal.
1224
+ if (carrierFailed(epoch, error) || opened.current !== openedRef) return;
1225
+ refuse('the desktop refused our answer', 'transport');
1226
+ });
1010
1227
  return;
1011
1228
  }
1012
1229
  case 'candidate': {
@@ -1017,7 +1234,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
1017
1234
  candidate: String(event.payload.candidate ?? ''),
1018
1235
  sdpMid: (event.payload.sdpMid as string | null) ?? null,
1019
1236
  sdpMLineIndex: (event.payload.sdpMLineIndex as number | null) ?? null,
1020
- }).catch(() => undefined);
1237
+ }).catch((error) => { carrierFailed(epoch, error); });
1021
1238
  return;
1022
1239
  }
1023
1240
  case 'track':
@@ -1097,7 +1314,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
1097
1314
  // The peer's connection state, as it happens: Android
1098
1315
  // and iOS already emit this, and web matches them.
1099
1316
  const state = String(event.payload.state ?? '').toLowerCase();
1100
- if (state === 'failed') transportFailed();
1317
+ if (state === 'failed') transportFailed(UNREACHABLE_DESKTOP, true);
1101
1318
  else onTransportState(state);
1102
1319
  return;
1103
1320
  }
@@ -1107,7 +1324,9 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
1107
1324
  else if (typeof event.payload.text === 'string') typeText(event.payload.text);
1108
1325
  return;
1109
1326
  case 'failure':
1110
- transportFailed();
1327
+ // Only an ICE failure is held; an SDP or candidate
1328
+ // failure is terminal exactly as before.
1329
+ transportFailed(UNREACHABLE_DESKTOP, String(event.payload.reason ?? '') === 'ice');
1111
1330
  return;
1112
1331
  case 'closed':
1113
1332
  return;
@@ -1121,7 +1340,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
1121
1340
  dropped = true;
1122
1341
  subscription?.remove();
1123
1342
  };
1124
- }, [transportFailed, onTransportState, cancelRestart, spendRestartAttempt, notePing, teardown, update, pressKey, typeText, armMedia]);
1343
+ }, [transportFailed, onTransportState, cancelRestart, spendRestartAttempt, notePing, teardown, update, pressKey, typeText, armMedia, carrierFailed]);
1125
1344
 
1126
1345
  const copyRemoteToLocal = useCallback(async (writeLocal: (text: string) => Promise<void>) => {
1127
1346
  if (!inputEnabled.current) throw new Error('Desktop control is off.');
@@ -1171,26 +1390,62 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
1171
1390
  // across about a minute: a session that was revoked must not be reopened
1172
1391
  // on the strength of the old grant, and an outage that outlasts one
1173
1392
  // attempt must not spend the whole budget while the network is still down.
1393
+ //
1394
+ // An ICE failure is the one exception. It means the peer's path died, not
1395
+ // that the engine ended the session, so the held session (and its native
1396
+ // handle) is kept for HOLD_AFTER_FAILED_MS from the failure, whatever the
1397
+ // carrier watch is doing; a roam that outlasts the client's own ~15 s ICE
1398
+ // `failed` can then reattach and restart ICE on the same session. Only a
1399
+ // failure that is not ICE — SDP, candidate, a revoked transport — drops the
1400
+ // session at once, exactly as before.
1174
1401
  useEffect(() => {
1175
- if (snapshot.status !== 'failed') return;
1402
+ const immediate = reopenAtOnce.current;
1403
+ reopenAtOnce.current = false;
1404
+ if (snapshot.status !== 'failed') {
1405
+ heldIceFailureAt.current = null;
1406
+ return;
1407
+ }
1176
1408
  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);
1409
+ const reopen = () => {
1410
+ // The failed session is dead on the engine side. Drop its handle and
1411
+ // close the remote session whether or not another attempt is left: a
1412
+ // failure the user can retry by hand must not sit behind a stale
1413
+ // native handle that makes `connect()` a no-op.
1414
+ const owner = signaling.current;
1415
+ const openedRef = opened.current;
1416
+ cancelRestart();
1417
+ discardSession();
1418
+ void endRemote(openedRef, owner);
1419
+ // With the carrier dead, the dial loop opens on the first one that comes up.
1420
+ if (dialing.current !== null) {
1421
+ update({ status: 'reconnecting' });
1422
+ return;
1423
+ }
1424
+ const wait = immediate ? 0 : backoffDelay(REOPEN_BACKOFF_MS, attempts.current);
1425
+ if (wait === null) return;
1426
+ attempts.current += 1;
1427
+ update({ status: 'reconnecting' });
1428
+ reconnectTimer.current = setTimeout(() => {
1429
+ reconnectTimer.current = null;
1430
+ void establish();
1431
+ }, wait);
1432
+ };
1433
+ const failedAt = heldIceFailureAt.current;
1434
+ const remaining = failedAt === null ? 0 : HOLD_AFTER_FAILED_MS - (Date.now() - failedAt);
1435
+ if (remaining <= 0) {
1436
+ heldIceFailureAt.current = null;
1437
+ reopen();
1438
+ return;
1439
+ }
1440
+ // Hold: leave the session and its handle in place, and only drop them
1441
+ // when the window lapses with no recovery. A recovery that takes the
1442
+ // status off `failed` runs this effect's cleanup and clears the timer.
1443
+ const holdTimer = setTimeout(() => {
1444
+ if (statusRef.current !== 'failed') return;
1445
+ heldIceFailureAt.current = null;
1446
+ reopen();
1447
+ }, remaining);
1448
+ return () => clearTimeout(holdTimer);
1194
1449
  }, [establish, cancelRestart, discardSession, endRemote, snapshot.status, snapshot.failure, update]);
1195
1450
 
1196
1451
  useEffect(() => () => {
@@ -1208,9 +1463,10 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
1208
1463
  const openedRef = opened.current;
1209
1464
  const owner = signaling.current;
1210
1465
  opened.current = null;
1211
- signaling.current = null;
1466
+ stopDialing();
1467
+ dropCarrier(false);
1212
1468
  void endRemote(openedRef, owner);
1213
- }, [cancelReconnect, endRemote]);
1469
+ }, [cancelReconnect, endRemote, stopDialing, dropCarrier]);
1214
1470
 
1215
1471
  // A phone that goes to the background must not leave a remote button held:
1216
1472
  // the desktop cannot know the finger left the glass. This is the same