| 1 | import { withDeviceHubQuery } from "@t3tools/client-runtime/state/deviceHubAccess"; |
| 2 | import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess"; |
| 3 | import 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 | |
| 11 | export 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 | |
| 22 | export interface DeviceAxTree { |
| 23 | readonly elements: ReadonlyArray<DeviceAxElement>; |
| 24 | readonly errors: ReadonlyArray<string>; |
| 25 | } |
| 26 | |
| 27 | export interface DeviceEventLogEntry { |
| 28 | readonly id: number; |
| 29 | readonly timestamp: string; |
| 30 | readonly kind: string; |
| 31 | readonly summary: string; |
| 32 | } |
| 33 | |
| 34 | export interface DeviceForegroundInfo { |
| 35 | readonly id: string; |
| 36 | readonly label?: string; |
| 37 | readonly pid?: number; |
| 38 | readonly isReactNative?: boolean; |
| 39 | } |
| 40 | |
| 41 | interface Target { |
| 42 | readonly access: DeviceHubAccess; |
| 43 | readonly platform: DevicePlatform; |
| 44 | readonly deviceId: string; |
| 45 | } |
| 46 | |
| 47 | const vendorBase = (target: Target) => |
| 48 | `${target.access.httpBase}${target.platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu"}`; |
| 49 | |
| 50 | const 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 | |
| 55 | const 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 | |
| 65 | const isRecord = (value: unknown): value is Record<string, unknown> => |
| 66 | typeof value === "object" && value !== null; |
| 67 | |
| 68 | const numberOr = (value: unknown, fallback: number) => |
| 69 | typeof value === "number" && Number.isFinite(value) ? value : fallback; |
| 70 | |
| 71 | const 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 | */ |
| 78 | const 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 | |
| 109 | export 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 | |
| 170 | const 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. */ |
| 187 | export 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 | |
| 211 | const 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 | */ |
| 227 | export 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 | } |