@desklink/react-native 0.1.0 → 0.2.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,8 +27,73 @@ 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
+ /** The delay before attempt `n` (0-based), or null when the schedule is spent. */
59
+ export function backoffDelay(schedule: readonly number[], attempt: number): number | null {
60
+ return attempt < schedule.length ? schedule[attempt]! : null;
61
+ }
62
+
63
+ export type ConnectionStateName =
64
+ | 'new'
65
+ | 'checking'
66
+ | 'connected'
67
+ | 'completed'
68
+ | 'disconnected'
69
+ | 'failed'
70
+ | 'closed';
71
+
72
+ /**
73
+ * What one ICE/engine-transport state means for the session status, or null
74
+ * when it changes nothing. `disconnected` surfaces as `reconnecting` at once —
75
+ * event-driven, so within about a second of the drop — and `connected` moves a
76
+ * session that had shown frames back to `live`. `failed` is terminal and is
77
+ * handled by the failure path, not here.
78
+ */
79
+ export function connectionStatusFor(
80
+ state: string,
81
+ status: SessionSnapshot['status'],
82
+ presented: boolean,
83
+ ): SessionSnapshot['status'] | null {
84
+ switch (state) {
85
+ case 'disconnected':
86
+ return status === 'live' || status === 'connecting' ? 'reconnecting' : null;
87
+ case 'connected':
88
+ case 'completed':
89
+ if (status !== 'reconnecting') return null;
90
+ // Frames were never shown: this is still the first connection, not
91
+ // a recovery, so it stays `connecting` until a frame renders.
92
+ return presented ? 'live' : 'connecting';
93
+ default:
94
+ return null;
95
+ }
96
+ }
32
97
 
33
98
  /**
34
99
  * Without a relay there is no route between two networks: ICE only ever finds
@@ -58,9 +123,14 @@ const OPEN_FAILURE_CODES: Record<string, SessionFailure['code']> = {
58
123
  transport: 'transport',
59
124
  };
60
125
 
61
- function classifyOpenFailure(error: unknown): SessionFailure['code'] {
126
+ export function classifyOpenFailure(error: unknown): SessionFailure['code'] {
62
127
  const code = (error as { code?: unknown } | null)?.code;
63
- if (typeof code === 'string') return OPEN_FAILURE_CODES[code] ?? 'platform';
128
+ if (typeof code === 'string') {
129
+ // An engine that never answers — stopped, wedged, or unreachable — is
130
+ // a transport problem a retry may fix, not a platform verdict.
131
+ if (code === 'engine' || code === 'timeout') return 'transport';
132
+ return OPEN_FAILURE_CODES[code] ?? 'platform';
133
+ }
64
134
  // No token at all: fall back to the message, treating anything that is not a
65
135
  // permission refusal as a transport failure a retry may fix.
66
136
  const message = error instanceof Error ? error.message : '';
@@ -125,7 +195,7 @@ export interface DesktopSession {
125
195
  /** Hold the screen in landscape while the desktop is shown, or follow the phone again. */
126
196
  setOrientation: (mode: 'landscape' | 'auto') => void;
127
197
  send: (message: ControlMessage) => void;
128
- /** Ctrl and Shift as sticky keys: off, armed for the next key, or locked. */
198
+ /** Desktop modifiers as sticky keys: off, armed for the next key, or locked. */
129
199
  modifiers: StickyModifiers;
130
200
  /** Tap a sticky modifier: off, then armed for the next key, then locked, then off. */
131
201
  tapModifier: (name: StickyModifier) => void;
@@ -165,6 +235,13 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
165
235
  const nativeRef = useRef<string | null>(null);
166
236
  const inputEnabled = useRef(false);
167
237
  const pendingClipboard = useRef(new Map<string, (reply: { text: string; truncated: boolean; error?: string }) => void>());
