@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.
@@ -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
- /** One reconnect attempt per session; beyond that the caller must ask again. */
31
- const MAX_RECONNECT_ATTEMPTS = 1;
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
- * 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.
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, which is not available yet.';
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') return OPEN_FAILURE_CODES[code] ?? 'platform';
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
- setSnapshot((current) => {
183
- const next = { ...current, ...patch };
184
- optionsRef.current.onStateChange?.(next);
185
- return next;
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 connect = useCallback(async () => {
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
- restore_token: authorization.session.restoreToken,
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
- 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
- });
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
- refuse(UNREACHABLE_DESKTOP, 'transport');
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
- }, [refuse, teardown, update, pressKey, typeText]);
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
- // 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.
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
- if (attempts.current >= MAX_RECONNECT_ATTEMPTS) return;
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 connect();
584
- }, 800);
585
- }, [connect, discardSession, endRemote, snapshot.status, snapshot.failure, update]);
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') releaseHeld();
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 () => subscription.remove();
609
- }, [releaseHeld]);
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,