Skip to content

Commit 4c77aca

Browse files
committed
feat: add public --launch-args CLI flag for iOS open
main already has internal launchArgs support for iOS simulators (from the Maestro work). This change makes it a first-class CLI flag and fills in the gaps: - Add `--launch-args <arg>` to the schema, allowed flags on `open`, and the CLI handler. Mirrors `--header`'s shape (repeatable string flag, parsed to `string[]`). - Plumb `launchArgs` through `AppOpenOptions`, the client normalizer, and the standalone `daemon-client.openApp` request builder so programmatic callers see the same surface. - Forward `launchArgs` to iOS device launches via `launchIosDeviceProcess`, inserting an explicit `--` end-of-options marker before the user's args so devicectl (Swift ArgumentParser) can't reparse leading-dash launch args as its own options. - Reject `launchArgs` explicitly on macOS rather than silently dropping them through the existing macOS launch path. - Reject `launchArgs` combined with an iOS simulator URL open (which goes through `simctl openurl`, ignoring launch arguments) with an INVALID_ARGS pointing the caller at the two-step flow. Android remains explicitly rejected in dispatch.ts as already on main; adding Android support is a separate decision and out of scope here. Tests: - `src/utils/__tests__/args.test.ts`: parser/help coverage for `--launch-args` including `-FeatureFlag`-style values and `--es KEY VALUE`-style values, plus the rejection on unrelated commands. - `src/platforms/ios/__tests__/index.test.ts`: device launch with args, device deep-link launch with args (`--` separator after `--payload-url`), simulator URL reject, macOS reject.
1 parent a5fffc6 commit 4c77aca

10 files changed

Lines changed: 259 additions & 6 deletions

File tree

