@desklink/react-native 0.5.1 → 0.5.2

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
@@ -127,6 +127,24 @@ the iOS receiver asks the engine for H.264, which iOS decodes in hardware
127
127
  iOS decodes in software — so a 1440p or 4K picture on such a host costs CPU,
128
128
  heat and battery.
129
129
 
130
+ ### Paths with no UDP route
131
+
132
+ `session.loopbackTcp` asks the engine to also offer ICE over TCP on its own
133
+ `127.0.0.1`, for a client whose only way to the computer is a forward of that
134
+ loopback — an SSH tunnel, `adb reverse`:
135
+
136
+ ```tsx
137
+ session: { loopbackTcp: true },
138
+ ```
139
+
140
+ It is off by default, like the wire field it sets. A client with a UDP path
141
+ keeps it: ICE prefers UDP whenever it has one. The engine picks that port per
142
+ session, so the forward has to follow it (`adb reverse tcp:<port> tcp:<port>`,
143
+ `ssh -L <port>:127.0.0.1:<port> <computer>`) with `<port>` from the
144
+ `session.candidate` the engine offers. Without it a connection that never
145
+ forms fails after 20 seconds with that reason, rather than sitting on an empty
146
+ view.
147
+
130
148
  ### What the package guarantees
131
149
 
132
150
  - **Hardware decoding where the platform has it.** Android and web decode the
@@ -164,8 +182,10 @@ heat and battery.
164
182
  touched shows the finger itself.
165
183
  - **The picture above the keyboard.** While the phone's keyboard is up, the
166
184
  picture sits above it and above the room the app keeps for its own controls
167
- (`keyboardClearance`). Android follows the keyboard animation and keeps the
168
- pointer in sight; iOS follows the keyboard show/hide bounds.
185
+ (`keyboardClearance`). Only the part of the view the keyboard really covers
186
+ counts, and a picture taller than what is left moves so the pointer, where
187
+ a tap just put the caret, stays in sight. Android also follows the keyboard
188
+ animation frame by frame; iOS moves once, as the keyboard shows or hides.
169
189
  - **Contained geometry.** Touch maps through the picture's actual placement.
170
190
  On the whole desktop, a one-finger drag starting in the letterbox does not move the pointer; a
171
191
  second finger on the picture can still start a pinch or scroll. A drag that
@@ -218,25 +238,30 @@ heat and battery.
218
238
  peer's ICE state and the engine's `session.state` both feed it: a
219
239
  `disconnected` path reads as `reconnecting` within about a second of the
220
240
  drop — never a frozen picture that still says live — and a recovered path
221
- reads as `live` again once ICE is `connected`. A path that reports
241
+ reads as `live` again once the picture is advancing again (ICE `connected`
242
+ alone does not restore `live` while the picture is frozen). A path that reports
222
243
  `failed` reopens the session with fresh authority instead of staying stuck.
223
244
  Every event carries its engine session id where the carrier preserves it,
224
245
  and the session ignores anything naming a session it no longer holds, so a
225
246
  previous generation's queued offer or revocation cannot corrupt or kill a
226
247
  healthy recovery.
227
- Detection is two-layered: ICE and engine `session.state` events, plus the
248
+ Detection is three-layered: ICE and engine `session.state` events, the
228
249
  engine's twice-a-second control-channel heartbeat (`{"kind":"ping"}`) — a
229
250
  path that stops heartbeating reads as stalled within ~1.5 s even while ICE
230
251
  consent timers are still making up their minds, and resumed heartbeats read
231
- as recovered. Clients on engines that predate the heartbeat simply never
232
- arm that watchdog.
252
+ as recovered — and a half-second poll of the receiver's own video counters
253
+ while a picture is shown: a picture stopped for ~2 s reads as not `live`
254
+ even while signalling, ICE and control all answer. Clients on engines that
255
+ predate the heartbeat simply never arm that watchdog.
233
256
  - **ICE restarts before reopens.** When the path drops after frames were