238
+ /**
239
+ * The engine's offer and candidates can beat the open result back: the
240
+ * subscription starts before `session.open`, so anything that arrives
241
+ * before the native session exists waits here and is replayed into it.
242
+ */
243
+ const pendingOffer = useRef<{ sdp: string; sessionId?: string } | null>(null);
244
+ const pendingCandidates = useRef<Array<{ candidate: string; sdpMid: string | null; sdpMLineIndex: number | null; sessionId?: string }>>([]);
168
245
  const attempts = useRef(0);
169
246
  /**
170
247
  * The pending reconnect attempt. It lives here rather than in the effect
@@ -172,6 +249,31 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
172
249
  * its dependencies, so React would run its cleanup and cancel the retry.
173
250
  */
174
251
  const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
252
+ /** The pending ICE-restart attempt; cancelled on recovery or failure. */
253
+ const restartTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
254
+ /** The pending stall watchdog, armed by heartbeats. */
255
+ const stallTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
256
+ /** When the last heartbeat arrived, or 0 before the first one. */
257
+ const lastPingAt = useRef(0);
258
+ /** Renegotiation transients are not a new outage before this time. */
259
+ const restartGraceUntil = useRef(0);
260
+ /** ICE-restart attempts spent on the current drop. */
261
+ const restartAttempts = useRef(0);
262
+ /**
263
+ * Successful restarts since the last heartbeat: when ICE is up but the
264
+ * path stays quiet, enough of these in a row means the session itself is
265
+ * wedged and only a reopen heals it.
266
+ */
267
+ const restartCycles = useRef(0);
268
+ /** The last ICE state the peer or the engine reported. */
269
+ const iceConnected = useRef(false);
270
+ /** The engine's restore token, for a consent-free reopen. Unset by hand. */
271
+ const restoreToken = useRef<string | null>(null);
272
+ /** The latest status and presentation, read by event handlers. */
273
+ const statusRef = useRef<SessionSnapshot['status']>('idle');
274
+ const presentedRef = useRef(false);
275
+ /** The latest snapshot; mirrored eagerly so timers read exact state. */
276
+ const snapshotRef = useRef<SessionSnapshot>(IDLE);
175
277
  const diagnostics = useRef<Record<string, string | number | boolean>>({});
176
278
  /** Guards every asynchronous callback against a session that already ended. */
177
279
  const generationToken = useRef(0);
@@ -179,11 +281,15 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
179
281
  optionsRef.current = options;
180
282
 
181
283
  const update = useCallback((patch: Partial<SessionSnapshot>) => {
182
- setSnapshot((current) => {
183
- const next = { ...current, ...patch };
184
- optionsRef.current.onStateChange?.(next);
185
- return next;
186
- });
284
+ // Mirrored eagerly: timers read these between renders, and React may
285
+ // defer the render itself. (Calling onStateChange here also keeps it
286
+ // to one call per update, where the updater form could double-fire.)
287
+ const next = { ...snapshotRef.current, ...patch };
288
+ snapshotRef.current = next;
289
+ statusRef.current = next.status;
290
+ presentedRef.current = next.presented;
291
+ setSnapshot(next);
292
+ optionsRef.current.onStateChange?.(next);
187
293
  }, []);
188
294
 
189
295
  const fail = useCallback((failure: SessionFailure) => {
@@ -195,6 +301,177 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
195
301
  fail({ code, message });
196
302
  }, [fail]);
197
303
 
