Skip to content

Commit be5a9c2

Browse files
committed
feat: add iOS device URL session fallback behavior
1 parent 17940c1 commit be5a9c2

7 files changed

Lines changed: 134 additions & 10 deletions

File tree

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,12 +159,16 @@ Navigation helpers:
159159
Deep links:
160160
- `open <url>` supports deep links with `scheme://...`.
161161
- Android opens deep links via `VIEW` intent.
162-
- iOS deep link open is simulator-only.
162+
- iOS simulator opens deep links via `simctl openurl`.
163+
- iOS device opens deep links via `devicectl --payload-url`. For `http(s)://` URLs, Safari is used automatically if no app is active in the session. Custom scheme URLs (`myapp://`) require an active app.
163164
- `--activity` cannot be combined with URL opens.
164165

165166
```bash
166167
agent-device open "myapp://home" --platform android
167-
agent-device open "https://example.com" --platform ios
168+
agent-device open "https://example.com" --platform ios # Safari on device, simctl on simulator
169+
# iOS device custom scheme: open the app first, then deep link in the same session
170+
agent-device open com.example.app --session dev --platform ios --device "iPhone"
171+
agent-device open "myapp://home" --session dev
168172
```
169173

170174
Find (semantic):

skills/agent-device/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ agent-device open [app|url] # Boot device/simulator; optionally launch app
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 (simulator: simctl, device: devicectl --payload-url)
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
@@ -190,7 +190,7 @@ agent-device apps --platform android --user-installed
190190
- If AX returns the Simulator window or empty tree, restart Simulator or use `--backend xctest`.
191191
- Use `--session <name>` for parallel sessions; avoid device contention.
192192
- Use `--activity <component>` on Android to launch a specific activity (e.g. TV apps with LEANBACK); do not combine with URL opens.
193-
- iOS deep-link opens are simulator-only.
193+
- iOS deep-link opens work on simulators and devices. On devices, `http(s)://` URLs fall back to Safari automatically; custom scheme URLs require an active app in the session.
194194
- 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`.
195195
- For long first-run physical-device setup/build, increase daemon timeout: `AGENT_DEVICE_DAEMON_TIMEOUT_MS=180000` (or higher).
196196
- Use `fill` when you want clear-then-type semantics.

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import test from 'node:test';
22
import assert from 'node:assert/strict';
3-
import { isDeepLinkTarget } from '../open-target.ts';
3+
import { isDeepLinkTarget, isWebUrl } from '../open-target.ts';
44

55
test('isDeepLinkTarget accepts URL-style deep links', () => {
66
assert.equal(isDeepLinkTarget('myapp://home'), true);
@@ -14,3 +14,16 @@ test('isDeepLinkTarget rejects app identifiers and malformed URLs', () => {
1414
assert.equal(isDeepLinkTarget('settings'), false);
1515
assert.equal(isDeepLinkTarget('http:/x'), false);
1616
});
17+
18+
test('isWebUrl accepts http and https URLs', () => {
19+
assert.equal(isWebUrl('https://example.com'), true);
20+
assert.equal(isWebUrl('http://example.com/path'), true);
21+
assert.equal(isWebUrl('https://example.com/path?q=1'), true);
22+
});
23+
24+
test('isWebUrl rejects custom schemes and non-URLs', () => {
25+
assert.equal(isWebUrl('myapp://home'), false);
26+
assert.equal(isWebUrl('tel:123456789'), false);
27+
assert.equal(isWebUrl('com.example.app'), false);
28+
assert.equal(isWebUrl('settings'), false);
29+
});

src/core/open-target.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,8 @@ 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+
}

src/daemon/handlers/__tests__/session.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,94 @@ test('open URL on existing iOS session clears stale app bundle id', async () =>
170170
assert.equal(dispatchedContext?.appBundleId, undefined);
171171
});
172172

173+
test('open URL on existing iOS device session preserves app bundle id context', async () => {
174+
const sessionStore = makeSessionStore();
175+
const sessionName = 'ios-device-session';
176+
sessionStore.set(
177+
sessionName,
178+
{
179+
...makeSession(sessionName, {
180+
platform: 'ios',
181+
id: 'ios-device-1',
182+
name: 'iPhone Device',
183+
kind: 'device',
184+
booted: true,
185+
}),
186+
appBundleId: 'com.example.app',
187+
appName: 'Example App',
188+
},
189+
);
190+
191+
let dispatchedContext: Record<string, unknown> | undefined;
192+
const response = await handleSessionCommands({
193+
req: {
194+
token: 't',
195+
session: sessionName,
196+
command: 'open',
197+
positionals: ['myapp://item/42'],
198+
flags: {},
199+
},
200+
sessionName,
201+
logPath: path.join(os.tmpdir(), 'daemon.log'),
202+
sessionStore,
203+
invoke: noopInvoke,
204+
dispatch: async (_device, _command, _positionals, _out, context) => {
205+
dispatchedContext = context as Record<string, unknown> | undefined;
206+
return {};
207+
},
208+
ensureReady: async () => {},
209+
});
210+
211+
assert.ok(response);
212+
assert.equal(response?.ok, true);
213+
const updated = sessionStore.get(sessionName);
214+
assert.equal(updated?.appBundleId, 'com.example.app');
215+
assert.equal(updated?.appName, 'myapp://item/42');
216+
assert.equal(dispatchedContext?.appBundleId, 'com.example.app');
217+
});
218+
219+
test('open web URL on iOS device session without active app falls back to Safari', async () => {
220+
const sessionStore = makeSessionStore();
221+
const sessionName = 'ios-device-session';
222+
sessionStore.set(
223+
sessionName,
224+
makeSession(sessionName, {
225+
platform: 'ios',
226+
id: 'ios-device-1',
227+
name: 'iPhone Device',
228+
kind: 'device',
229+
booted: true,
230+
}),
231+
);
232+
233+
let dispatchedContext: Record<string, unknown> | undefined;
234+
const response = await handleSessionCommands({
235+
req: {
236+
token: 't',
237+
session: sessionName,
238+
command: 'open',
239+
positionals: ['https://example.com/path'],
240+
flags: {},
241+
},
242+
sessionName,
243+
logPath: path.join(os.tmpdir(), 'daemon.log'),
244+
sessionStore,
245+
invoke: noopInvoke,
246+
dispatch: async (_device, _command, _positionals, _out, context) => {
247+
dispatchedContext = context as Record<string, unknown> | undefined;
248+
return {};
249+
},
250+
ensureReady: async () => {},
251+
});
252+
253+
assert.ok(response);
254+
assert.equal(response?.ok, true);
255+
const updated = sessionStore.get(sessionName);
256+
assert.equal(updated?.appBundleId, 'com.apple.mobilesafari');
257+
assert.equal(updated?.appName, 'https://example.com/path');
258+
assert.equal(dispatchedContext?.appBundleId, 'com.apple.mobilesafari');
259+
});
260+
173261
test('open app on existing iOS session resolves and stores bundle id', async () => {
174262
const sessionStore = makeSessionStore();
175263
const sessionName = 'ios-session';
@@ -250,6 +338,7 @@ test('open --relaunch closes and reopens active session app', async () => {
250338
calls.push({ command, positionals });
251339
return {};
252340
},
341+
ensureReady: async () => {},
253342
});
254343

255344
assert.ok(response);

src/daemon/handlers/session.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from 'node:fs';
22
import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts';
33
import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
4-
import { isDeepLinkTarget } from '../../core/open-target.ts';
4+
import { isDeepLinkTarget, isWebUrl } from '../../core/open-target.ts';
55
import { AppError, asAppError } from '../../utils/errors.ts';
66
import type { DeviceInfo } from '../../utils/device.ts';
77
import type { DaemonRequest, DaemonResponse, SessionAction, SessionState } from '../types.ts';
@@ -54,10 +54,22 @@ const defaultReinstallOps: ReinstallOps = {
5454
},
5555
};
5656

57-
async function resolveIosBundleIdForOpen(device: DeviceInfo, openTarget: string | undefined): Promise<string | undefined> {
58-
if (device.platform !== 'ios' || !openTarget || isDeepLinkTarget(openTarget)) {
57+
async function resolveIosBundleIdForOpen(
58+
device: DeviceInfo,
59+
openTarget: string | undefined,
60+
currentAppBundleId?: string,
61+
): Promise<string | undefined> {
62+
if (device.platform !== 'ios' || !openTarget) return undefined;
63+
if (isDeepLinkTarget(openTarget)) {
64+
if (device.kind === 'device') {
65+
return currentAppBundleId ?? (isWebUrl(openTarget) ? 'com.apple.mobilesafari' : undefined);
66+
}
5967
return undefined;
6068
}
69+
return await tryResolveIosAppBundleId(device, openTarget);
70+
}
71+
72+
async function tryResolveIosAppBundleId(device: DeviceInfo, openTarget: string): Promise<string | undefined> {
6173
try {
6274
const { resolveIosApp } = await import('../../platforms/ios/index.ts');
6375
return await resolveIosApp(device, openTarget);
@@ -331,7 +343,8 @@ export async function handleSessionCommands(params: {
331343
},
332344
};
333345
}
334-
const appBundleId = await resolveIosBundleIdForOpen(session.device, openTarget);
346+
await ensureReady(session.device);
347+
const appBundleId = await resolveIosBundleIdForOpen(session.device, openTarget, session.appBundleId);
335348
const openPositionals = requestedOpenTarget ? (req.positionals ?? []) : [openTarget];
336349
if (shouldRelaunch) {
337350
const closeTarget = appBundleId ?? openTarget;

website/docs/docs/commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ agent-device app-switcher
2323
- `boot` requires either an active session or an explicit device selector.
2424
- `boot` is mainly needed when starting a new session and `open` fails because no booted simulator/emulator is available.
2525
- `open [app]` already boots/activates the selected target when needed.
26-
- `open <url>` deep links are supported on Android; iOS deep-link open is simulator-only.
26+
- `open <url>` deep links are supported on Android and iOS. On iOS devices, `http(s)://` URLs fall back to Safari automatically; custom scheme URLs require an active app in the session.
2727

2828
## Snapshot and inspect
2929

0 commit comments

Comments
 (0)