@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.
- package/README.md +174 -18
- 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 +364 -0
- package/src/DesktopView.ios.tsx +217 -30
- package/src/hardwareKeys.ts +61 -0
- package/src/native.ios.ts +53 -5
- package/src/pointer.png +0 -0
- package/src/pointer.png.d.ts +2 -0
- package/src/pointer.svg +7 -0
- package/src/pointer@2x.png +0 -0
- package/src/pointer@3x.png +0 -0
- package/src/protocol.ts +17 -2
- package/src/useDesktopSession.ts +99 -15
- package/src/webrtc.ios.ts +10 -0
package/src/useDesktopSession.ts
CHANGED
|
@@ -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
|
-
*
|
|
100
|
-
*
|
|
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
|
|
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
|
|
331
|
-
*
|
|
332
|
-
*
|
|
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)
|
|
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')
|
|
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 () =>
|
|
974
|
-
|
|
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
|
+
}
|