Skip to content

Commit 1454cc0

Browse files
committed
Add iOS device runner support and document platform matrix
1 parent 37d1faf commit 1454cc0

27 files changed

Lines changed: 557 additions & 99 deletions

README.md

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ CLI to control iOS and Android devices for AI agents influenced by Vercel’s [a
1313
The project is in early development and considered experimental. Pull requests are welcome!
1414

1515
## Features
16-
- Platforms: iOS (simulator + limited device support) and Android (emulator + device).
16+
- Platforms: iOS (simulator + physical device core automation) and Android (emulator + device).
1717
- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`, `reinstall`.
1818
- Inspection commands: `snapshot` (accessibility tree).
1919
- Device tooling: `adb` (Android), `simctl`/`devicectl` (iOS via Xcode).
@@ -91,9 +91,10 @@ Coordinates:
9191
| `ax` | Fast | Medium | Accessibility permission for the terminal app, not recommended |
9292

9393
Notes:
94-
- Default backend is `xctest` on iOS.
94+
- Default backend is `xctest` on iOS simulators and iOS devices.
9595
- Scope snapshots with `-s "<label>"` or `-s @ref`.
96-
- If XCTest returns 0 nodes (e.g., foreground app changed), agent-device falls back to AX when available.
96+
- If XCTest returns 0 nodes (e.g., foreground app changed), agent-device falls back to AX on simulators when available.
97+
- `ax` backend is simulator-only.
9798

9899
Flags:
99100
- `--version, -V` print version and exit
@@ -184,14 +185,14 @@ Android fill reliability:
184185
- If value does not match, agent-device clears the field and retries once with slower typing.
185186
- This reduces IME-related character swaps on long strings (e.g. emails and IDs).
186187

187-
Settings helpers (simulators):
188+
Settings helpers:
188189
- `settings wifi on|off`
189190
- `settings airplane on|off`
190191
- `settings location on|off` (iOS uses per-app permission for the current session app)
191-
Note: iOS wifi/airplane toggles status bar indicators, not actual network state. Airplane off clears status bar overrides.
192+
Note: iOS supports these only on simulators in v1. iOS wifi/airplane toggles status bar indicators, not actual network state. Airplane off clears status bar overrides.
192193

193194
App state:
194-
- `appstate` shows the foreground app/activity (Android). On iOS it uses the current session app when available, otherwise it falls back to a snapshot-based guess (AX first, XCTest if AX can’t identify).
195+
- `appstate` shows the foreground app/activity (Android). On iOS it uses the current session app when available, otherwise it falls back to a snapshot-based guess (`xctest` on devices; AX-first on simulators with XCTest fallback).
195196
- `apps --metadata` returns app list with minimal metadata.
196197

197198
## Debug
@@ -215,9 +216,10 @@ Boot diagnostics:
215216
- Built-in aliases include `Settings` for both platforms.
216217

217218
## iOS notes
218-
- Input commands (`press`, `type`, `scroll`, etc.) are supported only on simulators in v1 and use the XCTest runner.
219-
- `alert` and `scrollintoview` use the XCTest runner and are simulator-only in v1.
220-
- Real device support (including snapshots) is on the roadmap for iOS.
219+
- Core runner commands (`snapshot`, `wait`, `click`, `fill`, `get`, `is`, `find`, `press`, `long-press`, `focus`, `type`, `scroll`, `scrollintoview`, `back`, `home`, `app-switcher`) support iOS simulators and iOS devices.
220+
- Simulator-only commands in v1: `alert`, `pinch`, `record`, `reinstall`, `apps`, `settings`.
221+
- iOS deep link open (`open <url>`) is simulator-only in v1.
222+
- iOS device runs require valid signing/provisioning (Automatic Signing recommended). Optional overrides: `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`.
221223

222224
## Testing
223225

@@ -243,6 +245,10 @@ Environment selectors:
243245
- `ANDROID_DEVICE=Pixel_9_Pro_XL` or `ANDROID_SERIAL=emulator-5554`
244246
- `IOS_DEVICE="iPhone 17 Pro"` or `IOS_UDID=<udid>`
245247
- `AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS=<ms>` to adjust iOS simulator boot timeout (default: `120000`, minimum: `5000`).
248+
- `AGENT_DEVICE_DAEMON_TIMEOUT_MS=<ms>` to increase daemon request timeout for slow first-run iOS device setup (for example `180000`).
249+
- `AGENT_DEVICE_IOS_TEAM_ID=<team-id>` optional Team ID override for iOS device runner signing.
250+
- `AGENT_DEVICE_IOS_SIGNING_IDENTITY=<identity>` optional signing identity override (defaults to `Apple Development` when signing overrides are used).
251+
- `AGENT_DEVICE_IOS_PROVISIONING_PROFILE=<profile>` optional provisioning profile specifier for iOS device runner signing.
246252

247253
Test screenshots are written to:
248254
- `test/screenshots/android-settings.png`

skills/agent-device/SKILL.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: agent-device
3-
description: Automates mobile and simulator interactions for iOS and Android devices. Use when navigating apps, taking snapshots/screenshots, tapping, typing, scrolling, pinching, or extracting UI info on mobile devices or simulators.
3+
description: Automates interactions for iOS simulators/devices and Android emulators/devices. Use when navigating apps, taking snapshots/screenshots, tapping, typing, scrolling, or extracting UI info on mobile targets.
44
---
55

66
# Mobile Automation with agent-device
@@ -39,13 +39,13 @@ npx -y agent-device
3939

4040
```bash
4141
agent-device boot # Ensure target is booted/ready without opening app
42-
agent-device boot --platform ios # Boot iOS simulator
42+
agent-device boot --platform ios # Boot iOS simulator/device target
4343
agent-device boot --platform android # Boot Android emulator/device target
4444
agent-device open [app|url] # Boot device/simulator; optionally launch app or deep link URL
4545
agent-device open [app] --relaunch # Terminate app process first, then launch (fresh runtime)
4646
agent-device open [app] --activity com.example/.MainActivity # Android: open specific activity (app targets only)
4747
agent-device open "myapp://home" --platform android # Android deep link
48-
agent-device open "https://example.com" --platform ios # iOS simulator deep link
48+
agent-device open "https://example.com" --platform ios # iOS simulator deep link (device unsupported in v1)
4949
agent-device close [app] # Close app or just end session
5050
agent-device reinstall <app> <path> # Uninstall + install app in one command
5151
agent-device session list # List active sessions
@@ -82,7 +82,7 @@ agent-device find "Settings" wait 10000
8282
agent-device find "Settings" exists
8383
```
8484

85-
### Settings helpers (simulators)
85+
### Settings helpers
8686

8787
```bash
8888
agent-device settings wifi on
@@ -95,6 +95,7 @@ agent-device settings location off
9595

9696
Note: iOS wifi/airplane toggles status bar indicators, not actual network state.
9797
Airplane off clears status bar overrides.
98+
iOS settings helpers are simulator-only in v1.
9899

99100
### App state
100101

@@ -114,7 +115,7 @@ agent-device type "text" # Type into focused field without clearin
114115
agent-device press 300 500 # Tap by coordinates
115116
agent-device long-press 300 500 800 # Long press (where supported)
116117
agent-device scroll down 0.5
117-
agent-device pinch 2.0 # Zoom in 2x (iOS simulator + Android)
118+
agent-device pinch 2.0 # Zoom in 2x (iOS simulator only)
118119
agent-device pinch 0.5 200 400 # Zoom out at coordinates
119120
agent-device back
120121
agent-device home
@@ -167,19 +168,21 @@ agent-device apps --platform android --user-installed
167168

168169
## Best practices
169170

170-
- Pinch (`pinch <scale> [x y]`) is supported on iOS simulators and Android; scale > 1 zooms in, < 1 zooms out. On Android, pinch uses multi-touch `sendevent` injection.
171+
- Pinch (`pinch <scale> [x y]`) is iOS simulator-only in v1; scale > 1 zooms in, < 1 zooms out.
171172
- Snapshot refs are the core mechanism for interactive agent flows.
172173
- Use selectors for deterministic replay artifacts and assertions (e.g. in e2e test workflows).
173174
- Prefer `snapshot -i` to reduce output size.
174175
- On iOS, `xctest` is the default and does not require Accessibility permission.
175-
- If XCTest returns 0 nodes (foreground app changed), agent-device falls back to AX when available.
176+
- If XCTest returns 0 nodes (foreground app changed), agent-device falls back to AX on simulators when available.
176177
- `open <app|url>` can be used within an existing session to switch apps or open deep links.
177178
- `open <app>` updates session app bundle context; URL opens do not set an app bundle id.
178179
- Use `open <app> --relaunch` during React Native/Fast Refresh debugging when you need a fresh app process without ending the session.
179180
- If AX returns the Simulator window or empty tree, restart Simulator or use `--backend xctest`.
180181
- Use `--session <name>` for parallel sessions; avoid device contention.
181182
- Use `--activity <component>` on Android to launch a specific activity (e.g. TV apps with LEANBACK); do not combine with URL opens.
182183
- iOS deep-link opens are simulator-only in v1.
184+
- iOS physical-device runner requires Xcode signing/provisioning; optional overrides: `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`.
185+
- For long first-run physical-device setup/build, increase daemon timeout: `AGENT_DEVICE_DAEMON_TIMEOUT_MS=180000` (or higher).
183186
- Use `fill` when you want clear-then-type semantics.
184187
- Use `type` when you want to append/enter text without clearing.
185188
- On Android, prefer `fill` for important fields; it verifies entered text and retries once when IME reorders characters.

skills/agent-device/references/permissions.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,20 @@ agent-device snapshot --backend xctest --platform ios
1313
```
1414

1515
Hybrid/AX is fast; XCTest is equally fast but does not require permissions.
16+
AX backend is simulator-only in v1.
17+
18+
## iOS physical device runner
19+
20+
For iOS physical devices, XCTest runner setup requires valid signing/provisioning.
21+
Use Automatic Signing in Xcode, or provide optional overrides:
22+
23+
- `AGENT_DEVICE_IOS_TEAM_ID`
24+
- `AGENT_DEVICE_IOS_SIGNING_IDENTITY`
25+
- `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`
26+
27+
If first-run setup/build takes long, increase:
28+
29+
- `AGENT_DEVICE_DAEMON_TIMEOUT_MS` (for example `180000`)
1630

1731
## Simulator troubleshooting
1832

skills/agent-device/references/session-management.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Sessions isolate device context. A device can only be held by one session at a t
1414
- Name sessions semantically.
1515
- Close sessions when done.
1616
- Use separate sessions for parallel work.
17+
- In iOS sessions, use `open <app>` for simulator/device. `open <url>` is simulator-only in v1.
1718
- For dev loops where runtime state can persist (for example React Native Fast Refresh), use `open <app> --relaunch` to restart the app process in the same session.
1819
- For deterministic replay scripts, prefer selector-based actions and assertions.
1920
- Use `replay -u` to update selector drift during maintenance.

skills/agent-device/references/snapshot-refs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ agent-device snapshot -i -s @e3
5555
- Ref not found: re-snapshot.
5656
- AX returns Simulator window: restart Simulator and re-run.
5757
- AX empty: verify Accessibility permission or use `--backend xctest` (XCTest is more complete).
58+
- AX backend is simulator-only in v1; use `--backend xctest` on iOS devices.
5859

5960
## Replay note
6061

skills/agent-device/references/video-recording.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ agent-device close
2020
agent-device record stop
2121
```
2222

23+
`record` is iOS simulator-only in v1.
24+
2325
## Android Emulator/Device
2426

2527
Use `agent-device record` commands (wrapper around adb):

src/core/__tests__/capabilities.test.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,17 @@ test('iOS simulator-only commands reject iOS devices and Android', () => {
3232
}
3333
});
3434

