@limrun/ui 0.9.0-rc.4 → 0.9.0-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,9 @@ import {
18
18
  createSetClipboardMessage,
19
19
  createTwoFingerTouchControlMessage,
20
20
  } from '../core/webrtc-messages';
21
+ import { AxFetcher, AxStatus } from '../core/ax-fetcher';
22
+ import { AxElement, AxSnapshot, axElementAtPoint, axSnapshotsEqual } from '../core/ax-tree';
23
+ import { InspectOverlay, InspectOverlayGeometry, InspectMode } from './inspect-overlay';
21
24
 
22
25
  declare global {
23
26
  interface Window {
@@ -55,6 +58,88 @@ interface RemoteControlProps {
55
58
  // When true, drops after a working session auto-reconnect instead of
56
59
  // surfacing the manual "Retry" button. Defaults to false.
57
60
  autoReconnect?: boolean;
61
+
62
+ /**
63
+ * Enable the inspect overlay. When set, the component starts polling the
64
+ * accessibility tree and draws boxes over each element on top of the
65
+ * video stream.
66
+ *
67
+ * - `true` — Select mode. Boxes are clickable, click pins a selection
68
+ * with action buttons (Tap / Copy selector / Copy id), ESC clears.
69
+ * Device input is blocked while in this mode.
70
+ * - `'hover-only'` — Boxes follow the cursor as a visual preview. Device
71
+ * input still passes through, so you can drive the simulator while
72
+ * inspecting.
73
+ * - `undefined` / `false` (default) — overlay disabled, no polling.
74
+ */
75
+ inspectMode?: boolean | 'hover-only';
76
+
77
+ /**
78
+ * Fires whenever a fresh accessibility snapshot is delivered.
79
+ *
80
+ * Customers use this to drive their own side panels, agent prompts,
81
+ * analytics, etc. The built-in overlay does not require this callback —
82
+ * it renders from internal state regardless.
83
+ *
84
+ * Identical-to-previous snapshots (per `axSnapshotsEqual`) are NOT
85
+ * re-emitted, so a stable UI doesn't generate callback noise.
86
+ *
87
+ * Invoked in a microtask so customer code doesn't run synchronously
88
+ * inside React's commit phase.
89
+ */
90
+ onAxSnapshotChange?: (snapshot: AxSnapshot | null) => void;
91
+
92
+ /**
93
+ * Fires when the user clicks an overlay element (only emitted when
94
+ * `inspectMode === true`). `null` indicates a deselection (ESC, click
95
+ * outside any box, or programmatic clear).
96
+ *
97
+ * The `snapshot` field is the snapshot active at the moment of the
98
+ * click — useful for capturing context without races against the next
99
+ * poll cycle.
100
+ */
101
+ onInspectSelectionChange?: (selection: { element: AxElement; snapshot: AxSnapshot } | null) => void;
102
+
103
+ /**
104
+ * Fires whenever the accessibility subsystem changes coarse-grained
105
+ * status. Useful for rendering readiness indicators or error banners in
106
+ * a customer-built side panel.
107
+ *
108
+ * Transitions are deduplicated; no self-loops are emitted. The `error`
109
+ * argument is populated when status is `error` or `unavailable`.
110
+ *
111
+ * Lifecycle: `idle` → `starting` → `ready` (or `unavailable` / `error`).
112
+ * Recovery from `error` / `unavailable` is automatic — the fetcher
113
+ * keeps polling and transitions back to `ready` on the next success.
114
+ */
115
+ onAxStatusChange?: (status: AxStatus, error?: string) => void;
116
+
117
+ /**
118
+ * Base interval (ms) between successful AX-tree fetches.
119
+ *
120
+ * The fetcher will:
121
+ * - Wait `axPollIntervalMs` after a successful fetch with NEW data.
122
+ * - Double the wait (up to `axMaxBackoffMs`) when consecutive snapshots
123
+ * are byte-identical (e.g. static screen).
124
+ * - Wait 5 s when the server reports AX is unavailable.
125
+ *
126
+ * In addition, after user input (taps, scrolls, keypresses, openUrl,
127
+ * terminateApp, orientation flips), the fetcher enters a short
128
+ * "activity boost" window (~1.2 s) during which fetches happen at
129
+ * ~250 ms regardless of this setting. This captures mid-animation UI
130
+ * changes without you having to manually call `refreshAxTree`.
131
+ *
132
+ * @default 500
133
+ */
134
+ axPollIntervalMs?: number;
135
+
136
+ /**
137
+ * Maximum backoff (ms) for the AX-tree polling loop when consecutive
138
+ * snapshots are unchanged.
139
+ *
140
+ * @default 2000
141
+ */
142
+ axMaxBackoffMs?: number;
58
143
  }
59
144
 
60
145
  interface ScreenshotData {
@@ -76,6 +161,27 @@ export interface RemoteControlHandle {
76
161
  screenshot: () => Promise<ScreenshotData>;
77
162
  terminateApp: (bundleId: string) => Promise<void>;
78
163
  reconnect: () => void;
164
+
165
+ // Inspect-mode helpers. These are no-ops when inspect mode is disabled or
166
+ // the WebSocket isn't open.
167
+
168
+ // Force a fresh accessibility-tree fetch outside the normal poll cadence.
169
+ refreshAxTree: () => Promise<AxSnapshot>;
170
+
171
+ // Pull-based access to the most recent snapshot (the same one passed to
172
+ // onAxSnapshotChange). Returns null when no snapshot has arrived yet or
173
+ // when inspect mode is off.
174
+ getAxSnapshot: () => AxSnapshot | null;
175
+
176
+ // Programmatically drive the overlay highlight/selection — useful when a
177
+ // customer's own side panel wants to cross-highlight with the overlay.
178
+ // Pass `null` to clear.
179
+ setInspectHighlight: (element: AxElement | null) => void;
180
+ setInspectSelection: (element: AxElement | null) => void;
181
+
182
+ // Pull-based access to the current AX subsystem status. Mirrors what
183
+ // onAxStatusChange reports, for customers that don't want to subscribe.
184
+ getAxStatus: () => AxStatus;
79
185
  }
80
186
 
81
187
  const debugLog = (...args: any[]) => {
@@ -90,6 +196,26 @@ const debugWarn = (...args: any[]) => {
90
196
  }
91
197
  };
92
198
 
199
+ // Invokes a customer-provided callback in isolation. A throw from the
200
+ // customer's code must NOT propagate back into our state-update flow — that
201
+ // would risk corrupting React reconciliation. We log the error to the
202
+ // console so the customer can still debug, but otherwise swallow.
203
+ const safeInvoke = <Args extends unknown[]>(
204
+ label: string,
205
+ fn: ((...args: Args) => unknown) | undefined,
206
+ ...args: Args
207
+ ): void => {
208
+ if (!fn) return;
209
+ try {
210
+ fn(...args);
211
+ } catch (err) {
212
+ // Surface to the developer regardless of debug flag — this is a bug
213
+ // in the customer's handler and they'll want to see it.
214
+ // eslint-disable-next-line no-console
215
+ console.error(`[RemoteControl] customer callback "${label}" threw:`, err);
216
+ }
217
+ };
218
+
93
219
  const motionActionToString = (action: number): string => {
94
220
  // AMOTION_EVENT is a constants object; find the matching ACTION_* key if present
95
221
  const match = Object.entries(AMOTION_EVENT).find(
@@ -206,6 +332,12 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
206
332
  openUrl,
207
333
  showFrame = true,
208
334
  autoReconnect = false,
335
+ inspectMode,
336
+ onAxSnapshotChange,
337
+ onInspectSelectionChange,
338
+ onAxStatusChange,
339
+ axPollIntervalMs,
340
+ axMaxBackoffMs,
209
341
  }: RemoteControlProps,
210
342
  ref,
211
343
  ) => {
@@ -279,6 +411,62 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
279
411
  };
280
412
  const [hoverPoint, setHoverPoint] = useState<HoverPoint | null>(null);
281
413
 
414
+ // Inspect-mode state.
415
+ //
416
+ // Lifecycle of `axFetcherRef`:
417
+ // - Created in dataChannel.onopen (last step of WebRTC handshake)
418
+ // so we know the signaling WS is healthy and the device is
419
+ // responsive to control messages.
420
+ // - Started immediately if `inspectMode` is already enabled, or
421
+ // started later via the sibling useEffect when inspectMode flips on.
422
+ // - Stopped + nulled in teardownConnection (WS close / unmount).
423
+ //
424
+ // Customers can observe readiness via the `onAxStatusChange` callback:
425
+ // `starting` fires when start() runs but no snapshot has landed yet;
426
+ // `ready` once the first snapshot arrives. Status falls back to
427
+ // `unavailable` / `error` if the server can't satisfy AX requests.
428
+ const axFetcherRef = useRef<AxFetcher | null>(null);
429
+ const [axSnapshot, setAxSnapshot] = useState<AxSnapshot | null>(null);
430
+ const [axHighlightedId, setAxHighlightedId] = useState<string | null>(null);
431
+ const [axSelectedId, setAxSelectedId] = useState<string | null>(null);
432
+ const [overlayGeometry, setOverlayGeometry] = useState<InspectOverlayGeometry | null>(null);
433
+ // Viewport-space cursor position used to anchor the inspect InfoCard.
434
+ // Throttled to one update per animation frame to avoid React reconciling
435
+ // on every native mousemove (~60–120Hz).
436
+ const [axCursorPosition, setAxCursorPosition] = useState<{ x: number; y: number } | null>(null);
437
+ const cursorPositionRef = useRef<{ x: number; y: number } | null>(null);
438
+ const cursorRafIdRef = useRef<number | undefined>(undefined);
439
+ const scheduleCursorFlush = (next: { x: number; y: number } | null) => {
440
+ cursorPositionRef.current = next;
441
+ if (cursorRafIdRef.current !== undefined) return;
442
+ cursorRafIdRef.current = window.requestAnimationFrame(() => {
443
+ cursorRafIdRef.current = undefined;
444
+ setAxCursorPosition(cursorPositionRef.current);
445
+ });
446
+ };
447
+ // Position captured at click-time so the InfoCard "freezes" near where
448
+ // the user clicked, even as they move the cursor around afterward. The
449
+ // action buttons (Tap / Copy) stay reachable because the card no longer
450
+ // chases the cursor while the click target is the active selection.
451
+ const [axFrozenCursorPosition, setAxFrozenCursorPosition] = useState<{
452
+ x: number;
453
+ y: number;
454
+ } | null>(null);
455
+ // Mirrors for synchronous access from event handlers without stale closures.
456
+ const inspectModeRef = useRef<boolean | 'hover-only' | undefined>(inspectMode);
457
+ inspectModeRef.current = inspectMode;
458
+ const axSnapshotRef = useRef<AxSnapshot | null>(null);
459
+ axSnapshotRef.current = axSnapshot;
460
+ const onAxSnapshotChangeRef = useRef(onAxSnapshotChange);
461
+ onAxSnapshotChangeRef.current = onAxSnapshotChange;
462
+ const onInspectSelectionChangeRef = useRef(onInspectSelectionChange);
463
+ onInspectSelectionChangeRef.current = onInspectSelectionChange;
464
+ const onAxStatusChangeRef = useRef(onAxStatusChange);
465
+ onAxStatusChangeRef.current = onAxStatusChange;
466
+
467
+ const inspectActive = inspectMode === true || inspectMode === 'hover-only';
468
+ const inspectModeResolved: InspectMode = inspectMode === 'hover-only' ? 'hover-only' : 'select';
469
+
282
470
  const sessionId = useMemo(
283
471
  () =>
284
472
  propSessionId ||
@@ -299,6 +487,72 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
299
487
  return;
300
488
  }
301
489
  dataChannelRef.current.send(data);
490
+ // Any binary control message is an input event. Bump the AX poller so
491
+ // we get a fresh snapshot quickly — the UI almost certainly changed.
492
+ axFetcherRef.current?.bumpActivity();
493
+ };
494
+
495
+ // Pointer ID used by inspect-driven taps. Distinct from human pointers
496
+ // (-1 mouse, -2 alt-mirror) and our touch identifiers so they never
497
+ // interfere with an in-progress drag.
498
+ const AX_TAP_POINTER_ID = -10;
499
+
500
+ // Send a down+up tap at a viewport-space (clientX/Y) position. The point
501
+ // is mapped through the current video letterbox geometry so the
502
+ // simulator receives the correct in-stream coordinates regardless of
503
+ // how the device frame is sized in the DOM.
504
+ const sendTapAtClient = (clientX: number, clientY: number) => {
505
+ const ctx = computeVideoMappingContext();
506
+ if (!ctx) return;
507
+ const geometry = mapClientPointToVideo(ctx, clientX, clientY);
508
+ if (!geometry) return;
509
+ const { videoX, videoY, videoWidth, videoHeight } = geometry;
510
+ const down = createTouchControlMessage(
511
+ AMOTION_EVENT.ACTION_DOWN,
512
+ AX_TAP_POINTER_ID,
513
+ videoWidth,
514
+ videoHeight,
515
+ videoX,
516
+ videoY,
517
+ 1.0,
518
+ AMOTION_EVENT.BUTTON_PRIMARY,
519
+ AMOTION_EVENT.BUTTON_PRIMARY,
520
+ );
521
+ if (down) sendBinaryControlMessage(down);
522
+ window.setTimeout(() => {
523
+ const up = createTouchControlMessage(
524
+ AMOTION_EVENT.ACTION_UP,
525
+ AX_TAP_POINTER_ID,
526
+ videoWidth,
527
+ videoHeight,
528
+ videoX,
529
+ videoY,
530
+ 0,
531
+ AMOTION_EVENT.BUTTON_PRIMARY,
532
+ AMOTION_EVENT.BUTTON_PRIMARY,
533
+ );
534
+ if (up) sendBinaryControlMessage(up);
535
+ }, 60);
536
+ };
537
+
538
+ // Center-of-bounds fallback for programmatic taps when there's no
539
+ // user-aimed click position (e.g. customer calls `setInspectSelection`
540
+ // followed by their own "tap selected" handler without forwarding a
541
+ // pointer position). Maps the element's frame center through the AX
542
+ // screen-coordinate space to viewport coords, then delegates to
543
+ // sendTapAtClient.
544
+ const sendTapAtElementCenter = (element: AxElement, snapshot: AxSnapshot) => {
545
+ const ctx = computeVideoMappingContext();
546
+ if (!ctx) return;
547
+ if (snapshot.screen.width <= 0 || snapshot.screen.height <= 0) return;
548
+ const cxAx = element.frame.x + element.frame.width / 2;
549
+ const cyAx = element.frame.y + element.frame.height / 2;
550
+ // AX screen-fraction → in-video pixel offset → viewport client coord.
551
+ const inVideoX = (cxAx / snapshot.screen.width) * ctx.actualWidth;
552
+ const inVideoY = (cyAx / snapshot.screen.height) * ctx.actualHeight;
553
+ const clientX = ctx.videoRect.left + ctx.offsetX + inVideoX;
554
+ const clientY = ctx.videoRect.top + ctx.offsetY + inVideoY;
555
+ sendTapAtClient(clientX, clientY);
302
556
  };
303
557
 
304
558
  // Fixed pointer IDs for Alt-simulated two-finger gestures
@@ -688,6 +942,23 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
688
942
  setHoverPoint(fullPoint);
689
943
  };
690
944
 
945
+ // Map clientX/Y to AX screen-coordinate space using the latest snapshot.
946
+ // Returns null if there's no snapshot or the click is outside the video.
947
+ const hitTestAxAtClient = (
948
+ ctx: VideoMappingContext,
949
+ clientX: number,
950
+ clientY: number,
951
+ ): AxElement | null => {
952
+ const snapshot = axSnapshotRef.current;
953
+ if (!snapshot || snapshot.screen.width <= 0 || snapshot.screen.height <= 0) return null;
954
+ const relX = clientX - ctx.videoRect.left - ctx.offsetX;
955
+ const relY = clientY - ctx.videoRect.top - ctx.offsetY;
956
+ if (relX < 0 || relY < 0 || relX > ctx.actualWidth || relY > ctx.actualHeight) return null;
957
+ const axX = (relX / ctx.actualWidth) * snapshot.screen.width;
958
+ const axY = (relY / ctx.actualHeight) * snapshot.screen.height;
959
+ return axElementAtPoint(snapshot, axX, axY);
960
+ };
961
+
691
962
  // Unified handler for both mouse and touch interactions
692
963
  const handleInteraction = (event: React.MouseEvent | React.TouchEvent) => {
693
964
  event.preventDefault();
@@ -696,6 +967,36 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
696
967
  // Compute mapping context once per event (reused for all pointers)
697
968
  const ctx = computeVideoMappingContext();
698
969
 
970
+ // Inspect-mode handling.
971
+ //
972
+ // We use JS hit-testing (not box-level onMouseEnter/Leave) as the
973
+ // single source of truth for which element is under the cursor — it
974
+ // handles overlapping rectangles deterministically by picking the
975
+ // smallest matching box. The overlay's InspectBox children no longer
976
+ // attach hover handlers; they just paint themselves based on the
977
+ // `highlightedId` prop driven from here.
978
+ //
979
+ // Cursor position is tracked in both modes so the cursor-anchored
980
+ // InfoCard can follow the pointer.
981
+ const isInspecting = inspectModeRef.current === true || inspectModeRef.current === 'hover-only';
982
+ if (isInspecting && !('touches' in event)) {
983
+ if (event.type === 'mousemove') {
984
+ scheduleCursorFlush({ x: event.clientX, y: event.clientY });
985
+ if (ctx) {
986
+ const hit = hitTestAxAtClient(ctx, event.clientX, event.clientY);
987
+ setAxHighlightedId(hit?.id ?? null);
988
+ }
989
+ } else if (event.type === 'mouseleave') {
990
+ scheduleCursorFlush(null);
991
+ setAxHighlightedId(null);
992
+ }
993
+ }
994
+ // Select mode blocks device input — clicks/drags don't reach the
995
+ // simulator. Hover-only mode falls through to the regular path.
996
+ if (inspectModeRef.current === true) {
997
+ return;
998
+ }
999
+
699
1000
  // Handle hover point updates for mouse events (only when Alt is held)
700
1001
  if (!('touches' in event) && ctx) {
701
1002
  if (event.type === 'mousemove') {
@@ -1138,6 +1439,16 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1138
1439
  clearConnectionSuccessTimeout();
1139
1440
  clearIceDisconnectedGrace();
1140
1441
  stopRequestFrameLoop();
1442
+ if (axFetcherRef.current) {
1443
+ axFetcherRef.current.stop();
1444
+ axFetcherRef.current = null;
1445
+ }
1446
+ // A scheduled cursor flush would otherwise call setState on a
1447
+ // teardown component once the next frame runs.
1448
+ if (cursorRafIdRef.current !== undefined) {
1449
+ window.cancelAnimationFrame(cursorRafIdRef.current);
1450
+ cursorRafIdRef.current = undefined;
1451
+ }
1141
1452
  if (wsRef.current) {
1142
1453
  wsRef.current.onopen = null;
1143
1454
  wsRef.current.onmessage = null;
@@ -1372,6 +1683,44 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1372
1683
  controlChannelOpenedRef.current = true;
1373
1684
  clearConnectionSuccessTimeout();
1374
1685
  updateStatus('Control channel opened');
1686
+
1687
+ // Spin up the AX fetcher now that we have a stable WS + control
1688
+ // channel. The fetcher's send function reuses this WS; it stops
1689
+ // sending if the WS dies. start() is called lazily based on the
1690
+ // inspectMode prop via a sibling useEffect.
1691
+ if (!axFetcherRef.current) {
1692
+ axFetcherRef.current = new AxFetcher({
1693
+ platform,
1694
+ baseIntervalMs: axPollIntervalMs,
1695
+ maxBackoffMs: axMaxBackoffMs,
1696
+ send: (payload) => {
1697
+ if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return false;
1698
+ try {
1699
+ wsRef.current.send(JSON.stringify(payload));
1700
+ return true;
1701
+ } catch {
1702
+ return false;
1703
+ }
1704
+ },
1705
+ onSnapshot: (snapshot) => {
1706
+ setAxSnapshot((prev) => (axSnapshotsEqual(prev, snapshot) ? prev : snapshot));
1707
+ // Defer to a microtask so customer code (which may DOM-write,
1708
+ // start expensive work, or itself call back into ref
1709
+ // methods) doesn't run synchronously inside our state-setter
1710
+ // path. React then has a chance to schedule its render before
1711
+ // the customer handler kicks off side-effects.
1712
+ queueMicrotask(() => {
1713
+ safeInvoke('onAxSnapshotChange', onAxSnapshotChangeRef.current, snapshot);
1714
+ });
1715
+ },
1716
+ onStatusChange: (status, error) => {
1717
+ safeInvoke('onAxStatusChange', onAxStatusChangeRef.current, status, error);
1718
+ },
1719
+ });
1720
+ if (inspectModeRef.current === true || inspectModeRef.current === 'hover-only') {
1721
+ axFetcherRef.current.start();
1722
+ }
1723
+ }
1375
1724
  const sendRequestFrame = () => {
1376
1725
  if (
1377
1726
  !isCurrentAttempt() ||
@@ -1423,6 +1772,9 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1423
1772
  }),
1424
1773
  );
1425
1774
  }
1775
+ // openUrl can take a moment to load the destination — boost
1776
+ // AX polling so the overlay refreshes through the transition.
1777
+ axFetcherRef.current?.bumpActivity();
1426
1778
  }
1427
1779
  };
1428
1780
 
@@ -1529,6 +1881,12 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1529
1881
  debugWarn('Error parsing message:', e);
1530
1882
  return;
1531
1883
  }
