Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@ test('local Apple tool provider exposes macOS host operations as semantic method
if (cmd === '/usr/bin/lsappinfo') {
return { exitCode: 0, stdout: 'ASN:0x0-0x1234-"Demo":\n', stderr: '' };
}
if (cmd === 'ps') {
return { exitCode: 0, stdout: ' 1 0 /sbin/launchd\n', stderr: '' };
}
calls.push([cmd, args, typeof options?.stdin === 'string' ? options.stdin : undefined]);
if (cmd === 'pbpaste') {
return { exitCode: 0, stdout: 'copied\r\n', stderr: '' };
Expand Down
16 changes: 10 additions & 6 deletions packages/platform-apple/src/os/macos/host-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ export function createLocalAppleMacOsHostProvider(
return {
openBundle: async (bundleId, url, options) => {
// Loaded on the first bundle open, not when this provider is constructed: the probe's host-kit
// reads stay off the Apple façades' eager closure (ADR 0019), and an open of an already-listed
// app is the common path that never needs them until `open` actually runs.
const { waitForLaunchServicesRegistration } =
await import('./launch-services-registration.ts');
await waitForLaunchServicesRegistration(runCommand, bundleId);
// reads stay off the Apple façades' eager closure (ADR 0019).
const { resolveListedRunningBundle } = await import('./launch-services-registration.ts');
const runningBundle = await resolveListedRunningBundle(runCommand, bundleId);
await runCommand('open', [
...openActivationArgs(options),
...buildMacOpenArgs(bundleId, url),
...(runningBundle
? buildMacOpenPathArgs(runningBundle, url)
: buildMacOpenArgs(bundleId, url)),
]);
},
openTarget: async (target, options) => {
Expand Down Expand Up @@ -87,6 +87,10 @@ function buildMacOpenArgs(bundleId: string, url?: string): string[] {
return openArgs;
}

function buildMacOpenPathArgs(bundlePath: string, url?: string): string[] {
return url ? ['-a', bundlePath, url] : [bundlePath];
}

async function listLocalMacApps(
runCommand: AppleToolCommandExecutor,
readPlistJson: ApplePlistJsonReader,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,24 +125,92 @@ afterEach(() => {
mockEmitDiagnostic.mockClear();
});

test('openBundle runs open at once when LaunchServices already lists an instance', async () => {
const { runCommand, calls, transcript } = fakeMacHost((call) =>
call.cmd === LAUNCH_SERVICES_TOOL ? { stdout: REGISTERED } : {},
);
test('openBundle opens the listed running copy by its own path, not by bundle id', async () => {
runningCopies();
const { runCommand, calls, transcript } = fakeMacHost((call) => {
if (call.cmd === LAUNCH_SERVICES_TOOL) return { stdout: REGISTERED };
if (call.cmd === 'plutil') return { stdout: 'com.example.demo\ncom.ghost.app\n' };
return {};
});

await openBundle(runCommand);

// Attaching to a listed instance is `open -b`'s own job, so no process-table read and no poll
// stands between this open and `open`.
expect(transcript()).toEqual([
{ cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] },
{ cmd: 'open', args: ['-b', BUNDLE_ID] },
{ cmd: PROCESS_TABLE_TOOL, args: PROCESS_TABLE_ARGS },
{ cmd: 'plutil', args: ['-extract', 'CFBundleIdentifier', 'raw', DEMO_PLIST, GHOST_PLIST] },
{ cmd: 'open', args: [DEMO_BUNDLE] },
]);
expect(mockSleep).not.toHaveBeenCalled();
// One read of the registration view, inside its own cap.
expect(registrationReads(calls)[0]?.timeoutMs).toBeLessThanOrEqual(LAUNCH_SERVICES_READ_CAP_MS);
});

test('openBundle sends a link to the listed running copy by its own path', async () => {
runningCopies();
const { runCommand, calls } = fakeMacHost((call) => {
if (call.cmd === LAUNCH_SERVICES_TOOL) return { stdout: REGISTERED };
if (call.cmd === 'plutil') return { stdout: 'com.example.demo\ncom.ghost.app\n' };
return {};
});

await createLocalAppleMacOsHostProvider(runCommand, async () => null).openBundle(
BUNDLE_ID,
'demo://item/1',
{ background: true },
);

expect(calls.at(-1)).toEqual({ cmd: 'open', args: ['-g', '-a', DEMO_BUNDLE, 'demo://item/1'] });
});

test('openBundle finds the running copy whose identifier differs only in case', async () => {
runningCopies();
const { runCommand, calls } = fakeMacHost((call) => {
if (call.cmd === LAUNCH_SERVICES_TOOL) return { stdout: REGISTERED };
if (call.cmd === 'plutil') return { stdout: 'com.Example.Demo\ncom.ghost.app\n' };
return {};
});

await openBundle(runCommand);

expect(calls.at(-1)).toEqual({ cmd: 'open', args: [DEMO_BUNDLE] });
});

test('openBundle leaves copies running from two paths to open -b', async () => {
processTable.stdout = psRows([
[4242, `${DEMO_BUNDLE}/Contents/MacOS/Demo -DevFlag`],
[4300, `${GHOST_BUNDLE}/Contents/MacOS/Ghost`],
]);
const { runCommand, calls } = fakeMacHost((call) => {
if (call.cmd === LAUNCH_SERVICES_TOOL) return { stdout: REGISTERED };
if (call.cmd === 'plutil') return { stdout: 'com.example.demo\ncom.example.demo\n' };
return {};
});

await openBundle(runCommand);

expect(calls.at(-1)).toEqual({ cmd: 'open', args: ['-b', BUNDLE_ID] });
});

test.each([
['a relative', 'Contents/Resources/FortiTray/FortiTray.app/Contents/MacOS/FortiTray'],
['a dash-led', '-Demo.app/Contents/MacOS/Demo'],
])('openBundle leaves a copy run from %s path to open -b', async (_shape, command) => {
processTable.stdout = psRows([[4242, command]]);
const { runCommand, transcript } = fakeMacHost((call) => {
if (call.cmd === LAUNCH_SERVICES_TOOL) return { stdout: REGISTERED };
if (call.cmd === 'plutil') return { stdout: `${BUNDLE_ID}\n` };
return {};
});

await openBundle(runCommand);

expect(transcript()).toEqual([
{ cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`] },
{ cmd: PROCESS_TABLE_TOOL, args: PROCESS_TABLE_ARGS },
{ cmd: 'open', args: ['-b', BUNDLE_ID] },
]);
});

test('openBundle runs open at once when no process of the opened bundle runs', async () => {
noRunningApps();
const { runCommand, calls, transcript } = fakeMacHost();
Expand Down Expand Up @@ -196,7 +264,7 @@ test('openBundle waits for LaunchServices to list a copy that runs unlisted', as
{ cmd: LAUNCH_SERVICES_TOOL, args: ['find', `bundleid=${BUNDLE_ID}`, 'pid=4242'] },
]);
expect(mockSleep).toHaveBeenCalledTimes(2);
expect(transcript().at(-1)).toEqual({ cmd: 'open', args: ['-b', BUNDLE_ID] });
expect(transcript().at(-1)).toEqual({ cmd: 'open', args: [DEMO_BUNDLE] });
// Each probe read carries a budget: the registration reads their own cap, the identifier read
// its larger one, and neither spends more than that.
for (const read of registrationReads(calls)) {
Expand Down Expand Up @@ -291,7 +359,7 @@ test('openBundle pairs rows with paths when an unreadable bundle name itself con
},
]);
expect(mockSleep).toHaveBeenCalledTimes(1);
expect(calls.at(-1)).toEqual({ cmd: 'open', args: ['-b', BUNDLE_ID] });
expect(calls.at(-1)).toEqual({ cmd: 'open', args: [DEMO_BUNDLE] });
});

test('openBundle still opens a copy LaunchServices never lists', async () => {
Expand Down
111 changes: 63 additions & 48 deletions packages/platform-apple/src/os/macos/launch-services-registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,53 +35,44 @@ const APP_BUNDLE_INFO_PLIST = 'Contents/Info.plist';
const LSAPPINFO_ASN = /ASN:/;

/**
* Waits out the LaunchServices registration race so an open does not start a second copy of an app
* whose running copy LaunchServices has not listed yet (#3406).
* The bundle path of the one running copy of `bundleId` that LaunchServices lists, or undefined to
* leave the open to `open -b`. `open -b` opens the copy LaunchServices resolves the bundle id to,
* which can be a stale build registered at another path rather than the running copy.
*
* A copy started directly — by a supervisor or a dev script — is only listed by LaunchServices
* about half a second after it spawns. While it is unlisted, `open -b` sees no instance and launches
* a second one with no arguments, which the session then drives instead of the copy the caller
* owns. An open whose bundle LaunchServices already lists reaches `open` on that single read; an
* open with no running copy pays one `lsappinfo find` and one process-table read (plus a
* bundle-identifier read only when app-shaped candidates exist) to learn that; only a copy that
* runs while unlisted is waited for, and only until LaunchServices lists it, since `open -b` then
* attaches instead of launching. Every read shares one budget, and the wait itself stays well
* inside it:
* about half a second after it spawns (#3406). While it is unlisted, `open` sees no instance and
* launches a second one with no arguments, which the session then drives instead of the copy the
* caller owns. An open with no running copy pays one `lsappinfo find` and one process-table read
* (plus a bundle-identifier read only when app-shaped candidates exist) to learn that; an open of a
* running copy pays the bundle-identifier read as well; only a copy that runs while unlisted is
* waited for. Every read shares one budget, and the wait itself stays well inside it:
*
* - `LAUNCH_SERVICES_PROBE_BUDGET_MS` bounds every read below, each read also taking its own
* smaller cap so a single hung tool cannot consume the budget alone;
* - `LAUNCH_SERVICES_MAX_POLLS` x `LAUNCH_SERVICES_POLL_MS` bounds the wait, sized for the ~0.5 s
* race measured on host Macs and far below the 90 s request envelope an open runs inside.
*
* A copy still unlisted when either bound runs out is opened anyway: the wait can only delay a
* duplicate, never cause one, and never withhold the open.
* A copy still unlisted when either bound runs out is opened by bundle id anyway: the wait can only
* delay a duplicate, never cause one, and never withhold the open.
*/
export async function waitForLaunchServicesRegistration(
export async function resolveListedRunningBundle(
runCommand: AppleToolCommandExecutor,
bundleId: string,
): Promise<void> {
): Promise<string | undefined> {
const deadline = Deadline.fromTimeoutMs(LAUNCH_SERVICES_PROBE_BUDGET_MS);
try {
const listed = await readsLaunchServicesInstance(runCommand, bundleId, deadline);
// Only an explicit "nothing is listed" is worth probing the process table for; a read that
// cannot answer leaves this open the way it was before the check existed.
if (listed !== false) return;
const unlistedPids = await findUnlistedInstancePids(runCommand, bundleId, deadline);
if (unlistedPids.length === 0) return;
for (let poll = 0; poll < LAUNCH_SERVICES_MAX_POLLS; poll += 1) {
if (deadline.remainingMs() < LAUNCH_SERVICES_POLL_MS + MIN_PROBE_READ_MS) return;
await sleep(LAUNCH_SERVICES_POLL_MS);
for (const pid of unlistedPids) {
// A copy LaunchServices has come to list is the copy `open -b` will attach to, whichever of
// the running copies it is. A read that answers nothing keeps the wait on its own bound
// rather than being read as a registration, which is what would hand the duplicate back. A
// read that cannot run at all ends the probe, which opens the bundle the way an unreadable
// host always did.
if ((await readsLaunchServicesInstance(runCommand, bundleId, deadline, pid)) === true) {
return;
}
}
// A read that cannot answer leaves this open the way it was before the check existed.
if (listed === undefined) return undefined;
const running = await findRunningInstances(runCommand, bundleId, deadline);
if (running.size === 0) return undefined;
if (
!listed &&
!(await waitForListing(runCommand, bundleId, [...running.values()].flat(), deadline))
) {
return undefined;
}
return running.size === 1 ? [...running.keys()][0] : undefined;
} catch (error) {
// The probe is an extra read on someone else's app launch, so a host that cannot answer it at
// all gets the open it would have had without the probe. The diagnostic is the only trace of
Expand All @@ -94,9 +85,33 @@ export async function waitForLaunchServicesRegistration(
reason: error instanceof AppError ? error.code : 'probe_read_failed',
},
});
return undefined;
}
}

async function waitForListing(
runCommand: AppleToolCommandExecutor,
bundleId: string,
pids: readonly number[],
deadline: Deadline,
): Promise<boolean> {
for (let poll = 0; poll < LAUNCH_SERVICES_MAX_POLLS; poll += 1) {
if (deadline.remainingMs() < LAUNCH_SERVICES_POLL_MS + MIN_PROBE_READ_MS) return false;
await sleep(LAUNCH_SERVICES_POLL_MS);
for (const pid of pids) {
// A copy LaunchServices has come to list is the copy `open` will attach to, whichever of
// the running copies it is. A read that answers nothing keeps the wait on its own bound
// rather than being read as a registration, which is what would hand the duplicate back. A
// read that cannot run at all ends the probe, which opens the bundle the way an unreadable
// host always did.
if ((await readsLaunchServicesInstance(runCommand, bundleId, deadline, pid)) === true) {
return true;
}
}
}
return false;
}

/**
* Whether LaunchServices lists a running instance of `bundleId`, optionally one exact process, or
* `undefined` when the read failed. It lists nothing for an app that is not running and for a copy
Expand Down Expand Up @@ -131,18 +146,13 @@ async function readsLaunchServicesInstance(
return LSAPPINFO_ASN.test(result.stdout);
}

/**
* The processes running `bundleId` that LaunchServices does not list. `open -b` attaches to
* whichever copy the registration eventually names, so every running copy of the bundle is watched
* and any one of them being listed ends the wait.
*/
async function findUnlistedInstancePids(
async function findRunningInstances(
runCommand: AppleToolCommandExecutor,
bundleId: string,
deadline: Deadline,
): Promise<number[]> {
): Promise<Map<string, number[]>> {
const timeoutMs = probeBudgetMs(deadline, PROCESS_TABLE_TIMEOUT_MS);
if (timeoutMs === undefined) return [];
if (timeoutMs === undefined) return new Map();
// The provider's own executor, so every read of this probe answers about the same host its
// `lsappinfo`, `plutil` and `open` run on: a scoped Apple provider must never pair pids from one
// host with tools on another.
Expand All @@ -156,7 +166,7 @@ async function findUnlistedInstancePids(
phase: 'macos_launch_services_process_table_unreadable',
data: { bundleId },
});
return [];
return new Map();
}
const pidsByBundle = new Map<string, number[]>();
for (const processInfo of processes) {
Expand All @@ -166,19 +176,23 @@ async function findUnlistedInstancePids(
if (pids) pids.push(processInfo.pid);
else pidsByBundle.set(bundlePath, [processInfo.pid]);
}
if (pidsByBundle.size === 0) return [];
if (pidsByBundle.size === 0) return pidsByBundle;
const bundlePaths = Array.from(pidsByBundle.keys());
const bundleIds = await readAppBundleIds(
runCommand,
bundlePaths.map((bundlePath) => path.join(bundlePath, APP_BUNDLE_INFO_PLIST)),
deadline,
);
return bundlePaths
.filter(
(bundlePath) =>
bundleIds.get(path.join(bundlePath, APP_BUNDLE_INFO_PLIST))?.trim() === bundleId,
)
.flatMap((bundlePath) => pidsByBundle.get(bundlePath)!);
return new Map(
bundlePaths
.filter(
// LaunchServices compares bundle identifiers without regard to case.
(bundlePath) =>
bundleIds.get(path.join(bundlePath, APP_BUNDLE_INFO_PLIST))?.trim().toLowerCase() ===
bundleId.toLowerCase(),
)
.map((bundlePath) => [bundlePath, pidsByBundle.get(bundlePath)!]),
);
}

/**
Expand All @@ -188,6 +202,7 @@ async function findUnlistedInstancePids(
* XPC services, app extensions, and driver bundles, whose identifiers match no app anyone opens.
*/
function macAppBundlePath(command: string): string | undefined {
if (!path.isAbsolute(command)) return undefined;
const marker = command.indexOf(APP_EXECUTABLE_MARKER);
if (marker <= 0) return undefined;
const bundlePath = command.slice(0, marker);
Expand Down
2 changes: 1 addition & 1 deletion website/docs/docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ agent-device snapshot -i --platform apple --target desktop
- `--platform macos`, or `--platform apple --target desktop`, selects the host Mac as a `desktop` target.
- Use an `app` session for normal app control: `open`, `snapshot`, `click`, `fill`, `press`, `scroll`, `back`, `screenshot`, `record`. In app sessions, `screenshot` captures the app window, not the whole desktop.
- Use the other surfaces to inspect desktop-wide UI before choosing an app, mainly with `snapshot`, `get`, `is`, and `wait`: `frontmost-app` is the focused app, `desktop` is the visible windows across the desktop, and `menubar` is the active app's menu bar and system menu extras. To click or fill inside one app afterwards, open it in an `app` session.
- `open <app> --platform macos` attaches to a copy of the app that is already running, including one you started yourself from a script or supervisor. If that copy has not finished registering with macOS, the open waits briefly for it before handing the URL over, so a copy that registers within that window keeps the arguments it was started with; a copy still unregistered after the wait gets the plain `open`, which can start a second instance.
- `open <app> --platform macos` attaches to a copy of the app that is already running, including one you started yourself from a script or supervisor. It opens that copy by its own app path, so another build registered for the same bundle ID, such as an earlier dev build at another path, does not start beside it. If that copy has not finished registering with macOS, the open waits briefly for it before handing the URL over, so a copy that registers within that window keeps the arguments it was started with; a copy still unregistered after the wait, or copies running from more than one path, get the plain `open -b`, which can start a second instance.
- `open <app> --platform macos --surface menubar` targets one app's menu bar extras. Prefer it for status-item apps, which often expose little through the `app` surface.
- Prefer selectors and `@ref`s; window positions shift between runs, so raw x/y is less stable. Use `click --button secondary` for context menus, then run `snapshot -i` again.
- On `frontmost-app` and `menubar`, `press` and `click` post synthetic mouse events (`desktop` is inspect-only). `--hold-ms` keeps the button down (at least 40 ms, default 60 ms), `--count` sends independent clicks, and `--double-tap` sends double-click pairs. Apps like Finder read two clicks at the default interval as one double-click; pass an `--interval-ms` longer than the system double-click time to keep them apart. Long schedules get the time they need, and a cancelled or interrupted click releases the button. `--jitter-px` is ignored on these surfaces.
Expand Down
Loading