@desklink/react-native 0.1.1 → 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 +198 -17
- package/android/src/main/java/expo/modules/desklink/DesklinkModule.kt +4 -0
- package/android/src/main/java/expo/modules/desklink/DesktopView.kt +61 -3
- 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 +11 -2
- package/src/DesktopView.ios.spec.tsx +354 -0
- package/src/DesktopView.ios.tsx +244 -27
- package/src/DesktopView.tsx +9 -2
- package/src/DesktopView.web.tsx +14 -2
- package/src/hardwareKeys.ts +61 -0
- package/src/native.ios.ts +53 -5
- package/src/native.web.ts +86 -5
- package/src/protocol.ts +34 -7
- package/src/useDesktopSession.ts +496 -63
- package/src/webrtc.ios.ts +10 -0
package/src/useDesktopSession.ts
CHANGED
|
@@ -27,16 +27,93 @@ import {
|
|
|
27
27
|
/** How long a clipboard round trip may take before it is reported as lost. */
|
|
28
28
|
const CLIPBOARD_TIMEOUT_MS = 4000;
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
30
|
+
/**
|
|
31
|
+
* A drop heals on its own more often than not, so the first ICE restart waits
|
|
32
|
+
* out brief blips; the retries then back off. The whole schedule spans about a
|
|
33
|
+
* minute, so a 30–60 s outage still recovers when the network comes back.
|
|
34
|
+
*/
|
|
35
|
+
const RESTART_AFTER_MS = 2000;
|
|
36
|
+
export const RESTART_BACKOFF_MS = [2000, 4000, 8000, 16000, 30000];
|
|
37
|
+
/**
|
|
38
|
+
* A heartbeat missed this long means the path is stalled, even when ICE has
|
|
39
|
+
* not said so yet. Twice the engine's ping interval, with room for jitter.
|
|
40
|
+
*/
|
|
41
|
+
const STALL_AFTER_MS = 1200;
|
|
42
|
+
/**
|
|
43
|
+
* After the session itself asks for a restart, renegotiation transients are
|
|
44
|
+
* expected: ICE may flap and heartbeats may pause while the new generation
|
|
45
|
+
* comes up. These are not a new outage, so they neither flip the status nor
|
|
46
|
+
* trigger another restart while the window lasts.
|
|
47
|
+
*/
|
|
48
|
+
const RESTART_GRACE_MS = 2500;
|
|
49
|
+
/** How long a restart offer may go unanswered before the next attempt. */
|
|
50
|
+
const RESTART_REPLY_MS = 5000;
|
|
51
|
+
/**
|
|
52
|
+
* Full session reopens back off across about a minute, holding at patient
|
|
53
|
+
* 8 s retries: a return must never wait out a 32 s gap, and an attempt every
|
|
54
|
+
* 8 s costs nothing next to a live desktop.
|
|
55
|
+
*/
|
|
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;
|
|
69
|
+
|
|
70
|
+
/** The delay before attempt `n` (0-based), or null when the schedule is spent. */
|
|
71
|
+
export function backoffDelay(schedule: readonly number[], attempt: number): number | null {
|
|
72
|
+
return attempt < schedule.length ? schedule[attempt]! : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type ConnectionStateName =
|
|
76
|
+
| 'new'
|
|
77
|
+
| 'checking'
|
|
78
|
+
| 'connected'
|
|
79
|
+
| 'completed'
|
|
80
|
+
| 'disconnected'
|
|
81
|
+
| 'failed'
|
|
82
|
+
| 'closed';
|
|
32
83
|
|
|
33
84
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
85
|
+
* What one ICE/engine-transport state means for the session status, or null
|
|
86
|
+
* when it changes nothing. `disconnected` surfaces as `reconnecting` at once —
|
|
87
|
+
* event-driven, so within about a second of the drop — and `connected` moves a
|
|
88
|
+
* session that had shown frames back to `live`. `failed` is terminal and is
|
|
89
|
+
* handled by the failure path, not here.
|
|
90
|
+
*/
|
|
91
|
+
export function connectionStatusFor(
|
|
92
|
+
state: string,
|
|
93
|
+
status: SessionSnapshot['status'],
|
|
94
|
+
presented: boolean,
|
|
95
|
+
): SessionSnapshot['status'] | null {
|
|
96
|
+
switch (state) {
|
|
97
|
+
case 'disconnected':
|
|
98
|
+
return status === 'live' || status === 'connecting' ? 'reconnecting' : null;
|
|
99
|
+
case 'connected':
|
|
100
|
+
case 'completed':
|
|
101
|
+
if (status !== 'reconnecting') return null;
|
|
102
|
+
// Frames were never shown: this is still the first connection, not
|
|
103
|
+
// a recovery, so it stays `connecting` until a frame renders.
|
|
104
|
+
return presented ? 'live' : 'connecting';
|
|
105
|
+
default:
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
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.
|
|
37
114
|
*/
|
|
38
115
|
const UNREACHABLE_DESKTOP =
|
|
39
|
-
'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.';
|
|
40
117
|
|
|
41
118
|
/**
|
|
42
119
|
* The engine's and host's own refusal tokens, mapped to this package's failure
|
|
@@ -58,9 +135,14 @@ const OPEN_FAILURE_CODES: Record<string, SessionFailure['code']> = {
|
|
|
58
135
|
transport: 'transport',
|
|
59
136
|
};
|
|
60
137
|
|
|
61
|
-
function classifyOpenFailure(error: unknown): SessionFailure['code'] {
|
|
138
|
+
export function classifyOpenFailure(error: unknown): SessionFailure['code'] {
|
|
62
139
|
const code = (error as { code?: unknown } | null)?.code;
|
|
63
|
-
if (typeof code === 'string')
|
|
140
|
+
if (typeof code === 'string') {
|
|
141
|
+
// An engine that never answers — stopped, wedged, or unreachable — is
|
|
142
|
+
// a transport problem a retry may fix, not a platform verdict.
|
|
143
|
+
if (code === 'engine' || code === 'timeout') return 'transport';
|
|
144
|
+
return OPEN_FAILURE_CODES[code] ?? 'platform';
|
|
145
|
+
}
|
|
64
146
|
// No token at all: fall back to the message, treating anything that is not a
|
|
65
147
|
// permission refusal as a transport failure a retry may fix.
|
|
66
148
|
const message = error instanceof Error ? error.message : '';
|
|
@@ -165,6 +247,13 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
165
247
|
const nativeRef = useRef<string | null>(null);
|
|
166
248
|
const inputEnabled = useRef(false);
|
|
167
249
|
const pendingClipboard = useRef(new Map<string, (reply: { text: string; truncated: boolean; error?: string }) => void>());
|
|
250
|
+
/**
|
|
251
|
+
* The engine's offer and candidates can beat the open result back: the
|
|
252
|
+
* subscription starts before `session.open`, so anything that arrives
|
|
253
|
+
* before the native session exists waits here and is replayed into it.
|
|
254
|
+
*/
|
|
255
|
+
const pendingOffer = useRef<{ sdp: string; sessionId?: string } | null>(null);
|
|
256
|
+
const pendingCandidates = useRef<Array<{ candidate: string; sdpMid: string | null; sdpMLineIndex: number | null; sessionId?: string }>>([]);
|
|
168
257
|
const attempts = useRef(0);
|
|
169
258
|
/**
|
|
170
259
|
* The pending reconnect attempt. It lives here rather than in the effect
|
|
@@ -172,18 +261,49 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
172
261
|
* its dependencies, so React would run its cleanup and cancel the retry.
|
|
173
262
|
*/
|
|
174
263
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
264
|
+
/** The pending ICE-restart attempt; cancelled on recovery or failure. */
|
|
265
|
+
const restartTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
266
|
+
/** The pending stall watchdog, armed by heartbeats. */
|
|
267
|
+
const stallTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
268
|
+
/** When the last heartbeat arrived, or 0 before the first one. */
|
|
269
|
+
const lastPingAt = useRef(0);
|
|
270
|
+
/** Renegotiation transients are not a new outage before this time. */
|
|
271
|
+
const restartGraceUntil = useRef(0);
|
|
272
|
+
/** ICE-restart attempts spent on the current drop. */
|
|
273
|
+
const restartAttempts = useRef(0);
|
|
274
|
+
/**
|
|
275
|
+
* Successful restarts since the last heartbeat: when ICE is up but the
|
|
276
|
+
* path stays quiet, enough of these in a row means the session itself is
|
|
277
|
+
* wedged and only a reopen heals it.
|
|
278
|
+
*/
|
|
279
|
+
const restartCycles = useRef(0);
|
|
280
|
+
/** The last ICE state the peer or the engine reported. */
|
|
281
|
+
const iceConnected = useRef(false);
|
|
282
|
+
/** The engine's restore token, for a consent-free reopen. Unset by hand. */
|
|
283
|
+
const restoreToken = useRef<string | null>(null);
|
|
284
|
+
/** The latest status and presentation, read by event handlers. */
|
|
285
|
+
const statusRef = useRef<SessionSnapshot['status']>('idle');
|
|
286
|
+
const presentedRef = useRef(false);
|
|
287
|
+
/** The latest snapshot; mirrored eagerly so timers read exact state. */
|
|
288
|
+
const snapshotRef = useRef<SessionSnapshot>(IDLE);
|
|
175
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);
|
|
176
292
|
/** Guards every asynchronous callback against a session that already ended. */
|
|
177
293
|
const generationToken = useRef(0);
|
|
178
294
|
const optionsRef = useRef(options);
|
|
179
295
|
optionsRef.current = options;
|
|
180
296
|
|
|
181
297
|
const update = useCallback((patch: Partial<SessionSnapshot>) => {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
298
|
+
// Mirrored eagerly: timers read these between renders, and React may
|
|
299
|
+
// defer the render itself. (Calling onStateChange here also keeps it
|
|
300
|
+
// to one call per update, where the updater form could double-fire.)
|
|
301
|
+
const next = { ...snapshotRef.current, ...patch };
|
|
302
|
+
snapshotRef.current = next;
|
|
303
|
+
statusRef.current = next.status;
|
|
304
|
+
presentedRef.current = next.presented;
|
|
305
|
+
setSnapshot(next);
|
|
306
|
+
optionsRef.current.onStateChange?.(next);
|
|
187
307
|
}, []);
|
|
188
308
|
|
|
189
309
|
const fail = useCallback((failure: SessionFailure) => {
|
|
@@ -195,6 +315,180 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
195
315
|
fail({ code, message });
|
|
196
316
|
}, [fail]);
|
|
197
317
|
|
|
318
|
+
/** A transport loss is one event no matter how many sides report it: the
|
|
319
|
+
* peer's `failed` state and its `failure` event would otherwise spend the
|
|
320
|
+
* reopen budget twice for the same outage. */
|
|
321
|
+
const transportFailed = useCallback((message: string = UNREACHABLE_DESKTOP) => {
|
|
322
|
+
if (statusRef.current === 'failed') return;
|
|
323
|
+
refuse(message, 'transport');
|
|
324
|
+
}, [refuse]);
|
|
325
|
+
|
|
326
|
+
const cancelRestart = useCallback(() => {
|
|
327
|
+
if (restartTimer.current !== null) clearTimeout(restartTimer.current);
|
|
328
|
+
restartTimer.current = null;
|
|
329
|
+
}, []);
|
|
330
|
+
|
|
331
|
+
const cancelReconnect = useCallback(() => {
|
|
332
|
+
if (reconnectTimer.current !== null) clearTimeout(reconnectTimer.current);
|
|
333
|
+
reconnectTimer.current = null;
|
|
334
|
+
}, []);
|
|
335
|
+
|
|
336
|
+
const cancelStall = useCallback(() => {
|
|
337
|
+
if (stallTimer.current !== null) clearTimeout(stallTimer.current);
|
|
338
|
+
stallTimer.current = null;
|
|
339
|
+
}, []);
|
|
340
|
+
|
|
341
|
+
const scheduleRestartRef = useRef<(delay: number) => void>(() => undefined);
|
|
342
|
+
|
|
343
|
+
/**
|
|
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.
|
|
347
|
+
*/
|
|
348
|
+
const spendRestartAttempt = useCallback((): void => {
|
|
349
|
+
if (statusRef.current !== 'reconnecting' || nativeRef.current == null) return;
|
|
350
|
+
const wait = backoffDelay(RESTART_BACKOFF_MS, restartAttempts.current);
|
|
351
|
+
if (wait === null) {
|
|
352
|
+
transportFailed('the desktop did not come back; reopening the session');
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
restartAttempts.current += 1;
|
|
356
|
+
scheduleRestartRef.current(wait);
|
|
357
|
+
}, [transportFailed]);
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Queue the next ICE restart after `delay`, or do nothing when an attempt
|
|
361
|
+
* is already queued or the session is gone. The status is validated when
|
|
362
|
+
* the timer fires rather than here: the caller just moved the session to
|
|
363
|
+
* `reconnecting` and that update has not been processed yet.
|
|
364
|
+
*
|
|
365
|
+
* A restart asks the engine to re-offer on the same peer connection; the
|
|
366
|
+
* offer arrives as the usual description event and is answered as usual.
|
|
367
|
+
* The session never reopens for it.
|
|
368
|
+
*/
|
|
369
|
+
const scheduleRestart = useCallback((delay: number) => {
|
|
370
|
+
if (restartTimer.current !== null) return;
|
|
371
|
+
if (nativeRef.current == null) return;
|
|
372
|
+
restartTimer.current = setTimeout(() => {
|
|
373
|
+
restartTimer.current = null;
|
|
374
|
+
if (statusRef.current !== 'reconnecting') return;
|
|
375
|
+
const openedRef = opened.current;
|
|
376
|
+
const channel = signaling.current;
|
|
377
|
+
if (openedRef == null || channel == null) {
|
|
378
|
+
spendRestartAttempt();
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
void (async () => {
|
|
382
|
+
try {
|
|
383
|
+
await channel.request('session.restart_ice', {
|
|
384
|
+
session_id: openedRef.sessionId,
|
|
385
|
+
generation: openedRef.generation,
|
|
386
|
+
});
|
|
387
|
+
} catch {
|
|
388
|
+
spendRestartAttempt();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (statusRef.current !== 'reconnecting') return;
|
|
392
|
+
restartGraceUntil.current = Date.now() + RESTART_GRACE_MS;
|
|
393
|
+
restartCycles.current += 1;
|
|
394
|
+
// Media is up but the path stays quiet after clean restarts:
|
|
395
|
+
// the session itself is wedged and only a reopen heals it.
|
|
396
|
+
// (While ICE is down, patience: the network may still come
|
|
397
|
+
// back, and the backoff schedule owns that case.)
|
|
398
|
+
if (restartCycles.current >= 2 && iceConnected.current) {
|
|
399
|
+
transportFailed('the desktop stopped responding; reopening the session');
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
// The engine re-offers through the usual description event,
|
|
403
|
+
// which the session answers as usual; without recovery by the
|
|
404
|
+
// reply deadline, spend the next attempt. A live transport
|
|
405
|
+
// answers in milliseconds, so hold it to a short leash.
|
|
406
|
+
const replyMs = iceConnected.current ? 2000 : RESTART_REPLY_MS;
|
|
407
|
+
restartTimer.current = setTimeout(() => {
|
|
408
|
+
restartTimer.current = null;
|
|
409
|
+
spendRestartAttempt();
|
|
410
|
+
}, replyMs);
|
|
411
|
+
})();
|
|
412
|
+
}, delay);
|
|
413
|
+
}, [spendRestartAttempt, transportFailed]);
|
|
414
|
+
|
|
415
|
+
useEffect(() => {
|
|
416
|
+
scheduleRestartRef.current = scheduleRestart;
|
|
417
|
+
checkStallRef.current = checkStall;
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const checkStallRef = useRef(() => {});
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* One ICE or engine-transport state, from either reporter. The mapping is
|
|
424
|
+
* idempotent, so the peer's event and the engine's `session.state` telling
|
|
425
|
+
* the same story neither double-counts nor fights.
|
|
426
|
+
*/
|
|
427
|
+
const onTransportState = useCallback((state: string, trackIce = true) => {
|
|
428
|
+
// Synthetic stall reports say nothing about ICE itself.
|
|
429
|
+
if (trackIce) iceConnected.current = state === 'connected' || state === 'completed';
|
|
430
|
+
const next = connectionStatusFor(state, statusRef.current, presentedRef.current);
|
|
431
|
+
if (next === null) return;
|
|
432
|
+
// Our own restart's transients are expected, not a new outage.
|
|
433
|
+
if (next === 'reconnecting' && Date.now() < restartGraceUntil.current) return;
|
|
434
|
+
update({ status: next });
|
|
435
|
+
if (next === 'reconnecting') {
|
|
436
|
+
// Only a path that once carried frames is worth restarting: an
|
|
437
|
+
// initial connection that never got there has nothing to save.
|
|
438
|
+
if (presentedRef.current) {
|
|
439
|
+
restartAttempts.current = 0;
|
|
440
|
+
scheduleRestart(RESTART_AFTER_MS);
|
|
441
|
+
}
|
|
442
|
+
} else {
|
|
443
|
+
cancelRestart();
|
|
444
|
+
if (next === 'live') {
|
|
445
|
+
// The path is back but may be quiet: a missing heartbeat
|
|
446
|
+
// from here is a stalled session, not a healthy idle one.
|
|
447
|
+
cancelStall();
|
|
448
|
+
stallTimer.current = setTimeout(() => checkStallRef.current(), STALL_AFTER_MS);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}, [cancelRestart, cancelStall, scheduleRestart, update]);
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* One stall check: quiet too long while the session should be talking
|
|
455
|
+
* means the path is stalled, even when ICE has not said so yet. Only
|
|
456
|
+
* refs and module constants, so stable callbacks may hold it.
|
|
457
|
+
*/
|
|
458
|
+
const checkStall = useCallback((): void => {
|
|
459
|
+
stallTimer.current = null;
|
|
460
|
+
if (lastPingAt.current === 0) return;
|
|
461
|
+
// A timer that fires a shade early re-arms for the remainder rather
|
|
462
|
+
// than dying: a one-shot watchdog must not miss by milliseconds.
|
|
463
|
+
const quietMs = Date.now() - lastPingAt.current;
|
|
464
|
+
if (quietMs < STALL_AFTER_MS) {
|
|
465
|
+
stallTimer.current = setTimeout(() => checkStallRef.current(), STALL_AFTER_MS - quietMs);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const status = statusRef.current;
|
|
469
|
+
if (status !== 'live' && status !== 'connecting' && status !== 'reconnecting') return;
|
|
470
|
+
if (Date.now() < restartGraceUntil.current) {
|
|
471
|
+
// Our own restart's transients: recheck when the grace ends.
|
|
472
|
+
stallTimer.current = setTimeout(() => checkStallRef.current(), restartGraceUntil.current - Date.now());
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
onTransportState('disconnected', false);
|
|
476
|
+
}, [onTransportState]);
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* One engine heartbeat: the path works. It clears a stall the watchdog
|
|
480
|
+
* saw, then rearms the watchdog — which stays disarmed on engines that
|
|
481
|
+
* predate heartbeats, since `lastPingAt` never leaves 0 for them.
|
|
482
|
+
*/
|
|
483
|
+
const notePing = useCallback(() => {
|
|
484
|
+
lastPingAt.current = Date.now();
|
|
485
|
+
restartCycles.current = 0;
|
|
486
|
+
cancelStall();
|
|
487
|
+
// A heartbeat is the engine alive and the path working.
|
|
488
|
+
if (statusRef.current === 'reconnecting') onTransportState('connected');
|
|
489
|
+
stallTimer.current = setTimeout(checkStall, STALL_AFTER_MS);
|
|
490
|
+
}, [cancelStall, checkStall, onTransportState]);
|
|
491
|
+
|
|
198
492
|
const send = useCallback((message: ControlMessage) => {
|
|
199
493
|
const id = nativeRef.current;
|
|
200
494
|
if (id == null || nativeDesklink === null || (!inputEnabled.current && message.kind !== 'release_all')) return;
|
|
@@ -276,6 +570,10 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
276
570
|
inputEnabled.current = false;
|
|
277
571
|
for (const resolve of pendingClipboard.current.values()) resolve({ text: '', truncated: false, error: 'the session ended' });
|
|
278
572
|
pendingClipboard.current.clear();
|
|
573
|
+
pendingOffer.current = null;
|
|
574
|
+
pendingCandidates.current = [];
|
|
575
|
+
restartGraceUntil.current = 0;
|
|
576
|
+
restartCycles.current = 0;
|
|
279
577
|
opened.current = null;
|
|
280
578
|
signaling.current = null;
|
|
281
579
|
setNativeId(null);
|
|
@@ -302,15 +600,19 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
302
600
|
}, []);
|
|
303
601
|
|
|
304
602
|
const teardown = useCallback(async (reason: string, closeRemote: boolean) => {
|
|
603
|
+
cancelReconnect();
|
|
604
|
+
cancelRestart();
|
|
605
|
+
cancelStall();
|
|
606
|
+
restoreToken.current = null;
|
|
305
607
|
const openedRef = opened.current;
|
|
306
608
|
const owner = signaling.current;
|
|
307
609
|
discardSession();
|
|
308
610
|
if (closeRemote) await endRemote(openedRef, owner);
|
|
309
611
|
update({ status: 'ended', presented: false, failure: null });
|
|
310
612
|
void reason;
|
|
311
|
-
}, [endRemote, discardSession, update]);
|
|
613
|
+
}, [cancelReconnect, cancelRestart, cancelStall, endRemote, discardSession, update]);
|
|
312
614
|
|
|
313
|
-
const
|
|
615
|
+
const establish = useCallback(async () => {
|
|
314
616
|
if (nativeRef.current != null) return;
|
|
315
617
|
const token = ++generationToken.current;
|
|
316
618
|
if (!(await loadPlatform())) {
|
|
@@ -330,6 +632,76 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
330
632
|
if (token !== generationToken.current) return;
|
|
331
633
|
signaling.current = authorization.signaling;
|
|
332
634
|
|
|
635
|
+
// Subscribed before the open, because the engine's offer and its first
|
|
636
|
+
// candidates can beat the open result back. Anything that arrives
|
|
637
|
+
// before the native session exists is stashed and replayed into it.
|
|
638
|
+
authorization.signaling.subscribe((event) => {
|
|
639
|
+
if (token !== generationToken.current) return;
|
|
640
|
+
const native = nativeRef.current;
|
|
641
|
+
const held = opened.current;
|
|
642
|
+
const current = event.sessionId === undefined || held == null || held.sessionId === event.sessionId;
|
|
643
|
+
switch (event.kind) {
|
|
644
|
+
case 'description':
|
|
645
|
+
if (event.description.type !== 'offer') return;
|
|
646
|
+
if (native == null || held == null) {
|
|
647
|
+
pendingOffer.current = { sdp: event.description.sdp, sessionId: event.sessionId };
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (!current) return;
|
|
651
|
+
// A mid-session re-offer is a restart this session asked
|
|
652
|
+
// for, through its policy or directly: renegotiation
|
|
653
|
+
// transients after it are expected, not a new outage.
|
|
654
|
+
if (presentedRef.current) restartGraceUntil.current = Date.now() + RESTART_GRACE_MS;
|
|
655
|
+
nativeDesklink?.setRemoteDescription(native, 'offer', event.description.sdp);
|
|
656
|
+
return;
|
|
657
|
+
case 'candidate':
|
|
658
|
+
if (native == null || held == null) {
|
|
659
|
+
pendingCandidates.current.push({
|
|
660
|
+
candidate: event.candidate.candidate,
|
|
661
|
+
sdpMid: event.candidate.sdpMid ?? null,
|
|
662
|
+
sdpMLineIndex: event.candidate.sdpMLineIndex ?? null,
|
|
663
|
+
sessionId: event.sessionId,
|
|
664
|
+
});
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (!current) return;
|
|
668
|
+
nativeDesklink?.addRemoteCandidate(
|
|
669
|
+
native,
|
|
670
|
+
event.candidate.candidate,
|
|
671
|
+
event.candidate.sdpMid ?? null,
|
|
672
|
+
event.candidate.sdpMLineIndex ?? null,
|
|
673
|
+
);
|
|
674
|
+
return;
|
|
675
|
+
case 'state':
|
|
676
|
+
if (!current) return;
|
|
677
|
+
diagnostics.current.transport = event.transport;
|
|
678
|
+
// Before the session exists there is nothing to map onto.
|
|
679
|
+
if (held != null) onTransportState(String(event.transport ?? '').toLowerCase());
|
|
680
|
+
update({ diagnostics: { ...diagnostics.current } });
|
|
681
|
+
return;
|
|
682
|
+
case 'restoreToken':
|
|
683
|
+
// The live session's grant for a consent-free reopen.
|
|
684
|
+
if (current && held != null) restoreToken.current = event.token;
|
|
685
|
+
return;
|
|
686
|
+
case 'revoked':
|
|
687
|
+
// Nothing held: the reopen policy owns recovery, and a
|
|
688
|
+
// dead session's late revocation must not end it.
|
|
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
|
+
}
|
|
697
|
+
void teardown(event.reason, false);
|
|
698
|
+
update({ status: 'ended', failure: { code: 'revoked', message: event.reason } });
|
|
699
|
+
return;
|
|
700
|
+
default:
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
|
|
333
705
|
let openedResult: SessionOpenResult;
|
|
334
706
|
try {
|
|
335
707
|
openedResult = await authorization.signaling.request<SessionOpenResult>('session.open', {
|
|
@@ -339,7 +711,9 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
339
711
|
bitrate_kbps: authorization.session.bitrateKbps,
|
|
340
712
|
max_fps: authorization.session.maxFps,
|
|
341
713
|
ice_servers: serializeIceServers(authorization.session.iceServers),
|
|
342
|
-
|
|
714
|
+
// A restore token the live session earned buys a consent-free
|
|
715
|
+
// reopen; the app's own grant wins when it names one.
|
|
716
|
+
restore_token: authorization.session.restoreToken ?? restoreToken.current ?? undefined,
|
|
343
717
|
ttl_seconds: authorization.session.ttlSeconds,
|
|
344
718
|
});
|
|
345
719
|
} catch (error) {
|
|
@@ -370,35 +744,20 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
370
744
|
platform?.setInputEnabled(id, inputEnabled.current);
|
|
371
745
|
setNativeId(id);
|
|
372
746
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
});
|
|
747
|
+
// Anything the engine sent before the native session existed.
|
|
748
|
+
// A stashed message naming another session is a previous
|
|
749
|
+
// generation's leftover and is dropped, not replayed.
|
|
750
|
+
const stashedOffer = pendingOffer.current;
|
|
751
|
+
pendingOffer.current = null;
|
|
752
|
+
if (stashedOffer !== null
|
|
753
|
+
&& (stashedOffer.sessionId === undefined || stashedOffer.sessionId === openedResult.sessionId)) {
|
|
754
|
+
platform?.setRemoteDescription(id, 'offer', stashedOffer.sdp);
|
|
755
|
+
}
|
|
756
|
+
for (const candidate of pendingCandidates.current.splice(0)) {
|
|
757
|
+
if (candidate.sessionId !== undefined && candidate.sessionId !== openedResult.sessionId) continue;
|
|
758
|
+
platform?.addRemoteCandidate(id, candidate.candidate, candidate.sdpMid, candidate.sdpMLineIndex);
|
|
759
|
+
}
|
|
760
|
+
|
|
402
761
|
if (token === generationToken.current) established = true;
|
|
403
762
|
} catch (error) {
|
|
404
763
|
if (token === generationToken.current) {
|
|
@@ -410,7 +769,20 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
410
769
|
await endRemote(openedResult, authorization.signaling);
|
|
411
770
|
}
|
|
412
771
|
}
|
|
413
|
-
}, [discardSession, endRemote, refuse, teardown, update]);
|
|
772
|
+
}, [discardSession, endRemote, refuse, teardown, transportFailed, update, onTransportState]);
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Open (or reopen) the session with fresh authority. A deliberate call
|
|
776
|
+
* starts a fresh reopen budget; the automatic retry calls `establish`
|
|
777
|
+
* directly so its budget survives.
|
|
778
|
+
*/
|
|
779
|
+
const connect = useCallback(async () => {
|
|
780
|
+
attempts.current = 0;
|
|
781
|
+
restartAttempts.current = 0;
|
|
782
|
+
restartCycles.current = 0;
|
|
783
|
+
cancelRestart();
|
|
784
|
+
await establish();
|
|
785
|
+
}, [cancelRestart, establish]);
|
|
414
786
|
|
|
415
787
|
// Native events: answer, candidates, control replies, presentation, failure.
|
|
416
788
|
useEffect(() => {
|
|
@@ -455,11 +827,18 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
455
827
|
return;
|
|
456
828
|
case 'presented':
|
|
457
829
|
attempts.current = 0;
|
|
830
|
+
restartAttempts.current = 0;
|
|
831
|
+
restartCycles.current = 0;
|
|
832
|
+
cancelRestart();
|
|
458
833
|
update({ status: 'live', presented: true, failure: null });
|
|
459
834
|
return;
|
|
460
835
|
case 'control': {
|
|
461
836
|
const reply = parseControlReply(String(event.payload.message ?? ''));
|
|
462
837
|
if (reply == null) return;
|
|
838
|
+
if (reply.kind === 'ping') {
|
|
839
|
+
notePing();
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
463
842
|
if (reply.kind === 'hello') {
|
|
464
843
|
diagnostics.current.protocol = reply.protocol;
|
|
465
844
|
if (reply.protocol !== PROTOCOL_VERSION) {
|
|
@@ -489,20 +868,33 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
489
868
|
if (reply.kind === 'revoked') {
|
|
490
869
|
// The engine ends the data channel with a revocation when
|
|
491
870
|
// the session is replaced or closed; it is the only notice
|
|
492
|
-
// 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
|
+
}
|
|
493
877
|
void teardown(reply.reason, false);
|
|
494
878
|
update({ status: 'ended', failure: { code: 'revoked', message: reply.reason } });
|
|
495
879
|
return;
|
|
496
880
|
}
|
|
497
881
|
return;
|
|
498
882
|
}
|
|
883
|
+
case 'ice': {
|
|
884
|
+
// The peer's connection state, as it happens: Android
|
|
885
|
+
// and iOS already emit this, and web matches them.
|
|
886
|
+
const state = String(event.payload.state ?? '').toLowerCase();
|
|
887
|
+
if (state === 'failed') transportFailed();
|
|
888
|
+
else onTransportState(state);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
499
891
|
case 'keyboard':
|
|
500
892
|
// Typing the platform held back for an armed modifier.
|
|
501
893
|
if (typeof event.payload.key === 'string') pressKey(event.payload.key)();
|
|
502
894
|
else if (typeof event.payload.text === 'string') typeText(event.payload.text);
|
|
503
895
|
return;
|
|
504
896
|
case 'failure':
|
|
505
|
-
|
|
897
|
+
transportFailed();
|
|
506
898
|
return;
|
|
507
899
|
case 'closed':
|
|
508
900
|
return;
|
|
@@ -516,7 +908,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
516
908
|
dropped = true;
|
|
517
909
|
subscription?.remove();
|
|
518
910
|
};
|
|
519
|
-
}, [
|
|
911
|
+
}, [transportFailed, onTransportState, cancelRestart, spendRestartAttempt, notePing, teardown, update, pressKey, typeText]);
|
|
520
912
|
|
|
521
913
|
const copyRemoteToLocal = useCallback(async () => {
|
|
522
914
|
if (!inputEnabled.current) throw new Error('Desktop control is off.');
|
|
@@ -551,19 +943,18 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
551
943
|
if (reply.error != null) throw new Error(reply.error);
|
|
552
944
|
}, []);
|
|
553
945
|
|
|
554
|
-
const cancelReconnect = useCallback(() => {
|
|
555
|
-
if (reconnectTimer.current !== null) clearTimeout(reconnectTimer.current);
|
|
556
|
-
reconnectTimer.current = null;
|
|
557
|
-
}, []);
|
|
558
|
-
|
|
559
946
|
const close = useCallback(async (reason = 'closed by the user') => {
|
|
560
947
|
cancelReconnect();
|
|
561
948
|
attempts.current = 0;
|
|
949
|
+
restartAttempts.current = 0;
|
|
950
|
+
restartCycles.current = 0;
|
|
562
951
|
await teardown(reason, true);
|
|
563
952
|
}, [cancelReconnect, teardown]);
|
|
564
953
|
|
|
565
|
-
//
|
|
566
|
-
// that was revoked must not be reopened
|
|
954
|
+
// Reopen with fresh authority after a transport failure, backing off
|
|
955
|
+
// across about a minute: a session that was revoked must not be reopened
|
|
956
|
+
// on the strength of the old grant, and an outage that outlasts one
|
|
957
|
+
// attempt must not spend the whole budget while the network is still down.
|
|
567
958
|
useEffect(() => {
|
|
568
959
|
if (snapshot.status !== 'failed') return;
|
|
569
960
|
if (snapshot.failure?.code !== 'transport') return;
|
|
@@ -573,19 +964,23 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
573
964
|
// native handle that makes `connect()` a no-op.
|
|
574
965
|
const owner = signaling.current;
|
|
575
966
|
const openedRef = opened.current;
|
|
967
|
+
cancelRestart();
|
|
576
968
|
discardSession();
|
|
577
969
|
void endRemote(openedRef, owner);
|
|
578
|
-
|
|
970
|
+
const wait = backoffDelay(REOPEN_BACKOFF_MS, attempts.current);
|
|
971
|
+
if (wait === null) return;
|
|
579
972
|
attempts.current += 1;
|
|
580
973
|
update({ status: 'reconnecting' });
|
|
581
974
|
reconnectTimer.current = setTimeout(() => {
|
|
582
975
|
reconnectTimer.current = null;
|
|
583
|
-
void
|
|
584
|
-
},
|
|
585
|
-
}, [
|
|
976
|
+
void establish();
|
|
977
|
+
}, wait);
|
|
978
|
+
}, [establish, cancelRestart, discardSession, endRemote, snapshot.status, snapshot.failure, update]);
|
|
586
979
|
|
|
587
980
|
useEffect(() => () => {
|
|
588
981
|
cancelReconnect();
|
|
982
|
+
cancelRestart();
|
|
983
|
+
cancelStall();
|
|
589
984
|
generationToken.current += 1;
|
|
590
985
|
const id = nativeRef.current;
|
|
591
986
|
nativeRef.current = null;
|
|
@@ -601,12 +996,50 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
601
996
|
// A phone that goes to the background must not leave a remote button held:
|
|
602
997
|
// the desktop cannot know the finger left the glass. This is the same
|
|
603
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.
|
|
604
1004
|
useEffect(() => {
|
|
1005
|
+
let resumeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
605
1006
|
const subscription = AppState.addEventListener('change', (status) => {
|
|
606
|
-
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);
|
|
607
1037
|
});
|
|
608
|
-
return () =>
|
|
609
|
-
|
|
1038
|
+
return () => {
|
|
1039
|
+
if (resumeTimer !== null) clearTimeout(resumeTimer);
|
|
1040
|
+
subscription.remove();
|
|
1041
|
+
};
|
|
1042
|
+
}, [releaseHeld, cancelReconnect, connect, transportFailed]);
|
|
610
1043
|
|
|
611
1044
|
return useMemo<DesktopSession>(() => ({
|
|
612
1045
|
snapshot,
|