fix(hopper): share launcher discovery across setup and runtime

Centralize canonical system/user and deterministic renamed-bundle path facts. Reuse the installer destination and honor the same local candidates in doctor and runtime; scan renamed bundles only after canonical candidates fail. Preserve explicit path spelling and exclusive selection. Homebrew command probing remains doctor-only with explicit runtime configuration guidance.

Validation: owner, config, setup and doctor tests cover canonical priority and renamed paths; public CLI verifies explicit path preservation without launching Hopper. No real Hopper analysis claimed.

Fixes #3054
Related: #3085

Co-authored-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
committed 2026-10-14 17:09:17 +08:00
1 parent 864314ac85
commit 7582f0dff2
11 files changed
+275 -25

No files matched your search

+7 -2
View File
@@ -577,8 +577,13 @@ sessions before removing that recovery directory and retrying.
### Launcher paths and troubleshooting
On macOS, REA uses
`/Applications/Hopper Disassembler.app/Contents/MacOS/hopper` by default.
On macOS, REA checks executable Hopper launchers in this order: the canonical
system application, the canonical application installed by `rea setup` under
`~/Applications`, then other `Hopper*.app` bundles in `/Applications` and
`~/Applications`. The Homebrew cask location is an optional `rea doctor`
diagnostic; to use a Homebrew-installed launcher for CLI/MCP startup, set
`HOPPER_LAUNCHER_PATH` to the path reported by doctor. REA does not run Homebrew
while loading runtime configuration.
On Linux, it prefers executable `/opt/hopper/bin/Hopper`, then executable
`~/.local/share/rea/hopper/bin/Hopper`, followed by `/opt/hopper-v4/bin/hopper`
and `/usr/bin/hopper`; if none exists, the former remains the diagnostic
+22
View File
@@ -595,6 +595,28 @@ describe("doctor Linux and Hopper discovery", () => {
});
describe("doctor installed Hopper discovery", () => {
it("checks the canonical user install after the system launcher", async () => {
const userPath =
"/fixture/home/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
const checked: string[] = [];
const result = await runDoctor(
undefined,
host({
homeDirectory: "/fixture/home",
executable: (candidate) => {
checked.push(candidate);
return Promise.resolve(candidate === userPath);
},
}),
);
expect(result.hopperPath).toBe(userPath);
expect(checked.slice(0, 2)).toEqual([
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
userPath,
]);
});
it("detects a Homebrew cask installed outside /Applications", async () => {
const path =
"/opt/homebrew/Caskroom/hopper/Hopper Disassembler.app/Contents/MacOS/hopper";
+14 -7
View File
@@ -14,6 +14,10 @@ import {
type JavaScriptRuntime,
} from "../domain/runtimeVersion.js";
import { installedCaskAppdir, probeHomebrew } from "./homebrew.js";
import {
macHopperApplicationRoots,
macHopperRenamedLauncherCandidates,
} from "./MacHopperPaths.js";
import {
linuxHopperBinarySupported,
linuxSharedLibrariesAvailable,
@@ -622,19 +626,22 @@ const uncomposedLinuxDemoRuntimeCheck = (): Promise<DoctorCheck> =>
});
const manualHopperPaths = async (home: string): Promise<readonly string[]> => {
const paths: string[] = [];
for (const root of ["/Applications", join(home, "Applications")]) {
const roots = macHopperApplicationRoots(home);
const bundleNamesByRoot = new Map<string, readonly string[]>();
for (const root of roots) {
try {
for (const entry of await readdir(root, { withFileTypes: true })) {
if (entry.isDirectory() && /^Hopper.*\.app$/i.test(entry.name))
paths.push(join(root, entry.name, "Contents/MacOS/hopper"));
}
bundleNamesByRoot.set(
root,
(await readdir(root, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map(({ name }) => name),
);
} catch (cause: unknown) {
// A missing or unreadable optional application directory is not fatal.
void cause;
}
}
return paths;
return macHopperRenamedLauncherCandidates(home, bundleNamesByRoot);
};
const uniqueLines = (value: string): string[] => [
+12 -4
View File
@@ -7,15 +7,16 @@ import {
systemLinuxHopperLauncherCandidates,
supportsReaHost,
} from "./LinuxHopper.js";
import {
MAC_HOPPER_SYSTEM_LAUNCHER,
macHopperCanonicalLauncherCandidates,
} from "./MacHopperPaths.js";
import type {
DoctorCheck,
DoctorHost,
DoctorProviderInspection,
} from "./Doctor.js";
const DEFAULT_HOPPER =
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
/** Ordered environment diagnostics consumed by the Doctor report assembler. */
export interface DoctorDiagnostics {
readonly checks: readonly DoctorCheck[];
@@ -150,10 +151,17 @@ const findHopper = async (host: DoctorHost): Promise<string | undefined> => {
const candidates =
host.configuredHopperPath === undefined
? [
DEFAULT_HOPPER,
...(host.platform === "darwin"
? macHopperCanonicalLauncherCandidates(host.homeDirectory)
: [MAC_HOPPER_SYSTEM_LAUNCHER]),
...(host.platform === "linux"
? linuxHopperLauncherCandidates(host.homeDirectory)
: [...systemLinuxHopperLauncherCandidates]),
...(host.platform === "darwin"
? []
: macHopperCanonicalLauncherCandidates(host.homeDirectory).slice(
1,
)),
...(await host.manualHopperPaths()),
]
: [host.configuredHopperPath];
+2 -2
View File
@@ -17,6 +17,7 @@ import { z } from "zod";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import { HOPPER_PUBLISHER } from "../domain/hopperPublisher.js";
import { macDeveloperIdRequirement } from "../process/MacCodeSignature.js";
import { macHopperUserApplicationPath } from "./MacHopperPaths.js";
import {
downloadHopperPackage,
HOPPER_DOWNLOAD_BYTES,
@@ -320,8 +321,7 @@ export const systemMacHopperInstallHost = (
}
return failure;
},
destination: () =>
join(homeDirectory, "Applications/Hopper Disassembler.app"),
destination: () => macHopperUserApplicationPath(homeDirectory),
};
};
+56
View File
@@ -0,0 +1,56 @@
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
macHopperCanonicalLauncherCandidates,
macHopperLauncherCandidates,
macHopperRenamedLauncherCandidates,
} from "./MacHopperPaths.js";
describe("macOS Hopper launcher paths", () => {
const home = "/fixture/home";
it("keeps canonical system and user candidates ahead of renamed bundles", () => {
expect(macHopperCanonicalLauncherCandidates(home)).toEqual([
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
"/fixture/home/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
]);
expect(
macHopperRenamedLauncherCandidates(
home,
new Map([
[
"/Applications",
["Hopper z.app", "Hopper a.app", "Hopper Disassembler.app"],
],
["/fixture/home/Applications", ["Hopper User.app"]],
]),
),
).toEqual([
"/Applications/Hopper a.app/Contents/MacOS/hopper",
"/Applications/Hopper z.app/Contents/MacOS/hopper",
"/fixture/home/Applications/Hopper User.app/Contents/MacOS/hopper",
]);
});
it("always includes the installer's canonical user destination", () => {
expect(macHopperLauncherCandidates(home)).toContain(
"/fixture/home/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
);
});
it("finds renamed user bundles through the runtime filesystem scan", async () => {
const temporary = await mkdtemp(join(tmpdir(), "rea-hopper-paths-"));
const appDirectory = join(temporary, "Applications", "Hopper Preview.app");
try {
await mkdir(appDirectory, { recursive: true });
expect([...macHopperLauncherCandidates(temporary)]).toContain(
join(appDirectory, "Contents/MacOS/hopper"),
);
} finally {
await rm(temporary, { recursive: true, force: true });
}
});
});
+76
View File
@@ -0,0 +1,76 @@
import { readdirSync } from "node:fs";
import { join } 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 = join(
MAC_HOPPER_APPLICATION_DIRECTORY,
MAC_HOPPER_APPLICATION_NAME,
"Contents/MacOS/hopper",
);
export const macHopperUserApplicationPath = (homeDirectory: string): string =>
join(
homeDirectory,
MAC_HOPPER_USER_APPLICATIONS_DIRECTORY,
MAC_HOPPER_APPLICATION_NAME,
);
export const macHopperApplicationRoots = (
homeDirectory: string,
): readonly string[] => [
MAC_HOPPER_APPLICATION_DIRECTORY,
join(homeDirectory, MAC_HOPPER_USER_APPLICATIONS_DIRECTORY),
];
export const macHopperCanonicalLauncherCandidates = (
homeDirectory: string,
): readonly string[] => [
MAC_HOPPER_SYSTEM_LAUNCHER,
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) => join(root, name, "Contents/MacOS/hopper")),
);
const localBundleNames = (root: string): readonly string[] => {
try {
return readdirSync(root, { withFileTypes: true })
.filter(
(entry) => entry.isDirectory() && /^Hopper.*\.app$/iu.test(entry.name),
)
.map(({ name }) => name);
} catch (cause: unknown) {
// Missing or unreadable optional app directories contain no candidates.
void cause;
return [];
}
};
/** Yield canonical paths first; enumerate renamed bundles only if needed. */
export function* macHopperLauncherCandidates(
homeDirectory: string,
): Generator<string> {
yield* macHopperCanonicalLauncherCandidates(homeDirectory);
for (const root of macHopperApplicationRoots(homeDirectory))
yield* macHopperRenamedLauncherCandidates(
homeDirectory,
new Map([[root, localBundleNames(root)]]),
);
}
+2 -6
View File
@@ -1,5 +1,3 @@
import { join } from "node:path";
import { classifyClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import {
linuxHopperInstallDisclosure,
@@ -7,6 +5,7 @@ import {
} from "./LinuxHopper.js";
import type { SkillDestination } from "./SetupSkill.js";
import { macHopperInstallDisclosure } from "./MacHopper.js";
import { macHopperUserApplicationPath } from "./MacHopperPaths.js";
import type {
ClientConfigurationInspection,
SetupAction,
@@ -287,10 +286,7 @@ const setupPlan = (input: {
label: "Hopper deep-analysis provider",
target:
input.platform === "darwin"
? join(
input.homeDirectory,
"Applications/Hopper Disassembler.app",
)
? macHopperUserApplicationPath(input.homeDirectory)
: "system package manager",
detail:
input.platform === "linux"
+26
View File
@@ -368,3 +368,29 @@ describe("default Linux Hopper launcher", () => {
).toBe(`${home}/.local/share/rea/hopper/bin/Hopper`);
});
});
describe("default macOS Hopper launcher", () => {
it("selects the system path before the installer user path", () => {
const home = "/fixture/home";
const system =
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
const user =
"/fixture/home/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
const executable = (path: string): boolean =>
path === system || path === user;
expect(defaultHopperLauncherPath("darwin", home, executable)).toBe(system);
expect(
defaultHopperLauncherPath("darwin", home, (path) => path === user),
).toBe(user);
});
it("keeps an explicit launcher path unchanged", () => {
const configured = "/custom/../Hopper launcher";
const parsed = parseConfig({ HOPPER_LAUNCHER_PATH: configured });
expect(parsed).toMatchObject({
ok: true,
value: { hopperLauncherPath: configured },
});
});
});
+10 -4
View File
@@ -9,9 +9,10 @@ import {
linuxHopperLauncherCandidates,
REA_MANAGED_LINUX_HOPPER_LAUNCHER,
} from "../application/LinuxHopper.js";
const DEFAULT_HOPPER_LAUNCHER_PATH =
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
import {
MAC_HOPPER_SYSTEM_LAUNCHER,
macHopperLauncherCandidates,
} from "../application/MacHopperPaths.js";
const executableAvailable = (path: string): boolean => {
try {
accessSync(path, constants.X_OK);
@@ -26,7 +27,12 @@ export const defaultHopperLauncherPath = (
homeDirectory: string,
executable: (path: string) => boolean = executableAvailable,
): string => {
if (platform !== "linux") return DEFAULT_HOPPER_LAUNCHER_PATH;
if (platform === "darwin") {
for (const candidate of macHopperLauncherCandidates(homeDirectory))
if (executable(candidate)) return candidate;
return MAC_HOPPER_SYSTEM_LAUNCHER;
}
if (platform !== "linux") return MAC_HOPPER_SYSTEM_LAUNCHER;
return (
linuxHopperLauncherCandidates(homeDirectory).find(executable) ??
REA_MANAGED_LINUX_HOPPER_LAUNCHER
@@ -0,0 +1,48 @@
import { access, chmod, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest.skipIf(process.platform === "win32")(
"preserves a configured Hopper launcher through public CLI doctor without launching it",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-doctor-hopper-home-");
const launcher = `${home}/bin/../bin/hopper`;
const launchMarker = join(home, "hopper-was-launched");
await mkdir(join(home, "bin"), { recursive: true });
await writeFile(
launcher,
'#!/bin/sh\nprintf launched > "$REA_HOPPER_TEST_MARKER"\nexit 99\n',
);
await chmod(launcher, 0o700);
const result = await cli.run({
arguments: ["doctor", "--provider", "hopper", "--json"],
cwd: home,
environment: {
HOME: home,
USERPROFILE: home,
HOPPER_LAUNCHER_PATH: launcher,
REA_HOPPER_TEST_MARKER: launchMarker,
},
timeoutMs: 20_000,
});
expect(result.json).toMatchObject({
hopperPath: launcher,
checks: expect.arrayContaining([
expect.objectContaining({
name: "hopper",
ok: true,
detail: launcher,
}),
]),
});
await expect(access(launchMarker)).rejects.toMatchObject({
code: "ENOENT",
});
},
);