304
+ /** A transport loss is one event no matter how many sides report it: the
305
+ * peer's `failed` state and its `failure` event would otherwise spend the
306
+ * reopen budget twice for the same outage. */
307
+ const transportFailed = useCallback((message: string = UNREACHABLE_DESKTOP) => {
308
+ if (statusRef.current === 'failed') return;
309
+ refuse(message, 'transport');
310
+ }, [refuse]);
311
+
312
+ const cancelRestart = useCallback(() => {
313
+ if (restartTimer.current !== null) clearTimeout(restartTimer.current);
314
+ restartTimer.current = null;
315
+ }, []);
316
+
317
+ const cancelReconnect = useCallback(() => {
318
+ if (reconnectTimer.current !== null) clearTimeout(reconnectTimer.current);
319
+ reconnectTimer.current = null;
320
+ }, []);
321
+
322
+ const cancelStall = useCallback(() => {
323
+ if (stallTimer.current !== null) clearTimeout(stallTimer.current);
324
+ stallTimer.current = null;
325
+ }, []);
326
+
327
+ const scheduleRestartRef = useRef<(delay: number) => void>(() => undefined);
328
+
329
+ /**
330
+ * Spend one restart from the backoff schedule, or stop asking when the
331
+ * schedule is spent: ICE's own failure then moves the session to a full
332
+ * reopen. Only refs and module constants, so stable callbacks may hold it.
333
+ */
334
+ const spendRestartAttempt = useCallback((): void => {
335
+ if (statusRef.current !== 'reconnecting' || nativeRef.current == null) return;
336
+ const wait = backoffDelay(RESTART_BACKOFF_MS, restartAttempts.current);
337
+ if (wait === null) return;
338
+ restartAttempts.current += 1;
339
+ scheduleRestartRef.current(wait);
340
+ }, []);
341
+
342
+ /**
343
+ * Queue the next ICE restart after `delay`, or do nothing when an attempt
344
+ * is already queued or the session is gone. The status is validated when
345
+ * the timer fires rather than here: the caller just moved the session to
346
+ * `reconnecting` and that update has not been processed yet.
347
+ *
348
+ * A restart asks the engine to re-offer on the same peer connection; the
349
+ * offer arrives as the usual description event and is answered as usual.
350
+ * The session never reopens for it.
351
+ */
352
+ const scheduleRestart = useCallback((delay: number) => {
353
+ if (restartTimer.current !== null) return;
354
+ if (nativeRef.current == null) return;
355
+ restartTimer.current = setTimeout(() => {
356
+ restartTimer.current = null;
357
+ if (statusRef.current !== 'reconnecting') return;
358
+ const openedRef = opened.current;
359
+ const channel = signaling.current;
360
+ if (openedRef == null || channel == null) {
361
+ spendRestartAttempt();
362
+ return;
363
+ }
364
+ void (async () => {
365
+ try {
366
+ await channel.request('session.restart_ice', {
367
+ session_id: openedRef.sessionId,
368
+ generation: openedRef.generation,
369
+ });
370
+ } catch {
371
+ spendRestartAttempt();
372
+ return;
373
+ }
374
+ if (statusRef.current !== 'reconnecting') return;
375
+ restartGraceUntil.current = Date.now() + RESTART_GRACE_MS;
376
+ restartCycles.current += 1;
377
+ // Media is up but the path stays quiet after clean restarts:
378
+ // the session itself is wedged and only a reopen heals it.
379
+ // (While ICE is down, patience: the network may still come
380
+ // back, and the backoff schedule owns that case.)
381
+ if (restartCycles.current >= 2 && iceConnected.current) {
382
+ transportFailed('the desktop stopped responding; reopening the session');
383
+ return;
384
+ }
385
+ // The engine re-offers through the usual description event,
386
+ // which the session answers as usual; without recovery by the
387
+ // reply deadline, spend the next attempt. A live transport
388
+ // answers in milliseconds, so hold it to a short leash.
389
+ const replyMs = iceConnected.current ? 2000 : RESTART_REPLY_MS;
390
+ restartTimer.current = setTimeout(() => {
391
+ restartTimer.current = null;
392
+ spendRestartAttempt();
393
+ }, replyMs);
394
+ })();
395
+ }, delay);
396
+ }, [spendRestartAttempt, transportFailed]);
397
+
398
+ useEffect(() => {
399
+ scheduleRestartRef.current = scheduleRestart;
400
+ checkStallRef.current = checkStall;
401
+ });
402
+
403
+ const checkStallRef = useRef(() => {});
404
+
405
+ /**
406
+ * One ICE or engine-transport state, from either reporter. The mapping is
407
+ * idempotent, so the peer's event and the engine's `session.state` telling
408
+ * the same story neither double-counts nor fights.
409
+ */
410
+ const onTransportState = useCallback((state: string, trackIce = true) => {
411
+ // Synthetic stall reports say nothing about ICE itself.
412
+ if (trackIce) iceConnected.current = state === 'connected' || state === 'completed';
413
+ const next = connectionStatusFor(state, statusRef.current, presentedRef.current);
414
+ if (next === null) return;
415
+ // Our own restart's transients are expected, not a new outage.
416
+ if (next === 'reconnecting' && Date.now() < restartGraceUntil.current) return;
417
+ update({ status: next });
418
+ if (next === 'reconnecting') {
419
+ // Only a path that once carried frames is worth restarting: an
420
+ // initial connection that never got there has nothing to save.
421
+ if (presentedRef.current) {
422
+ restartAttempts.current = 0;
423
+ scheduleRestart(RESTART_AFTER_MS);
424
+ }
425
+ } else {
426
+ cancelRestart();
427
+ if (next === 'live') {
428
+ // The path is back but may be quiet: a missing heartbeat
429
+ // from here is a stalled session, not a healthy idle one.
430
+ cancelStall();
431
+ stallTimer.current = setTimeout(() => checkStallRef.current(), STALL_AFTER_MS);
432
+ }
433
+ }
434
+ }, [cancelRestart, cancelStall, scheduleRestart, update]);
435
+
436
+ /**
437
+ * One stall check: quiet too long while the session should be talking
438
+ * means the path is stalled, even when ICE has not said so yet. Only
439
+ * refs and module constants, so stable callbacks may hold it.
440
+ */
441
+ const checkStall = useCallback((): void => {
442
+ stallTimer.current = null;
443
+ if (lastPingAt.current === 0) return;
444
+ // A timer that fires a shade early re-arms for the remainder rather
445
+ // than dying: a one-shot watchdog must not miss by milliseconds.
446
+ const quietMs = Date.now() - lastPingAt.current;
447
+ if (quietMs < STALL_AFTER_MS) {
448
+ stallTimer.current = setTimeout(() => checkStallRef.current(), STALL_AFTER_MS - quietMs);
449
+ return;
450
+ }
451
+ const status = statusRef.current;
452
+ if (status !== 'live' && status !== 'connecting' && status !== 'reconnecting') return;
453
+ if (Date.now() < restartGraceUntil.current) {
454
+ // Our own restart's transients: recheck when the grace ends.
455
+ stallTimer.current = setTimeout(() => checkStallRef.current(), restartGraceUntil.current - Date.now());
456
+ return;
457
+ }
458
+ onTransportState('disconnected', false);
459
+ }, [onTransportState]);
460
+
461
+ /**
462
+ * One engine heartbeat: the path works. It clears a stall the watchdog
463
+ * saw, then rearms the watchdog — which stays disarmed on engines that
464
+ * predate heartbeats, since `lastPingAt` never leaves 0 for them.
465
+ */
466
+ const notePing = useCallback(() => {
467
+ lastPingAt.current = Date.now();
468
+ restartCycles.current = 0;
469
+ cancelStall();
470
+ // A heartbeat is the engine alive and the path working.
471
+ if (statusRef.current === 'reconnecting') onTransportState('connected');
472
+ stallTimer.current = setTimeout(checkStall, STALL_AFTER_MS);
473
+ }, [cancelStall, checkStall, onTransportState]);
474
+
198
475
  const send = useCallback((message: ControlMessage) => {
199
476
  const id = nativeRef.current;
200
477
  if (id == null || nativeDesklink === null || (!inputEnabled.current && message.kind !== 'release_all')) return;
@@ -218,7 +495,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
218
495
 
219
496
  const applyModifiers = useCallback((next: StickyModifiers) => {
220
497
  const current = modifiersRef.current;
221
- if (next.Control === current.Control && next.Shift === current.Shift) return;
498
+ if (next.Control === current.Control && next.Shift === current.Shift && next.Alt === current.Alt && next.Meta === current.Meta) return;
222
499
  modifiersRef.current = next;
223
500
  setModifiers(next);
224
501
  // The phone's keyboard is the session's own only while a modifier waits
@@ -276,6 +553,10 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
276
553
  inputEnabled.current = false;
277
554
  for (const resolve of pendingClipboard.current.values()) resolve({ text: '', truncated: false, error: 'the session ended' });
278
555
  pendingClipboard.current.clear();
556
+ pendingOffer.current = null;
557
+ pendingCandidates.current = [];
558
+ restartGraceUntil.current = 0;
559
+ restartCycles.current = 0;
279
560
  opened.current = null;
280
561
  signaling.current = null;
281
562
  setNativeId(null);
@@ -302,15 +583,19 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
302
583
  }, []);
303
584
 
304
585
  const teardown = useCallback(async (reason: string, closeRemote: boolean) => {
586
+ cancelReconnect();
587
+ cancelRestart();
588
+ cancelStall();
589
+ restoreToken.current = null;
305
590
  const openedRef = opened.current;
306
591
  const owner = signaling.current;
307
592
  discardSession();
308
593
  if (closeRemote) await endRemote(openedRef, owner);
309
594
  update({ status: 'ended', presented: false, failure: null });
310
595
  void reason;
311
- }, [endRemote, discardSession, update]);
596
+ }, [cancelReconnect, cancelRestart, cancelStall, endRemote, discardSession, update]);
312
597
 