src/cli/commands/open.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const openCommand: ClientCommandHandler = async ({ positionals, flags, cl
99
surface: flags.surface,
1010
activity: flags.activity,
1111
launchConsole: flags.launchConsole,
12+
launchArgs: flags.launchArgs,
1213
relaunch: flags.relaunch,
1314
saveScript: flags.saveScript,
1415
noRecord: flags.noRecord,

src/client-normalizers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags {
276276
surface: options.surface,
277277
activity: options.activity,
278278
launchConsole: options.launchConsole,
279+
launchArgs: options.launchArgs,
279280
relaunch: options.relaunch,
280281
shutdown: options.shutdown,
281282
saveScript: options.saveScript,

src/client-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ export type AppOpenOptions = AgentDeviceRequestOverrides &
174174
surface?: 'app' | 'frontmost-app' | 'desktop' | 'menubar';
175175
activity?: string;
176176
launchConsole?: string;
177+
launchArgs?: string[];
177178
relaunch?: boolean;
178179
saveScript?: boolean | string;
179180
noRecord?: boolean;
@@ -847,6 +848,7 @@ export type InternalRequestOptions = AgentDeviceClientConfig &
847848
surface?: 'app' | 'frontmost-app' | 'desktop' | 'menubar';
848849
activity?: string;
849850
launchConsole?: string;
851+
launchArgs?: string[];
850852
relaunch?: boolean;
851853
shutdown?: boolean;
852854
saveScript?: boolean | string;

src/commands/session-lifecycle/definition.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const openCommandDefinition = defineCommand({
2929
'Boot device/simulator; optionally launch app or deep link URL (macOS also supports --surface app|frontmost-app|desktop|menubar)',
3030
summary: 'Open an app, deep link or URL, save replays',
3131
positionalArgs: ['appOrUrl?', 'url?'],
32-
allowedFlags: ['activity', 'launchConsole', 'saveScript', 'relaunch', 'surface'],
32+
allowedFlags: ['activity', 'launchConsole', 'launchArgs', 'saveScript', 'relaunch', 'surface'],
3333
},
3434
capability: APP_RUNTIME_CAPABILITY,
3535
});

src/core/launch-console.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ export const LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE =
33

44
export const LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE =
55
'--launch-console requires a direct app launch and cannot be used with URL opens';
6+
7+
export const IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE =
8+
'--launch-args is not supported with iOS simulator URL opens (simctl openurl ignores launch args). Launch the app first with --launch-args, then issue the URL open in a separate call.';

src/daemon-client.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export type OpenAppOptions = {
4545
serial?: NonNullable<DaemonRequest['flags']>['serial'];
4646
activity?: NonNullable<DaemonRequest['flags']>['activity'];
4747
launchConsole?: NonNullable<DaemonRequest['flags']>['launchConsole'];
48+
launchArgs?: NonNullable<DaemonRequest['flags']>['launchArgs'];
4849
out?: NonNullable<DaemonRequest['flags']>['out'];
4950
saveScript?: NonNullable<DaemonRequest['flags']>['saveScript'];
5051
relaunch?: boolean;
@@ -203,6 +204,7 @@ export async function openApp(options: OpenAppOptions = {}): Promise<DaemonRespo
203204
serial,
204205
activity,
205206
launchConsole,
207+
launchArgs,
206208
out,
207209
saveScript,
208210
relaunch,
@@ -224,6 +226,7 @@ export async function openApp(options: OpenAppOptions = {}): Promise<DaemonRespo
224226
...(serial !== undefined ? { serial } : {}),
225227
...(activity !== undefined ? { activity } : {}),
226228
...(launchConsole !== undefined ? { launchConsole } : {}),
229+
...(launchArgs !== undefined ? { launchArgs } : {}),
227230
...(out !== undefined ? { out } : {}),
228231
...(saveScript !== undefined ? { saveScript } : {}),
229232
...(relaunch ? { relaunch: true } : {}),

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

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -925,6 +925,158 @@ test('openIosApp captures iOS simulator launch console output when requested', a
925925
}
926926
});
927927

928+
test('openIosApp appends launchArgs after the bundle id on iOS device', async () => {
929+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-launch-args-dev-'));
930+
const xcrunPath = path.join(tmpDir, 'xcrun');
931+
const argsLogPath = path.join(tmpDir, 'args.log');
932+
await fs.writeFile(
933+
xcrunPath,
934+
'#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
935+
'utf8',
936+
);
937+
await fs.chmod(xcrunPath, 0o755);
938+
939+
const previousPath = process.env.PATH;
940+
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
941+
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
942+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
943+
944+
const device: DeviceInfo = {
945+
platform: 'ios',
946+
id: 'ios-device-1',
947+
name: 'iPhone Device',
948+
kind: 'device',
949+
booted: true,
950+
};
951+
952+
try {
953+
await openIosApp(device, 'MyApp', {
954+
appBundleId: 'com.example.app',
955+
launchArgs: ['-FeatureFlag', 'YES'],
956+
});
957+
const args = (await fs.readFile(argsLogPath, 'utf8')).trim().split('\n').filter(Boolean);
958+
assert.deepEqual(args, [
959+
'devicectl',
960+
'device',
961+
'process',
962+
'launch',
963+
'--device',
964+
'ios-device-1',
965+
'com.example.app',
966+
'--',
967+
'-FeatureFlag',
968+
'YES',
969+
]);
970+
} finally {
971+
process.env.PATH = previousPath;
972+
if (previousArgsFile === undefined) {
973+
delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
974+
} else {
975+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
976+
}
977+
await fs.rm(tmpDir, { recursive: true, force: true });
978+
}
979+
});
980+
981+
test('openIosApp appends launchArgs alongside --payload-url for iOS device deep links', async () => {
982+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-launch-args-deep-'));
983+
const xcrunPath = path.join(tmpDir, 'xcrun');
984+
const argsLogPath = path.join(tmpDir, 'args.log');
985+
await fs.writeFile(
986+
xcrunPath,
987+
'#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
988+
'utf8',
989+
);
990+
await fs.chmod(xcrunPath, 0o755);
991+
992+
const previousPath = process.env.PATH;
993+
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
994+
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
995+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
996+
997+
const device: DeviceInfo = {
998+
platform: 'ios',
999+
id: 'ios-device-1',
1000+
name: 'iPhone Device',
1001+
kind: 'device',
1002+
booted: true,
1003+
};
1004+
1005+
try {
1006+
await openIosApp(device, 'myapp://item/42', {
1007+
appBundleId: 'com.example.app',
1008+
launchArgs: ['-Tracking', 'NO'],
1009+
});
1010+
const args = (await fs.readFile(argsLogPath, 'utf8')).trim().split('\n').filter(Boolean);
1011+
assert.deepEqual(args, [
1012+
'devicectl',
1013+
'device',
1014+
'process',
1015+
'launch',
1016+
'--device',
1017+
'ios-device-1',
1018+
'com.example.app',
1019+
'--payload-url',
1020+
'myapp://item/42',
1021+
'--',
1022+
'-Tracking',
1023+
'NO',
1024+
]);
1025+
} finally {
1026+
process.env.PATH = previousPath;
1027+
if (previousArgsFile === undefined) {
1028+
delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
1029+
} else {
1030+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
1031+
}
1032+
await fs.rm(tmpDir, { recursive: true, force: true });
1033+
}
1034+
});
1035+
1036+
test('openIosApp rejects launchArgs combined with URL deep link on iOS simulator', async () => {
1037+
mockEnsureBootedSimulator.mockResolvedValue();
1038+
await assert.rejects(
1039+
() =>
1040+
openIosApp(IOS_TEST_SIMULATOR, 'myapp://item/42', {
1041+
launchArgs: ['-FeatureFlag', 'YES'],
1042+
}),
1043+
(error: unknown) => {
1044+
assert.ok(error instanceof AppError);
1045+
assert.equal(error.code, 'INVALID_ARGS');
1046+
assert.match(String(error.message), /simctl openurl/);
1047+
return true;
1048+
},
1049+
);
1050+
await assert.rejects(
1051+
() =>
1052+
openIosApp(IOS_TEST_SIMULATOR, 'MyApp', {
1053+
appBundleId: 'com.example.app',
1054+
url: 'https://example.com/path',
1055+
launchArgs: ['-FeatureFlag', 'YES'],
1056+
}),
1057+
(error: unknown) => {
1058+
assert.ok(error instanceof AppError);
1059+
assert.equal(error.code, 'INVALID_ARGS');
1060+
return true;
1061+
},
1062+
);
1063+
});
1064+
1065+
test('openIosApp rejects launchArgs on macOS', async () => {
1066+
await assert.rejects(
1067+
() =>
1068+
openIosApp(MACOS_TEST_DEVICE, 'TextEdit', {
1069+
launchArgs: ['-FeatureFlag', 'YES'],
1070+
}),
1071+
(error: unknown) => {
1072+
assert.ok(error instanceof AppError);
1073+
assert.equal(error.code, 'UNSUPPORTED_OPERATION');
1074+
assert.match(String(error.message), /macOS/);
1075+
return true;
1076+
},
1077+
);
1078+
});
1079+
9281080
test('readIosClipboardText rejects physical devices', async () => {
9291081
await assert.rejects(
9301082
() => readIosClipboardText(IOS_TEST_DEVICE),

src/platforms/ios/apps.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { AppError } from '../../utils/errors.ts';
66
import { emitDiagnostic } from '../../utils/diagnostics.ts';
77
import type { AppsFilter } from '../../commands/app-inventory-contract.ts';
88
import {
9+
IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE,
910
LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE,
1011
LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE,
1112
} from '../../core/launch-console.ts';
@@ -130,10 +131,17 @@ export async function openIosApp(
130131
options?: { appBundleId?: string; launchConsole?: string; launchArgs?: string[]; url?: string },
131132
): Promise<void> {
132133
const launchConsole = options?.launchConsole?.trim();
134+
const launchArgs = options?.launchArgs;
133135
if (launchConsole && (device.platform !== 'ios' || device.kind !== 'simulator')) {
134136
throw new AppError('UNSUPPORTED_OPERATION', LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE);
135137
}
136138
if (device.platform === 'macos') {
139+
if (launchArgs && launchArgs.length > 0) {
140+
throw new AppError(
141+
'UNSUPPORTED_OPERATION',
142+
'--launch-args is not supported on macOS; launch arguments are currently iOS-only.',
143+
);
144+
}
137145
await openMacOsApp(device, app, options);
138146
return;
139147
}
@@ -146,6 +154,9 @@ export async function openIosApp(
146154
throw new AppError('INVALID_ARGS', 'open <app> <url> requires a valid URL target');
147155
}
148156
if (device.kind === 'simulator') {
157+
if (launchArgs && launchArgs.length > 0) {
158+
throw new AppError('INVALID_ARGS', IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE);
159+
}
149160
await ensureBootedSimulator(device);
150161
await runSimctl(device, ['openurl', device.id, explicitUrl]);
151162
return;
@@ -158,7 +169,7 @@ export async function openIosApp(
158169
'Deep link open on iOS devices requires an active app bundle ID. Open the app first, then open the URL.',
159170
);
160171
}
161-
await launchIosDeviceProcess(device, bundleId, { payloadUrl: explicitUrl });
172+
await launchIosDeviceProcess(device, bundleId, { payloadUrl: explicitUrl, launchArgs });
162173
return;
163174
}
164175

@@ -168,6 +179,9 @@ export async function openIosApp(
168179
throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE);
169180
}
170181
if (device.kind === 'simulator') {
182+
if (launchArgs && launchArgs.length > 0) {
183+
throw new AppError('INVALID_ARGS', IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE);
184+
}
171185
await ensureBootedSimulator(device);
172186
await runSimctl(device, ['openurl', device.id, deepLinkTarget]);
173187
return;
@@ -179,20 +193,20 @@ export async function openIosApp(
179193
'Deep link open on iOS devices requires an active app bundle ID. Open the app first, then open the URL.',
180194
);
181195
}
182-
await launchIosDeviceProcess(device, bundleId, { payloadUrl: deepLinkTarget });
196+
await launchIosDeviceProcess(device, bundleId, { payloadUrl: deepLinkTarget, launchArgs });
183197
return;
184198
}
185199

186200
const bundleId = options?.appBundleId ?? (await resolveIosApp(device, app));
187201
if (device.kind === 'simulator') {
188202
await launchIosSimulatorApp(device, bundleId, {
189203
...(launchConsole ? { launchConsole } : {}),
190-
...(options?.launchArgs ? { launchArgs: options.launchArgs } : {}),
204+
...(launchArgs ? { launchArgs } : {}),
191205
});
192206
return;
193207
}
194208

195-
await launchIosDeviceProcess(device, bundleId);
209+
await launchIosDeviceProcess(device, bundleId, { launchArgs });
196210
}
197211

198212
export async function openIosDevice(device: DeviceInfo): Promise<void> {
@@ -1059,11 +1073,18 @@ function joinProcessOutput(stdout: string, stderr: string): string {
10591073
async function launchIosDeviceProcess(
10601074
device: DeviceInfo,
10611075
bundleId: string,
1062-
options?: { payloadUrl?: string },
1076+
options?: { payloadUrl?: string; launchArgs?: string[] },
10631077
): Promise<void> {
10641078
const args = ['device', 'process', 'launch', '--device', device.id, bundleId];
10651079
if (options?.payloadUrl) {
10661080
args.push('--payload-url', options.payloadUrl);
10671081
}
1082+
if (options?.launchArgs && options.launchArgs.length > 0) {
1083+
// `devicectl` uses Swift ArgumentParser; without `--` an arg starting with
1084+
// `-` / `--` could be re-interpreted as one of devicectl's own options.
1085+
// The marker makes the boundary unambiguous so launch args reach the app
1086+
// verbatim.
1087+
args.push('--', ...options.launchArgs);
1088+
}
10681089
await runIosDevicectl(args, { action: 'launch iOS app', deviceId: device.id });
10691090
}

src/utils/__tests__/args.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,66 @@ test('parseArgs accepts install-from-source url and repeated headers', () => {
272272
assert.equal(parsed.flags.retentionMs, 60000);
273273
});
274274

275+
test('parseArgs accepts open --launch-args with plain values', () => {
276+
const parsed = parseArgs(
277+
['open', 'com.example.app', '--launch-args', 'fixtureMode', '--launch-args', 'verbose'],
278+
{ strictFlags: true },
279+
);
280+
assert.equal(parsed.command, 'open');
281+
assert.deepEqual(parsed.positionals, ['com.example.app']);
282+
assert.deepEqual(parsed.flags.launchArgs, ['fixtureMode', 'verbose']);
283+
});
284+
285+
test('parseArgs accepts open --launch-args with dash-prefixed values', () => {
286+
const parsed = parseArgs(
287+
[
288+
'open',
289+
'com.example.app',
290+
'--platform',
291+
'ios',
292+
'--launch-args',
293+
'-FeatureFlag',
294+
'--launch-args',
295+
'YES',
296+
],
297+
{ strictFlags: true },
298+
);
299+
assert.equal(parsed.command, 'open');
300+
assert.deepEqual(parsed.flags.launchArgs, ['-FeatureFlag', 'YES']);
301+
});
302+
303+
test('parseArgs accepts open --launch-args with double-dash-prefixed values (am extras style)', () => {
304+
const parsed = parseArgs(
305+
[
306+
'open',
307+
'com.example.app',
308+
'--launch-args',
309+
'--es',
310+
'--launch-args',
311+
'EXTRA_CONFIG',
312+
'--launch-args',
313+
'{"mode":"debug"}',
314+
],
315+
{ strictFlags: true },
316+
);
317+
assert.equal(parsed.command, 'open');
318+
assert.deepEqual(parsed.flags.launchArgs, ['--es', 'EXTRA_CONFIG', '{"mode":"debug"}']);
319+
});
320+
321+
test('parseArgs rejects --launch-args on commands that do not allow it', () => {
322+
assert.throws(
323+
() => parseArgs(['tap', '100', '200', '--launch-args', 'foo'], { strictFlags: true }),
324+
(error) => error instanceof AppError && error.code === 'INVALID_ARGS',
325+
);
326+
});
327+
328+
test('usageForCommand documents open --launch-args', () => {
329+
const help = usageForCommand('open');
330+
if (help === null) throw new Error('Expected open help text');
331+
assert.match(help, /--launch-args <arg>/);
332+
assert.match(help, /forwarded verbatim/);
333+
});
334+
275335
test('parseArgs accepts install-from-source GitHub Actions artifact flag', () => {
276336
const parsed = parseArgs(
277337
[

0 commit comments

Comments
 (0)