Skip to content

Commit 2f64e84

Browse files
authored
feat: iOS device deep link support and web links (#66)
* feat: add iOS device URL session fallback behavior * chore: trim PR branch naming note * docs: remove simulator/device support phrasing * feat: unify iOS deep link behavior across simulator and device * feat: support iOS open <app> <url> flow * fix: validate iOS open app+url argument shape * docs: remove redundant iOS deep-link note
1 parent ac07890 commit 2f64e84

18 files changed

Lines changed: 559 additions & 50 deletions

File tree

AGENTS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,11 @@ Run integration tests when behavior crosses platform boundaries:
103103
- Unit tests: `pnpm test:unit`
104104
- Smoke tests: `pnpm test:smoke`
105105
- Integration tests: `pnpm test:integration`
106+
107+
## Pull Requests
108+
- Before opening PR: ensure no conflict markers and no unmerged paths.
109+
- Run required checks for touched scope (at minimum `pnpm typecheck`; plus test commands from **Testing** above).
110+
- PR body must be short and include:
111+
- `## Summary` with key behavior changes
112+
- `## Validation` with exact commands run
113+
- Call out known gaps or follow-ups explicitly; do not hide failing checks.

README.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ agent-device swipe 540 1500 540 500 120 --count 8 --pause-ms 30 --pattern ping-p
9999
| `ax` | Fast | Medium | Accessibility permission for the terminal app, not recommended |
100100

101101
Notes:
102-
- Default backend is `xctest` on iOS simulators and iOS devices.
102+
- Default backend is `xctest`.
103103
- Scope snapshots with `-s "<label>"` or `-s @ref`.
104104
- If XCTest returns 0 nodes (e.g., foreground app changed), agent-device fails explicitly.
105105
- `ax` backend is simulator-only.
@@ -153,19 +153,23 @@ Sessions:
153153
Navigation helpers:
154154
- `boot --platform ios|android` ensures the target is ready without launching an app.
155155
- Use `boot` mainly when starting a new session and `open` fails because no booted simulator/emulator is available.
156-
- `open [app|url]` already boots/activates the selected target when needed.
156+
- `open [app|url] [url]` already boots/activates the selected target when needed.
157157
- `reinstall <app> <path>` uninstalls and installs the app binary in one command (Android + iOS simulator).
158158
- `reinstall` accepts package/bundle id style app names and supports `~` in paths.
159159

160160
Deep links:
161161
- `open <url>` supports deep links with `scheme://...`.
162+
- `open <app> <url>` opens a deep link on iOS.
162163
- Android opens deep links via `VIEW` intent.
163-
- iOS deep link open is simulator-only.
164+
- iOS simulator opens deep links via `simctl openurl`.
165+
- iOS device opens deep links via `devicectl --payload-url`.
166+
- On iOS devices, `http(s)://` URLs open in Safari when no app is active. Custom scheme URLs (`myapp://`) require an active app in the session.
164167
- `--activity` cannot be combined with URL opens.
165168

166169
```bash
167170
agent-device open "myapp://home" --platform android
168-
agent-device open "https://example.com" --platform ios
171+
agent-device open "https://example.com" --platform ios # open link in web browser
172+
agent-device open MyApp "myapp://screen/to" --platform ios # open deep link to MyApp
169173
```
170174

171175
Find (semantic):
@@ -220,7 +224,6 @@ Note: iOS supports these only on simulators. iOS wifi/airplane toggles status ba
220224
App state:
221225
- `appstate` shows the foreground app/activity (Android).
222226
- On iOS, `appstate` returns the currently tracked session app (`source: session`) and requires an active session on the selected device.
223-
- `apps` supports Android, iOS simulators, and iOS devices.
224227
- `apps` includes default/system apps by default (use `--user-installed` to filter).
225228

226229
## Debug
@@ -245,10 +248,8 @@ Boot diagnostics:
245248
- Built-in aliases include `Settings` for both platforms.
246249

247250
## iOS notes
248-
- 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.
249-
- `apps` is supported on both iOS simulators and iOS devices.
251+
- Core runner commands: `snapshot`, `wait`, `click`, `fill`, `get`, `is`, `find`, `press`, `long-press`, `focus`, `type`, `scroll`, `scrollintoview`, `back`, `home`, `app-switcher`.
250252
- Simulator-only commands: `alert`, `pinch`, `record`, `reinstall`, `settings`.
251-
- iOS deep link open (`open <url>`) is simulator-only.
252253
- 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`.
253254

254255
## Testing

skills/agent-device/SKILL.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ npx -y agent-device
2727

2828
## Core workflow
2929

30-
1. Open app or deep link: `open [app|url]` (`open` handles target selection + boot/activation in the normal flow)
30+
1. Open app or deep link: `open [app|url] [url]` (`open` handles target selection + boot/activation in the normal flow)
3131
2. Snapshot: `snapshot` to get refs from accessibility tree
3232
3. Interact using refs (`click @ref`, `fill @ref "text"`)
3333
4. Re-snapshot after navigation/UI changes
@@ -39,13 +39,14 @@ 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/device target
42+
agent-device boot --platform ios # Boot iOS target
4343
agent-device boot --platform android # Boot Android emulator/device target
44-
agent-device open [app|url] # Boot device/simulator; optionally launch app or deep link URL
44+
agent-device open [app|url] [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 (device unsupported)
48+
agent-device open "https://example.com" --platform ios # iOS deep link (opens in browser)
49+
agent-device open MyApp "myapp://screen/to" --platform ios # iOS deep link in app context
4950
agent-device close [app] # Close app or just end session
5051
agent-device reinstall <app> <path> # Uninstall + install app in one command
5152
agent-device session list # List active sessions
@@ -188,13 +189,13 @@ agent-device apps --platform android --user-installed
188189
- Prefer `snapshot -i` to reduce output size.
189190
- On iOS, `xctest` is the default and does not require Accessibility permission.
190191
- If XCTest returns 0 nodes (foreground app changed), treat it as an explicit failure and retry the flow/app state.
191-
- `open <app|url>` can be used within an existing session to switch apps or open deep links.
192-
- `open <app>` updates session app bundle context; URL opens do not set an app bundle id.
192+
- `open <app|url> [url]` can be used within an existing session to switch apps or open deep links.
193+
- `open <app>` updates session app bundle context; `open <app> <url>` opens a deep link on iOS.
193194
- Use `open <app> --relaunch` during React Native/Fast Refresh debugging when you need a fresh app process without ending the session.
194195
- If AX returns the Simulator window or empty tree, restart Simulator or use `--backend xctest`.
195196
- Use `--session <name>` for parallel sessions; avoid device contention.
196197
- Use `--activity <component>` on Android to launch a specific activity (e.g. TV apps with LEANBACK); do not combine with URL opens.
197-
- iOS deep-link opens are simulator-only.
198+
- On iOS devices, `http(s)://` URLs fall back to Safari automatically; custom scheme URLs require an active app in the session.
198199
- 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`.
199200
- Default daemon request timeout is `45000`ms. For slow physical-device setup/build, increase `AGENT_DEVICE_DAEMON_TIMEOUT_MS` (for example `120000`).
200201
- For daemon startup troubleshooting, follow stale metadata hints for `~/.agent-device/daemon.json` / `~/.agent-device/daemon.lock`.

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ 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.
17+
- In iOS sessions, use `open <app>`. `open <url>` opens deep links; on devices `http(s)://` opens Safari when no app is active, and custom schemes require an active app in the session.
18+
- In iOS sessions, `open <app> <url>` opens a deep link.
1819
- On iOS, `appstate` is session-scoped and requires a matching active session on the target device.
1920
- 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.
2021
- Use `--save-script [path]` to record replay scripts on `close`; path is a file path and parent directories are created automatically.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { dispatchCommand } from '../dispatch.ts';
4+
import { AppError } from '../../utils/errors.ts';
5+
import type { DeviceInfo } from '../../utils/device.ts';
6+
7+
test('dispatch open rejects URL as first argument when second URL is provided', async () => {
8+
const device: DeviceInfo = {
9+
platform: 'ios',
10+
id: 'sim-1',
11+
name: 'iPhone 15',
12+
kind: 'simulator',
13+
booted: true,
14+
};
15+
16+
await assert.rejects(
17+
() => dispatchCommand(device, 'open', ['myapp://first', 'myapp://second']),
18+
(error: unknown) => {
19+
assert.equal(error instanceof AppError, true);
20+
assert.equal((error as AppError).code, 'INVALID_ARGS');
21+
assert.match((error as AppError).message, /requires an app target as the first argument/i);
22+
return true;
23+
},
24+
);
25+
});

src/core/__tests__/open-target.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import test from 'node:test';
22
import assert from 'node:assert/strict';
3-
import { isDeepLinkTarget } from '../open-target.ts';
3+
import {
4+
IOS_SAFARI_BUNDLE_ID,
5+
isDeepLinkTarget,
6+
isWebUrl,
7+
resolveIosDeviceDeepLinkBundleId,
8+
} from '../open-target.ts';
49

510
test('isDeepLinkTarget accepts URL-style deep links', () => {
611
assert.equal(isDeepLinkTarget('myapp://home'), true);
@@ -14,3 +19,37 @@ test('isDeepLinkTarget rejects app identifiers and malformed URLs', () => {
1419
assert.equal(isDeepLinkTarget('settings'), false);
1520
assert.equal(isDeepLinkTarget('http:/x'), false);
1621
});
22+
23+
test('isWebUrl accepts http and https URLs', () => {
24+
assert.equal(isWebUrl('https://example.com'), true);
25+
assert.equal(isWebUrl('http://example.com/path'), true);
26+
assert.equal(isWebUrl('https://example.com/path?q=1'), true);
27+
});
28+
29+
test('isWebUrl rejects custom schemes and non-URLs', () => {
30+
assert.equal(isWebUrl('myapp://home'), false);
31+
assert.equal(isWebUrl('tel:123456789'), false);
32+
assert.equal(isWebUrl('com.example.app'), false);
33+
assert.equal(isWebUrl('settings'), false);
34+
});
35+
36+
test('resolveIosDeviceDeepLinkBundleId prefers active app context', () => {
37+
assert.equal(
38+
resolveIosDeviceDeepLinkBundleId('com.example.app', 'myapp://home'),
39+
'com.example.app',
40+
);
41+
});
42+
43+
test('resolveIosDeviceDeepLinkBundleId falls back to Safari for web URLs', () => {
44+
assert.equal(
45+
resolveIosDeviceDeepLinkBundleId(undefined, 'https://example.com/path'),
46+
IOS_SAFARI_BUNDLE_ID,
47+
);
48+
});
49+
50+
test('resolveIosDeviceDeepLinkBundleId returns undefined for custom scheme without app context', () => {
51+
assert.equal(
52+
resolveIosDeviceDeepLinkBundleId(undefined, 'myapp://home'),
53+
undefined,
54+
);
55+
});

src/core/dispatch.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { getInteractor, type RunnerContext } from '../utils/interactors.ts';
1616
import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts';
1717
import { snapshotAx } from '../platforms/ios/ax-snapshot.ts';
1818
import { setIosSetting } from '../platforms/ios/index.ts';
19+
import { isDeepLinkTarget } from './open-target.ts';
1920
import type { RawSnapshotNode } from '../utils/snapshot.ts';
2021
import type { CliFlags } from '../utils/command-schema.ts';
2122

@@ -89,10 +90,31 @@ export async function dispatchCommand(
8990
switch (command) {
9091
case 'open': {
9192
const app = positionals[0];
93+
const url = positionals[1];
94+
if (positionals.length > 2) {
95+
throw new AppError('INVALID_ARGS', 'open accepts at most two arguments: <app|url> [url]');
96+
}
9297
if (!app) {
9398
await interactor.openDevice();
9499
return { app: null };
95100
}
101+
if (url !== undefined) {
102+
if (device.platform !== 'ios') {
103+
throw new AppError('INVALID_ARGS', 'open <app> <url> is supported only on iOS');
104+
}
105+
if (isDeepLinkTarget(app)) {
106+
throw new AppError('INVALID_ARGS', 'open <app> <url> requires an app target as the first argument');
107+
}
108+
if (!isDeepLinkTarget(url)) {
109+
throw new AppError('INVALID_ARGS', 'open <app> <url> requires a valid URL target');
110+
}
111+
await interactor.open(app, {
112+
activity: context?.activity,
113+
appBundleId: context?.appBundleId,
114+
url,
115+
});
116+
return { app, url };
117+
}
96118
await interactor.open(app, { activity: context?.activity, appBundleId: context?.appBundleId });
97119
return { app };
98120
}

src/core/open-target.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,17 @@ export function isDeepLinkTarget(input: string): boolean {
1111
}
1212
return true;
1313
}
14+
15+
export function isWebUrl(input: string): boolean {
16+
const scheme = input.trim().split(':')[0]?.toLowerCase();
17+
return scheme === 'http' || scheme === 'https';
18+
}
19+
20+
export const IOS_SAFARI_BUNDLE_ID = 'com.apple.mobilesafari';
21+
22+
export function resolveIosDeviceDeepLinkBundleId(appBundleId: string | undefined, url: string): string | undefined {
23+
const bundleId = appBundleId?.trim();
24+
if (bundleId) return bundleId;
25+
if (isWebUrl(url)) return IOS_SAFARI_BUNDLE_ID;
26+
return undefined;
27+
}

0 commit comments

Comments
 (0)