@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.
- package/README.md +162 -16
- package/app.plugin.js +16 -0
- package/expo-module.config.json +4 -1
- package/ios/DesklinkInput.podspec +22 -0
- package/ios/DesklinkInputModule.swift +20 -0
- package/ios/DesklinkInputView.swift +209 -0
- package/package.json +9 -3
- package/src/DesktopView.ios.spec.tsx +354 -0
- package/src/DesktopView.ios.tsx +215 -29
- package/src/hardwareKeys.ts +61 -0
- package/src/native.ios.ts +53 -5
- package/src/protocol.ts +6 -2
- package/src/useDesktopSession.ts +83 -15
- package/src/webrtc.ios.ts +10 -0
package/src/useDesktopSession.ts
CHANGED
|
@@ -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
|
-
*
|
|
100
|
-
*
|
|
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
|
|
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
|
|
331
|
-
*
|
|
332
|
-
*
|
|
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)
|
|
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')
|
|
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 () =>
|
|
974
|
-
|
|
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
|
+
}
|