1884
+ // Inspect-mode responses are routed to the fetcher first so it
1885
+ // can resolve in-flight requests regardless of which platform's
1886
+ // protocol is in use.
1887
+ if (axFetcherRef.current?.handleMessage(message)) {
1888
+ return;
1889
+ }
1532
1890
  updateStatus('Received: ' + message.type);
1533
1891
  switch (message.type) {
1534
1892
  case 'answer':
@@ -1724,20 +2082,52 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1724
2082
  };
1725
2083
  }, [url, token, propSessionId]);
1726
2084
 
2085
+ // Recompute the inspect-overlay geometry (container-local pixel rect of
2086
+ // the actually-rendered video content) from the current mapping context.
2087
+ // The InfoCard places itself in viewport coordinates from pointer events
2088
+ // directly, so no viewport-space origin is needed in the geometry.
2089
+ const recomputeOverlayGeometry = () => {
2090
+ const ctx = computeVideoMappingContext();
2091
+ if (!ctx) {
2092
+ setOverlayGeometry(null);
2093
+ return;
2094
+ }
2095
+ const next: InspectOverlayGeometry = {
2096
+ left: ctx.videoRect.left - ctx.containerRect.left + ctx.offsetX,
2097
+ top: ctx.videoRect.top - ctx.containerRect.top + ctx.offsetY,
2098
+ width: ctx.actualWidth,
2099
+ height: ctx.actualHeight,
2100
+ };
2101
+ setOverlayGeometry((prev) =>
2102
+ (
2103
+ prev &&
2104
+ prev.left === next.left &&
2105
+ prev.top === next.top &&
2106
+ prev.width === next.width &&
2107
+ prev.height === next.height
2108
+ ) ?
2109
+ prev
2110
+ : next,
2111
+ );
2112
+ };
2113
+
1727
2114
  // Calculate video position and border-radius based on frame dimensions
