@phone-use/sdk 0.1.0
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/LICENSE +202 -0
- package/README.md +103 -0
- package/dist/backend-Cbr2tIN-.d.mts +316 -0
- package/dist/device-BzPnHvQy.mjs +284 -0
- package/dist/device-BzPnHvQy.mjs.map +1 -0
- package/dist/index.d.mts +689 -0
- package/dist/index.mjs +1848 -0
- package/dist/index.mjs.map +1 -0
- package/dist/testing.d.mts +127 -0
- package/dist/testing.mjs +188 -0
- package/dist/testing.mjs.map +1 -0
- package/package.json +44 -0
- package/src/actions.ts +545 -0
- package/src/backend.ts +185 -0
- package/src/backends/agent-device.ts +242 -0
- package/src/backends/ios.ts +262 -0
- package/src/config.ts +43 -0
- package/src/device.ts +98 -0
- package/src/errors.ts +177 -0
- package/src/exec.ts +48 -0
- package/src/index.ts +86 -0
- package/src/lifecycle.ts +349 -0
- package/src/observe.ts +1093 -0
- package/src/secrets.ts +67 -0
- package/src/testing.ts +239 -0
package/src/observe.ts
ADDED
|
@@ -0,0 +1,1093 @@
|
|
|
1
|
+
import type { DeviceBackend } from './backend.ts';
|
|
2
|
+
import type { Rect, SnapshotNode } from './device.ts';
|
|
3
|
+
import { SessionNotFoundError } from './errors.ts';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// DeviceCore (docs/20 item 4): the one-brain observe/resolve/act core, moved
|
|
7
|
+
// verbatim from the harness's DeviceContext. Everything here is portable —
|
|
8
|
+
// no runtime-specific globals and no image libraries — so it runs under Node. The
|
|
9
|
+
// module level holds only types, pure functions, and read-only lookup tables;
|
|
10
|
+
// the harness's DeviceContext subclasses this and layers on cursor/live-view
|
|
11
|
+
// rendering.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/** One compressed observation: the frontmost app plus the rendered element list. */
|
|
15
|
+
export type Observation = {
|
|
16
|
+
/** Frontmost app name, when known. */
|
|
17
|
+
app?: string | undefined;
|
|
18
|
+
/** Frontmost app bundle id, when known. */
|
|
19
|
+
bundleId?: string | undefined;
|
|
20
|
+
/** Whether the element list was truncated. */
|
|
21
|
+
truncated: boolean;
|
|
22
|
+
/** The compressed, human/LLM-readable element listing. */
|
|
23
|
+
elements: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Post-action evidence from the driver's verify pass: whether the
|
|
28
|
+
* accessibility tree changed, without paying for a full follow-up snapshot.
|
|
29
|
+
*/
|
|
30
|
+
export type ActionEvidence = {
|
|
31
|
+
/** Did the tree fingerprint change across the action. */
|
|
32
|
+
changed?: boolean | undefined;
|
|
33
|
+
/** Human-readable verdict detail. */
|
|
34
|
+
detail?: string | undefined;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Raw iOS accessibility trees are unusable for agents on content-heavy screens
|
|
38
|
+
// (a full HN page is 485 nodes / ~15k tokens, most of it "|" separators and
|
|
39
|
+
// off-screen rows). observe() reduces to what a human sees: on-screen,
|
|
40
|
+
// non-noise elements with compact geometry, plus counts for what's off-screen.
|
|
41
|
+
const NOISE_LABELS: ReadonlySet<string> = new Set(['|', '(', ')', ',', '·', '•']);
|
|
42
|
+
|
|
43
|
+
function isNoise(n: SnapshotNode): boolean {
|
|
44
|
+
const kind = n.type ?? n.role ?? '';
|
|
45
|
+
if (!n.label) return false;
|
|
46
|
+
return (kind === 'StaticText' || kind === 'Other') && NOISE_LABELS.has(n.label.trim());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function intersectsViewport(rect: Rect | undefined, vw: number, vh: number): boolean {
|
|
50
|
+
if (!rect) return true;
|
|
51
|
+
return rect.x < vw && rect.y < vh && rect.x + rect.width > 0 && rect.y + rect.height > 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Longest label/value the renderer emits verbatim. Longer text is cut WITH an
|
|
55
|
+
// explicit "[truncated]" marker — a silently shortened value reads as the
|
|
56
|
+
// field's full content and sends the model verifying against a phantom.
|
|
57
|
+
const RENDER_TEXT_MAX = 160;
|
|
58
|
+
|
|
59
|
+
function renderText(s: string): string {
|
|
60
|
+
return s.length <= RENDER_TEXT_MAX
|
|
61
|
+
? JSON.stringify(s)
|
|
62
|
+
: `${JSON.stringify(s.slice(0, RENDER_TEXT_MAX))} [truncated]`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatNode(n: SnapshotNode, opts: { suppressFocused: boolean; vw?: number | undefined }): string {
|
|
66
|
+
const role = n.role ?? n.type ?? 'element';
|
|
67
|
+
const ref = n.ref && !n.ref.startsWith('@') ? `@${n.ref}` : (n.ref ?? '');
|
|
68
|
+
const parts = [`${ref} [${role}]`];
|
|
69
|
+
const label = n.label ?? n.identifier;
|
|
70
|
+
if (label) parts.push(renderText(label));
|
|
71
|
+
if (n.value && n.value !== n.label) parts.push(`value=${renderText(n.value)}`);
|
|
72
|
+
if (n.rect)
|
|
73
|
+
parts.push(
|
|
74
|
+
`(${Math.round(n.rect.x)},${Math.round(n.rect.y)} ${Math.round(n.rect.width)}x${Math.round(n.rect.height)})`,
|
|
75
|
+
);
|
|
76
|
+
// Rendered nodes always intersect the viewport, but a carousel/pager item can
|
|
77
|
+
// straddle the edge with its CENTER off-screen horizontally — where a
|
|
78
|
+
// center-targeted tap misses. Say so instead of letting the line imply a
|
|
79
|
+
// normally tappable element.
|
|
80
|
+
if (n.rect && opts.vw !== undefined) {
|
|
81
|
+
const cx = n.rect.x + n.rect.width / 2;
|
|
82
|
+
if (cx < 0 || cx > opts.vw) parts.push('(center off-screen)');
|
|
83
|
+
}
|
|
84
|
+
if (n.enabled === false) parts.push('(disabled)');
|
|
85
|
+
if (n.selected) parts.push('(selected)');
|
|
86
|
+
if (n.focused && !opts.suppressFocused) parts.push('(focused)');
|
|
87
|
+
if (n.interactionBlocked) parts.push(`(blocked: ${n.interactionBlocked})`);
|
|
88
|
+
return parts.join(' ');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The on-screen, non-noise elements plus off-viewport counts — the shared core
|
|
92
|
+
// of both the full render (compressNodes) and the delta render.
|
|
93
|
+
function keptViewportNodes(nodes: SnapshotNode[]): {
|
|
94
|
+
kept: SnapshotNode[];
|
|
95
|
+
above: number;
|
|
96
|
+
below: number;
|
|
97
|
+
suppressFocused: boolean;
|
|
98
|
+
vw: number;
|
|
99
|
+
} {
|
|
100
|
+
const root = nodes.find((n) => (n.type === 'Application' || n.type === 'Window') && n.rect);
|
|
101
|
+
const vw = root?.rect?.width ?? 500;
|
|
102
|
+
const vh = root?.rect?.height ?? 1000;
|
|
103
|
+
|
|
104
|
+
// The XCTest tree sometimes marks every node focused — meaningless; only
|
|
105
|
+
// show (focused) when it identifies a minority of elements.
|
|
106
|
+
const focusedCount = nodes.filter((n) => n.focused).length;
|
|
107
|
+
const suppressFocused = focusedCount > nodes.length / 3;
|
|
108
|
+
|
|
109
|
+
const kept: SnapshotNode[] = [];
|
|
110
|
+
let above = 0;
|
|
111
|
+
let below = 0;
|
|
112
|
+
for (const n of nodes) {
|
|
113
|
+
if (isNoise(n)) continue;
|
|
114
|
+
if (!intersectsViewport(n.rect, vw, vh)) {
|
|
115
|
+
if (n.rect && n.rect.y >= vh) below += 1;
|
|
116
|
+
else above += 1;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
kept.push(n);
|
|
120
|
+
}
|
|
121
|
+
return { kept, above, below, suppressFocused, vw };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function compressNodes(nodes: SnapshotNode[]): string {
|
|
125
|
+
const { kept, above, below, suppressFocused, vw } = keptViewportNodes(nodes);
|
|
126
|
+
const lines = kept.map((n) => formatNode(n, { suppressFocused, vw }));
|
|
127
|
+
if (above > 0) lines.unshift(`[${above} elements above the viewport — scroll up to reach them]`);
|
|
128
|
+
if (below > 0) lines.push(`[${below} elements below the viewport — scroll down to reach them]`);
|
|
129
|
+
return lines.join('\n');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- Observation delta rendering (LLM-loop token saver) --------------------
|
|
133
|
+
// Re-serializing the whole compressed tree on every observe is the dominant
|
|
134
|
+
// token cost of a long agent loop (research brief 3: LLM round-trips are
|
|
135
|
+
// 75-94% of task latency). When the screen is structurally identical to the
|
|
136
|
+
// last render — same elements, same order, so @refs are unchanged — we emit
|
|
137
|
+
// only the value/state changes plus an "unchanged" note instead of the full
|
|
138
|
+
// tree. This is SAFE BY CONSTRUCTION: iOS @refs are assigned by traversal
|
|
139
|
+
// order, so ANY add/remove/reorder rotates them; we detect that via ordered-
|
|
140
|
+
// key equality and fall back to the full tree, which re-establishes valid
|
|
141
|
+
// refs. The delta path therefore only fires when refs are provably stable
|
|
142
|
+
// (the toggle / settings re-observe case); scrolls and screen changes render
|
|
143
|
+
// full. This is a rendering optimization only — the driver's structured
|
|
144
|
+
// element accessors (interactiveElements/findElement) are unaffected.
|
|
145
|
+
|
|
146
|
+
/** The delta renderer's baseline: last rendered app, element keys, and lines. */
|
|
147
|
+
export type RenderState = { app?: string | undefined; keys: string[]; lineByKey: Map<string, string> };
|
|
148
|
+
|
|
149
|
+
// Identity of an element that is stable across a value/state change (so a
|
|
150
|
+
// flipped toggle keeps its key) but distinguishes different elements. Value is
|
|
151
|
+
// deliberately excluded; the formatted line carries value/state for diffing.
|
|
152
|
+
function elementKey(n: SnapshotNode): string {
|
|
153
|
+
const role = n.role ?? n.type ?? 'element';
|
|
154
|
+
const label = (n.label ?? n.identifier ?? '').trim();
|
|
155
|
+
const pos = n.rect ? `${Math.round(n.rect.x)},${Math.round(n.rect.y)}` : '';
|
|
156
|
+
return `${role}|${label}|${pos}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function arraysEqual(a: string[], b: string[]): boolean {
|
|
160
|
+
if (a.length !== b.length) return false;
|
|
161
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A structured interactive element extracted from the snapshot cache. */
|
|
166
|
+
export type UiElement = {
|
|
167
|
+
/** Snapshot-scoped element ref (normalized to the `@N` form). */
|
|
168
|
+
ref: string;
|
|
169
|
+
/** Visible label (or accessibility identifier when the label is empty). */
|
|
170
|
+
label: string;
|
|
171
|
+
/** Semantic role, e.g. "Button", "Cell", "Switch". */
|
|
172
|
+
role: string;
|
|
173
|
+
/** Current value ("1"/"0" for switches, field contents, ...). */
|
|
174
|
+
value?: string | undefined;
|
|
175
|
+
/** On-screen geometry when known. */
|
|
176
|
+
rect?: Rect | undefined;
|
|
177
|
+
/** Accessibility identifier when the app exposes one. */
|
|
178
|
+
id?: string | undefined;
|
|
179
|
+
/** false when the element is disabled. */
|
|
180
|
+
enabled?: boolean | undefined;
|
|
181
|
+
/** Reason interaction is blocked, when the tree reports one. */
|
|
182
|
+
blocked?: string | undefined;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export const TAPPABLE: ReadonlySet<string> = new Set([
|
|
186
|
+
'Button',
|
|
187
|
+
'Cell',
|
|
188
|
+
'Link',
|
|
189
|
+
'MenuItem',
|
|
190
|
+
'Tab',
|
|
191
|
+
'StaticText',
|
|
192
|
+
'Switch',
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
const EDITABLE: ReadonlySet<string> = new Set(['SearchField', 'TextField', 'SecureTextField']);
|
|
196
|
+
// Multiline bodies (Notes/Messages compose areas) are TextViews — reachable by
|
|
197
|
+
// setField but NOT by search (a search bar is never a TextView), so they're
|
|
198
|
+
// opt-in to avoid a compose body shadowing a real search field.
|
|
199
|
+
const EDITABLE_MULTILINE: ReadonlySet<string> = new Set([...EDITABLE, 'TextView', 'TextEditor']);
|
|
200
|
+
|
|
201
|
+
function labelTokens(s: string): string[] {
|
|
202
|
+
return s
|
|
203
|
+
.toLowerCase()
|
|
204
|
+
.split(/[^a-z0-9]+/i)
|
|
205
|
+
.filter((t) => t.length > 2);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// How well a query matches a label, tolerant of punctuation / spacing / word
|
|
209
|
+
// order: the fraction of the label's tokens the query covers. 1.0 means the query
|
|
210
|
+
// contains every significant word of the label (a pure punctuation/spacing
|
|
211
|
+
// variant); a partial overlap (e.g. "Screen Capture" vs "Full Screen Previews",
|
|
212
|
+
// sharing only "screen") scores low and is rejected.
|
|
213
|
+
function fuzzyScore(label: string, query: string): number {
|
|
214
|
+
const qt = new Set(labelTokens(query));
|
|
215
|
+
const lt = labelTokens(label);
|
|
216
|
+
if (!qt.size || !lt.length) return 0;
|
|
217
|
+
return lt.filter((t) => qt.has(t)).length / lt.length;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Does a label match a query — by substring, or a strict punctuation/spacing-
|
|
222
|
+
* tolerant fuzzy match? Shared by the task layer's cached-map lookups so
|
|
223
|
+
* ask/toggle tolerate rewording the same way findElement does.
|
|
224
|
+
*/
|
|
225
|
+
export function labelMatches(label: string, query: string): boolean {
|
|
226
|
+
return label.toLowerCase().includes(query.toLowerCase()) || fuzzyScore(label, query) >= 0.75;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --- Element resolution ladder (harness v2, decision doc research/10 §4) ----
|
|
230
|
+
// Progressive relaxation, apply_patch-style: each rung only fires if the rung
|
|
231
|
+
// above found nothing. `via` reports the match provenance so drift is visible
|
|
232
|
+
// in traces. Ambiguity is a CONTRACT, not a guess: multiple distinct matches on
|
|
233
|
+
// the winning rung (different role or id) return `candidates` instead of
|
|
234
|
+
// silently picking one — the caller disambiguates with role/near or fails
|
|
235
|
+
// loudly with the list (the Claude Code Edit-tool uniqueness pattern).
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Outcome of the resolution ladder. Exactly one of: a match (`el` set, `via`
|
|
239
|
+
* reporting the winning rung), an ambiguity (`el` null + `candidates` listing
|
|
240
|
+
* the distinct matches — the caller disambiguates with role/near), or a miss
|
|
241
|
+
* (`el` null, no candidates).
|
|
242
|
+
*/
|
|
243
|
+
export type Resolution = {
|
|
244
|
+
/** The winning element, or null on ambiguity/miss. */
|
|
245
|
+
el: UiElement | null;
|
|
246
|
+
/** Match provenance — which rung won (id, exact label, substring, fuzzy). */
|
|
247
|
+
via?: string | undefined;
|
|
248
|
+
/** On ambiguity: the distinct elements that tied. */
|
|
249
|
+
candidates?: UiElement[] | undefined;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/** Disambiguators accepted by the resolution ladder. */
|
|
253
|
+
export type ResolveOpts = {
|
|
254
|
+
/** Restrict matches to this role (case-insensitive). */
|
|
255
|
+
role?: string | undefined;
|
|
256
|
+
/** Label of another element; pick the candidate geometrically closest to it. */
|
|
257
|
+
near?: string | undefined;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
function center(e: UiElement): { x: number; y: number } | null {
|
|
261
|
+
return e.rect ? { x: e.rect.x + e.rect.width / 2, y: e.rect.y + e.rect.height / 2 } : null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function disambiguate(matches: UiElement[], via: string, opts: ResolveOpts, els: UiElement[]): Resolution {
|
|
265
|
+
let pool = matches;
|
|
266
|
+
if (opts.role) {
|
|
267
|
+
const byRole = pool.filter((e) => e.role.toLowerCase() === opts.role!.toLowerCase());
|
|
268
|
+
if (byRole.length) pool = byRole;
|
|
269
|
+
}
|
|
270
|
+
if (pool.length > 1 && opts.near) {
|
|
271
|
+
const anchor = els.find((e) => labelMatches(e.label, opts.near!));
|
|
272
|
+
const ac = anchor ? center(anchor) : null;
|
|
273
|
+
if (ac) {
|
|
274
|
+
pool = [...pool].sort((a, b) => {
|
|
275
|
+
const ca = center(a);
|
|
276
|
+
const cb = center(b);
|
|
277
|
+
const da = ca ? (ca.x - ac.x) ** 2 + (ca.y - ac.y) ** 2 : Infinity;
|
|
278
|
+
const db = cb ? (cb.x - ac.x) ** 2 + (cb.y - ac.y) ** 2 : Infinity;
|
|
279
|
+
return da - db;
|
|
280
|
+
});
|
|
281
|
+
return { el: pool[0]!, via: `${via}, nearest "${opts.near}"` }; // non-empty: sorted copy of pool
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (pool.length === 1) return { el: pool[0]!, via };
|
|
285
|
+
// Same role AND same label are pre-deduped upstream; what's left here are
|
|
286
|
+
// genuinely different controls matching the same query — refuse to guess.
|
|
287
|
+
return { el: null, candidates: pool, via };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* One screen's worth of the resolution ladder: id exact → exact label →
|
|
292
|
+
* substring → fuzzy above a strict bar. This is the per-iteration body of
|
|
293
|
+
* resolveElement's scroll loop, extracted so callers holding a fresh cache
|
|
294
|
+
* (item-4 auto-wait) can match without scrolling. Returns `{ el: null }` with
|
|
295
|
+
* no candidates when no rung matched at all.
|
|
296
|
+
*/
|
|
297
|
+
export function matchInElements(els: UiElement[], query: string, opts: ResolveOpts): Resolution {
|
|
298
|
+
const q = query.toLowerCase();
|
|
299
|
+
|
|
300
|
+
// Rung 0: exact accessibility-identifier match — the stablest anchor an
|
|
301
|
+
// app exposes (survives label rewording and localization).
|
|
302
|
+
const byId = els.filter((e) => e.id && e.id.toLowerCase() === q);
|
|
303
|
+
if (byId.length) return disambiguate(byId, 'id', opts, els);
|
|
304
|
+
|
|
305
|
+
// Rung 1: exact label (case-insensitive).
|
|
306
|
+
const exact = els.filter((e) => e.label.toLowerCase() === q);
|
|
307
|
+
if (exact.length) return disambiguate(exact, 'exact label', opts, els);
|
|
308
|
+
|
|
309
|
+
// Rung 2: label substring.
|
|
310
|
+
const sub = els.filter((e) => e.label.toLowerCase().includes(q));
|
|
311
|
+
if (sub.length) return disambiguate(sub, 'label substring', opts, els);
|
|
312
|
+
|
|
313
|
+
// Rung 3: best punctuation/spacing-tolerant fuzzy match above a strict bar.
|
|
314
|
+
let best: UiElement | null = null;
|
|
315
|
+
let bestScore = 0;
|
|
316
|
+
for (const e of els) {
|
|
317
|
+
const s = fuzzyScore(e.label, query);
|
|
318
|
+
if (s > bestScore) {
|
|
319
|
+
bestScore = s;
|
|
320
|
+
best = e;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (best && bestScore >= 0.75) return { el: best, via: `fuzzy ${bestScore.toFixed(2)}` };
|
|
324
|
+
|
|
325
|
+
return { el: null };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Outcome of a system-alert interaction (see `DeviceCore.handleAlert`). */
|
|
329
|
+
export type AlertOutcome = {
|
|
330
|
+
/** Was an alert showing at all. */
|
|
331
|
+
present: boolean;
|
|
332
|
+
/** Was it cleared (accept/dismiss actions only). */
|
|
333
|
+
handled?: boolean | undefined;
|
|
334
|
+
/** The button that was tapped, when handled. */
|
|
335
|
+
button?: string | undefined;
|
|
336
|
+
/** Title/message/buttons summary of the alert. */
|
|
337
|
+
description?: string | undefined;
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// Permission / system dialogs surface in the snapshot as a type:"Alert" node
|
|
341
|
+
// whose sibling Buttons are the choices (the alert is modal, so no other
|
|
342
|
+
// buttons coexist). The daemon's system-alert command only sees an app's *own*
|
|
343
|
+
// UIAlertControllers — SpringBoard-presented permission prompts (location,
|
|
344
|
+
// notifications, contacts…) are invisible to it, which is why real apps stalled
|
|
345
|
+
// the crawler behind a dialog it couldn't clear. Detecting the node directly
|
|
346
|
+
// catches both. Button labels use a curly apostrophe ("Don't Allow"), so match
|
|
347
|
+
// on an apostrophe-normalized form.
|
|
348
|
+
const na = (s: string): string => s.toLowerCase().replace(/[’']/g, "'").trim();
|
|
349
|
+
// Preference order matters: for a location prompt, "Allow While Using App" is
|
|
350
|
+
// the standard grant and must win over "Allow Once".
|
|
351
|
+
const ALERT_ACCEPT: readonly string[] = [
|
|
352
|
+
'allow while using app',
|
|
353
|
+
'always allow',
|
|
354
|
+
'allow',
|
|
355
|
+
'ok',
|
|
356
|
+
'yes',
|
|
357
|
+
'continue',
|
|
358
|
+
'allow once',
|
|
359
|
+
'turn on',
|
|
360
|
+
'enable',
|
|
361
|
+
'agree',
|
|
362
|
+
'accept',
|
|
363
|
+
'got it',
|
|
364
|
+
'join',
|
|
365
|
+
];
|
|
366
|
+
const ALERT_DISMISS: readonly string[] = [
|
|
367
|
+
"don't allow",
|
|
368
|
+
'not now',
|
|
369
|
+
'cancel',
|
|
370
|
+
'no thanks',
|
|
371
|
+
'no',
|
|
372
|
+
'deny',
|
|
373
|
+
'dismiss',
|
|
374
|
+
'later',
|
|
375
|
+
'skip',
|
|
376
|
+
"don't",
|
|
377
|
+
];
|
|
378
|
+
|
|
379
|
+
type AlertInfo = { title: string; message?: string | undefined; buttons: UiElement[] };
|
|
380
|
+
|
|
381
|
+
function pickAlertButton(buttons: UiElement[], action: 'accept' | 'dismiss'): UiElement | undefined {
|
|
382
|
+
const prefs = action === 'accept' ? ALERT_ACCEPT : ALERT_DISMISS;
|
|
383
|
+
for (const p of prefs) {
|
|
384
|
+
const hit = buttons.find((b) => na(b.label) === p);
|
|
385
|
+
if (hit) return hit;
|
|
386
|
+
}
|
|
387
|
+
for (const p of prefs) {
|
|
388
|
+
const hit = buttons.find((b) => na(b.label).includes(p));
|
|
389
|
+
if (hit) return hit;
|
|
390
|
+
}
|
|
391
|
+
// Last resort: accepting picks any non-negative button; dismissing, any button.
|
|
392
|
+
if (action === 'accept') return buttons.find((b) => !ALERT_DISMISS.some((d) => na(b.label).includes(d)));
|
|
393
|
+
return buttons[0];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function describeAlert(info: AlertInfo): string {
|
|
397
|
+
const head = `${info.title}${info.message ? ` ${info.message}` : ''}`.trim();
|
|
398
|
+
return head + (info.buttons.length ? ` [buttons: ${info.buttons.map((b) => b.label).join(', ')}]` : '');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function normRef(ref: string): string {
|
|
402
|
+
return ref.startsWith('@') ? ref : `@${ref}`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Render any thrown value as a one-line message (appends `details.hint` when present). */
|
|
406
|
+
export function describeError(error: unknown): string {
|
|
407
|
+
if (error instanceof Error && error.message) {
|
|
408
|
+
const hint = (error as { details?: { hint?: string } }).details?.hint;
|
|
409
|
+
return hint ? `${error.message} (${hint})` : error.message;
|
|
410
|
+
}
|
|
411
|
+
return String(error);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The device core: one instance = one device's observe/resolve/act state,
|
|
416
|
+
* driving one {@link DeviceBackend}. Everything here is portable — no
|
|
417
|
+
* runtime-specific globals and no image libraries — so it runs under Node.
|
|
418
|
+
* The harness's DeviceContext subclasses this and adds the cursor +
|
|
419
|
+
* live-viewer layer via the onCacheUpdated hook.
|
|
420
|
+
*/
|
|
421
|
+
export class DeviceCore {
|
|
422
|
+
/** The backend this core drives. */
|
|
423
|
+
readonly backend: DeviceBackend;
|
|
424
|
+
|
|
425
|
+
// Shared snapshot cache so a cursor move (highlight an element) doesn't need
|
|
426
|
+
// a fresh accessibility snapshot — observe() populates it; the cursor tools
|
|
427
|
+
// reuse it for element geometry and box overlays.
|
|
428
|
+
protected cachedNodes: SnapshotNode[] = [];
|
|
429
|
+
protected cachedViewport = { width: 390, height: 844 };
|
|
430
|
+
protected lastApp: { app?: string | undefined; bundleId?: string | undefined } = {};
|
|
431
|
+
private lastRender: RenderState | null = null;
|
|
432
|
+
// Freshness stamp of the snapshot cache (item-4 auto-wait reads this).
|
|
433
|
+
protected cacheAt = 0;
|
|
434
|
+
|
|
435
|
+
constructor(backend: DeviceBackend) {
|
|
436
|
+
this.backend = backend;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Hook for harness-side live-viewer publication: called after every snapshot
|
|
440
|
+
// cache update. The base core has no viewer, so this is a no-op.
|
|
441
|
+
protected onCacheUpdated(): void {}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Canonical post-action report for LLM tool results, shared by every tool
|
|
445
|
+
* surface (agent tools + MCP): verdict from the action's own evidence, then a
|
|
446
|
+
* delta-rendered view of the screen it left behind.
|
|
447
|
+
*/
|
|
448
|
+
async renderActionResult(prefix: string, evidence?: ActionEvidence, refresh = false): Promise<string> {
|
|
449
|
+
if (refresh) await this.observe(); // refresh the cache; renderObservation reads it
|
|
450
|
+
const verdict = evidence?.detail ? ` (${evidence.detail})` : '';
|
|
451
|
+
return `${prefix}${verdict}\n\nCurrent screen (app: ${this.currentApp() ?? 'unknown'}):\n${this.renderObservation()}`;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Render the current cached screen for the LLM. full=true (or a structural
|
|
456
|
+
* change since last render) yields the complete compressed tree; otherwise a
|
|
457
|
+
* compact delta. Always updates the baseline.
|
|
458
|
+
*/
|
|
459
|
+
renderObservation(full = false): string {
|
|
460
|
+
const app = this.lastApp.app;
|
|
461
|
+
const { kept, above, below, suppressFocused, vw } = keptViewportNodes(this.cachedNodes);
|
|
462
|
+
const keys = kept.map(elementKey);
|
|
463
|
+
const lineByKey = new Map<string, string>();
|
|
464
|
+
for (const n of kept) lineByKey.set(elementKey(n), formatNode(n, { suppressFocused, vw }));
|
|
465
|
+
|
|
466
|
+
const sameStructure =
|
|
467
|
+
!full &&
|
|
468
|
+
this.lastRender != null &&
|
|
469
|
+
this.lastRender.app === app &&
|
|
470
|
+
arraysEqual(this.lastRender.keys, keys) &&
|
|
471
|
+
// Guard against duplicate-key collisions collapsing the maps differently.
|
|
472
|
+
lineByKey.size === keys.length &&
|
|
473
|
+
this.lastRender.lineByKey.size === this.lastRender.keys.length;
|
|
474
|
+
|
|
475
|
+
if (sameStructure && this.lastRender) {
|
|
476
|
+
const changed: string[] = [];
|
|
477
|
+
for (const key of keys) {
|
|
478
|
+
const now = lineByKey.get(key)!;
|
|
479
|
+
const before = this.lastRender.lineByKey.get(key);
|
|
480
|
+
if (before !== now) changed.push(`~ ${now}`);
|
|
481
|
+
}
|
|
482
|
+
this.lastRender = { app, keys, lineByKey };
|
|
483
|
+
if (changed.length === 0) {
|
|
484
|
+
return `Screen unchanged since last observation (${keys.length} elements).`;
|
|
485
|
+
}
|
|
486
|
+
return `Same screen; ${changed.length} of ${keys.length} element(s) changed:\n${changed.join('\n')}\n(other elements and their @refs unchanged)`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Full render + refresh the baseline.
|
|
490
|
+
this.lastRender = { app, keys, lineByKey };
|
|
491
|
+
const lines = kept.map((n) => formatNode(n, { suppressFocused, vw }));
|
|
492
|
+
if (above > 0) lines.unshift(`[${above} elements above the viewport — scroll up to reach them]`);
|
|
493
|
+
if (below > 0) lines.push(`[${below} elements below the viewport — scroll down to reach them]`);
|
|
494
|
+
return lines.join('\n');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private cacheSnapshot(nodes: SnapshotNode[]): void {
|
|
498
|
+
this.cachedNodes = nodes;
|
|
499
|
+
const root = nodes.find((n) => (n.type === 'Application' || n.type === 'Window') && n.rect);
|
|
500
|
+
if (root?.rect) this.cachedViewport = { width: root.rect.width, height: root.rect.height };
|
|
501
|
+
this.cacheAt = Date.now();
|
|
502
|
+
this.onCacheUpdated();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// One snapshot → cache. observe() and every action share this so we snapshot
|
|
506
|
+
// once per state change instead of separately for verify + observe.
|
|
507
|
+
private async refreshCache(): Promise<void> {
|
|
508
|
+
const snap = await this.backend.snapshot({ interactiveOnly: true });
|
|
509
|
+
this.cacheSnapshot(snap.nodes);
|
|
510
|
+
this.lastApp = { app: snap.appName, bundleId: snap.appBundleId };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// A cheap fingerprint of the current screen to detect whether an action
|
|
514
|
+
// changed anything, replacing the driver's expensive verify pass.
|
|
515
|
+
private cacheSignature(): string {
|
|
516
|
+
const head = this.cachedNodes
|
|
517
|
+
.slice(0, 16)
|
|
518
|
+
.map((n) => `${n.ref ?? ''}:${n.label ?? n.type ?? ''}`)
|
|
519
|
+
.join('|');
|
|
520
|
+
return `${this.cachedNodes.length}#${head}`;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Milliseconds since the cache was last refreshed (Infinity before first). */
|
|
524
|
+
cacheAgeMs(): number {
|
|
525
|
+
return this.cacheAt === 0 ? Number.POSITIVE_INFINITY : Date.now() - this.cacheAt;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Public fingerprint of the cached tree — the settle/verify signal. */
|
|
529
|
+
stateSignature(): string {
|
|
530
|
+
return this.cacheSignature();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** The cached screen as a compressed {@link Observation} (no new snapshot). */
|
|
534
|
+
currentElements(): Observation {
|
|
535
|
+
return {
|
|
536
|
+
app: this.lastApp.app,
|
|
537
|
+
bundleId: this.lastApp.bundleId,
|
|
538
|
+
truncated: false,
|
|
539
|
+
elements: compressNodes(this.cachedNodes),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* The frontmost app name from the last snapshot — cheap label without paying
|
|
545
|
+
* the full tree compression (used by the delta renderer's callers).
|
|
546
|
+
*/
|
|
547
|
+
currentApp(): string | undefined {
|
|
548
|
+
return this.lastApp.app;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Structured interactive elements from the current cache — the crawler taps
|
|
553
|
+
* these by label (refs are only valid within one snapshot).
|
|
554
|
+
*/
|
|
555
|
+
interactiveElements(): UiElement[] {
|
|
556
|
+
const out: UiElement[] = [];
|
|
557
|
+
const seen = new Set<string>();
|
|
558
|
+
for (const n of this.cachedNodes) {
|
|
559
|
+
if (!n.ref || !n.rect) continue;
|
|
560
|
+
// role-first, matching the human-readable element list (a Settings row is
|
|
561
|
+
// type=Button role=Cell; we treat it as its semantic role, "Cell").
|
|
562
|
+
const role = n.role ?? n.type ?? '';
|
|
563
|
+
if (!TAPPABLE.has(role)) continue;
|
|
564
|
+
const label = (n.label ?? n.identifier ?? '').trim();
|
|
565
|
+
if (!label) continue;
|
|
566
|
+
if (n.rect.width >= this.cachedViewport.width && n.rect.height >= this.cachedViewport.height) continue;
|
|
567
|
+
const key = `${role}:${label}`;
|
|
568
|
+
if (seen.has(key)) continue;
|
|
569
|
+
seen.add(key);
|
|
570
|
+
out.push({
|
|
571
|
+
ref: normRef(n.ref),
|
|
572
|
+
label,
|
|
573
|
+
role,
|
|
574
|
+
value: n.value,
|
|
575
|
+
rect: n.rect,
|
|
576
|
+
id: n.identifier?.trim() || undefined,
|
|
577
|
+
enabled: n.enabled,
|
|
578
|
+
blocked: n.interactionBlocked,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
return out;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Editable text inputs from the current cache. These roles are deliberately
|
|
586
|
+
* excluded from interactiveElements() (they aren't "tap" targets), so the
|
|
587
|
+
* input primitive needs its own accessor to find a search bar / text field to
|
|
588
|
+
* focus. includeMultiline adds TextView bodies for form/compose filling.
|
|
589
|
+
*/
|
|
590
|
+
inputFields(includeMultiline = false): UiElement[] {
|
|
591
|
+
const editable = includeMultiline ? EDITABLE_MULTILINE : EDITABLE;
|
|
592
|
+
const out: UiElement[] = [];
|
|
593
|
+
for (const n of this.cachedNodes) {
|
|
594
|
+
if (!n.ref || !n.rect) continue;
|
|
595
|
+
const role = n.role ?? n.type ?? '';
|
|
596
|
+
if (!editable.has(role)) continue;
|
|
597
|
+
out.push({
|
|
598
|
+
ref: normRef(n.ref),
|
|
599
|
+
label: (n.label ?? n.identifier ?? '').trim(),
|
|
600
|
+
role,
|
|
601
|
+
value: n.value,
|
|
602
|
+
rect: n.rect,
|
|
603
|
+
enabled: n.enabled,
|
|
604
|
+
blocked: n.interactionBlocked,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Run the resolution ladder against the current cache only — no scrolling,
|
|
612
|
+
* no fresh snapshot. resolveElement drives this per scroll step.
|
|
613
|
+
*/
|
|
614
|
+
resolveInCache(query: string, opts: ResolveOpts = {}): Resolution {
|
|
615
|
+
return matchInElements(this.interactiveElements(), query, opts);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** The full ladder: scroll to top, then match + scroll down until found or stable. */
|
|
619
|
+
async resolveElement(query: string, opts: ResolveOpts = {}): Promise<Resolution> {
|
|
620
|
+
await this.scrollToTop();
|
|
621
|
+
for (let i = 0; i < 10; i++) {
|
|
622
|
+
const r = this.resolveInCache(query, opts);
|
|
623
|
+
if (r.el || r.candidates) return r;
|
|
624
|
+
|
|
625
|
+
const before = this.screenSignature();
|
|
626
|
+
await this.scroll('down');
|
|
627
|
+
await this.observe();
|
|
628
|
+
if (this.screenSignature() === before) break;
|
|
629
|
+
}
|
|
630
|
+
return { el: null };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Compatibility wrapper: single best element or null (read paths — ask/read a
|
|
635
|
+
* value — where picking the first match is low-risk). Tap paths use
|
|
636
|
+
* resolveElement directly and honor the ambiguity contract.
|
|
637
|
+
*/
|
|
638
|
+
async findElement(labelSubstring: string): Promise<UiElement | null> {
|
|
639
|
+
const r = await this.resolveElement(labelSubstring);
|
|
640
|
+
return r.el ?? r.candidates?.[0] ?? null;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Read a labeled value. iOS list rows fold the value into the label
|
|
645
|
+
* ("iOS Version, 26.1") or expose it as a Switch value ("1"/"0"); handle both.
|
|
646
|
+
*/
|
|
647
|
+
async readField(labelSubstring: string): Promise<string | null> {
|
|
648
|
+
const el = await this.findElement(labelSubstring);
|
|
649
|
+
if (!el) return null;
|
|
650
|
+
if (el.value != null && el.value !== '') return el.value;
|
|
651
|
+
// "Label, value" pattern → take the part after the label text. If the query
|
|
652
|
+
// wasn't a substring (a fuzzy match), we can't split cleanly — return the
|
|
653
|
+
// whole row, which still carries the value.
|
|
654
|
+
const idx = el.label.toLowerCase().indexOf(labelSubstring.toLowerCase());
|
|
655
|
+
if (idx < 0) return el.label;
|
|
656
|
+
const after = el.label
|
|
657
|
+
.slice(idx + labelSubstring.length)
|
|
658
|
+
.replace(/^[\s,:]+/, '')
|
|
659
|
+
.trim();
|
|
660
|
+
return after || el.label;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* A structural fingerprint of the current screen that is stable across
|
|
665
|
+
* dynamic content (times, battery, values) — it keys the crawler's graph
|
|
666
|
+
* nodes so the same screen is recognized regardless of transient text.
|
|
667
|
+
*/
|
|
668
|
+
screenSignature(): string {
|
|
669
|
+
const title =
|
|
670
|
+
this.cachedNodes.find((n) => (n.type === 'NavigationBar' || n.role === 'NavigationBar') && n.label)
|
|
671
|
+
?.label ?? '';
|
|
672
|
+
const labels = this.cachedNodes
|
|
673
|
+
.filter((n) => TAPPABLE.has(n.role ?? n.type ?? '') && (n.label ?? '').trim())
|
|
674
|
+
.map((n) => `${n.role ?? n.type}:${(n.label ?? '').trim()}`)
|
|
675
|
+
.sort();
|
|
676
|
+
const uniq = [...new Set(labels)];
|
|
677
|
+
return `${this.lastApp.bundleId ?? ''}|${title}|${uniq.join('~')}`;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Navigation-bar title of the cached screen ('' when absent). */
|
|
681
|
+
screenTitle(): string {
|
|
682
|
+
return (
|
|
683
|
+
this.cachedNodes.find((n) => (n.type === 'NavigationBar' || n.role === 'NavigationBar') && n.label)
|
|
684
|
+
?.label ?? ''
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Take one fresh snapshot into the cache and return the compressed observation. */
|
|
689
|
+
async observe(): Promise<Observation> {
|
|
690
|
+
try {
|
|
691
|
+
await this.refreshCache();
|
|
692
|
+
return this.currentElements();
|
|
693
|
+
} catch (error) {
|
|
694
|
+
if (
|
|
695
|
+
error instanceof SessionNotFoundError ||
|
|
696
|
+
(error as { code?: string })?.code === 'SESSION_NOT_FOUND'
|
|
697
|
+
) {
|
|
698
|
+
return {
|
|
699
|
+
truncated: false,
|
|
700
|
+
elements: 'No app session is active yet. Use open_app to launch an app first.',
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
throw error;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Open an app by name/bundle id. relaunch forces a fresh launch (clean
|
|
709
|
+
* initial screen) instead of just foregrounding — iOS keeps an app's
|
|
710
|
+
* navigation state across foregrounding, so primitives that need a known
|
|
711
|
+
* starting screen pass relaunch=true.
|
|
712
|
+
*/
|
|
713
|
+
async openApp(app: string, relaunch = false): Promise<string> {
|
|
714
|
+
const result = await this.backend.openApp({ app, relaunch });
|
|
715
|
+
return `Opened ${result.appName ?? app} (${result.appBundleId ?? 'unknown bundle'})`;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Level-2 of the action ladder: deep links beat tap sequences when a URL route
|
|
720
|
+
* exists (maps://, app schemes, https:// universal links). XCTest sessions are
|
|
721
|
+
* app-scoped, so a link that opens a different app must re-scope the session
|
|
722
|
+
* to that app or observations keep tracking the old one.
|
|
723
|
+
*/
|
|
724
|
+
async openUrl(url: string, app?: string): Promise<string> {
|
|
725
|
+
const target = app ?? (await this.currentBundleId());
|
|
726
|
+
await this.backend.openApp(target ? { app: target, url } : { url });
|
|
727
|
+
return app ? `Opened ${url} in ${app}` : `Opened ${url}`;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
private async currentBundleId(): Promise<string | undefined> {
|
|
731
|
+
try {
|
|
732
|
+
const snap = await this.backend.snapshot({ interactiveOnly: true, depth: 1 });
|
|
733
|
+
return snap.appBundleId;
|
|
734
|
+
} catch {
|
|
735
|
+
return undefined;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** List installed app bundle ids. */
|
|
740
|
+
async listApps(): Promise<string[]> {
|
|
741
|
+
return this.backend.listApps();
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// A tap self-diffs against our own snapshot cache: fingerprint before, tap
|
|
745
|
+
// with no driver-side verify (which would cost a second snapshot pair),
|
|
746
|
+
// re-snapshot once, fingerprint after. One snapshot instead of the verify
|
|
747
|
+
// pass's three, and the cache is left fresh so callers don't observe again.
|
|
748
|
+
private async tapAndDiff(tap: () => Promise<unknown>): Promise<ActionEvidence> {
|
|
749
|
+
const before = this.cacheSignature();
|
|
750
|
+
await tap();
|
|
751
|
+
await this.refreshCache();
|
|
752
|
+
const after = this.cacheSignature();
|
|
753
|
+
const changed = before !== after;
|
|
754
|
+
return {
|
|
755
|
+
changed,
|
|
756
|
+
detail: changed ? 'screen changed' : 'screen did NOT change — the action may have had no effect',
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** Tap an element ref, self-diffing the cache to report whether the screen changed. */
|
|
761
|
+
async press(ref: string): Promise<ActionEvidence> {
|
|
762
|
+
try {
|
|
763
|
+
return await this.tapAndDiff(() => this.backend.press({ ref }));
|
|
764
|
+
} catch (error) {
|
|
765
|
+
// The runner refuses center-targeted taps whose center is off-screen
|
|
766
|
+
// ("off-screen and not safe to press") — but a carousel/pager item can
|
|
767
|
+
// straddle the viewport edge with most of it visible and perfectly
|
|
768
|
+
// tappable. Fall back to the midpoint of the VISIBLE region. Fully
|
|
769
|
+
// off-screen elements still refuse (rethrow), preserving the scroll-into-
|
|
770
|
+
// view recovery in tapLabel/tapControl.
|
|
771
|
+
if (!/off-?screen/i.test(describeError(error))) throw error;
|
|
772
|
+
const mid = this.visibleMidpoint(this.findNode(ref)?.rect);
|
|
773
|
+
if (!mid) throw error;
|
|
774
|
+
return this.tapAndDiff(() => this.backend.press({ x: mid.x, y: mid.y }));
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Midpoint of the part of `rect` inside the viewport, or null when nothing
|
|
779
|
+
// of it is visible.
|
|
780
|
+
private visibleMidpoint(rect?: Rect): { x: number; y: number } | null {
|
|
781
|
+
if (!rect) return null;
|
|
782
|
+
const x1 = Math.max(rect.x, 0);
|
|
783
|
+
const y1 = Math.max(rect.y, 0);
|
|
784
|
+
const x2 = Math.min(rect.x + rect.width, this.cachedViewport.width);
|
|
785
|
+
const y2 = Math.min(rect.y + rect.height, this.cachedViewport.height);
|
|
786
|
+
if (x2 <= x1 || y2 <= y1) return null;
|
|
787
|
+
return { x: Math.round((x1 + x2) / 2), y: Math.round((y1 + y2) / 2) };
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Fully within a safe band clear of the top nav bar and bottom tab/home area.
|
|
791
|
+
protected onScreen(rect?: Rect): boolean {
|
|
792
|
+
if (!rect) return false;
|
|
793
|
+
const cy = rect.y + rect.height / 2;
|
|
794
|
+
return cy > 56 && cy < this.cachedViewport.height - 44 && rect.x < this.cachedViewport.width;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Open an app and walk its nav stack back to the root (dismissing modals), so
|
|
799
|
+
* map-based navigation always starts from a known origin.
|
|
800
|
+
*/
|
|
801
|
+
async goToRoot(app: string): Promise<void> {
|
|
802
|
+
const DISMISS = ['Close', 'Cancel', 'Done', 'Not Now', 'Dismiss'];
|
|
803
|
+
await this.observe().catch(() => undefined);
|
|
804
|
+
if (this.lastApp.bundleId !== app) {
|
|
805
|
+
await this.openApp(app);
|
|
806
|
+
await this.observe();
|
|
807
|
+
}
|
|
808
|
+
// Real apps launch behind stacked permission prompts (see clearBlockingAlerts);
|
|
809
|
+
// clear them first so the nav-stack walk below sees the actual app.
|
|
810
|
+
await this.clearBlockingAlerts('accept');
|
|
811
|
+
for (let i = 0; i < 12; i++) {
|
|
812
|
+
const els = this.interactiveElements();
|
|
813
|
+
const back = els.find((e) => e.role === 'Button' && !!e.rect && e.rect.x < 70 && e.rect.y < 110);
|
|
814
|
+
const dismiss = els.find((e) => e.role === 'Button' && DISMISS.includes(e.label));
|
|
815
|
+
const target = back ?? dismiss;
|
|
816
|
+
if (!target) break;
|
|
817
|
+
await this.press(target.ref);
|
|
818
|
+
await this.observe();
|
|
819
|
+
}
|
|
820
|
+
// The root may be left scrolled from prior navigation; reset it to the top
|
|
821
|
+
// so crawls/navigation start from a known position.
|
|
822
|
+
await this.scrollToTop();
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Height of the cached viewport in points. */
|
|
826
|
+
viewportHeight(): number {
|
|
827
|
+
return this.cachedViewport.height;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Vertical span of interactive content in the current cache. Used to decide
|
|
832
|
+
* whether scrolling is even necessary — scroll gestures cost ~2s each, so
|
|
833
|
+
* skipping them on screens that already fit is the single biggest crawl
|
|
834
|
+
* speedup.
|
|
835
|
+
*/
|
|
836
|
+
contentBounds(): { minY: number; maxY: number } {
|
|
837
|
+
let minY = Infinity;
|
|
838
|
+
let maxY = -Infinity;
|
|
839
|
+
for (const n of this.cachedNodes) {
|
|
840
|
+
if (!n.rect) continue;
|
|
841
|
+
if (!TAPPABLE.has(n.role ?? n.type ?? '')) continue;
|
|
842
|
+
minY = Math.min(minY, n.rect.y);
|
|
843
|
+
maxY = Math.max(maxY, n.rect.y + n.rect.height);
|
|
844
|
+
}
|
|
845
|
+
return { minY: minY === Infinity ? 0 : minY, maxY: maxY === -Infinity ? 0 : maxY };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Tapping the status bar scrolls the active scroll view to the top — native
|
|
850
|
+
* iOS behavior, one fast tap instead of multiple ~2s scroll gestures. Falls
|
|
851
|
+
* back to gesture scrolling if the tap doesn't take.
|
|
852
|
+
*/
|
|
853
|
+
async scrollToTop(): Promise<void> {
|
|
854
|
+
try {
|
|
855
|
+
await this.backend.press({ x: Math.round(this.cachedViewport.width / 2), y: 6 });
|
|
856
|
+
await this.observe();
|
|
857
|
+
return;
|
|
858
|
+
} catch {
|
|
859
|
+
/* fall back to gesture scroll below */
|
|
860
|
+
}
|
|
861
|
+
for (let i = 0; i < 6; i++) {
|
|
862
|
+
const before = this.screenSignature();
|
|
863
|
+
await this.scroll('up');
|
|
864
|
+
await this.observe();
|
|
865
|
+
if (this.screenSignature() === before) return;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Tap an element by its label, scrolling it into view first if it's
|
|
871
|
+
* off-screen. The crawler and the map navigator use this so a target below
|
|
872
|
+
* the fold (a long Settings list) is still reachable. Re-resolves the ref
|
|
873
|
+
* after each scroll.
|
|
874
|
+
*/
|
|
875
|
+
async tapLabel(label: string): Promise<boolean> {
|
|
876
|
+
for (let i = 0; i < 12; i++) {
|
|
877
|
+
const el = this.interactiveElements().find((e) => e.label === label);
|
|
878
|
+
if (el && this.onScreen(el.rect)) {
|
|
879
|
+
try {
|
|
880
|
+
await this.press(el.ref);
|
|
881
|
+
return true;
|
|
882
|
+
} catch (error) {
|
|
883
|
+
if (!/off-?screen/i.test(describeError(error))) throw error;
|
|
884
|
+
// straddled the edge — fall through to a nudge scroll and retry
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
// Known position → scroll toward it. Not in the realized tree at all →
|
|
888
|
+
// sweep to the top first (rows may be scrolled past above), then scan down.
|
|
889
|
+
const dir: 'up' | 'down' = el?.rect ? (el.rect.y < 0 ? 'up' : 'down') : i < 5 ? 'up' : 'down';
|
|
890
|
+
await this.scroll(dir);
|
|
891
|
+
await this.observe();
|
|
892
|
+
}
|
|
893
|
+
return false;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Vision-path fallback: tap raw coordinates when the accessibility tree is
|
|
898
|
+
* missing or wrong (canvas, games, custom controls). Coordinates are in the
|
|
899
|
+
* same space as observe()'s rects and the screenshot pixels (@1x points).
|
|
900
|
+
*/
|
|
901
|
+
async pressAt(x: number, y: number): Promise<ActionEvidence> {
|
|
902
|
+
return this.tapAndDiff(() => this.backend.press({ x, y }));
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Coordinate drag: touch down at (x,y), move by (dx,dy). The primitive for
|
|
907
|
+
* controls a tap can't operate — picker wheels (drag vertically on the wheel
|
|
908
|
+
* column), sliders, and custom carousels. Same coordinate space as rects.
|
|
909
|
+
*/
|
|
910
|
+
async pan(x: number, y: number, dx: number, dy: number, durationMs?: number): Promise<void> {
|
|
911
|
+
await this.backend.pan(x, y, dx, dy, durationMs);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Set-of-Marks visual observation: screenshot with `@ref` labels drawn on the
|
|
916
|
+
* elements, so a vision model can ground itself in pixels and still act by ref.
|
|
917
|
+
*/
|
|
918
|
+
async screenshotWithRefs(path: string): Promise<string> {
|
|
919
|
+
const result = await this.backend.screenshot({ path, overlayRefs: true });
|
|
920
|
+
return result.path;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
protected async currentViewport(): Promise<{ width: number; height: number }> {
|
|
924
|
+
try {
|
|
925
|
+
const snap = await this.backend.snapshot({ interactiveOnly: true, depth: 1 });
|
|
926
|
+
const root = snap.nodes.find((n) => (n.type === 'Application' || n.type === 'Window') && n.rect);
|
|
927
|
+
return { width: root?.rect?.width ?? 390, height: root?.rect?.height ?? 844 };
|
|
928
|
+
} catch {
|
|
929
|
+
return { width: 390, height: 844 };
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** Long-press an element ref. */
|
|
934
|
+
async longPress(ref: string, durationMs = 800): Promise<void> {
|
|
935
|
+
await this.backend.longPress(ref, durationMs);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** Focus a field and replace its text, self-diffing the cache for evidence. */
|
|
939
|
+
async fill(ref: string, text: string): Promise<ActionEvidence> {
|
|
940
|
+
return this.tapAndDiff(() => this.backend.fill(ref, text));
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** Type into whatever currently has keyboard focus. */
|
|
944
|
+
async typeText(text: string): Promise<void> {
|
|
945
|
+
await this.backend.typeText(text);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Press the keyboard's return/go key. Submits a search bar that acts on
|
|
950
|
+
* Return (Safari's address bar, web forms) rather than filtering results as
|
|
951
|
+
* you type.
|
|
952
|
+
*/
|
|
953
|
+
async pressReturn(): Promise<void> {
|
|
954
|
+
await this.backend.pressKey('return');
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/** Scroll the active scroll view one step. */
|
|
958
|
+
async scroll(direction: 'up' | 'down' | 'left' | 'right'): Promise<void> {
|
|
959
|
+
await this.backend.scroll(direction);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** Block until `text` appears on screen; returns a confirmation note. */
|
|
963
|
+
async waitForText(text: string, timeoutMs = 5000): Promise<string> {
|
|
964
|
+
await this.backend.waitForText(text, timeoutMs);
|
|
965
|
+
return `"${text}" appeared on screen`;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Read a modal alert (if any) from the current snapshot cache — cheap, no
|
|
969
|
+
// extra snapshot, since observe() already caches the Alert node
|
|
970
|
+
// (interactiveOnly keeps it).
|
|
971
|
+
private alertFromCache(): AlertInfo | null {
|
|
972
|
+
const alert = this.cachedNodes.find((n) => (n.type ?? n.role) === 'Alert');
|
|
973
|
+
if (!alert) return null;
|
|
974
|
+
const texts = this.cachedNodes
|
|
975
|
+
.filter((n) => (n.type ?? n.role) === 'StaticText' && n.label)
|
|
976
|
+
.map((n) => (n.label ?? '').trim());
|
|
977
|
+
const buttons: UiElement[] = [];
|
|
978
|
+
for (const n of this.cachedNodes) {
|
|
979
|
+
if (!n.ref || !n.rect || (n.role ?? n.type) !== 'Button') continue;
|
|
980
|
+
const label = (n.label ?? n.identifier ?? '').trim();
|
|
981
|
+
if (label)
|
|
982
|
+
buttons.push({
|
|
983
|
+
ref: normRef(n.ref),
|
|
984
|
+
label,
|
|
985
|
+
role: 'Button',
|
|
986
|
+
value: n.value,
|
|
987
|
+
rect: n.rect,
|
|
988
|
+
enabled: n.enabled,
|
|
989
|
+
blocked: n.interactionBlocked,
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
const title = (alert.label ?? texts[0] ?? 'Alert').trim();
|
|
993
|
+
return { title, message: texts.find((t) => t !== title), buttons };
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* System dialogs (permissions, sign-in prompts) block everything else; the
|
|
998
|
+
* driver exposes them as a first-class action instead of hoping a tap lands.
|
|
999
|
+
*/
|
|
1000
|
+
async handleAlert(action: 'get' | 'accept' | 'dismiss'): Promise<AlertOutcome> {
|
|
1001
|
+
await this.observe().catch(() => undefined);
|
|
1002
|
+
const info = this.alertFromCache();
|
|
1003
|
+
if (info) {
|
|
1004
|
+
const description = describeAlert(info);
|
|
1005
|
+
if (action === 'get') return { present: true, description };
|
|
1006
|
+
const btn = pickAlertButton(info.buttons, action);
|
|
1007
|
+
if (!btn?.rect) return { present: true, handled: false, description };
|
|
1008
|
+
await this.pressAt(btn.rect.x + btn.rect.width / 2, btn.rect.y + btn.rect.height / 2);
|
|
1009
|
+
await this.observe().catch(() => undefined);
|
|
1010
|
+
const still = this.alertFromCache();
|
|
1011
|
+
// Handled if this alert is gone; a *different* alert surfacing (stacked
|
|
1012
|
+
// prompts) still counts — this one was cleared.
|
|
1013
|
+
return {
|
|
1014
|
+
present: true,
|
|
1015
|
+
handled: still == null || still.title !== info.title,
|
|
1016
|
+
button: btn.label,
|
|
1017
|
+
description,
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
// Fallback: the backend's command for an app's own alert not surfaced as a node.
|
|
1021
|
+
try {
|
|
1022
|
+
const result = await this.backend.systemAlert(action);
|
|
1023
|
+
const alert = result.alert;
|
|
1024
|
+
return {
|
|
1025
|
+
present: alert != null,
|
|
1026
|
+
handled: result.handled,
|
|
1027
|
+
button: result.button,
|
|
1028
|
+
description: alert
|
|
1029
|
+
? `${alert.title ?? ''} ${alert.message ?? ''}`.trim() +
|
|
1030
|
+
(alert.buttons?.length ? ` [buttons: ${alert.buttons.join(', ')}]` : '')
|
|
1031
|
+
: undefined,
|
|
1032
|
+
};
|
|
1033
|
+
} catch (error) {
|
|
1034
|
+
if (/alert not found/i.test(describeError(error))) return { present: false };
|
|
1035
|
+
throw error;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Clear the launch permission gauntlet — real apps stack location /
|
|
1041
|
+
* notification / tracking prompts on first open, each blocking the app.
|
|
1042
|
+
* Grants by default so the crawl sees the most surface. Returns the buttons
|
|
1043
|
+
* tapped. Bounded so a non-clearing dialog can't loop forever.
|
|
1044
|
+
*/
|
|
1045
|
+
async clearBlockingAlerts(action: 'accept' | 'dismiss' = 'accept', max = 6): Promise<string[]> {
|
|
1046
|
+
const tapped: string[] = [];
|
|
1047
|
+
let lastTitle = '';
|
|
1048
|
+
for (let i = 0; i < max; i++) {
|
|
1049
|
+
const info = this.alertFromCache();
|
|
1050
|
+
if (!info) {
|
|
1051
|
+
await this.observe().catch(() => undefined);
|
|
1052
|
+
if (!this.alertFromCache()) break;
|
|
1053
|
+
}
|
|
1054
|
+
const r = await this.handleAlert(action);
|
|
1055
|
+
if (!r.present || !r.button || !r.handled) break;
|
|
1056
|
+
if (r.description === lastTitle) break; // no progress — same dialog persists
|
|
1057
|
+
lastTitle = r.description ?? '';
|
|
1058
|
+
tapped.push(r.button);
|
|
1059
|
+
}
|
|
1060
|
+
return tapped;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** Go to the home screen. */
|
|
1064
|
+
async goHome(): Promise<void> {
|
|
1065
|
+
await this.backend.home();
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/** Navigate back (nav-bar back / hardware back). */
|
|
1069
|
+
async goBack(): Promise<void> {
|
|
1070
|
+
await this.backend.back();
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/** Save a screenshot to `path`; returns the written path. */
|
|
1074
|
+
async screenshot(path: string): Promise<string> {
|
|
1075
|
+
const result = await this.backend.screenshot({ path });
|
|
1076
|
+
return result.path;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/** Close the backend's transport session. */
|
|
1080
|
+
async closeSession(): Promise<void> {
|
|
1081
|
+
await this.backend.closeSession();
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
protected findNode(ref: string): SnapshotNode | undefined {
|
|
1085
|
+
const want = normRef(ref);
|
|
1086
|
+
return this.cachedNodes.find((n) => n.ref && normRef(n.ref) === want);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Ensure the cache is fresh enough to resolve refs / draw boxes.
|
|
1090
|
+
protected async ensureCache(): Promise<void> {
|
|
1091
|
+
if (this.cachedNodes.length === 0) await this.observe();
|
|
1092
|
+
}
|
|
1093
|
+
}
|