Skip to content

Commit 8a2ed84

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 5083d04 commit 8a2ed84

10 files changed

Lines changed: 256 additions & 10 deletions

File tree

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
@@ -175,6 +175,7 @@ export type AppOpenOptions = AgentDeviceRequestOverrides &
175175
surface?: 'app' | 'frontmost-app' | 'desktop' | 'menubar';
176176
activity?: string;
177177
launchConsole?: string;
178+
launchArgs?: string[];
178179
relaunch?: boolean;
179180
saveScript?: boolean | string;
180181
noRecord?: boolean;
@@ -844,6 +845,7 @@ export type InternalRequestOptions = AgentDeviceClientConfig &
844845
surface?: 'app' | 'frontmost-app' | 'desktop' | 'menubar';
845846
activity?: string;
846847
launchConsole?: string;
848+
launchArgs?: string[];
847849
relaunch?: boolean;
848850
shutdown?: boolean;
849851
saveScript?: boolean | string;

src/commands/cli-grammar/apps.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export const appCliReaders = {
3636
surface: flags.surface,
3737
activity: flags.activity,
3838
launchConsole: flags.launchConsole,
39+
launchArgs: flags.launchArgs,
3940
relaunch: flags.relaunch,
4041
saveScript: flags.saveScript,
4142
noRecord: flags.noRecord,

src/commands/client-command-contracts.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ export const clientCommandDefinitions = [
8181
surface: enumField(SURFACE_VALUES),
8282
activity: stringField('Android activity name.'),
8383
launchConsole: stringField('Launch console mode.'),
84+
launchArgs: stringArrayField(
85+
'iOS-only launch arguments forwarded to the app; Android and macOS reject this field.',
86+
),
8487
relaunch: booleanField('Force relaunch.'),
8588
saveScript: jsonSchemaField<boolean | string>({ oneOf: [booleanSchema(), stringSchema()] }),
8689
noRecord: booleanField('Do not record this action.'),

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: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import { AppError } from '../../../utils/errors.ts';
7676
import { runCmd } from '../../../utils/exec.ts';
7777
import { retryWithPolicy } from '../../../utils/retry.ts';
7878
import { parseIosDeviceAppsPayload, parseIosDeviceProcessesPayload } from '../devicectl.ts';
79+
import { withAppleToolProvider } from '../tool-provider.ts';
7980

8081
const IOS_TEST_DEVICE: DeviceInfo = {
8182
platform: 'ios',
@@ -203,6 +204,20 @@ async function withMockedXcrun(
203204
}
204205
}
205206

207+
async function withCapturedXcrunArgs(run: (calls: string[][]) => Promise<void>): Promise<void> {
208+
const calls: string[][] = [];
209+
await withAppleToolProvider(
210+
async (cmd, args) => {
211+
assert.equal(cmd, 'xcrun');
212+
calls.push(args);
213+
return { exitCode: 0, stdout: '', stderr: '' };
214+
},
215+
async () => {
216+
await run(calls);
217+
},
218+
);
219+
}
220+
206221
function injectDefaultPrivacyHelp(script: string): string {
207222
if (script.includes('AGENT_DEVICE_CUSTOM_PRIVACY_HELP')) return script;
208223
const helpBlock = `if [ "$1" = "simctl" ] && [ "$2" = "privacy" ] && [ "$3" = "help" ]; then
@@ -925,6 +940,127 @@ test('openIosApp captures iOS simulator launch console output when requested', a
925940
}
926941
});
927942

943+
test('openIosApp emits a clean simctl launch when launchArgs is an empty array', async () => {
944+
await withCapturedXcrunArgs(async (calls) => {
945+
mockEnsureBootedSimulator.mockResolvedValue();
946+
await openIosApp(IOS_TEST_SIMULATOR, 'MyApp', {
947+
appBundleId: 'com.example.app',
948+
launchArgs: [],
949+
});
950+
assert.deepEqual(calls, [['simctl', 'launch', 'sim-1', 'com.example.app']]);
951+
});
952+
});
953+
954+
test('openIosApp forwards dash-prefixed launchArgs after the bundle id on iOS simulator', async () => {
955+
await withCapturedXcrunArgs(async (calls) => {
956+
mockEnsureBootedSimulator.mockResolvedValue();
957+
await openIosApp(IOS_TEST_SIMULATOR, 'MyApp', {
958+
appBundleId: 'com.example.app',
959+
launchArgs: ['-FeatureFlag', 'YES'],
960+
});
961+
assert.deepEqual(calls, [
962+
['simctl', 'launch', 'sim-1', 'com.example.app', '-FeatureFlag', 'YES'],
963+
]);
964+
});
965+
});
966+
967+
test('openIosApp inserts the devicectl delimiter before the bundle id for iOS device launchArgs', async () => {
968+
await withCapturedXcrunArgs(async (calls) => {
969+
await openIosApp(IOS_TEST_DEVICE, 'MyApp', {
970+
appBundleId: 'com.example.app',
971+
launchArgs: ['-FeatureFlag', 'YES'],
972+
});
973+
assert.deepEqual(calls, [
974+
[
975+
'devicectl',
976+
'device',
977+
'process',
978+
'launch',
979+
'--device',
980+
'ios-device-1',
981+
'--',
982+
'com.example.app',
983+
'-FeatureFlag',
984+
'YES',
985+
],
986+
]);
987+
});
988+
});
989+
990+
test('openIosApp inserts the devicectl delimiter after --payload-url for iOS device deep links', async () => {
991+
await withCapturedXcrunArgs(async (calls) => {
992+
await openIosApp(IOS_TEST_DEVICE, 'myapp://item/42', {
993+
appBundleId: 'com.example.app',
994+
launchArgs: ['-Tracking', 'NO'],
995+
});
996+
assert.deepEqual(calls, [
997+
[
998+
'devicectl',
999+
'device',
1000+
'process',
1001+
'launch',
1002+
'--device',
1003+
'ios-device-1',
1004+
'--payload-url',
1005+
'myapp://item/42',
1006+
'--',
1007+
'com.example.app',
1008+
'-Tracking',
1009+
'NO',
1010+
],
1011+
]);
1012+
});
1013+
});
1014+
1015+
test('openIosApp rejects launchArgs combined with URL deep link on iOS simulator', async () => {
1016+
mockEnsureBootedSimulator.mockResolvedValue();
1017+
const launchArgsCases: string[][] = [['-FeatureFlag', 'YES'], []];
1018+
for (const launchArgs of launchArgsCases) {
1019+
await assert.rejects(
1020+
() =>
1021+
openIosApp(IOS_TEST_SIMULATOR, 'myapp://item/42', {
1022+
launchArgs,
1023+
}),
1024+
(error: unknown) => {
1025+
assert.ok(error instanceof AppError);
1026+
assert.equal(error.code, 'INVALID_ARGS');
1027+
assert.match(String(error.message), /simctl openurl/);
1028+
return true;
1029+
},
1030+
);
1031+
await assert.rejects(
1032+
() =>
1033+
openIosApp(IOS_TEST_SIMULATOR, 'MyApp', {
1034+
appBundleId: 'com.example.app',
1035+
url: 'https://example.com/path',
1036+
launchArgs,
1037+
}),
1038+
(error: unknown) => {
1039+
assert.ok(error instanceof AppError);
1040+
assert.equal(error.code, 'INVALID_ARGS');
1041+
return true;
1042+
},
1043+
);
1044+
}
1045+
});
1046+
1047+
test('openIosApp rejects launchArgs on macOS', async () => {
1048+
for (const launchArgs of [['-FeatureFlag', 'YES'], []]) {
1049+
await assert.rejects(
1050+
() =>
1051+
openIosApp(MACOS_TEST_DEVICE, 'TextEdit', {
1052+
launchArgs,
1053+
}),
1054+
(error: unknown) => {
1055+
assert.ok(error instanceof AppError);
1056+
assert.equal(error.code, 'UNSUPPORTED_OPERATION');
1057+
assert.match(String(error.message), /macOS/);
1058+
return true;
1059+
},
1060+
);
1061+
}
1062+
});
1063+
9281064
test('readIosClipboardText rejects physical devices', async () => {
9291065
await assert.rejects(
9301066
() => readIosClipboardText(IOS_TEST_DEVICE),

src/platforms/ios/apps.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ const ALIASES: Record<string, string> = {
6767
};
6868
const IOS_SIMULATOR_CONSOLE_CAPTURE_MS = 25_000;
6969

70+
const IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE =
71+
'--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.';
72+
const MACOS_LAUNCH_ARGS_UNSUPPORTED_MESSAGE =
73+
'--launch-args is not supported on macOS; launch arguments are currently iOS-only.';
74+
7075
const iosAppResolutionCache = createAppResolutionCache<string>();
7176
let cachedSimctlPrivacyServices: Set<string> | null = null;
7277
let cachedSimctlPrivacyServicesCacheKey: string | undefined;
@@ -132,10 +137,14 @@ export async function openIosApp(
132137
options?: { appBundleId?: string; launchConsole?: string; launchArgs?: string[]; url?: string },
133138
): Promise<void> {
134139
const launchConsole = options?.launchConsole?.trim();
140+
const launchArgs = options?.launchArgs;
135141
if (launchConsole && (device.platform !== 'ios' || device.kind !== 'simulator')) {
136142
throw new AppError('UNSUPPORTED_OPERATION', LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE);
137143
}
138144
if (device.platform === 'macos') {
145+
if (launchArgs !== undefined) {
146+
throw new AppError('UNSUPPORTED_OPERATION', MACOS_LAUNCH_ARGS_UNSUPPORTED_MESSAGE);
147+
}
139148
await openMacOsApp(device, app, options);
140149
return;
141150
}
@@ -148,6 +157,9 @@ export async function openIosApp(
148157
throw new AppError('INVALID_ARGS', 'open <app> <url> requires a valid URL target');
149158
}
150159
if (device.kind === 'simulator') {
160+
if (launchArgs !== undefined) {
161+
throw new AppError('INVALID_ARGS', IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE);
162+
}
151163
await ensureBootedSimulator(device);
152164
await runSimctl(device, ['openurl', device.id, explicitUrl]);
153165
return;
@@ -160,7 +172,7 @@ export async function openIosApp(
160172
'Deep link open on iOS devices requires an active app bundle ID. Open the app first, then open the URL.',
161173
);
162174
}
163-
await launchIosDeviceProcess(device, bundleId, { payloadUrl: explicitUrl });
175+
await launchIosDeviceProcess(device, bundleId, { payloadUrl: explicitUrl, launchArgs });
164176
return;
165177
}
166178

@@ -170,6 +182,9 @@ export async function openIosApp(
170182
throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE);
171183
}
172184
if (device.kind === 'simulator') {
185+
if (launchArgs !== undefined) {
186+
throw new AppError('INVALID_ARGS', IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE);
187+
}
173188
await ensureBootedSimulator(device);
174189
await runSimctl(device, ['openurl', device.id, deepLinkTarget]);
175190
return;
@@ -181,20 +196,20 @@ export async function openIosApp(
181196
'Deep link open on iOS devices requires an active app bundle ID. Open the app first, then open the URL.',
182197
);
183198
}
184-
await launchIosDeviceProcess(device, bundleId, { payloadUrl: deepLinkTarget });
199+
await launchIosDeviceProcess(device, bundleId, { payloadUrl: deepLinkTarget, launchArgs });
185200
return;
186201
}
187202

188203
const bundleId = options?.appBundleId ?? (await resolveIosApp(device, app));
189204
if (device.kind === 'simulator') {
190205
await launchIosSimulatorApp(device, bundleId, {
191206
...(launchConsole ? { launchConsole } : {}),
192-
...(options?.launchArgs ? { launchArgs: options.launchArgs } : {}),
207+
...(launchArgs ? { launchArgs } : {}),
193208
});
194209
return;
195210
}
196211

197-
await launchIosDeviceProcess(device, bundleId);
212+
await launchIosDeviceProcess(device, bundleId, { launchArgs });
198213
}
199214

200215
export async function openIosDevice(device: DeviceInfo): Promise<void> {
@@ -1005,7 +1020,10 @@ function buildIosSimulatorLaunchArgs(
10051020
const args = ['launch'];
10061021
if (options?.launchConsole) args.push('--console-pty');
10071022
args.push(deviceId, bundleId);
1008-
if (options?.launchArgs) args.push(...options.launchArgs);
1023+
if (options?.launchArgs && options.launchArgs.length > 0) {
1024+
// simctl launch treats args after <device> <bundle id> as app argv.
1025+
args.push(...options.launchArgs);
1026+
}
10091027
return args;
10101028
}
10111029

@@ -1062,11 +1080,23 @@ function joinProcessOutput(stdout: string, stderr: string): string {
10621080
async function launchIosDeviceProcess(
10631081
device: DeviceInfo,
10641082
bundleId: string,
1065-
options?: { payloadUrl?: string },
1083+
options?: { payloadUrl?: string; launchArgs?: string[] },
10661084
): Promise<void> {
1067-
const args = ['device', 'process', 'launch', '--device', device.id, bundleId];
1068-
if (options?.payloadUrl) {
1069-
args.push('--payload-url', options.payloadUrl);
1085+
const args = ['device', 'process', 'launch', '--device', device.id];
1086+
if (options?.launchArgs && options.launchArgs.length > 0) {
1087+
if (options?.payloadUrl) {
1088+
args.push('--payload-url', options.payloadUrl);
1089+
}
1090+
// `devicectl` uses Swift ArgumentParser; without `--` a leading-dash app
1091+
// arg can be re-interpreted as a devicectl option. The marker must come
1092+
// before the positional bundle id so it is consumed by devicectl instead
1093+
// of being forwarded to the app as argv[1].
1094+
args.push('--', bundleId, ...options.launchArgs);
1095+
} else {
1096+
args.push(bundleId);
1097+
if (options?.payloadUrl) {
1098+
args.push('--payload-url', options.payloadUrl);
1099+
}
10701100
}
10711101
await runIosDevicectl(args, { action: 'launch iOS app', deviceId: device.id });
10721102
}

0 commit comments

Comments
 (0)