Skip to content

Commit 3d5d3e0

Browse files
committed
feat: add iOS device URL session fallback behavior
1 parent ac07890 commit 3d5d3e0

7 files changed

Lines changed: 132 additions & 10 deletions

File tree

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,12 +160,16 @@ Navigation helpers:
160160
Deep links:
161161
- `open <url>` supports deep links with `scheme://...`.
162162
- Android opens deep links via `VIEW` intent.
163-
- iOS deep link open is simulator-only.
163+
- iOS simulator opens deep links via `simctl openurl`.
164+
- 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.
164165
- `--activity` cannot be combined with URL opens.
165166

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

171175
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
@@ -194,7 +194,7 @@ agent-device apps --platform android --user-installed
194194
- If AX returns the Simulator window or empty tree, restart Simulator or use `--backend xctest`.
195195
- Use `--session <name>` for parallel sessions; avoid device contention.
196196
- 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.
197+
- 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.
198198
- 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`.
199199
- Default daemon request timeout is `45000`ms. For slow physical-device setup/build, increase `AGENT_DEVICE_DAEMON_TIMEOUT_MS` (for example `120000`).
200200
- For daemon startup troubleshooting, follow stale metadata hints for `~/.agent-device/daemon.json` / `~/.agent-device/daemon.lock`.

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: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,94 @@ test('open URL on existing iOS session clears stale app bundle id', async () =>
463463
assert.equal(dispatchedContext?.appBundleId, undefined);
464464
});
465465

466+
test('open URL on existing iOS device session preserves app bundle id context', async () => {
467+
const sessionStore = makeSessionStore();
468+
const sessionName = 'ios-device-session';
469+
sessionStore.set(
470+
sessionName,
471+
{
472+
...makeSession(sessionName, {
473+
platform: 'ios',
474+
id: 'ios-device-1',
475+
name: 'iPhone Device',
476+
kind: 'device',
477+
booted: true,
478+
}),
479+
appBundleId: 'com.example.app',
480+
appName: 'Example App',
481+
},
482+
);
483+
484+
let dispatchedContext: Record<string, unknown> | undefined;
485+
const response = await handleSessionCommands({
486+
req: {
487+
token: 't',
488+
session: sessionName,
489+
command: 'open',
490+
positionals: ['myapp://item/42'],
491+
flags: {},
492+
},
493+
sessionName,
494+
logPath: path.join(os.tmpdir(), 'daemon.log'),
495+
sessionStore,
496+
invoke: noopInvoke,
497+
dispatch: async (_device, _command, _positionals, _out, context) => {
498+
dispatchedContext = context as Record<string, unknown> | undefined;
499+
return {};
500+
},
501+
ensureReady: async () => {},
502+
});
503+
504+
assert.ok(response);
505+
assert.equal(response?.ok, true);
506+
const updated = sessionStore.get(sessionName);
507+
assert.equal(updated?.appBundleId, 'com.example.app');
508+
assert.equal(updated?.appName, 'myapp://item/42');
509+
assert.equal(dispatchedContext?.appBundleId, 'com.example.app');
510+
});
511+
512+
test('open web URL on iOS device session without active app falls back to Safari', async () => {
513+
const sessionStore = makeSessionStore();
514+
const sessionName = 'ios-device-session';
515+
sessionStore.set(
516+
sessionName,
517+
makeSession(sessionName, {
518+
platform: 'ios',
519+
id: 'ios-device-1',
520+
name: 'iPhone Device',
521+
kind: 'device',
522+
booted: true,
523+
}),
524+
);
525+
526+
let dispatchedContext: Record<string, unknown> | undefined;
527+
const response = await handleSessionCommands({
528+
req: {
529+
token: 't',
530+
session: sessionName,
531+
command: 'open',
532+
positionals: ['https://example.com/path'],
533+
flags: {},
534+
},
535+
sessionName,
536+
logPath: path.join(os.tmpdir(), 'daemon.log'),
537+
sessionStore,
538+
invoke: noopInvoke,
539+
dispatch: async (_device, _command, _positionals, _out, context) => {
540+
dispatchedContext = context as Record<string, unknown> | undefined;
541+
return {};
542+
},
543+
ensureReady: async () => {},
544+
});
545+
546+
assert.ok(response);
547+
assert.equal(response?.ok, true);
548+
const updated = sessionStore.get(sessionName);
549+
assert.equal(updated?.appBundleId, 'com.apple.mobilesafari');
550+
assert.equal(updated?.appName, 'https://example.com/path');
551+
assert.equal(dispatchedContext?.appBundleId, 'com.apple.mobilesafari');
552+
});
553+
466554
test('open app on existing iOS session resolves and stores bundle id', async () => {
467555
const sessionStore = makeSessionStore();
468556
const sessionName = 'ios-session';

src/daemon/handlers/session.ts

Lines changed: 16 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';
@@ -97,10 +97,22 @@ const defaultReinstallOps: ReinstallOps = {
9797
},
9898
};
9999

100-
async function resolveIosBundleIdForOpen(device: DeviceInfo, openTarget: string | undefined): Promise<string | undefined> {
101-
if (device.platform !== 'ios' || !openTarget || isDeepLinkTarget(openTarget)) {
100+
async function resolveIosBundleIdForOpen(
101+
device: DeviceInfo,
102+
openTarget: string | undefined,
103+
currentAppBundleId?: string,
104+
): Promise<string | undefined> {
105+
if (device.platform !== 'ios' || !openTarget) return undefined;
106+
if (isDeepLinkTarget(openTarget)) {
107+
if (device.kind === 'device') {
108+
return currentAppBundleId ?? (isWebUrl(openTarget) ? 'com.apple.mobilesafari' : undefined);
109+
}
102110
return undefined;
103111
}
112+
return await tryResolveIosAppBundleId(device, openTarget);
113+
}
114+
115+
async function tryResolveIosAppBundleId(device: DeviceInfo, openTarget: string): Promise<string | undefined> {
104116
try {
105117
const { resolveIosApp } = await import('../../platforms/ios/index.ts');
106118
return await resolveIosApp(device, openTarget);
@@ -423,7 +435,7 @@ export async function handleSessionCommands(params: {
423435
};
424436
}
425437
await ensureReady(session.device);
426-
const appBundleId = await resolveIosBundleIdForOpen(session.device, openTarget);
438+
const appBundleId = await resolveIosBundleIdForOpen(session.device, openTarget, session.appBundleId);
427439
const openPositionals = requestedOpenTarget ? (req.positionals ?? []) : [openTarget];
428440
if (shouldRelaunch) {
429441
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)