313
- const connect = useCallback(async () => {
598
+ const establish = useCallback(async () => {
314
599
  if (nativeRef.current != null) return;
315
600
  const token = ++generationToken.current;
316
601
  if (!(await loadPlatform())) {
@@ -330,6 +615,69 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
330
615
  if (token !== generationToken.current) return;
331
616
  signaling.current = authorization.signaling;
332
617
 
618
+ // Subscribed before the open, because the engine's offer and its first
619
+ // candidates can beat the open result back. Anything that arrives
620
+ // before the native session exists is stashed and replayed into it.
621
+ authorization.signaling.subscribe((event) => {
622
+ if (token !== generationToken.current) return;
623
+ const native = nativeRef.current;
624
+ const held = opened.current;
625
+ const current = event.sessionId === undefined || held == null || held.sessionId === event.sessionId;
626
+ switch (event.kind) {
627
+ case 'description':
628
+ if (event.description.type !== 'offer') return;
629
+ if (native == null || held == null) {
630
+ pendingOffer.current = { sdp: event.description.sdp, sessionId: event.sessionId };
631
+ return;
632
+ }
633
+ if (!current) return;
634
+ // A mid-session re-offer is a restart this session asked
635
+ // for, through its policy or directly: renegotiation
636
+ // transients after it are expected, not a new outage.
637
+ if (presentedRef.current) restartGraceUntil.current = Date.now() + RESTART_GRACE_MS;
638
+ nativeDesklink?.setRemoteDescription(native, 'offer', event.description.sdp);
639
+ return;
640
+ case 'candidate':
641
+ if (native == null || held == null) {
642
+ pendingCandidates.current.push({
643
+ candidate: event.candidate.candidate,
644
+ sdpMid: event.candidate.sdpMid ?? null,
645
+ sdpMLineIndex: event.candidate.sdpMLineIndex ?? null,
646
+ sessionId: event.sessionId,
647
+ });
648
+ return;
649
+ }
650
+ if (!current) return;
651
+ nativeDesklink?.addRemoteCandidate(
652
+ native,
653
+ event.candidate.candidate,
654
+ event.candidate.sdpMid ?? null,
655
+ event.candidate.sdpMLineIndex ?? null,
656
+ );
657
+ return;
658
+ case 'state':
659
+ if (!current) return;
660
+ diagnostics.current.transport = event.transport;
661
+ // Before the session exists there is nothing to map onto.
662
+ if (held != null) onTransportState(String(event.transport ?? '').toLowerCase());
663
+ update({ diagnostics: { ...diagnostics.current } });
664
+ return;
665
+ case 'restoreToken':
666
+ // The live session's grant for a consent-free reopen.
667
+ if (current && held != null) restoreToken.current = event.token;
668
+ return;
669
+ case 'revoked':
670
+ // Nothing held: the reopen policy owns recovery, and a
671
+ // dead session's late revocation must not end it.
672
+ if (held == null || !current) return;
673
+ void teardown(event.reason, false);
674
+ update({ status: 'ended', failure: { code: 'revoked', message: event.reason } });
675
+ return;
676
+ default:
677
+ return;
678
+ }
679
+ });
680
+
333
681
  let openedResult: SessionOpenResult;
334
682
  try {
335
683
  openedResult = await authorization.signaling.request<SessionOpenResult>('session.open', {
@@ -339,7 +687,9 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
339
687
  bitrate_kbps: authorization.session.bitrateKbps,
340
688
  max_fps: authorization.session.maxFps,
341
689
  ice_servers: serializeIceServers(authorization.session.iceServers),
342
- restore_token: authorization.session.restoreToken,
690
+ // A restore token the live session earned buys a consent-free
691
+ // reopen; the app's own grant wins when it names one.
692
+ restore_token: authorization.session.restoreToken ?? restoreToken.current ?? undefined,
343
693
  ttl_seconds: authorization.session.ttlSeconds,
344
694
  });
345
695
  } catch (error) {
@@ -370,35 +720,20 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
370
720
  platform?.setInputEnabled(id, inputEnabled.current);
371
721
  setNativeId(id);
372
722
 
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
- });
723
+ // Anything the engine sent before the native session existed.
724
+ // A stashed message naming another session is a previous
725
+ // generation's leftover and is dropped, not replayed.
726
+ const stashedOffer = pendingOffer.current;
727
+ pendingOffer.current = null;
728
+ if (stashedOffer !== null
729
+ && (stashedOffer.sessionId === undefined || stashedOffer.sessionId === openedResult.sessionId)) {
730
+ platform?.setRemoteDescription(id, 'offer', stashedOffer.sdp);
731
+ }
732
+ for (const candidate of pendingCandidates.current.splice(0)) {
733
+ if (candidate.sessionId !== undefined && candidate.sessionId !== openedResult.sessionId) continue;
734
+ platform?.addRemoteCandidate(id, candidate.candidate, candidate.sdpMid, candidate.sdpMLineIndex);
735
+ }
736
+
402
737
  if (token === generationToken.current) established = true;
403
738
  } catch (error) {
404
739
  if (token === generationToken.current) {
@@ -410,7 +745,20 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
410
745
  await endRemote(openedResult, authorization.signaling);
411
746
  }
412
747
  }
413
- }, [discardSession, endRemote, refuse, teardown, update]);
748
+ }, [discardSession, endRemote, refuse, teardown, update, onTransportState]);
749
+
750
+ /**
751
+ * Open (or reopen) the session with fresh authority. A deliberate call
752
+ * starts a fresh reopen budget; the automatic retry calls `establish`
753
+ * directly so its budget survives.
754
+ */
755
+ const connect = useCallback(async () => {
756
+ attempts.current = 0;
757
+ restartAttempts.current = 0;
758
+ restartCycles.current = 0;
759
+ cancelRestart();
760
+ await establish();
761
+ }, [cancelRestart, establish]);
414
762
 