35-
test('iOS simulator + Android commands reject iOS devices', () => {
35+
test('simulator-only iOS commands with Android support reject iOS devices', () => {
36+
for (const cmd of ['apps', 'reinstall', 'record', 'settings']) {
37+
assert.equal(isCommandSupportedOnDevice(cmd, iosSimulator), true, `${cmd} on iOS sim`);
38+
assert.equal(isCommandSupportedOnDevice(cmd, iosDevice), false, `${cmd} on iOS device`);
39+
assert.equal(isCommandSupportedOnDevice(cmd, androidDevice), true, `${cmd} on Android`);
40+
}
41+
});
42+
43+
test('core commands support iOS simulator, iOS device, and Android', () => {
3644
for (const cmd of [
3745
'app-switcher',
38-
'apps',
3946
'back',
4047
'boot',
4148
'click',
@@ -47,18 +54,16 @@ test('iOS simulator + Android commands reject iOS devices', () => {
4754
'home',
4855
'long-press',
4956
'open',
50-
'reinstall',
5157
'press',
52-
'record',
5358
'screenshot',
5459
'scroll',
55-
'settings',
60+
'scrollintoview',
5661
'snapshot',
5762
'type',
5863
'wait',
5964
]) {
6065
assert.equal(isCommandSupportedOnDevice(cmd, iosSimulator), true, `${cmd} on iOS sim`);
61-
assert.equal(isCommandSupportedOnDevice(cmd, iosDevice), false, `${cmd} on iOS device`);
66+
assert.equal(isCommandSupportedOnDevice(cmd, iosDevice), true, `${cmd} on iOS device`);
6267
assert.equal(isCommandSupportedOnDevice(cmd, androidDevice), true, `${cmd} on Android`);
6368
}
6469
});

src/core/capabilities.ts

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,29 +16,30 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
1616
// iOS simulator-only in v1.
1717
alert: { ios: { simulator: true }, android: {} },
1818
pinch: { ios: { simulator: true }, android: {} },
19-
'app-switcher': { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
19+
'app-switcher': { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
2020
apps: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
21-
back: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
22-
boot: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
23-
click: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
24-
close: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
25-
fill: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
26-
find: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
27-
focus: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
28-
get: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
29-
is: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
30-
home: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
31-
'long-press': { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
32-
open: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
21+
back: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
22+
boot: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
23+
click: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
24+
close: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
25+
fill: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
26+
find: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
27+
focus: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
28+
get: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
29+
is: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
30+
home: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
31+
'long-press': { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
32+
open: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3333
reinstall: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
34-
press: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
34+
press: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3535
record: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
36-
screenshot: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
37-
scroll: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
36+
screenshot: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
37+
scroll: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
38+
scrollintoview: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3839
settings: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
39-
snapshot: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
40-
type: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
41-
wait: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
40+
snapshot: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
41+
type: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
42+
wait: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
4243
};
4344

4445
export function isCommandSupportedOnDevice(command: string, device: DeviceInfo): boolean {

src/core/dispatch.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,12 @@ export async function dispatchCommand(
239239
case 'snapshot': {
240240
const backend = context?.snapshotBackend ?? 'xctest';
241241
if (device.platform === 'ios') {
242+
if (backend === 'ax' && device.kind !== 'simulator') {
243+
throw new AppError(
244+
'UNSUPPORTED_OPERATION',
245+
'AX snapshot backend is not supported on iOS physical devices; use --backend xctest',
246+
);
247+
}
242248
if (backend === 'ax') {
243249
const ax = await snapshotAx(device, { traceLogPath: context?.traceLogPath });
244250
return { nodes: ax.nodes ?? [], truncated: false, backend: 'ax' };
@@ -257,7 +263,7 @@ export async function dispatchCommand(
257263
{ verbose: context?.verbose, logPath: context?.logPath, traceLogPath: context?.traceLogPath },
258264
)) as { nodes?: RawSnapshotNode[]; truncated?: boolean };
259265
const nodes = result.nodes ?? [];
260-
if (nodes.length === 0) {
266+
if (nodes.length === 0 && device.kind === 'simulator') {
261267
try {
262268
const ax = await snapshotAx(device, { traceLogPath: context?.traceLogPath });
263269
return { nodes: ax.nodes ?? [], truncated: false, backend: 'ax' };

src/daemon.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ function start(): void {
175175
const shutdown = async () => {
176176
const sessionsToStop = sessionStore.toArray();
177177
for (const session of sessionsToStop) {
178-
if (session.device.platform === 'ios' && session.device.kind === 'simulator') {
178+
if (session.device.platform === 'ios') {
179179
await stopIosRunnerSession(session.device.id);
180180
}
181181
sessionStore.writeSessionLog(session);

0 commit comments

Comments
 (0)