Skip to content

Commit ec0c004

Browse files
committed
feat: add public --launch-arg CLI flag for iOS open
1 parent 5083d04 commit ec0c004

10 files changed

Lines changed: 270 additions & 11 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: 154 additions & 3 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
@@ -815,9 +830,9 @@ test('openIosApp web URL on iOS device without app falls back to Safari', async
815830
'launch',
816831
'--device',
817832
'ios-device-1',
818-
'com.apple.mobilesafari',
819833
'--payload-url',
820834
'https://example.com/path',
835+
'com.apple.mobilesafari',
821836
]);
822837
} finally {
823838
process.env.PATH = previousPath;
@@ -864,9 +879,9 @@ test('openIosApp custom scheme on iOS device uses active app context', async ()
864879
'launch',
865880
'--device',
866881
'ios-device-1',
867-
'com.example.app',
868882
'--payload-url',
869883
'myapp://item/42',
884+
'com.example.app',
870885
]);
871886
} finally {
872887
process.env.PATH = previousPath;
@@ -925,6 +940,142 @@ 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+
await assert.rejects(
1018+
() =>
1019+
openIosApp(IOS_TEST_SIMULATOR, 'myapp://item/42', {
1020+
launchArgs: ['-FeatureFlag', 'YES'],
1021+
}),
1022+
(error: unknown) => {
1023+
assert.ok(error instanceof AppError);
1024+
assert.equal(error.code, 'INVALID_ARGS');
1025+
assert.match(String(error.message), /simctl openurl/);
1026+
return true;
1027+
},
1028+
);
1029+
await assert.rejects(
1030+
() =>
1031+
openIosApp(IOS_TEST_SIMULATOR, 'MyApp', {
1032+
appBundleId: 'com.example.app',
1033+
url: 'https://example.com/path',
1034+
launchArgs: ['-FeatureFlag', 'YES'],
1035+
}),
1036+
(error: unknown) => {
1037+
assert.ok(error instanceof AppError);
1038+
assert.equal(error.code, 'INVALID_ARGS');
1039+
return true;
1040+
},
1041+
);
1042+
});
1043+
1044+
test('openIosApp treats empty launchArgs as absent for iOS simulator URL opens', async () => {
1045+
await withCapturedXcrunArgs(async (calls) => {
1046+
mockEnsureBootedSimulator.mockResolvedValue();
1047+
await openIosApp(IOS_TEST_SIMULATOR, 'myapp://item/42', { launchArgs: [] });
1048+
await openIosApp(IOS_TEST_SIMULATOR, 'MyApp', {
1049+
appBundleId: 'com.example.app',
1050+
url: 'https://example.com/path',
1051+
launchArgs: [],
1052+
});
1053+
assert.deepEqual(calls, [
1054+
['simctl', 'openurl', 'sim-1', 'myapp://item/42'],
1055+
['simctl', 'openurl', 'sim-1', 'https://example.com/path'],
1056+
]);
1057+
});
1058+
});
1059+
1060+
test('openIosApp rejects non-empty launchArgs on macOS', async () => {
1061+
await assert.rejects(
1062+
() =>
1063+
openIosApp(MACOS_TEST_DEVICE, 'TextEdit', {
1064+
launchArgs: ['-FeatureFlag', 'YES'],
1065+
}),
1066+
(error: unknown) => {
1067+
assert.ok(error instanceof AppError);
1068+
assert.equal(error.code, 'UNSUPPORTED_OPERATION');
1069+
assert.match(String(error.message), /macOS/);
1070+
return true;
1071+
},
1072+
);
1073+
});
1074+
1075+
test('openIosApp treats empty launchArgs as absent on macOS', async () => {
1076+
await openIosApp(MACOS_TEST_DEVICE, 'TextEdit', { launchArgs: [] });
1077+
});
1078+
9281079
test('readIosClipboardText rejects physical devices', async () => {
9291080
await assert.rejects(
9301081
() => readIosClipboardText(IOS_TEST_DEVICE),
@@ -1393,9 +1544,9 @@ test('openIosApp with app and URL on iOS device launches app bundle with payload
13931544
'launch',
13941545
'--device',
13951546
'ios-device-1',
1396-
'com.example.app',
13971547
'--payload-url',
13981548
'myapp://screen/to',
1549+
'com.example.app',
13991550
]);
14001551
} finally {
14011552
process.env.PATH = previousPath;

src/platforms/ios/apps.ts

Lines changed: 34 additions & 7 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-arg is not supported with iOS simulator URL opens (simctl openurl ignores launch args). Launch the app first with --launch-arg, then issue the URL open in a separate call.';
72+
const MACOS_LAUNCH_ARGS_UNSUPPORTED_MESSAGE =
73+
'--launch-arg 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?.length ? options.launchArgs : undefined;
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,20 @@ 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];
1085+
const args = ['device', 'process', 'launch', '--device', device.id];
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 `--` 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+
}
10711098
await runIosDevicectl(args, { action: 'launch iOS app', deviceId: device.id });
10721099
}

0 commit comments

Comments
 (0)