@desklink/react-native 0.5.3 → 0.5.4
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 +40 -11
- package/android/build.gradle +0 -1
- package/android/src/main/java/expo/modules/desklink/DesktopSession.kt +20 -12
- package/package.json +1 -1
- 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 +393 -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,20 @@ const STALL_AFTER_MS = 1200;
|
|
|
55
56
|
* trigger another restart while the window lasts.
|
|
56
57
|
*/
|
|
57
58
|
const RESTART_GRACE_MS = 2500;
|
|
59
|
+
/** How long a moving picture outlasts silent heartbeats: after an outage the data channel lags the video. */
|
|
60
|
+
const QUIET_PICTURE_MS = 20_000;
|
|
58
61
|
/** How long a restart offer may go unanswered before the next attempt. */
|
|
59
62
|
const RESTART_REPLY_MS = 5000;
|
|
63
|
+
/** A restart request unanswered this long means its carrier is dead, even if it never closed. */
|
|
64
|
+
const RESTART_REQUEST_DEADLINE_MS = 1500;
|
|
65
|
+
/** A dial that failed is retried this soon: the path may be back any moment. */
|
|
66
|
+
const DIAL_RETRY_MS = 250;
|
|
67
|
+
/** One dial's bound. Not shorter: Chromium throttles refused WebSocket handshakes. */
|
|
68
|
+
const DIAL_TIMEOUT_MS = 5000;
|
|
69
|
+
/** How long the host holds a session with no carrier (the bridge's `reattachMs`); past it, reopen. */
|
|
70
|
+
const REATTACH_WINDOW_MS = 30_000;
|
|
71
|
+
/** How long a reattached carrier waits for the old ICE pair before asking for a restart. */
|
|
72
|
+
const REATTACH_GRACE_MS = 300;
|
|
60
73
|
/**
|
|
61
74
|
* How long the media path may carry nothing at all before a session that says
|
|
62
75
|
* `live` stops saying so. The engine's transport state reaches the client over
|
|
@@ -98,6 +111,16 @@ export const REOPEN_BACKOFF_MS = [1000, 2000, 4000, 8000, 8000, 8000, 8000, 8000
|
|
|
98
111
|
* A return after that reopens at once instead of spending restarts on it.
|
|
99
112
|
*/
|
|
100
113
|
export const BACKGROUND_REOPEN_MS = 30_000;
|
|
114
|
+
/**
|
|
115
|
+
* A peer that reaches ICE `failed` has lost its media path, not its session:
|
|
116
|
+
* the engine keeps the session alive for about 28 s after the path dies (its
|
|
117
|
+
* ICE `Failed` timer). So a client-side ICE failure holds the session — and
|
|
118
|
+
* its native handle — this long from the failure, whatever the carrier is
|
|
119
|
+
* doing, instead of throwing it away at once. A roam longer than the client's
|
|
120
|
+
* own ~15 s ICE `failed` then still returns to the same session. An SDP or
|
|
121
|
+
* candidate failure is not a lost path and stays terminal as it always was.
|
|
122
|
+
*/
|
|
123
|
+
export const HOLD_AFTER_FAILED_MS = 30_000;
|
|
101
124
|
/**
|
|
102
125
|
* A resumed app hears what queued up while it was suspended just after it
|
|
103
126
|
* hears it is active again. A revocation among it decides whether a reopen is
|
|
@@ -105,6 +128,19 @@ export const BACKGROUND_REOPEN_MS = 30_000;
|
|
|
105
128
|
*/
|
|
106
129
|
const RESUME_DRAIN_MS = 750;
|
|
107
130
|
|
|
131
|
+
const DEADLINE = new Error('no reply in time');
|
|
132
|
+
|
|
133
|
+
/** `promise`, or a DEADLINE rejection after `ms`; a value that lands later goes to `late`. */
|
|
134
|
+
function withDeadline<T>(promise: Promise<T>, ms: number, late?: (value: T) => void): Promise<T> {
|
|
135
|
+
let expired = false;
|
|
136
|
+
return Promise.race([
|
|
137
|
+
promise.then((value) => { if (expired) late?.(value); return value; }),
|
|
138
|
+
new Promise<never>((_, reject) => setTimeout(() => { expired = true; reject(DEADLINE); }, ms)),
|
|
139
|
+
]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const errorCode = (error: unknown): unknown => (error as { code?: unknown } | null)?.code;
|
|
143
|
+
|
|
108
144
|
/** The delay before attempt `n` (0-based), or null when the schedule is spent. */
|
|
109
145
|
export function backoffDelay(schedule: readonly number[], attempt: number): number | null {
|
|
110
146
|
return attempt < schedule.length ? schedule[attempt]! : null;
|
|
@@ -348,6 +384,12 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
348
384
|
const restartCycles = useRef(0);
|
|
349
385
|
/** The last ICE state the peer or the engine reported. */
|
|
350
386
|
const iceConnected = useRef(false);
|
|
387
|
+
/**
|
|
388
|
+
* When the peer's own ICE last failed while the session was held, or null
|
|
389
|
+
* when the current failure is not an ICE one. Kept across the hold so the
|
|
390
|
+
* reopen effect can measure the remaining window.
|
|
391
|
+
*/
|
|
392
|
+
const heldIceFailureAt = useRef<number | null>(null);
|
|
351
393
|
/** The engine's restore token, for a consent-free reopen. Unset by hand. */
|
|
352
394
|
const restoreToken = useRef<string | null>(null);
|
|
353
395
|
/** The latest status and presentation, read by event handlers. */
|
|
@@ -360,6 +402,13 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
360
402
|
const awaySince = useRef<number | null>(null);
|
|
361
403
|
/** Guards every asynchronous callback against a session that already ended. */
|
|
362
404
|
const generationToken = useRef(0);
|
|
405
|
+
/** Bumped per carrier adopted or dropped: an older carrier's events and replies are ignored. */
|
|
406
|
+
const carrierEpoch = useRef(0);
|
|
407
|
+
const unsubscribeCarrier = useRef<(() => void) | null>(null);
|
|
408
|
+
/** While the carrier is dead, whatever the status: the dial in flight and its retry. */
|
|
409
|
+
const dialing = useRef<{ inFlight: boolean; since: number; retry?: ReturnType<typeof setTimeout> } | null>(null);
|
|
410
|
+
/** Set with a reopen that skips its backoff: the host no longer holds the session at all. */
|
|
411
|
+
const reopenAtOnce = useRef(false);
|
|
363
412
|
const optionsRef = useRef(options);
|
|
364
413
|
optionsRef.current = options;
|
|
365
414
|
|
|
@@ -375,21 +424,30 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
375
424
|
optionsRef.current.onStateChange?.(next);
|
|
376
425
|
}, []);
|
|
377
426
|
|
|
378
|
-
const fail = useCallback((failure: SessionFailure) => {
|
|
427
|
+
const fail = useCallback((failure: SessionFailure, ice = false) => {
|
|
428
|
+
heldIceFailureAt.current = failure.code === 'transport' && ice && nativeRef.current != null ? Date.now() : null;
|
|
379
429
|
update({ status: 'failed', failure });
|
|
380
430
|
optionsRef.current.onError?.(failure);
|
|
381
431
|
}, [update]);
|
|
382
432
|
|
|
383
|
-
const refuse = useCallback((message: string, code: SessionFailure['code'] = 'platform') => {
|
|
384
|
-
fail({ code, message });
|
|
433
|
+
const refuse = useCallback((message: string, code: SessionFailure['code'] = 'platform', ice = false) => {
|
|
434
|
+
fail({ code, message }, ice);
|
|
385
435
|
}, [fail]);
|
|
386
436
|
|
|
387
437
|
/** A transport loss is one event no matter how many sides report it: the
|
|
388
438
|
* peer's `failed` state and its `failure` event would otherwise spend the
|
|
389
|
-
* reopen budget twice for the same outage.
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
439
|
+
* reopen budget twice for the same outage. While dialing, it stays
|
|
440
|
+
* `reconnecting`.
|
|
441
|
+
*
|
|
442
|
+
* `ice` marks a failure of the peer's own media path (its ICE `failed`
|
|
443
|
+
* state, or a `failure` event whose reason is `ice`). While the session is
|
|
444
|
+
* held and has not been revoked, that is not the end of the session: the
|
|
445
|
+
* reopen effect keeps it for `HOLD_AFTER_FAILED_MS` first. */
|
|
446
|
+
const transportFailed = useCallback((message: string = UNREACHABLE_DESKTOP, ice = false): boolean => {
|
|
447
|
+
if (dialing.current !== null) return false;
|
|
448
|
+
if (ice && statusRef.current === 'failed') return false;
|
|
449
|
+
refuse(message, 'transport', ice);
|
|
450
|
+
return true;
|
|
393
451
|
}, [refuse]);
|
|
394
452
|
|
|
395
453
|
const cancelRestart = useCallback(() => {
|
|
@@ -412,20 +470,69 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
412
470
|
firstFrameTimer.current = null;
|
|
413
471
|
}, []);
|
|
414
472
|
|
|
473
|
+
const stopDialing = useCallback(() => { clearTimeout(dialing.current?.retry); dialing.current = null; }, []);
|
|
474
|
+
|
|
415
475
|
/**
|
|
416
476
|
* A first connection that stays frameless for this long is not going to
|
|
417
477
|
* arrive: report why instead of leaving an empty picture on screen.
|
|
418
478
|
*/
|
|
419
479
|
const armFirstFrame = useCallback(() => {
|
|
420
480
|
cancelFirstFrame();
|
|
421
|
-
|
|
422
|
-
firstFrameTimer.current =
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
481
|
+
const watch: () => void = () => {
|
|
482
|
+
firstFrameTimer.current = setTimeout(() => {
|
|
483
|
+
firstFrameTimer.current = null;
|
|
484
|
+
const status = statusRef.current;
|
|
485
|
+
if (!presentedRef.current && status === 'failed' && heldIceFailureAt.current !== null) {
|
|
486
|
+
watch();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (presentedRef.current || (status !== 'connecting' && status !== 'reconnecting')) return;
|
|
490
|
+
stopDialing();
|
|
491
|
+
transportFailed(NO_PICTURE);
|
|
492
|
+
}, FIRST_FRAME_AFTER_MS);
|
|
493
|
+
};
|
|
494
|
+
watch();
|
|
495
|
+
}, [cancelFirstFrame, stopDialing, transportFailed]);
|
|
427
496
|
|
|
428
497
|
const scheduleRestartRef = useRef<(delay: number) => void>(() => undefined);
|
|
498
|
+
const dialRef = useRef<() => void>(() => undefined);
|
|
499
|
+
const carrierEventRef = useRef<(event: SessionEvent) => void>(() => undefined);
|
|
500
|
+
|
|
501
|
+
/** Stop listening to the carrier; a dead one is also closed, so nothing more is sent on it. */
|
|
502
|
+
const dropCarrier = useCallback((dead: boolean) => {
|
|
503
|
+
carrierEpoch.current += 1;
|
|
504
|
+
unsubscribeCarrier.current?.();
|
|
505
|
+
unsubscribeCarrier.current = null;
|
|
506
|
+
const channel = signaling.current;
|
|
507
|
+
signaling.current = null;
|
|
508
|
+
if (dead) channel?.close?.();
|
|
509
|
+
}, []);
|
|
510
|
+
|
|
511
|
+
const adoptCarrier = useCallback((channel: Signaling) => {
|
|
512
|
+
dropCarrier(false);
|
|
513
|
+
const epoch = carrierEpoch.current;
|
|
514
|
+
signaling.current = channel;
|
|
515
|
+
unsubscribeCarrier.current = channel.subscribe((event) => {
|
|
516
|
+
if (epoch === carrierEpoch.current) carrierEventRef.current(event);
|
|
517
|
+
});
|
|
518
|
+
}, [dropCarrier]);
|
|
519
|
+
|
|
520
|
+
/** Carrier `epoch` is dead: drop it hard and, while a session is held, dial a new one. */
|
|
521
|
+
const carrierLost = useCallback((epoch: number) => {
|
|
522
|
+
if (epoch !== carrierEpoch.current || signaling.current == null) return;
|
|
523
|
+
dropCarrier(true);
|
|
524
|
+
if (dialing.current !== null || opened.current == null) return;
|
|
525
|
+
dialing.current = { inFlight: false, since: Date.now() };
|
|
526
|
+
dialRef.current();
|
|
527
|
+
}, [dropCarrier]);
|
|
528
|
+
|
|
529
|
+
/** Whether a request on carrier `epoch` failed because that carrier is gone; the dial loop then owns it. */
|
|
530
|
+
const carrierFailed = useCallback((epoch: number, error: unknown): boolean => {
|
|
531
|
+
if (epoch !== carrierEpoch.current) return true;
|
|
532
|
+
if (error !== DEADLINE && errorCode(error) !== 'transport') return false;
|
|
533
|
+
carrierLost(epoch);
|
|
534
|
+
return true;
|
|
535
|
+
}, [carrierLost]);
|
|
429
536
|
|
|
430
537
|
/**
|
|
431
538
|
* Spend one restart from the backoff schedule, or reopen when the schedule
|
|
@@ -465,20 +572,33 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
465
572
|
if (statusRef.current !== 'reconnecting') return;
|
|
466
573
|
const openedRef = opened.current;
|
|
467
574
|
const channel = signaling.current;
|
|
468
|
-
if (openedRef == null
|
|
575
|
+
if (openedRef == null) {
|
|
469
576
|
spendRestartAttempt();
|
|
470
577
|
return;
|
|
471
578
|
}
|
|
579
|
+
// No carrier: the dial loop restarts once it has a new one.
|
|
580
|
+
if (channel == null) return;
|
|
581
|
+
const epoch = carrierEpoch.current;
|
|
582
|
+
const token = generationToken.current;
|
|
472
583
|
void (async () => {
|
|
473
584
|
try {
|
|
474
|
-
await channel.request('session.restart_ice', {
|
|
585
|
+
await withDeadline(channel.request('session.restart_ice', {
|
|
475
586
|
session_id: openedRef.sessionId,
|
|
476
587
|
generation: openedRef.generation,
|
|
477
|
-
});
|
|
478
|
-
} catch {
|
|
588
|
+
}), RESTART_REQUEST_DEADLINE_MS);
|
|
589
|
+
} catch (error) {
|
|
590
|
+
if (token !== generationToken.current || carrierFailed(epoch, error)) return;
|
|
591
|
+
// The host no longer holds this session: reopen without waiting out a backoff.
|
|
592
|
+
if (errorCode(error) === 'session' || errorCode(error) === 'generation') {
|
|
593
|
+
attempts.current = 0;
|
|
594
|
+
reopenAtOnce.current = transportFailed('the desktop no longer holds this session; reopening it');
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
479
597
|
spendRestartAttempt();
|
|
480
598
|
return;
|
|
481
599
|
}
|
|
600
|
+
// A late reply from a replaced carrier or session says nothing about this one.
|
|
601
|
+
if (epoch !== carrierEpoch.current || token !== generationToken.current) return;
|
|
482
602
|
if (statusRef.current !== 'reconnecting') return;
|
|
483
603
|
restartGraceUntil.current = Date.now() + RESTART_GRACE_MS;
|
|
484
604
|
restartCycles.current += 1;
|
|
@@ -501,10 +621,12 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
501
621
|
}, replyMs);
|
|
502
622
|
})();
|
|
503
623
|
}, delay);
|
|
504
|
-
}, [spendRestartAttempt, transportFailed]);
|
|
624
|
+
}, [spendRestartAttempt, transportFailed, carrierFailed]);
|
|
505
625
|
|
|
506
626
|
useEffect(() => {
|
|
507
627
|
scheduleRestartRef.current = scheduleRestart;
|
|
628
|
+
dialRef.current = dial;
|
|
629
|
+
carrierEventRef.current = onCarrierEvent;
|
|
508
630
|
checkStallRef.current = checkStall;
|
|
509
631
|
mediaWatchRef.current = checkMedia;
|
|
510
632
|
});
|
|
@@ -526,6 +648,11 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
526
648
|
const onTransportState = useCallback((state: string, trackIce = true, skipGrace = false) => {
|
|
527
649
|
// Synthetic stall reports say nothing about ICE itself.
|
|
528
650
|
if (trackIce) iceConnected.current = state === 'connected' || state === 'completed';
|
|
651
|
+
if (heldIceFailureAt.current !== null && statusRef.current === 'failed'
|
|
652
|
+
&& (state === 'connected' || state === 'completed')) {
|
|
653
|
+
heldIceFailureAt.current = null;
|
|
654
|
+
update({ status: 'reconnecting' });
|
|
655
|
+
}
|
|
529
656
|
const mapped = connectionStatusFor(state, statusRef.current, presentedRef.current && !frozen.current);
|
|
530
657
|
if (mapped === null) return;
|
|
531
658
|
// A transport that answers while the picture is frozen is not a
|
|
@@ -598,7 +725,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
598
725
|
frozen.current = false;
|
|
599
726
|
// The picture advances again, so the transport can be believed
|
|
600
727
|
// for this session: live returns without waiting for it to say so.
|
|
601
|
-
if (statusRef.current === 'reconnecting') onTransportState('connected', false);
|
|
728
|
+
if (statusRef.current === 'reconnecting' || (statusRef.current === 'failed' && heldIceFailureAt.current !== null)) onTransportState('connected', false);
|
|
602
729
|
return;
|
|
603
730
|
}
|
|
604
731
|
if (mediaMovingAt.current === null) return;
|
|
@@ -612,6 +739,8 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
612
739
|
}, () => undefined);
|
|
613
740
|
}, [armMedia, onTransportState]);
|
|
614
741
|
|
|
742
|
+
const pictureMoving = useCallback((within: number): boolean => mediaMovingAt.current !== null && Date.now() - mediaMovingAt.current < within, []);
|
|
743
|
+
|
|
615
744
|
/**
|
|
616
745
|
* One stall check: quiet too long while the session should be talking
|
|
617
746
|
* means the path is stalled, even when ICE has not said so yet. Only
|
|
@@ -634,8 +763,12 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
634
763
|
stallTimer.current = setTimeout(() => checkStallRef.current(), restartGraceUntil.current - Date.now());
|
|
635
764
|
return;
|
|
636
765
|
}
|
|
766
|
+
if (pictureMoving(STALL_AFTER_MS) && !frozen.current && quietMs < QUIET_PICTURE_MS) {
|
|
767
|
+
stallTimer.current = setTimeout(() => checkStallRef.current(), STALL_AFTER_MS);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
637
770
|
onTransportState('disconnected', false);
|
|
638
|
-
}, [onTransportState]);
|
|
771
|
+
}, [onTransportState, pictureMoving]);
|
|
639
772
|
|
|
640
773
|
/**
|
|
641
774
|
* One engine heartbeat: the path works. It clears a stall the watchdog
|
|
@@ -733,6 +866,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
733
866
|
/** Drop everything this process holds for a session the engine has ended. */
|
|
734
867
|
const discardSession = useCallback(() => {
|
|
735
868
|
generationToken.current += 1;
|
|
869
|
+
cancelFirstFrame();
|
|
736
870
|
const id = nativeRef.current;
|
|
737
871
|
nativeRef.current = null;
|
|
738
872
|
inputEnabled.current = false;
|
|
@@ -741,6 +875,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
741
875
|
pendingOffer.current = null;
|
|
742
876
|
pendingCandidates.current = [];
|
|
743
877
|
pendingCursorEvents.current = [];
|
|
878
|
+
heldIceFailureAt.current = null;
|
|
744
879
|
restartGraceUntil.current = 0;
|
|
745
880
|
restartCycles.current = 0;
|
|
746
881
|
// A new session has its own media path: the last one's counts and its
|
|
@@ -751,13 +886,13 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
751
886
|
mediaMovingAt.current = null;
|
|
752
887
|
frozen.current = false;
|
|
753
888
|
opened.current = null;
|
|
754
|
-
|
|
889
|
+
dropCarrier(false);
|
|
755
890
|
setNativeId(null);
|
|
756
891
|
// An armed modifier belongs to the session it was armed in.
|
|
757
892
|
modifiersRef.current = NO_MODIFIERS;
|
|
758
893
|
setModifiers(NO_MODIFIERS);
|
|
759
894
|
if (id != null) nativeDesklink?.closeSession(id);
|
|
760
|
-
}, []);
|
|
895
|
+
}, [cancelFirstFrame, dropCarrier]);
|
|
761
896
|
|
|
762
897
|
/** Tell the host a session is over; a failure is nothing the user can act on. */
|
|
763
898
|
const endRemote = useCallback(async (
|
|
@@ -780,6 +915,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
780
915
|
cancelRestart();
|
|
781
916
|
cancelStall();
|
|
782
917
|
cancelFirstFrame();
|
|
918
|
+
stopDialing();
|
|
783
919
|
restoreToken.current = null;
|
|
784
920
|
const openedRef = opened.current;
|
|
785
921
|
const owner = signaling.current;
|
|
@@ -787,10 +923,98 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
787
923
|
if (closeRemote) await endRemote(openedRef, owner);
|
|
788
924
|
update({ status: 'ended', presented: false, failure: null });
|
|
789
925
|
void reason;
|
|
790
|
-
}, [cancelReconnect, cancelRestart, cancelStall, cancelFirstFrame, endRemote, discardSession, update]);
|
|
926
|
+
}, [cancelReconnect, cancelRestart, cancelStall, cancelFirstFrame, stopDialing, endRemote, discardSession, update]);
|
|
791
927
|
|
|
792
|
-
|
|
928
|
+
/**
|
|
929
|
+
* One engine event from the live carrier. Subscribed before the open,
|
|
930
|
+
* because the engine's offer and its first candidates can beat the open
|
|
931
|
+
* result back: anything that arrives before the native session exists is
|
|
932
|
+
* stashed and replayed into it.
|
|
933
|
+
*/
|
|
934
|
+
const onCarrierEvent = useCallback((event: SessionEvent) => {
|
|
935
|
+
const native = nativeRef.current;
|
|
936
|
+
const held = opened.current;
|
|
937
|
+
const current = event.sessionId === undefined || held == null || held.sessionId === event.sessionId;
|
|
938
|
+
switch (event.kind) {
|
|
939
|
+
case 'carrier-closed':
|
|
940
|
+
carrierLost(carrierEpoch.current);
|
|
941
|
+
return;
|
|
942
|
+
case 'description':
|
|
943
|
+
if (event.description.type !== 'offer') return;
|
|
944
|
+
if (native == null || held == null) {
|
|
945
|
+
pendingOffer.current = { sdp: event.description.sdp, sessionId: event.sessionId };
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (!current) return;
|
|
949
|
+
// A mid-session re-offer is a restart this session asked
|
|
950
|
+
// for, through its policy or directly: renegotiation
|
|
951
|
+
// transients after it are expected, not a new outage.
|
|
952
|
+
if (presentedRef.current) restartGraceUntil.current = Date.now() + RESTART_GRACE_MS;
|
|
953
|
+
nativeDesklink?.setRemoteDescription(native, 'offer', event.description.sdp);
|
|
954
|
+
return;
|
|
955
|
+
case 'candidate':
|
|
956
|
+
if (native == null || held == null) {
|
|
957
|
+
pendingCandidates.current.push({
|
|
958
|
+
candidate: event.candidate.candidate,
|
|
959
|
+
sdpMid: event.candidate.sdpMid ?? null,
|
|
960
|
+
sdpMLineIndex: event.candidate.sdpMLineIndex ?? null,
|
|
961
|
+
sessionId: event.sessionId,
|
|
962
|
+
});
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
if (!current) return;
|
|
966
|
+
nativeDesklink?.addRemoteCandidate(
|
|
967
|
+
native,
|
|
968
|
+
event.candidate.candidate,
|
|
969
|
+
event.candidate.sdpMid ?? null,
|
|
970
|
+
event.candidate.sdpMLineIndex ?? null,
|
|
971
|
+
);
|
|
972
|
+
return;
|
|
973
|
+
case 'cursor':
|
|
974
|
+
if (held == null) {
|
|
975
|
+
pendingCursorEvents.current.push(event);
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
if (current) optionsRef.current.onCursor?.(event);
|
|
979
|
+
return;
|
|
980
|
+
case 'state':
|
|
981
|
+
if (!current) return;
|
|
982
|
+
diagnostics.current.transport = event.transport;
|
|
983
|
+
// Before the session exists there is nothing to map onto.
|
|
984
|
+
if (held != null) onTransportState(String(event.transport ?? '').toLowerCase());
|
|
985
|
+
update({ diagnostics: { ...diagnostics.current } });
|
|
986
|
+
return;
|
|
987
|
+
case 'restoreToken':
|
|
988
|
+
// The live session's grant for a consent-free reopen.
|
|
989
|
+
if (current && held != null) restoreToken.current = event.token;
|
|
990
|
+
return;
|
|
991
|
+
case 'revoked':
|
|
992
|
+
// Nothing held: the reopen policy owns recovery, and a
|
|
993
|
+
// dead session's late revocation must not end it.
|
|
994
|
+
if (held == null || !current) return;
|
|
995
|
+
// A lost path is an outage, not a verdict: the reopen
|
|
996
|
+
// policy asks for fresh authority. Any other code, or
|
|
997
|
+
// none, is the session's authority ending.
|
|
998
|
+
if (event.code === 'transport') {
|
|
999
|
+
transportFailed(event.reason);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
void teardown(event.reason, false);
|
|
1003
|
+
update({ status: 'ended', failure: { code: 'revoked', message: event.reason } });
|
|
1004
|
+
return;
|
|
1005
|
+
default:
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
}, [carrierLost, onTransportState, teardown, transportFailed, update]);
|
|
1009
|
+
|
|
1010
|
+
/** Open a session on `given`, a carrier the dial loop just authorized, or on a fresh authorization. */
|
|
1011
|
+
const establish = useCallback(async (given?: { signaling: Signaling; session: SessionOpenRequest }) => {
|
|
793
1012
|
if (nativeRef.current != null) return;
|
|
1013
|
+
// While a dead carrier is replaced, the dial loop opens on the new one.
|
|
1014
|
+
if (given === undefined && dialing.current !== null) {
|
|
1015
|
+
dialRef.current();
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
794
1018
|
const token = ++generationToken.current;
|
|
795
1019
|
pendingCursorEvents.current = [];
|
|
796
1020
|
if (!(await loadPlatform())) {
|
|
@@ -800,93 +1024,17 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
800
1024
|
if (token !== generationToken.current) return;
|
|
801
1025
|
update({ status: 'opening', failure: null, presented: false });
|
|
802
1026
|
|
|
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;
|
|
1027
|
+
let authorization = given;
|
|
1028
|
+
if (authorization === undefined) {
|
|
1029
|
+
try {
|
|
1030
|
+
authorization = await optionsRef.current.authorize();
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
refuse(error instanceof Error ? error.message : 'the desktop was not authorized', errorCode(error) === 'transport' ? 'transport' : 'permission');
|
|
1033
|
+
return;
|
|
888
1034
|
}
|
|
889
|
-
|
|
1035
|
+
if (token !== generationToken.current) return;
|
|
1036
|
+
adoptCarrier(authorization.signaling);
|
|
1037
|
+
}
|
|
890
1038
|
|
|
891
1039
|
let openedResult: SessionOpenResult;
|
|
892
1040
|
try {
|
|
@@ -967,7 +1115,64 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
967
1115
|
await endRemote(openedResult, authorization.signaling);
|
|
968
1116
|
}
|
|
969
1117
|
}
|
|
970
|
-
}, [armFirstFrame, discardSession, endRemote, refuse,
|
|
1118
|
+
}, [adoptCarrier, armFirstFrame, discardSession, endRemote, refuse, update]);
|
|
1119
|
+
|
|
1120
|
+
/** Dial a carrier to replace a dead one, one authorize() at a time; reattach the session or open anew. */
|
|
1121
|
+
const dial = useCallback(() => {
|
|
1122
|
+
const run = dialing.current;
|
|
1123
|
+
if (run === null || run.inFlight) return;
|
|
1124
|
+
clearTimeout(run.retry);
|
|
1125
|
+
run.inFlight = true;
|
|
1126
|
+
void (async () => {
|
|
1127
|
+
let authorization: { signaling: Signaling; session: SessionOpenRequest };
|
|
1128
|
+
try {
|
|
1129
|
+
authorization = await withDeadline(optionsRef.current.authorize(), DIAL_TIMEOUT_MS, (late) => late.signaling.close?.());
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
if (dialing.current !== run) return;
|
|
1132
|
+
run.inFlight = false;
|
|
1133
|
+
if (error === DEADLINE || errorCode(error) === 'transport') {
|
|
1134
|
+
run.retry = setTimeout(() => dialRef.current(), DIAL_RETRY_MS);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
// A refused pairing is final: a revoked phone must not reattach.
|
|
1138
|
+
const message = error instanceof Error ? error.message : 'the desktop was not authorized';
|
|
1139
|
+
await teardown(message, true);
|
|
1140
|
+
update({ failure: { code: 'permission', message } });
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
if (dialing.current !== run) {
|
|
1144
|
+
authorization.signaling.close?.();
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
stopDialing();
|
|
1148
|
+
// Past the reattach window the host has ended the held session: open anew on this carrier.
|
|
1149
|
+
if (Date.now() - run.since >= REATTACH_WINDOW_MS) discardSession();
|
|
1150
|
+
adoptCarrier(authorization.signaling);
|
|
1151
|
+
if (opened.current == null) {
|
|
1152
|
+
attempts.current = 0;
|
|
1153
|
+
cancelReconnect();
|
|
1154
|
+
void establish(authorization);
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
// The old pair on an unchanged path resumes in ~100 ms: give it a moment before spending a restart.
|
|
1158
|
+
const held = opened.current, before = mediaRef.current;
|
|
1159
|
+
cancelRestart();
|
|
1160
|
+
restartTimer.current = setTimeout(() => {
|
|
1161
|
+
restartTimer.current = null;
|
|
1162
|
+
void getStats().catch(() => []).then((rows) => {
|
|
1163
|
+
if (opened.current !== held || dialing.current !== null) return;
|
|
1164
|
+
// The picture moves: only the carrier was missing.
|
|
1165
|
+
if ((mediaCounts(rows)?.frames ?? 0) > (before?.frames ?? Infinity)) {
|
|
1166
|
+
if (statusRef.current === 'reconnecting' || (statusRef.current === 'failed' && heldIceFailureAt.current !== null)) onTransportState('connected', false);
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
if (statusRef.current !== 'reconnecting') update({ status: 'reconnecting' });
|
|
1170
|
+
restartAttempts.current = restartCycles.current = 0;
|
|
1171
|
+
scheduleRestart(0);
|
|
1172
|
+
});
|
|
1173
|
+
}, REATTACH_GRACE_MS);
|
|
1174
|
+
})();
|
|
1175
|
+
}, [adoptCarrier, cancelReconnect, cancelRestart, discardSession, establish, getStats, onTransportState, scheduleRestart, stopDialing, teardown, update]);
|
|
971
1176
|
|
|
972
1177
|
/**
|
|
973
1178
|
* Open (or reopen) the session with fresh authority. A deliberate call
|
|
@@ -979,8 +1184,15 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
979
1184
|
restartAttempts.current = 0;
|
|
980
1185
|
restartCycles.current = 0;
|
|
981
1186
|
cancelRestart();
|
|
1187
|
+
if (heldIceFailureAt.current !== null && statusRef.current === 'failed') {
|
|
1188
|
+
const owner = signaling.current;
|
|
1189
|
+
const openedRef = opened.current;
|
|
1190
|
+
heldIceFailureAt.current = null;
|
|
1191
|
+
discardSession();
|
|
1192
|
+
void endRemote(openedRef, owner);
|
|
1193
|
+
}
|
|
982
1194
|
await establish();
|
|
983
|
-
}, [cancelRestart, establish]);
|
|
1195
|
+
}, [cancelRestart, discardSession, endRemote, establish]);
|
|
984
1196
|
|
|
985
1197
|
// Native events: answer, candidates, control replies, presentation, failure.
|
|
986
1198
|
useEffect(() => {
|
|
@@ -998,6 +1210,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
998
1210
|
if (event.sessionId !== nativeRef.current) return;
|
|
999
1211
|
const openedRef = opened.current;
|
|
1000
1212
|
const channel = signaling.current;
|
|
1213
|
+
const epoch = carrierEpoch.current;
|
|
1001
1214
|
switch (event.name) {
|
|
1002
1215
|
case 'answer': {
|
|
1003
1216
|
const sdp = String(event.payload.sdp ?? '');
|
|
@@ -1006,7 +1219,11 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
1006
1219
|
session_id: openedRef.sessionId,
|
|
1007
1220
|
generation: openedRef.generation,
|
|
1008
1221
|
description: { type: 'answer', sdp },
|
|
1009
|
-
}).catch(() =>
|
|
1222
|
+
}).catch((error) => {
|
|
1223
|
+
// A dead or replaced carrier is the dial loop's, not a refusal.
|
|
1224
|
+
if (carrierFailed(epoch, error) || opened.current !== openedRef) return;
|
|
1225
|
+
refuse('the desktop refused our answer', 'transport');
|
|
1226
|
+
});
|
|
1010
1227
|
return;
|
|
1011
1228
|
}
|
|
1012
1229
|
case 'candidate': {
|
|
@@ -1017,7 +1234,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
1017
1234
|
candidate: String(event.payload.candidate ?? ''),
|
|
1018
1235
|
sdpMid: (event.payload.sdpMid as string | null) ?? null,
|
|
1019
1236
|
sdpMLineIndex: (event.payload.sdpMLineIndex as number | null) ?? null,
|
|
1020
|
-
}).catch(() =>
|
|
1237
|
+
}).catch((error) => { carrierFailed(epoch, error); });
|
|
1021
1238
|
return;
|
|
1022
1239
|
}
|
|
1023
1240
|
case 'track':
|
|
@@ -1097,7 +1314,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
1097
1314
|
// The peer's connection state, as it happens: Android
|
|
1098
1315
|
// and iOS already emit this, and web matches them.
|
|
1099
1316
|
const state = String(event.payload.state ?? '').toLowerCase();
|
|
1100
|
-
if (state === 'failed') transportFailed();
|
|
1317
|
+
if (state === 'failed') transportFailed(UNREACHABLE_DESKTOP, true);
|
|
1101
1318
|
else onTransportState(state);
|
|
1102
1319
|
return;
|
|
1103
1320
|
}
|
|
@@ -1107,7 +1324,9 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
1107
1324
|
else if (typeof event.payload.text === 'string') typeText(event.payload.text);
|
|
1108
1325
|
return;
|
|
1109
1326
|
case 'failure':
|
|
1110
|
-
|
|
1327
|
+
// Only an ICE failure is held; an SDP or candidate
|
|
1328
|
+
// failure is terminal exactly as before.
|
|
1329
|
+
transportFailed(UNREACHABLE_DESKTOP, String(event.payload.reason ?? '') === 'ice');
|
|
1111
1330
|
return;
|
|
1112
1331
|
case 'closed':
|
|
1113
1332
|
return;
|
|
@@ -1121,7 +1340,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
1121
1340
|
dropped = true;
|
|
1122
1341
|
subscription?.remove();
|
|
1123
1342
|
};
|
|
1124
|
-
}, [transportFailed, onTransportState, cancelRestart, spendRestartAttempt, notePing, teardown, update, pressKey, typeText, armMedia]);
|
|
1343
|
+
}, [transportFailed, onTransportState, cancelRestart, spendRestartAttempt, notePing, teardown, update, pressKey, typeText, armMedia, carrierFailed]);
|
|
1125
1344
|
|
|
1126
1345
|
const copyRemoteToLocal = useCallback(async (writeLocal: (text: string) => Promise<void>) => {
|
|
1127
1346
|
if (!inputEnabled.current) throw new Error('Desktop control is off.');
|
|
@@ -1171,26 +1390,62 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
1171
1390
|
// across about a minute: a session that was revoked must not be reopened
|
|
1172
1391
|
// on the strength of the old grant, and an outage that outlasts one
|
|
1173
1392
|
// attempt must not spend the whole budget while the network is still down.
|
|
1393
|
+
//
|
|
1394
|
+
// An ICE failure is the one exception. It means the peer's path died, not
|
|
1395
|
+
// that the engine ended the session, so the held session (and its native
|
|
1396
|
+
// handle) is kept for HOLD_AFTER_FAILED_MS from the failure, whatever the
|
|
1397
|
+
// carrier watch is doing; a roam that outlasts the client's own ~15 s ICE
|
|
1398
|
+
// `failed` can then reattach and restart ICE on the same session. Only a
|
|
1399
|
+
// failure that is not ICE — SDP, candidate, a revoked transport — drops the
|
|
1400
|
+
// session at once, exactly as before.
|
|
1174
1401
|
useEffect(() => {
|
|
1175
|
-
|
|
1402
|
+
const immediate = reopenAtOnce.current;
|
|
1403
|
+
reopenAtOnce.current = false;
|
|
1404
|
+
if (snapshot.status !== 'failed') {
|
|
1405
|
+
heldIceFailureAt.current = null;
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1176
1408
|
if (snapshot.failure?.code !== 'transport') return;
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1409
|
+
const reopen = () => {
|
|
1410
|
+
// The failed session is dead on the engine side. Drop its handle and
|
|
1411
|
+
// close the remote session whether or not another attempt is left: a
|
|
1412
|
+
// failure the user can retry by hand must not sit behind a stale
|
|
1413
|
+
// native handle that makes `connect()` a no-op.
|
|
1414
|
+
const owner = signaling.current;
|
|
1415
|
+
const openedRef = opened.current;
|
|
1416
|
+
cancelRestart();
|
|
1417
|
+
discardSession();
|
|
1418
|
+
void endRemote(openedRef, owner);
|
|
1419
|
+
// With the carrier dead, the dial loop opens on the first one that comes up.
|
|
1420
|
+
if (dialing.current !== null) {
|
|
1421
|
+
update({ status: 'reconnecting' });
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
const wait = immediate ? 0 : backoffDelay(REOPEN_BACKOFF_MS, attempts.current);
|
|
1425
|
+
if (wait === null) return;
|
|
1426
|
+
attempts.current += 1;
|
|
1427
|
+
update({ status: 'reconnecting' });
|
|
1428
|
+
reconnectTimer.current = setTimeout(() => {
|
|
1429
|
+
reconnectTimer.current = null;
|
|
1430
|
+
void establish();
|
|
1431
|
+
}, wait);
|
|
1432
|
+
};
|
|
1433
|
+
const failedAt = heldIceFailureAt.current;
|
|
1434
|
+
const remaining = failedAt === null ? 0 : HOLD_AFTER_FAILED_MS - (Date.now() - failedAt);
|
|
1435
|
+
if (remaining <= 0) {
|
|
1436
|
+
heldIceFailureAt.current = null;
|
|
1437
|
+
reopen();
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
// Hold: leave the session and its handle in place, and only drop them
|
|
1441
|
+
// when the window lapses with no recovery. A recovery that takes the
|
|
1442
|
+
// status off `failed` runs this effect's cleanup and clears the timer.
|
|
1443
|
+
const holdTimer = setTimeout(() => {
|
|
1444
|
+
if (statusRef.current !== 'failed') return;
|
|
1445
|
+
heldIceFailureAt.current = null;
|
|
1446
|
+
reopen();
|
|
1447
|
+
}, remaining);
|
|
1448
|
+
return () => clearTimeout(holdTimer);
|
|
1194
1449
|
}, [establish, cancelRestart, discardSession, endRemote, snapshot.status, snapshot.failure, update]);
|
|
1195
1450
|
|
|
1196
1451
|
useEffect(() => () => {
|
|
@@ -1208,9 +1463,10 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
|
|
|
1208
1463
|
const openedRef = opened.current;
|
|
1209
1464
|
const owner = signaling.current;
|
|
1210
1465
|
opened.current = null;
|
|
1211
|
-
|
|
1466
|
+
stopDialing();
|
|
1467
|
+
dropCarrier(false);
|
|
1212
1468
|
void endRemote(openedRef, owner);
|
|
1213
|
-
}, [cancelReconnect, endRemote]);
|
|
1469
|
+
}, [cancelReconnect, endRemote, stopDialing, dropCarrier]);
|
|
1214
1470
|
|
|
1215
1471
|
// A phone that goes to the background must not leave a remote button held:
|
|
1216
1472
|
// the desktop cannot know the finger left the glass. This is the same
|