Skip to content

Commit 8cef2a6

Browse files
authored
Add deep-link URL support to open command (#53)
* Add deep-link URL support to open command * Update agent-device skill docs for deep-link open
1 parent e3b1aa6 commit 8cef2a6

13 files changed

Lines changed: 224 additions & 19 deletions

File tree

README.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ Flags:
101101
- `--device <name>`
102102
- `--udid <udid>` (iOS)
103103
- `--serial <serial>` (Android)
104-
- `--activity <component>` (Android; package/Activity or package/.Activity)
104+
- `--activity <component>` (Android app launch only; package/Activity or package/.Activity; not for URL opens)
105105
- `--session <name>`
106106
- `--verbose` for daemon and runner logs
107107
- `--json` for structured output
@@ -117,7 +117,7 @@ npx skills add https://github.com/callstackincubator/agent-device --skill agent-
117117
Sessions:
118118
- `open` starts a session. Without args boots/activates the target device/simulator without launching an app.
119119
- All interaction commands require an open session.
120-
- If a session is already open, `open <app>` switches the active app and updates the session app bundle.
120+
- If a session is already open, `open <app|url>` switches the active app or opens a deep link URL.
121121
- `close` stops the session and releases device resources. Pass an app to close it explicitly, or omit to just close the session.
122122
- Use `--session <name>` to manage multiple sessions.
123123
- Session scripts are written to `~/.agent-device/sessions/<session>-<timestamp>.ad` when recording is enabled with `--save-script`.
@@ -126,10 +126,21 @@ Sessions:
126126
Navigation helpers:
127127
- `boot --platform ios|android` ensures the target is ready without launching an app.
128128
- Use `boot` mainly when starting a new session and `open` fails because no booted simulator/emulator is available.
129-
- `open [app]` already boots/activates the selected target when needed.
129+
- `open [app|url]` already boots/activates the selected target when needed.
130130
- `reinstall <app> <path>` uninstalls and installs the app binary in one command (Android + iOS simulator in v1).
131131
- `reinstall` accepts package/bundle id style app names and supports `~` in paths.
132132

133+
Deep links:
134+
- `open <url>` supports deep links with `scheme://...`.
135+
- Android opens deep links via `VIEW` intent.
136+
- iOS deep link open is simulator-only in v1.
137+
- `--activity` cannot be combined with URL opens.
138+
139+
```bash
140+
agent-device open "myapp://home" --platform android
141+
agent-device open "https://example.com" --platform ios
142+
```
143+
133144
Find (semantic):
134145
- `find <text> <action> [value]` finds by any text (label/value/identifier) using a scoped snapshot.
135146
- `find text|label|value|role|id <value> <action> [value]` for specific locators.

skills/agent-device/SKILL.md

Lines changed: 9 additions & 5 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: `open [app]` (`open` handles target selection + boot/activation in the normal flow)
30+
1. Open app or deep link: `open [app|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
@@ -41,8 +41,10 @@ npx -y agent-device
4141
agent-device boot # Ensure target is booted/ready without opening app
4242
agent-device boot --platform ios # Boot iOS simulator
4343
agent-device boot --platform android # Boot Android emulator/device target
44-
agent-device open [app] # Boot device/simulator; optionally launch app
45-
agent-device open [app] --activity com.example/.MainActivity # Android: open specific activity
44+
agent-device open [app|url] # Boot device/simulator; optionally launch app or deep link URL
45+
agent-device open [app] --activity com.example/.MainActivity # Android: open specific activity (app targets only)
46+
agent-device open "myapp://home" --platform android # Android deep link
47+
agent-device open "https://example.com" --platform ios # iOS simulator deep link
4648
agent-device close [app] # Close app or just end session
4749
agent-device reinstall <app> <path> # Uninstall + install app in one command
4850
agent-device session list # List active sessions
@@ -168,10 +170,12 @@ agent-device apps --platform android --user-installed
168170
- Prefer `snapshot -i` to reduce output size.
169171
- On iOS, `xctest` is the default and does not require Accessibility permission.
170172
- If XCTest returns 0 nodes (foreground app changed), agent-device falls back to AX when available.
171-
- `open <app>` can be used within an existing session to switch apps and update the session bundle id.
173+
- `open <app|url>` can be used within an existing session to switch apps or open deep links.
174+
- `open <app>` updates session app bundle context; URL opens do not set an app bundle id.
172175
- If AX returns the Simulator window or empty tree, restart Simulator or use `--backend xctest`.
173176
- Use `--session <name>` for parallel sessions; avoid device contention.
174-
- Use `--activity <component>` on Android to launch a specific activity (e.g. TV apps with LEANBACK).
177+
- Use `--activity <component>` on Android to launch a specific activity (e.g. TV apps with LEANBACK); do not combine with URL opens.
178+
- iOS deep-link opens are simulator-only in v1.
175179
- Use `fill` when you want clear-then-type semantics.
176180
- Use `type` when you want to append/enter text without clearing.
177181
- On Android, prefer `fill` for important fields; it verifies entered text and retries once when IME reorders characters.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { isDeepLinkTarget } from '../open-target.ts';
4+
5+
test('isDeepLinkTarget accepts URL-style deep links', () => {
6+
assert.equal(isDeepLinkTarget('myapp://home'), true);
7+
assert.equal(isDeepLinkTarget('https://example.com'), true);
8+
});
9+
10+
test('isDeepLinkTarget rejects app identifiers and malformed URLs', () => {
11+
assert.equal(isDeepLinkTarget('com.example.app'), false);
12+
assert.equal(isDeepLinkTarget('settings'), false);
13+
assert.equal(isDeepLinkTarget('http:/x'), false);
14+
});

src/core/dispatch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export async function dispatchCommand(
106106
await interactor.openDevice();
107107
return { app: null };
108108
}
109-
await interactor.open(app, { activity: context?.activity });
109+
await interactor.open(app, { activity: context?.activity, appBundleId: context?.appBundleId });
110110
return { app };
111111
}
112112
case 'close': {

src/core/open-target.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export function isDeepLinkTarget(input: string): boolean {
2+
const value = input.trim();
3+
if (!value) return false;
4+
return /^[A-Za-z][A-Za-z0-9+.-]*:\/\/.+/.test(value);
5+
}

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

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,95 @@ test('boot succeeds for supported device in session', async () => {
120120
assert.equal(response.data?.booted, true);
121121
}
122122
});
123+
124+
test('open URL on existing iOS session clears stale app bundle id', async () => {
125+
const sessionStore = makeSessionStore();
126+
const sessionName = 'ios-session';
127+
sessionStore.set(
128+
sessionName,
129+
{
130+
...makeSession(sessionName, {
131+
platform: 'ios',
132+
id: 'sim-1',
133+
name: 'iPhone 15',
134+
kind: 'simulator',
135+
booted: true,
136+
}),
137+
appBundleId: 'com.example.old',
138+
appName: 'Old App',
139+
},
140+
);
141+
142+
let dispatchedContext: Record<string, unknown> | undefined;
143+
const response = await handleSessionCommands({
144+
req: {
145+
token: 't',
146+
session: sessionName,
147+
command: 'open',
148+
positionals: ['https://example.com/path'],
149+
flags: {},
150+
},
151+
sessionName,
152+
logPath: path.join(os.tmpdir(), 'daemon.log'),
153+
sessionStore,
154+
invoke: noopInvoke,
155+
dispatch: async (_device, _command, _positionals, _out, context) => {
156+
dispatchedContext = context as Record<string, unknown> | undefined;
157+
return {};
158+
},
159+
ensureReady: async () => {},
160+
});
161+
162+
assert.ok(response);
163+
assert.equal(response?.ok, true);
164+
const updated = sessionStore.get(sessionName);
165+
assert.equal(updated?.appBundleId, undefined);
166+
assert.equal(updated?.appName, 'https://example.com/path');
167+
assert.equal(dispatchedContext?.appBundleId, undefined);
168+
});
169+
170+
test('open app on existing iOS session resolves and stores bundle id', async () => {
171+
const sessionStore = makeSessionStore();
172+
const sessionName = 'ios-session';
173+
sessionStore.set(
174+
sessionName,
175+
{
176+
...makeSession(sessionName, {
177+
platform: 'ios',
178+
id: 'sim-1',
179+
name: 'iPhone 15',
180+
kind: 'simulator',
181+
booted: true,
182+
}),
183+
appBundleId: 'com.example.old',
184+
appName: 'Old App',
185+
},
186+
);
187+
188+
let dispatchedContext: Record<string, unknown> | undefined;
189+
const response = await handleSessionCommands({
190+
req: {
191+
token: 't',
192+
session: sessionName,
193+
command: 'open',
194+
positionals: ['settings'],
195+
flags: {},
196+
},
197+
sessionName,
198+
logPath: path.join(os.tmpdir(), 'daemon.log'),
199+
sessionStore,
200+
invoke: noopInvoke,
201+
dispatch: async (_device, _command, _positionals, _out, context) => {
202+
dispatchedContext = context as Record<string, unknown> | undefined;
203+
return {};
204+
},
205+
ensureReady: async () => {},
206+
});
207+
208+
assert.ok(response);
209+
assert.equal(response?.ok, true);
210+
const updated = sessionStore.get(sessionName);
211+
assert.equal(updated?.appBundleId, 'com.apple.Preferences');
212+
assert.equal(updated?.appName, 'settings');
213+
assert.equal(dispatchedContext?.appBundleId, 'com.apple.Preferences');
214+
});

src/daemon/handlers/session.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +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';
45
import { AppError, asAppError } from '../../utils/errors.ts';
56
import type { DeviceInfo } from '../../utils/device.ts';
67
import type { DaemonRequest, DaemonResponse, SessionAction, SessionState } from '../types.ts';
@@ -299,7 +300,7 @@ export async function handleSessionCommands(params: {
299300
};
300301
}
301302
let appBundleId: string | undefined;
302-
if (session.device.platform === 'ios') {
303+
if (session.device.platform === 'ios' && !isDeepLinkTarget(appName)) {
303304
try {
304305
const { resolveIosApp } = await import('../../platforms/ios/index.ts');
305306
appBundleId = await resolveIosApp(session.device, appName);
@@ -340,10 +341,10 @@ export async function handleSessionCommands(params: {
340341
}
341342
let appBundleId: string | undefined;
342343
const appName = req.positionals?.[0];
343-
if (device.platform === 'ios') {
344+
if (device.platform === 'ios' && appName && !isDeepLinkTarget(appName)) {
344345
try {
345346
const { resolveIosApp } = await import('../../platforms/ios/index.ts');
346-
appBundleId = await resolveIosApp(device, req.positionals?.[0] ?? '');
347+
appBundleId = await resolveIosApp(device, appName);
347348
} catch {
348349
appBundleId = undefined;
349350
}

src/platforms/android/__tests__/index.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import test from 'node:test';
22
import assert from 'node:assert/strict';
3-
import { parseAndroidLaunchComponent } from '../index.ts';
3+
import { openAndroidApp, parseAndroidLaunchComponent } from '../index.ts';
4+
import type { DeviceInfo } from '../../../utils/device.ts';
5+
import { AppError } from '../../../utils/errors.ts';
46
import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts';
57

68
test('parseUiHierarchy reads double-quoted Android node attributes', () => {
@@ -89,3 +91,22 @@ test('parseAndroidLaunchComponent returns null when no component is present', ()
8991
const stdout = 'No activity found';
9092
assert.equal(parseAndroidLaunchComponent(stdout), null);
9193
});
94+
95+
test('openAndroidApp rejects activity override for deep link URLs', async () => {
96+
const device: DeviceInfo = {
97+
platform: 'android',
98+
id: 'emulator-5554',
99+
name: 'Pixel',
100+
kind: 'emulator',
101+
booted: true,
102+
};
103+
104+
await assert.rejects(
105+
() => openAndroidApp(device, ' https://example.com/path ', '.MainActivity'),
106+
(error: unknown) => {
107+
assert.equal(error instanceof AppError, true);
108+
assert.equal((error as AppError).code, 'INVALID_ARGS');
109+
return true;
110+
},
111+
);
112+
});

src/platforms/android/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { withRetry } from '../../utils/retry.ts';
44
import { AppError } from '../../utils/errors.ts';
55
import type { DeviceInfo } from '../../utils/device.ts';
66
import type { RawSnapshotNode, SnapshotOptions } from '../../utils/snapshot.ts';
7+
import { isDeepLinkTarget } from '../../core/open-target.ts';
78
import { waitForAndroidBoot } from './devices.ts';
89
import { findBounds, parseBounds, parseUiHierarchy, readNodeAttributes } from './ui-hierarchy.ts';
910

@@ -157,6 +158,23 @@ export async function openAndroidApp(
157158
if (!device.booted) {
158159
await waitForAndroidBoot(device.id);
159160
}
161+
const deepLinkTarget = app.trim();
162+
if (isDeepLinkTarget(deepLinkTarget)) {
163+
if (activity) {
164+
throw new AppError('INVALID_ARGS', 'Activity override is not supported when opening a deep link URL');
165+
}
166+
await runCmd('adb', adbArgs(device, [
167+
'shell',
168+
'am',
169+
'start',
170+
'-W',
171+
'-a',
172+
'android.intent.action.VIEW',
173+
'-d',
174+
deepLinkTarget,
175+
]));
176+
return;
177+
}
160178
const resolved = await resolveAndroidApp(device, app);
161179
if (resolved.type === 'intent') {
162180
if (activity) {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { openIosApp } from '../index.ts';
4+
import type { DeviceInfo } from '../../../utils/device.ts';
5+
import { AppError } from '../../../utils/errors.ts';
6+
7+
test('openIosApp rejects deep links on iOS physical devices', async () => {
8+
const device: DeviceInfo = {
9+
platform: 'ios',
10+
id: 'ios-device-1',
11+
name: 'iPhone Device',
12+
kind: 'device',
13+
booted: true,
14+
};
15+
16+
await assert.rejects(
17+
() => openIosApp(device, 'https://example.com/path'),
18+
(error: unknown) => {
19+
assert.equal(error instanceof AppError, true);
20+
assert.equal((error as AppError).code, 'UNSUPPORTED_OPERATION');
21+
return true;
22+
},
23+
);
24+
});

0 commit comments

Comments
 (0)