415
763
  // Native events: answer, candidates, control replies, presentation, failure.
416
764
  useEffect(() => {
@@ -455,11 +803,18 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
455
803
  return;
456
804
  case 'presented':
457
805
  attempts.current = 0;
806
+ restartAttempts.current = 0;
807
+ restartCycles.current = 0;
808
+ cancelRestart();
458
809
  update({ status: 'live', presented: true, failure: null });
459
810
  return;
460
811
  case 'control': {
461
812
  const reply = parseControlReply(String(event.payload.message ?? ''));
462
813
  if (reply == null) return;
814
+ if (reply.kind === 'ping') {
815
+ notePing();
816
+ return;
817
+ }
463
818
  if (reply.kind === 'hello') {
464
819
  diagnostics.current.protocol = reply.protocol;
465
820
  if (reply.protocol !== PROTOCOL_VERSION) {
@@ -496,13 +851,21 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
496
851
  }
497
852
  return;
498
853
  }
854
+ case 'ice': {
855
+ // The peer's connection state, as it happens: Android
856
+ // and iOS already emit this, and web matches them.
857
+ const state = String(event.payload.state ?? '').toLowerCase();
858
+ if (state === 'failed') transportFailed();
859
+ else onTransportState(state);
860
+ return;
861
+ }
499
862
  case 'keyboard':
500
863
  // Typing the platform held back for an armed modifier.
501
864
  if (typeof event.payload.key === 'string') pressKey(event.payload.key)();
502
865
  else if (typeof event.payload.text === 'string') typeText(event.payload.text);
503
866
  return;
504
867
  case 'failure':
505
- refuse(UNREACHABLE_DESKTOP, 'transport');
868
+ transportFailed();
506
869
  return;
507
870
  case 'closed':
508
871
  return;
@@ -516,7 +879,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
516
879
  dropped = true;
517
880
  subscription?.remove();
518
881
  };
519
- }, [refuse, teardown, update, pressKey, typeText]);
882
+ }, [transportFailed, onTransportState, cancelRestart, spendRestartAttempt, notePing, teardown, update, pressKey, typeText]);
520
883
 
