@desklink/react-native 0.5.3 → 0.5.6
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 +44 -11
- package/android/build.gradle +0 -1
- package/android/src/main/java/expo/modules/desklink/DesktopSession.kt +20 -12
- package/android/src/main/java/expo/modules/desklink/DesktopView.kt +3 -2
- package/package.json +8 -2
- package/src/DesktopView.ios.tsx +10 -4
- package/src/native.ios.ts +12 -7
- package/src/native.web.ts +3 -2
- package/src/protocol.ts +5 -2
- package/src/useDesktopSession.ts +437 -137
- package/src/DesktopView.ios.spec.tsx +0 -530
- package/src/controls.spec.tsx +0 -205
package/src/useDesktopSession.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
SessionFailure,
|
|
11
11
|
SessionOpenRequest,
|
|
12
12
|
SessionOpenResult,
|
|
13
|
+
SessionEvent,
|
|
13
14
|
SessionSnapshot,
|
|
14
15
|
Signaling,
|
|
15
16
|
SurfaceGeometry,
|
|
@@ -55,8 +56,41 @@ const STALL_AFTER_MS = 1200;
|
|
|
55
56
|
* trigger another restart while the window lasts.
|
|
56
57
|
*/
|
|
57
58
|
const RESTART_GRACE_MS = 2500;
|
|
59
|
+
/**
|
|
60
|
+
* How long a moving picture outlasts silent heartbeats, from the later of the
|
|
61
|
+
* last heartbeat and the picture's return from a freeze: after an outage the
|
|
62
|
+
* data channel lags the video, as SCTP's retransmissions back off (to 15 s,
|
|
63
|
+
* then 31 s, after the first loss).
|
|
64
|
+
*/
|
|
65
|
+
const QUIET_PICTURE_MS = 20_000;
|
|
58
66
|
/** How long a restart offer may go unanswered before the next attempt. */
|
|
59
67
|
const RESTART_REPLY_MS = 5000;
|
|
68
|
+
/** A restart request unanswered this long means its carrier is dead, even if it never closed. */
|
|
69
|
+
const RESTART_REQUEST_DEADLINE_MS = 1500;
|
|
70
|
+
/** A dial that failed is retried this soon: the path may be back any moment. */
|
|
71
|
+
const DIAL_RETRY_MS = 250;
|
|
72
|
+
/** One dial's bound. Not shorter: Chromium throttles refused WebSocket handshakes. */
|
|
73
|
+
const DIAL_TIMEOUT_MS = 5000;
|
|
74
|
+
/** How long the host holds a session with no carrier (the bridge's `reattachMs`); past it, reopen. */
|
|
75
|
+
const REATTACH_WINDOW_MS = 30_000;
|
|
76
|
+
/**
|
|
77
|
+
* How long a reattached carrier waits for the old ICE pair before asking for
|
|
78
|
+
* a restart — the wait for a brief blip: on an unchanged path the old pair
|
|
79
|
+
* resumes in ~100 ms and only the carrier was missing, so the wait keeps the
|
|
80
|
+
* restart unspent while the picture comes back on its own.
|
|
81
|
+
*/
|
|
82
|
+
const REATTACH_GRACE_MS = 200;
|
|
83
|
+
/**
|
|
84
|
+
* A picture still this long is a real outage, not a blip: the old pair is
|
|
85
|
+
* not resuming, so the wait that protects a blip would only cost the recovery
|
|
86
|
+
* its 2 s bar. The line separates the two measured regimes — blips of about
|
|
87
|
+
* a second heal on the old pair in ~100 ms; by 12 s the pair is gone. The
|
|
88
|
+
* picture's own last motion is the clock: a re-dialled carrier's age resets
|
|
89
|
+
* with every refused attempt, so it cannot tell the two apart.
|
|
90
|
+
*/
|
|
91
|
+
const REATTACH_GRACE_BLIP_MAX_MS = 4000;
|
|
92
|
+
/** A real outage's wait: one stats beat — enough to see a picture already moving. */
|
|
93
|
+
const REATTACH_GRACE_SETTLE_MS = 50;
|
|
60
94
|
/**
|
|
61
95
|
* How long the media path may carry nothing at all before a session that says
|
|
62
96
|
* `live` stops saying so. The engine's transport state reaches the client over
|
|
@@ -98,6 +132,16 @@ export const REOPEN_BACKOFF_MS = [1000, 2000, 4000, 8000, 8000, 8000, 8000, 8000
|
|
|
98
132
|
* A return after that reopens at once instead of spending restarts on it.
|
|
99
133
|
*/
|
|
100
134
|
export const BACKGROUND_REOPEN_MS = 30_000;
|
|
135
|
+
/**
|
|
136
|
+
* A peer that reaches ICE `failed` has lost its media path, not its session:
|
|
137
|
+
* the engine keeps the session alive for about 28 s after the path dies (its
|
|
138
|
+
* ICE `Failed` timer). So a client-side ICE failure holds the session — and
|
|
139
|
+
* its native handle — this long from the failure, whatever the carrier is
|
|
140
|
+
* doing, instead of throwing it away at once. A roam longer than the client's
|
|
141
|
+
* own ~15 s ICE `failed` then still returns to the same session. An SDP or
|
|
142
|
+
* candidate failure is not a lost path and stays terminal as it always was.
|
|
143
|
+
*/
|
|
144
|
+
export const HOLD_AFTER_FAILED_MS = 30_000;
|
|
101
145
|
/**
|
|
102
146
|
* A resumed app hears what queued up while it was suspended just after it
|
|
103
147
|
* hears it is active again. A revocation among it decides whether a reopen is
|
|
@@ -105,6 +149,19 @@ export const BACKGROUND_REOPEN_MS = 30_000;
|
|
|
105
149
|
*/
|
|
106
150
|
const RESUME_DRAIN_MS = 750;
|
|
107
151
|
|
|
152
|
+
const DEADLINE = new Error('no reply in time');
|
|
153
|
+
|
|
154
|
+
/** `promise`, or a DEADLINE rejection after `ms`; a value that lands later goes to `late`. */
|
|
155
|
+
function withDeadline<T>(promise: Promise<T>, ms: number, late?: (value: T) => void): Promise<T> {
|
|
156
|
+
let expired = false;
|
|
157
|
+
return Promise.race([
|
|
158
|
+
promise.then((value) => { if (expired) late?.(value); return value; }),
|
|
159
|
+
new Promise<never>((_, reject) => setTimeout(() => { expired = true; reject(DEADLINE); }, ms)),
|
|
160
|
+
]);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const errorCode = (error: unknown): unknown => (error as { code?: unknown } | null)?.code;
|
|
164
|
+
|
|
108
165
|
/** The delay before attempt `n` (0-based), or null when the schedule is spent. */
|
|
109
166
|
export function backoffDelay(schedule: readonly number[], attempt: number): number | null {
|
|
110
167
|
return attempt < schedule.length ? schedule[attempt]! : null;
|
|
@@ -336,6 +393,8 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
336
393
|
const mediaMovingAt = useRef<number | null>(null);
|
|
337
394
|
/** Whether the picture of an open session has stopped advancing. */
|
|
338
395
|
const frozen = useRef(false);
|
|
396
|
+
/** When a frozen picture last moved again, or 0 while it has not. */
|
|
397
|
+
const pictureBackAt = useRef(0);
|
|
339
398
|
/** Renegotiation transients are not a new outage before this time. */
|
|
340
399
|
const restartGraceUntil = useRef(0);
|
|
341
400
|
/** ICE-restart attempts spent on the current drop. */
|
|
@@ -348,6 +407,12 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
348
407
|
const restartCycles = useRef(0);
|
|
349
408
|
/** The last ICE state the peer or the engine reported. */
|
|
350
409
|
const iceConnected = useRef(false);
|
|
410
|
+
/**
|
|
411
|
+
* When the peer's own ICE last failed while the session was held, or null
|
|
412
|
+
* when the current failure is not an ICE one. Kept across the hold so the
|
|
413
|
+
* reopen effect can measure the remaining window.
|
|
414
|
+
*/
|
|
415
|
+
const heldIceFailureAt = useRef<number | null>(null);
|
|
351
416
|
/** The engine's restore token, for a consent-free reopen. Unset by hand. */
|
|
352
417
|
const restoreToken = useRef<string | null>(null);
|
|
353
418
|
/** The latest status and presentation, read by event handlers. */
|
|
@@ -360,6 +425,13 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
360
425
|
const awaySince = useRef<number | null>(null);
|
|
361
426
|
/** Guards every asynchronous callback against a session that already ended. */
|
|
362
427
|
const generationToken = useRef(0);
|
|
428
|
+
/** Bumped per carrier adopted or dropped: an older carrier's events and replies are ignored. */
|
|
429
|
+
const carrierEpoch = useRef(0);
|
|
430
|
+
const unsubscribeCarrier = useRef<(() => void) | null>(null);
|
|
431
|
+
/** While the carrier is dead, whatever the status: the dial in flight and its retry. */
|
|
432
|
+
const dialing = useRef<{ inFlight: boolean; since: number; retry?: ReturnType<typeof setTimeout> } | null>(null);
|
|
433
|
+
/** Set with a reopen that skips its backoff: the host no longer holds the session at all. */
|
|
434
|
+
const reopenAtOnce = useRef(false);
|
|
363
435
|
const optionsRef = useRef(options);
|
|
364
436
|
optionsRef.current = options;
|
|
365
437
|
|
|
@@ -375,21 +447,30 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
375
447
|
optionsRef.current.onStateChange?.(next);
|
|
376
448
|
}, []);
|
|
377
449
|
|
|
378
|
-
const fail = useCallback((failure: SessionFailure) => {
|
|
450
|
+
const fail = useCallback((failure: SessionFailure, ice = false) => {
|
|
451
|
+
heldIceFailureAt.current = failure.code === 'transport' && ice && nativeRef.current != null ? Date.now() : null;
|
|
379
452
|
update({ status: 'failed', failure });
|
|
380
453
|
optionsRef.current.onError?.(failure);
|
|
381
454
|
}, [update]);
|
|
382
455
|
|
|
383
|
-
const refuse = useCallback((message: string, code: SessionFailure['code'] = 'platform') => {
|
|
384
|
-
fail({ code, message });
|
|
456
|
+
const refuse = useCallback((message: string, code: SessionFailure['code'] = 'platform', ice = false) => {
|
|
457
|
+
fail({ code, message }, ice);
|
|
385
458
|
}, [fail]);
|
|
386
459
|
|
|
387
460
|
/** A transport loss is one event no matter how many sides report it: the
|
|
388
461
|
* peer's `failed` state and its `failure` event would otherwise spend the
|
|
389
|
-
* reopen budget twice for the same outage.
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
462
|
+
* reopen budget twice for the same outage. While dialing, it stays
|
|
463
|
+
* `reconnecting`.
|
|
464
|
+
*
|
|
465
|
+
* `ice` marks a failure of the peer's own media path (its ICE `failed`
|
|
466
|
+
* state, or a `failure` event whose reason is `ice`). While the session is
|
|
467
|
+
* held and has not been revoked, that is not the end of the session: the
|
|
468
|
+
* reopen effect keeps it for `HOLD_AFTER_FAILED_MS` first. */
|
|
469
|
+
const transportFailed = useCallback((message: string = UNREACHABLE_DESKTOP, ice = false): boolean => {
|
|
470
|
+
if (dialing.current !== null) return false;
|
|
471
|
+
if (ice && statusRef.current === 'failed') return false;
|
|
472
|
+
refuse(message, 'transport', ice);
|
|
473
|
+
return true;
|
|
393
474
|
}, [refuse]);
|
|
394
475
|
|
|
395
476
|
const cancelRestart = useCallback(() => {
|
|
@@ -412,20 +493,69 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
412
493
|
firstFrameTimer.current = null;
|
|
413
494
|
}, []);
|
|
414
495
|
|
|
496
|
+
const stopDialing = useCallback(() => { clearTimeout(dialing.current?.retry); dialing.current = null; }, []);
|
|
497
|
+
|
|
415
498
|
/**
|
|
416
499
|
* A first connection that stays frameless for this long is not going to
|
|
417
500
|
* arrive: report why instead of leaving an empty picture on screen.
|
|
418
501
|
*/
|
|
419
502
|
const armFirstFrame = useCallback(() => {
|
|
420
503
|
cancelFirstFrame();
|
|
421
|
-
|
|
422
|
-
firstFrameTimer.current =
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
504
|
+
const watch: () => void = () => {
|
|
505
|
+
firstFrameTimer.current = setTimeout(() => {
|
|
506
|
+
firstFrameTimer.current = null;
|
|
507
|
+
const status = statusRef.current;
|
|
508
|
+
if (!presentedRef.current && status === 'failed' && heldIceFailureAt.current !== null) {
|
|
509
|
+
watch();
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (presentedRef.current || (status !== 'connecting' && status !== 'reconnecting')) return;
|
|
513
|
+
stopDialing();
|
|
514
|
+
transportFailed(NO_PICTURE);
|
|
515
|
+
}, FIRST_FRAME_AFTER_MS);
|
|
516
|
+
};
|
|
517
|
+
watch();
|
|
518
|
+
}, [cancelFirstFrame, stopDialing, transportFailed]);
|
|
427
519
|
|
|
428
520
|
const scheduleRestartRef = useRef<(delay: number) => void>(() => undefined);
|
|
521
|
+
const dialRef = useRef<() => void>(() => undefined);
|
|
522
|
+
const carrierEventRef = useRef<(event: SessionEvent) => void>(() => undefined);
|
|
523
|
+
|
|
524
|
+
/** Stop listening to the carrier; a dead one is also closed, so nothing more is sent on it. */
|
|
525
|
+
const dropCarrier = useCallback((dead: boolean) => {
|
|
526
|
+
carrierEpoch.current += 1;
|
|
527
|
+
unsubscribeCarrier.current?.();
|
|
528
|
+
unsubscribeCarrier.current = null;
|
|
529
|
+
const channel = signaling.current;
|
|
530
|
+
signaling.current = null;
|
|
531
|
+
if (dead) channel?.close?.();
|
|
532
|
+
}, []);
|
|
533
|
+
|
|
534
|
+
const adoptCarrier = useCallback((channel: Signaling) => {
|
|
535
|
+
dropCarrier(false);
|
|
536
|
+
const epoch = carrierEpoch.current;
|
|
537
|
+
signaling.current = channel;
|
|
538
|
+
unsubscribeCarrier.current = channel.subscribe((event) => {
|
|
539
|
+
if (epoch === carrierEpoch.current) carrierEventRef.current(event);
|
|
540
|
+
});
|
|
541
|
+
}, [dropCarrier]);
|
|
542
|
+
|
|
543
|
+
/** Carrier `epoch` is dead: drop it hard and, while a session is held, dial a new one. */
|
|
544
|
+
const carrierLost = useCallback((epoch: number) => {
|
|
545
|
+
if (epoch !== carrierEpoch.current || signaling.current == null) return;
|
|
546
|
+
dropCarrier(true);
|
|
547
|
+
if (dialing.current !== null || opened.current == null) return;
|
|
548
|
+
dialing.current = { inFlight: false, since: Date.now() };
|
|
549
|
+
dialRef.current();
|
|
550
|
+
}, [dropCarrier]);
|
|
551
|
+
|
|
552
|
+
/** Whether a request on carrier `epoch` failed because that carrier is gone; the dial loop then owns it. */
|
|
553
|
+
const carrierFailed = useCallback((epoch: number, error: unknown): boolean => {
|
|
554
|
+
if (epoch !== carrierEpoch.current) return true;
|
|
555
|
+
if (error !== DEADLINE && errorCode(error) !== 'transport') return false;
|
|
556
|
+
carrierLost(epoch);
|
|
557
|
+
return true;
|
|
558
|
+
}, [carrierLost]);
|
|
429
559
|
|
|
430
560
|
/**
|
|
431
561
|
* Spend one restart from the backoff schedule, or reopen when the schedule
|
|
@@ -465,20 +595,33 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
465
595
|
if (statusRef.current !== 'reconnecting') return;
|
|
466
596
|
const openedRef = opened.current;
|
|
467
597
|
const channel = signaling.current;
|
|
468
|
-
if (openedRef == null
|
|
598
|
+
if (openedRef == null) {
|
|
469
599
|
spendRestartAttempt();
|
|
470
600
|
return;
|
|
471
601
|
}
|
|
602
|
+
// No carrier: the dial loop restarts once it has a new one.
|
|
603
|
+
if (channel == null) return;
|
|
604
|
+
const epoch = carrierEpoch.current;
|
|
605
|
+
const token = generationToken.current;
|
|
472
606
|
void (async () => {
|
|
473
607
|
try {
|
|
474
|
-
await channel.request('session.restart_ice', {
|
|
608
|
+
await withDeadline(channel.request('session.restart_ice', {
|
|
475
609
|
session_id: openedRef.sessionId,
|
|
476
610
|
generation: openedRef.generation,
|
|
477
|
-
});
|
|
478
|
-
} catch {
|
|
611
|
+
}), RESTART_REQUEST_DEADLINE_MS);
|
|
612
|
+
} catch (error) {
|
|
613
|
+
if (token !== generationToken.current || carrierFailed(epoch, error)) return;
|
|
614
|
+
// The host no longer holds this session: reopen without waiting out a backoff.
|
|
615
|
+
if (errorCode(error) === 'session' || errorCode(error) === 'generation') {
|
|
616
|
+
attempts.current = 0;
|
|
617
|
+
reopenAtOnce.current = transportFailed('the desktop no longer holds this session; reopening it');
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
479
620
|
spendRestartAttempt();
|
|
480
621
|
return;
|
|
481
622
|
}
|
|
623
|
+
// A late reply from a replaced carrier or session says nothing about this one.
|
|
624
|
+
if (epoch !== carrierEpoch.current || token !== generationToken.current) return;
|
|
482
625
|
if (statusRef.current !== 'reconnecting') return;
|
|
483
626
|
restartGraceUntil.current = Date.now() + RESTART_GRACE_MS;
|
|
484
627
|
restartCycles.current += 1;
|
|
@@ -501,10 +644,12 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
501
644
|
}, replyMs);
|
|
502
645
|
})();
|
|
503
646
|
}, delay);
|
|
504
|
-
}, [spendRestartAttempt, transportFailed]);
|
|
647
|
+
}, [spendRestartAttempt, transportFailed, carrierFailed]);
|
|
505
648
|
|
|
506
649
|
useEffect(() => {
|
|
507
650
|
scheduleRestartRef.current = scheduleRestart;
|
|
651
|
+
dialRef.current = dial;
|
|
652
|
+
carrierEventRef.current = onCarrierEvent;
|
|
508
653
|
checkStallRef.current = checkStall;
|
|
509
654
|
mediaWatchRef.current = checkMedia;
|
|
510
655
|
});
|
|
@@ -526,6 +671,11 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
526
671
|
const onTransportState = useCallback((state: string, trackIce = true, skipGrace = false) => {
|
|
527
672
|
// Synthetic stall reports say nothing about ICE itself.
|
|
528
673
|
if (trackIce) iceConnected.current = state === 'connected' || state === 'completed';
|
|
674
|
+
if (heldIceFailureAt.current !== null && statusRef.current === 'failed'
|
|
675
|
+
&& (state === 'connected' || state === 'completed')) {
|
|
676
|
+
heldIceFailureAt.current = null;
|
|
677
|
+
update({ status: 'reconnecting' });
|
|
678
|
+
}
|
|
529
679
|
const mapped = connectionStatusFor(state, statusRef.current, presentedRef.current && !frozen.current);
|
|
530
680
|
if (mapped === null) return;
|
|
531
681
|
// A transport that answers while the picture is frozen is not a
|
|
@@ -596,9 +746,10 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
596
746
|
mediaMovingAt.current = Date.now();
|
|
597
747
|
if (!frozen.current) return;
|
|
598
748
|
frozen.current = false;
|
|
749
|
+
pictureBackAt.current = Date.now();
|
|
599
750
|
// The picture advances again, so the transport can be believed
|
|
600
751
|
// for this session: live returns without waiting for it to say so.
|
|
601
|
-
if (statusRef.current === 'reconnecting') onTransportState('connected', false);
|
|
752
|
+
if (statusRef.current === 'reconnecting' || (statusRef.current === 'failed' && heldIceFailureAt.current !== null)) onTransportState('connected', false);
|
|
602
753
|
return;
|
|
603
754
|
}
|
|
604
755
|
if (mediaMovingAt.current === null) return;
|
|
@@ -612,6 +763,8 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
612
763
|
}, () => undefined);
|
|
613
764
|
}, [armMedia, onTransportState]);
|
|
614
765
|
|
|
766
|
+
const pictureMoving = useCallback((within: number): boolean => mediaMovingAt.current !== null && Date.now() - mediaMovingAt.current < within, []);
|
|
767
|
+
|
|
615
768
|
/**
|
|
616
769
|
* One stall check: quiet too long while the session should be talking
|
|
617
770
|
* means the path is stalled, even when ICE has not said so yet. Only
|
|
@@ -634,8 +787,19 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
634
787
|
stallTimer.current = setTimeout(() => checkStallRef.current(), restartGraceUntil.current - Date.now());
|
|
635
788
|
return;
|
|
636
789
|
}
|
|
790
|
+
// The outage's own silence is not the data channel lagging the
|
|
791
|
+
// picture: that lag starts when the picture comes back. Until the
|
|
792
|
+
// heartbeat catches up, the picture is held to the freeze bar: a
|
|
793
|
+
// still desktop's keepalive frames, a second apart, read up to
|
|
794
|
+
// 1.5 s apart through the stats poll.
|
|
795
|
+
const lagging = pictureBackAt.current > lastPingAt.current;
|
|
796
|
+
const lagMs = Date.now() - Math.max(lastPingAt.current, pictureBackAt.current);
|
|
797
|
+
if (pictureMoving(lagging ? FROZEN_AFTER_MS : STALL_AFTER_MS) && !frozen.current && lagMs < QUIET_PICTURE_MS) {
|
|
798
|
+
stallTimer.current = setTimeout(() => checkStallRef.current(), STALL_AFTER_MS);
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
637
801
|
onTransportState('disconnected', false);
|
|
638
|
-
}, [onTransportState]);
|
|
802
|
+
}, [onTransportState, pictureMoving]);
|
|
639
803
|
|
|
640
804
|
/**
|
|
641
805
|
* One engine heartbeat: the path works. It clears a stall the watchdog
|
|
@@ -733,6 +897,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
733
897
|
/** Drop everything this process holds for a session the engine has ended. */
|
|
734
898
|
const discardSession = useCallback(() => {
|
|
735
899
|
generationToken.current += 1;
|
|
900
|
+
cancelFirstFrame();
|
|
736
901
|
const id = nativeRef.current;
|
|
737
902
|
nativeRef.current = null;
|
|
738
903
|
inputEnabled.current = false;
|
|
@@ -741,6 +906,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
741
906
|
pendingOffer.current = null;
|
|
742
907
|
pendingCandidates.current = [];
|
|
743
908
|
pendingCursorEvents.current = [];
|
|
909
|
+
heldIceFailureAt.current = null;
|
|
744
910
|
restartGraceUntil.current = 0;
|
|
745
911
|
restartCycles.current = 0;
|
|
746
912
|
// A new session has its own media path: the last one's counts and its
|
|
@@ -750,14 +916,15 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
750
916
|
mediaRef.current = null;
|
|
751
917
|
mediaMovingAt.current = null;
|
|
752
918
|
frozen.current = false;
|
|
919
|
+
pictureBackAt.current = 0;
|
|
753
920
|
opened.current = null;
|
|
754
|
-
|
|
921
|
+
dropCarrier(false);
|
|
755
922
|
setNativeId(null);
|
|
756
923
|
// An armed modifier belongs to the session it was armed in.
|
|
757
924
|
modifiersRef.current = NO_MODIFIERS;
|
|
758
925
|
setModifiers(NO_MODIFIERS);
|
|
759
926
|
if (id != null) nativeDesklink?.closeSession(id);
|
|
760
|
-
}, []);
|
|
927
|
+
}, [cancelFirstFrame, dropCarrier]);
|
|
761
928
|
|
|
762
929
|
/** Tell the host a session is over; a failure is nothing the user can act on. */
|
|
763
930
|
const endRemote = useCallback(async (
|
|
@@ -780,6 +947,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
780
947
|
cancelRestart();
|
|
781
948
|
cancelStall();
|
|
782
949
|
cancelFirstFrame();
|
|
950
|
+
stopDialing();
|
|
783
951
|
restoreToken.current = null;
|
|
784
952
|
const openedRef = opened.current;
|
|
785
953
|
const owner = signaling.current;
|
|
@@ -787,10 +955,98 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
787
955
|
if (closeRemote) await endRemote(openedRef, owner);
|
|
788
956
|
update({ status: 'ended', presented: false, failure: null });
|
|
789
957
|
void reason;
|
|
790
|
-
}, [cancelReconnect, cancelRestart, cancelStall, cancelFirstFrame, endRemote, discardSession, update]);
|
|
958
|
+
}, [cancelReconnect, cancelRestart, cancelStall, cancelFirstFrame, stopDialing, endRemote, discardSession, update]);
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* One engine event from the live carrier. Subscribed before the open,
|
|
962
|
+
* because the engine's offer and its first candidates can beat the open
|
|
963
|
+
* result back: anything that arrives before the native session exists is
|
|
964
|
+
* stashed and replayed into it.
|
|
965
|
+
*/
|
|
966
|
+
const onCarrierEvent = useCallback((event: SessionEvent) => {
|
|
967
|
+
const native = nativeRef.current;
|
|
968
|
+
const held = opened.current;
|
|
969
|
+
const current = event.sessionId === undefined || held == null || held.sessionId === event.sessionId;
|
|
970
|
+
switch (event.kind) {
|
|
971
|
+
case 'carrier-closed':
|
|
972
|
+
carrierLost(carrierEpoch.current);
|
|
973
|
+
return;
|
|
974
|
+
case 'description':
|
|
975
|
+
if (event.description.type !== 'offer') return;
|
|
976
|
+
if (native == null || held == null) {
|
|
977
|
+
pendingOffer.current = { sdp: event.description.sdp, sessionId: event.sessionId };
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
if (!current) return;
|
|
981
|
+
// A mid-session re-offer is a restart this session asked
|
|
982
|
+
// for, through its policy or directly: renegotiation
|
|
983
|
+
// transients after it are expected, not a new outage.
|
|
984
|
+
if (presentedRef.current) restartGraceUntil.current = Date.now() + RESTART_GRACE_MS;
|
|
985
|
+
nativeDesklink?.setRemoteDescription(native, 'offer', event.description.sdp);
|
|
986
|
+
return;
|
|
987
|
+
case 'candidate':
|
|
988
|
+
if (native == null || held == null) {
|
|
989
|
+
pendingCandidates.current.push({
|
|
990
|
+
candidate: event.candidate.candidate,
|
|
991
|
+
sdpMid: event.candidate.sdpMid ?? null,
|
|
992
|
+
sdpMLineIndex: event.candidate.sdpMLineIndex ?? null,
|
|
993
|
+
sessionId: event.sessionId,
|
|
994
|
+
});
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
if (!current) return;
|
|
998
|
+
nativeDesklink?.addRemoteCandidate(
|
|
999
|
+
native,
|
|
1000
|
+
event.candidate.candidate,
|
|
1001
|
+
event.candidate.sdpMid ?? null,
|
|
1002
|
+
event.candidate.sdpMLineIndex ?? null,
|
|
1003
|
+
);
|
|
1004
|
+
return;
|
|
1005
|
+
case 'cursor':
|
|
1006
|
+
if (held == null) {
|
|
1007
|
+
pendingCursorEvents.current.push(event);
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
if (current) optionsRef.current.onCursor?.(event);
|
|
1011
|
+
return;
|
|
1012
|
+
case 'state':
|
|
1013
|
+
if (!current) return;
|
|
1014
|
+
diagnostics.current.transport = event.transport;
|
|
1015
|
+
// Before the session exists there is nothing to map onto.
|
|
1016
|
+
if (held != null) onTransportState(String(event.transport ?? '').toLowerCase());
|
|
1017
|
+
update({ diagnostics: { ...diagnostics.current } });
|
|
1018
|
+
return;
|
|
1019
|
+
case 'restoreToken':
|
|
1020
|
+
// The live session's grant for a consent-free reopen.
|
|
1021
|
+
if (current && held != null) restoreToken.current = event.token;
|
|
1022
|
+
return;
|
|
1023
|
+
case 'revoked':
|
|
1024
|
+
// Nothing held: the reopen policy owns recovery, and a
|
|
1025
|
+
// dead session's late revocation must not end it.
|
|
1026
|
+
if (held == null || !current) return;
|
|
1027
|
+
// A lost path is an outage, not a verdict: the reopen
|
|
1028
|
+
// policy asks for fresh authority. Any other code, or
|
|
1029
|
+
// none, is the session's authority ending.
|
|
1030
|
+
if (event.code === 'transport') {
|
|
1031
|
+
transportFailed(event.reason);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
void teardown(event.reason, false);
|
|
1035
|
+
update({ status: 'ended', failure: { code: 'revoked', message: event.reason } });
|
|
1036
|
+
return;
|
|
1037
|
+
default:
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
}, [carrierLost, onTransportState, teardown, transportFailed, update]);
|
|
791
1041
|
|
|
792
|
-
|
|
1042
|
+
/** Open a session on `given`, a carrier the dial loop just authorized, or on a fresh authorization. */
|
|
1043
|
+
const establish = useCallback(async (given?: { signaling: Signaling; session: SessionOpenRequest }) => {
|
|
793
1044
|
if (nativeRef.current != null) return;
|
|
1045
|
+
// While a dead carrier is replaced, the dial loop opens on the new one.
|
|
1046
|
+
if (given === undefined && dialing.current !== null) {
|
|
1047
|
+
dialRef.current();
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
794
1050
|
const token = ++generationToken.current;
|
|
795
1051
|
pendingCursorEvents.current = [];
|
|
796
1052
|
if (!(await loadPlatform())) {
|
|
@@ -800,93 +1056,17 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
800
1056
|
if (token !== generationToken.current) return;
|
|
801
1057
|
update({ status: 'opening', failure: null, presented: false });
|
|
802
1058
|
|
|
803
|
-
let authorization
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
}
|
|
811
|
-
if (token !== generationToken.current) return;
|
|
812
|
-
signaling.current = authorization.signaling;
|
|
813
|
-
|
|
814
|
-
// Subscribed before the open, because the engine's offer and its first
|
|
815
|
-
// candidates can beat the open result back. Anything that arrives
|
|
816
|
-
// before the native session exists is stashed and replayed into it.
|
|
817
|
-
authorization.signaling.subscribe((event) => {
|
|
818
|
-
if (token !== generationToken.current) return;
|
|
819
|
-
const native = nativeRef.current;
|
|
820
|
-
const held = opened.current;
|
|
821
|
-
const current = event.sessionId === undefined || held == null || held.sessionId === event.sessionId;
|
|
822
|
-
switch (event.kind) {
|
|
823
|
-
case 'description':
|
|
824
|
-
if (event.description.type !== 'offer') return;
|
|
825
|
-
if (native == null || held == null) {
|
|
826
|
-
pendingOffer.current = { sdp: event.description.sdp, sessionId: event.sessionId };
|
|
827
|
-
return;
|
|
828
|
-
}
|
|
829
|
-
if (!current) return;
|
|
830
|
-
// A mid-session re-offer is a restart this session asked
|
|
831
|
-
// for, through its policy or directly: renegotiation
|
|
832
|
-
// transients after it are expected, not a new outage.
|
|
833
|
-
if (presentedRef.current) restartGraceUntil.current = Date.now() + RESTART_GRACE_MS;
|
|
834
|
-
nativeDesklink?.setRemoteDescription(native, 'offer', event.description.sdp);
|
|
835
|
-
return;
|
|
836
|
-
case 'candidate':
|
|
837
|
-
if (native == null || held == null) {
|
|
838
|
-
pendingCandidates.current.push({
|
|
839
|
-
candidate: event.candidate.candidate,
|
|
840
|
-
sdpMid: event.candidate.sdpMid ?? null,
|
|
841
|
-
sdpMLineIndex: event.candidate.sdpMLineIndex ?? null,
|
|
842
|
-
sessionId: event.sessionId,
|
|
843
|
-
});
|
|
844
|
-
return;
|
|
845
|
-
}
|
|
846
|
-
if (!current) return;
|
|
847
|
-
nativeDesklink?.addRemoteCandidate(
|
|
848
|
-
native,
|
|
849
|
-
event.candidate.candidate,
|
|
850
|
-
event.candidate.sdpMid ?? null,
|
|
851
|
-
event.candidate.sdpMLineIndex ?? null,
|
|
852
|
-
);
|
|
853
|
-
return;
|
|
854
|
-
case 'cursor':
|
|
855
|
-
if (held == null) {
|
|
856
|
-
pendingCursorEvents.current.push(event);
|
|
857
|
-
return;
|
|
858
|
-
}
|
|
859
|
-
if (current) optionsRef.current.onCursor?.(event);
|
|
860
|
-
return;
|
|
861
|
-
case 'state':
|
|
862
|
-
if (!current) return;
|
|
863
|
-
diagnostics.current.transport = event.transport;
|
|
864
|
-
// Before the session exists there is nothing to map onto.
|
|
865
|
-
if (held != null) onTransportState(String(event.transport ?? '').toLowerCase());
|
|
866
|
-
update({ diagnostics: { ...diagnostics.current } });
|
|
867
|
-
return;
|
|
868
|
-
case 'restoreToken':
|
|
869
|
-
// The live session's grant for a consent-free reopen.
|
|
870
|
-
if (current && held != null) restoreToken.current = event.token;
|
|
871
|
-
return;
|
|
872
|
-
case 'revoked':
|
|
873
|
-
// Nothing held: the reopen policy owns recovery, and a
|
|
874
|
-
// dead session's late revocation must not end it.
|
|
875
|
-
if (held == null || !current) return;
|
|
876
|
-
// A lost path is an outage, not a verdict: the reopen
|
|
877
|
-
// policy asks for fresh authority. Any other code, or
|
|
878
|
-
// none, is the session's authority ending.
|
|
879
|
-
if (event.code === 'transport') {
|
|
880
|
-
transportFailed(event.reason);
|
|
881
|
-
return;
|
|
882
|
-
}
|
|
883
|
-
void teardown(event.reason, false);
|
|
884
|
-
update({ status: 'ended', failure: { code: 'revoked', message: event.reason } });
|
|
885
|
-
return;
|
|
886
|
-
default:
|
|
887
|
-
return;
|
|
1059
|
+
let authorization = given;
|
|
1060
|
+
if (authorization === undefined) {
|
|
1061
|
+
try {
|
|
1062
|
+
authorization = await optionsRef.current.authorize();
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
refuse(error instanceof Error ? error.message : 'the desktop was not authorized', errorCode(error) === 'transport' ? 'transport' : 'permission');
|
|
1065
|
+
return;
|
|
888
1066
|
}
|
|
889
|
-
|
|
1067
|
+
if (token !== generationToken.current) return;
|
|
1068
|
+
adoptCarrier(authorization.signaling);
|
|
1069
|
+
}
|
|
890
1070
|
|
|
891
1071
|
let openedResult: SessionOpenResult;
|
|
892
1072
|
try {
|
|
@@ -967,7 +1147,76 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
967
1147
|
await endRemote(openedResult, authorization.signaling);
|
|
968
1148
|
}
|
|
969
1149
|
}
|
|
970
|
-
}, [armFirstFrame, discardSession, endRemote, refuse,
|
|
1150
|
+
}, [adoptCarrier, armFirstFrame, discardSession, endRemote, refuse, update]);
|
|
1151
|
+
|
|
1152
|
+
/** Dial a carrier to replace a dead one, one authorize() at a time; reattach the session or open anew. */
|
|
1153
|
+
const dial = useCallback(() => {
|
|
1154
|
+
const run = dialing.current;
|
|
1155
|
+
if (run === null || run.inFlight) return;
|
|
1156
|
+
clearTimeout(run.retry);
|
|
1157
|
+
run.inFlight = true;
|
|
1158
|
+
void (async () => {
|
|
1159
|
+
let authorization: { signaling: Signaling; session: SessionOpenRequest };
|
|
1160
|
+
try {
|
|
1161
|
+
authorization = await withDeadline(optionsRef.current.authorize(), DIAL_TIMEOUT_MS, (late) => late.signaling.close?.());
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
if (dialing.current !== run) return;
|
|
1164
|
+
run.inFlight = false;
|
|
1165
|
+
if (error === DEADLINE || errorCode(error) === 'transport') {
|
|
1166
|
+
run.retry = setTimeout(() => dialRef.current(), DIAL_RETRY_MS);
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
// A refused pairing is final: a revoked phone must not reattach.
|
|
1170
|
+
const message = error instanceof Error ? error.message : 'the desktop was not authorized';
|
|
1171
|
+
await teardown(message, true);
|
|
1172
|
+
update({ failure: { code: 'permission', message } });
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
if (dialing.current !== run) {
|
|
1176
|
+
authorization.signaling.close?.();
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
stopDialing();
|
|
1180
|
+
// Past the reattach window the host has ended the held session: open anew on this carrier.
|
|
1181
|
+
if (Date.now() - run.since >= REATTACH_WINDOW_MS) discardSession();
|
|
1182
|
+
adoptCarrier(authorization.signaling);
|
|
1183
|
+
if (opened.current == null) {
|
|
1184
|
+
attempts.current = 0;
|
|
1185
|
+
cancelReconnect();
|
|
1186
|
+
void establish(authorization);
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
// A picture that has only just stopped may still be riding its
|
|
1190
|
+
// old ICE pair back: on an unchanged path that pair resumes in
|
|
1191
|
+
// ~100 ms, so a brief blip waits that moment out before a restart
|
|
1192
|
+
// is spent. A picture still past the line is a real outage — the
|
|
1193
|
+
// wait shortens to one stats beat, because waiting out a blip
|
|
1194
|
+
// that never comes would cost the recovery its bar.
|
|
1195
|
+
const held = opened.current, before = mediaRef.current;
|
|
1196
|
+
cancelRestart();
|
|
1197
|
+
const stillMs = mediaMovingAt.current === null ? Infinity : Date.now() - mediaMovingAt.current;
|
|
1198
|
+
const graceMs = stillMs < REATTACH_GRACE_BLIP_MAX_MS ? REATTACH_GRACE_MS : REATTACH_GRACE_SETTLE_MS;
|
|
1199
|
+
restartTimer.current = setTimeout(() => {
|
|
1200
|
+
restartTimer.current = null;
|
|
1201
|
+
void getStats().catch(() => []).then((rows) => {
|
|
1202
|
+
if (opened.current !== held || dialing.current !== null) return;
|
|
1203
|
+
// The picture moves: only the carrier was missing.
|
|
1204
|
+
if ((mediaCounts(rows)?.frames ?? 0) > (before?.frames ?? Infinity)) {
|
|
1205
|
+
if (statusRef.current === 'reconnecting' || (statusRef.current === 'failed' && heldIceFailureAt.current !== null)) onTransportState('connected', false);
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
if (statusRef.current !== 'reconnecting') update({ status: 'reconnecting' });
|
|
1209
|
+
restartAttempts.current = restartCycles.current = 0;
|
|
1210
|
+
// A transport `disconnected` that landed while the stats read
|
|
1211
|
+
// above was in flight armed `scheduleRestart(RESTART_AFTER_MS)`;
|
|
1212
|
+
// without clearing it first the request here would return early
|
|
1213
|
+
// on the armed timer and the restart would slip by up to 2 s.
|
|
1214
|
+
cancelRestart();
|
|
1215
|
+
scheduleRestart(0);
|
|
1216
|
+
});
|
|
1217
|
+
}, graceMs);
|
|
1218
|
+
})();
|
|
1219
|
+
}, [adoptCarrier, cancelReconnect, cancelRestart, discardSession, establish, getStats, onTransportState, scheduleRestart, stopDialing, teardown, update]);
|
|
971
1220
|
|
|
972
1221
|
/**
|
|
973
1222
|
* Open (or reopen) the session with fresh authority. A deliberate call
|
|
@@ -979,8 +1228,15 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
979
1228
|
restartAttempts.current = 0;
|
|
980
1229
|
restartCycles.current = 0;
|
|
981
1230
|
cancelRestart();
|
|
1231
|
+
if (heldIceFailureAt.current !== null && statusRef.current === 'failed') {
|
|
1232
|
+
const owner = signaling.current;
|
|
1233
|
+
const openedRef = opened.current;
|
|
1234
|
+
heldIceFailureAt.current = null;
|
|
1235
|
+
discardSession();
|
|
1236
|
+
void endRemote(openedRef, owner);
|
|
1237
|
+
}
|
|
982
1238
|
await establish();
|
|
983
|
-
}, [cancelRestart, establish]);
|
|
1239
|
+
}, [cancelRestart, discardSession, endRemote, establish]);
|
|
984
1240
|
|
|
985
1241
|
// Native events: answer, candidates, control replies, presentation, failure.
|
|
986
1242
|
useEffect(() => {
|
|
@@ -998,6 +1254,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
998
1254
|
if (event.sessionId !== nativeRef.current) return;
|
|
999
1255
|
const openedRef = opened.current;
|
|
1000
1256
|
const channel = signaling.current;
|
|
1257
|
+
const epoch = carrierEpoch.current;
|
|
1001
1258
|
switch (event.name) {
|
|
1002
1259
|
case 'answer': {
|
|
1003
1260
|
const sdp = String(event.payload.sdp ?? '');
|
|
@@ -1006,7 +1263,11 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
1006
1263
|
session_id: openedRef.sessionId,
|
|
1007
1264
|
generation: openedRef.generation,
|
|
1008
1265
|
description: { type: 'answer', sdp },
|
|
1009
|
-
}).catch(() =>
|
|
1266
|
+
}).catch((error) => {
|
|
1267
|
+
// A dead or replaced carrier is the dial loop's, not a refusal.
|
|
1268
|
+
if (carrierFailed(epoch, error) || opened.current !== openedRef) return;
|
|
1269
|
+
refuse('the desktop refused our answer', 'transport');
|
|
1270
|
+
});
|
|
1010
1271
|
return;
|
|
1011
1272
|
}
|
|
1012
1273
|
case 'candidate': {
|
|
@@ -1017,7 +1278,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
1017
1278
|
candidate: String(event.payload.candidate ?? ''),
|
|
1018
1279
|
sdpMid: (event.payload.sdpMid as string | null) ?? null,
|
|
1019
1280
|
sdpMLineIndex: (event.payload.sdpMLineIndex as number | null) ?? null,
|
|
1020
|
-
}).catch(() =>
|
|
1281
|
+
}).catch((error) => { carrierFailed(epoch, error); });
|
|
1021
1282
|
return;
|
|
1022
1283
|
}
|
|
1023
1284
|
case 'track':
|
|
@@ -1097,7 +1358,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
1097
1358
|
// The peer's connection state, as it happens: Android
|
|
1098
1359
|
// and iOS already emit this, and web matches them.
|
|
1099
1360
|
const state = String(event.payload.state ?? '').toLowerCase();
|
|
1100
|
-
if (state === 'failed') transportFailed();
|
|
1361
|
+
if (state === 'failed') transportFailed(UNREACHABLE_DESKTOP, true);
|
|
1101
1362
|
else onTransportState(state);
|
|
1102
1363
|
return;
|
|
1103
1364
|
}
|
|
@@ -1107,7 +1368,9 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
1107
1368
|
else if (typeof event.payload.text === 'string') typeText(event.payload.text);
|
|
1108
1369
|
return;
|
|
1109
1370
|
case 'failure':
|
|
1110
|
-
|
|
1371
|
+
// Only an ICE failure is held; an SDP or candidate
|
|
1372
|
+
// failure is terminal exactly as before.
|
|
1373
|
+
transportFailed(UNREACHABLE_DESKTOP, String(event.payload.reason ?? '') === 'ice');
|
|
1111
1374
|
return;
|
|
1112
1375
|
case 'closed':
|
|
1113
1376
|
return;
|
|
@@ -1121,7 +1384,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
1121
1384
|
dropped = true;
|
|
1122
1385
|
subscription?.remove();
|
|
1123
1386
|
};
|
|
1124
|
-
}, [transportFailed, onTransportState, cancelRestart, spendRestartAttempt, notePing, teardown, update, pressKey, typeText, armMedia]);
|
|
1387
|
+
}, [transportFailed, onTransportState, cancelRestart, spendRestartAttempt, notePing, teardown, update, pressKey, typeText, armMedia, carrierFailed]);
|
|
1125
1388
|
|
|
1126
1389
|
const copyRemoteToLocal = useCallback(async (writeLocal: (text: string) => Promise<void>) => {
|
|
1127
1390
|
if (!inputEnabled.current) throw new Error('Desktop control is off.');
|
|
@@ -1171,26 +1434,62 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
1171
1434
|
// across about a minute: a session that was revoked must not be reopened
|
|
1172
1435
|
// on the strength of the old grant, and an outage that outlasts one
|
|
1173
1436
|
// attempt must not spend the whole budget while the network is still down.
|
|
1437
|
+
//
|
|
1438
|
+
// An ICE failure is the one exception. It means the peer's path died, not
|
|
1439
|
+
// that the engine ended the session, so the held session (and its native
|
|
1440
|
+
// handle) is kept for HOLD_AFTER_FAILED_MS from the failure, whatever the
|
|
1441
|
+
// carrier watch is doing; a roam that outlasts the client's own ~15 s ICE
|
|
1442
|
+
// `failed` can then reattach and restart ICE on the same session. Only a
|
|
1443
|
+
// failure that is not ICE — SDP, candidate, a revoked transport — drops the
|
|
1444
|
+
// session at once, exactly as before.
|
|
1174
1445
|
useEffect(() => {
|
|
1175
|
-
|
|
1446
|
+
const immediate = reopenAtOnce.current;
|
|
1447
|
+
reopenAtOnce.current = false;
|
|
1448
|
+
if (snapshot.status !== 'failed') {
|
|
1449
|
+
heldIceFailureAt.current = null;
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1176
1452
|
if (snapshot.failure?.code !== 'transport') return;
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1453
|
+
const reopen = () => {
|
|
1454
|
+
// The failed session is dead on the engine side. Drop its handle and
|
|
1455
|
+
// close the remote session whether or not another attempt is left: a
|
|
1456
|
+
// failure the user can retry by hand must not sit behind a stale
|
|
1457
|
+
// native handle that makes `connect()` a no-op.
|
|
1458
|
+
const owner = signaling.current;
|
|
1459
|
+
const openedRef = opened.current;
|
|
1460
|
+
cancelRestart();
|
|
1461
|
+
discardSession();
|
|
1462
|
+
void endRemote(openedRef, owner);
|
|
1463
|
+
// With the carrier dead, the dial loop opens on the first one that comes up.
|
|
1464
|
+
if (dialing.current !== null) {
|
|
1465
|
+
update({ status: 'reconnecting' });
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
const wait = immediate ? 0 : backoffDelay(REOPEN_BACKOFF_MS, attempts.current);
|
|
1469
|
+
if (wait === null) return;
|
|
1470
|
+
attempts.current += 1;
|
|
1471
|
+
update({ status: 'reconnecting' });
|
|
1472
|
+
reconnectTimer.current = setTimeout(() => {
|
|
1473
|
+
reconnectTimer.current = null;
|
|
1474
|
+
void establish();
|
|
1475
|
+
}, wait);
|
|
1476
|
+
};
|
|
1477
|
+
const failedAt = heldIceFailureAt.current;
|
|
1478
|
+
const remaining = failedAt === null ? 0 : HOLD_AFTER_FAILED_MS - (Date.now() - failedAt);
|
|
1479
|
+
if (remaining <= 0) {
|
|
1480
|
+
heldIceFailureAt.current = null;
|
|
1481
|
+
reopen();
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
// Hold: leave the session and its handle in place, and only drop them
|
|
1485
|
+
// when the window lapses with no recovery. A recovery that takes the
|
|
1486
|
+
// status off `failed` runs this effect's cleanup and clears the timer.
|
|
1487
|
+
const holdTimer = setTimeout(() => {
|
|
1488
|
+
if (statusRef.current !== 'failed') return;
|
|
1489
|
+
heldIceFailureAt.current = null;
|
|
1490
|
+
reopen();
|
|
1491
|
+
}, remaining);
|
|
1492
|
+
return () => clearTimeout(holdTimer);
|
|
1194
1493
|
}, [establish, cancelRestart, discardSession, endRemote, snapshot.status, snapshot.failure, update]);
|
|
1195
1494
|
|
|
1196
1495
|
useEffect(() => () => {
|
|
@@ -1208,9 +1507,10 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
1208
1507
|
const openedRef = opened.current;
|
|
1209
1508
|
const owner = signaling.current;
|
|
1210
1509
|
opened.current = null;
|
|
1211
|
-
|
|
1510
|
+
stopDialing();
|
|
1511
|
+
dropCarrier(false);
|
|
1212
1512
|
void endRemote(openedRef, owner);
|
|
1213
|
-
}, [cancelReconnect, endRemote]);
|
|
1513
|
+
}, [cancelReconnect, endRemote, stopDialing, dropCarrier]);
|
|
1214
1514
|
|
|
1215
1515
|
// A phone that goes to the background must not leave a remote button held:
|
|
1216
1516
|
// the desktop cannot know the finger left the glass. This is the same
|