1728
2115
  useEffect(() => {
1729
2116
  const video = videoRef.current;
1730
2117
  const frame = frameRef.current;
2118
+ const container = containerRef.current;
1731
2119
 
1732
2120
  if (!video) return;
1733
2121
 
1734
- // If no frame, no positioning needed
1735
- if (!showFrame || !frame) {
1736
- setVideoStyle({});
1737
- return;
1738
- }
1739
-
1740
2122
  const updateVideoPosition = () => {
2123
+ // If no frame, just refresh overlay geometry; no inset/letterbox math
2124
+ // is needed since the video element is its own size.
2125
+ if (!showFrame || !frame) {
2126
+ setVideoStyle({});
2127
+ recomputeOverlayGeometry();
2128
+ return;
2129
+ }
2130
+
1741
2131
  const frameWidth = frame.clientWidth;
1742
2132
  const frameHeight = frame.clientHeight;
1743
2133
 
@@ -1767,17 +2157,19 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1767
2157
  : frameWidth * config.videoBorderRadiusMultiplier
1768
2158
  }px`;
1769
2159
  setVideoStyle(newStyle);
2160
+ recomputeOverlayGeometry();
1770
2161
  };
1771
2162
 
1772
2163
  const resizeObserver = new ResizeObserver(() => {
1773
2164
  updateVideoPosition();
1774
2165
  });
1775
2166
 
1776
- resizeObserver.observe(frame);
2167
+ if (frame) resizeObserver.observe(frame);
1777
2168
  resizeObserver.observe(video);
2169
+ if (container) resizeObserver.observe(container);
1778
2170
 
1779
2171
  // Also update when the frame image loads
1780
- frame.addEventListener('load', updateVideoPosition);
2172
+ if (frame) frame.addEventListener('load', updateVideoPosition);
1781
2173
 
1782
2174
  // Update when video metadata loads (to get correct intrinsic dimensions)
1783
2175
  video.addEventListener('loadedmetadata', updateVideoPosition);
@@ -1786,6 +2178,12 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1786
2178
  // (videoWidth/videoHeight) can change without re-firing 'loadedmetadata'.
1787
2179
  // The <video> element emits 'resize' in that case.
1788
2180
  video.addEventListener('resize', updateVideoPosition);
2181
+ // Orientation flips also mean every element's AX frame just changed
2182
+ // (portrait↔landscape rotates the layout). Bump so the overlay
2183
+ // refreshes immediately rather than waiting out the current poll
2184
+ // cycle in a layout that no longer matches the boxes.
2185
+ const bumpOnResize = () => axFetcherRef.current?.bumpActivity();
2186
+ video.addEventListener('resize', bumpOnResize);
1789
2187
 
1790
2188
  // Initial calculation
1791
2189
  updateVideoPosition();
@@ -1794,10 +2192,60 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1794
2192
  resizeObserver.disconnect();
1795
2193
  video.removeEventListener('loadedmetadata', updateVideoPosition);
1796
2194
  video.removeEventListener('resize', updateVideoPosition);
1797
- frame.removeEventListener('load', updateVideoPosition);
2195
+ video.removeEventListener('resize', bumpOnResize);
2196
+ if (frame) frame.removeEventListener('load', updateVideoPosition);
1798
2197
  };
1799
2198
  }, [config, showFrame]);
1800
2199
 
2200
+ // Start/stop the AX poller and reset inspect state when inspect mode
2201
+ // toggles. Connection state is independent: the fetcher gets created on
2202
+ // dataChannel.onopen and destroyed on teardown.
2203
+ useEffect(() => {
2204
+ const fetcher = axFetcherRef.current;
2205
+ if (inspectActive) {
2206
+ fetcher?.start();
2207
+ } else {
2208
+ fetcher?.stop();
2209
+ setAxSnapshot(null);
2210
+ setAxHighlightedId(null);
2211
+ setAxSelectedId(null);
2212
+ setAxCursorPosition(null);
2213
+ setAxFrozenCursorPosition(null);
2214
+ cursorPositionRef.current = null;
2215
+ if (cursorRafIdRef.current !== undefined) {
2216
+ window.cancelAnimationFrame(cursorRafIdRef.current);
2217
+ cursorRafIdRef.current = undefined;
2218
+ }
2219
+ safeInvoke('onAxSnapshotChange', onAxSnapshotChangeRef.current, null);
2220
+ }
2221
+ }, [inspectActive]);
2222
+
2223
+ // Cancel any pending cursor-rAF on unmount so we don't setState on a
2224
+ // dead component.
2225
+ useEffect(() => {
2226
+ return () => {
2227
+ if (cursorRafIdRef.current !== undefined) {
2228
+ window.cancelAnimationFrame(cursorRafIdRef.current);
2229
+ cursorRafIdRef.current = undefined;
2230
+ }
2231
+ };
2232
+ }, []);
2233
+
2234
+ // ESC clears overlay selection (Chrome DevTools behavior).
2235
+ useEffect(() => {
2236
+ if (!inspectActive) return;
2237
+ const handleEsc = (e: KeyboardEvent) => {
2238
+ if (e.key === 'Escape' && (axSelectedId || axHighlightedId)) {
2239
+ setAxSelectedId(null);
2240
+ setAxHighlightedId(null);
2241
+ setAxFrozenCursorPosition(null);
2242
+ safeInvoke('onInspectSelectionChange', onInspectSelectionChangeRef.current, null);
2243
+ }
2244
+ };
2245
+ window.addEventListener('keydown', handleEsc);
2246
+ return () => window.removeEventListener('keydown', handleEsc);
2247
+ }, [inspectActive, axSelectedId, axHighlightedId]);
2248
+
1801
2249
  const handleVideoClick = () => {
1802
2250
  if (videoRef.current) {
1803
2251
  videoRef.current.focus();
@@ -1831,6 +2279,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1831
2279
  }),
1832
2280
  );
1833
2281
  }
2282
+ axFetcherRef.current?.bumpActivity();
1834
2283
  },
1835
2284
 
1836
2285
  sendKeyEvent: (event: ImperativeKeyboardEvent) => {
@@ -1930,6 +2379,10 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1930
2379
  reject(err);
1931
2380
  return;
1932
2381
  }
2382
+ // Terminating the foreground app drops the user back to the home
2383
+ // screen — bump so the overlay reflects the post-terminate state
2384
+ // through the SpringBoard transition.
2385
+ axFetcherRef.current?.bumpActivity();
1933
2386
 
1934
2387
  setTimeout(() => {
1935
2388
  if (pendingTerminateAppResolversRef.current.has(id)) {
@@ -1942,6 +2395,46 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1942
2395
  });
1943
2396
  },
1944
2397
  reconnect: () => start(),
2398
+
2399
+ refreshAxTree: async (): Promise<AxSnapshot> => {
2400
+ const fetcher = axFetcherRef.current;
2401
+ if (!fetcher) {
2402
+ throw new Error('Inspect mode is not active');
2403
+ }
2404
+ // The fetcher's refresh() runs the result through the same
2405
+ // change-detect path as the poll loop (via deliver()), which calls
2406
+ // back into onSnapshot — already wired to setAxSnapshot +
2407
+ // onAxSnapshotChange (with safe-invoke). We just return the fetched
2408
+ // payload for callers that want it.
2409
+ return fetcher.refresh();
2410
+ },
2411
+
2412
+ getAxSnapshot: () => axSnapshotRef.current,
2413
+
2414
+ setInspectHighlight: (element: AxElement | null) => {
2415
+ setAxHighlightedId(element?.id ?? null);
2416
+ },
2417
+
2418
+ setInspectSelection: (element: AxElement | null) => {
2419
+ setAxSelectedId(element?.id ?? null);
2420
+ // Programmatic selection has no click position — anchor the card at
2421
+ // the last known cursor position (if any), otherwise clear.
2422
+ // Customer-facing UIs that drive selection from their own panels can
2423
+ // call setInspectHighlight separately to move the cursor visual.
2424
+ if (element) {
2425
+ setAxFrozenCursorPosition(cursorPositionRef.current);
2426
+ } else {
2427
+ setAxFrozenCursorPosition(null);
2428
+ }
2429
+ const snapshot = axSnapshotRef.current;
2430
+ if (element && snapshot) {
2431
+ safeInvoke('onInspectSelectionChange', onInspectSelectionChangeRef.current, { element, snapshot });
2432
+ } else {
2433
+ safeInvoke('onInspectSelectionChange', onInspectSelectionChangeRef.current, null);
2434
+ }
2435
+ },
2436
+
2437
+ getAxStatus: () => axFetcherRef.current?.getStatus() ?? 'idle',
1945
2438
  }));
1946
2439
 
1947
2440
  // Show indicators when Alt is held and we have a valid hover point (null when outside)
@@ -2029,6 +2522,51 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
2029
2522
  }
2030
2523
  }}
2031
2524
  />
2525
+ {inspectActive && (
2526
+ <InspectOverlay
2527
+ snapshot={axSnapshot}
2528
+ geometry={overlayGeometry}
2529
+ highlightedId={axHighlightedId}
2530
+ selectedId={axSelectedId}
2531
+ mode={inspectModeResolved}
2532
+ cursorPosition={axCursorPosition}
2533
+ frozenCursorPosition={axFrozenCursorPosition}
2534
+ onSelectChange={(element, clickPosition) => {
2535
+ setAxSelectedId(element?.id ?? null);
2536
+ if (element && clickPosition) {
2537
+ setAxFrozenCursorPosition(clickPosition);
2538
+ } else if (!element) {
2539
+ setAxFrozenCursorPosition(null);
2540
+ }
2541
+ const snapshot = axSnapshotRef.current;
2542
+ if (element && snapshot) {
2543
+ safeInvoke('onInspectSelectionChange', onInspectSelectionChangeRef.current, {
2544
+ element,
2545
+ snapshot,
2546
+ });
2547
+ } else {
2548
+ safeInvoke('onInspectSelectionChange', onInspectSelectionChangeRef.current, null);
2549
+ }
2550
+ }}
2551
+ onTapElement={(element, tapAt) => {
2552
+ // Use the viewport-space position the user originally aimed at
2553
+ // (the frozen click position). For containers whose children
2554
+ // are absent from the accessibility tree — e.g. iOS UITabBar's
2555
+ // home/diagnostics/settings buttons — this taps the specific
2556
+ // button the user pointed at instead of the container's
2557
+ // averaged center.
2558
+ if (tapAt) {
2559
+ sendTapAtClient(tapAt.x, tapAt.y);
2560
+ return;
2561
+ }
2562
+ // Fallback (defensive): center of element. Should be
2563
+ // unreachable from the InfoCard since it always passes anchor.
2564
+ const snapshot = axSnapshotRef.current;
2565
+ if (!snapshot) return;
2566
+ sendTapAtElementCenter(element, snapshot);
2567
+ }}
2568
+ />
2569
+ )}
2032
2570
  {retryExhausted && (
2033
2571
  <button type="button" className="rc-retry-button" onClick={handleManualRetry}>
2034
2572
  Retry