@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.
@@ -0,0 +1,377 @@
1
+ // Driver that fetches accessibility snapshots over an existing WebSocket and
2
+ // emits change-detected updates to subscribers. Reuses the RemoteControl's
3
+ // signaling WS (not a separate connection) and routes responses by request id.
4
+ //
5
+ // Cadence:
6
+ // - Base interval (default 500ms) right after a fetch resolved.
7
+ // - Doubles up to maxBackoff (default 2000ms) while consecutive snapshots
8
+ // are byte-identical.
9
+ // - Jumps to UNAVAILABLE_RETRY_INTERVAL_MS when the server reports the AX
10
+ // subsystem isn't usable yet.
11
+ // - On bumpActivity() (called by RemoteControl after any user input), the
12
+ // backoff is reset to base — UI almost certainly changed.
13
+
14
+ import {
15
+ AX_UNAVAILABLE_ERROR,
16
+ AxPlatform,
17
+ AxSnapshot,
18
+ axSnapshotsEqual,
19
+ normalizeAndroidTree,
20
+ normalizeIosTree,
21
+ } from './ax-tree';
22
+
23
+ const DEFAULT_BASE_INTERVAL_MS = 500;
24
+ const DEFAULT_MAX_BACKOFF_MS = 2000;
25
+ const UNAVAILABLE_RETRY_INTERVAL_MS = 5000;
26
+ const REQUEST_TIMEOUT_MS = 8000;
27
+ // After a user-driven event (tap/scroll/openUrl/etc.) we enter a brief
28
+ // "boost" window during which scheduled fetches happen on a shorter
29
+ // interval. This catches mid- and post-animation UI states without
30
+ // hammering the server during idle time.
31
+ const ACTIVITY_BOOST_DURATION_MS = 1200;
32
+ const ACTIVITY_BOOST_INTERVAL_MS = 250;
33
+
34
+ // Coarse-grained status surfaced to customers so they can render readiness
35
+ // indicators / error UI in their own panels.
36
+ //
37
+ // State machine:
38
+ // idle ───start()───▶ starting ──snapshot────▶ ready
39
+ // ──AX_UNAVAILABLE_ERROR─▶ unavailable
40
+ // ──other error──▶ error
41
+ // ready ──AX_UNAVAILABLE_ERROR─▶ unavailable
42
+ // ──other error──▶ error (transient; back to ready on next success)
43
+ // unavailable ──snapshot────▶ ready
44
+ // error ──snapshot────▶ ready
45
+ // * stop() ────────▶ idle
46
+ //
47
+ // Note `error` is sticky-but-recoverable: the fetcher keeps polling, and as
48
+ // soon as a fresh snapshot arrives we transition back to `ready`. Customers
49
+ // don't need to manually retry.
50
+ export type AxStatus = 'idle' | 'starting' | 'ready' | 'unavailable' | 'error';
51
+
52
+ export type AxFetcherSendFn = (payload: Record<string, unknown>) => boolean;
53
+
54
+ export interface AxFetcherOptions {
55
+ platform: AxPlatform;
56
+ send: AxFetcherSendFn;
57
+ onSnapshot: (snapshot: AxSnapshot | null) => void;
58
+ // Optional: notified on every status transition (deduplicated — no
59
+ // self-loops are emitted). `error` provides the error message when the
60
+ // status is `error` or `unavailable`.
61
+ onStatusChange?: (status: AxStatus, error?: string) => void;
62
+ baseIntervalMs?: number;
63
+ maxBackoffMs?: number;
64
+ }
65
+
66
+ type PendingResolver = {
67
+ resolve: (snapshot: AxSnapshot) => void;
68
+ reject: (err: Error) => void;
69
+ timer: number;
70
+ };
71
+
72
+ // Returns the request id used so the caller can route the matching response
73
+ // back. We use ax-rc-{ts}-{rand} so it's easy to distinguish from screenshot
74
+ // ids in debug output.
75
+ const generateRequestId = (): string => `ax-rc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
76
+
77
+ export class AxFetcher {
78
+ private readonly platform: AxPlatform;
79
+ private readonly send: AxFetcherSendFn;
80
+ private readonly onSnapshot: (snapshot: AxSnapshot | null) => void;
81
+ private readonly onStatusChange?: (status: AxStatus, error?: string) => void;
82
+ private readonly baseIntervalMs: number;
83
+ private readonly maxBackoffMs: number;
84
+ private readonly pending: Map<string, PendingResolver> = new Map();
85
+
86
+ private running = false;
87
+ private timer: number | undefined;
88
+ private currentInterval: number;
89
+ private lastSnapshot: AxSnapshot | null = null;
90
+ // Single-flight: only one outstanding request at a time. Avoids piling
91
+ // identical requests on a slow server.
92
+ private inflight = false;
93
+ // Rate-limit floor for bumpActivity so high-frequency input (drags,
94
+ // typing) doesn't trigger a request storm.
95
+ private lastBumpAtMs = 0;
96
+ // Wall-clock timestamp until which we're in "activity boost" mode and
97
+ // schedule fetches at ACTIVITY_BOOST_INTERVAL_MS instead of the normal
98
+ // backed-off interval. Set by bumpActivity().
99
+ private boostUntilMs = 0;
100
+ // Coarse-grained current status; deduplicated before emission so
101
+ // identical-to-current transitions are no-ops.
102
+ private status: AxStatus = 'idle';
103
+
104
+ constructor(opts: AxFetcherOptions) {
105
+ this.platform = opts.platform;
106
+ this.send = opts.send;
107
+ this.onSnapshot = opts.onSnapshot;
108
+ this.onStatusChange = opts.onStatusChange;
109
+ this.baseIntervalMs = opts.baseIntervalMs ?? DEFAULT_BASE_INTERVAL_MS;
110
+ this.maxBackoffMs = opts.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
111
+ this.currentInterval = this.baseIntervalMs;
112
+ }
113
+
114
+ start(): void {
115
+ if (this.running) return;
116
+ this.running = true;
117
+ this.currentInterval = this.baseIntervalMs;
118
+ this.boostUntilMs = 0;
119
+ this.setStatus('starting');
120
+ // Kick a fetch immediately on enable so the overlay shows up fast.
121
+ void this.runOnce();
122
+ }
123
+
124
+ stop(): void {
125
+ if (!this.running) return;
126
+ this.running = false;
127
+ if (this.timer !== undefined) {
128
+ window.clearTimeout(this.timer);
129
+ this.timer = undefined;
130
+ }
131
+ this.failAllPending('Inspector stopped');
132
+ this.lastSnapshot = null;
133
+ this.setStatus('idle');
134
+ this.onSnapshot(null);
135
+ }
136
+
137
+ getStatus(): AxStatus {
138
+ return this.status;
139
+ }
140
+
141
+ private setStatus(next: AxStatus, error?: string): void {
142
+ if (this.status === next) return;
143
+ this.status = next;
144
+ if (!this.onStatusChange) return;
145
+ try {
146
+ this.onStatusChange(next, error);
147
+ } catch (err) {
148
+ // Don't let customer status-handler errors break our state machine.
149
+ console.error('[AxFetcher] onStatusChange threw:', err);
150
+ }
151
+ }
152
+
153
+ // Called after any user-driven action that's likely to change the UI
154
+ // (taps, scrolls, key events, openUrl, app termination, orientation
155
+ // change, etc.). Has two effects:
156
+ //
157
+ // 1. Resets the polling interval to base and triggers an immediate
158
+ // fetch (rate-limited so drags / rapid typing don't request-storm).
159
+ // 2. Enters a brief "boost" window during which scheduleNext() uses a
160
+ // shorter interval (ACTIVITY_BOOST_INTERVAL_MS), so we capture the
161
+ // mid- and post-animation tree without waiting for the next
162
+ // back-off cycle.
163
+ bumpActivity(): void {
164
+ if (!this.running) return;
165
+ const now = Date.now();
166
+ // Extend the boost window on every bump so a rapid sequence of inputs
167
+ // (a swipe-then-tap, scrolling through a list) keeps polling fast
168
+ // throughout the action.
169
+ this.boostUntilMs = now + ACTIVITY_BOOST_DURATION_MS;
170
+ const floorMs = Math.max(150, Math.floor(this.baseIntervalMs / 2));
171
+ if (now - this.lastBumpAtMs < floorMs) {
172
+ // Still reset cadence so the next scheduled fetch lands at base
173
+ // interval, but don't cancel the current timer or trigger an extra
174
+ // immediate fetch.
175
+ this.currentInterval = this.baseIntervalMs;
176
+ return;
177
+ }
178
+ this.lastBumpAtMs = now;
179
+ this.currentInterval = this.baseIntervalMs;
180
+ if (this.timer !== undefined) {
181
+ window.clearTimeout(this.timer);
182
+ this.timer = undefined;
183
+ }
184
+ if (!this.inflight) {
185
+ void this.runOnce();
186
+ }
187
+ }
188
+
189
+ // Fire a one-shot fetch independent of the poll loop. Useful for the
190
+ // imperative refresh() ref method customers may call.
191
+ //
192
+ // Goes through the same change-detect path as the regular poll loop —
193
+ // identical-to-last-snapshot responses do NOT re-emit via onSnapshot, so
194
+ // a customer calling refresh() in a tight loop doesn't see a stream of
195
+ // duplicate snapshots. The returned promise still resolves with the
196
+ // (possibly identical) snapshot for the caller's own use.
197
+ async refresh(): Promise<AxSnapshot> {
198
+ const next = await this.requestOnce();
199
+ if (this.running) {
200
+ this.deliver(next);
201
+ }
202
+ return next;
203
+ }
204
+
205
+ // Returns the latest snapshot delivered to onSnapshot. May be stale.
206
+ getLatest(): AxSnapshot | null {
207
+ return this.lastSnapshot;
208
+ }
209
+
210
+ // Called by RemoteControl's ws.onmessage when it sees a message type we own.
211
+ // Returns true when the message was a response we were waiting for.
212
+ handleMessage(message: { type?: string; id?: string; [k: string]: unknown }): boolean {
213
+ if (!message || typeof message.id !== 'string') return false;
214
+ const id = message.id;
215
+ const resolver = this.pending.get(id);
216
+ if (!resolver) return false;
217
+
218
+ if (this.platform === 'ios') {
219
+ // iOS: {type:'elementTreeResult', id, json, error}
220
+ if (message.type !== 'elementTreeResult') return false;
221
+ const error = typeof message.error === 'string' ? message.error : null;
222
+ if (error) {
223
+ this.settleReject(id, new Error(error));
224
+ return true;
225
+ }
226
+ const json = typeof message.json === 'string' ? message.json : '';
227
+ try {
228
+ const parsed = JSON.parse(json);
229
+ const snapshot = normalizeIosTree(parsed);
230
+ this.settleResolve(id, snapshot);
231
+ } catch (e) {
232
+ this.settleReject(id, e instanceof Error ? e : new Error(String(e)));
233
+ }
234
+ return true;
235
+ }
236
+
237
+ // android: {type:'getElementTreeResult', id, payload:{xml,nodes}, error?}
238
+ if (message.type !== 'getElementTreeResult') return false;
239
+ const errObj = message.error as { message?: string; code?: string } | undefined;
240
+ if (errObj && typeof errObj === 'object') {
241
+ const msg = typeof errObj.message === 'string' ? errObj.message : 'getElementTree failed';
242
+ this.settleReject(id, new Error(msg));
243
+ return true;
244
+ }
245
+ const payload = message.payload as { nodes?: unknown[] } | undefined;
246
+ const nodes = Array.isArray(payload?.nodes) ? (payload!.nodes as Record<string, unknown>[]) : [];
247
+ try {
248
+ const snapshot = normalizeAndroidTree(nodes as Parameters<typeof normalizeAndroidTree>[0]);
249
+ this.settleResolve(id, snapshot);
250
+ } catch (e) {
251
+ this.settleReject(id, e instanceof Error ? e : new Error(String(e)));
252
+ }
253
+ return true;
254
+ }
255
+
256
+ private buildRequest(id: string): Record<string, unknown> {
257
+ if (this.platform === 'ios') {
258
+ return { type: 'elementTree', id };
259
+ }
260
+ return { type: 'getElementTree', id };
261
+ }
262
+
263
+ private async requestOnce(): Promise<AxSnapshot> {
264
+ const id = generateRequestId();
265
+ return new Promise<AxSnapshot>((resolve, reject) => {
266
+ const timer = window.setTimeout(() => {
267
+ this.pending.delete(id);
268
+ reject(new Error('elementTree request timed out'));
269
+ }, REQUEST_TIMEOUT_MS);
270
+ this.pending.set(id, { resolve, reject, timer });
271
+ const ok = this.send(this.buildRequest(id));
272
+ if (!ok) {
273
+ window.clearTimeout(timer);
274
+ this.pending.delete(id);
275
+ reject(new Error('elementTree send failed (WebSocket not open)'));
276
+ }
277
+ });
278
+ }
279
+
280
+ private async runOnce(): Promise<void> {
281
+ if (!this.running) return;
282
+ if (this.inflight) return;
283
+ this.inflight = true;
284
+ try {
285
+ const next = await this.requestOnce();
286
+ if (!this.running) return;
287
+ this.deliver(next);
288
+ } catch (err) {
289
+ // Don't surface transient errors to the customer's onSnapshot — just
290
+ // back off and try again. We DO surface them via status transitions
291
+ // so customers building their own UI can show a banner / spinner /
292
+ // etc. Persistent failures eventually settle on the longest backoff.
293
+ //
294
+ // If the fetcher was stopped while a request was in flight, the
295
+ // pending request is rejected by failAllPending() and we end up here
296
+ // with running=false. In that case skip the status transition — the
297
+ // current status is already 'idle' and we shouldn't churn it back to
298
+ // an error.
299
+ if (!this.running) return;
300
+ const message = err instanceof Error ? err.message : String(err);
301
+ const unavailable = /unavailable|not (yet )?running|failed|timeout/i.test(message);
302
+ this.currentInterval =
303
+ unavailable ? UNAVAILABLE_RETRY_INTERVAL_MS : Math.min(this.currentInterval * 2, this.maxBackoffMs);
304
+ this.setStatus(unavailable ? 'unavailable' : 'error', message);
305
+ } finally {
306
+ this.inflight = false;
307
+ this.scheduleNext();
308
+ }
309
+ }
310
+
311
+ private deliver(next: AxSnapshot): void {
312
+ const previous = this.lastSnapshot;
313
+ if (axSnapshotsEqual(previous, next)) {
314
+ // Same payload — back off so we don't churn.
315
+ this.currentInterval = Math.min(this.currentInterval * 2, this.maxBackoffMs);
316
+ // Still update capturedAt by replacing the cached snapshot.
317
+ this.lastSnapshot = next;
318
+ // A successful (even if unchanged) fetch is a sign that AX is
319
+ // working — emit `ready` if we were in a degraded status.
320
+ if (!next.errors?.includes(AX_UNAVAILABLE_ERROR)) {
321
+ this.setStatus('ready');
322
+ }
323
+ return;
324
+ }
325
+ this.lastSnapshot = next;
326
+ this.currentInterval = this.baseIntervalMs;
327
+ if (next.errors?.includes(AX_UNAVAILABLE_ERROR)) {
328
+ this.currentInterval = UNAVAILABLE_RETRY_INTERVAL_MS;
329
+ this.setStatus('unavailable', AX_UNAVAILABLE_ERROR);
330
+ } else {
331
+ this.setStatus('ready');
332
+ }
333
+ this.onSnapshot(next);
334
+ }
335
+
336
+ private scheduleNext(): void {
337
+ if (!this.running) return;
338
+ if (this.timer !== undefined) {
339
+ window.clearTimeout(this.timer);
340
+ }
341
+ // While in activity-boost mode, cap the wait so we keep capturing the
342
+ // UI through any animation that's mid-flight. Outside the boost
343
+ // window, use the normal (possibly backed-off) interval.
344
+ const interval =
345
+ Date.now() < this.boostUntilMs ?
346
+ Math.min(ACTIVITY_BOOST_INTERVAL_MS, this.currentInterval)
347
+ : this.currentInterval;
348
+ this.timer = window.setTimeout(() => {
349
+ this.timer = undefined;
350
+ void this.runOnce();
351
+ }, interval);
352
+ }
353
+
354
+ private settleResolve(id: string, snapshot: AxSnapshot): void {
355
+ const r = this.pending.get(id);
356
+ if (!r) return;
357
+ window.clearTimeout(r.timer);
358
+ this.pending.delete(id);
359
+ r.resolve(snapshot);
360
+ }
361
+
362
+ private settleReject(id: string, err: Error): void {
363
+ const r = this.pending.get(id);
364
+ if (!r) return;
365
+ window.clearTimeout(r.timer);
366
+ this.pending.delete(id);
367
+ r.reject(err);
368
+ }
369
+
370
+ private failAllPending(reason: string): void {
371
+ for (const [id, r] of this.pending.entries()) {
372
+ window.clearTimeout(r.timer);
373
+ r.reject(new Error(reason));
374
+ this.pending.delete(id);
375
+ }
376
+ }
377
+ }