@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/src/backend.ts ADDED
@@ -0,0 +1,185 @@
1
+ import type { DeviceConfig } from './config.ts';
2
+ import type {
3
+ AlertAction,
4
+ BackendAlertResult,
5
+ Capability,
6
+ OpenAppResult,
7
+ PressTarget,
8
+ ScrollDirection,
9
+ Snapshot,
10
+ } from './device.ts';
11
+ import { PhoneUseError, UnsupportedCapabilityError } from './errors.ts';
12
+
13
+ /**
14
+ * The backend contract (docs/19 §Backend contract): a STRUCTURAL interface —
15
+ * any object with these methods is a backend — plus an optional
16
+ * {@link BaseDeviceBackend} class with the plumbing done (Appium BaseDriver
17
+ * ergonomics). Methods reject only with PhoneUseError subclasses; backends
18
+ * normalize their transport's errors at the boundary.
19
+ */
20
+ export interface DeviceBackend {
21
+ /** Stable backend identifier, e.g. `"agent-device"`. */
22
+ readonly backendName: string;
23
+ /** The capabilities this backend actually implements. */
24
+ readonly capabilities: ReadonlySet<Capability>;
25
+
26
+ /** Capture the accessibility tree of the frontmost app. */
27
+ snapshot(opts?: { interactiveOnly?: boolean | undefined; depth?: number | undefined }): Promise<Snapshot>;
28
+ /** Save a screenshot to `path` (optionally with `@ref` overlays drawn). */
29
+ screenshot(opts: { path: string; overlayRefs?: boolean | undefined }): Promise<{ path: string }>;
30
+ /** Tap an element ref or raw coordinates. */
31
+ press(target: PressTarget): Promise<void>;
32
+ /** Long-press an element ref. */
33
+ longPress(ref: string, durationMs?: number): Promise<void>;
34
+ /** Focus an element and replace its text. */
35
+ fill(ref: string, text: string): Promise<void>;
36
+ /** Type into whatever currently has keyboard focus. */
37
+ typeText(text: string): Promise<void>;
38
+ /** Press a hardware/keyboard key (Return only, this cycle). */
39
+ pressKey(key: 'return'): Promise<void>;
40
+ /** Scroll the active scroll view one step. */
41
+ scroll(direction: ScrollDirection): Promise<void>;
42
+ /** Coordinate drag: touch down at (x,y), move by (dx,dy). Operates picker wheels, sliders, carousels. */
43
+ pan(x: number, y: number, dx: number, dy: number, durationMs?: number): Promise<void>;
44
+ /** Block until `text` appears on screen or the timeout elapses. */
45
+ waitForText(text: string, timeoutMs?: number): Promise<void>;
46
+ /** Read/accept/dismiss the app's own alert via the transport's alert command. */
47
+ systemAlert(action: AlertAction): Promise<BackendAlertResult>;
48
+ /** Go to the home screen. */
49
+ home(): Promise<void>;
50
+ /** Navigate back (hardware back / nav-bar back). */
51
+ back(): Promise<void>;
52
+ /** Open an app by name/bundle id, or a URL (deep link). */
53
+ openApp(opts: {
54
+ app?: string | undefined;
55
+ url?: string | undefined;
56
+ relaunch?: boolean | undefined;
57
+ }): Promise<OpenAppResult>;
58
+ /** List installed app bundle ids. */
59
+ listApps(): Promise<string[]>;
60
+ /** Close the transport session (idempotent; safe when none is open). */
61
+ closeSession(): Promise<void>;
62
+ }
63
+
64
+ /**
65
+ * Optional plumbing base: every method rejects with a typed
66
+ * UnsupportedCapabilityError until overridden. Subclasses declare their
67
+ * capability set and override exactly what they support.
68
+ */
69
+ export abstract class BaseDeviceBackend implements DeviceBackend {
70
+ readonly backendName: string;
71
+ readonly capabilities: ReadonlySet<Capability>;
72
+
73
+ protected constructor(backendName: string, capabilities: Iterable<Capability>) {
74
+ this.backendName = backendName;
75
+ this.capabilities = new Set(capabilities);
76
+ }
77
+
78
+ protected unsupported(capability: Capability): UnsupportedCapabilityError {
79
+ return new UnsupportedCapabilityError({ backend: this.backendName, capability });
80
+ }
81
+
82
+ /** Throws UnsupportedCapabilityError unless this backend declares `capability`. */
83
+ requireCapability(capability: Capability): void {
84
+ if (!this.capabilities.has(capability)) throw this.unsupported(capability);
85
+ }
86
+
87
+ snapshot(_opts?: { interactiveOnly?: boolean | undefined; depth?: number | undefined }): Promise<Snapshot> {
88
+ return Promise.reject(this.unsupported('snapshot'));
89
+ }
90
+ screenshot(_opts: { path: string; overlayRefs?: boolean | undefined }): Promise<{ path: string }> {
91
+ return Promise.reject(this.unsupported('screenshot'));
92
+ }
93
+ press(_target: PressTarget): Promise<void> {
94
+ return Promise.reject(this.unsupported('press'));
95
+ }
96
+ longPress(_ref: string, _durationMs?: number): Promise<void> {
97
+ return Promise.reject(this.unsupported('longPress'));
98
+ }
99
+ fill(_ref: string, _text: string): Promise<void> {
100
+ return Promise.reject(this.unsupported('fill'));
101
+ }
102
+ typeText(_text: string): Promise<void> {
103
+ return Promise.reject(this.unsupported('type'));
104
+ }
105
+ pressKey(_key: 'return'): Promise<void> {
106
+ return Promise.reject(this.unsupported('key'));
107
+ }
108
+ scroll(_direction: ScrollDirection): Promise<void> {
109
+ return Promise.reject(this.unsupported('scroll'));
110
+ }
111
+ pan(_x: number, _y: number, _dx: number, _dy: number, _durationMs?: number): Promise<void> {
112
+ return Promise.reject(this.unsupported('pan'));
113
+ }
114
+ waitForText(_text: string, _timeoutMs?: number): Promise<void> {
115
+ return Promise.reject(this.unsupported('waitForText'));
116
+ }
117
+ systemAlert(_action: AlertAction): Promise<BackendAlertResult> {
118
+ return Promise.reject(this.unsupported('alert'));
119
+ }
120
+ home(): Promise<void> {
121
+ return Promise.reject(this.unsupported('home'));
122
+ }
123
+ back(): Promise<void> {
124
+ return Promise.reject(this.unsupported('back'));
125
+ }
126
+ openApp(_opts: {
127
+ app?: string | undefined;
128
+ url?: string | undefined;
129
+ relaunch?: boolean | undefined;
130
+ }): Promise<OpenAppResult> {
131
+ return Promise.reject(this.unsupported('openApp'));
132
+ }
133
+ listApps(): Promise<string[]> {
134
+ return Promise.reject(this.unsupported('listApps'));
135
+ }
136
+ closeSession(): Promise<void> {
137
+ return Promise.reject(this.unsupported('closeSession'));
138
+ }
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Runtime backend registry (registerBackend + phone-backend-* convention,
143
+ // docs/19). A deliberate module-level Map: this IS the registry — mirrored on
144
+ // permissions.ts's documented pattern; the item-2 grep audit is scoped to
145
+ // driver code.
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /** Builds a backend from an optional {@link DeviceConfig}. */
149
+ export type BackendFactory = (config?: DeviceConfig) => DeviceBackend | Promise<DeviceBackend>;
150
+
151
+ const factories = new Map<string, BackendFactory>();
152
+
153
+ /**
154
+ * Register a backend factory under a unique name (the `phone-backend-*`
155
+ * convention for third parties). Throws if the name is already taken.
156
+ */
157
+ export function registerBackend(name: string, factory: BackendFactory): void {
158
+ if (factories.has(name)) {
159
+ throw new PhoneUseError(`backend "${name}" is already registered`, {
160
+ code: 'ACTION_FAILED',
161
+ retryable: false,
162
+ });
163
+ }
164
+ factories.set(name, factory);
165
+ }
166
+
167
+ /** Look up a registered factory by name; throws `BACKEND_NOT_FOUND` if absent. */
168
+ export function getBackendFactory(name: string): BackendFactory {
169
+ const factory = factories.get(name);
170
+ if (!factory) {
171
+ throw new PhoneUseError(
172
+ `no backend named "${name}" — registered: ${[...factories.keys()].join(', ') || '(none)'}`,
173
+ {
174
+ code: 'BACKEND_NOT_FOUND',
175
+ retryable: false,
176
+ },
177
+ );
178
+ }
179
+ return factory;
180
+ }
181
+
182
+ /** Names of every registered backend, in registration order. */
183
+ export function listBackends(): string[] {
184
+ return [...factories.keys()];
185
+ }
@@ -0,0 +1,242 @@
1
+ import { createAgentDeviceClient } from 'agent-device';
2
+ import type { DeviceBackend } from '../backend.ts';
3
+ import { BaseDeviceBackend } from '../backend.ts';
4
+ import type { DeviceConfig } from '../config.ts';
5
+ import type {
6
+ AlertAction,
7
+ BackendAlertResult,
8
+ Capability,
9
+ OpenAppResult,
10
+ PressTarget,
11
+ ScrollDirection,
12
+ Snapshot,
13
+ } from '../device.ts';
14
+ import { ALL_CAPABILITIES } from '../device.ts';
15
+ import { toPhoneUseError } from '../errors.ts';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // The agent-device backend: the ONE place agent-device is called. Device
19
+ // pinning is per-request in agent-device (AgentDeviceSelectionOptions), so a
20
+ // backend holds a selection object and spreads it into every call. With no
21
+ // config, selection is {} and the client is default-constructed — requests are
22
+ // byte-identical to the pre-seam process-global path (booted-sim auto-detect),
23
+ // which is the item-2 backward-compat guarantee (docs/20 risk: default-
24
+ // selection drift).
25
+ //
26
+ // Every method normalizes errors via toPhoneUseError — no agent-device type
27
+ // or error ever escapes this file.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ type AdClient = ReturnType<typeof createAgentDeviceClient>;
31
+
32
+ class AgentDeviceBackend extends BaseDeviceBackend {
33
+ private readonly client: AdClient;
34
+ private readonly selection: Record<string, unknown>;
35
+ // Sessions are lazy daemon-side: nothing exists to close until a first real
36
+ // call is made, and asking the daemon anyway would SPAWN one on hosts where
37
+ // it isn't running (observed in the Mac verification sweep).
38
+ private used = false;
39
+
40
+ constructor(config?: DeviceConfig) {
41
+ super('agent-device', ALL_CAPABILITIES);
42
+ this.client = createAgentDeviceClient(
43
+ config &&
44
+ (config.session !== undefined ||
45
+ config.daemonBaseUrl !== undefined ||
46
+ config.daemonAuthToken !== undefined)
47
+ ? {
48
+ ...(config.session === undefined ? {} : { session: config.session }),
49
+ ...(config.daemonBaseUrl === undefined ? {} : { daemonBaseUrl: config.daemonBaseUrl }),
50
+ ...(config.daemonAuthToken === undefined ? {} : { daemonAuthToken: config.daemonAuthToken }),
51
+ }
52
+ : undefined,
53
+ );
54
+ this.selection = !config
55
+ ? {}
56
+ : {
57
+ platform: config.platform,
58
+ ...(config.device === undefined ? {} : { device: config.device }),
59
+ ...(config.platform === 'ios' && config.udid !== undefined ? { udid: config.udid } : {}),
60
+ ...(config.platform === 'ios' && config.simulatorDeviceSet !== undefined
61
+ ? { iosSimulatorDeviceSet: config.simulatorDeviceSet }
62
+ : {}),
63
+ ...(config.platform === 'android' && config.serial !== undefined ? { serial: config.serial } : {}),
64
+ };
65
+ }
66
+
67
+ private async guard<T>(capability: Capability, fn: () => Promise<T>): Promise<T> {
68
+ if (capability !== 'closeSession') this.used = true;
69
+ try {
70
+ return await fn();
71
+ } catch (error) {
72
+ throw toPhoneUseError(error, { backend: this.backendName, capability });
73
+ }
74
+ }
75
+
76
+ override snapshot(opts?: {
77
+ interactiveOnly?: boolean | undefined;
78
+ depth?: number | undefined;
79
+ }): Promise<Snapshot> {
80
+ return this.guard('snapshot', async () => {
81
+ const snap = await this.client.capture.snapshot({
82
+ ...this.selection,
83
+ ...(opts?.interactiveOnly === undefined ? {} : { interactiveOnly: opts.interactiveOnly }),
84
+ ...(opts?.depth === undefined ? {} : { depth: opts.depth }),
85
+ });
86
+ return { nodes: snap.nodes, appName: snap.appName, appBundleId: snap.appBundleId };
87
+ });
88
+ }
89
+
90
+ override screenshot(opts: { path: string; overlayRefs?: boolean | undefined }): Promise<{ path: string }> {
91
+ return this.guard('screenshot', async () => {
92
+ const result = await this.client.capture.screenshot({
93
+ ...this.selection,
94
+ path: opts.path,
95
+ ...(opts.overlayRefs === undefined ? {} : { overlayRefs: opts.overlayRefs }),
96
+ });
97
+ return { path: result.path };
98
+ });
99
+ }
100
+
101
+ override press(target: PressTarget): Promise<void> {
102
+ return this.guard('press', async () => {
103
+ await this.client.interactions.press({ ...this.selection, ...target });
104
+ });
105
+ }
106
+
107
+ override longPress(ref: string, durationMs?: number): Promise<void> {
108
+ return this.guard('longPress', async () => {
109
+ // settle: true preserved exactly from the pre-seam driver.
110
+ await this.client.interactions.longPress({
111
+ ...this.selection,
112
+ ref,
113
+ ...(durationMs === undefined ? {} : { durationMs }),
114
+ settle: true,
115
+ });
116
+ });
117
+ }
118
+
119
+ override fill(ref: string, text: string): Promise<void> {
120
+ return this.guard('fill', async () => {
121
+ await this.client.interactions.fill({ ...this.selection, ref, text });
122
+ });
123
+ }
124
+
125
+ override typeText(text: string): Promise<void> {
126
+ return this.guard('type', async () => {
127
+ await this.client.interactions.type({ ...this.selection, text });
128
+ });
129
+ }
130
+
131
+ override pressKey(_key: 'return'): Promise<void> {
132
+ return this.guard('key', async () => {
133
+ await this.client.command.keyboard({ ...this.selection, action: 'return' });
134
+ });
135
+ }
136
+
137
+ override scroll(direction: ScrollDirection): Promise<void> {
138
+ return this.guard('scroll', async () => {
139
+ const args = { ...this.selection, direction } as Parameters<AdClient['interactions']['scroll']>[0];
140
+ await this.client.interactions.scroll(args);
141
+ });
142
+ }
143
+
144
+ override pan(x: number, y: number, dx: number, dy: number, durationMs?: number): Promise<void> {
145
+ return this.guard('pan', async () => {
146
+ await this.client.interactions.pan({
147
+ ...this.selection,
148
+ x,
149
+ y,
150
+ dx,
151
+ dy,
152
+ ...(durationMs === undefined ? {} : { durationMs }),
153
+ });
154
+ });
155
+ }
156
+
157
+ override waitForText(text: string, timeoutMs?: number): Promise<void> {
158
+ return this.guard('waitForText', async () => {
159
+ await this.client.command.wait({
160
+ ...this.selection,
161
+ text,
162
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
163
+ });
164
+ });
165
+ }
166
+
167
+ override systemAlert(action: AlertAction): Promise<BackendAlertResult> {
168
+ return this.guard('alert', async () => {
169
+ const result = (await this.client.command.alert({ ...this.selection, action })) as {
170
+ alert?: { title?: string; message?: string; buttons?: string[] } | null;
171
+ handled?: boolean;
172
+ button?: string;
173
+ };
174
+ return { alert: result.alert, handled: result.handled, button: result.button };
175
+ });
176
+ }
177
+
178
+ override home(): Promise<void> {
179
+ return this.guard('home', async () => {
180
+ await this.client.command.home({ ...this.selection });
181
+ });
182
+ }
183
+
184
+ override back(): Promise<void> {
185
+ return this.guard('back', async () => {
186
+ await this.client.command.back({ ...this.selection });
187
+ });
188
+ }
189
+
190
+ override openApp(opts: {
191
+ app?: string | undefined;
192
+ url?: string | undefined;
193
+ relaunch?: boolean | undefined;
194
+ }): Promise<OpenAppResult> {
195
+ return this.guard('openApp', async () => {
196
+ // Preserve the pre-seam arg branching exactly:
197
+ // {app, relaunch} for app launches; {app, url} | {url} for deep links.
198
+ const args =
199
+ opts.url === undefined
200
+ ? { app: opts.app, ...(opts.relaunch === undefined ? {} : { relaunch: opts.relaunch }) }
201
+ : opts.app !== undefined
202
+ ? { app: opts.app, url: opts.url }
203
+ : { url: opts.url };
204
+ const result = (await this.client.apps.open({ ...this.selection, ...args } as Parameters<
205
+ AdClient['apps']['open']
206
+ >[0])) as { appName?: string; appBundleId?: string };
207
+ return { appName: result.appName, appBundleId: result.appBundleId };
208
+ });
209
+ }
210
+
211
+ override listApps(): Promise<string[]> {
212
+ return this.guard('listApps', async () => {
213
+ const result = (await this.client.apps.list(
214
+ Object.keys(this.selection).length
215
+ ? (this.selection as Parameters<AdClient['apps']['list']>[0])
216
+ : undefined,
217
+ )) as unknown as string[];
218
+ return result;
219
+ });
220
+ }
221
+
222
+ override closeSession(): Promise<void> {
223
+ if (!this.used) return Promise.resolve();
224
+ return this.guard('closeSession', async () => {
225
+ // Pre-seam behavior: close({}) — session override only when pinned.
226
+ await this.client.sessions.close({ ...this.selection });
227
+ });
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Build the agent-device backend: the ONE place agent-device is called. Device
233
+ * pinning is per-request in agent-device, so the backend holds a selection
234
+ * object and spreads it into every call. With no config, selection is `{}` and
235
+ * the client is default-constructed — requests are byte-identical to the
236
+ * pre-seam process-global path (booted-sim auto-detect). Every method
237
+ * normalizes errors via `toPhoneUseError`; no agent-device type or error ever
238
+ * escapes this module.
239
+ */
240
+ export function createAgentDeviceBackend(config?: DeviceConfig): DeviceBackend {
241
+ return new AgentDeviceBackend(config);
242
+ }
@@ -0,0 +1,262 @@
1
+ import type { IosDeviceConfig } from '../config.ts';
2
+ import { ActionFailedError, DeviceNotFoundError, TimeoutError, toPhoneUseError } from '../errors.ts';
3
+ import { defaultExecRunner, type ExecRunner, isExecError } from '../exec.ts';
4
+ import { createDeviceHandle, type Device } from '../lifecycle.ts';
5
+ import { createAgentDeviceBackend } from './agent-device.ts';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // The iOS engine (docs/19 §Lifecycle, engine-as-object): ios.launch() creates
9
+ // and boots a DEDICATED simulator via simctl — no more "whatever is booted" —
10
+ // and returns a Device whose backend is pinned to that udid. ios.connect()
11
+ // reattaches; its no-arg form is the sole survivor of the old booted-sim
12
+ // auto-detect. Scripts never branch on locality: connect(endpoint) for cloud
13
+ // devices is reserved API (docs/20 kill list — local-only this cycle).
14
+ //
15
+ // Created sims are named `phone-use-<hex>` deliberately: if a process is
16
+ // kill -9'd, the in-process reaper can't run, and the name prefix is how
17
+ // orphans are found (`xcrun simctl list devices -j` | filter the prefix).
18
+ // ---------------------------------------------------------------------------
19
+
20
+ type CommonIosOptions = {
21
+ /** Custom simulator device set directory (maps to `simctl --set`). */
22
+ simulatorDeviceSet?: string | undefined;
23
+ /** agent-device session/daemon pinning, passed through to the backend. */
24
+ session?: string | undefined;
25
+ daemonBaseUrl?: string | undefined;
26
+ daemonAuthToken?: string | undefined;
27
+ /** Idle lease window in ms (false disables). Default 180_000 (3 min). */
28
+ idleTimeoutMs?: number | false | undefined;
29
+ /** Observer for reaper-initiated closes. */
30
+ onIdleClose?: ((device: Device) => void) | undefined;
31
+ /** Initial %name% secret values (see Device.secrets). */
32
+ secrets?: Record<string, string> | undefined;
33
+ /** @internal harness seam — supplies the verb core (see createDeviceHandle). */
34
+ coreFactory?:
35
+ | ((backend: import('../backend.ts').DeviceBackend) => import('../observe.ts').DeviceCore)
36
+ | undefined;
37
+ /**
38
+ * Probe the agent-device daemon right away (one listApps) so a missing
39
+ * daemon fails at launch instead of on first use. Default false: sessions
40
+ * open lazily and a probe requires the daemon to exist.
41
+ */
42
+ failFast?: boolean | undefined;
43
+ /** @internal test seam — DI'd process runner (docs/19 test strategy). */
44
+ exec?: ExecRunner | undefined;
45
+ };
46
+
47
+ /** Options for `ios.launch()` — device type, runtime, name, boot ceiling. */
48
+ export type IosLaunchOptions = CommonIosOptions & {
49
+ /** simctl device type, e.g. "iPhone 16" (the default). */
50
+ deviceType?: string | undefined;
51
+ /** simctl runtime id; omitted → newest compatible. */
52
+ runtime?: string | undefined;
53
+ /** Simulator name; default `phone-use-<hex>` (the orphan-discovery prefix). */
54
+ name?: string | undefined;
55
+ /** Boot wait ceiling for `simctl bootstatus` (default 120_000 ms). */
56
+ bootTimeoutMs?: number | undefined;
57
+ };
58
+
59
+ /** Options for `ios.connect()`. */
60
+ export type IosConnectOptions = CommonIosOptions & {
61
+ /** Boot wait ceiling when connect has to boot a shut-down sim (default 120_000 ms). */
62
+ bootTimeoutMs?: number | undefined;
63
+ };
64
+
65
+ const UDID_RE = /^[0-9A-F]{8}(-[0-9A-F]{4}){3}-[0-9A-F]{12}$/i;
66
+
67
+ type SimctlDeviceRow = { udid: string; state: string; name?: string; isAvailable?: boolean };
68
+ type SimctlList = { devices: Record<string, SimctlDeviceRow[]> };
69
+
70
+ function simctlArgs(setPath: string | undefined, args: string[]): string[] {
71
+ return setPath === undefined ? ['simctl', ...args] : ['simctl', '--set', setPath, ...args];
72
+ }
73
+
74
+ // Exit 149 = "operation not allowed in current state" (already booted /
75
+ // already shut down). Code first, stderr regex as fallback — Apple rewords
76
+ // messages; the numeric code is a Mac-verification item (docs/20 as-built).
77
+ function isAlreadyInState(err: unknown): boolean {
78
+ if (!isExecError(err)) return false;
79
+ if (err.code === 149) return true;
80
+ return /current state.*(Booted|Shutdown)/i.test(err.stderr ?? '');
81
+ }
82
+
83
+ async function runSimctl(
84
+ exec: ExecRunner,
85
+ setPath: string | undefined,
86
+ args: string[],
87
+ opts: { timeoutMs?: number | undefined; tolerateState?: boolean | undefined } = {},
88
+ ): Promise<string> {
89
+ try {
90
+ const r = await exec(
91
+ 'xcrun',
92
+ simctlArgs(setPath, args),
93
+ opts.timeoutMs === undefined ? undefined : { timeoutMs: opts.timeoutMs },
94
+ );
95
+ return r.stdout;
96
+ } catch (err) {
97
+ if (opts.tolerateState && isAlreadyInState(err)) return '';
98
+ if (isExecError(err)) {
99
+ if (err.killed || err.signal) {
100
+ throw new TimeoutError(
101
+ `simctl ${args[0]} timed out${opts.timeoutMs ? ` after ${opts.timeoutMs}ms` : ''}`,
102
+ {
103
+ cause: err,
104
+ },
105
+ );
106
+ }
107
+ throw new ActionFailedError(
108
+ `simctl ${args[0]} failed (exit ${String(err.code ?? '?')}): ${(err.stderr ?? err.message).trim()}`,
109
+ { details: { backendCode: String(err.code ?? 'EXEC') }, cause: err },
110
+ );
111
+ }
112
+ throw toPhoneUseError(err);
113
+ }
114
+ }
115
+
116
+ function parseCreatedUdid(stdout: string): string {
117
+ const lines = stdout
118
+ .split('\n')
119
+ .map((l) => l.trim())
120
+ .filter(Boolean);
121
+ const last = lines[lines.length - 1] ?? '';
122
+ if (!UDID_RE.test(last)) {
123
+ throw new ActionFailedError(
124
+ `could not parse udid from simctl create output: ${JSON.stringify(stdout.slice(0, 200))}`,
125
+ );
126
+ }
127
+ return last;
128
+ }
129
+
130
+ function parseList(stdout: string): SimctlDeviceRow[] {
131
+ let parsed: SimctlList;
132
+ try {
133
+ parsed = JSON.parse(stdout) as SimctlList;
134
+ } catch (err) {
135
+ throw new ActionFailedError('could not parse simctl list output as JSON', { cause: err });
136
+ }
137
+ return Object.values(parsed.devices ?? {}).flat();
138
+ }
139
+
140
+ function makeBackendConfig(udid: string, opts: CommonIosOptions): IosDeviceConfig {
141
+ return {
142
+ platform: 'ios',
143
+ udid,
144
+ ...(opts.simulatorDeviceSet === undefined ? {} : { simulatorDeviceSet: opts.simulatorDeviceSet }),
145
+ // The daemon binds one session to one device: a udid-pinned Device on the
146
+ // shared "default" session collides with whatever bound it first (live
147
+ // Mac finding). A udid-derived session name is what makes two Devices
148
+ // independent; deterministic so reconnects reuse the same session.
149
+ session: opts.session ?? `phone-use-${udid}`,
150
+ ...(opts.daemonBaseUrl === undefined ? {} : { daemonBaseUrl: opts.daemonBaseUrl }),
151
+ ...(opts.daemonAuthToken === undefined ? {} : { daemonAuthToken: opts.daemonAuthToken }),
152
+ };
153
+ }
154
+
155
+ async function bootAndWait(
156
+ exec: ExecRunner,
157
+ setPath: string | undefined,
158
+ udid: string,
159
+ bootTimeoutMs: number,
160
+ ): Promise<void> {
161
+ await runSimctl(exec, setPath, ['boot', udid], { tolerateState: true });
162
+ // -b boots if needed, closing the boot/bootstatus race; blocks until booted.
163
+ await runSimctl(exec, setPath, ['bootstatus', udid, '-b'], { timeoutMs: bootTimeoutMs });
164
+ }
165
+
166
+ async function finishHandle(
167
+ udid: string,
168
+ name: string | undefined,
169
+ createdByUs: boolean,
170
+ opts: CommonIosOptions,
171
+ exec: ExecRunner,
172
+ ): Promise<Device> {
173
+ const backend = createAgentDeviceBackend(makeBackendConfig(udid, opts));
174
+ if (opts.failFast) await backend.listApps();
175
+ return createDeviceHandle({
176
+ id: udid,
177
+ platform: 'ios',
178
+ name,
179
+ backend,
180
+ createdByUs,
181
+ idleTimeoutMs: opts.idleTimeoutMs,
182
+ onIdleClose: opts.onIdleClose,
183
+ secrets: opts.secrets,
184
+ coreFactory: opts.coreFactory,
185
+ doClose: async () => {
186
+ await runSimctl(exec, opts.simulatorDeviceSet, ['shutdown', udid], { tolerateState: true });
187
+ if (createdByUs) await runSimctl(exec, opts.simulatorDeviceSet, ['delete', udid]);
188
+ },
189
+ });
190
+ }
191
+
192
+ /**
193
+ * Create and boot a DEDICATED simulator via simctl — no more "whatever is
194
+ * booted" — and return a {@link Device} pinned to its udid. Created sims are
195
+ * named `phone-use-<hex>` deliberately: if the process is kill -9'd the
196
+ * in-process reaper can't run, and the name prefix is how orphans are found.
197
+ * `close()` shuts the sim down AND deletes it (we created it); a failed boot
198
+ * best-effort-deletes before rethrowing.
199
+ */
200
+ async function launch(options: IosLaunchOptions = {}): Promise<Device> {
201
+ const exec = options.exec ?? defaultExecRunner;
202
+ const deviceType = options.deviceType ?? 'iPhone 16';
203
+ const name = options.name ?? `phone-use-${Math.random().toString(16).slice(2, 10)}`;
204
+ const bootTimeoutMs = options.bootTimeoutMs ?? 120_000;
205
+
206
+ const createArgs = [
207
+ 'create',
208
+ name,
209
+ deviceType,
210
+ ...(options.runtime === undefined ? [] : [options.runtime]),
211
+ ];
212
+ const udid = parseCreatedUdid(await runSimctl(exec, options.simulatorDeviceSet, createArgs));
213
+
214
+ try {
215
+ await bootAndWait(exec, options.simulatorDeviceSet, udid, bootTimeoutMs);
216
+ } catch (err) {
217
+ // We created it and it never booted — best-effort delete so the failure
218
+ // doesn't leak a sim, then rethrow the original error.
219
+ await runSimctl(exec, options.simulatorDeviceSet, ['delete', udid]).catch(() => undefined);
220
+ throw err;
221
+ }
222
+
223
+ return finishHandle(udid, name, true, options, exec);
224
+ }
225
+
226
+ /**
227
+ * Reattach to an existing simulator by udid (booting it if shut down). The
228
+ * no-arg form is the sole survivor of the old booted-sim auto-detect: it
229
+ * attaches to the first booted, available sim. `close()` on a connected
230
+ * device shuts it down but never deletes it.
231
+ */
232
+ async function connect(udid?: string, options: IosConnectOptions = {}): Promise<Device> {
233
+ const exec = options.exec ?? defaultExecRunner;
234
+ const bootTimeoutMs = options.bootTimeoutMs ?? 120_000;
235
+
236
+ if (udid === undefined) {
237
+ // The surviving auto-detect: attach to the first booted, available sim.
238
+ const rows = parseList(
239
+ await runSimctl(exec, options.simulatorDeviceSet, ['list', 'devices', 'booted', '-j']),
240
+ );
241
+ const booted = rows.find((d) => d.state === 'Booted' && d.isAvailable !== false);
242
+ if (!booted) throw new DeviceNotFoundError('no booted simulator — use ios.launch() or boot one');
243
+ return finishHandle(booted.udid, booted.name, false, options, exec);
244
+ }
245
+
246
+ const rows = parseList(await runSimctl(exec, options.simulatorDeviceSet, ['list', 'devices', '-j']));
247
+ const row = rows.find((d) => d.udid.toLowerCase() === udid.toLowerCase());
248
+ if (!row) throw new DeviceNotFoundError(`no simulator with udid ${udid}`);
249
+ if (row.state !== 'Booted') await bootAndWait(exec, options.simulatorDeviceSet, row.udid, bootTimeoutMs);
250
+ return finishHandle(row.udid, row.name, false, options, exec);
251
+ }
252
+
253
+ /**
254
+ * The iOS engine object (Playwright-style): `ios.launch()` for a dedicated
255
+ * simulator, `ios.connect()` to reattach. Both return the same Device type.
256
+ */
257
+ export const ios = {
258
+ /** Create + boot a dedicated simulator and return a Device pinned to it. */
259
+ launch,
260
+ /** Reattach to an existing simulator (no-arg: first booted sim). */
261
+ connect,
262
+ } as const;