Skip to content

Commit 3685678

Browse files
authored
feat(open): answer the iOS launch confirmation for --launch-url (#3084)
* feat(apple): answer the launch confirmation open --launch-url raises On an iOS Simulator, `open <bundle> --launch-url <scheme-url>` hands the URL to SpringBoard, which can hold the launch behind `Open in "<App>"?`. After the launch settles, the open reads the alert once through the runner. A confirmation naming the session app (display name from `simctl listapps`) is accepted, the released launch is observed again, and the outcome carries `launchConfirmation: 'accepted'`. A confirmation naming any other app is never accepted: the open fails with `reason: launch_confirmation_foreign_app` and the named app in `details.appName`. No alert (the runner's typed ALERT_NOT_FOUND) or a non-confirmation alert leaves the outcome as before. The read is skipped once the launch budget (IOS_APP_LAUNCH_TIMEOUT_MS) is spent, and a confirmable open demands the runner even under an observation-only plan. Tests and the mutation that makes each fail: - launch-confirmation.test.ts - accepts ... curly/straight quotes: drop `"` from the title pattern. - foreign app never accepted: replace `namedApp !== sessionAppName` with `false`. - no alert costs one read: remove the `isAlertNotFoundError` return. - non-confirmation alert left for the caller: make the title pattern match any `Open in|Allow` prefix. - failed alert read reported as itself: catch every read error as absence. - port reads/accepts for the session app: drop `appBundleId` from the alert target, or return the first listed app's name. - lifecycle.test.ts - accepted and reported: remove the re-settle after accept (event order), or keep `resolveAppleSimulatorRunnerDemand` for confirmable opens (runnerDemand 'none'). - no confirmation costs one alert read: same runner-demand mutation. - foreign app fails without accepting: skip the foreign-app check. - no/web launch URL never reads an alert: drop `!isWebUrl(url)` from `confirmable`. - physical iOS never reads an alert: drop `device.kind === 'simulator'` from `confirmable`. * feat(open): report launchConfirmation in the open response `completeOpenCommand` forwards the lifecycle outcome's `launchConfirmation` into `buildOpenResult`, which sets `data.launchConfirmation` only when the platform answered a confirmation. The Node client's `apps.open` result carries it as `launchConfirmation: 'accepted'`. The open command help names both outcomes. Tests and the mutation that makes each fail: - session-open-surface.test.ts "buildOpenResult reports an answered launch confirmation...": remove `if (launchConfirmation) result.launchConfirmation`. - session-open-execution-runtime.test.ts "open reports the launch confirmation its platform answered": drop `launchConfirmation: outcome.launchConfirmation` from the `buildOpenResult` call. - client.test.ts "apps.open reports an answered launch confirmation only when the daemon answered one": replace `data.launchConfirmation === 'accepted'` with `false`. * docs(commands): describe how open answers the iOS launch confirmation Adds both outcomes of an iOS Simulator `open --launch-url` prompt to the Navigation notes: a prompt naming the session app is answered and reported as launchConfirmation: "accepted"; a prompt naming another app fails the open with launch_confirmation_foreign_app and details.appName. Docs only; no test. * test(ios-smoke): delete answerDeepLinkConfirmation now that open answers it `open --launch-url` answers the `Open in "<App>"?` confirmation itself, so the smoke lane's `answerDeepLinkConfirmation` / `acceptDeepLinkConfirmationIfPresent` helper and its unit test are deleted. The three deep-link scenarios (automation lab, WebView lab, visible-depth frontier) now assert the open either raised no confirmation or reports `launchConfirmation: "accepted"`, then wait once for their route landmark with a 30 s budget (the released launch has taken over 20 s to reach the foreground on a loaded CI host). `assertWaitSelector` takes the same optional `timeoutMs` as `assertWaitText`. No new unit test: the changed files are live-device scenario code, exercised only by the iOS Simulator smoke lane. The deleted test covered the deleted helper. * feat(cli): print launchConfirmation in open --json output The CLI serializes open through the Node client result, and serializeOpenResult listed its fields explicitly, so an answered confirmation reached the client but not `open --json`. It now carries `launchConfirmation` when present. Found by the live iOS Simulator run: the runner log showed the confirmation read and accepted while the CLI JSON had no field. Test and the mutation that makes it fail: - result-serialization.test.ts "serializeOpenResult carries an answered launch confirmation": delete the launchConfirmation spread in serializeOpenResult. * fix(apple): read the launch confirmation only when the bridge cannot see the app The alert read now runs only after the host AX bridge reports the launched app unobservable, within what is left of the launch budget, so an open whose app is observed keeps its runner demand and timing. A failed or late read leaves the open as it was. A prompt is accepted only when its name resolves to exactly one installed bundle and that bundle is the session app; a name no single bundle carries is left unanswered, and the foreign-app failure names foreignAppBundleId and sessionAppBundleId. * test(client): move apps namespace cases into client-apps.test.ts client.test.ts grew past the test-file size ratchet (1573 lines vs 1552 at the merge-base). Move the apps.open and apps.installFromSource cases, which exercise the client's apps namespace, into their own file. The cases are moved unchanged. * fix(apple): read the launch confirmation only when the launched app itself is unreadable The Simulator launch observation now reports probe-failed, with its typed failure, for an unresolvable target, an open bridge circuit, or a bridge failure that is not a launch transition. Only an app with no running process or an expired launch-transition window stays unobservable, so only those opens read the alert. An open that reads it records runner demand 'required'. * fix(apple): attribute the launch confirmation to the URL scheme owner within one budget The sheet title now only recognizes a launch confirmation. The app it would open is the URL scheme owner from resolveIosSimulatorDeepLinkBundleId, so a localized app name can no longer attribute a foreign app's sheet to the session app; the listapps name matching is deleted. The alert read, the runner interactor resolution, the owner lookup and the accept share the remaining launch budget. A failed or late leg leaves the open as it was, with a diagnostic. * docs(open): state that open answers only a confirmation present before it sees the app The --launch-url help and the commands page now attribute the prompt to the URL scheme owner and say a prompt that appears after open has seen the app is left for alert accept. * test(ios-smoke): retry the deep-link destination wait in one shared helper The three deep-link scenarios wait through waitForDeepLinkDestination: up to five 15 s waits, retried only on a miss that says the destination was not observed yet, with no alert step. assertLaunchConfirmationAnswered could not fail, so it is deleted. * refactor(apple): carry the session app with the confirmable launch URL AppleLaunchPlan.confirmation holds the URL and the bundle it was checked for, so the answer no longer re-checks appBundleId. followUpUrl stays separate: it says how the URL is dispatched, confirmation says whether SpringBoard may hold it, and a direct Simulator launch has both. * fix(apple): let the confirmation answer finish instead of racing a budget The race only stopped the waiting: a late accept could still tap the sheet after open returned unanswered. Each step is already bounded (one runner alert get, the simctl owner lookup, actOnAppleAlert's 2 s retry window), so the timer, the Leg enum and the launch-budget arithmetic go away and the answer awaits each step to its own end. * refactor(apple): read a typed alert absence as no alert at the port alert.ts maps the backend's typed absence to undefined in alertIfPresent and keeps isAlertNotFoundError private; the answer no longer branches on which step failed. * refactor(apple): build the confirmation port from one resolved interactor The port binds readAlert and acceptAlert once; the answer resolves the runner interactor a single time and leaves the open unanswered when it cannot. * refactor(apple): answer the launch confirmation inside the one open settle settleAppleOpen observes the launched app as a typed LaunchObservation; when the verdict is unobservable and the plan can answer a confirmation, it answers once and observes again, then writes timing once. The second settle, the delete of a stale failure and the reads back from timing go away, and postOpenSettleDurationMs now spans the whole settle. * refactor(contracts): give the post-open observation failure one meaning per field PostOpenObservationFailure is a union keyed by source: capture (Android error code and reason), target (iOS target resolution error code and reason), bridge (bridge failure kind and code), circuit (no fields). The circuit no longer invents a code pair, the bridge no longer swaps kind into code, and the doc note that explained the swap is gone. Android's released fields keep their meaning and gain source. * fix(apple): report a launch alert open cannot recognize as a confirmation The runner reports an alert only as its localized title and button labels, and matching the app name is unsafe because permission alerts name the app too, so the confirmation stays recognized by its English title. An alert that is present but not recognized now emits a warn diagnostic with its title and buttons, and commands.md and the open help state the English-only limit. * fix(apple): bound and abort the launch URL owner lookup The settle's URL-owner lookup ran simctl listapps and one plutil per installed app with no timeout and no signal, so a wedged CoreSimulator held open --launch-url without limit. The lookup now shares one 10 s deadline across every spawn and the open's signal aborts it. * test(apple): clear the wedged spawn's timeout when its signal aborts * test(apple): end a wedged spawn at once when its signal is already aborted * docs(open): an owner the lookup cannot read is one more unresolved case * fix(apple): keep the app-list conversion inside the owner lookup budget and surface its timeout * test(apple): bound the spawn wait and index the last arg with at()
1 parent 7ae4c86 commit 3685678

34 files changed

Lines changed: 1780 additions & 813 deletions

‎packages/contracts/src/application-lifecycle-runtime.ts‎

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,9 @@ export type OpenApplicationTiming = Readonly<{
140140
* or AX-server state that did not clear, a system surface over the app, a content verdict after
141141
* the capture's own re-captures, or the window ran out.
142142
* - `probe-failed`: the observation could not run (Android: the helper is not installed at the
143-
* current version, or adb or the accessibility service failed). `postOpenObservationFailure`
144-
* carries the typed failure.
143+
* current version, or adb or the accessibility service failed; iOS Simulator: the app's bridge
144+
* target could not be resolved, the bridge circuit is open, or the bridge failed with a code that
145+
* is not a launch transition). `postOpenObservationFailure` carries the typed failure.
145146
* - `app-unidentified`: the open targeted an app, but the owner could not read which package it
146147
* launched, so nothing was observed.
147148
* - `not-eligible`: the device has no observation path.
@@ -153,15 +154,31 @@ export type PostOpenObservation =
153154
| 'app-unidentified'
154155
| 'not-eligible';
155156

156-
/** The typed failure of a `probe-failed` observation: the error code and its typed reason. */
157-
export type PostOpenObservationFailure = Readonly<{
158-
code: string;
159-
reason?: string;
160-
}>;
157+
/**
158+
* The typed failure of a `probe-failed` observation, by what stopped it.
159+
* - `capture`: an Android capture failed with this error `code` and typed `reason`.
160+
* - `target`: an iOS Simulator could not resolve the app's bridge target; the error `code` and
161+
* typed `reason`.
162+
* - `bridge`: the iOS Simulator host AX bridge failed with this failure `kind` and `code`.
163+
* - `circuit`: the bridge circuit is open for this app generation, so the probe did not run.
164+
*/
165+
export type PostOpenObservationFailure =
166+
| Readonly<{ source: 'capture'; code: string; reason?: string }>
167+
| Readonly<{ source: 'target'; code: string; reason?: string }>
168+
| Readonly<{ source: 'bridge'; kind: string; code: string }>
169+
| Readonly<{ source: 'circuit' }>;
170+
171+
/**
172+
* A system confirmation the launch itself raised and the open answered. `accepted`: iOS held a
173+
* launch URL behind `Open in "<App>"?` naming the session app, and the open accepted it.
174+
*/
175+
export type LaunchConfirmation = 'accepted';
161176

162177
export type OpenApplicationOutcome = Readonly<{
163178
appBundleId?: string;
164179
timing: OpenApplicationTiming;
180+
/** Present only when the open answered a launch confirmation. */
181+
launchConfirmation?: LaunchConfirmation;
165182
}>;
166183

167184
/** Applies or clears a platform's native representation of neutral runtime hints. */

‎packages/contracts/src/client-app.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// The public API vocabulary for app install, deploy, open, close and inventory.
22

33
import type { AppsFilter } from './app-inventory.ts';
4+
import type { LaunchConfirmation } from './application-lifecycle-runtime.ts';
45
import type { JsonObject } from './json.ts';
56
import type { SessionSurface } from './session-surface.ts';
67
import type { TargetShutdownResult } from './target-shutdown-contract.ts';
@@ -109,6 +110,11 @@ export type AppOpenResult = {
109110
runtime?: SessionRuntimeHints;
110111
selection?: DeviceSelectionMetadata;
111112
device?: AgentDeviceSessionDevice;
113+
/**
114+
* `accepted` when iOS held the launch URL behind an `Open in "<App>"?` confirmation naming the
115+
* session app and the open answered it.
116+
*/
117+
launchConfirmation?: LaunchConfirmation;
112118
/**
113119
* Initial interactive snapshot captured immediately after an open that
114120
* requested `foreground`, composed from the same snapshot-runtime dispatch

‎packages/platform-android/src/launch-observation.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ test('a capture mechanism failure is a failed probe with its typed reason', asyn
139139

140140
await expect(observation.observe()).resolves.toEqual({
141141
observation: 'probe-failed',
142-
failure: { code: 'COMMAND_FAILED', reason: 'accessibility-timeout' },
142+
failure: { source: 'capture', code: 'COMMAND_FAILED', reason: 'accessibility-timeout' },
143143
});
144144
});
145145

@@ -151,7 +151,11 @@ test('a device without the current helper is a failed probe', async () => {
151151

152152
await expect(observation.observe()).resolves.toEqual({
153153
observation: 'probe-failed',
154-
failure: { code: 'COMMAND_FAILED', reason: 'android-snapshot-helper-not-current' },
154+
failure: {
155+
source: 'capture',
156+
code: 'COMMAND_FAILED',
157+
reason: 'android-snapshot-helper-not-current',
158+
},
155159
});
156160
});
157161

‎packages/platform-android/src/launch-observation.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,5 +50,8 @@ export async function observeAndroidLaunch(
5050
function typedFailure(error: unknown): PostOpenObservationFailure {
5151
const normalized = normalizeError(error);
5252
const reason = readAndroidCaptureFailureReason(normalized) ?? normalized.details?.reason;
53-
return typeof reason === 'string' ? { code: normalized.code, reason } : { code: normalized.code };
53+
const { code } = normalized;
54+
return typeof reason === 'string'
55+
? { source: 'capture', code, reason }
56+
: { source: 'capture', code };
5457
}

‎packages/platform-android/src/lifecycle.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ test('a failed launch probe reports its typed failure and the open still succeed
167167

168168
expect(outcome.timing.postOpenObservation).toBe('probe-failed');
169169
expect(outcome.timing.postOpenObservationFailure).toEqual({
170+
source: 'capture',
170171
code: 'COMMAND_FAILED',
171172
reason: 'accessibility-timeout',
172173
});

‎packages/platform-apple/src/alert.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ function runAppleAlert(
5656
)) as Record<string, unknown>;
5757
}
5858

59+
/** The alert `read` settles with, or `undefined` when the backend reports a typed absence. */
60+
export async function alertIfPresent<T>(read: Promise<T>): Promise<T | undefined> {
61+
try {
62+
return await read;
63+
} catch (error) {
64+
if (isAlertNotFoundError(error)) return undefined;
65+
throw error;
66+
}
67+
}
68+
5969
export async function readAppleAlert(
6070
device: DeviceInfo,
6171
runnerOptions: RunnerCallOptions,

‎packages/platform-apple/src/core/app-resolution.ts‎

Lines changed: 83 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import path from 'node:path';
22
import { hostFileStat } from '@agent-device/host-kit/host-file';
33
import { fileURLToPath } from 'node:url';
44
import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device';
5-
import { AppError } from '@agent-device/kernel/errors';
5+
import { AppError, isRequestCanceledError } from '@agent-device/kernel/errors';
6+
import { isCommandTimeoutError } from '@agent-device/host-kit/command';
67
import type { AppsFilter } from '@agent-device/contracts/device';
78
import {
89
createAppResolutionCache,
@@ -16,6 +17,7 @@ import { runAppleToolCommand } from './tool-provider.ts';
1617
import { runSimctlForDevice } from './simctl.ts';
1718
import { resolveIosPhysicalDeviceControl } from './physical-device-control.ts';
1819
import { createTtlMemo } from '@agent-device/kernel/ttl-memo';
20+
import { Deadline } from '@agent-device/host-kit/retry';
1921

2022
const ALIASES: Record<string, string> = {
2123
settings: 'com.apple.Preferences',
@@ -168,20 +170,30 @@ type SimulatorAppMetadata = {
168170
applicationType?: string;
169171
};
170172

173+
/**
174+
* The installed app that owns `url`'s scheme, when exactly one does. `timeoutMs` is one deadline
175+
* shared by the app listing and every Info.plist read; a spawn it cuts short or `signal` aborts
176+
* rejects the lookup.
177+
*/
171178
export async function resolveIosSimulatorDeepLinkBundleId(
172179
device: DeviceInfo,
173180
url: string,
181+
options: SimulatorAppListOptions = {},
174182
): Promise<string | undefined> {
175183
if (!isIosFamily(device) || device.kind !== 'simulator') return undefined;
176184
const scheme = parseUrlScheme(url);
177185
if (!scheme) return undefined;
178186

179-
const apps = await listSimulatorAppMetadata(device);
187+
const remaining = remainingLookupBudget(options);
188+
const apps = await listSimulatorAppMetadata(device, remaining());
180189
const matches: SimulatorAppMetadata[] = [];
181190
for (const app of apps) {
182191
if (app.bundleId.startsWith(AGENT_DEVICE_RUNNER_BUNDLE_PREFIX)) continue;
183192
if (!app.path) continue;
184-
const schemes = await readIosSimulatorAppUrlSchemes(path.join(app.path, 'Info.plist'));
193+
const schemes = await readIosSimulatorAppUrlSchemes(
194+
path.join(app.path, 'Info.plist'),
195+
remaining(),
196+
);
185197
if (schemes.has(scheme)) {
186198
matches.push(app);
187199
}
@@ -193,6 +205,16 @@ export async function resolveIosSimulatorDeepLinkBundleId(
193205
return matches.length === 1 ? matches[0]?.bundleId : undefined;
194206
}
195207

208+
function remainingLookupBudget({
209+
timeoutMs,
210+
signal,
211+
}: SimulatorAppListOptions): () => SimulatorAppListOptions {
212+
const abortable = signal ? { signal } : {};
213+
if (timeoutMs === undefined) return () => abortable;
214+
const deadline = Deadline.fromTimeoutMs(timeoutMs);
215+
return () => ({ timeoutMs: Math.max(1, deadline.remainingMs()), ...abortable });
216+
}
217+
196218
function parseUrlScheme(url: string): string | undefined {
197219
const match = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(url.trim());
198220
return match?.[1]?.toLowerCase();
@@ -209,10 +231,11 @@ export async function listIosApps(device: DeviceInfo, filter: AppsFilter): Promi
209231
return await resolveIosPhysicalDeviceControl(device).listApps(device, filter);
210232
}
211233

212-
type SimulatorAppListOptions = {
234+
type SimulatorAppListOptions = Readonly<{
213235
/** Unset (default) preserves prior unbounded behavior for normal command flow. */
214236
timeoutMs?: number;
215-
};
237+
signal?: AbortSignal;
238+
}>;
216239

217240
async function listSimulatorApps(
218241
device: DeviceInfo,
@@ -225,69 +248,31 @@ async function listSimulatorApps(
225248
}));
226249
}
227250

251+
type SimulatorAppListRecord = Record<
252+
string,
253+
{
254+
ApplicationType?: string;
255+
Bundle?: string;
256+
CFBundleDisplayName?: string;
257+
CFBundleName?: string;
258+
Path?: string;
259+
}
260+
>;
261+
228262
async function listSimulatorAppMetadata(
229263
device: DeviceInfo,
230264
options?: SimulatorAppListOptions,
231265
): Promise<SimulatorAppMetadata[]> {
266+
const remaining = remainingLookupBudget(options ?? {});
232267
const result = await runSimctlForDevice(device, ['listapps', device.id], {
233268
allowFailure: true,
234-
timeoutMs: options?.timeoutMs,
269+
...remaining(),
235270
});
236-
const stdout = result.stdout as string;
237-
const trimmed = stdout.trim();
238-
if (!trimmed) return [];
239-
240-
let parsed: Record<
241-
string,
242-
{
243-
ApplicationType?: string;
244-
Bundle?: string;
245-
CFBundleDisplayName?: string;
246-
CFBundleName?: string;
247-
Path?: string;
248-
}
249-
> | null = null;
250-
if (trimmed.startsWith('{')) {
251-
try {
252-
parsed = JSON.parse(trimmed) as Record<
253-
string,
254-
{
255-
ApplicationType?: string;
256-
Bundle?: string;
257-
CFBundleDisplayName?: string;
258-
CFBundleName?: string;
259-
Path?: string;
260-
}
261-
>;
262-
} catch {
263-
parsed = null;
264-
}
265-
}
266-
267-
if (!parsed && trimmed.startsWith('{')) {
268-
try {
269-
const converted = await runAppleToolCommand('plutil', ['-convert', 'json', '-o', '-', '-'], {
270-
allowFailure: true,
271-
stdin: trimmed,
272-
timeoutMs: options?.timeoutMs,
273-
});
274-
if (converted.exitCode === 0 && converted.stdout.trim().startsWith('{')) {
275-
parsed = JSON.parse(converted.stdout) as Record<
276-
string,
277-
{
278-
ApplicationType?: string;
279-
Bundle?: string;
280-
CFBundleDisplayName?: string;
281-
CFBundleName?: string;
282-
Path?: string;
283-
}
284-
>;
285-
}
286-
} catch {
287-
parsed = null;
288-
}
289-
}
271+
const trimmed = (result.stdout as string).trim();
272+
if (!trimmed.startsWith('{')) return [];
290273

274+
const parsed =
275+
parseSimulatorAppList(trimmed) ?? (await convertSimulatorAppList(trimmed, remaining()));
291276
if (!parsed) return [];
292277
return Object.entries(parsed).map(([bundleId, info]) => {
293278
const appPath = resolveSimulatorAppPath(info);
@@ -300,6 +285,36 @@ async function listSimulatorAppMetadata(
300285
});
301286
}
302287

288+
function parseSimulatorAppList(text: string): SimulatorAppListRecord | null {
289+
try {
290+
return JSON.parse(text) as SimulatorAppListRecord;
291+
} catch {
292+
return null;
293+
}
294+
}
295+
296+
/**
297+
* `simctl listapps` prints an OpenStep plist on some Xcode versions; `plutil` converts it. A
298+
* timeout or abort of that conversion is the lookup's failure, not an empty app list.
299+
*/
300+
async function convertSimulatorAppList(
301+
text: string,
302+
options: SimulatorAppListOptions,
303+
): Promise<SimulatorAppListRecord | null> {
304+
try {
305+
const converted = await runAppleToolCommand('plutil', ['-convert', 'json', '-o', '-', '-'], {
306+
allowFailure: true,
307+
stdin: text,
308+
...options,
309+
});
310+
if (converted.exitCode !== 0 || !converted.stdout.trim().startsWith('{')) return null;
311+
return JSON.parse(converted.stdout) as SimulatorAppListRecord;
312+
} catch (error) {
313+
if (isCommandTimeoutError(error) || isRequestCanceledError(error)) throw error;
314+
return null;
315+
}
316+
}
317+
303318
function resolveSimulatorAppPath(info: { Bundle?: string; Path?: string }): string | undefined {
304319
if (info.Path) return info.Path;
305320
if (!info.Bundle) return undefined;
@@ -322,19 +337,22 @@ const iosSimulatorAppUrlSchemeMemo = createTtlMemo<string, Set<string>>({
322337
scheduleExpiry: true,
323338
});
324339

325-
async function readIosSimulatorAppUrlSchemes(infoPlistPath: string): Promise<Set<string>> {
340+
async function readIosSimulatorAppUrlSchemes(
341+
infoPlistPath: string,
342+
options: SimulatorAppListOptions,
343+
): Promise<Set<string>> {
326344
let cacheKey: string | undefined;
327345
try {
328346
cacheKey = `${infoPlistPath}:${(await hostFileStat(infoPlistPath)).mtimeMs}`;
329347
} catch {
330348
cacheKey = undefined;
331349
}
332350
if (cacheKey === undefined) {
333-
return (await readIosSimulatorAppUrlSchemesUncached(infoPlistPath)) ?? new Set();
351+
return (await readIosSimulatorAppUrlSchemesUncached(infoPlistPath, options)) ?? new Set();
334352
}
335353
const cached = iosSimulatorAppUrlSchemeMemo.get(cacheKey);
336354
if (cached) return cached;
337-
const schemes = await readIosSimulatorAppUrlSchemesUncached(infoPlistPath);
355+
const schemes = await readIosSimulatorAppUrlSchemesUncached(infoPlistPath, options);
338356
if (schemes) iosSimulatorAppUrlSchemeMemo.set(cacheKey, schemes);
339357
return schemes ?? new Set();
340358
}
@@ -345,13 +363,12 @@ async function readIosSimulatorAppUrlSchemes(infoPlistPath: string): Promise<Set
345363
*/
346364
async function readIosSimulatorAppUrlSchemesUncached(
347365
infoPlistPath: string,
366+
options: SimulatorAppListOptions,
348367
): Promise<Set<string> | undefined> {
349368
const result = await runAppleToolCommand(
350369
'plutil',
351370
['-convert', 'json', '-o', '-', infoPlistPath],
352-
{
353-
allowFailure: true,
354-
},
371+
{ allowFailure: true, ...options },
355372
);
356373
if (result.exitCode !== 0) return undefined;
357374
try {

0 commit comments

Comments
 (0)