234
257
  shown, the session asks the engine (`session.restart_ice`) to re-offer on
235
258
  the same peer connection: the fresh offer arrives as the usual
236
259
  `session.description` event and is answered as usual, all without reopening
237
260
  the session. The first restart waits ~2 s for blips that heal alone;
238
261
  retries back off across ~60 s, so a 30–60 s outage still recovers when the
239
- network returns. When the restart schedule is spent without the peer
262
+ network returns — except while the picture is frozen, when the short ~2 s
263
+ retry keeps being spent without consuming the reopen budget until frames
264
+ move again. When the restart schedule is spent without the peer
240
265
  reporting `failed`, the session reopens instead of sitting in
241
266
  `reconnecting`. An engine revocation with code `transport` (the path was
242
267
  lost) also reopens the session; any other revocation code, or none, ends
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desklink/react-native",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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",
@@ -71,7 +71,12 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
71
71
  const [revision, refresh] = React.useReducer((n: number) => n + 1, 0);
72
72
  const [bounds, setBounds] = React.useState({ width: 0, height: 0 });
73
73
  const [size, setSize] = React.useState(() => getDesktopSize(sessionId) ?? { width: 0, height: 0 });
74
- const [keyboardHeight, setKeyboardHeight] = React.useState(0);
74
+ /** How much of the view's bottom the keyboard covers, measured against where the view is on screen. */
75
+ const [keyboardOverlap, setKeyboardOverlap] = React.useState(0);
76
+ /** The keyboard's top on screen while it is up. */
77
+ const keyboardTop = React.useRef<number | null>(null);
78
+ /** Where the desktop's pointer was last sent, in desktop pixels: where a tap just put the caret. */
79
+ const pointerAt = React.useRef<Point | null>(null);
75
80
  /** Zoom over the whole-desktop fit; null fills the view, the default. */
76
81
  const [zoom, setZoom] = React.useState<number | null>(null);
77
82
  /** From the middle of the uncovered part of the view; clamped to the picture's edges when shown. */
@@ -100,9 +105,11 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
100
105
  return observeDesktopSurface(() => { refresh(); setEnabled(desktopInputEnabled(sessionId)); });
101
106
  }, [sessionId]);
102
107
  React.useEffect(() => {
103
- const show = Keyboard.addListener('keyboardWillShow', (event) => setKeyboardHeight(event.endCoordinates.height));
104
- const hide = Keyboard.addListener('keyboardWillHide', () => setKeyboardHeight(0));
108
+ const show = Keyboard.addListener('keyboardWillShow', (event) => { keyboardTop.current = event.endCoordinates.screenY; measureKeyboard(); });
109
+ const hide = Keyboard.addListener('keyboardWillHide', () => { keyboardTop.current = null; setKeyboardOverlap(0); });
105
110
  return () => { show.remove(); hide.remove(); };
111
+ // `measureKeyboard` reads refs only.
112
+ // eslint-disable-next-line react-hooks/exhaustive-deps
106
113
  }, []);
