@desklink/react-native 0.1.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.
@@ -0,0 +1,661 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { AppState } from 'react-native';
3
+
4
+ import type { NativeDesklinkModule } from './native';
5
+ import type {
6
+ ControlMessage,
7
+ IceServerConfig,
8
+ Permission,
9
+ SessionFailure,
10
+ SessionOpenRequest,
11
+ SessionOpenResult,
12
+ SessionSnapshot,
13
+ Signaling,
14
+ SurfaceGeometry,
15
+ } from './protocol';
16
+ import { PROTOCOL_VERSION, parseControlReply } from './protocol';
17
+ import {
18
+ NO_MODIFIERS,
19
+ afterKey,
20
+ armedModifiers,
21
+ tapModifier as nextModifiers,
22
+ typeWithModifiers,
23
+ type StickyModifier,
24
+ type StickyModifiers,
25
+ } from './stickyModifiers';
26
+
27
+ /** How long a clipboard round trip may take before it is reported as lost. */
28
+ const CLIPBOARD_TIMEOUT_MS = 4000;
29
+
30
+ /** One reconnect attempt per session; beyond that the caller must ask again. */
31
+ const MAX_RECONNECT_ATTEMPTS = 1;
32
+
33
+ /**
34
+ * Without a relay there is no route between two networks: ICE only ever finds
35
+ * host candidates. A client that cannot reach the desktop is told that plainly
36
+ * rather than left on a spinner.
37
+ */
38
+ const UNREACHABLE_DESKTOP =
39
+ 'The phone must reach the desktop directly, on the same network or its tailnet. Other networks need a relay, which is not available yet.';
40
+
41
+ /**
42
+ * The engine's and host's own refusal tokens, mapped to this package's failure
43
+ * codes. A token the engine did not send is a deliberate refusal, not a network
44
+ * blip, so it is not treated as retryable.
45
+ */
46
+ const OPEN_FAILURE_CODES: Record<string, SessionFailure['code']> = {
47
+ permission: 'permission',
48
+ 'not-authorized': 'permission',
49
+ 'consent-timeout': 'consent',
50
+ 'no-screen': 'no-screen',
51
+ 'input-unavailable': 'input-unavailable',
52
+ 'unsupported-codec': 'unsupported-codec',
53
+ encode: 'unsupported-codec',
54
+ 'incompatible-version': 'incompatible-version',
55
+ 'host-contract-mismatch': 'incompatible-version',
56
+ 'unsupported-protocol': 'incompatible-version',
57
+ 'source-changed': 'source-changed',
58
+ transport: 'transport',
59
+ };
60
+
61
+ function classifyOpenFailure(error: unknown): SessionFailure['code'] {
62
+ const code = (error as { code?: unknown } | null)?.code;
63
+ if (typeof code === 'string') return OPEN_FAILURE_CODES[code] ?? 'platform';
64
+ // No token at all: fall back to the message, treating anything that is not a
65
+ // permission refusal as a transport failure a retry may fix.
66
+ const message = error instanceof Error ? error.message : '';
67
+ return /permission|not authorized/i.test(message) ? 'permission' : 'transport';
68
+ }
69
+
70
+ /**
71
+ * The platform module, loaded the first time a desktop is opened.
72
+ *
73
+ * It holds the session, the renderer and the input bridge — the largest thing in
74
+ * this package — and most sessions never open a desktop, so it must not sit in
75
+ * the application's initial bundle. Everything below uses this binding as it did
76
+ * when it was a static import; the only change is when it becomes non-null.
77
+ */
78
+ let nativeDesklink: NativeDesklinkModule | null = null;
79
+
80
+ async function loadPlatform(): Promise<boolean> {
81
+ if (nativeDesklink !== null) return true;
82
+ try {
83
+ nativeDesklink = (await import('./native')).nativeDesklink;
84
+ } catch {
85
+ return false;
86
+ }
87
+ return nativeDesklink !== null;
88
+ }
89
+
90
+ export interface DesktopSessionOptions {
91
+ /**
92
+ * Ask the owning application for a fresh, short-lived authorization and the
93
+ * authenticated channel to the host engine. Called for the first connection
94
+ * and again for a reconnect: the package never reuses authority it was not
95
+ * granted again.
96
+ */
97
+ authorize: () => Promise<{ signaling: Signaling; session: SessionOpenRequest }>;
98
+ onStateChange?: (snapshot: SessionSnapshot) => void;
99
+ onError?: (failure: SessionFailure) => void;
100
+ /**
101
+ * The engine refused one input message and the session carries on, such as
102
+ * text the desktop's layout cannot type (`text-unsupported`) or too much of
103
+ * it at once (`text-too-large`).
104
+ */
105
+ onRejected?: (rejection: { code: string; message: string }) => void;
106
+ }
107
+
108
+ export interface DesktopSession {
109
+ snapshot: SessionSnapshot;
110
+ /** The native session handle, or null when nothing is open. */
111
+ nativeId: string | null;
112
+ connect: () => Promise<void>;
113
+ close: (reason?: string) => Promise<void>;
114
+ showKeyboard: () => void;
115
+ hideKeyboard: () => void;
116
+ /** Copy the desktop's clipboard to the phone; the caller places it locally. */
117
+ copyRemoteToLocal: () => Promise<{ text: string; truncated: boolean }>;
118
+ /** Send the phone's clipboard text to the desktop. */
119
+ pasteLocalToRemote: (text: string) => Promise<void>;
120
+ /** Release anything the desktop is holding, without ending the session. */
121
+ releaseHeld: () => void;
122
+ setInputEnabled: (enabled: boolean) => void;
123
+ /** Show the whole desktop again after the user zoomed in. */
124
+ fitToView: () => void;
125
+ /** Hold the screen in landscape while the desktop is shown, or follow the phone again. */
126
+ setOrientation: (mode: 'landscape' | 'auto') => void;
127
+ send: (message: ControlMessage) => void;
128
+ /** Ctrl and Shift as sticky keys: off, armed for the next key, or locked. */
129
+ modifiers: StickyModifiers;
130
+ /** Tap a sticky modifier: off, then armed for the next key, then locked, then off. */
131
+ tapModifier: (name: StickyModifier) => void;
132
+ /**
133
+ * Press a named key with the armed modifiers; the returned function releases
134
+ * it. A tap is `pressKey(name)()`. The release carries the same modifiers,
135
+ * because the engine lets go of exactly the modifiers the message names.
136
+ */
137
+ pressKey: (name: string) => () => void;
138
+ }
139
+
140
+ const IDLE: SessionSnapshot = {
141
+ status: 'idle',
142
+ geometry: null,
143
+ presented: false,
144
+ failure: null,
145
+ diagnostics: {},
146
+ };
147
+
148
+ /**
149
+ * Desktop session lifecycle.
150
+ *
151
+ * The default is idle: nothing connects until the caller asks, because opening a
152
+ * remote desktop is a user action and also because capture needs the host's
153
+ * consent. The hook owns negotiation, readiness, reconnect and the clipboard
154
+ * round trip; the view owns pixels and gestures.
155
+ */
156
+ export function useDesktopSession(options: DesktopSessionOptions): DesktopSession {
157
+ const [snapshot, setSnapshot] = useState<SessionSnapshot>(IDLE);
158
+ const [nativeId, setNativeId] = useState<string | null>(null);
159
+ const [modifiers, setModifiers] = useState<StickyModifiers>(NO_MODIFIERS);
160
+ /** Read by input as it arrives, which can be faster than a render. */
161
+ const modifiersRef = useRef<StickyModifiers>(NO_MODIFIERS);
162
+
163
+ const opened = useRef<SessionOpenResult | null>(null);
164
+ const signaling = useRef<Signaling | null>(null);
165
+ const nativeRef = useRef<string | null>(null);
166
+ const inputEnabled = useRef(false);
167
+ const pendingClipboard = useRef(new Map<string, (reply: { text: string; truncated: boolean; error?: string }) => void>());
168
+ const attempts = useRef(0);
169
+ /**
170
+ * The pending reconnect attempt. It lives here rather than in the effect
171
+ * that schedules it: that effect's own `reconnecting` update changes one of
172
+ * its dependencies, so React would run its cleanup and cancel the retry.
173
+ */
174
+ const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
175
+ const diagnostics = useRef<Record<string, string | number | boolean>>({});
176
+ /** Guards every asynchronous callback against a session that already ended. */
177
+ const generationToken = useRef(0);
178
+ const optionsRef = useRef(options);
179
+ optionsRef.current = options;
180
+
181
+ const update = useCallback((patch: Partial<SessionSnapshot>) => {
182
+ setSnapshot((current) => {
183
+ const next = { ...current, ...patch };
184
+ optionsRef.current.onStateChange?.(next);
185
+ return next;
186
+ });
187
+ }, []);
188
+
189
+ const fail = useCallback((failure: SessionFailure) => {
190
+ update({ status: 'failed', failure });
191
+ optionsRef.current.onError?.(failure);
192
+ }, [update]);
193
+
194
+ const refuse = useCallback((message: string, code: SessionFailure['code'] = 'platform') => {
195
+ fail({ code, message });
196
+ }, [fail]);
197
+
198
+ const send = useCallback((message: ControlMessage) => {
199
+ const id = nativeRef.current;
200
+ if (id == null || nativeDesklink === null || (!inputEnabled.current && message.kind !== 'release_all')) return;
201
+ nativeDesklink.sendControl(id, JSON.stringify(message));
202
+ }, []);
203
+
204
+ const setInputEnabled = useCallback((enabled: boolean) => {
205
+ inputEnabled.current = enabled;
206
+ const id = nativeRef.current;
207
+ if (!enabled && modifiersRef.current !== NO_MODIFIERS) {
208
+ modifiersRef.current = NO_MODIFIERS;
209
+ setModifiers(NO_MODIFIERS);
210
+ if (id != null) nativeDesklink?.captureKeyboard(id, false);
211
+ }
212
+ if (id != null) nativeDesklink?.setInputEnabled(id, enabled);
213
+ }, []);
214
+
215
+ const releaseHeld = useCallback(() => {
216
+ send({ kind: 'release_all' });
217
+ }, [send]);
218
+
219
+ const applyModifiers = useCallback((next: StickyModifiers) => {
220
+ const current = modifiersRef.current;
221
+ if (next.Control === current.Control && next.Shift === current.Shift) return;
222
+ modifiersRef.current = next;
223
+ setModifiers(next);
224
+ // The phone's keyboard is the session's own only while a modifier waits
225
+ // for its key; otherwise typing goes straight to the desktop.
226
+ const id = nativeRef.current;
227
+ if (id != null) nativeDesklink?.captureKeyboard(id, armedModifiers(next).length > 0);
228
+ }, []);
229
+
230
+ const tapModifier = useCallback((name: StickyModifier) => {
231
+ applyModifiers(nextModifiers(modifiersRef.current, name));
232
+ }, [applyModifiers]);
233
+
234
+ const pressKey = useCallback((name: string) => {
235
+ const held = armedModifiers(modifiersRef.current);
236
+ send({ kind: 'key', name, modifiers: held, down: true });
237
+ applyModifiers(afterKey(modifiersRef.current));
238
+ let pressed = true;
239
+ return () => {
240
+ if (!pressed) return;
241
+ pressed = false;
242
+ send({ kind: 'key', name, modifiers: held, down: false });
243
+ };
244
+ }, [send, applyModifiers]);
245
+
246
+ const typeText = useCallback((text: string) => {
247
+ const { messages, next } = typeWithModifiers(text, modifiersRef.current);
248
+ for (const message of messages) send(message);
249
+ applyModifiers(next);
250
+ }, [send, applyModifiers]);
251
+
252
+ const showKeyboard = useCallback(() => {
253
+ const id = nativeRef.current;
254
+ if (id != null) nativeDesklink?.showKeyboard(id);
255
+ }, []);
256
+
257
+ const hideKeyboard = useCallback(() => {
258
+ const id = nativeRef.current;
259
+ if (id != null) nativeDesklink?.hideKeyboard(id);
260
+ }, []);
261
+
262
+ const fitToView = useCallback(() => {
263
+ const id = nativeRef.current;
264
+ if (id != null) nativeDesklink?.fitToView(id);
265
+ }, []);
266
+
267
+ const setOrientation = useCallback((mode: 'landscape' | 'auto') => {
268
+ nativeDesklink?.setOrientation(mode);
269
+ }, []);
270
+
271
+ /** Drop everything this process holds for a session the engine has ended. */
272
+ const discardSession = useCallback(() => {
273
+ generationToken.current += 1;
274
+ const id = nativeRef.current;
275
+ nativeRef.current = null;
276
+ inputEnabled.current = false;
277
+ for (const resolve of pendingClipboard.current.values()) resolve({ text: '', truncated: false, error: 'the session ended' });
278
+ pendingClipboard.current.clear();
279
+ opened.current = null;
280
+ signaling.current = null;
281
+ setNativeId(null);
282
+ // An armed modifier belongs to the session it was armed in.
283
+ modifiersRef.current = NO_MODIFIERS;
284
+ setModifiers(NO_MODIFIERS);
285
+ if (id != null) nativeDesklink?.closeSession(id);
286
+ }, []);
287
+
288
+ /** Tell the host a session is over; a failure is nothing the user can act on. */
289
+ const endRemote = useCallback(async (
290
+ openedRef: SessionOpenResult | null,
291
+ owner: Signaling | null,
292
+ ): Promise<void> => {
293
+ if (openedRef == null || owner == null) return;
294
+ try {
295
+ await owner.request('session.close', {
296
+ session_id: openedRef.sessionId,
297
+ generation: openedRef.generation,
298
+ });
299
+ } catch {
300
+ // The engine already dropped it; nothing useful to report.
301
+ }
302
+ }, []);
303
+
304
+ const teardown = useCallback(async (reason: string, closeRemote: boolean) => {
305
+ const openedRef = opened.current;
306
+ const owner = signaling.current;
307
+ discardSession();
308
+ if (closeRemote) await endRemote(openedRef, owner);
309
+ update({ status: 'ended', presented: false, failure: null });
310
+ void reason;
311
+ }, [endRemote, discardSession, update]);
312
+
313
+ const connect = useCallback(async () => {
314
+ if (nativeRef.current != null) return;
315
+ const token = ++generationToken.current;
316
+ if (!(await loadPlatform())) {
317
+ if (token === generationToken.current) refuse('this build cannot show a desktop surface');
318
+ return;
319
+ }
320
+ if (token !== generationToken.current) return;
321
+ update({ status: 'opening', failure: null, presented: false });
322
+
323
+ let authorization: { signaling: Signaling; session: SessionOpenRequest };
324
+ try {
325
+ authorization = await optionsRef.current.authorize();
326
+ } catch (error) {
327
+ refuse(error instanceof Error ? error.message : 'the desktop was not authorized', 'permission');
328
+ return;
329
+ }
330
+ if (token !== generationToken.current) return;
331
+ signaling.current = authorization.signaling;
332
+
333
+ let openedResult: SessionOpenResult;
334
+ try {
335
+ openedResult = await authorization.signaling.request<SessionOpenResult>('session.open', {
336
+ permissions: authorization.session.permissions,
337
+ max_width: authorization.session.maxWidth,
338
+ max_height: authorization.session.maxHeight,
339
+ bitrate_kbps: authorization.session.bitrateKbps,
340
+ max_fps: authorization.session.maxFps,
341
+ ice_servers: serializeIceServers(authorization.session.iceServers),
342
+ restore_token: authorization.session.restoreToken,
343
+ ttl_seconds: authorization.session.ttlSeconds,
344
+ });
345
+ } catch (error) {
346
+ const message = error instanceof Error ? error.message : 'the desktop could not start';
347
+ refuse(message, classifyOpenFailure(error));
348
+ return;
349
+ }
350
+ let established = false;
351
+ try {
352
+ if (token !== generationToken.current) return;
353
+ opened.current = openedResult;
354
+ update({ geometry: openedResult.geometry, status: 'connecting' });
355
+ if (token !== generationToken.current) return;
356
+
357
+ const platform = nativeDesklink;
358
+ const id = platform === null ? null : platform.createSession(
359
+ JSON.stringify(authorization.session.iceServers ?? []),
360
+ );
361
+ if (id == null) {
362
+ refuse('the native session could not be created');
363
+ return;
364
+ }
365
+ if (token !== generationToken.current) {
366
+ platform?.closeSession(id);
367
+ return;
368
+ }
369
+ nativeRef.current = id;
370
+ platform?.setInputEnabled(id, inputEnabled.current);
371
+ setNativeId(id);
372
+
373
+ authorization.signaling.subscribe((event) => {
374
+ if (token !== generationToken.current) return;
375
+ const native = nativeRef.current;
376
+ if (native == null) return;
377
+ switch (event.kind) {
378
+ case 'description':
379
+ if (event.description.type !== 'offer') return;
380
+ nativeDesklink?.setRemoteDescription(native, 'offer', event.description.sdp);
381
+ return;
382
+ case 'candidate':
383
+ nativeDesklink?.addRemoteCandidate(
384
+ native,
385
+ event.candidate.candidate,
386
+ event.candidate.sdpMid ?? null,
387
+ event.candidate.sdpMLineIndex ?? null,
388
+ );
389
+ return;
390
+ case 'state':
391
+ diagnostics.current.transport = event.transport;
392
+ update({ diagnostics: { ...diagnostics.current } });
393
+ return;
394
+ case 'revoked':
395
+ void teardown(event.reason, false);
396
+ update({ status: 'ended', failure: { code: 'revoked', message: event.reason } });
397
+ return;
398
+ default:
399
+ return;
400
+ }
401
+ });
402
+ if (token === generationToken.current) established = true;
403
+ } catch (error) {
404
+ if (token === generationToken.current) {
405
+ refuse(error instanceof Error ? error.message : 'the native session could not be created');
406
+ }
407
+ } finally {
408
+ if (!established) {
409
+ if (opened.current === openedResult) discardSession();
410
+ await endRemote(openedResult, authorization.signaling);
411
+ }
412
+ }
413
+ }, [discardSession, endRemote, refuse, teardown, update]);
414
+
415
+ // Native events: answer, candidates, control replies, presentation, failure.
416
+ useEffect(() => {
417
+ let dropped = false;
418
+ let subscription: { remove: () => void } | null = null;
419
+ const subscribe = async (): Promise<void> => {
420
+ // The platform module is loaded on first use, which may be after this
421
+ // effect runs; subscribing before it resolves would drop the listener.
422
+ if (!(await loadPlatform())) return;
423
+ if (dropped) return;
424
+ const platform = nativeDesklink;
425
+ if (platform?.addListener == null) return;
426
+ subscription = platform.addListener('onSessionEvent', (event) => {
427
+ if (dropped) return;
428
+ if (event.sessionId !== nativeRef.current) return;
429
+ const openedRef = opened.current;
430
+ const channel = signaling.current;
431
+ switch (event.name) {
432
+ case 'answer': {
433
+ const sdp = String(event.payload.sdp ?? '');
434
+ if (openedRef == null || channel == null || sdp === '') return;
435
+ channel.request('session.description', {
436
+ session_id: openedRef.sessionId,
437
+ generation: openedRef.generation,
438
+ description: { type: 'answer', sdp },
439
+ }).catch(() => refuse('the desktop refused our answer', 'transport'));
440
+ return;
441
+ }
442
+ case 'candidate': {
443
+ if (openedRef == null || channel == null) return;
444
+ channel.request('session.candidate', {
445
+ session_id: openedRef.sessionId,
446
+ generation: openedRef.generation,
447
+ candidate: String(event.payload.candidate ?? ''),
448
+ sdpMid: (event.payload.sdpMid as string | null) ?? null,
449
+ sdpMLineIndex: (event.payload.sdpMLineIndex as number | null) ?? null,
450
+ }).catch(() => undefined);
451
+ return;
452
+ }
453
+ case 'track':
454
+ // Readiness is not "a track arrived": it is a frame on screen.
455
+ return;
456
+ case 'presented':
457
+ attempts.current = 0;
458
+ update({ status: 'live', presented: true, failure: null });
459
+ return;
460
+ case 'control': {
461
+ const reply = parseControlReply(String(event.payload.message ?? ''));
462
+ if (reply == null) return;
463
+ if (reply.kind === 'hello') {
464
+ diagnostics.current.protocol = reply.protocol;
465
+ if (reply.protocol !== PROTOCOL_VERSION) {
466
+ refuse('this app and the host engine speak different protocol versions', 'incompatible-version');
467
+ void teardown('protocol mismatch', true);
468
+ return;
469
+ }
470
+ update({ geometry: reply.geometry });
471
+ // The native view needs the surface size to map touches.
472
+ const id = nativeRef.current;
473
+ if (id != null) {
474
+ nativeDesklink?.setSurfaceSize(id, reply.geometry.encoded.width, reply.geometry.encoded.height);
475
+ }
476
+ return;
477
+ }
478
+ if (reply.kind === 'clipboard') {
479
+ pendingClipboard.current.get(reply.request)?.({ text: reply.text, truncated: reply.truncated === true, error: reply.error });
480
+ pendingClipboard.current.delete(reply.request);
481
+ return;
482
+ }
483
+ if (reply.kind === 'rejected') {
484
+ diagnostics.current.lastRejection = `${reply.code}: ${reply.message}`;
485
+ update({ diagnostics: { ...diagnostics.current } });
486
+ optionsRef.current.onRejected?.({ code: reply.code, message: reply.message });
487
+ return;
488
+ }
489
+ if (reply.kind === 'revoked') {
490
+ // The engine ends the data channel with a revocation when
491
+ // the session is replaced or closed; it is the only notice
492
+ // the client gets, so it has to end the session here.
493
+ void teardown(reply.reason, false);
494
+ update({ status: 'ended', failure: { code: 'revoked', message: reply.reason } });
495
+ return;
496
+ }
497
+ return;
498
+ }
499
+ case 'keyboard':
500
+ // Typing the platform held back for an armed modifier.
501
+ if (typeof event.payload.key === 'string') pressKey(event.payload.key)();
502
+ else if (typeof event.payload.text === 'string') typeText(event.payload.text);
503
+ return;
504
+ case 'failure':
505
+ refuse(UNREACHABLE_DESKTOP, 'transport');
506
+ return;
507
+ case 'closed':
508
+ return;
509
+ default:
510
+ return;
511
+ }
512
+ });
513
+ };
514
+ void subscribe();
515
+ return () => {
516
+ dropped = true;
517
+ subscription?.remove();
518
+ };
519
+ }, [refuse, teardown, update, pressKey, typeText]);
520
+
521
+ const copyRemoteToLocal = useCallback(async () => {
522
+ if (!inputEnabled.current) throw new Error('Desktop control is off.');
523
+ const id = nativeRef.current;
524
+ if (id == null || nativeDesklink == null) throw new Error('No desktop session is open.');
525
+ const request = randomId();
526
+ const answer = new Promise<{ text: string; truncated: boolean; error?: string }>((resolve) => {
527
+ pendingClipboard.current.set(request, resolve);
528
+ setTimeout(() => {
529
+ if (pendingClipboard.current.delete(request)) resolve({ text: '', truncated: false, error: 'the desktop did not answer' });
530
+ }, CLIPBOARD_TIMEOUT_MS);
531
+ });
532
+ nativeDesklink.sendControl(id, JSON.stringify({ kind: 'clipboard_read', request }));
533
+ const reply = await answer;
534
+ if (reply.error != null) throw new Error(reply.error);
535
+ return { text: reply.text, truncated: reply.truncated };
536
+ }, []);
537
+
538
+ const pasteLocalToRemote = useCallback(async (text: string) => {
539
+ if (!inputEnabled.current) throw new Error('Desktop control is off.');
540
+ const id = nativeRef.current;
541
+ if (id == null || nativeDesklink == null) throw new Error('No desktop session is open.');
542
+ const request = randomId();
543
+ const answer = new Promise<{ text: string; truncated: boolean; error?: string }>((resolve) => {
544
+ pendingClipboard.current.set(request, resolve);
545
+ setTimeout(() => {
546
+ if (pendingClipboard.current.delete(request)) resolve({ text: '', truncated: false, error: 'the desktop did not answer' });
547
+ }, CLIPBOARD_TIMEOUT_MS);
548
+ });
549
+ nativeDesklink.sendControl(id, JSON.stringify({ kind: 'clipboard_write', request, text }));
550
+ const reply = await answer;
551
+ if (reply.error != null) throw new Error(reply.error);
552
+ }, []);
553
+
554
+ const cancelReconnect = useCallback(() => {
555
+ if (reconnectTimer.current !== null) clearTimeout(reconnectTimer.current);
556
+ reconnectTimer.current = null;
557
+ }, []);
558
+
559
+ const close = useCallback(async (reason = 'closed by the user') => {
560
+ cancelReconnect();
561
+ attempts.current = 0;
562
+ await teardown(reason, true);
563
+ }, [cancelReconnect, teardown]);
564
+
565
+ // Reconnect once after a transport failure, with fresh authority: a session
566
+ // that was revoked must not be reopened on the strength of the old grant.
567
+ useEffect(() => {
568
+ if (snapshot.status !== 'failed') return;
569
+ if (snapshot.failure?.code !== 'transport') return;
570
+ // The failed session is dead on the engine side. Drop its handle and
571
+ // close the remote session whether or not another attempt is left: a
572
+ // failure the user can retry by hand must not sit behind a stale
573
+ // native handle that makes `connect()` a no-op.
574
+ const owner = signaling.current;
575
+ const openedRef = opened.current;
576
+ discardSession();
577
+ void endRemote(openedRef, owner);
578
+ if (attempts.current >= MAX_RECONNECT_ATTEMPTS) return;
579
+ attempts.current += 1;
580
+ update({ status: 'reconnecting' });
581
+ reconnectTimer.current = setTimeout(() => {
582
+ reconnectTimer.current = null;
583
+ void connect();
584
+ }, 800);
585
+ }, [connect, discardSession, endRemote, snapshot.status, snapshot.failure, update]);
586
+
587
+ useEffect(() => () => {
588
+ cancelReconnect();
589
+ generationToken.current += 1;
590
+ const id = nativeRef.current;
591
+ nativeRef.current = null;
592
+ inputEnabled.current = false;
593
+ if (id != null) nativeDesklink?.closeSession(id);
594
+ const openedRef = opened.current;
595
+ const owner = signaling.current;
596
+ opened.current = null;
597
+ signaling.current = null;
598
+ void endRemote(openedRef, owner);
599
+ }, [cancelReconnect, endRemote]);
600
+
601
+ // A phone that goes to the background must not leave a remote button held:
602
+ // the desktop cannot know the finger left the glass. This is the same
603
+ // release path a lost control channel takes.
604
+ useEffect(() => {
605
+ const subscription = AppState.addEventListener('change', (status) => {
606
+ if (status !== 'active') releaseHeld();
607
+ });
608
+ return () => subscription.remove();
609
+ }, [releaseHeld]);
610
+
611
+ return useMemo<DesktopSession>(() => ({
612
+ snapshot,
613
+ nativeId,
614
+ connect,
615
+ close,
616
+ showKeyboard,
617
+ hideKeyboard,
618
+ copyRemoteToLocal,
619
+ pasteLocalToRemote,
620
+ releaseHeld,
621
+ setInputEnabled,
622
+ fitToView,
623
+ setOrientation,
624
+ send,
625
+ modifiers,
626
+ tapModifier,
627
+ pressKey,
628
+ }), [
629
+ snapshot,
630
+ nativeId,
631
+ connect,
632
+ close,
633
+ showKeyboard,
634
+ hideKeyboard,
635
+ copyRemoteToLocal,
636
+ pasteLocalToRemote,
637
+ releaseHeld,
638
+ setInputEnabled,
639
+ fitToView,
640
+ setOrientation,
641
+ send,
642
+ modifiers,
643
+ tapModifier,
644
+ pressKey,
645
+ ]);
646
+ }
647
+
648
+ function serializeIceServers(servers: IceServerConfig[] | undefined): Array<Record<string, unknown>> {
649
+ return (servers ?? []).map((server) => ({
650
+ urls: server.urls,
651
+ username: server.username,
652
+ credential: server.credential,
653
+ }));
654
+ }
655
+
656
+ function randomId(): string {
657
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
658
+ }
659
+
660
+ /** The permissions a normal remote-control session asks for. */
661
+ export const CONTROL_PERMISSIONS: Permission[] = ['view', 'control', 'clipboard'];