@desklink/react-native 0.2.0 → 0.3.1

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.
@@ -4,6 +4,7 @@ import { AppState } from 'react-native';
4
4
  import type { NativeDesklinkModule } from './native';
5
5
  import type {
6
6
  ControlMessage,
7
+ CursorSample,
7
8
  IceServerConfig,
8
9
  Permission,
9
10
  SessionFailure,
@@ -54,6 +55,18 @@ const RESTART_REPLY_MS = 5000;
54
55
  * 8 s costs nothing next to a live desktop.
55
56
  */
56
57
  export const REOPEN_BACKOFF_MS = [1000, 2000, 4000, 8000, 8000, 8000, 8000, 8000];
58
+ /**
59
+ * Away from the foreground this long, the path is presumed dead: ICE consent
60
+ * lapses after about 30 s (RFC 7675), and the engine then revokes the session.
61
+ * A return after that reopens at once instead of spending restarts on it.
62
+ */
63
+ export const BACKGROUND_REOPEN_MS = 30_000;
64
+ /**
65
+ * A resumed app hears what queued up while it was suspended just after it
66
+ * hears it is active again. A revocation among it decides whether a reopen is
67
+ * allowed at all, so the long-absence reopen waits this long for it first.
68
+ */
69
+ const RESUME_DRAIN_MS = 750;
57
70
 
58
71
  /** The delay before attempt `n` (0-based), or null when the schedule is spent. */
59
72
  export function backoffDelay(schedule: readonly number[], attempt: number): number | null {
@@ -96,12 +109,12 @@ export function connectionStatusFor(
96
109
  }
97
110
 
98
111
  /**
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.
112
+ * With no app-supplied ICE servers, ICE only finds host candidates, which need
113
+ * a directly reachable desktop. A client with no usable route is told that
114
+ * plainly rather than left on a spinner.
102
115
  */
103
116
  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.';
117
+ '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
118
 
106
119
  /**
107
120
  * The engine's and host's own refusal tokens, mapped to this package's failure
@@ -166,6 +179,7 @@ export interface DesktopSessionOptions {
166
179
  */
167
180
  authorize: () => Promise<{ signaling: Signaling; session: SessionOpenRequest }>;
168
181
  onStateChange?: (snapshot: SessionSnapshot) => void;
182
+ onCursor?: (sample: CursorSample) => void;
169
183
  onError?: (failure: SessionFailure) => void;
170
184
  /**
171
185
  * The engine refused one input message and the session carries on, such as
@@ -241,6 +255,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
241
255
  * before the native session exists waits here and is replayed into it.
242
256
  */
243
257
  const pendingOffer = useRef<{ sdp: string; sessionId?: string } | null>(null);
258
+ const pendingCursorEvents = useRef<CursorSample[]>([]);
244
259
  const pendingCandidates = useRef<Array<{ candidate: string; sdpMid: string | null; sdpMLineIndex: number | null; sessionId?: string }>>([]);
245
260
  const attempts = useRef(0);
246
261
  /**
@@ -275,6 +290,8 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
275
290
  /** The latest snapshot; mirrored eagerly so timers read exact state. */
276
291
  const snapshotRef = useRef<SessionSnapshot>(IDLE);
277
292
  const diagnostics = useRef<Record<string, string | number | boolean>>({});
293
+ /** When the app left the foreground, or null while it is active. */
294
+ const awaySince = useRef<number | null>(null);
278
295
  /** Guards every asynchronous callback against a session that already ended. */
279
296
  const generationToken = useRef(0);
280
297
  const optionsRef = useRef(options);
@@ -327,17 +344,20 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
327
344
  const scheduleRestartRef = useRef<(delay: number) => void>(() => undefined);
328
345
 
329
346
  /**
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.
347
+ * Spend one restart from the backoff schedule, or reopen when the schedule
348
+ * is spent: a peer may never report `failed` (a resumed phone often does
349
+ * not), and waiting for it would leave the session reconnecting forever.
333
350
  */
334
351
  const spendRestartAttempt = useCallback((): void => {
335
352
  if (statusRef.current !== 'reconnecting' || nativeRef.current == null) return;
336
353
  const wait = backoffDelay(RESTART_BACKOFF_MS, restartAttempts.current);
337
- if (wait === null) return;
354
+ if (wait === null) {
355
+ transportFailed('the desktop did not come back; reopening the session');
356
+ return;
357
+ }
338
358
  restartAttempts.current += 1;
339
359
  scheduleRestartRef.current(wait);
340
- }, []);
360
+ }, [transportFailed]);
341
361
 