521
884
  const copyRemoteToLocal = useCallback(async () => {
522
885
  if (!inputEnabled.current) throw new Error('Desktop control is off.');
@@ -551,19 +914,18 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
551
914
  if (reply.error != null) throw new Error(reply.error);
552
915
  }, []);
553
916
 
554
- const cancelReconnect = useCallback(() => {
555
- if (reconnectTimer.current !== null) clearTimeout(reconnectTimer.current);
556
- reconnectTimer.current = null;
557
- }, []);
558
-
559
917
  const close = useCallback(async (reason = 'closed by the user') => {
560
918
  cancelReconnect();
561
919
  attempts.current = 0;
920
+ restartAttempts.current = 0;
921
+ restartCycles.current = 0;
562
922
  await teardown(reason, true);
563
923
  }, [cancelReconnect, teardown]);
564
924
 
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.
925
+ // Reopen with fresh authority after a transport failure, backing off
926
+ // across about a minute: a session that was revoked must not be reopened
927
+ // on the strength of the old grant, and an outage that outlasts one
928
+ // attempt must not spend the whole budget while the network is still down.
567
929
  useEffect(() => {
568
930
  if (snapshot.status !== 'failed') return;
569
931
  if (snapshot.failure?.code !== 'transport') return;
@@ -575,17 +937,20 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
575
937
  const openedRef = opened.current;
576
938
  discardSession();
577
939
  void endRemote(openedRef, owner);
578
- if (attempts.current >= MAX_RECONNECT_ATTEMPTS) return;
940
+ const wait = backoffDelay(REOPEN_BACKOFF_MS, attempts.current);
941
+ if (wait === null) return;
579
942
  attempts.current += 1;
580
943
  update({ status: 'reconnecting' });
581
944
  reconnectTimer.current = setTimeout(() => {
582
945
  reconnectTimer.current = null;
583
- void connect();
584
- }, 800);
585
- }, [connect, discardSession, endRemote, snapshot.status, snapshot.failure, update]);
946
+ void establish();
947
+ }, wait);
948
+ }, [establish, discardSession, endRemote, snapshot.status, snapshot.failure, update]);
586
949
 
587
950
  useEffect(() => () => {
588
951
  cancelReconnect();
952
+ cancelRestart();
953
+ cancelStall();
589
954
  generationToken.current += 1;
590
955
  const id = nativeRef.current;
591
956
  nativeRef.current = null;