Skip to content

Commit 3a02cdc

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 3a02cdc

10 files changed

Lines changed: 297 additions & 7 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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export const clientCommandDefinitions = [
8181
surface: enumField(SURFACE_VALUES),
8282
activity: stringField('Android activity name.'),
8383
launchConsole: stringField('Launch console mode.'),
84+
launchArgs: stringArrayField('Launch arguments forwarded to the app.'),
8485
relaunch: booleanField('Force relaunch.'),
8586
saveScript: jsonSchemaField<boolean | string>({ oneOf: [booleanSchema(), stringSchema()] }),
8687
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: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -925,6 +925,193 @@ test('openIosApp captures iOS simulator launch console output when requested', a
925925
}
926926
});
927927

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

src/platforms/ios/apps.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ 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+
7073
const iosAppResolutionCache = createAppResolutionCache<string>();
7174
let cachedSimctlPrivacyServices: Set<string> | null = null;
7275
let cachedSimctlPrivacyServicesCacheKey: string | undefined;
@@ -132,10 +135,17 @@ export async function openIosApp(
132135
options?: { appBundleId?: string; launchConsole?: string; launchArgs?: string[]; url?: string },
133136
): Promise<void> {
134137
const launchConsole = options?.launchConsole?.trim();
138+
const launchArgs = options?.launchArgs;
135139
if (launchConsole && (device.platform !== 'ios' || device.kind !== 'simulator')) {
136140
throw new AppError('UNSUPPORTED_OPERATION', LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE);
137141
}
138142
if (device.platform === 'macos') {
143+
if (launchArgs && launchArgs.length > 0) {
144+
throw new AppError(
145+
'UNSUPPORTED_OPERATION',
146+
'--launch-args is not supported on macOS; launch arguments are currently iOS-only.',
147+
);
148+
}
139149
await openMacOsApp(device, app, options);
140150
return;
141151
}
@@ -148,6 +158,9 @@ export async function openIosApp(
148158
throw new AppError('INVALID_ARGS', 'open <app> <url> requires a valid URL target');
149159
}
150160
if (device.kind === 'simulator') {
161+
if (launchArgs && launchArgs.length > 0) {
162+
throw new AppError('INVALID_ARGS', IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE);
163+
}
151164
await ensureBootedSimulator(device);
152165
await runSimctl(device, ['openurl', device.id, explicitUrl]);
153166
return;
@@ -160,7 +173,7 @@ export async function openIosApp(
160173
'Deep link open on iOS devices requires an active app bundle ID. Open the app first, then open the URL.',
161174
);
162175
}
163-
await launchIosDeviceProcess(device, bundleId, { payloadUrl: explicitUrl });
176+
await launchIosDeviceProcess(device, bundleId, { payloadUrl: explicitUrl, launchArgs });
164177
return;
165178
}
166179

@@ -170,6 +183,9 @@ export async function openIosApp(
170183
throw new AppError('INVALID_ARGS', LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE);
171184
}
172185
if (device.kind === 'simulator') {
186+
if (launchArgs && launchArgs.length > 0) {
187+
throw new AppError('INVALID_ARGS', IOS_SIMULATOR_LAUNCH_ARGS_WITH_URL_MESSAGE);
188+
}
173189
await ensureBootedSimulator(device);
174190
await runSimctl(device, ['openurl', device.id, deepLinkTarget]);
175191
return;
@@ -181,20 +197,20 @@ export async function openIosApp(
181197
'Deep link open on iOS devices requires an active app bundle ID. Open the app first, then open the URL.',
182198
);
183199
}
184-
await launchIosDeviceProcess(device, bundleId, { payloadUrl: deepLinkTarget });
200+
await launchIosDeviceProcess(device, bundleId, { payloadUrl: deepLinkTarget, launchArgs });
185201
return;
186202
}
187203

188204
const bundleId = options?.appBundleId ?? (await resolveIosApp(device, app));
189205
if (device.kind === 'simulator') {
190206
await launchIosSimulatorApp(device, bundleId, {
191207
...(launchConsole ? { launchConsole } : {}),
192-
...(options?.launchArgs ? { launchArgs: options.launchArgs } : {}),
208+
...(launchArgs ? { launchArgs } : {}),
193209
});
194210
return;
195211
}
196212

197-
await launchIosDeviceProcess(device, bundleId);
213+
await launchIosDeviceProcess(device, bundleId, { launchArgs });
198214
}
199215

200216
export async function openIosDevice(device: DeviceInfo): Promise<void> {
@@ -1005,7 +1021,9 @@ function buildIosSimulatorLaunchArgs(
10051021
const args = ['launch'];
10061022
if (options?.launchConsole) args.push('--console-pty');
10071023
args.push(deviceId, bundleId);
1008-
if (options?.launchArgs) args.push(...options.launchArgs);
1024+
if (options?.launchArgs && options.launchArgs.length > 0) {
1025+
args.push(...options.launchArgs);
1026+
}
10091027
return args;
10101028
}
10111029

@@ -1062,11 +1080,18 @@ 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> {
10671085
const args = ['device', 'process', 'launch', '--device', device.id, bundleId];
10681086
if (options?.payloadUrl) {
10691087
args.push('--payload-url', options.payloadUrl);
10701088
}
1089+
if (options?.launchArgs && options.launchArgs.length > 0) {
1090+
// `devicectl` uses Swift ArgumentParser; without `--` an arg starting with
1091+
// `-` / `--` could be re-interpreted as one of devicectl's own options.
1092+
// The marker makes the boundary unambiguous so launch args reach the app
1093+
// verbatim.
1094+
args.push('--', ...options.launchArgs);
1095+
}
10711096
await runIosDevicectl(args, { action: 'launch iOS app', deviceId: device.id });
10721097
}

0 commit comments

Comments
 (0)