@desklink/react-native 0.2.0 → 0.3.0

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.
@@ -54,6 +54,18 @@ const RESTART_REPLY_MS = 5000;
54
54
  * 8 s costs nothing next to a live desktop.
55
55
  */
56
56
  export const REOPEN_BACKOFF_MS = [1000, 2000, 4000, 8000, 8000, 8000, 8000, 8000];
57
+ /**
58
+ * Away from the foreground this long, the path is presumed dead: ICE consent
59
+ * lapses after about 30 s (RFC 7675), and the engine then revokes the session.
60
+ * A return after that reopens at once instead of spending restarts on it.
61
+ */
62
+ export const BACKGROUND_REOPEN_MS = 30_000;
63
+ /**
64
+ * A resumed app hears what queued up while it was suspended just after it
65
+ * hears it is active again. A revocation among it decides whether a reopen is
66
+ * allowed at all, so the long-absence reopen waits this long for it first.
67
+ */
68
+ const RESUME_DRAIN_MS = 750;
57
69
 
58
70
  /** The delay before attempt `n` (0-based), or null when the schedule is spent. */
59
71
  export function backoffDelay(schedule: readonly number[], attempt: number): number | null {
@@ -96,12 +108,12 @@ export function connectionStatusFor(
96
108
  }
97
109
 
98
110
  /**
99
- * Without a relay there is no route between two networks: ICE only ever finds
100
- * host candidates. A client that cannot reach the desktop is told that plainly
101
- * rather than left on a spinner.
111
+ * With no app-supplied ICE servers, ICE only finds host candidates, which need
112
+ * a directly reachable desktop. A client with no usable route is told that
113
+ * plainly rather than left on a spinner.
102
114
  */
103
115
  const UNREACHABLE_DESKTOP =
104
- 'The phone must reach the desktop directly, on the same network or its tailnet. Other networks need a relay, which is not available yet.';
116
+ 'The phone must reach the desktop directly, on the same network or its tailnet. Other networks need a relay route supplied by the app.';
105
117
 
106
118
  /**
107
119
  * The engine's and host's own refusal tokens, mapped to this package's failure
@@ -275,6 +287,8 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
275
287
  /** The latest snapshot; mirrored eagerly so timers read exact state. */
276
288
  const snapshotRef = useRef<SessionSnapshot>(IDLE);
277
289
  const diagnostics = useRef<Record<string, string | number | boolean>>({});
290
+ /** When the app left the foreground, or null while it is active. */
291
+ const awaySince = useRef<number | null>(null);
278
292
  /** Guards every asynchronous callback against a session that already ended. */
279
293
  const generationToken = useRef(0);
280
294
  const optionsRef = useRef(options);
@@ -327,17 +341,20 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
327
341
  const scheduleRestartRef = useRef<(delay: number) => void>(() => undefined);
328
342
 
329
343
  /**
330
- * Spend one restart from the backoff schedule, or stop asking when the
331
- * schedule is spent: ICE's own failure then moves the session to a full
332
- * reopen. Only refs and module constants, so stable callbacks may hold it.
344
+ * Spend one restart from the backoff schedule, or reopen when the schedule
345
+ * is spent: a peer may never report `failed` (a resumed phone often does
346
+ * not), and waiting for it would leave the session reconnecting forever.
333
347
  */
334
348
  const spendRestartAttempt = useCallback((): void => {
335
349
  if (statusRef.current !== 'reconnecting' || nativeRef.current == null) return;
336
350
  const wait = backoffDelay(RESTART_BACKOFF_MS, restartAttempts.current);
337
- if (wait === null) return;
351
+ if (wait === null) {
352
+ transportFailed('the desktop did not come back; reopening the session');
353
+ return;
354
+ }
338
355
  restartAttempts.current += 1;
339
356
  scheduleRestartRef.current(wait);
340
- }, []);
357
+ }, [transportFailed]);
341
358
 
