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