107
114
  React.useEffect(() => {
108
115
  // A held drag belongs to the old meaning: let go of the button rather
@@ -135,7 +142,7 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
135
142
  const left = insets?.left ?? 0;
136
143
  const safeWidth = Math.max(1, bounds.width - left - (insets?.right ?? 0));
137
144
  const safeHeight = Math.max(1, bounds.height - top - (insets?.bottom ?? 0));
138
- const visibleBottom = Math.min(top + safeHeight, bounds.height - (keyboardHeight ? keyboardHeight + keyboardClearance : 0));
145
+ const visibleBottom = Math.min(top + safeHeight, bounds.height - (keyboardOverlap ? keyboardOverlap + keyboardClearance : 0));
139
146
  const visibleHeight = Math.max(1, visibleBottom - top);
140
147
  const fit = size.width && size.height ? Math.min(safeWidth / size.width, visibleHeight / size.height) : 1;
141
148
  const defaultScale = bounds.width <= bounds.height
@@ -169,6 +176,21 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
169
176
  const shown = clampOffset(offset.x, offset.y);
170
177
  const originX = left + (safeWidth - pictureWidth) / 2 + shown.x;
171
178
  const originY = top + (zoom === null && pictureHeight <= visibleHeight ? 0 : (visibleHeight - pictureHeight) / 2) + shown.y;
179
+ // The keyboard took (or gave back) the bottom of the view. A picture taller
180
+ // than what is left is moved so the pointer, where a tap just put the
181
+ // caret, stays in sight, as `DesktopView.kt` does.
182
+ const shownBottom = React.useRef(visibleBottom);
183
+ React.useLayoutEffect(() => {
184
+ const before = shownBottom.current;
185
+ shownBottom.current = visibleBottom;
186
+ const at = pointerAt.current;
187
+ if (Math.abs(visibleBottom - before) < 0.5 || !at) return;
188
+ const y = originY + (at.y + 0.5) * scale;
189
+ const margin = Math.min(56, visibleBottom / 4);
190
+ if (y > visibleBottom - margin) setOffset(clampOffset(shown.x, shown.y - (y - (visibleBottom - margin))));
191
+ // Only a change in what the keyboard covers moves the picture.
192
+ // eslint-disable-next-line react-hooks/exhaustive-deps
193
+ }, [visibleBottom]);
172
194
  // An application's controls can sit under the picture instead of over it,
173
195
  // so it needs to know where the picture ended. Reported only when the
174
196
  // picture moved: the callback sets state, and a fresh callback each render
@@ -189,6 +211,7 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
189
211
  };
190
212
  const pointer = (phase: string, at: Point, button?: number, mark = true) => {
191
213
  send({ kind: 'pointer', phase, x: at.x, y: at.y, ...(button ? { button } : {}) });
214
+ pointerAt.current = at;
192
215
  // A device screen shows the touch itself; a cursor would be a second finger that lies.
193
216
  // A trackpad or mouse has the system's own pointer on screen.
194
217
  if (!mark) setCursor(null);
@@ -426,7 +449,19 @@ export function DesktopView({ sessionId, style, placeholder, accessibilityLabel,
426
449
  wheel.current.y += -dy / scale / PIXELS_PER_DETENT;
427
450
  flushWheel(false);
428
451
  };
429
- const onLayout = (event: LayoutChangeEvent) => setBounds(event.nativeEvent.layout);
452
+ /**
453
+ * The keyboard covers only the part of the view below its top: the view can
454
+ * end above the screen's bottom. The hidden input sits at the view's bottom
455
+ * edge, so it says where that edge is on screen.
456
+ */
457
+ const measureKeyboard = () => {
458
+ const keyboardAt = keyboardTop.current;
459
+ if (keyboardAt === null) return;
460
+ input.current?.measureInWindow((_x, y, _width, height) => {
461
+ if (keyboardTop.current === keyboardAt) setKeyboardOverlap(Math.max(0, y + height - keyboardAt));
462
+ });
463
+ };
464
+ const onLayout = (event: LayoutChangeEvent) => { setBounds(event.nativeEvent.layout); measureKeyboard(); };
430
465
  const surface: ViewProps = {
431
466
  style: [styles.surface, style], onLayout, accessible: accessibilityLabel !== undefined, accessibilityLabel,
432
467
  onStartShouldSetResponder: () => enabled, onMoveShouldSetResponder: () => enabled,
package/src/protocol.ts CHANGED
@@ -56,6 +56,13 @@ export interface SessionOpenRequest {
56
56
  iceServers?: IceServerConfig[];
57
57
  restoreToken?: string;
58
58
  ttlSeconds?: number;
59
+ /**
60
+ * Ask the engine to also offer ICE over TCP on its own loopback, for a
61
+ * client whose only way to this computer is a forward of that loopback
62
+ * (an SSH tunnel, `adb reverse`). A client with a UDP path keeps it: ICE
63
+ * prefers UDP whenever it has one.
64
+ */
65
+ loopbackTcp?: boolean;
59
66
  }
60
67
 
61
68
  export interface SessionOpenResult {
@@ -57,6 +57,35 @@ const STALL_AFTER_MS = 1200;
57
57
  const RESTART_GRACE_MS = 2500;
58
58
  /** How long a restart offer may go unanswered before the next attempt. */
59
59
  const RESTART_REPLY_MS = 5000;
60
+ /**
61
+ * How long the media path may carry nothing at all before a session that says
62
+ * `live` stops saying so. The engine's transport state reaches the client over
63
+ * the signalling channel, which is not part of the media path, so a dead link
64
+ * with signalling up reads as `connected` over a picture that has stopped: live
65
+ * has to mean the picture is advancing, not that someone answered.
66
+ */
67
+ const FROZEN_AFTER_MS = 2000;
68
+ /** How often the media path is read while the session claims to be live. */
69
+ const MEDIA_POLL_MS = 500;
70
+
71
+ /**
72
+ * What the receiver has taken in, summed over its video streams: new decoded
73
+ * frames, and packets that arrived at all. Either moving means the picture is
74
+ * advancing; a still screen that keeps no frames moving is not a dead path, so
75
+ * both counts are read rather than frames alone.
76
+ */
77
+ function mediaCounts(rows: Array<Record<string, unknown>>): { frames: number; packets: number } | null {
78
+ let frames = 0;
79
+ let packets = 0;
80
+ let streams = 0;
81
+ for (const row of rows) {
82
+ if (row.mediaType !== undefined && row.mediaType !== 'video') continue;
83
+ streams += 1;
84
+ frames += typeof row.framesDecoded === 'number' ? row.framesDecoded : 0;
85
+ packets += typeof row.packetsReceived === 'number' ? row.packetsReceived : 0;
86
+ }
87
+ return streams === 0 ? null : { frames, packets };
88
+ }
60
89
  /**
61
90
  * Full session reopens back off across about a minute, holding at patient
62
91
  * 8 s retries: a return must never wait out a 32 s gap, and an attempt every
@@ -94,13 +123,17 @@ export type ConnectionStateName =
94
123
  * What one ICE/engine-transport state means for the session status, or null
95
124
  * when it changes nothing. `disconnected` surfaces as `reconnecting` at once —
96
125
  * event-driven, so within about a second of the drop — and `connected` moves a
97
- * session that had shown frames back to `live`. `failed` is terminal and is
98
- * handled by the failure path, not here.
126
+ * session whose picture is advancing back to `live`. `failed` is terminal and
127
+ * is handled by the failure path, not here.
128
+ *
129
+ * `advancing` is the media path, not the transport: a transport that answers
130
+ * with a still image is not a live desktop, so a session whose picture has
131
+ * stopped stays out of `live` whatever the transport reports.
99
132
  */
100
133
  export function connectionStatusFor(
101
134
  state: string,
102
135
  status: SessionSnapshot['status'],
103
- presented: boolean,
136
+ advancing: boolean,
104
137
  ): SessionSnapshot['status'] | null {
105
138
  switch (state) {
106
139
  case 'disconnected':
@@ -108,9 +141,9 @@ export function connectionStatusFor(
108
141
  case 'connected':
109
142
  case 'completed':
110
143
  if (status !== 'reconnecting') return null;
111
- // Frames were never shown: this is still the first connection, not
112
- // a recovery, so it stays `connecting` until a frame renders.
113
- return presented ? 'live' : 'connecting';
144
+ // No picture is moving: this is still the first connection, not a
145
+ // recovery, so it stays `connecting` until frames advance again.
146
+ return advancing ? 'live' : 'connecting';
114
147
  default:
115
148
  return null;
116
149
  }
@@ -124,6 +157,15 @@ export function connectionStatusFor(
124
157
  const UNREACHABLE_DESKTOP =
125
158
  'The phone must reach the desktop directly, on the same network or its tailnet. Other networks need a relay route supplied by the app.';
126
159
 
160
+ /**
161
+ * A connection that never shows a frame is a connection that never formed, and
162
+ * saying so beats an empty picture: on a path that only reaches the desktop
163
+ * through a tunnel or a USB forward, the app has to ask for the loopback route.
164
+ */
165
+ export const FIRST_FRAME_AFTER_MS = 20_000;
166
+ const NO_PICTURE =
167
+ 'The desktop never sent a picture. On a path that only reaches the desktop through a tunnel or a USB forward, open the session with loopbackTcp: true.';
168
+
127
169
  /**
128
170
  * The engine's and host's own refusal tokens, mapped to this package's failure
129
171
  * codes. A token the engine did not send is a deliberate refusal, not a network
@@ -282,8 +324,18 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
282
324
  const restartTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
283
325
  /** The pending stall watchdog, armed by heartbeats. */
284
326
  const stallTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
327
+ /** Armed while a first connection waits for its first frame. */
328
+ const firstFrameTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
285
329
  /** When the last heartbeat arrived, or 0 before the first one. */
286
330
  const lastPingAt = useRef(0);
331
+ /** The pending media read, armed while the session claims to be live. */
332
+ const mediaTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
333
+ /** The receiver's counts at the last media read, to see the next one move. */
334
+ const mediaRef = useRef<{ frames: number; packets: number } | null>(null);
335
+ /** When the media path last moved, or null while it has never moved. */
336
+ const mediaMovingAt = useRef<number | null>(null);
337
+ /** Whether the picture of an open session has stopped advancing. */
338
+ const frozen = useRef(false);
287
339
  /** Renegotiation transients are not a new outage before this time. */
288
340
  const restartGraceUntil = useRef(0);
289
341
  /** ICE-restart attempts spent on the current drop. */
@@ -355,6 +407,24 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
355
407
  stallTimer.current = null;
356
408
  }, []);
357
409
 
410
+ const cancelFirstFrame = useCallback(() => {
411
+ if (firstFrameTimer.current !== null) clearTimeout(firstFrameTimer.current);
412
+ firstFrameTimer.current = null;
413
+ }, []);
414
+
415
+ /**
416
+ * A first connection that stays frameless for this long is not going to
417
+ * arrive: report why instead of leaving an empty picture on screen.
418
+ */
419
+ const armFirstFrame = useCallback(() => {
420
+ cancelFirstFrame();
421
+ firstFrameTimer.current = setTimeout(() => {
422
+ firstFrameTimer.current = null;
423
+ if (presentedRef.current || statusRef.current !== 'connecting') return;
424
+ transportFailed(NO_PICTURE);
425
+ }, FIRST_FRAME_AFTER_MS);
426
+ }, [cancelFirstFrame, transportFailed]);
427
+
358
428
  const scheduleRestartRef = useRef<(delay: number) => void>(() => undefined);
359
429
 
360
430
  /**
@@ -364,7 +434,11 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
364
434
  */
365
435
  const spendRestartAttempt = useCallback((): void => {
366
436
  if (statusRef.current !== 'reconnecting' || nativeRef.current == null) return;
367
- const wait = backoffDelay(RESTART_BACKOFF_MS, restartAttempts.current);
437
+ // A frozen picture is the media path itself, and a restart is what
438
+ // rebuilds it, so the short delay keeps being spent until frames move
439
+ // again. Backing off to half a minute while the picture is stopped
440
+ // leaves the user looking at a still image long after the link is back.
441
+ const wait = frozen.current ? RESTART_AFTER_MS : backoffDelay(RESTART_BACKOFF_MS, restartAttempts.current);
368
442
  if (wait === null) {
369
443
  transportFailed('the desktop did not come back; reopening the session');
370
444
  return;
@@ -412,7 +486,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
412
486
  // the session itself is wedged and only a reopen heals it.
413
487
  // (While ICE is down, patience: the network may still come
414
488
  // back, and the backoff schedule owns that case.)
415
- if (restartCycles.current >= 2 && iceConnected.current) {
489
+ if (restartCycles.current >= 2 && iceConnected.current && !frozen.current) {
416
490
  transportFailed('the desktop stopped responding; reopening the session');
417
491
  return;
418
492
  }
@@ -420,7 +494,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
420
494
  // which the session answers as usual; without recovery by the
421
495
  // reply deadline, spend the next attempt. A live transport
422
496
  // answers in milliseconds, so hold it to a short leash.
423
- const replyMs = iceConnected.current ? 2000 : RESTART_REPLY_MS;
497
+ const replyMs = frozen.current ? RESTART_AFTER_MS : iceConnected.current ? 2000 : RESTART_REPLY_MS;
424
498
  restartTimer.current = setTimeout(() => {
425
499
  restartTimer.current = null;
426
500
  spendRestartAttempt();
@@ -432,22 +506,36 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
432
506
  useEffect(() => {
433
507
  scheduleRestartRef.current = scheduleRestart;
434
508
  checkStallRef.current = checkStall;
509
+ mediaWatchRef.current = checkMedia;
435
510
  });
436
511
 
437
512
  const checkStallRef = useRef(() => {});
513
+ const mediaWatchRef = useRef<() => void>(() => undefined);
514
+
515
+ /** Arm one media read; each tick decides whether to arm another. */
516
+ const armMedia = useCallback((): void => {
517
+ if (mediaTimer.current !== null) return;
518
+ mediaTimer.current = setTimeout(() => mediaWatchRef.current(), MEDIA_POLL_MS);
519
+ }, []);
438
520
 
439
521
  /**
440
522
  * One ICE or engine-transport state, from either reporter. The mapping is
441
523
  * idempotent, so the peer's event and the engine's `session.state` telling
442
524
  * the same story neither double-counts nor fights.
443
525
  */
444
- const onTransportState = useCallback((state: string, trackIce = true) => {
526
+ const onTransportState = useCallback((state: string, trackIce = true, skipGrace = false) => {
445
527
  // Synthetic stall reports say nothing about ICE itself.
446
528
  if (trackIce) iceConnected.current = state === 'connected' || state === 'completed';
447
- const next = connectionStatusFor(state, statusRef.current, presentedRef.current);
448
- if (next === null) return;
529
+ const mapped = connectionStatusFor(state, statusRef.current, presentedRef.current && !frozen.current);
530
+ if (mapped === null) return;
531
+ // A transport that answers while the picture is frozen is not a
532
+ // recovery. For a session that has shown frames that stays `reconnecting`,
533
+ // rather than falling back to the first connection it has left behind.
534
+ const next = mapped === 'connecting' && statusRef.current === 'reconnecting' && presentedRef.current
535
+ ? 'reconnecting'
536
+ : mapped;
449
537
  // Our own restart's transients are expected, not a new outage.
450
- if (next === 'reconnecting' && Date.now() < restartGraceUntil.current) return;
538
+ if (!skipGrace && next === 'reconnecting' && Date.now() < restartGraceUntil.current) return;
451
539
  update({ status: next });
452
540
  if (next === 'reconnecting') {
453
541
  // Only a path that once carried frames is worth restarting: an
@@ -463,9 +551,66 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
463
551
  // from here is a stalled session, not a healthy idle one.
464
552
  cancelStall();
465
553
  stallTimer.current = setTimeout(() => checkStallRef.current(), STALL_AFTER_MS);
554
+ // And the picture is what says live, so it is watched from here.
555
+ armMedia();
466
556
  }
467
557
  }
468
- }, [cancelRestart, cancelStall, scheduleRestart, update]);
558
+ }, [cancelRestart, cancelStall, scheduleRestart, update, armMedia]);
559
+
560
+ /**
561
+ * One read of the receiver's own counts: is the picture actually advancing?
562
+ *
563
+ * The engine's transport state arrives over the signalling channel, which
564
+ * is not the media path, so a link that carries nothing while signalling
565
+ * answers can hand the session a `connected` it must not believe. This is
566
+ * the check that keeps `live` honest, and the one that ends the freeze:
567
+ * frames moving again put the session back to `live` on the same session.
568
+ */
569
+ const checkMedia = useCallback((): void => {
570
+ mediaTimer.current = null;
571
+ // A session that has shown a picture is watched for as long as it has
572
+ // one: a freeze that arrives while the session is already reconnecting
573
+ // is exactly the case where waiting for the transport to say so would
574
+ // say it for the rest of the outage.
575
+ if (!presentedRef.current) return;
576
+ armMedia();
577
+ const id = nativeRef.current;
578
+ if (id == null || nativeDesklink?.getStats == null) return;
579
+ const token = generationToken.current;
580
+ void nativeDesklink.getStats(id).then((raw) => {
581
+ if (token !== generationToken.current || nativeRef.current !== id) return;
582
+ let counts: { frames: number; packets: number } | null;
583
+ try {
584
+ counts = mediaCounts(JSON.parse(raw));
585
+ } catch {
586
+ return;
587
+ }
588
+ // No video stats yet: there is nothing here to judge a picture by.
589
+ if (counts === null) return;
590
+ const previous = mediaRef.current;
591
+ mediaRef.current = counts;
592
+ const moving = previous === null
593
+ || counts.frames > previous.frames
594
+ || counts.packets > previous.packets;
595
+ if (moving) {
596
+ mediaMovingAt.current = Date.now();
597
+ if (!frozen.current) return;
598
+ frozen.current = false;
599
+ // The picture advances again, so the transport can be believed
600
+ // for this session: live returns without waiting for it to say so.
601
+ if (statusRef.current === 'reconnecting') onTransportState('connected', false);
602
+ return;
603
+ }
604
+ if (mediaMovingAt.current === null) return;
605
+ if (Date.now() - mediaMovingAt.current < FROZEN_AFTER_MS) return;
606
+ if (frozen.current) return;
607
+ frozen.current = true;
608
+ // The picture has stopped, so the session is not live whatever the
609
+ // transport reports. When the transport has already said so, there
610
+ // is nothing to change: the recovery it started is the right one.
611
+ if (statusRef.current === 'live' || statusRef.current === 'connecting') onTransportState('disconnected', false, true);
612
+ }, () => undefined);
613
+ }, [armMedia, onTransportState]);
469
614
 
470
615
  /**
471
616
  * One stall check: quiet too long while the session should be talking
@@ -598,6 +743,13 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
598
743
  pendingCursorEvents.current = [];
599
744
  restartGraceUntil.current = 0;
600
745
  restartCycles.current = 0;
746
+ // A new session has its own media path: the last one's counts and its
747
+ // freeze say nothing about this one.
748
+ if (mediaTimer.current !== null) clearTimeout(mediaTimer.current);
749
+ mediaTimer.current = null;
750
+ mediaRef.current = null;
751
+ mediaMovingAt.current = null;
752
+ frozen.current = false;
601
753
  opened.current = null;
602
754
  signaling.current = null;
603
755
  setNativeId(null);
@@ -627,6 +779,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
627
779
  cancelReconnect();
628
780
  cancelRestart();
629
781
  cancelStall();
782
+ cancelFirstFrame();
630
783
  restoreToken.current = null;
631
784
  const openedRef = opened.current;
632
785
  const owner = signaling.current;
@@ -634,7 +787,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
634
787
  if (closeRemote) await endRemote(openedRef, owner);
635
788
  update({ status: 'ended', presented: false, failure: null });
636
789
  void reason;
637
- }, [cancelReconnect, cancelRestart, cancelStall, endRemote, discardSession, update]);
790
+ }, [cancelReconnect, cancelRestart, cancelStall, cancelFirstFrame, endRemote, discardSession, update]);
638
791
 
639
792
  const establish = useCallback(async () => {
640
793
  if (nativeRef.current != null) return;
@@ -751,6 +904,10 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
751
904
  // reopen; the app's own grant wins when it names one.
752
905
  restore_token: authorization.session.restoreToken ?? restoreToken.current ?? undefined,
753
906
  ttl_seconds: authorization.session.ttlSeconds,
907
+ // A TCP-only path (an SSH tunnel, `adb reverse`) can only be
908
+ // reached on the engine's loopback, which the engine offers
909
+ // only when the app asks. Off by default, as on the wire.
910
+ ...(authorization.session.loopbackTcp ? { loopback_tcp: true } : {}),
754
911
  });
755
912
  } catch (error) {
756
913
  const message = error instanceof Error ? error.message : 'the desktop could not start';
@@ -765,6 +922,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
765
922
  if (sample.sessionId === openedResult.sessionId) optionsRef.current.onCursor?.(sample);
766
923
  }
767
924
  update({ geometry: openedResult.geometry, status: 'connecting' });
925
+ armFirstFrame();
768
926
  if (token !== generationToken.current) return;
769
927
 
770
928
  const platform = nativeDesklink;
@@ -808,7 +966,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
808
966
  await endRemote(openedResult, authorization.signaling);
809
967
  }
810
968
  }
811
- }, [discardSession, endRemote, refuse, teardown, transportFailed, update, onTransportState]);
969
+ }, [armFirstFrame, discardSession, endRemote, refuse, teardown, transportFailed, update, onTransportState]);
812
970
 
813
971
  /**
814
972
  * Open (or reopen) the session with fresh authority. A deliberate call
@@ -869,7 +1027,11 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
869
1027
  restartAttempts.current = 0;
870
1028
  restartCycles.current = 0;
871
1029
  cancelRestart();
1030
+ cancelFirstFrame();
872
1031
  update({ status: 'live', presented: true, failure: null });
1032
+ // A first frame is a picture, so from here the client
1033
+ // watches that the picture keeps coming.
1034
+ armMedia();
873
1035
  return;
874
1036
  case 'control': {
875
1037
  const reply = parseControlReply(String(event.payload.message ?? ''));
@@ -955,7 +1117,7 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
955
1117
  dropped = true;
956
1118
  subscription?.remove();
957
1119
  };
958
- }, [transportFailed, onTransportState, cancelRestart, spendRestartAttempt, notePing, teardown, update, pressKey, typeText]);
1120
+ }, [transportFailed, onTransportState, cancelRestart, spendRestartAttempt, notePing, teardown, update, pressKey, typeText, armMedia]);
959
1121
 
960
1122
  const copyRemoteToLocal = useCallback(async (writeLocal: (text: string) => Promise<void>) => {
961
1123
  if (!inputEnabled.current) throw new Error('Desktop control is off.');
@@ -1031,6 +1193,9 @@ export function useDesktopSession(options: DesktopSessionOptions): DesktopSessio
1031
1193
  cancelReconnect();
1032
1194
  cancelRestart();
1033
1195
  cancelStall();
1196
+ cancelFirstFrame();
1197
+ if (mediaTimer.current !== null) clearTimeout(mediaTimer.current);
1198
+ mediaTimer.current = null;
1034
1199
  generationToken.current += 1;
1035
1200
  const id = nativeRef.current;
1036
1201
  nativeRef.current = null;