@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.
- package/dist/components/inspect-overlay.d.ts +33 -0
- package/dist/components/remote-control.d.ts +86 -0
- package/dist/core/ax-fetcher.d.ts +49 -0
- package/dist/core/ax-tree.d.ts +99 -0
- package/dist/index.cjs +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1485 -778
- package/package.json +7 -3
- package/src/components/inspect-overlay.css +223 -0
- package/src/components/inspect-overlay.tsx +437 -0
- package/src/components/remote-control.tsx +547 -9
- package/src/core/ax-fetcher.test.ts +418 -0
- package/src/core/ax-fetcher.ts +377 -0
- package/src/core/ax-tree.test.ts +491 -0
- package/src/core/ax-tree.ts +416 -0
- package/src/demo.tsx +93 -10
- package/src/index.ts +17 -0
- package/vitest.config.ts +23 -0
|
@@ -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
|
-
|
|
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
|