1import { withDeviceHubQuery } from "@t3tools/client-runtime/state/deviceHubAccess";
2import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess";
3import type { DevicePlatform } from "@t3tools/contracts";
4
5/**
6 * Read-only hub endpoints the Tools drawer consumes directly: the accessibility
7 * tree, the foreground app, and the event log. Everything that changes device
8 * state goes through the `device.action` RPC instead, so this file never POSTs.
9 */
10
11export interface DeviceAxElement {
12 readonly id: string;
13 readonly label: string;
14 readonly role: string;
15 /** Normalized to the displayed screen: 0..1 on both axes. */
16 readonly x: number;
17 readonly y: number;
18 readonly width: number;
19 readonly height: number;
20}
21
22export interface DeviceAxTree {
23 readonly elements: ReadonlyArray<DeviceAxElement>;
24 readonly errors: ReadonlyArray<string>;
25}
26
27export interface DeviceEventLogEntry {
28 readonly id: number;
29 readonly timestamp: string;
30 readonly kind: string;
31 readonly summary: string;
32}
33
34export interface DeviceForegroundInfo {
35 readonly id: string;
36 readonly label?: string;
37 readonly pid?: number;
38 readonly isReactNative?: boolean;
39}
40
41interface Target {
42 readonly access: DeviceHubAccess;
43 readonly platform: DevicePlatform;
44 readonly deviceId: string;
45}
46
47const vendorBase = (target: Target) =>
48 `${target.access.httpBase}${target.platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu"}`;
49
50const hubUrl = (target: Target, path: string, params?: Record<string, string>) => {
51 const search = params ? `?${new URLSearchParams(params).toString()}` : "";
52 return withDeviceHubQuery(`${vendorBase(target)}${path}${search}`, target.access);
53};
54
55const fetchJson = async (target: Target, url: string, signal?: AbortSignal): Promise<unknown> => {
56 const response = await fetch(url, {
57 cache: "no-store",
58 credentials: target.access.credentials ? "include" : "same-origin",
59 ...(signal ? { signal } : {}),
60 });
61 if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
62 return response.json();
63};
64
65const isRecord = (value: unknown): value is Record<string, unknown> =>
66 typeof value === "object" && value !== null;
67
68const numberOr = (value: unknown, fallback: number) =>
69 typeof value === "number" && Number.isFinite(value) ? value : fallback;
70
71const AX_ELEMENT_LIMIT = 500;
72
73/**
74 * serve-sim's helper returns the native nested tree; the root node is the
75 * application covering the whole screen. Flatten it the way serve-sim's own
76 * overlay does: skip nodes with the root's frame, cap the count.
77 */
78const flattenIosAxTree = (roots: ReadonlyArray<unknown>): ReadonlyArray<DeviceAxElement> => {
79 const first = roots[0];
80 const rootFrame = isRecord(first) && isRecord(first.frame) ? first.frame : null;
81 const screenWidth = Math.max(1, numberOr(rootFrame?.width, 1));
82 const screenHeight = Math.max(1, numberOr(rootFrame?.height, 1));
83 const elements: DeviceAxElement[] = [];
84 const visit = (node: unknown, path: string) => {
85 if (elements.length >= AX_ELEMENT_LIMIT || !isRecord(node) || !isRecord(node.frame)) return;
86 const frame = node.frame;
87 const width = numberOr(frame.width, 0);
88 const height = numberOr(frame.height, 0);
89 const coversScreen =
90 Math.abs(width - screenWidth) < 0.5 && Math.abs(height - screenHeight) < 0.5;
91 if (!coversScreen && width > 0 && height > 0) {
92 elements.push({
93 id: typeof node.AXUniqueId === "string" ? node.AXUniqueId : path,
94 label: typeof node.AXLabel === "string" ? node.AXLabel : "",
95 role: typeof node.type === "string" ? node.type : "",
96 x: numberOr(frame.x, 0) / screenWidth,
97 y: numberOr(frame.y, 0) / screenHeight,
98 width: width / screenWidth,
99 height: height / screenHeight,
100 });
101 }
102 const children = Array.isArray(node.children) ? node.children : [];
103 children.forEach((child, index) => visit(child, `${path}.${index}`));
104 };
105 roots.forEach((root, index) => visit(root, String(index)));
106 return elements;
107};
108
109export async function fetchDeviceAxTree(
110 target: Target,
111 signal?: AbortSignal,
112): Promise<DeviceAxTree> {
113 if (target.platform === "ios") {
114 const payload = await fetchJson(
115 target,
116 hubUrl(target, `/helper/${encodeURIComponent(target.deviceId)}/ax`),
117 signal,
118 );
119 if (!Array.isArray(payload)) {
120 const error = isRecord(payload) && typeof payload.error === "string" ? payload.error : null;
121 return { elements: [], errors: [error ?? "Unexpected accessibility payload."] };
122 }
123 return { elements: flattenIosAxTree(payload), errors: [] };
124 }
125 const payload = await fetchJson(
126 target,
127 hubUrl(target, "/api/accessibility", { device: target.deviceId }),
128 signal,
129 );
130 if (!isRecord(payload) || !Array.isArray(payload.nodes)) {
131 const error = isRecord(payload) && typeof payload.error === "string" ? payload.error : null;
132 return { elements: [], errors: [error ?? "Unexpected accessibility payload."] };
133 }
134 // uiautomator reports pixel bounds; the first node is the full window.
135 const nodes = payload.nodes.filter(
136 (node): node is Record<string, unknown> => isRecord(node) && isRecord(node.bounds),
137 );
138 const root = nodes[0]?.bounds as Record<string, unknown> | undefined;
139 const screenWidth = Math.max(1, numberOr(root?.right, 1));
140 const screenHeight = Math.max(1, numberOr(root?.bottom, 1));
141 // Layout containers span the whole window and would tint the entire
142 // screen; only nodes a user could point at are worth drawing.
143 const elements = nodes.slice(1).flatMap((node): DeviceAxElement[] => {
144 const bounds = node.bounds as Record<string, unknown>;
145 const left = numberOr(bounds.left, 0);
146 const top = numberOr(bounds.top, 0);
147 const width = (numberOr(bounds.right, left) - left) / screenWidth;
148 const height = (numberOr(bounds.bottom, top) - top) / screenHeight;
149 const text = typeof node.text === "string" ? node.text : "";
150 const description = typeof node.contentDescription === "string" ? node.contentDescription : "";
151 const label = text || description;
152 if (width >= 0.95 && height >= 0.9) return [];
153 if (!label && node.clickable !== true) return [];
154 const className = typeof node.className === "string" ? node.className : "";
155 return [
156 {
157 id: String(node.id ?? ""),
158 label,
159 role: className.split(".").at(-1) ?? "",
160 x: left / screenWidth,
161 y: top / screenHeight,
162 width,
163 height,
164 },
165 ];
166 });
167 return { elements, errors: [] };
168}
169
170const openEventSource = (
171 target: Target,
172 url: string,
173 onMessage: (data: unknown) => void,
174): (() => void) => {
175 const source = new EventSource(url, { withCredentials: target.access.credentials });
176 source.addEventListener("message", (event) => {
177 try {
178 onMessage(JSON.parse(String(event.data)));
179 } catch {
180 // Keep-alive comments and malformed frames carry nothing to render.
181 }
182 });
183 return () => source.close();
184};
185
186/** iOS only: the frontmost app, pushed by serve-sim whenever it changes. */
187export function subscribeDeviceForeground(
188 target: Target,
189 onChange: (app: DeviceForegroundInfo | null) => void,
190): () => void {
191 if (target.platform !== "ios") return () => {};
192 return openEventSource(
193 target,
194 hubUrl(target, "/appstate", { device: target.deviceId }),
195 (data) => {
196 if (!isRecord(data)) return;
197 if (data.bundleId === null || data.bundleId === "") {
198 onChange(null);
199 return;
200 }
201 if (typeof data.bundleId !== "string") return;
202 onChange({
203 id: data.bundleId,
204 ...(typeof data.pid === "number" ? { pid: data.pid } : {}),
205 ...(typeof data.isReactNative === "boolean" ? { isReactNative: data.isReactNative } : {}),
206 });
207 },
208 );
209}
210
211const toEventLogEntry = (raw: unknown): DeviceEventLogEntry | null => {
212 if (!isRecord(raw) || typeof raw.id !== "number") return null;
213 return {
214 id: raw.id,
215 timestamp: typeof raw.timestamp === "string" ? raw.timestamp : "",
216 kind: typeof raw.kind === "string" ? raw.kind : "",
217 summary:
218 typeof raw.summary === "string" ? raw.summary : typeof raw.msg === "string" ? raw.msg : "",
219 };
220};
221
222/**
223 * iOS only: serve-sim's event log, seeded with recent history and then pushed
224 * live. Android's session recorder only tracks replayable gestures, which the
225 * user already sees themselves, so it is not surfaced.
226 */
227export function subscribeDeviceEventLog(
228 target: Target,
229 onEvents: (entries: ReadonlyArray<DeviceEventLogEntry>, reset: boolean) => void,
230): () => void {
231 if (target.platform !== "ios") return () => {};
232 return openEventSource(
233 target,
234 hubUrl(target, "/api/event-log/events", { device: target.deviceId, limit: "100" }),
235 (data) => {
236 if (!isRecord(data)) return;
237 if (Array.isArray(data.events)) {
238 onEvents(
239 data.events.flatMap((raw) => toEventLogEntry(raw) ?? []),
240 true,
241 );
242 return;
243 }
244 const entry = toEventLogEntry(data.event);
245 if (entry) onEvents([entry], false);
246 },
247 );
248}