342
362
  /**
343
363
  * Queue the next ICE restart after `delay`, or do nothing when an attempt
@@ -555,6 +575,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
555
575
  pendingClipboard.current.clear();
556
576
  pendingOffer.current = null;
557
577
  pendingCandidates.current = [];
578
+ pendingCursorEvents.current = [];
558
579
  restartGraceUntil.current = 0;
559
580
  restartCycles.current = 0;
560
581
  opened.current = null;
@@ -598,6 +619,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
598
619
  const establish = useCallback(async () => {
599
620
  if (nativeRef.current != null) return;
600
621
  const token = ++generationToken.current;
622
+ pendingCursorEvents.current = [];
601
623
  if (!(await loadPlatform())) {
602
624
  if (token === generationToken.current) refuse('this build cannot show a desktop surface');
603
625
  return;
@@ -655,6 +677,13 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
655
677
  event.candidate.sdpMLineIndex ?? null,
656
678
  );
657
679
  return;
680
+ case 'cursor':
681
+ if (held == null) {
682
+ pendingCursorEvents.current.push(event);
683
+ return;
684
+ }
685
+ if (current) optionsRef.current.onCursor?.(event);
686
+ return;
658
687
  case 'state':
659
688
  if (!current) return;
660
689
  diagnostics.current.transport = event.transport;
@@ -670,6 +699,13 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
670
699
  // Nothing held: the reopen policy owns recovery, and a
671
700
  // dead session's late revocation must not end it.
672
701
  if (held == null || !current) return;
702
+ // A lost path is an outage, not a verdict: the reopen
703
+ // policy asks for fresh authority. Any other code, or
704
+ // none, is the session's authority ending.
705
+ if (event.code === 'transport') {
706
+ transportFailed(event.reason);
707
+ return;
708
+ }
673
709
  void teardown(event.reason, false);
674
710
  update({ status: 'ended', failure: { code: 'revoked', message: event.reason } });
675
711
  return;
@@ -683,6 +719,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
683
719
  openedResult = await authorization.signaling.request<SessionOpenResult>('session.open', {
684
720
  permissions: authorization.session.permissions,
685
721
  max_width: authorization.session.maxWidth,
722
+ ...(authorization.session.cursor === undefined ? {} : { cursor: authorization.session.cursor }),
686
723
  max_height: authorization.session.maxHeight,
687
724
  bitrate_kbps: authorization.session.bitrateKbps,
688
725
  max_fps: authorization.session.maxFps,
@@ -701,6 +738,9 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
701
738
  try {
702
739
  if (token !== generationToken.current) return;
703
740
  opened.current = openedResult;
741
+ for (const sample of pendingCursorEvents.current.splice(0)) {
742
+ if (sample.sessionId === openedResult.sessionId) optionsRef.current.onCursor?.(sample);
743
+ }
704
744
  update({ geometry: openedResult.geometry, status: 'connecting' });
705
745
  if (token !== generationToken.current) return;
706
746
 
@@ -745,7 +785,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
745
785
  await endRemote(openedResult, authorization.signaling);
746
786
  }
747
787
  }
748
- }, [discardSession, endRemote, refuse, teardown, update, onTransportState]);
788
+ }, [discardSession, endRemote, refuse, teardown, transportFailed, update, onTransportState]);
749
789
 
750
790
  /**
751
791
  * Open (or reopen) the session with fresh authority. A deliberate call
@@ -844,7 +884,12 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
844
884
  if (reply.kind === 'revoked') {
845
885
  // The engine ends the data channel with a revocation when
846
886
  // the session is replaced or closed; it is the only notice
847
- // the client gets, so it has to end the session here.
887
+ // the client gets, so it has to end the session here —
888
+ // unless the path was lost, which a reopen heals.
889
+ if (reply.code === 'transport') {
890
+ transportFailed(reply.reason);
891
+ return;
892
+ }
848
893
  void teardown(reply.reason, false);
849
894
  update({ status: 'ended', failure: { code: 'revoked', message: reply.reason } });
850
895
  return;
@@ -935,6 +980,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
935
980
  // native handle that makes `connect()` a no-op.
936
981
  const owner = signaling.current;
937
982
  const openedRef = opened.current;
983
+ cancelRestart();
938
984
  discardSession();
939
985
  void endRemote(openedRef, owner);
940
986
  const wait = backoffDelay(REOPEN_BACKOFF_MS, attempts.current);
@@ -945,7 +991,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
945
991
  reconnectTimer.current = null;
946
992
  void establish();
947
993
  }, wait);
948
- }, [establish, discardSession, endRemote, snapshot.status, snapshot.failure, update]);
994
+ }, [establish, cancelRestart, discardSession, endRemote, snapshot.status, snapshot.failure, update]);
949
995
 
950
996
  useEffect(() => () => {
951
997
  cancelReconnect();
@@ -966,12 +1012,50 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
966
1012
  // A phone that goes to the background must not leave a remote button held:
967
1013
  // the desktop cannot know the finger left the glass. This is the same
968
1014
  // release path a lost control channel takes.
1015
+ //
1016
+ // Coming back, a session that was away long enough for the path to lapse
1017
+ // reopens without spending restarts on it, once anything queued has had
1018
+ // a moment to arrive; one already waiting to reopen stops waiting: the
1019
+ // app was suspended, so no timer or peer event can be trusted to have run.
969
1020
  useEffect(() => {
1021
+ let resumeTimer: ReturnType<typeof setTimeout> | null = null;
970
1022
  const subscription = AppState.addEventListener('change', (status) => {
971
- if (status !== 'active') releaseHeld();
1023
+ if (status !== 'active') {
1024
+ // `inactive` is Control Centre or the app switcher: the app
1025
+ // still runs, so only `background` starts the clock.
1026
+ if (status === 'background') awaySince.current ??= Date.now();
1027
+ releaseHeld();
1028
+ return;
1029
+ }
1030
+ const away = awaySince.current === null ? 0 : Date.now() - awaySince.current;
1031
+ awaySince.current = null;
1032
+ const current = statusRef.current;
1033
+ const waiting = (current === 'reconnecting' && nativeRef.current == null)
1034
+ || (current === 'failed' && snapshotRef.current.failure?.code === 'transport');
1035
+ if (waiting) {
1036
+ cancelReconnect();
1037
+ void connect();
1038
+ return;
1039
+ }
1040
+ const id = nativeRef.current;
1041
+ if (away < BACKGROUND_REOPEN_MS || id == null) return;
1042
+ // ponytail: a fixed drain window; a revocation queued behind it
1043
+ // is dropped with the old session, as any reopen drops one.
1044
+ if (resumeTimer !== null) clearTimeout(resumeTimer);
1045
+ resumeTimer = setTimeout(() => {
1046
+ resumeTimer = null;
1047
+ const now = statusRef.current;
1048
+ if (nativeRef.current !== id) return;
1049
+ if (now !== 'live' && now !== 'connecting' && now !== 'reconnecting') return;
1050
+ attempts.current = 0;
1051
+ transportFailed('the app was in the background; reopening the session');
1052
+ }, RESUME_DRAIN_MS);
972
1053
  });
973
- return () => subscription.remove();
974
- }, [releaseHeld]);
1054
+ return () => {
1055
+ if (resumeTimer !== null) clearTimeout(resumeTimer);
1056
+ subscription.remove();
1057
+ };
1058
+ }, [releaseHeld, cancelReconnect, connect, transportFailed]);
975
1059
 
976
1060
  return useMemo<DesktopSession>(() => ({
977
1061
  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
+ }