@desklink/react-native 0.1.1 → 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.
- package/README.md +40 -5
- package/android/src/main/java/expo/modules/desklink/DesklinkModule.kt +4 -0
- package/android/src/main/java/expo/modules/desklink/DesktopView.kt +61 -3
- package/package.json +4 -1
- package/src/DesktopView.ios.tsx +36 -5
- package/src/DesktopView.tsx +9 -2
- package/src/DesktopView.web.tsx +14 -2
- package/src/native.web.ts +86 -5
- package/src/protocol.ts +30 -7
- package/src/useDesktopSession.ts +420 -55
package/README.md
CHANGED
|
@@ -70,7 +70,8 @@ import { desktopAvailable } from '@desklink/react-native/availability';
|
|
|
70
70
|
- **A pointer a phone can see.** Once a touch has sent the desktop's pointer
|
|
71
71
|
somewhere, the view draws it there at a readable size, over a picture whose
|
|
72
72
|
own cursor is a few pixels tall or not captured at all. Android hides the
|
|
73
|
-
extra mark when a mouse is attached
|
|
73
|
+
extra mark when a mouse is attached, and the `device` profile draws no mark
|
|
74
|
+
on any platform — the screen being touched shows the finger itself.
|
|
74
75
|
- **The picture above the keyboard.** While the phone's keyboard is up, the
|
|
75
76
|
picture sits above it and above the room the app keeps for its own controls
|
|
76
77
|
(`keyboardClearance`). Android follows the keyboard animation and keeps the
|
|
@@ -88,7 +89,14 @@ import { desktopAvailable } from '@desklink/react-native/availability';
|
|
|
88
89
|
under the finger while the picture catches up; two fingers scroll the desktop
|
|
89
90
|
under them, or pinch; a quick two-finger tap is a right click too. Scrolling
|
|
90
91
|
is fractional wheel steps, smooth where the desktop supports high-resolution
|
|
91
|
-
wheels. Android coalesces pending pointer moves under congestion.
|
|
92
|
+
wheels. Android coalesces pending pointer moves under congestion. The
|
|
93
|
+
`gestures` prop reshapes one finger for what the picture shows, and can be
|
|
94
|
+
changed mid-session: `browser` scrolls the page under one finger (a tap
|
|
95
|
+
still clicks and a hold still right-clicks), `device` presses the screen —
|
|
96
|
+
the finger lands with the button down, drags with it held and lifts to
|
|
97
|
+
release, so a tap clicks and a hold holds with no long-press right click,
|
|
98
|
+
and no drawn pointer — while the default `desktop` is the trackpad above.
|
|
99
|
+
Two fingers scroll and pinch the same under every profile.
|
|
92
100
|
- **The keys a phone lacks.** `modifiers`, `tapModifier` and `pressKey` give
|
|
93
101
|
sticky Ctrl and Shift: tap arms one for the next key, tap again locks it.
|
|
94
102
|
While one is armed, the next key or character the phone's keyboard types is
|
|
@@ -96,6 +104,33 @@ import { desktopAvailable } from '@desklink/react-native/availability';
|
|
|
96
104
|
- **Readiness is a rendered frame.** Android marks the first draw; iOS uses
|
|
97
105
|
`RTCView`'s native video-dimensions callback, which fires after a decoded
|
|
98
106
|
picture arrives. Neither marks a track or ICE connection as live.
|
|
107
|
+
- **Connection states, as they happen.** The session status is `idle`,
|
|
108
|
+
`opening`, `connecting`, `live`, `reconnecting`, `ended` or `failed`. The
|
|
109
|
+
peer's ICE state and the engine's `session.state` both feed it: a
|
|
110
|
+
`disconnected` path reads as `reconnecting` within about a second of the
|
|
111
|
+
drop — never a frozen picture that still says live — and a recovered path
|
|
112
|
+
reads as `live` again once ICE is `connected`. `failed` stays terminal.
|
|
113
|
+
Every event carries its engine session id where the carrier preserves it,
|
|
114
|
+
and the session ignores anything naming a session it no longer holds, so a
|
|
115
|
+
previous generation's queued offer or revocation cannot corrupt or kill a
|
|
116
|
+
healthy recovery.
|
|
117
|
+
Detection is two-layered: ICE and engine `session.state` events, plus the
|
|
118
|
+
engine's twice-a-second control-channel heartbeat (`{"kind":"ping"}`) — a
|
|
119
|
+
path that stops heartbeating reads as stalled within ~1.5 s even while ICE
|
|
120
|
+
consent timers are still making up their minds, and resumed heartbeats read
|
|
121
|
+
as recovered. Clients on engines that predate the heartbeat simply never
|
|
122
|
+
arm that watchdog.
|
|
123
|
+
- **ICE restarts before reopens.** When the path drops after frames were
|
|
124
|
+
shown, the session asks the engine (`session.restart_ice`) to re-offer on
|
|
125
|
+
the same peer connection: the fresh offer arrives as the usual
|
|
126
|
+
`session.description` event and is answered as usual, all without reopening
|
|
127
|
+
the session. The first restart waits ~2 s for blips that heal alone;
|
|
128
|
+
retries back off across ~60 s, so a 30–60 s outage still recovers when the
|
|
129
|
+
network returns. Only a terminal `failed` reopens the session, and those
|
|
130
|
+
reopens back off across ~60 s too instead of spending one attempt while the
|
|
131
|
+
network is still down. Renegotiation transients from a restart the session
|
|
132
|
+
asked for itself are expected, not a new outage: they neither flip the
|
|
133
|
+
status nor trigger another restart for a short grace window.
|
|
99
134
|
- **Control starts off.** `setInputEnabled(true)` enables pointer, keyboard and
|
|
100
135
|
clipboard input; `setInputEnabled(false)` blocks new input, releases held keys
|
|
101
136
|
and buttons, and resets pending gestures. The app decides when to enable it
|
|
@@ -119,9 +154,9 @@ interface Signaling {
|
|
|
119
154
|
```
|
|
120
155
|
|
|
121
156
|
`method` is one of `session.open`, `session.description`, `session.candidate`,
|
|
122
|
-
`session.close`; `SessionEvent` is the engine's offer,
|
|
123
|
-
revocation. Pixels and input do **not** use this
|
|
124
|
-
desktop's own WebRTC session.
|
|
157
|
+
`session.restart_ice`, `session.close`; `SessionEvent` is the engine's offer,
|
|
158
|
+
candidates, state and revocation. Pixels and input do **not** use this
|
|
159
|
+
channel — they are the desktop's own WebRTC session.
|
|
125
160
|
|
|
126
161
|
## Licence
|
|
127
162
|
|
|
@@ -126,6 +126,10 @@ class DesklinkModule : Module() {
|
|
|
126
126
|
view.setKeyboardClearance(clearance ?: 0f)
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
Prop("gestures") { view: DesktopView, gestures: String? ->
|
|
130
|
+
view.setGestures(gestures ?: "desktop")
|
|
131
|
+
}
|
|
132
|
+
|
|
129
133
|
OnViewDestroys { view: DesktopView -> view.release() }
|
|
130
134
|
|
|
131
135
|
OnViewDidUpdateProps { view: DesktopView ->
|
|
@@ -66,7 +66,9 @@ private const val MIN_WHEEL_STEP = 0.05f
|
|
|
66
66
|
* - press and hold: a right click on release, or drag after it to hold the left
|
|
67
67
|
* button (select text, move a window);
|
|
68
68
|
* - one finger: move around a zoomed-in desktop; on the whole desktop,
|
|
69
|
-
* move its pointer, which follows the finger without pressing a button
|
|
69
|
+
* move its pointer, which follows the finger without pressing a button —
|
|
70
|
+
* unless `gestures` is `browser`, where it scrolls the page, or `device`,
|
|
71
|
+
* where it lands with the button down and drags with it held;
|
|
70
72
|
* - two fingers: scroll the desktop under them, or pinch to zoom (and move) the
|
|
71
73
|
* picture; a quick two-finger tap is a right click.
|
|
72
74
|
*/
|
|
@@ -81,6 +83,23 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
81
83
|
private var surfaceWidth = 0
|
|
82
84
|
private var surfaceHeight = 0
|
|
83
85
|
|
|
86
|
+
/** Which touch meaning this surface uses; a browser page and a device screen answer one finger differently. */
|
|
87
|
+
var gestures = "desktop"
|
|
88
|
+
private set
|
|
89
|
+
|
|
90
|
+
fun setGestures(value: String) {
|
|
91
|
+
if (value == gestures) return
|
|
92
|
+
// A held drag belongs to the old meaning: let go rather than release the
|
|
93
|
+
// button somewhere the finger never meant.
|
|
94
|
+
if (gesture == Gesture.DRAG) session?.sendCancel()
|
|
95
|
+
cancelGesture()
|
|
96
|
+
gestures = value
|
|
97
|
+
if (value == "device" && pointerAt != null) {
|
|
98
|
+
pointerAt = null
|
|
99
|
+
placePointer()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
84
103
|
// The picture's placement: surface pixels per desktop pixel, and where the
|
|
85
104
|
// desktop's top-left corner sits in this view. `fitted` keeps a picture that
|
|
86
105
|
// was showing the whole desktop showing it when the view changes size.
|
|
@@ -449,7 +468,8 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
449
468
|
/** Send the desktop's pointer somewhere, and show it there. */
|
|
450
469
|
private fun pointerTo(active: DesktopSession, phase: String, at: Pair<Int, Int>, withButton: Boolean = false) {
|
|
451
470
|
active.sendPointer(phase, at.first, at.second, withButton)
|
|
452
|
-
|
|
471
|
+
// A device screen shows the touch itself; a mark would be a second finger that lies.
|
|
472
|
+
if (!mouseInput && gestures != "device") showPointer(at)
|
|
453
473
|
}
|
|
454
474
|
|
|
455
475
|
private fun showPointer(at: Pair<Int, Int>) {
|
|
@@ -519,6 +539,25 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
519
539
|
}
|
|
520
540
|
when (event.actionMasked) {
|
|
521
541
|
MotionEvent.ACTION_DOWN -> {
|
|
542
|
+
// A device screen is pressed, not pointed at: the finger lands with the
|
|
543
|
+
// button down, drags with it held, and lifts to release. A tap is the
|
|
544
|
+
// same down and up with no move, so it clicks for free, and a hold
|
|
545
|
+
// holds for free — there is no long press to arm.
|
|
546
|
+
if (gestures == "device") {
|
|
547
|
+
downX = event.x
|
|
548
|
+
downY = event.y
|
|
549
|
+
lastX = event.x
|
|
550
|
+
lastY = event.y
|
|
551
|
+
val at = point(event.x, event.y)
|
|
552
|
+
if (at == null) {
|
|
553
|
+
gesture = Gesture.LETTERBOX
|
|
554
|
+
} else {
|
|
555
|
+
gesture = Gesture.DRAG
|
|
556
|
+
lastTapPoint = null
|
|
557
|
+
pointerTo(active, "down", at)
|
|
558
|
+
}
|
|
559
|
+
return true
|
|
560
|
+
}
|
|
522
561
|
downOnPicture = point(event.x, event.y) != null
|
|
523
562
|
gesture = if (fitted && !downOnPicture) Gesture.LETTERBOX else Gesture.PENDING
|
|
524
563
|
downX = event.x
|
|
@@ -573,6 +612,15 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
573
612
|
Gesture.PENDING -> if (hypot(event.x - downX, event.y - downY) > touchSlop) {
|
|
574
613
|
removeCallbacks(longPress)
|
|
575
614
|
lastTapPoint = null
|
|
615
|
+
// A page scrolls under one finger; the desktop's pointer rides along
|
|
616
|
+
// so the scroll lands where the finger is.
|
|
617
|
+
if (gestures == "browser") {
|
|
618
|
+
gesture = Gesture.SCROLL
|
|
619
|
+
lastX = event.x
|
|
620
|
+
lastY = event.y
|
|
621
|
+
point(event.x, event.y)?.let { pointerTo(active, "move", it) }
|
|
622
|
+
return true
|
|
623
|
+
}
|
|
576
624
|
// The whole desktop has nowhere to move to, so the finger moves the
|
|
577
625
|
// desktop's pointer instead, without a button.
|
|
578
626
|
gesture = if (!fitted) Gesture.PAN else if (downOnPicture) Gesture.HOVER else Gesture.LETTERBOX
|
|
@@ -599,6 +647,15 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
599
647
|
Gesture.DRAG -> dragTo(active, event.x, event.y)
|
|
600
648
|
|
|
601
649
|
Gesture.TWO, Gesture.PINCH, Gesture.SCROLL -> if (event.pointerCount >= 2) twoFingers(active, event)
|
|
650
|
+
else if (gesture == Gesture.SCROLL && gestures == "browser") {
|
|
651
|
+
// One finger dragging a page: content follows it, in pixel deltas
|
|
652
|
+
// turned to detents like the two-finger scroll.
|
|
653
|
+
wheelX -= (event.x - lastX) / scale / PIXELS_PER_DETENT
|
|
654
|
+
wheelY -= (event.y - lastY) / scale / PIXELS_PER_DETENT
|
|
655
|
+
lastX = event.x
|
|
656
|
+
lastY = event.y
|
|
657
|
+
flushWheel(active, force = false)
|
|
658
|
+
}
|
|
602
659
|
|
|
603
660
|
else -> {}
|
|
604
661
|
}
|
|
@@ -618,6 +675,7 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
618
675
|
removeCallbacks(longPress)
|
|
619
676
|
when (gesture) {
|
|
620
677
|
Gesture.PENDING -> tap(active, event.x, event.y)
|
|
678
|
+
Gesture.SCROLL -> flushWheel(active, force = true)
|
|
621
679
|
Gesture.HOVER -> hoverTo(active, event.x, event.y)
|
|
622
680
|
Gesture.ARMED -> point(downX, downY)?.let { rightClick(active, it) }
|
|
623
681
|
Gesture.DRAG -> endDrag(active, event.x, event.y)
|
|
@@ -669,7 +727,7 @@ class DesktopView(context: Context, appContext: AppContext) : ExpoView(context,
|
|
|
669
727
|
|
|
670
728
|
private fun rightClick(active: DesktopSession, at: Pair<Int, Int>) {
|
|
671
729
|
active.sendRightClick(at.first, at.second)
|
|
672
|
-
if (!mouseInput) showPointer(at)
|
|
730
|
+
if (!mouseInput && gestures != "device") showPointer(at)
|
|
673
731
|
}
|
|
674
732
|
|
|
675
733
|
private fun twoFingers(active: DesktopSession, event: MotionEvent) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@desklink/react-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Render and drive a desktop session from a React Native app",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -29,5 +29,8 @@
|
|
|
29
29
|
"react": ">=19",
|
|
30
30
|
"react-native": ">=0.83",
|
|
31
31
|
"react-native-webrtc": ">=124"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test:reconnect": "node test/reconnect-flow.mjs"
|
|
32
35
|
}
|
|
33
36
|
}
|
package/src/DesktopView.ios.tsx
CHANGED
|
@@ -8,7 +8,7 @@ import { desktopInputEnabled, emitDesktopKeyboard, getDesktopSize, getDesktopStr
|
|
|
8
8
|
type Point = { x: number; y: number };
|
|
9
9
|
type Gesture = { start: Point; last: Point; time: number; mode: 'pending' | 'hover' | 'pan' | 'armed' | 'drag' | 'two' | 'scroll' | 'pinch' | 'spent'; span: number; focus: Point };
|
|
10
10
|
|
|
11
|
-
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0 }: DesktopViewProps) {
|
|
11
|
+
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, gestures = 'desktop' }: DesktopViewProps) {
|
|
12
12
|
const [revision, refresh] = React.useReducer((n: number) => n + 1, 0);
|
|
13
13
|
const [bounds, setBounds] = React.useState({ width: 0, height: 0 });
|
|
14
14
|
const [size, setSize] = React.useState(() => getDesktopSize(sessionId) ?? { width: 0, height: 0 });
|
|
@@ -36,6 +36,15 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
36
36
|
const hide = Keyboard.addListener('keyboardWillHide', () => setKeyboardHeight(0));
|
|
37
37
|
return () => { show.remove(); hide.remove(); };
|
|
38
38
|
}, []);
|
|
39
|
+
React.useEffect(() => {
|
|
40
|
+
if (gestures !== 'device') return;
|
|
41
|
+
setCursor(null);
|
|
42
|
+
const held = gesture.current;
|
|
43
|
+
if (held && (held.mode === 'pending' || held.mode === 'armed')) {
|
|
44
|
+
stopTimer();
|
|
45
|
+
held.mode = 'spent';
|
|
46
|
+
}
|
|
47
|
+
}, [gestures]);
|
|
39
48
|
React.useEffect(() => {
|
|
40
49
|
if (!sessionId) return;
|
|
41
50
|
return registerDesktopView(sessionId, {
|
|
@@ -65,7 +74,8 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
65
74
|
const send = (control: Record<string, unknown>) => { if (sessionId) nativeDesklink.sendControl(sessionId, JSON.stringify(control)); };
|
|
66
75
|
const pointer = (phase: string, at: Point, button?: number) => {
|
|
67
76
|
send({ kind: 'pointer', phase, x: at.x, y: at.y, ...(button ? { button } : {}) });
|
|
68
|
-
|
|
77
|
+
// A device screen shows the touch itself; a cursor would be a second finger that lies.
|
|
78
|
+
if (gestures !== 'device') setCursor(at);
|
|
69
79
|
};
|
|
70
80
|
const click = (at: Point, button = 1) => { pointer('down', at, button); pointer('up', at, button); };
|
|
71
81
|
const stopTimer = () => { if (longPress.current) clearTimeout(longPress.current); longPress.current = null; };
|
|
@@ -79,6 +89,15 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
79
89
|
const touches = event.nativeEvent.touches;
|
|
80
90
|
if (touches.length !== 1) return;
|
|
81
91
|
const at = { x: touches[0].locationX, y: touches[0].locationY };
|
|
92
|
+
// A device screen is pressed, not pointed at: the finger lands with the
|
|
93
|
+
// button down. A tap is the same press lifted without moving, and a
|
|
94
|
+
// hold holds — there is no long press to arm.
|
|
95
|
+
if (gestures === 'device') {
|
|
96
|
+
const from = point(at.x, at.y);
|
|
97
|
+
gesture.current = { start: at, last: at, time: Date.now(), mode: from ? 'drag' : 'spent', span: 0, focus: at };
|
|
98
|
+
if (from) pointer('down', from, 1);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
82
101
|
gesture.current = { start: at, last: at, time: Date.now(), mode: 'pending', span: 0, focus: at };
|
|
83
102
|
stopTimer();
|
|
84
103
|
longPress.current = setTimeout(() => {
|
|
@@ -108,14 +127,26 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
108
127
|
} else if (g.mode === 'scroll') {
|
|
109
128
|
send({ kind: 'wheel', dx: -(center.x - g.focus.x) / scale / 120, dy: -(center.y - g.focus.y) / scale / 120 });
|
|
110
129
|
}
|
|
111
|
-
g.span = distance; g.focus = center;
|
|
130
|
+
g.span = distance; g.focus = center; g.last = center;
|
|
112
131
|
return;
|
|
113
132
|
}
|
|
114
|
-
if (touches.length !== 1 || g.mode === 'spent' || g.mode === 'two' || g.mode === 'pinch' || g.mode === 'scroll') return;
|
|
133
|
+
if (touches.length !== 1 || g.mode === 'spent' || g.mode === 'two' || g.mode === 'pinch' || (g.mode === 'scroll' && gestures !== 'browser')) return;
|
|
115
134
|
const at = { x: touches[0].locationX, y: touches[0].locationY };
|
|
116
135
|
if (g.mode === 'pending' && Math.hypot(at.x - g.start.x, at.y - g.start.y) > 8) {
|
|
117
136
|
stopTimer();
|
|
118
|
-
|
|
137
|
+
// A page scrolls under one finger; the desktop's pointer rides along
|
|
138
|
+
// so the scroll lands where the finger is.
|
|
139
|
+
if (gestures === 'browser') {
|
|
140
|
+
g.mode = 'scroll';
|
|
141
|
+
const under = point(at.x, at.y);
|
|
142
|
+
if (under) pointer('move', under);
|
|
143
|
+
} else g.mode = zoom > 1 ? 'pan' : 'hover';
|
|
144
|
+
}
|
|
145
|
+
if (g.mode === 'scroll') {
|
|
146
|
+
// Content follows the finger: moving it up scrolls the page down.
|
|
147
|
+
send({ kind: 'wheel', dx: -(at.x - g.last.x) / scale / 120, dy: -(at.y - g.last.y) / scale / 120 });
|
|
148
|
+
g.last = at; g.focus = at;
|
|
149
|
+
return;
|
|
119
150
|
}
|
|
120
151
|
if (g.mode === 'pan') {
|
|
121
152
|
const dx = at.x - g.last.x;
|
package/src/DesktopView.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import * as React from 'react';
|
|
|
3
3
|
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
4
4
|
|
|
5
5
|
import { desktopAvailable } from './native';
|
|
6
|
+
import type { GestureProfile } from './protocol';
|
|
6
7
|
|
|
7
8
|
export interface DesktopViewProps {
|
|
8
9
|
/** The session handle from `useDesktopSession`. */
|
|
@@ -18,6 +19,11 @@ export interface DesktopViewProps {
|
|
|
18
19
|
* following the keyboard as it moves.
|
|
19
20
|
*/
|
|
20
21
|
keyboardClearance?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Which touch meaning the surface uses: a desktop pointer, a browser page
|
|
24
|
+
* (one finger scrolls), or a device screen (one finger presses and drags).
|
|
25
|
+
*/
|
|
26
|
+
gestures?: GestureProfile;
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
interface NativeSurfaceProps {
|
|
@@ -26,6 +32,7 @@ interface NativeSurfaceProps {
|
|
|
26
32
|
accessible?: boolean;
|
|
27
33
|
accessibilityLabel?: string;
|
|
28
34
|
keyboardClearance?: number;
|
|
35
|
+
gestures?: GestureProfile;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
// The Expo view is resolved at module load; on a platform without it the
|
|
@@ -42,7 +49,7 @@ const NativeSurface: React.ComponentType<NativeSurfaceProps> | null = desktopAva
|
|
|
42
49
|
* belong to the application, which mounts this wherever it wants the desktop
|
|
43
50
|
* to appear.
|
|
44
51
|
*/
|
|
45
|
-
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0 }: DesktopViewProps) {
|
|
52
|
+
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, gestures = 'desktop' }: DesktopViewProps) {
|
|
46
53
|
if (NativeSurface == null || sessionId == null) {
|
|
47
54
|
return (
|
|
48
55
|
<View style={[styles.surface, style]}>
|
|
@@ -50,7 +57,7 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
|
|
|
50
57
|
</View>
|
|
51
58
|
);
|
|
52
59
|
}
|
|
53
|
-
return <NativeSurface style={[styles.surface, style]} sessionId={sessionId} accessible={accessibilityLabel !== undefined} accessibilityLabel={accessibilityLabel} keyboardClearance={keyboardClearance} />;
|
|
60
|
+
return <NativeSurface style={[styles.surface, style]} sessionId={sessionId} accessible={accessibilityLabel !== undefined} accessibilityLabel={accessibilityLabel} keyboardClearance={keyboardClearance} gestures={gestures} />;
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
const styles = StyleSheet.create({
|
package/src/DesktopView.web.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
3
|
|
|
4
|
+
import type { GestureProfile } from './protocol';
|
|
5
|
+
|
|
4
6
|
export interface DesktopViewProps {
|
|
5
7
|
/** The session handle from `useDesktopSession`. */
|
|
6
8
|
sessionId: string | null;
|
|
@@ -14,6 +16,11 @@ export interface DesktopViewProps {
|
|
|
14
16
|
* controls, in points. While the keyboard is up the picture sits above both.
|
|
15
17
|
*/
|
|
16
18
|
keyboardClearance?: number;
|
|
19
|
+
/**
|
|
20
|
+
* Which touch meaning the surface uses: a desktop pointer, a browser page
|
|
21
|
+
* (one finger scrolls), or a device screen (one finger presses and drags).
|
|
22
|
+
*/
|
|
23
|
+
gestures?: GestureProfile;
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
/**
|
|
@@ -25,19 +32,24 @@ export interface DesktopViewProps {
|
|
|
25
32
|
* actually mounted rather than at module load, so an application that never
|
|
26
33
|
* opens a desktop does not carry one in its first paint.
|
|
27
34
|
*/
|
|
28
|
-
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0 }: DesktopViewProps) {
|
|
35
|
+
export function DesktopView({ sessionId, style, placeholder, accessibilityLabel, keyboardClearance = 0, gestures = 'desktop' }: DesktopViewProps) {
|
|
29
36
|
const mounted = React.useCallback(
|
|
30
37
|
(node: unknown) => {
|
|
31
38
|
const element = (node as HTMLElement | null) ?? null;
|
|
32
39
|
if (sessionId === null) return;
|
|
33
40
|
void import('./native').then((platform) => {
|
|
34
41
|
if (element === null || !platform.desktopAvailable) return;
|
|
35
|
-
platform.attachSurface(sessionId, element);
|
|
42
|
+
platform.attachSurface(sessionId, element, gestures);
|
|
36
43
|
});
|
|
37
44
|
},
|
|
38
45
|
[sessionId],
|
|
39
46
|
);
|
|
40
47
|
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
if (sessionId === null) return;
|
|
50
|
+
void import('./native').then((platform) => platform.setGestures(sessionId, gestures));
|
|
51
|
+
}, [sessionId, gestures]);
|
|
52
|
+
|
|
41
53
|
React.useEffect(() => {
|
|
42
54
|
if (sessionId === null) return;
|
|
43
55
|
void import('./native').then((platform) => platform.setKeyboardClearance(sessionId, keyboardClearance));
|
package/src/native.web.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { NativeDesklinkModule, NativeEventName, NativeSessionEvent } from './native';
|
|
2
|
+
import type { GestureProfile } from './protocol';
|
|
2
3
|
import { observeWebKeyboardMotion } from './webKeyboardMotion';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -114,6 +115,8 @@ interface WebSession {
|
|
|
114
115
|
longPress: ReturnType<typeof setTimeout> | null;
|
|
115
116
|
/** While a sticky modifier waits for its key, typing is the session's to chord. */
|
|
116
117
|
captured: boolean;
|
|
118
|
+
/** Which touch meaning this surface uses; a browser page and a device screen answer one finger differently. */
|
|
119
|
+
gestures: GestureProfile;
|
|
117
120
|
inputEnabled: boolean;
|
|
118
121
|
/** The word the keyboard is composing, and how much of it has already gone. */
|
|
119
122
|
composition: string;
|
|
@@ -378,8 +381,9 @@ function cancelLongPress(session: WebSession): void {
|
|
|
378
381
|
function pointer(session: WebSession, phase: 'move' | 'down' | 'up' | 'cancel', at: { x: number; y: number }, button?: number): void {
|
|
379
382
|
control(session, { kind: 'pointer', phase, x: at.x, y: at.y, ...(button === undefined ? {} : { button }), seq: seq(session) });
|
|
380
383
|
// A mouse brings its own cursor; a finger leaves nothing on the glass to
|
|
381
|
-
// show where the desktop's pointer went, so the mark does.
|
|
382
|
-
|
|
384
|
+
// show where the desktop's pointer went, so the mark does. A device screen
|
|
385
|
+
// shows the touch itself, so a mark would be a second finger that lies.
|
|
386
|
+
if (session.mouse || phase === 'cancel' || session.gestures === 'device') return;
|
|
383
387
|
session.pointerAt = { x: at.x, y: at.y };
|
|
384
388
|
placePointer(session);
|
|
385
389
|
}
|
|
@@ -391,13 +395,15 @@ function click(session: WebSession, at: { x: number; y: number }, button: number
|
|
|
391
395
|
|
|
392
396
|
/**
|
|
393
397
|
* Touch gestures, the ones mature remote-desktop viewers settled on — the same
|
|
394
|
-
* as the native view's:
|
|
398
|
+
* as the native view's. The profile reshapes one finger only:
|
|
395
399
|
*
|
|
396
400
|
* - tap: click where the finger lands; two taps: a double click on one spot;
|
|
397
401
|
* - press and hold: a right click on release, or drag after it to hold the
|
|
398
402
|
* left button (select text, move a window);
|
|
399
403
|
* - one finger: move around a zoomed-in desktop; on the whole desktop,
|
|
400
|
-
* move its pointer, which follows the finger without pressing a button
|
|
404
|
+
* move its pointer, which follows the finger without pressing a button —
|
|
405
|
+
* unless the profile is `browser`, where it scrolls the page, or `device`,
|
|
406
|
+
* where it lands with the button down and drags with it held;
|
|
401
407
|
* - two fingers: scroll the desktop under them, or pinch to zoom (and move)
|
|
402
408
|
* the picture; a quick two-finger tap is a right click.
|
|
403
409
|
*
|
|
@@ -534,6 +540,27 @@ function attachGestures(session: WebSession): () => void {
|
|
|
534
540
|
}
|
|
535
541
|
session.touches.set(event.pointerId, { x, y });
|
|
536
542
|
if (session.touches.size === 1) {
|
|
543
|
+
// A device screen is pressed, not pointed at: the finger lands with
|
|
544
|
+
// the button down, drags with it held, and lifts to release. A tap
|
|
545
|
+
// is the same down and up with no move, so it clicks for free, and
|
|
546
|
+
// a hold holds for free — there is no long press to arm.
|
|
547
|
+
if (session.gestures === 'device') {
|
|
548
|
+
session.downX = x;
|
|
549
|
+
session.downY = y;
|
|
550
|
+
session.lastX = x;
|
|
551
|
+
session.lastY = y;
|
|
552
|
+
const at = desktopPoint(session, x, y);
|
|
553
|
+
if (at === null) {
|
|
554
|
+
session.gesture = 'letterbox';
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
session.gesture = 'drag';
|
|
558
|
+
session.lastTap = null;
|
|
559
|
+
pointer(session, 'down', at, 1);
|
|
560
|
+
session.dragX = at.x;
|
|
561
|
+
session.dragY = at.y;
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
537
564
|
session.downOnPicture = desktopPoint(session, x, y) !== null;
|
|
538
565
|
session.gesture = session.view.fitted && !session.downOnPicture ? 'letterbox' : 'pending';
|
|
539
566
|
session.downX = x;
|
|
@@ -586,6 +613,16 @@ function attachGestures(session: WebSession): () => void {
|
|
|
586
613
|
if (Math.hypot(x - session.downX, y - session.downY) > TOUCH_SLOP) {
|
|
587
614
|
cancelLongPress(session);
|
|
588
615
|
session.lastTap = null;
|
|
616
|
+
// A page scrolls under one finger; the desktop's pointer
|
|
617
|
+
// rides along so the scroll lands where the finger is.
|
|
618
|
+
if (session.gestures === 'browser') {
|
|
619
|
+
session.gesture = 'scroll';
|
|
620
|
+
session.lastX = x;
|
|
621
|
+
session.lastY = y;
|
|
622
|
+
const at = desktopPoint(session, x, y);
|
|
623
|
+
if (at !== null) pointer(session, 'move', at);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
589
626
|
// The whole desktop has nowhere to move to, so the finger
|
|
590
627
|
// moves the desktop's pointer instead, without a button.
|
|
591
628
|
session.gesture = session.view.fitted
|
|
@@ -620,6 +657,15 @@ function attachGestures(session: WebSession): () => void {
|
|
|
620
657
|
case 'pinch':
|
|
621
658
|
case 'scroll':
|
|
622
659
|
if (session.touches.size >= 2) twoFingers();
|
|
660
|
+
else if (session.gesture === 'scroll' && session.gestures === 'browser') {
|
|
661
|
+
// One finger dragging a page: content follows it, in pixel
|
|
662
|
+
// deltas turned to detents like the two-finger scroll.
|
|
663
|
+
session.wheelX -= (x - session.lastX) / session.view.scale / PIXELS_PER_DETENT;
|
|
664
|
+
session.wheelY -= (y - session.lastY) / session.view.scale / PIXELS_PER_DETENT;
|
|
665
|
+
session.lastX = x;
|
|
666
|
+
session.lastY = y;
|
|
667
|
+
flushWheel(false);
|
|
668
|
+
}
|
|
623
669
|
return;
|
|
624
670
|
default:
|
|
625
671
|
return;
|
|
@@ -662,6 +708,7 @@ function attachGestures(session: WebSession): () => void {
|
|
|
662
708
|
return;
|
|
663
709
|
}
|
|
664
710
|
if (gesture === 'pending') tap(x, y);
|
|
711
|
+
else if (gesture === 'scroll') flushWheel(true);
|
|
665
712
|
else if (gesture === 'hover') hoverTo(x, y);
|
|
666
713
|
else if (gesture === 'armed') {
|
|
667
714
|
const at = desktopPoint(session, session.downX, session.downY);
|
|
@@ -902,6 +949,7 @@ export const nativeDesklink: NativeDesklinkModule = {
|
|
|
902
949
|
lastTap: null,
|
|
903
950
|
longPress: null,
|
|
904
951
|
captured: false,
|
|
952
|
+
gestures: 'desktop',
|
|
905
953
|
inputEnabled: false,
|
|
906
954
|
composition: '',
|
|
907
955
|
compositionSent: '',
|
|
@@ -959,6 +1007,13 @@ export const nativeDesklink: NativeDesklinkModule = {
|
|
|
959
1007
|
emit(id, 'failure', { code: 'transport', message: 'the connection to the desktop was lost' });
|
|
960
1008
|
}
|
|
961
1009
|
};
|
|
1010
|
+
peer.oniceconnectionstatechange = () => {
|
|
1011
|
+
if (sessions.get(id) !== session) return;
|
|
1012
|
+
// Surfaced as it happens — upstream of any failure timeout — so a
|
|
1013
|
+
// short drop reads as reconnecting within about a second, not as
|
|
1014
|
+
// a frozen picture that still says live.
|
|
1015
|
+
emit(id, 'ice', { state: peer.iceConnectionState.toUpperCase() });
|
|
1016
|
+
};
|
|
962
1017
|
return id;
|
|
963
1018
|
},
|
|
964
1019
|
|
|
@@ -1130,9 +1185,35 @@ export const nativeDesklink: NativeDesklinkModule = {
|
|
|
1130
1185
|
* surface, and removing the old one is what keeps a re-render from leaving two
|
|
1131
1186
|
* live videos behind.
|
|
1132
1187
|
*/
|
|
1133
|
-
|
|
1188
|
+
/** Which touch meaning a surface uses; changing it mid-session is safe. */
|
|
1189
|
+
export function setGestures(id: string, gestures: GestureProfile): void {
|
|
1190
|
+
const session = sessions.get(id);
|
|
1191
|
+
if (session === undefined) return;
|
|
1192
|
+
if (session.gestures === gestures) return;
|
|
1193
|
+
// A held drag belongs to the old meaning: let go rather than release the
|
|
1194
|
+
// button somewhere the finger never meant.
|
|
1195
|
+
if (session.gesture === 'drag' || session.gesture === 'mouse') endGestureDrag(session);
|
|
1196
|
+
cancelLongPress(session);
|
|
1197
|
+
session.gesture = 'none';
|
|
1198
|
+
session.touches.clear();
|
|
1199
|
+
session.gestures = gestures;
|
|
1200
|
+
// A device screen shows the touch itself: a mark left over from another
|
|
1201
|
+
// profile would be a finger that is not there.
|
|
1202
|
+
if (gestures === 'device' && session.pointerAt !== null) {
|
|
1203
|
+
session.pointerAt = null;
|
|
1204
|
+
placePointer(session);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/** Release a held button at the last desktop point it was sent to. */
|
|
1209
|
+
function endGestureDrag(session: WebSession): void {
|
|
1210
|
+
pointer(session, 'up', { x: session.dragX, y: session.dragY }, 1);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
export function attachSurface(id: string, container: HTMLElement | null, gestures: GestureProfile = 'desktop'): void {
|
|
1134
1214
|
const session = sessions.get(id);
|
|
1135
1215
|
if (session === undefined) return;
|
|
1216
|
+
session.gestures = gestures;
|
|
1136
1217
|
session.detach?.();
|
|
1137
1218
|
session.detach = null;
|
|
1138
1219
|
if (session.surface !== null) session.surface.remove();
|
package/src/protocol.ts
CHANGED
|
@@ -18,6 +18,14 @@ export const PROTOCOL_VERSION = 3;
|
|
|
18
18
|
|
|
19
19
|
export type Permission = 'view' | 'control' | 'clipboard';
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Which touch meaning a surface uses. `desktop` is the trackpad-shaped
|
|
23
|
+
* default; `browser` scrolls a page with one finger; `device` presses the
|
|
24
|
+
* screen with one finger, like the finger itself was there. The wire messages
|
|
25
|
+
* are the same under all three — only which gesture sends which changes.
|
|
26
|
+
*/
|
|
27
|
+
export type GestureProfile = 'desktop' | 'browser' | 'device';
|
|
28
|
+
|
|
21
29
|
export interface IceServerConfig {
|
|
22
30
|
urls: string[];
|
|
23
31
|
username?: string;
|
|
@@ -61,13 +69,21 @@ export interface RtcCandidate {
|
|
|
61
69
|
sdpMLineIndex?: number | null;
|
|
62
70
|
}
|
|
63
71
|
|
|
64
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* Engine → client lifecycle events, already unwrapped from the local protocol.
|
|
74
|
+
*
|
|
75
|
+
* `sessionId` names the engine session the event belongs to, when the
|
|
76
|
+
* carrier preserves it. The hook ignores an event whose id does not match
|
|
77
|
+
* the session it holds: after an outage, a previous session's queued offer
|
|
78
|
+
* or revocation can arrive beside the new session's, and applying those to
|
|
79
|
+
* the new session corrupts or kills a healthy recovery.
|
|
80
|
+
*/
|
|
65
81
|
export type SessionEvent =
|
|
66
|
-
| { kind: 'description'; description: RtcDescription }
|
|
67
|
-
| { kind: 'candidate'; candidate: RtcCandidate }
|
|
68
|
-
| { kind: 'state'; capture: string; transport: string; firstFrame: boolean }
|
|
69
|
-
| { kind: 'restoreToken'; token: string }
|
|
70
|
-
| { kind: 'revoked'; reason: string };
|
|
82
|
+
| { kind: 'description'; description: RtcDescription; sessionId?: string }
|
|
83
|
+
| { kind: 'candidate'; candidate: RtcCandidate; sessionId?: string }
|
|
84
|
+
| { kind: 'state'; capture: string; transport: string; firstFrame: boolean; sessionId?: string }
|
|
85
|
+
| { kind: 'restoreToken'; token: string; sessionId?: string }
|
|
86
|
+
| { kind: 'revoked'; reason: string; sessionId?: string };
|
|
71
87
|
|
|
72
88
|
/**
|
|
73
89
|
* The application's own authenticated channel to the host engine.
|
|
@@ -139,7 +155,14 @@ export type ControlReply =
|
|
|
139
155
|
| { kind: 'ack'; seq: number }
|
|
140
156
|
| { kind: 'rejected'; seq: number; code: string; message: string }
|
|
141
157
|
| { kind: 'clipboard'; request: string; text: string; truncated?: boolean; error?: string }
|
|
142
|
-
| { kind: 'revoked'; reason: string }
|
|
158
|
+
| { kind: 'revoked'; reason: string }
|
|
159
|
+
/**
|
|
160
|
+
* The engine's heartbeat, twice a second while the control channel is
|
|
161
|
+
* open. A client that has seen one and then sees none for a while reads
|
|
162
|
+
* the path as stalled, long before ICE consent timers notice. Clients that
|
|
163
|
+
* predate it simply ignore it.
|
|
164
|
+
*/
|
|
165
|
+
| { kind: 'ping' };
|
|
143
166
|
|
|
144
167
|
export function parseControlReply(raw: string): ControlReply | null {
|
|
145
168
|
try {
|
package/src/useDesktopSession.ts
CHANGED
|
@@ -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
|
-
/**
|
|
31
|
-
|
|
30
|
+
/**
|
|
31
|
+
* A drop heals on its own more often than not, so the first ICE restart waits
|
|
32
|
+
* out brief blips; the retries then back off. The whole schedule spans about a
|
|
33
|
+
* minute, so a 30–60 s outage still recovers when the network comes back.
|
|
34
|
+
*/
|
|
35
|
+
const RESTART_AFTER_MS = 2000;
|
|
36
|
+
export const RESTART_BACKOFF_MS = [2000, 4000, 8000, 16000, 30000];
|
|
37
|
+
/**
|
|
38
|
+
* A heartbeat missed this long means the path is stalled, even when ICE has
|
|
39
|
+
* not said so yet. Twice the engine's ping interval, with room for jitter.
|
|
40
|
+
*/
|
|
41
|
+
const STALL_AFTER_MS = 1200;
|
|
42
|
+
/**
|
|
43
|
+
* After the session itself asks for a restart, renegotiation transients are
|
|
44
|
+
* expected: ICE may flap and heartbeats may pause while the new generation
|
|
45
|
+
* comes up. These are not a new outage, so they neither flip the status nor
|
|
46
|
+
* trigger another restart while the window lasts.
|
|
47
|
+
*/
|
|
48
|
+
const RESTART_GRACE_MS = 2500;
|
|
49
|
+
/** How long a restart offer may go unanswered before the next attempt. */
|
|
50
|
+
const RESTART_REPLY_MS = 5000;
|
|
51
|
+
/**
|
|
52
|
+
* Full session reopens back off across about a minute, holding at patient
|
|
53
|
+
* 8 s retries: a return must never wait out a 32 s gap, and an attempt every
|
|
54
|
+
* 8 s costs nothing next to a live desktop.
|
|
55
|
+
*/
|
|
56
|
+
export const REOPEN_BACKOFF_MS = [1000, 2000, 4000, 8000, 8000, 8000, 8000, 8000];
|
|
57
|
+
|
|
58
|
+
/** 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')
|
|
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 : '';
|
|
@@ -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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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;
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
event.candidate.sdpMLineIndex ?? null,
|
|
388
|
-
);
|
|
389
|
-
return;
|
|
390
|
-
case 'state':
|
|
391
|
-
diagnostics.current.transport = event.transport;
|
|
392
|
-
update({ diagnostics: { ...diagnostics.current } });
|
|
393
|
-
return;
|
|
394
|
-
case 'revoked':
|
|
395
|
-
void teardown(event.reason, false);
|
|
396
|
-
update({ status: 'ended', failure: { code: 'revoked', message: event.reason } });
|
|
397
|
-
return;
|
|
398
|
-
default:
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
401
|
-
});
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
-
//
|
|
566
|
-
// that was revoked must not be reopened
|
|
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
|
-
|
|
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
|
|
584
|
-
},
|
|
585
|
-
}, [
|
|
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;
|