342
359
  /**
343
360
  * Queue the next ICE restart after `delay`, or do nothing when an attempt
@@ -670,6 +687,13 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
670
687
  // Nothing held: the reopen policy owns recovery, and a
671
688
  // dead session's late revocation must not end it.
672
689
  if (held == null || !current) return;
690
+ // A lost path is an outage, not a verdict: the reopen
691
+ // policy asks for fresh authority. Any other code, or
692
+ // none, is the session's authority ending.
693
+ if (event.code === 'transport') {
694
+ transportFailed(event.reason);
695
+ return;
696
+ }
673
697
  void teardown(event.reason, false);
674
698
  update({ status: 'ended', failure: { code: 'revoked', message: event.reason } });
675
699
  return;
@@ -745,7 +769,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
745
769
  await endRemote(openedResult, authorization.signaling);
746
770
  }
747
771
  }
748
- }, [discardSession, endRemote, refuse, teardown, update, onTransportState]);
772
+ }, [discardSession, endRemote, refuse, teardown, transportFailed, update, onTransportState]);
749
773
 
750
774
  /**
751
775
  * Open (or reopen) the session with fresh authority. A deliberate call
@@ -844,7 +868,12 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
844
868
  if (reply.kind === 'revoked') {
845
869
  // The engine ends the data channel with a revocation when
846
870
  // the session is replaced or closed; it is the only notice
847
- // the client gets, so it has to end the session here.
871
+ // the client gets, so it has to end the session here —
872
+ // unless the path was lost, which a reopen heals.
873
+ if (reply.code === 'transport') {
874
+ transportFailed(reply.reason);
875
+ return;
876
+ }
848
877
  void teardown(reply.reason, false);
849
878
  update({ status: 'ended', failure: { code: 'revoked', message: reply.reason } });
850
879
  return;
@@ -935,6 +964,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
935
964
  // native handle that makes `connect()` a no-op.
936
965
  const owner = signaling.current;
937
966
  const openedRef = opened.current;
967
+ cancelRestart();
938
968
  discardSession();
939
969
  void endRemote(openedRef, owner);
940
970
  const wait = backoffDelay(REOPEN_BACKOFF_MS, attempts.current);
@@ -945,7 +975,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
945
975
  reconnectTimer.current = null;
946
976
  void establish();
947
977
  }, wait);
948
- }, [establish, discardSession, endRemote, snapshot.status, snapshot.failure, update]);
978
+ }, [establish, cancelRestart, discardSession, endRemote, snapshot.status, snapshot.failure, update]);
949
979
 
950
980
  useEffect(() => () => {
951
981
  cancelReconnect();
@@ -966,12 +996,50 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
966
996
  // A phone that goes to the background must not leave a remote button held:
967
997
  // the desktop cannot know the finger left the glass. This is the same
968
998
  // release path a lost control channel takes.
999
+ //
1000
+ // Coming back, a session that was away long enough for the path to lapse
1001
+ // reopens without spending restarts on it, once anything queued has had
1002
+ // a moment to arrive; one already waiting to reopen stops waiting: the
1003
+ // app was suspended, so no timer or peer event can be trusted to have run.
969
1004
  useEffect(() => {
1005
+ let resumeTimer: ReturnType<typeof setTimeout> | null = null;
970
1006
  const subscription = AppState.addEventListener('change', (status) => {
971
- if (status !== 'active') releaseHeld();
1007
+ if (status !== 'active') {
1008
+ // `inactive` is Control Centre or the app switcher: the app
1009
+ // still runs, so only `background` starts the clock.
1010
+ if (status === 'background') awaySince.current ??= Date.now();
1011
+ releaseHeld();
1012
+ return;
1013
+ }
1014
+ const away = awaySince.current === null ? 0 : Date.now() - awaySince.current;
1015
+ awaySince.current = null;
1016
+ const current = statusRef.current;
1017
+ const waiting = (current === 'reconnecting' && nativeRef.current == null)
1018
+ || (current === 'failed' && snapshotRef.current.failure?.code === 'transport');
1019
+ if (waiting) {
1020
+ cancelReconnect();
1021
+ void connect();
1022
+ return;
1023
+ }
1024
+ const id = nativeRef.current;
1025
+ if (away < BACKGROUND_REOPEN_MS || id == null) return;
1026
+ // ponytail: a fixed drain window; a revocation queued behind it
1027
+ // is dropped with the old session, as any reopen drops one.
1028
+ if (resumeTimer !== null) clearTimeout(resumeTimer);
1029
+ resumeTimer = setTimeout(() => {
1030
+ resumeTimer = null;
1031
+ const now = statusRef.current;
1032
+ if (nativeRef.current !== id) return;
1033
+ if (now !== 'live' && now !== 'connecting' && now !== 'reconnecting') return;
1034
+ attempts.current = 0;
1035
+ transportFailed('the app was in the background; reopening the session');
1036
+ }, RESUME_DRAIN_MS);
972
1037
  });
973
- return () => subscription.remove();
974
- }, [releaseHeld]);
1038
+ return () => {
1039
+ if (resumeTimer !== null) clearTimeout(resumeTimer);
1040
+ subscription.remove();
1041
+ };
1042
+ }, [releaseHeld, cancelReconnect, connect, transportFailed]);
975
1043
 
976
1044
  return useMemo<DesktopSession>(() => ({
977
1045
  snapshot,
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The app's `react-native-webrtc`, evaluated on the first call rather than
3
+ * with the package: importing the package must not pull the native WebRTC
4
+ * libraries into the application's first paint. Metro bundles an inline
5
+ * `require` and runs it only when reached; a require by a name built at
6
+ * runtime would not be bundled at all.
7
+ */
8
+ export function requireWebRTC(): unknown {
9
+ return require('react-native-webrtc');
10
+ }