@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/dist/index.mjs
ADDED
|
@@ -0,0 +1,1848 @@
|
|
|
1
|
+
import { a as registerBackend, c as DeviceInUseError, d as SessionNotFoundError, f as TimeoutError, i as listBackends, l as DeviceNotFoundError, m as toPhoneUseError, n as BaseDeviceBackend, o as AbortedError, p as UnsupportedCapabilityError, r as getBackendFactory, s as ActionFailedError, t as ALL_CAPABILITIES, u as PhoneUseError } from "./device-BzPnHvQy.mjs";
|
|
2
|
+
import { createAgentDeviceClient } from "agent-device";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
//#region src/observe.ts
|
|
6
|
+
const NOISE_LABELS = /* @__PURE__ */ new Set([
|
|
7
|
+
"|",
|
|
8
|
+
"(",
|
|
9
|
+
")",
|
|
10
|
+
",",
|
|
11
|
+
"·",
|
|
12
|
+
"•"
|
|
13
|
+
]);
|
|
14
|
+
function isNoise(n) {
|
|
15
|
+
const kind = n.type ?? n.role ?? "";
|
|
16
|
+
if (!n.label) return false;
|
|
17
|
+
return (kind === "StaticText" || kind === "Other") && NOISE_LABELS.has(n.label.trim());
|
|
18
|
+
}
|
|
19
|
+
function intersectsViewport(rect, vw, vh) {
|
|
20
|
+
if (!rect) return true;
|
|
21
|
+
return rect.x < vw && rect.y < vh && rect.x + rect.width > 0 && rect.y + rect.height > 0;
|
|
22
|
+
}
|
|
23
|
+
const RENDER_TEXT_MAX = 160;
|
|
24
|
+
function renderText(s) {
|
|
25
|
+
return s.length <= RENDER_TEXT_MAX ? JSON.stringify(s) : `${JSON.stringify(s.slice(0, RENDER_TEXT_MAX))} [truncated]`;
|
|
26
|
+
}
|
|
27
|
+
function formatNode(n, opts) {
|
|
28
|
+
const role = n.role ?? n.type ?? "element";
|
|
29
|
+
const parts = [`${n.ref && !n.ref.startsWith("@") ? `@${n.ref}` : n.ref ?? ""} [${role}]`];
|
|
30
|
+
const label = n.label ?? n.identifier;
|
|
31
|
+
if (label) parts.push(renderText(label));
|
|
32
|
+
if (n.value && n.value !== n.label) parts.push(`value=${renderText(n.value)}`);
|
|
33
|
+
if (n.rect) parts.push(`(${Math.round(n.rect.x)},${Math.round(n.rect.y)} ${Math.round(n.rect.width)}x${Math.round(n.rect.height)})`);
|
|
34
|
+
if (n.rect && opts.vw !== void 0) {
|
|
35
|
+
const cx = n.rect.x + n.rect.width / 2;
|
|
36
|
+
if (cx < 0 || cx > opts.vw) parts.push("(center off-screen)");
|
|
37
|
+
}
|
|
38
|
+
if (n.enabled === false) parts.push("(disabled)");
|
|
39
|
+
if (n.selected) parts.push("(selected)");
|
|
40
|
+
if (n.focused && !opts.suppressFocused) parts.push("(focused)");
|
|
41
|
+
if (n.interactionBlocked) parts.push(`(blocked: ${n.interactionBlocked})`);
|
|
42
|
+
return parts.join(" ");
|
|
43
|
+
}
|
|
44
|
+
function keptViewportNodes(nodes) {
|
|
45
|
+
const root = nodes.find((n) => (n.type === "Application" || n.type === "Window") && n.rect);
|
|
46
|
+
const vw = root?.rect?.width ?? 500;
|
|
47
|
+
const vh = root?.rect?.height ?? 1e3;
|
|
48
|
+
const suppressFocused = nodes.filter((n) => n.focused).length > nodes.length / 3;
|
|
49
|
+
const kept = [];
|
|
50
|
+
let above = 0;
|
|
51
|
+
let below = 0;
|
|
52
|
+
for (const n of nodes) {
|
|
53
|
+
if (isNoise(n)) continue;
|
|
54
|
+
if (!intersectsViewport(n.rect, vw, vh)) {
|
|
55
|
+
if (n.rect && n.rect.y >= vh) below += 1;
|
|
56
|
+
else above += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
kept.push(n);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
kept,
|
|
63
|
+
above,
|
|
64
|
+
below,
|
|
65
|
+
suppressFocused,
|
|
66
|
+
vw
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function compressNodes(nodes) {
|
|
70
|
+
const { kept, above, below, suppressFocused, vw } = keptViewportNodes(nodes);
|
|
71
|
+
const lines = kept.map((n) => formatNode(n, {
|
|
72
|
+
suppressFocused,
|
|
73
|
+
vw
|
|
74
|
+
}));
|
|
75
|
+
if (above > 0) lines.unshift(`[${above} elements above the viewport — scroll up to reach them]`);
|
|
76
|
+
if (below > 0) lines.push(`[${below} elements below the viewport — scroll down to reach them]`);
|
|
77
|
+
return lines.join("\n");
|
|
78
|
+
}
|
|
79
|
+
function elementKey(n) {
|
|
80
|
+
return `${n.role ?? n.type ?? "element"}|${(n.label ?? n.identifier ?? "").trim()}|${n.rect ? `${Math.round(n.rect.x)},${Math.round(n.rect.y)}` : ""}`;
|
|
81
|
+
}
|
|
82
|
+
function arraysEqual(a, b) {
|
|
83
|
+
if (a.length !== b.length) return false;
|
|
84
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
const TAPPABLE = /* @__PURE__ */ new Set([
|
|
88
|
+
"Button",
|
|
89
|
+
"Cell",
|
|
90
|
+
"Link",
|
|
91
|
+
"MenuItem",
|
|
92
|
+
"Tab",
|
|
93
|
+
"StaticText",
|
|
94
|
+
"Switch"
|
|
95
|
+
]);
|
|
96
|
+
const EDITABLE = /* @__PURE__ */ new Set([
|
|
97
|
+
"SearchField",
|
|
98
|
+
"TextField",
|
|
99
|
+
"SecureTextField"
|
|
100
|
+
]);
|
|
101
|
+
const EDITABLE_MULTILINE = /* @__PURE__ */ new Set([
|
|
102
|
+
...EDITABLE,
|
|
103
|
+
"TextView",
|
|
104
|
+
"TextEditor"
|
|
105
|
+
]);
|
|
106
|
+
function labelTokens(s) {
|
|
107
|
+
return s.toLowerCase().split(/[^a-z0-9]+/i).filter((t) => t.length > 2);
|
|
108
|
+
}
|
|
109
|
+
function fuzzyScore(label, query) {
|
|
110
|
+
const qt = new Set(labelTokens(query));
|
|
111
|
+
const lt = labelTokens(label);
|
|
112
|
+
if (!qt.size || !lt.length) return 0;
|
|
113
|
+
return lt.filter((t) => qt.has(t)).length / lt.length;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Does a label match a query — by substring, or a strict punctuation/spacing-
|
|
117
|
+
* tolerant fuzzy match? Shared by the task layer's cached-map lookups so
|
|
118
|
+
* ask/toggle tolerate rewording the same way findElement does.
|
|
119
|
+
*/
|
|
120
|
+
function labelMatches(label, query) {
|
|
121
|
+
return label.toLowerCase().includes(query.toLowerCase()) || fuzzyScore(label, query) >= .75;
|
|
122
|
+
}
|
|
123
|
+
function center(e) {
|
|
124
|
+
return e.rect ? {
|
|
125
|
+
x: e.rect.x + e.rect.width / 2,
|
|
126
|
+
y: e.rect.y + e.rect.height / 2
|
|
127
|
+
} : null;
|
|
128
|
+
}
|
|
129
|
+
function disambiguate(matches, via, opts, els) {
|
|
130
|
+
let pool = matches;
|
|
131
|
+
if (opts.role) {
|
|
132
|
+
const byRole = pool.filter((e) => e.role.toLowerCase() === opts.role.toLowerCase());
|
|
133
|
+
if (byRole.length) pool = byRole;
|
|
134
|
+
}
|
|
135
|
+
if (pool.length > 1 && opts.near) {
|
|
136
|
+
const anchor = els.find((e) => labelMatches(e.label, opts.near));
|
|
137
|
+
const ac = anchor ? center(anchor) : null;
|
|
138
|
+
if (ac) {
|
|
139
|
+
pool = [...pool].sort((a, b) => {
|
|
140
|
+
const ca = center(a);
|
|
141
|
+
const cb = center(b);
|
|
142
|
+
return (ca ? (ca.x - ac.x) ** 2 + (ca.y - ac.y) ** 2 : Infinity) - (cb ? (cb.x - ac.x) ** 2 + (cb.y - ac.y) ** 2 : Infinity);
|
|
143
|
+
});
|
|
144
|
+
return {
|
|
145
|
+
el: pool[0],
|
|
146
|
+
via: `${via}, nearest "${opts.near}"`
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (pool.length === 1) return {
|
|
151
|
+
el: pool[0],
|
|
152
|
+
via
|
|
153
|
+
};
|
|
154
|
+
return {
|
|
155
|
+
el: null,
|
|
156
|
+
candidates: pool,
|
|
157
|
+
via
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* One screen's worth of the resolution ladder: id exact → exact label →
|
|
162
|
+
* substring → fuzzy above a strict bar. This is the per-iteration body of
|
|
163
|
+
* resolveElement's scroll loop, extracted so callers holding a fresh cache
|
|
164
|
+
* (item-4 auto-wait) can match without scrolling. Returns `{ el: null }` with
|
|
165
|
+
* no candidates when no rung matched at all.
|
|
166
|
+
*/
|
|
167
|
+
function matchInElements(els, query, opts) {
|
|
168
|
+
const q = query.toLowerCase();
|
|
169
|
+
const byId = els.filter((e) => e.id && e.id.toLowerCase() === q);
|
|
170
|
+
if (byId.length) return disambiguate(byId, "id", opts, els);
|
|
171
|
+
const exact = els.filter((e) => e.label.toLowerCase() === q);
|
|
172
|
+
if (exact.length) return disambiguate(exact, "exact label", opts, els);
|
|
173
|
+
const sub = els.filter((e) => e.label.toLowerCase().includes(q));
|
|
174
|
+
if (sub.length) return disambiguate(sub, "label substring", opts, els);
|
|
175
|
+
let best = null;
|
|
176
|
+
let bestScore = 0;
|
|
177
|
+
for (const e of els) {
|
|
178
|
+
const s = fuzzyScore(e.label, query);
|
|
179
|
+
if (s > bestScore) {
|
|
180
|
+
bestScore = s;
|
|
181
|
+
best = e;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (best && bestScore >= .75) return {
|
|
185
|
+
el: best,
|
|
186
|
+
via: `fuzzy ${bestScore.toFixed(2)}`
|
|
187
|
+
};
|
|
188
|
+
return { el: null };
|
|
189
|
+
}
|
|
190
|
+
const na = (s) => s.toLowerCase().replace(/[’']/g, "'").trim();
|
|
191
|
+
const ALERT_ACCEPT = [
|
|
192
|
+
"allow while using app",
|
|
193
|
+
"always allow",
|
|
194
|
+
"allow",
|
|
195
|
+
"ok",
|
|
196
|
+
"yes",
|
|
197
|
+
"continue",
|
|
198
|
+
"allow once",
|
|
199
|
+
"turn on",
|
|
200
|
+
"enable",
|
|
201
|
+
"agree",
|
|
202
|
+
"accept",
|
|
203
|
+
"got it",
|
|
204
|
+
"join"
|
|
205
|
+
];
|
|
206
|
+
const ALERT_DISMISS = [
|
|
207
|
+
"don't allow",
|
|
208
|
+
"not now",
|
|
209
|
+
"cancel",
|
|
210
|
+
"no thanks",
|
|
211
|
+
"no",
|
|
212
|
+
"deny",
|
|
213
|
+
"dismiss",
|
|
214
|
+
"later",
|
|
215
|
+
"skip",
|
|
216
|
+
"don't"
|
|
217
|
+
];
|
|
218
|
+
function pickAlertButton(buttons, action) {
|
|
219
|
+
const prefs = action === "accept" ? ALERT_ACCEPT : ALERT_DISMISS;
|
|
220
|
+
for (const p of prefs) {
|
|
221
|
+
const hit = buttons.find((b) => na(b.label) === p);
|
|
222
|
+
if (hit) return hit;
|
|
223
|
+
}
|
|
224
|
+
for (const p of prefs) {
|
|
225
|
+
const hit = buttons.find((b) => na(b.label).includes(p));
|
|
226
|
+
if (hit) return hit;
|
|
227
|
+
}
|
|
228
|
+
if (action === "accept") return buttons.find((b) => !ALERT_DISMISS.some((d) => na(b.label).includes(d)));
|
|
229
|
+
return buttons[0];
|
|
230
|
+
}
|
|
231
|
+
function describeAlert(info) {
|
|
232
|
+
return `${info.title}${info.message ? ` ${info.message}` : ""}`.trim() + (info.buttons.length ? ` [buttons: ${info.buttons.map((b) => b.label).join(", ")}]` : "");
|
|
233
|
+
}
|
|
234
|
+
function normRef(ref) {
|
|
235
|
+
return ref.startsWith("@") ? ref : `@${ref}`;
|
|
236
|
+
}
|
|
237
|
+
/** Render any thrown value as a one-line message (appends `details.hint` when present). */
|
|
238
|
+
function describeError(error) {
|
|
239
|
+
if (error instanceof Error && error.message) {
|
|
240
|
+
const hint = error.details?.hint;
|
|
241
|
+
return hint ? `${error.message} (${hint})` : error.message;
|
|
242
|
+
}
|
|
243
|
+
return String(error);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The device core: one instance = one device's observe/resolve/act state,
|
|
247
|
+
* driving one {@link DeviceBackend}. Everything here is portable — no
|
|
248
|
+
* runtime-specific globals and no image libraries — so it runs under Node.
|
|
249
|
+
* The harness's DeviceContext subclasses this and adds the cursor +
|
|
250
|
+
* live-viewer layer via the onCacheUpdated hook.
|
|
251
|
+
*/
|
|
252
|
+
var DeviceCore = class {
|
|
253
|
+
/** The backend this core drives. */
|
|
254
|
+
backend;
|
|
255
|
+
cachedNodes = [];
|
|
256
|
+
cachedViewport = {
|
|
257
|
+
width: 390,
|
|
258
|
+
height: 844
|
|
259
|
+
};
|
|
260
|
+
lastApp = {};
|
|
261
|
+
lastRender = null;
|
|
262
|
+
cacheAt = 0;
|
|
263
|
+
constructor(backend) {
|
|
264
|
+
this.backend = backend;
|
|
265
|
+
}
|
|
266
|
+
onCacheUpdated() {}
|
|
267
|
+
/**
|
|
268
|
+
* Canonical post-action report for LLM tool results, shared by every tool
|
|
269
|
+
* surface (agent tools + MCP): verdict from the action's own evidence, then a
|
|
270
|
+
* delta-rendered view of the screen it left behind.
|
|
271
|
+
*/
|
|
272
|
+
async renderActionResult(prefix, evidence, refresh = false) {
|
|
273
|
+
if (refresh) await this.observe();
|
|
274
|
+
return `${prefix}${evidence?.detail ? ` (${evidence.detail})` : ""}\n\nCurrent screen (app: ${this.currentApp() ?? "unknown"}):\n${this.renderObservation()}`;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Render the current cached screen for the LLM. full=true (or a structural
|
|
278
|
+
* change since last render) yields the complete compressed tree; otherwise a
|
|
279
|
+
* compact delta. Always updates the baseline.
|
|
280
|
+
*/
|
|
281
|
+
renderObservation(full = false) {
|
|
282
|
+
const app = this.lastApp.app;
|
|
283
|
+
const { kept, above, below, suppressFocused, vw } = keptViewportNodes(this.cachedNodes);
|
|
284
|
+
const keys = kept.map(elementKey);
|
|
285
|
+
const lineByKey = /* @__PURE__ */ new Map();
|
|
286
|
+
for (const n of kept) lineByKey.set(elementKey(n), formatNode(n, {
|
|
287
|
+
suppressFocused,
|
|
288
|
+
vw
|
|
289
|
+
}));
|
|
290
|
+
if (!full && this.lastRender != null && this.lastRender.app === app && arraysEqual(this.lastRender.keys, keys) && lineByKey.size === keys.length && this.lastRender.lineByKey.size === this.lastRender.keys.length && this.lastRender) {
|
|
291
|
+
const changed = [];
|
|
292
|
+
for (const key of keys) {
|
|
293
|
+
const now = lineByKey.get(key);
|
|
294
|
+
if (this.lastRender.lineByKey.get(key) !== now) changed.push(`~ ${now}`);
|
|
295
|
+
}
|
|
296
|
+
this.lastRender = {
|
|
297
|
+
app,
|
|
298
|
+
keys,
|
|
299
|
+
lineByKey
|
|
300
|
+
};
|
|
301
|
+
if (changed.length === 0) return `Screen unchanged since last observation (${keys.length} elements).`;
|
|
302
|
+
return `Same screen; ${changed.length} of ${keys.length} element(s) changed:\n${changed.join("\n")}\n(other elements and their @refs unchanged)`;
|
|
303
|
+
}
|
|
304
|
+
this.lastRender = {
|
|
305
|
+
app,
|
|
306
|
+
keys,
|
|
307
|
+
lineByKey
|
|
308
|
+
};
|
|
309
|
+
const lines = kept.map((n) => formatNode(n, {
|
|
310
|
+
suppressFocused,
|
|
311
|
+
vw
|
|
312
|
+
}));
|
|
313
|
+
if (above > 0) lines.unshift(`[${above} elements above the viewport — scroll up to reach them]`);
|
|
314
|
+
if (below > 0) lines.push(`[${below} elements below the viewport — scroll down to reach them]`);
|
|
315
|
+
return lines.join("\n");
|
|
316
|
+
}
|
|
317
|
+
cacheSnapshot(nodes) {
|
|
318
|
+
this.cachedNodes = nodes;
|
|
319
|
+
const root = nodes.find((n) => (n.type === "Application" || n.type === "Window") && n.rect);
|
|
320
|
+
if (root?.rect) this.cachedViewport = {
|
|
321
|
+
width: root.rect.width,
|
|
322
|
+
height: root.rect.height
|
|
323
|
+
};
|
|
324
|
+
this.cacheAt = Date.now();
|
|
325
|
+
this.onCacheUpdated();
|
|
326
|
+
}
|
|
327
|
+
async refreshCache() {
|
|
328
|
+
const snap = await this.backend.snapshot({ interactiveOnly: true });
|
|
329
|
+
this.cacheSnapshot(snap.nodes);
|
|
330
|
+
this.lastApp = {
|
|
331
|
+
app: snap.appName,
|
|
332
|
+
bundleId: snap.appBundleId
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
cacheSignature() {
|
|
336
|
+
const head = this.cachedNodes.slice(0, 16).map((n) => `${n.ref ?? ""}:${n.label ?? n.type ?? ""}`).join("|");
|
|
337
|
+
return `${this.cachedNodes.length}#${head}`;
|
|
338
|
+
}
|
|
339
|
+
/** Milliseconds since the cache was last refreshed (Infinity before first). */
|
|
340
|
+
cacheAgeMs() {
|
|
341
|
+
return this.cacheAt === 0 ? Number.POSITIVE_INFINITY : Date.now() - this.cacheAt;
|
|
342
|
+
}
|
|
343
|
+
/** Public fingerprint of the cached tree — the settle/verify signal. */
|
|
344
|
+
stateSignature() {
|
|
345
|
+
return this.cacheSignature();
|
|
346
|
+
}
|
|
347
|
+
/** The cached screen as a compressed {@link Observation} (no new snapshot). */
|
|
348
|
+
currentElements() {
|
|
349
|
+
return {
|
|
350
|
+
app: this.lastApp.app,
|
|
351
|
+
bundleId: this.lastApp.bundleId,
|
|
352
|
+
truncated: false,
|
|
353
|
+
elements: compressNodes(this.cachedNodes)
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* The frontmost app name from the last snapshot — cheap label without paying
|
|
358
|
+
* the full tree compression (used by the delta renderer's callers).
|
|
359
|
+
*/
|
|
360
|
+
currentApp() {
|
|
361
|
+
return this.lastApp.app;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Structured interactive elements from the current cache — the crawler taps
|
|
365
|
+
* these by label (refs are only valid within one snapshot).
|
|
366
|
+
*/
|
|
367
|
+
interactiveElements() {
|
|
368
|
+
const out = [];
|
|
369
|
+
const seen = /* @__PURE__ */ new Set();
|
|
370
|
+
for (const n of this.cachedNodes) {
|
|
371
|
+
if (!n.ref || !n.rect) continue;
|
|
372
|
+
const role = n.role ?? n.type ?? "";
|
|
373
|
+
if (!TAPPABLE.has(role)) continue;
|
|
374
|
+
const label = (n.label ?? n.identifier ?? "").trim();
|
|
375
|
+
if (!label) continue;
|
|
376
|
+
if (n.rect.width >= this.cachedViewport.width && n.rect.height >= this.cachedViewport.height) continue;
|
|
377
|
+
const key = `${role}:${label}`;
|
|
378
|
+
if (seen.has(key)) continue;
|
|
379
|
+
seen.add(key);
|
|
380
|
+
out.push({
|
|
381
|
+
ref: normRef(n.ref),
|
|
382
|
+
label,
|
|
383
|
+
role,
|
|
384
|
+
value: n.value,
|
|
385
|
+
rect: n.rect,
|
|
386
|
+
id: n.identifier?.trim() || void 0,
|
|
387
|
+
enabled: n.enabled,
|
|
388
|
+
blocked: n.interactionBlocked
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Editable text inputs from the current cache. These roles are deliberately
|
|
395
|
+
* excluded from interactiveElements() (they aren't "tap" targets), so the
|
|
396
|
+
* input primitive needs its own accessor to find a search bar / text field to
|
|
397
|
+
* focus. includeMultiline adds TextView bodies for form/compose filling.
|
|
398
|
+
*/
|
|
399
|
+
inputFields(includeMultiline = false) {
|
|
400
|
+
const editable = includeMultiline ? EDITABLE_MULTILINE : EDITABLE;
|
|
401
|
+
const out = [];
|
|
402
|
+
for (const n of this.cachedNodes) {
|
|
403
|
+
if (!n.ref || !n.rect) continue;
|
|
404
|
+
const role = n.role ?? n.type ?? "";
|
|
405
|
+
if (!editable.has(role)) continue;
|
|
406
|
+
out.push({
|
|
407
|
+
ref: normRef(n.ref),
|
|
408
|
+
label: (n.label ?? n.identifier ?? "").trim(),
|
|
409
|
+
role,
|
|
410
|
+
value: n.value,
|
|
411
|
+
rect: n.rect,
|
|
412
|
+
enabled: n.enabled,
|
|
413
|
+
blocked: n.interactionBlocked
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Run the resolution ladder against the current cache only — no scrolling,
|
|
420
|
+
* no fresh snapshot. resolveElement drives this per scroll step.
|
|
421
|
+
*/
|
|
422
|
+
resolveInCache(query, opts = {}) {
|
|
423
|
+
return matchInElements(this.interactiveElements(), query, opts);
|
|
424
|
+
}
|
|
425
|
+
/** The full ladder: scroll to top, then match + scroll down until found or stable. */
|
|
426
|
+
async resolveElement(query, opts = {}) {
|
|
427
|
+
await this.scrollToTop();
|
|
428
|
+
for (let i = 0; i < 10; i++) {
|
|
429
|
+
const r = this.resolveInCache(query, opts);
|
|
430
|
+
if (r.el || r.candidates) return r;
|
|
431
|
+
const before = this.screenSignature();
|
|
432
|
+
await this.scroll("down");
|
|
433
|
+
await this.observe();
|
|
434
|
+
if (this.screenSignature() === before) break;
|
|
435
|
+
}
|
|
436
|
+
return { el: null };
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Compatibility wrapper: single best element or null (read paths — ask/read a
|
|
440
|
+
* value — where picking the first match is low-risk). Tap paths use
|
|
441
|
+
* resolveElement directly and honor the ambiguity contract.
|
|
442
|
+
*/
|
|
443
|
+
async findElement(labelSubstring) {
|
|
444
|
+
const r = await this.resolveElement(labelSubstring);
|
|
445
|
+
return r.el ?? r.candidates?.[0] ?? null;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Read a labeled value. iOS list rows fold the value into the label
|
|
449
|
+
* ("iOS Version, 26.1") or expose it as a Switch value ("1"/"0"); handle both.
|
|
450
|
+
*/
|
|
451
|
+
async readField(labelSubstring) {
|
|
452
|
+
const el = await this.findElement(labelSubstring);
|
|
453
|
+
if (!el) return null;
|
|
454
|
+
if (el.value != null && el.value !== "") return el.value;
|
|
455
|
+
const idx = el.label.toLowerCase().indexOf(labelSubstring.toLowerCase());
|
|
456
|
+
if (idx < 0) return el.label;
|
|
457
|
+
return el.label.slice(idx + labelSubstring.length).replace(/^[\s,:]+/, "").trim() || el.label;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* A structural fingerprint of the current screen that is stable across
|
|
461
|
+
* dynamic content (times, battery, values) — it keys the crawler's graph
|
|
462
|
+
* nodes so the same screen is recognized regardless of transient text.
|
|
463
|
+
*/
|
|
464
|
+
screenSignature() {
|
|
465
|
+
const title = this.cachedNodes.find((n) => (n.type === "NavigationBar" || n.role === "NavigationBar") && n.label)?.label ?? "";
|
|
466
|
+
const labels = this.cachedNodes.filter((n) => TAPPABLE.has(n.role ?? n.type ?? "") && (n.label ?? "").trim()).map((n) => `${n.role ?? n.type}:${(n.label ?? "").trim()}`).sort();
|
|
467
|
+
const uniq = [...new Set(labels)];
|
|
468
|
+
return `${this.lastApp.bundleId ?? ""}|${title}|${uniq.join("~")}`;
|
|
469
|
+
}
|
|
470
|
+
/** Navigation-bar title of the cached screen ('' when absent). */
|
|
471
|
+
screenTitle() {
|
|
472
|
+
return this.cachedNodes.find((n) => (n.type === "NavigationBar" || n.role === "NavigationBar") && n.label)?.label ?? "";
|
|
473
|
+
}
|
|
474
|
+
/** Take one fresh snapshot into the cache and return the compressed observation. */
|
|
475
|
+
async observe() {
|
|
476
|
+
try {
|
|
477
|
+
await this.refreshCache();
|
|
478
|
+
return this.currentElements();
|
|
479
|
+
} catch (error) {
|
|
480
|
+
if (error instanceof SessionNotFoundError || error?.code === "SESSION_NOT_FOUND") return {
|
|
481
|
+
truncated: false,
|
|
482
|
+
elements: "No app session is active yet. Use open_app to launch an app first."
|
|
483
|
+
};
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Open an app by name/bundle id. relaunch forces a fresh launch (clean
|
|
489
|
+
* initial screen) instead of just foregrounding — iOS keeps an app's
|
|
490
|
+
* navigation state across foregrounding, so primitives that need a known
|
|
491
|
+
* starting screen pass relaunch=true.
|
|
492
|
+
*/
|
|
493
|
+
async openApp(app, relaunch = false) {
|
|
494
|
+
const result = await this.backend.openApp({
|
|
495
|
+
app,
|
|
496
|
+
relaunch
|
|
497
|
+
});
|
|
498
|
+
return `Opened ${result.appName ?? app} (${result.appBundleId ?? "unknown bundle"})`;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Level-2 of the action ladder: deep links beat tap sequences when a URL route
|
|
502
|
+
* exists (maps://, app schemes, https:// universal links). XCTest sessions are
|
|
503
|
+
* app-scoped, so a link that opens a different app must re-scope the session
|
|
504
|
+
* to that app or observations keep tracking the old one.
|
|
505
|
+
*/
|
|
506
|
+
async openUrl(url, app) {
|
|
507
|
+
const target = app ?? await this.currentBundleId();
|
|
508
|
+
await this.backend.openApp(target ? {
|
|
509
|
+
app: target,
|
|
510
|
+
url
|
|
511
|
+
} : { url });
|
|
512
|
+
return app ? `Opened ${url} in ${app}` : `Opened ${url}`;
|
|
513
|
+
}
|
|
514
|
+
async currentBundleId() {
|
|
515
|
+
try {
|
|
516
|
+
return (await this.backend.snapshot({
|
|
517
|
+
interactiveOnly: true,
|
|
518
|
+
depth: 1
|
|
519
|
+
})).appBundleId;
|
|
520
|
+
} catch {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/** List installed app bundle ids. */
|
|
525
|
+
async listApps() {
|
|
526
|
+
return this.backend.listApps();
|
|
527
|
+
}
|
|
528
|
+
async tapAndDiff(tap) {
|
|
529
|
+
const before = this.cacheSignature();
|
|
530
|
+
await tap();
|
|
531
|
+
await this.refreshCache();
|
|
532
|
+
const changed = before !== this.cacheSignature();
|
|
533
|
+
return {
|
|
534
|
+
changed,
|
|
535
|
+
detail: changed ? "screen changed" : "screen did NOT change — the action may have had no effect"
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
/** Tap an element ref, self-diffing the cache to report whether the screen changed. */
|
|
539
|
+
async press(ref) {
|
|
540
|
+
try {
|
|
541
|
+
return await this.tapAndDiff(() => this.backend.press({ ref }));
|
|
542
|
+
} catch (error) {
|
|
543
|
+
if (!/off-?screen/i.test(describeError(error))) throw error;
|
|
544
|
+
const mid = this.visibleMidpoint(this.findNode(ref)?.rect);
|
|
545
|
+
if (!mid) throw error;
|
|
546
|
+
return this.tapAndDiff(() => this.backend.press({
|
|
547
|
+
x: mid.x,
|
|
548
|
+
y: mid.y
|
|
549
|
+
}));
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
visibleMidpoint(rect) {
|
|
553
|
+
if (!rect) return null;
|
|
554
|
+
const x1 = Math.max(rect.x, 0);
|
|
555
|
+
const y1 = Math.max(rect.y, 0);
|
|
556
|
+
const x2 = Math.min(rect.x + rect.width, this.cachedViewport.width);
|
|
557
|
+
const y2 = Math.min(rect.y + rect.height, this.cachedViewport.height);
|
|
558
|
+
if (x2 <= x1 || y2 <= y1) return null;
|
|
559
|
+
return {
|
|
560
|
+
x: Math.round((x1 + x2) / 2),
|
|
561
|
+
y: Math.round((y1 + y2) / 2)
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
onScreen(rect) {
|
|
565
|
+
if (!rect) return false;
|
|
566
|
+
const cy = rect.y + rect.height / 2;
|
|
567
|
+
return cy > 56 && cy < this.cachedViewport.height - 44 && rect.x < this.cachedViewport.width;
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Open an app and walk its nav stack back to the root (dismissing modals), so
|
|
571
|
+
* map-based navigation always starts from a known origin.
|
|
572
|
+
*/
|
|
573
|
+
async goToRoot(app) {
|
|
574
|
+
const DISMISS = [
|
|
575
|
+
"Close",
|
|
576
|
+
"Cancel",
|
|
577
|
+
"Done",
|
|
578
|
+
"Not Now",
|
|
579
|
+
"Dismiss"
|
|
580
|
+
];
|
|
581
|
+
await this.observe().catch(() => void 0);
|
|
582
|
+
if (this.lastApp.bundleId !== app) {
|
|
583
|
+
await this.openApp(app);
|
|
584
|
+
await this.observe();
|
|
585
|
+
}
|
|
586
|
+
await this.clearBlockingAlerts("accept");
|
|
587
|
+
for (let i = 0; i < 12; i++) {
|
|
588
|
+
const els = this.interactiveElements();
|
|
589
|
+
const back = els.find((e) => e.role === "Button" && !!e.rect && e.rect.x < 70 && e.rect.y < 110);
|
|
590
|
+
const dismiss = els.find((e) => e.role === "Button" && DISMISS.includes(e.label));
|
|
591
|
+
const target = back ?? dismiss;
|
|
592
|
+
if (!target) break;
|
|
593
|
+
await this.press(target.ref);
|
|
594
|
+
await this.observe();
|
|
595
|
+
}
|
|
596
|
+
await this.scrollToTop();
|
|
597
|
+
}
|
|
598
|
+
/** Height of the cached viewport in points. */
|
|
599
|
+
viewportHeight() {
|
|
600
|
+
return this.cachedViewport.height;
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Vertical span of interactive content in the current cache. Used to decide
|
|
604
|
+
* whether scrolling is even necessary — scroll gestures cost ~2s each, so
|
|
605
|
+
* skipping them on screens that already fit is the single biggest crawl
|
|
606
|
+
* speedup.
|
|
607
|
+
*/
|
|
608
|
+
contentBounds() {
|
|
609
|
+
let minY = Infinity;
|
|
610
|
+
let maxY = -Infinity;
|
|
611
|
+
for (const n of this.cachedNodes) {
|
|
612
|
+
if (!n.rect) continue;
|
|
613
|
+
if (!TAPPABLE.has(n.role ?? n.type ?? "")) continue;
|
|
614
|
+
minY = Math.min(minY, n.rect.y);
|
|
615
|
+
maxY = Math.max(maxY, n.rect.y + n.rect.height);
|
|
616
|
+
}
|
|
617
|
+
return {
|
|
618
|
+
minY: minY === Infinity ? 0 : minY,
|
|
619
|
+
maxY: maxY === -Infinity ? 0 : maxY
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Tapping the status bar scrolls the active scroll view to the top — native
|
|
624
|
+
* iOS behavior, one fast tap instead of multiple ~2s scroll gestures. Falls
|
|
625
|
+
* back to gesture scrolling if the tap doesn't take.
|
|
626
|
+
*/
|
|
627
|
+
async scrollToTop() {
|
|
628
|
+
try {
|
|
629
|
+
await this.backend.press({
|
|
630
|
+
x: Math.round(this.cachedViewport.width / 2),
|
|
631
|
+
y: 6
|
|
632
|
+
});
|
|
633
|
+
await this.observe();
|
|
634
|
+
return;
|
|
635
|
+
} catch {}
|
|
636
|
+
for (let i = 0; i < 6; i++) {
|
|
637
|
+
const before = this.screenSignature();
|
|
638
|
+
await this.scroll("up");
|
|
639
|
+
await this.observe();
|
|
640
|
+
if (this.screenSignature() === before) return;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Tap an element by its label, scrolling it into view first if it's
|
|
645
|
+
* off-screen. The crawler and the map navigator use this so a target below
|
|
646
|
+
* the fold (a long Settings list) is still reachable. Re-resolves the ref
|
|
647
|
+
* after each scroll.
|
|
648
|
+
*/
|
|
649
|
+
async tapLabel(label) {
|
|
650
|
+
for (let i = 0; i < 12; i++) {
|
|
651
|
+
const el = this.interactiveElements().find((e) => e.label === label);
|
|
652
|
+
if (el && this.onScreen(el.rect)) try {
|
|
653
|
+
await this.press(el.ref);
|
|
654
|
+
return true;
|
|
655
|
+
} catch (error) {
|
|
656
|
+
if (!/off-?screen/i.test(describeError(error))) throw error;
|
|
657
|
+
}
|
|
658
|
+
const dir = el?.rect ? el.rect.y < 0 ? "up" : "down" : i < 5 ? "up" : "down";
|
|
659
|
+
await this.scroll(dir);
|
|
660
|
+
await this.observe();
|
|
661
|
+
}
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Vision-path fallback: tap raw coordinates when the accessibility tree is
|
|
666
|
+
* missing or wrong (canvas, games, custom controls). Coordinates are in the
|
|
667
|
+
* same space as observe()'s rects and the screenshot pixels (@1x points).
|
|
668
|
+
*/
|
|
669
|
+
async pressAt(x, y) {
|
|
670
|
+
return this.tapAndDiff(() => this.backend.press({
|
|
671
|
+
x,
|
|
672
|
+
y
|
|
673
|
+
}));
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Coordinate drag: touch down at (x,y), move by (dx,dy). The primitive for
|
|
677
|
+
* controls a tap can't operate — picker wheels (drag vertically on the wheel
|
|
678
|
+
* column), sliders, and custom carousels. Same coordinate space as rects.
|
|
679
|
+
*/
|
|
680
|
+
async pan(x, y, dx, dy, durationMs) {
|
|
681
|
+
await this.backend.pan(x, y, dx, dy, durationMs);
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Set-of-Marks visual observation: screenshot with `@ref` labels drawn on the
|
|
685
|
+
* elements, so a vision model can ground itself in pixels and still act by ref.
|
|
686
|
+
*/
|
|
687
|
+
async screenshotWithRefs(path) {
|
|
688
|
+
return (await this.backend.screenshot({
|
|
689
|
+
path,
|
|
690
|
+
overlayRefs: true
|
|
691
|
+
})).path;
|
|
692
|
+
}
|
|
693
|
+
async currentViewport() {
|
|
694
|
+
try {
|
|
695
|
+
const root = (await this.backend.snapshot({
|
|
696
|
+
interactiveOnly: true,
|
|
697
|
+
depth: 1
|
|
698
|
+
})).nodes.find((n) => (n.type === "Application" || n.type === "Window") && n.rect);
|
|
699
|
+
return {
|
|
700
|
+
width: root?.rect?.width ?? 390,
|
|
701
|
+
height: root?.rect?.height ?? 844
|
|
702
|
+
};
|
|
703
|
+
} catch {
|
|
704
|
+
return {
|
|
705
|
+
width: 390,
|
|
706
|
+
height: 844
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
/** Long-press an element ref. */
|
|
711
|
+
async longPress(ref, durationMs = 800) {
|
|
712
|
+
await this.backend.longPress(ref, durationMs);
|
|
713
|
+
}
|
|
714
|
+
/** Focus a field and replace its text, self-diffing the cache for evidence. */
|
|
715
|
+
async fill(ref, text) {
|
|
716
|
+
return this.tapAndDiff(() => this.backend.fill(ref, text));
|
|
717
|
+
}
|
|
718
|
+
/** Type into whatever currently has keyboard focus. */
|
|
719
|
+
async typeText(text) {
|
|
720
|
+
await this.backend.typeText(text);
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Press the keyboard's return/go key. Submits a search bar that acts on
|
|
724
|
+
* Return (Safari's address bar, web forms) rather than filtering results as
|
|
725
|
+
* you type.
|
|
726
|
+
*/
|
|
727
|
+
async pressReturn() {
|
|
728
|
+
await this.backend.pressKey("return");
|
|
729
|
+
}
|
|
730
|
+
/** Scroll the active scroll view one step. */
|
|
731
|
+
async scroll(direction) {
|
|
732
|
+
await this.backend.scroll(direction);
|
|
733
|
+
}
|
|
734
|
+
/** Block until `text` appears on screen; returns a confirmation note. */
|
|
735
|
+
async waitForText(text, timeoutMs = 5e3) {
|
|
736
|
+
await this.backend.waitForText(text, timeoutMs);
|
|
737
|
+
return `"${text}" appeared on screen`;
|
|
738
|
+
}
|
|
739
|
+
alertFromCache() {
|
|
740
|
+
const alert = this.cachedNodes.find((n) => (n.type ?? n.role) === "Alert");
|
|
741
|
+
if (!alert) return null;
|
|
742
|
+
const texts = this.cachedNodes.filter((n) => (n.type ?? n.role) === "StaticText" && n.label).map((n) => (n.label ?? "").trim());
|
|
743
|
+
const buttons = [];
|
|
744
|
+
for (const n of this.cachedNodes) {
|
|
745
|
+
if (!n.ref || !n.rect || (n.role ?? n.type) !== "Button") continue;
|
|
746
|
+
const label = (n.label ?? n.identifier ?? "").trim();
|
|
747
|
+
if (label) buttons.push({
|
|
748
|
+
ref: normRef(n.ref),
|
|
749
|
+
label,
|
|
750
|
+
role: "Button",
|
|
751
|
+
value: n.value,
|
|
752
|
+
rect: n.rect,
|
|
753
|
+
enabled: n.enabled,
|
|
754
|
+
blocked: n.interactionBlocked
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
const title = (alert.label ?? texts[0] ?? "Alert").trim();
|
|
758
|
+
return {
|
|
759
|
+
title,
|
|
760
|
+
message: texts.find((t) => t !== title),
|
|
761
|
+
buttons
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* System dialogs (permissions, sign-in prompts) block everything else; the
|
|
766
|
+
* driver exposes them as a first-class action instead of hoping a tap lands.
|
|
767
|
+
*/
|
|
768
|
+
async handleAlert(action) {
|
|
769
|
+
await this.observe().catch(() => void 0);
|
|
770
|
+
const info = this.alertFromCache();
|
|
771
|
+
if (info) {
|
|
772
|
+
const description = describeAlert(info);
|
|
773
|
+
if (action === "get") return {
|
|
774
|
+
present: true,
|
|
775
|
+
description
|
|
776
|
+
};
|
|
777
|
+
const btn = pickAlertButton(info.buttons, action);
|
|
778
|
+
if (!btn?.rect) return {
|
|
779
|
+
present: true,
|
|
780
|
+
handled: false,
|
|
781
|
+
description
|
|
782
|
+
};
|
|
783
|
+
await this.pressAt(btn.rect.x + btn.rect.width / 2, btn.rect.y + btn.rect.height / 2);
|
|
784
|
+
await this.observe().catch(() => void 0);
|
|
785
|
+
const still = this.alertFromCache();
|
|
786
|
+
return {
|
|
787
|
+
present: true,
|
|
788
|
+
handled: still == null || still.title !== info.title,
|
|
789
|
+
button: btn.label,
|
|
790
|
+
description
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
try {
|
|
794
|
+
const result = await this.backend.systemAlert(action);
|
|
795
|
+
const alert = result.alert;
|
|
796
|
+
return {
|
|
797
|
+
present: alert != null,
|
|
798
|
+
handled: result.handled,
|
|
799
|
+
button: result.button,
|
|
800
|
+
description: alert ? `${alert.title ?? ""} ${alert.message ?? ""}`.trim() + (alert.buttons?.length ? ` [buttons: ${alert.buttons.join(", ")}]` : "") : void 0
|
|
801
|
+
};
|
|
802
|
+
} catch (error) {
|
|
803
|
+
if (/alert not found/i.test(describeError(error))) return { present: false };
|
|
804
|
+
throw error;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Clear the launch permission gauntlet — real apps stack location /
|
|
809
|
+
* notification / tracking prompts on first open, each blocking the app.
|
|
810
|
+
* Grants by default so the crawl sees the most surface. Returns the buttons
|
|
811
|
+
* tapped. Bounded so a non-clearing dialog can't loop forever.
|
|
812
|
+
*/
|
|
813
|
+
async clearBlockingAlerts(action = "accept", max = 6) {
|
|
814
|
+
const tapped = [];
|
|
815
|
+
let lastTitle = "";
|
|
816
|
+
for (let i = 0; i < max; i++) {
|
|
817
|
+
if (!this.alertFromCache()) {
|
|
818
|
+
await this.observe().catch(() => void 0);
|
|
819
|
+
if (!this.alertFromCache()) break;
|
|
820
|
+
}
|
|
821
|
+
const r = await this.handleAlert(action);
|
|
822
|
+
if (!r.present || !r.button || !r.handled) break;
|
|
823
|
+
if (r.description === lastTitle) break;
|
|
824
|
+
lastTitle = r.description ?? "";
|
|
825
|
+
tapped.push(r.button);
|
|
826
|
+
}
|
|
827
|
+
return tapped;
|
|
828
|
+
}
|
|
829
|
+
/** Go to the home screen. */
|
|
830
|
+
async goHome() {
|
|
831
|
+
await this.backend.home();
|
|
832
|
+
}
|
|
833
|
+
/** Navigate back (nav-bar back / hardware back). */
|
|
834
|
+
async goBack() {
|
|
835
|
+
await this.backend.back();
|
|
836
|
+
}
|
|
837
|
+
/** Save a screenshot to `path`; returns the written path. */
|
|
838
|
+
async screenshot(path) {
|
|
839
|
+
return (await this.backend.screenshot({ path })).path;
|
|
840
|
+
}
|
|
841
|
+
/** Close the backend's transport session. */
|
|
842
|
+
async closeSession() {
|
|
843
|
+
await this.backend.closeSession();
|
|
844
|
+
}
|
|
845
|
+
findNode(ref) {
|
|
846
|
+
const want = normRef(ref);
|
|
847
|
+
return this.cachedNodes.find((n) => n.ref && normRef(n.ref) === want);
|
|
848
|
+
}
|
|
849
|
+
async ensureCache() {
|
|
850
|
+
if (this.cachedNodes.length === 0) await this.observe();
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
//#endregion
|
|
854
|
+
//#region src/secrets.ts
|
|
855
|
+
const MIN_SECRET_LENGTH = 4;
|
|
856
|
+
/**
|
|
857
|
+
* `%variable%` secret substitution (docs/19 §API shape, Stagehand's pattern):
|
|
858
|
+
* the model/caller plans against NAMES; values are injected at the last moment
|
|
859
|
+
* before backend.fill/typeText and never rendered into Actions, results,
|
|
860
|
+
* observations, or traces. Redaction is best-effort belt-and-braces; the hard
|
|
861
|
+
* guarantee is at the substitution point — values never enter stored Actions
|
|
862
|
+
* by construction.
|
|
863
|
+
*/
|
|
864
|
+
var SecretStore = class SecretStore {
|
|
865
|
+
values = /* @__PURE__ */ new Map();
|
|
866
|
+
constructor(values) {
|
|
867
|
+
if (values) for (const [k, v] of Object.entries(values)) this.set(k, v);
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Store a secret under `name`. Rejects values shorter than 4 chars — a
|
|
871
|
+
* 2-char secret would redact innocent UI text everywhere.
|
|
872
|
+
*/
|
|
873
|
+
set(name, value) {
|
|
874
|
+
if (value.length < MIN_SECRET_LENGTH) throw new Error(`secret "${name}" is too short (<${MIN_SECRET_LENGTH} chars) to redact safely`);
|
|
875
|
+
this.values.set(name, value);
|
|
876
|
+
}
|
|
877
|
+
/** The stored secret NAMES (never the values). */
|
|
878
|
+
names() {
|
|
879
|
+
return [...this.values.keys()];
|
|
880
|
+
}
|
|
881
|
+
/** %name% → value. Unknown %x% stays literal. */
|
|
882
|
+
substitute(text) {
|
|
883
|
+
return text.replace(/%([A-Za-z0-9_-]+)%/g, (whole, name) => this.values.get(name) ?? whole);
|
|
884
|
+
}
|
|
885
|
+
/** value → %name% across outbound text (messages, rendered observations). */
|
|
886
|
+
redact(text) {
|
|
887
|
+
let out = text;
|
|
888
|
+
for (const [name, value] of this.values) out = out.split(value).join(`%${name}%`);
|
|
889
|
+
return out;
|
|
890
|
+
}
|
|
891
|
+
/** Per-call vars layered over the store (call-scoped, never persisted). */
|
|
892
|
+
withOverrides(vars) {
|
|
893
|
+
if (!vars || Object.keys(vars).length === 0) return this;
|
|
894
|
+
const merged = new SecretStore();
|
|
895
|
+
for (const [k, v] of this.values) merged.values.set(k, v);
|
|
896
|
+
for (const [k, v] of Object.entries(vars)) merged.set(k, v);
|
|
897
|
+
return merged;
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
//#endregion
|
|
901
|
+
//#region src/actions.ts
|
|
902
|
+
function throwIfAborted(signal) {
|
|
903
|
+
if (signal?.aborted) throw new AbortedError();
|
|
904
|
+
}
|
|
905
|
+
/** Race a backend promise against the caller's abort. The abandoned in-flight
|
|
906
|
+
* call may still land on the device — documented abort semantics (state
|
|
907
|
+
* indeterminate); no retry follows an abort. */
|
|
908
|
+
async function raceWithAbort(promise, signal) {
|
|
909
|
+
if (!signal) return promise;
|
|
910
|
+
throwIfAborted(signal);
|
|
911
|
+
let onAbort;
|
|
912
|
+
const aborted = new Promise((_, reject) => {
|
|
913
|
+
onAbort = () => reject(new AbortedError());
|
|
914
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
915
|
+
});
|
|
916
|
+
try {
|
|
917
|
+
return await Promise.race([promise, aborted]);
|
|
918
|
+
} finally {
|
|
919
|
+
if (onAbort) signal.removeEventListener("abort", onAbort);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
function sleep(ms, signal) {
|
|
923
|
+
return raceWithAbort(new Promise((r) => setTimeout(r, ms)), signal);
|
|
924
|
+
}
|
|
925
|
+
function queryFor(el) {
|
|
926
|
+
return el.id ? { id: el.id } : { label: el.label };
|
|
927
|
+
}
|
|
928
|
+
function provenance(core, el, via) {
|
|
929
|
+
return {
|
|
930
|
+
...via === void 0 ? {} : { via },
|
|
931
|
+
label: el.label,
|
|
932
|
+
role: el.role,
|
|
933
|
+
...el.rect === void 0 ? {} : { rect: el.rect },
|
|
934
|
+
...core.currentApp() === void 0 ? {} : { app: core.currentApp() },
|
|
935
|
+
...core.screenTitle() ? { screenTitle: core.screenTitle() } : {}
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
/** Synthesize portable actions from the CURRENT cache (observe-time). */
|
|
939
|
+
function toActions(core) {
|
|
940
|
+
const actions = [];
|
|
941
|
+
for (const el of core.interactiveElements()) {
|
|
942
|
+
const kind = el.role;
|
|
943
|
+
if (TAPPABLE.has(kind)) actions.push({
|
|
944
|
+
formatVersion: 0,
|
|
945
|
+
verb: "tap",
|
|
946
|
+
target: queryFor(el),
|
|
947
|
+
observed: provenance(core, el, el.id ? "id" : "exact label")
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
for (const el of core.inputFields(true)) actions.push({
|
|
951
|
+
formatVersion: 0,
|
|
952
|
+
verb: "fill",
|
|
953
|
+
target: queryFor(el),
|
|
954
|
+
params: { text: "" },
|
|
955
|
+
observed: provenance(core, el, el.id ? "id" : "exact label")
|
|
956
|
+
});
|
|
957
|
+
return actions;
|
|
958
|
+
}
|
|
959
|
+
const NO_SECRETS = new SecretStore();
|
|
960
|
+
/**
|
|
961
|
+
* Take one fresh snapshot and assemble the full {@link ObserveResult}:
|
|
962
|
+
* deduped element channel, secret-redacted rendered text, and a portable
|
|
963
|
+
* Action per tappable / input field.
|
|
964
|
+
*/
|
|
965
|
+
async function buildObserveResult(core, secrets = NO_SECRETS) {
|
|
966
|
+
const obs = await core.observe();
|
|
967
|
+
const redact = (s) => secrets.redact(s);
|
|
968
|
+
const seen = /* @__PURE__ */ new Set();
|
|
969
|
+
const elements = [];
|
|
970
|
+
for (const el of [...core.interactiveElements(), ...core.inputFields(true)]) {
|
|
971
|
+
if (seen.has(el.ref)) continue;
|
|
972
|
+
seen.add(el.ref);
|
|
973
|
+
elements.push({
|
|
974
|
+
...el,
|
|
975
|
+
label: redact(el.label),
|
|
976
|
+
...el.value === void 0 || el.value === null ? {} : { value: redact(el.value) }
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
return {
|
|
980
|
+
success: true,
|
|
981
|
+
message: elements.length ? `observed ${elements.length} interactive elements` : obs.elements.slice(0, 120),
|
|
982
|
+
...obs.app === void 0 ? {} : { app: obs.app },
|
|
983
|
+
...obs.bundleId === void 0 ? {} : { bundleId: obs.bundleId },
|
|
984
|
+
...core.screenTitle() ? { screenTitle: core.screenTitle() } : {},
|
|
985
|
+
elements,
|
|
986
|
+
rendered: redact(core.renderObservation(true)),
|
|
987
|
+
actions: toActions(core)
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
function toResolveOpts(q) {
|
|
991
|
+
return {
|
|
992
|
+
...q.role === void 0 ? {} : { role: q.role },
|
|
993
|
+
...q.near === void 0 ? {} : { near: q.near }
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
function queryText(q) {
|
|
997
|
+
const t = q.id ?? q.label;
|
|
998
|
+
if (t === void 0 || t === "") throw new ActionFailedError("action target needs an id or label");
|
|
999
|
+
return t;
|
|
1000
|
+
}
|
|
1001
|
+
function isActionable(el) {
|
|
1002
|
+
return el.enabled !== false && !el.blocked;
|
|
1003
|
+
}
|
|
1004
|
+
const CACHE_FRESH_MS = 2e3;
|
|
1005
|
+
const POLL_STEPS_MS = [
|
|
1006
|
+
150,
|
|
1007
|
+
300,
|
|
1008
|
+
600,
|
|
1009
|
+
800
|
|
1010
|
+
];
|
|
1011
|
+
/**
|
|
1012
|
+
* Resolve + auto-wait (docs/19: visible+hittable+enabled+settled on every
|
|
1013
|
+
* action, no caller sleep; docs/20 risk 2: must not double latency).
|
|
1014
|
+
* Fast path: a fresh cache resolving to an actionable target executes with
|
|
1015
|
+
* ZERO extra snapshots. Slow path: poll in place (never scroll — scrolling is
|
|
1016
|
+
* the ladder's job, and polling must not dismiss transient menus) until the
|
|
1017
|
+
* target is actionable AND the tree signature is stable between polls.
|
|
1018
|
+
*/
|
|
1019
|
+
async function resolveWithWait(core, q, opts, pool) {
|
|
1020
|
+
const signal = opts.signal;
|
|
1021
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 5e3);
|
|
1022
|
+
const text = queryText(q);
|
|
1023
|
+
const rOpts = toResolveOpts(q);
|
|
1024
|
+
const inCache = () => pool === "fields" ? matchInElements(core.inputFields(true), text, rOpts) : core.resolveInCache(text, rOpts);
|
|
1025
|
+
if (core.cacheAgeMs() < CACHE_FRESH_MS) {
|
|
1026
|
+
const r = inCache();
|
|
1027
|
+
if (r.el && isActionable(r.el)) return {
|
|
1028
|
+
ok: true,
|
|
1029
|
+
el: r.el,
|
|
1030
|
+
via: r.via ?? "cache",
|
|
1031
|
+
waited: void 0
|
|
1032
|
+
};
|
|
1033
|
+
if (r.candidates?.length) return {
|
|
1034
|
+
ok: false,
|
|
1035
|
+
result: ambiguityResult(text, r)
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
throwIfAborted(signal);
|
|
1039
|
+
let r;
|
|
1040
|
+
if (pool === "tappable") r = await raceWithAbort(core.resolveElement(text, rOpts), signal);
|
|
1041
|
+
else {
|
|
1042
|
+
await raceWithAbort(core.observe(), signal);
|
|
1043
|
+
r = inCache();
|
|
1044
|
+
}
|
|
1045
|
+
if (!r.el && r.candidates?.length) return {
|
|
1046
|
+
ok: false,
|
|
1047
|
+
result: ambiguityResult(text, r)
|
|
1048
|
+
};
|
|
1049
|
+
if (r.el && isActionable(r.el)) return {
|
|
1050
|
+
ok: true,
|
|
1051
|
+
el: r.el,
|
|
1052
|
+
via: r.via ?? "ladder",
|
|
1053
|
+
waited: void 0
|
|
1054
|
+
};
|
|
1055
|
+
const started = Date.now();
|
|
1056
|
+
let polls = 0;
|
|
1057
|
+
let lastSig = core.stateSignature();
|
|
1058
|
+
let step = 0;
|
|
1059
|
+
while (Date.now() < deadline) {
|
|
1060
|
+
await sleep(POLL_STEPS_MS[Math.min(step, POLL_STEPS_MS.length - 1)], signal);
|
|
1061
|
+
step++;
|
|
1062
|
+
polls++;
|
|
1063
|
+
throwIfAborted(signal);
|
|
1064
|
+
await raceWithAbort(core.observe(), signal);
|
|
1065
|
+
const sig = core.stateSignature();
|
|
1066
|
+
const settled = sig === lastSig;
|
|
1067
|
+
lastSig = sig;
|
|
1068
|
+
const rr = inCache();
|
|
1069
|
+
if (rr.el && isActionable(rr.el) && settled) return {
|
|
1070
|
+
ok: true,
|
|
1071
|
+
el: rr.el,
|
|
1072
|
+
via: rr.via ?? "wait",
|
|
1073
|
+
waited: {
|
|
1074
|
+
ms: Date.now() - started,
|
|
1075
|
+
polls
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
if (!rr.el && rr.candidates?.length) return {
|
|
1079
|
+
ok: false,
|
|
1080
|
+
result: ambiguityResult(text, rr)
|
|
1081
|
+
};
|
|
1082
|
+
r = rr;
|
|
1083
|
+
}
|
|
1084
|
+
return {
|
|
1085
|
+
ok: false,
|
|
1086
|
+
result: {
|
|
1087
|
+
success: false,
|
|
1088
|
+
code: "TIMEOUT",
|
|
1089
|
+
message: r.el ? `timed out after ${opts.timeoutMs ?? 5e3}ms waiting for "${text}" to become enabled/settled` : `timed out after ${opts.timeoutMs ?? 5e3}ms — no element matching "${text}" on this screen`,
|
|
1090
|
+
waited: {
|
|
1091
|
+
ms: Date.now() - started,
|
|
1092
|
+
polls
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
function ambiguityResult(text, r) {
|
|
1098
|
+
const list = (r.candidates ?? []).map((c) => `${c.role} "${c.label}"${c.rect ? ` at (${Math.round(c.rect.x)},${Math.round(c.rect.y)})` : ""}`).join("; ");
|
|
1099
|
+
return {
|
|
1100
|
+
success: false,
|
|
1101
|
+
message: `"${text}" is ambiguous — ${r.candidates?.length ?? 0} matches: ${list}. Disambiguate with role or near.`,
|
|
1102
|
+
candidates: r.candidates ?? []
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
function resolvedOf(el, via) {
|
|
1106
|
+
return {
|
|
1107
|
+
via,
|
|
1108
|
+
ref: el.ref,
|
|
1109
|
+
label: el.label,
|
|
1110
|
+
role: el.role,
|
|
1111
|
+
...el.rect === void 0 ? {} : { rect: el.rect }
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* THE dispatcher: resolve → auto-wait → execute → diff. One brain — used by
|
|
1116
|
+
* device.tap/type/act, and (items 5-6) by the harness and the skill runner.
|
|
1117
|
+
*/
|
|
1118
|
+
async function executeAction(core, action, opts = {}, secrets = NO_SECRETS) {
|
|
1119
|
+
const signal = opts.signal;
|
|
1120
|
+
throwIfAborted(signal);
|
|
1121
|
+
const store = secrets.withOverrides(opts.vars);
|
|
1122
|
+
const redact = (s) => store.redact(s);
|
|
1123
|
+
try {
|
|
1124
|
+
switch (action.verb) {
|
|
1125
|
+
case "tap":
|
|
1126
|
+
case "longPress":
|
|
1127
|
+
case "fill": {
|
|
1128
|
+
if (!action.target) return {
|
|
1129
|
+
success: false,
|
|
1130
|
+
message: `${action.verb} needs a target query`
|
|
1131
|
+
};
|
|
1132
|
+
const wait = await resolveWithWait(core, action.target, opts, action.verb === "fill" ? "fields" : "tappable");
|
|
1133
|
+
if (!wait.ok) return wait.result;
|
|
1134
|
+
const { el, via, waited } = wait;
|
|
1135
|
+
if (action.verb === "longPress") {
|
|
1136
|
+
await raceWithAbort(core.longPress(el.ref, action.params?.durationMs), signal);
|
|
1137
|
+
return {
|
|
1138
|
+
success: true,
|
|
1139
|
+
message: redact(`long-pressed "${el.label}"`),
|
|
1140
|
+
resolved: resolvedOf(el, via),
|
|
1141
|
+
waited
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
if (action.verb === "fill") {
|
|
1145
|
+
const text = store.substitute(action.params?.text ?? "");
|
|
1146
|
+
const ev = await raceWithAbort(core.fill(el.ref, text), signal);
|
|
1147
|
+
if (action.params?.submit) await raceWithAbort(core.pressReturn(), signal);
|
|
1148
|
+
return {
|
|
1149
|
+
success: true,
|
|
1150
|
+
message: redact(`filled "${el.label}"${action.params?.submit ? ", pressed Return" : ""}`),
|
|
1151
|
+
...ev.changed === void 0 ? {} : { changed: ev.changed },
|
|
1152
|
+
resolved: resolvedOf(el, via),
|
|
1153
|
+
waited
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
const ev = await raceWithAbort(core.press(el.ref), signal);
|
|
1157
|
+
return {
|
|
1158
|
+
success: true,
|
|
1159
|
+
message: redact(`tapped "${el.label}"${ev.changed === false ? " (no change)" : ""}`),
|
|
1160
|
+
...ev.changed === void 0 ? {} : { changed: ev.changed },
|
|
1161
|
+
resolved: resolvedOf(el, via),
|
|
1162
|
+
waited
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
case "type": {
|
|
1166
|
+
const text = store.substitute(action.params?.text ?? "");
|
|
1167
|
+
await raceWithAbort(core.typeText(text), signal);
|
|
1168
|
+
if (action.params?.submit) await raceWithAbort(core.pressReturn(), signal);
|
|
1169
|
+
return {
|
|
1170
|
+
success: true,
|
|
1171
|
+
message: redact(`typed ${JSON.stringify(action.params?.text ?? "")}`)
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
case "pressKey":
|
|
1175
|
+
await raceWithAbort(core.pressReturn(), signal);
|
|
1176
|
+
return {
|
|
1177
|
+
success: true,
|
|
1178
|
+
message: "pressed Return"
|
|
1179
|
+
};
|
|
1180
|
+
case "scroll": {
|
|
1181
|
+
const direction = action.params?.direction ?? "down";
|
|
1182
|
+
await raceWithAbort(core.scroll(direction), signal);
|
|
1183
|
+
await raceWithAbort(core.observe(), signal);
|
|
1184
|
+
return {
|
|
1185
|
+
success: true,
|
|
1186
|
+
message: `scrolled ${direction}`
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
case "openApp":
|
|
1190
|
+
if (!action.params?.app) return {
|
|
1191
|
+
success: false,
|
|
1192
|
+
message: "openApp needs params.app"
|
|
1193
|
+
};
|
|
1194
|
+
return {
|
|
1195
|
+
success: true,
|
|
1196
|
+
message: await raceWithAbort(core.openApp(action.params.app, action.params.relaunch ?? false), signal)
|
|
1197
|
+
};
|
|
1198
|
+
case "openUrl":
|
|
1199
|
+
if (!action.params?.url) return {
|
|
1200
|
+
success: false,
|
|
1201
|
+
message: "openUrl needs params.url"
|
|
1202
|
+
};
|
|
1203
|
+
return {
|
|
1204
|
+
success: true,
|
|
1205
|
+
message: await raceWithAbort(core.openUrl(action.params.url, action.params.app), signal)
|
|
1206
|
+
};
|
|
1207
|
+
case "back":
|
|
1208
|
+
await raceWithAbort(core.goBack(), signal);
|
|
1209
|
+
return {
|
|
1210
|
+
success: true,
|
|
1211
|
+
message: "went back"
|
|
1212
|
+
};
|
|
1213
|
+
case "home":
|
|
1214
|
+
await raceWithAbort(core.goHome(), signal);
|
|
1215
|
+
return {
|
|
1216
|
+
success: true,
|
|
1217
|
+
message: "went home"
|
|
1218
|
+
};
|
|
1219
|
+
case "alert": {
|
|
1220
|
+
const outcome = await raceWithAbort(core.handleAlert(action.params?.alertAction ?? "accept"), signal);
|
|
1221
|
+
if (!outcome.present) return {
|
|
1222
|
+
success: false,
|
|
1223
|
+
message: "no system alert is showing"
|
|
1224
|
+
};
|
|
1225
|
+
return {
|
|
1226
|
+
success: outcome.handled !== false,
|
|
1227
|
+
message: `alert ${outcome.handled ? `handled via "${outcome.button}"` : "NOT handled"}: ${outcome.description ?? ""}`
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
case "waitForText": {
|
|
1231
|
+
if (!action.params?.text) return {
|
|
1232
|
+
success: false,
|
|
1233
|
+
message: "waitForText needs params.text"
|
|
1234
|
+
};
|
|
1235
|
+
const note = await raceWithAbort(core.waitForText(action.params.text, opts.timeoutMs ?? 5e3), signal);
|
|
1236
|
+
const ok = !/did not appear|not found|timed out/i.test(note);
|
|
1237
|
+
return {
|
|
1238
|
+
success: ok,
|
|
1239
|
+
message: redact(note),
|
|
1240
|
+
...ok ? {} : { code: "TIMEOUT" }
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
default: return {
|
|
1244
|
+
success: false,
|
|
1245
|
+
message: `unknown verb ${String(action.verb)}`
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
} catch (error) {
|
|
1249
|
+
if (error instanceof AbortedError) throw error;
|
|
1250
|
+
if (error instanceof PhoneUseError) {
|
|
1251
|
+
if (error instanceof ActionFailedError || error instanceof TimeoutError) return {
|
|
1252
|
+
success: false,
|
|
1253
|
+
message: redact(error.message),
|
|
1254
|
+
code: error.code
|
|
1255
|
+
};
|
|
1256
|
+
throw error;
|
|
1257
|
+
}
|
|
1258
|
+
throw error;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
//#endregion
|
|
1262
|
+
//#region src/backends/agent-device.ts
|
|
1263
|
+
var AgentDeviceBackend = class extends BaseDeviceBackend {
|
|
1264
|
+
client;
|
|
1265
|
+
selection;
|
|
1266
|
+
used = false;
|
|
1267
|
+
constructor(config) {
|
|
1268
|
+
super("agent-device", ALL_CAPABILITIES);
|
|
1269
|
+
this.client = createAgentDeviceClient(config && (config.session !== void 0 || config.daemonBaseUrl !== void 0 || config.daemonAuthToken !== void 0) ? {
|
|
1270
|
+
...config.session === void 0 ? {} : { session: config.session },
|
|
1271
|
+
...config.daemonBaseUrl === void 0 ? {} : { daemonBaseUrl: config.daemonBaseUrl },
|
|
1272
|
+
...config.daemonAuthToken === void 0 ? {} : { daemonAuthToken: config.daemonAuthToken }
|
|
1273
|
+
} : void 0);
|
|
1274
|
+
this.selection = !config ? {} : {
|
|
1275
|
+
platform: config.platform,
|
|
1276
|
+
...config.device === void 0 ? {} : { device: config.device },
|
|
1277
|
+
...config.platform === "ios" && config.udid !== void 0 ? { udid: config.udid } : {},
|
|
1278
|
+
...config.platform === "ios" && config.simulatorDeviceSet !== void 0 ? { iosSimulatorDeviceSet: config.simulatorDeviceSet } : {},
|
|
1279
|
+
...config.platform === "android" && config.serial !== void 0 ? { serial: config.serial } : {}
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
async guard(capability, fn) {
|
|
1283
|
+
if (capability !== "closeSession") this.used = true;
|
|
1284
|
+
try {
|
|
1285
|
+
return await fn();
|
|
1286
|
+
} catch (error) {
|
|
1287
|
+
throw toPhoneUseError(error, {
|
|
1288
|
+
backend: this.backendName,
|
|
1289
|
+
capability
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
snapshot(opts) {
|
|
1294
|
+
return this.guard("snapshot", async () => {
|
|
1295
|
+
const snap = await this.client.capture.snapshot({
|
|
1296
|
+
...this.selection,
|
|
1297
|
+
...opts?.interactiveOnly === void 0 ? {} : { interactiveOnly: opts.interactiveOnly },
|
|
1298
|
+
...opts?.depth === void 0 ? {} : { depth: opts.depth }
|
|
1299
|
+
});
|
|
1300
|
+
return {
|
|
1301
|
+
nodes: snap.nodes,
|
|
1302
|
+
appName: snap.appName,
|
|
1303
|
+
appBundleId: snap.appBundleId
|
|
1304
|
+
};
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
screenshot(opts) {
|
|
1308
|
+
return this.guard("screenshot", async () => {
|
|
1309
|
+
return { path: (await this.client.capture.screenshot({
|
|
1310
|
+
...this.selection,
|
|
1311
|
+
path: opts.path,
|
|
1312
|
+
...opts.overlayRefs === void 0 ? {} : { overlayRefs: opts.overlayRefs }
|
|
1313
|
+
})).path };
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
press(target) {
|
|
1317
|
+
return this.guard("press", async () => {
|
|
1318
|
+
await this.client.interactions.press({
|
|
1319
|
+
...this.selection,
|
|
1320
|
+
...target
|
|
1321
|
+
});
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
longPress(ref, durationMs) {
|
|
1325
|
+
return this.guard("longPress", async () => {
|
|
1326
|
+
await this.client.interactions.longPress({
|
|
1327
|
+
...this.selection,
|
|
1328
|
+
ref,
|
|
1329
|
+
...durationMs === void 0 ? {} : { durationMs },
|
|
1330
|
+
settle: true
|
|
1331
|
+
});
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
fill(ref, text) {
|
|
1335
|
+
return this.guard("fill", async () => {
|
|
1336
|
+
await this.client.interactions.fill({
|
|
1337
|
+
...this.selection,
|
|
1338
|
+
ref,
|
|
1339
|
+
text
|
|
1340
|
+
});
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
typeText(text) {
|
|
1344
|
+
return this.guard("type", async () => {
|
|
1345
|
+
await this.client.interactions.type({
|
|
1346
|
+
...this.selection,
|
|
1347
|
+
text
|
|
1348
|
+
});
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
pressKey(_key) {
|
|
1352
|
+
return this.guard("key", async () => {
|
|
1353
|
+
await this.client.command.keyboard({
|
|
1354
|
+
...this.selection,
|
|
1355
|
+
action: "return"
|
|
1356
|
+
});
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
scroll(direction) {
|
|
1360
|
+
return this.guard("scroll", async () => {
|
|
1361
|
+
const args = {
|
|
1362
|
+
...this.selection,
|
|
1363
|
+
direction
|
|
1364
|
+
};
|
|
1365
|
+
await this.client.interactions.scroll(args);
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
pan(x, y, dx, dy, durationMs) {
|
|
1369
|
+
return this.guard("pan", async () => {
|
|
1370
|
+
await this.client.interactions.pan({
|
|
1371
|
+
...this.selection,
|
|
1372
|
+
x,
|
|
1373
|
+
y,
|
|
1374
|
+
dx,
|
|
1375
|
+
dy,
|
|
1376
|
+
...durationMs === void 0 ? {} : { durationMs }
|
|
1377
|
+
});
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
waitForText(text, timeoutMs) {
|
|
1381
|
+
return this.guard("waitForText", async () => {
|
|
1382
|
+
await this.client.command.wait({
|
|
1383
|
+
...this.selection,
|
|
1384
|
+
text,
|
|
1385
|
+
...timeoutMs === void 0 ? {} : { timeoutMs }
|
|
1386
|
+
});
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
systemAlert(action) {
|
|
1390
|
+
return this.guard("alert", async () => {
|
|
1391
|
+
const result = await this.client.command.alert({
|
|
1392
|
+
...this.selection,
|
|
1393
|
+
action
|
|
1394
|
+
});
|
|
1395
|
+
return {
|
|
1396
|
+
alert: result.alert,
|
|
1397
|
+
handled: result.handled,
|
|
1398
|
+
button: result.button
|
|
1399
|
+
};
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
home() {
|
|
1403
|
+
return this.guard("home", async () => {
|
|
1404
|
+
await this.client.command.home({ ...this.selection });
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
back() {
|
|
1408
|
+
return this.guard("back", async () => {
|
|
1409
|
+
await this.client.command.back({ ...this.selection });
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
openApp(opts) {
|
|
1413
|
+
return this.guard("openApp", async () => {
|
|
1414
|
+
const args = opts.url === void 0 ? {
|
|
1415
|
+
app: opts.app,
|
|
1416
|
+
...opts.relaunch === void 0 ? {} : { relaunch: opts.relaunch }
|
|
1417
|
+
} : opts.app !== void 0 ? {
|
|
1418
|
+
app: opts.app,
|
|
1419
|
+
url: opts.url
|
|
1420
|
+
} : { url: opts.url };
|
|
1421
|
+
const result = await this.client.apps.open({
|
|
1422
|
+
...this.selection,
|
|
1423
|
+
...args
|
|
1424
|
+
});
|
|
1425
|
+
return {
|
|
1426
|
+
appName: result.appName,
|
|
1427
|
+
appBundleId: result.appBundleId
|
|
1428
|
+
};
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
listApps() {
|
|
1432
|
+
return this.guard("listApps", async () => {
|
|
1433
|
+
return await this.client.apps.list(Object.keys(this.selection).length ? this.selection : void 0);
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
closeSession() {
|
|
1437
|
+
if (!this.used) return Promise.resolve();
|
|
1438
|
+
return this.guard("closeSession", async () => {
|
|
1439
|
+
await this.client.sessions.close({ ...this.selection });
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
};
|
|
1443
|
+
/**
|
|
1444
|
+
* Build the agent-device backend: the ONE place agent-device is called. Device
|
|
1445
|
+
* pinning is per-request in agent-device, so the backend holds a selection
|
|
1446
|
+
* object and spreads it into every call. With no config, selection is `{}` and
|
|
1447
|
+
* the client is default-constructed — requests are byte-identical to the
|
|
1448
|
+
* pre-seam process-global path (booted-sim auto-detect). Every method
|
|
1449
|
+
* normalizes errors via `toPhoneUseError`; no agent-device type or error ever
|
|
1450
|
+
* escapes this module.
|
|
1451
|
+
*/
|
|
1452
|
+
function createAgentDeviceBackend(config) {
|
|
1453
|
+
return new AgentDeviceBackend(config);
|
|
1454
|
+
}
|
|
1455
|
+
//#endregion
|
|
1456
|
+
//#region src/exec.ts
|
|
1457
|
+
const pExecFile = promisify(execFile);
|
|
1458
|
+
const defaultExecRunner = async (file, args, opts) => {
|
|
1459
|
+
const { stdout, stderr } = await pExecFile(file, args, {
|
|
1460
|
+
encoding: "utf8",
|
|
1461
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
1462
|
+
...opts?.timeoutMs === void 0 ? {} : { timeout: opts.timeoutMs },
|
|
1463
|
+
...opts?.env === void 0 ? {} : { env: opts.env }
|
|
1464
|
+
});
|
|
1465
|
+
return {
|
|
1466
|
+
stdout,
|
|
1467
|
+
stderr
|
|
1468
|
+
};
|
|
1469
|
+
};
|
|
1470
|
+
function isExecError(err) {
|
|
1471
|
+
return err instanceof Error && ("code" in err || "killed" in err || "stderr" in err);
|
|
1472
|
+
}
|
|
1473
|
+
//#endregion
|
|
1474
|
+
//#region src/lifecycle.ts
|
|
1475
|
+
var IdleLease = class {
|
|
1476
|
+
timer = null;
|
|
1477
|
+
windowMs;
|
|
1478
|
+
onExpire;
|
|
1479
|
+
constructor(windowMs, onExpire) {
|
|
1480
|
+
this.windowMs = windowMs;
|
|
1481
|
+
this.onExpire = onExpire;
|
|
1482
|
+
this.touch();
|
|
1483
|
+
}
|
|
1484
|
+
/** Re-arm with the configured window (no-op when disabled). */
|
|
1485
|
+
touch() {
|
|
1486
|
+
this.arm(this.windowMs);
|
|
1487
|
+
}
|
|
1488
|
+
/** Re-arm with a one-shot override window. */
|
|
1489
|
+
extend(ms) {
|
|
1490
|
+
this.arm(ms ?? this.windowMs);
|
|
1491
|
+
}
|
|
1492
|
+
arm(ms) {
|
|
1493
|
+
if (this.timer) clearTimeout(this.timer);
|
|
1494
|
+
this.timer = null;
|
|
1495
|
+
if (ms === false) return;
|
|
1496
|
+
const t = setTimeout(this.onExpire, ms);
|
|
1497
|
+
t.unref?.();
|
|
1498
|
+
this.timer = t;
|
|
1499
|
+
}
|
|
1500
|
+
dispose() {
|
|
1501
|
+
if (this.timer) clearTimeout(this.timer);
|
|
1502
|
+
this.timer = null;
|
|
1503
|
+
}
|
|
1504
|
+
};
|
|
1505
|
+
/**
|
|
1506
|
+
* Assemble a Device handle over a backend: lease/reaper, verb surface,
|
|
1507
|
+
* close/dispose semantics. Engine authors (ios here, android in item 7b,
|
|
1508
|
+
* phone-backend-* third parties) build on this; tests fabricate devices with
|
|
1509
|
+
* it over a FakeBackend.
|
|
1510
|
+
*/
|
|
1511
|
+
function createDeviceHandle(opts) {
|
|
1512
|
+
let status = "running";
|
|
1513
|
+
let closePromise = null;
|
|
1514
|
+
const close = () => {
|
|
1515
|
+
closePromise ??= (async () => {
|
|
1516
|
+
status = "closed";
|
|
1517
|
+
lease.dispose();
|
|
1518
|
+
await opts.backend.closeSession().catch(() => void 0);
|
|
1519
|
+
await opts.doClose();
|
|
1520
|
+
})();
|
|
1521
|
+
return closePromise;
|
|
1522
|
+
};
|
|
1523
|
+
const lease = new IdleLease(opts.idleTimeoutMs ?? 18e4, () => {
|
|
1524
|
+
close().catch(() => void 0).then(() => opts.onIdleClose?.(device));
|
|
1525
|
+
});
|
|
1526
|
+
const touchingBackend = new Proxy(opts.backend, { get(target, prop, receiver) {
|
|
1527
|
+
const value = Reflect.get(target, prop, receiver);
|
|
1528
|
+
if (typeof value !== "function") return value;
|
|
1529
|
+
return (...args) => {
|
|
1530
|
+
if (status === "running") lease.touch();
|
|
1531
|
+
return value.apply(target, args);
|
|
1532
|
+
};
|
|
1533
|
+
} });
|
|
1534
|
+
const core = opts.coreFactory?.(touchingBackend) ?? new DeviceCore(touchingBackend);
|
|
1535
|
+
const secrets = new SecretStore(opts.secrets);
|
|
1536
|
+
const assertOpen = () => {
|
|
1537
|
+
if (status === "closed") throw new SessionNotFoundError(`device ${opts.id} is closed`);
|
|
1538
|
+
};
|
|
1539
|
+
const toQuery = (target) => typeof target === "string" ? { label: target } : target;
|
|
1540
|
+
const device = {
|
|
1541
|
+
id: opts.id,
|
|
1542
|
+
platform: opts.platform,
|
|
1543
|
+
name: opts.name,
|
|
1544
|
+
backendName: opts.backend.backendName,
|
|
1545
|
+
capabilities: opts.backend.capabilities,
|
|
1546
|
+
backend: touchingBackend,
|
|
1547
|
+
createdByUs: opts.createdByUs,
|
|
1548
|
+
get status() {
|
|
1549
|
+
return status;
|
|
1550
|
+
},
|
|
1551
|
+
get isClosed() {
|
|
1552
|
+
return status === "closed";
|
|
1553
|
+
},
|
|
1554
|
+
extendLease(ms) {
|
|
1555
|
+
if (status === "running") lease.extend(ms);
|
|
1556
|
+
},
|
|
1557
|
+
close,
|
|
1558
|
+
[Symbol.asyncDispose]: close,
|
|
1559
|
+
observe() {
|
|
1560
|
+
assertOpen();
|
|
1561
|
+
return buildObserveResult(core, secrets);
|
|
1562
|
+
},
|
|
1563
|
+
tap(target, actOpts = {}) {
|
|
1564
|
+
assertOpen();
|
|
1565
|
+
return executeAction(core, {
|
|
1566
|
+
formatVersion: 0,
|
|
1567
|
+
verb: "tap",
|
|
1568
|
+
target: toQuery(target)
|
|
1569
|
+
}, actOpts, secrets);
|
|
1570
|
+
},
|
|
1571
|
+
type(text, actOpts = {}) {
|
|
1572
|
+
assertOpen();
|
|
1573
|
+
const { field, submit, ...rest } = actOpts;
|
|
1574
|
+
const action = field === void 0 ? {
|
|
1575
|
+
formatVersion: 0,
|
|
1576
|
+
verb: "type",
|
|
1577
|
+
params: {
|
|
1578
|
+
text,
|
|
1579
|
+
submit
|
|
1580
|
+
}
|
|
1581
|
+
} : {
|
|
1582
|
+
formatVersion: 0,
|
|
1583
|
+
verb: "fill",
|
|
1584
|
+
target: toQuery(field),
|
|
1585
|
+
params: {
|
|
1586
|
+
text,
|
|
1587
|
+
submit
|
|
1588
|
+
}
|
|
1589
|
+
};
|
|
1590
|
+
return executeAction(core, action, rest, secrets);
|
|
1591
|
+
},
|
|
1592
|
+
act(action, actOpts = {}) {
|
|
1593
|
+
assertOpen();
|
|
1594
|
+
return executeAction(core, action, actOpts, secrets);
|
|
1595
|
+
},
|
|
1596
|
+
apps: {
|
|
1597
|
+
open(app, o = {}) {
|
|
1598
|
+
assertOpen();
|
|
1599
|
+
const action = o.url === void 0 ? {
|
|
1600
|
+
formatVersion: 0,
|
|
1601
|
+
verb: "openApp",
|
|
1602
|
+
params: {
|
|
1603
|
+
app,
|
|
1604
|
+
relaunch: o.relaunch
|
|
1605
|
+
}
|
|
1606
|
+
} : {
|
|
1607
|
+
formatVersion: 0,
|
|
1608
|
+
verb: "openUrl",
|
|
1609
|
+
params: {
|
|
1610
|
+
app,
|
|
1611
|
+
url: o.url
|
|
1612
|
+
}
|
|
1613
|
+
};
|
|
1614
|
+
return executeAction(core, action, { signal: o.signal }, secrets);
|
|
1615
|
+
},
|
|
1616
|
+
list(o = {}) {
|
|
1617
|
+
assertOpen();
|
|
1618
|
+
return core.listApps();
|
|
1619
|
+
},
|
|
1620
|
+
current() {
|
|
1621
|
+
return core.currentApp();
|
|
1622
|
+
}
|
|
1623
|
+
},
|
|
1624
|
+
screen: {
|
|
1625
|
+
scroll(direction, o = {}) {
|
|
1626
|
+
assertOpen();
|
|
1627
|
+
return executeAction(core, {
|
|
1628
|
+
formatVersion: 0,
|
|
1629
|
+
verb: "scroll",
|
|
1630
|
+
params: { direction }
|
|
1631
|
+
}, { signal: o.signal }, secrets);
|
|
1632
|
+
},
|
|
1633
|
+
async screenshot(o) {
|
|
1634
|
+
assertOpen();
|
|
1635
|
+
return {
|
|
1636
|
+
success: true,
|
|
1637
|
+
message: `screenshot saved`,
|
|
1638
|
+
path: await core.screenshot(o.path)
|
|
1639
|
+
};
|
|
1640
|
+
},
|
|
1641
|
+
waitForText(text, o = {}) {
|
|
1642
|
+
assertOpen();
|
|
1643
|
+
return executeAction(core, {
|
|
1644
|
+
formatVersion: 0,
|
|
1645
|
+
verb: "waitForText",
|
|
1646
|
+
params: { text }
|
|
1647
|
+
}, {
|
|
1648
|
+
signal: o.signal,
|
|
1649
|
+
timeoutMs: o.timeoutMs
|
|
1650
|
+
}, secrets);
|
|
1651
|
+
},
|
|
1652
|
+
alert(action, o = {}) {
|
|
1653
|
+
assertOpen();
|
|
1654
|
+
if (action === "get") return core.handleAlert("get").then((r) => ({
|
|
1655
|
+
success: r.present,
|
|
1656
|
+
message: r.present ? `alert: ${r.description ?? ""}` : "no system alert is showing"
|
|
1657
|
+
}));
|
|
1658
|
+
return executeAction(core, {
|
|
1659
|
+
formatVersion: 0,
|
|
1660
|
+
verb: "alert",
|
|
1661
|
+
params: { alertAction: action }
|
|
1662
|
+
}, { signal: o.signal }, secrets);
|
|
1663
|
+
},
|
|
1664
|
+
back(o = {}) {
|
|
1665
|
+
assertOpen();
|
|
1666
|
+
return executeAction(core, {
|
|
1667
|
+
formatVersion: 0,
|
|
1668
|
+
verb: "back"
|
|
1669
|
+
}, { signal: o.signal }, secrets);
|
|
1670
|
+
},
|
|
1671
|
+
home(o = {}) {
|
|
1672
|
+
assertOpen();
|
|
1673
|
+
return executeAction(core, {
|
|
1674
|
+
formatVersion: 0,
|
|
1675
|
+
verb: "home"
|
|
1676
|
+
}, { signal: o.signal }, secrets);
|
|
1677
|
+
}
|
|
1678
|
+
},
|
|
1679
|
+
secrets
|
|
1680
|
+
};
|
|
1681
|
+
return device;
|
|
1682
|
+
}
|
|
1683
|
+
//#endregion
|
|
1684
|
+
//#region src/backends/ios.ts
|
|
1685
|
+
const UDID_RE = /^[0-9A-F]{8}(-[0-9A-F]{4}){3}-[0-9A-F]{12}$/i;
|
|
1686
|
+
function simctlArgs(setPath, args) {
|
|
1687
|
+
return setPath === void 0 ? ["simctl", ...args] : [
|
|
1688
|
+
"simctl",
|
|
1689
|
+
"--set",
|
|
1690
|
+
setPath,
|
|
1691
|
+
...args
|
|
1692
|
+
];
|
|
1693
|
+
}
|
|
1694
|
+
function isAlreadyInState(err) {
|
|
1695
|
+
if (!isExecError(err)) return false;
|
|
1696
|
+
if (err.code === 149) return true;
|
|
1697
|
+
return /current state.*(Booted|Shutdown)/i.test(err.stderr ?? "");
|
|
1698
|
+
}
|
|
1699
|
+
async function runSimctl(exec, setPath, args, opts = {}) {
|
|
1700
|
+
try {
|
|
1701
|
+
return (await exec("xcrun", simctlArgs(setPath, args), opts.timeoutMs === void 0 ? void 0 : { timeoutMs: opts.timeoutMs })).stdout;
|
|
1702
|
+
} catch (err) {
|
|
1703
|
+
if (opts.tolerateState && isAlreadyInState(err)) return "";
|
|
1704
|
+
if (isExecError(err)) {
|
|
1705
|
+
if (err.killed || err.signal) throw new TimeoutError(`simctl ${args[0]} timed out${opts.timeoutMs ? ` after ${opts.timeoutMs}ms` : ""}`, { cause: err });
|
|
1706
|
+
throw new ActionFailedError(`simctl ${args[0]} failed (exit ${String(err.code ?? "?")}): ${(err.stderr ?? err.message).trim()}`, {
|
|
1707
|
+
details: { backendCode: String(err.code ?? "EXEC") },
|
|
1708
|
+
cause: err
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
throw toPhoneUseError(err);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
function parseCreatedUdid(stdout) {
|
|
1715
|
+
const lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
1716
|
+
const last = lines[lines.length - 1] ?? "";
|
|
1717
|
+
if (!UDID_RE.test(last)) throw new ActionFailedError(`could not parse udid from simctl create output: ${JSON.stringify(stdout.slice(0, 200))}`);
|
|
1718
|
+
return last;
|
|
1719
|
+
}
|
|
1720
|
+
function parseList(stdout) {
|
|
1721
|
+
let parsed;
|
|
1722
|
+
try {
|
|
1723
|
+
parsed = JSON.parse(stdout);
|
|
1724
|
+
} catch (err) {
|
|
1725
|
+
throw new ActionFailedError("could not parse simctl list output as JSON", { cause: err });
|
|
1726
|
+
}
|
|
1727
|
+
return Object.values(parsed.devices ?? {}).flat();
|
|
1728
|
+
}
|
|
1729
|
+
function makeBackendConfig(udid, opts) {
|
|
1730
|
+
return {
|
|
1731
|
+
platform: "ios",
|
|
1732
|
+
udid,
|
|
1733
|
+
...opts.simulatorDeviceSet === void 0 ? {} : { simulatorDeviceSet: opts.simulatorDeviceSet },
|
|
1734
|
+
session: opts.session ?? `phone-use-${udid}`,
|
|
1735
|
+
...opts.daemonBaseUrl === void 0 ? {} : { daemonBaseUrl: opts.daemonBaseUrl },
|
|
1736
|
+
...opts.daemonAuthToken === void 0 ? {} : { daemonAuthToken: opts.daemonAuthToken }
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
async function bootAndWait(exec, setPath, udid, bootTimeoutMs) {
|
|
1740
|
+
await runSimctl(exec, setPath, ["boot", udid], { tolerateState: true });
|
|
1741
|
+
await runSimctl(exec, setPath, [
|
|
1742
|
+
"bootstatus",
|
|
1743
|
+
udid,
|
|
1744
|
+
"-b"
|
|
1745
|
+
], { timeoutMs: bootTimeoutMs });
|
|
1746
|
+
}
|
|
1747
|
+
async function finishHandle(udid, name, createdByUs, opts, exec) {
|
|
1748
|
+
const backend = createAgentDeviceBackend(makeBackendConfig(udid, opts));
|
|
1749
|
+
if (opts.failFast) await backend.listApps();
|
|
1750
|
+
return createDeviceHandle({
|
|
1751
|
+
id: udid,
|
|
1752
|
+
platform: "ios",
|
|
1753
|
+
name,
|
|
1754
|
+
backend,
|
|
1755
|
+
createdByUs,
|
|
1756
|
+
idleTimeoutMs: opts.idleTimeoutMs,
|
|
1757
|
+
onIdleClose: opts.onIdleClose,
|
|
1758
|
+
secrets: opts.secrets,
|
|
1759
|
+
coreFactory: opts.coreFactory,
|
|
1760
|
+
doClose: async () => {
|
|
1761
|
+
await runSimctl(exec, opts.simulatorDeviceSet, ["shutdown", udid], { tolerateState: true });
|
|
1762
|
+
if (createdByUs) await runSimctl(exec, opts.simulatorDeviceSet, ["delete", udid]);
|
|
1763
|
+
}
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
/**
|
|
1767
|
+
* Create and boot a DEDICATED simulator via simctl — no more "whatever is
|
|
1768
|
+
* booted" — and return a {@link Device} pinned to its udid. Created sims are
|
|
1769
|
+
* named `phone-use-<hex>` deliberately: if the process is kill -9'd the
|
|
1770
|
+
* in-process reaper can't run, and the name prefix is how orphans are found.
|
|
1771
|
+
* `close()` shuts the sim down AND deletes it (we created it); a failed boot
|
|
1772
|
+
* best-effort-deletes before rethrowing.
|
|
1773
|
+
*/
|
|
1774
|
+
async function launch(options = {}) {
|
|
1775
|
+
const exec = options.exec ?? defaultExecRunner;
|
|
1776
|
+
const deviceType = options.deviceType ?? "iPhone 16";
|
|
1777
|
+
const name = options.name ?? `phone-use-${Math.random().toString(16).slice(2, 10)}`;
|
|
1778
|
+
const bootTimeoutMs = options.bootTimeoutMs ?? 12e4;
|
|
1779
|
+
const createArgs = [
|
|
1780
|
+
"create",
|
|
1781
|
+
name,
|
|
1782
|
+
deviceType,
|
|
1783
|
+
...options.runtime === void 0 ? [] : [options.runtime]
|
|
1784
|
+
];
|
|
1785
|
+
const udid = parseCreatedUdid(await runSimctl(exec, options.simulatorDeviceSet, createArgs));
|
|
1786
|
+
try {
|
|
1787
|
+
await bootAndWait(exec, options.simulatorDeviceSet, udid, bootTimeoutMs);
|
|
1788
|
+
} catch (err) {
|
|
1789
|
+
await runSimctl(exec, options.simulatorDeviceSet, ["delete", udid]).catch(() => void 0);
|
|
1790
|
+
throw err;
|
|
1791
|
+
}
|
|
1792
|
+
return finishHandle(udid, name, true, options, exec);
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* Reattach to an existing simulator by udid (booting it if shut down). The
|
|
1796
|
+
* no-arg form is the sole survivor of the old booted-sim auto-detect: it
|
|
1797
|
+
* attaches to the first booted, available sim. `close()` on a connected
|
|
1798
|
+
* device shuts it down but never deletes it.
|
|
1799
|
+
*/
|
|
1800
|
+
async function connect(udid, options = {}) {
|
|
1801
|
+
const exec = options.exec ?? defaultExecRunner;
|
|
1802
|
+
const bootTimeoutMs = options.bootTimeoutMs ?? 12e4;
|
|
1803
|
+
if (udid === void 0) {
|
|
1804
|
+
const booted = parseList(await runSimctl(exec, options.simulatorDeviceSet, [
|
|
1805
|
+
"list",
|
|
1806
|
+
"devices",
|
|
1807
|
+
"booted",
|
|
1808
|
+
"-j"
|
|
1809
|
+
])).find((d) => d.state === "Booted" && d.isAvailable !== false);
|
|
1810
|
+
if (!booted) throw new DeviceNotFoundError("no booted simulator — use ios.launch() or boot one");
|
|
1811
|
+
return finishHandle(booted.udid, booted.name, false, options, exec);
|
|
1812
|
+
}
|
|
1813
|
+
const row = parseList(await runSimctl(exec, options.simulatorDeviceSet, [
|
|
1814
|
+
"list",
|
|
1815
|
+
"devices",
|
|
1816
|
+
"-j"
|
|
1817
|
+
])).find((d) => d.udid.toLowerCase() === udid.toLowerCase());
|
|
1818
|
+
if (!row) throw new DeviceNotFoundError(`no simulator with udid ${udid}`);
|
|
1819
|
+
if (row.state !== "Booted") await bootAndWait(exec, options.simulatorDeviceSet, row.udid, bootTimeoutMs);
|
|
1820
|
+
return finishHandle(row.udid, row.name, false, options, exec);
|
|
1821
|
+
}
|
|
1822
|
+
/**
|
|
1823
|
+
* The iOS engine object (Playwright-style): `ios.launch()` for a dedicated
|
|
1824
|
+
* simulator, `ios.connect()` to reattach. Both return the same Device type.
|
|
1825
|
+
*/
|
|
1826
|
+
const ios = {
|
|
1827
|
+
/** Create + boot a dedicated simulator and return a Device pinned to it. */
|
|
1828
|
+
launch,
|
|
1829
|
+
/** Reattach to an existing simulator (no-arg: first booted sim). */
|
|
1830
|
+
connect
|
|
1831
|
+
};
|
|
1832
|
+
//#endregion
|
|
1833
|
+
//#region src/index.ts
|
|
1834
|
+
/**
|
|
1835
|
+
* @phone-use/sdk — the device runtime SDK: engine-as-object lifecycle
|
|
1836
|
+
* (ios.launch/connect → Device), Device backends, config, errors, capabilities
|
|
1837
|
+
* (docs/20-runtime-sdk-v1-plan.md items 2-3; action verbs land in item 4).
|
|
1838
|
+
*
|
|
1839
|
+
* The test double (FakeBackend) lives on the "@phone-use/sdk/testing" subpath,
|
|
1840
|
+
* deliberately not re-exported here.
|
|
1841
|
+
*/
|
|
1842
|
+
/** The published package version (kept in sync with package.json by the release flow). */
|
|
1843
|
+
const VERSION = "0.1.0";
|
|
1844
|
+
registerBackend("agent-device", createAgentDeviceBackend);
|
|
1845
|
+
//#endregion
|
|
1846
|
+
export { ALL_CAPABILITIES, AbortedError, ActionFailedError, BaseDeviceBackend, DeviceCore, DeviceInUseError, DeviceNotFoundError, PhoneUseError, SecretStore, SessionNotFoundError, TimeoutError, UnsupportedCapabilityError, VERSION, buildObserveResult, createAgentDeviceBackend, createDeviceHandle, describeError, executeAction, getBackendFactory, ios, labelMatches, listBackends, matchInElements, registerBackend, toActions, toPhoneUseError };
|
|
1847
|
+
|
|
1848
|
+
//# sourceMappingURL=index.mjs.map
|