Files
reverse-engineering-anything/src/application/MacHopperPaths.ts
T
Paritosh LahreandClaude Opus 5.5 8f91e60558 fix(doctor): check only the current platform's Hopper launcher candidates (#6253)
* fix(doctor): check only the current platform's Hopper launcher candidates

Doctor built one launcher list for every platform, so on macOS it could
report a Linux-only path as a healthy Hopper ahead of a renamed bundle,
and setup then registered that path. Give per-platform automatic
candidates one owner that runtime configuration and doctor both read,
scan renamed macOS bundles and probe the Homebrew cask only on macOS,
and share runtime's renamed-bundle scan with the doctor host.

Fixes #6228

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(doctor): keep the renamed Hopper bundle scan asynchronous

Sharing runtime's synchronous scan made the doctor host read
~/Applications with readdirSync, which blocks the event loop on a slow
or network-mounted home. Give MacHopperPaths an asynchronous scan for
doctor beside runtime's lazy synchronous one, sharing the directory
filter and candidate builder, and check that both return the same
candidates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(doctor): model renamed launchers with runtime's POSIX joins

Since #5235, runtime builds macOS launcher paths with posix.join, so on
Windows the discovery table's native-join renamed launcher no longer
matched runtime's candidate. Model launchers with posix.join and keep
filesystem setup native, as MacHopperPaths.test.ts does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:41:54 +09:00

98 lines
3.2 KiB
TypeScript

import { readdirSync, type Dirent } from "node:fs";
import { readdir } from "node:fs/promises";
import { posix } from "node:path";
export const MAC_HOPPER_APPLICATION_DIRECTORY = "/Applications";
export const MAC_HOPPER_APPLICATION_NAME = "Hopper Disassembler.app";
export const MAC_HOPPER_USER_APPLICATIONS_DIRECTORY = "Applications";
export const MAC_HOPPER_SYSTEM_LAUNCHER = posix.join(
MAC_HOPPER_APPLICATION_DIRECTORY,
MAC_HOPPER_APPLICATION_NAME,
"Contents/MacOS/hopper",
);
export const macHopperUserApplicationPath = (homeDirectory: string): string =>
posix.join(
homeDirectory,
MAC_HOPPER_USER_APPLICATIONS_DIRECTORY,
MAC_HOPPER_APPLICATION_NAME,
);
export const macHopperApplicationRoots = (
homeDirectory: string,
): readonly string[] => [
MAC_HOPPER_APPLICATION_DIRECTORY,
posix.join(homeDirectory, MAC_HOPPER_USER_APPLICATIONS_DIRECTORY),
];
export const macHopperCanonicalLauncherCandidates = (
homeDirectory: string,
): readonly string[] => [
MAC_HOPPER_SYSTEM_LAUNCHER,
posix.join(
macHopperUserApplicationPath(homeDirectory),
"Contents/MacOS/hopper",
),
];
/** Build deterministic candidates from the discovered app bundle names. */
export const macHopperRenamedLauncherCandidates = (
homeDirectory: string,
bundleNamesByRoot: ReadonlyMap<string, readonly string[]>,
): readonly string[] =>
macHopperApplicationRoots(homeDirectory).flatMap((root) =>
[...new Set(bundleNamesByRoot.get(root) ?? [])]
.filter(
(name) =>
/^Hopper.*\.app$/iu.test(name) &&
name.toLocaleLowerCase("en-US") !==
MAC_HOPPER_APPLICATION_NAME.toLocaleLowerCase("en-US"),
)
.sort()
.map((name) => posix.join(root, name, "Contents/MacOS/hopper")),
);
const directoryNames = (entries: readonly Dirent[]): readonly string[] =>
entries.filter((entry) => entry.isDirectory()).map(({ name }) => name);
const localBundleNames = (root: string): readonly string[] => {
try {
return directoryNames(readdirSync(root, { withFileTypes: true }));
} catch (cause: unknown) {
// Missing or unreadable optional app directories contain no candidates.
void cause;
return [];
}
};
/** Yield renamed bundles from the local filesystem, reading each root only if needed. */
export function* localMacHopperRenamedLauncherCandidates(
homeDirectory: string,
): Generator<string> {
for (const root of macHopperApplicationRoots(homeDirectory))
yield* macHopperRenamedLauncherCandidates(
homeDirectory,
new Map([[root, localBundleNames(root)]]),
);
}
/** Read the same renamed bundles without blocking the event loop, for doctor. */
export const readMacHopperRenamedLauncherCandidates = async (
homeDirectory: string,
): Promise<readonly string[]> => {
const bundleNamesByRoot = new Map<string, readonly string[]>();
for (const root of macHopperApplicationRoots(homeDirectory)) {
try {
bundleNamesByRoot.set(
root,
directoryNames(await readdir(root, { withFileTypes: true })),
);
} catch (cause: unknown) {
// Missing or unreadable optional app directories contain no candidates.
void cause;
}
}
return macHopperRenamedLauncherCandidates(homeDirectory, bundleNamesByRoot);
};