diff --git a/docs/installation.md b/docs/installation.md index b84d6033a7..72bf11d3ef 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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 diff --git a/src/application/Doctor.test.ts b/src/application/Doctor.test.ts index 0fd444f34b..093063aaee 100644 --- a/src/application/Doctor.test.ts +++ b/src/application/Doctor.test.ts @@ -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"; diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index f5a6cd4bdb..c1e465a5e3 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -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 => }); const manualHopperPaths = async (home: string): Promise => { - const paths: string[] = []; - for (const root of ["/Applications", join(home, "Applications")]) { + const roots = macHopperApplicationRoots(home); + const bundleNamesByRoot = new Map(); + 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[] => [ diff --git a/src/application/DoctorDiagnostics.ts b/src/application/DoctorDiagnostics.ts index 069525a330..070ce4809e 100644 --- a/src/application/DoctorDiagnostics.ts +++ b/src/application/DoctorDiagnostics.ts @@ -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 => { 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]; diff --git a/src/application/MacHopper.ts b/src/application/MacHopper.ts index 858e7fe489..f6b60c71be 100644 --- a/src/application/MacHopper.ts +++ b/src/application/MacHopper.ts @@ -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), }; }; diff --git a/src/application/MacHopperPaths.test.ts b/src/application/MacHopperPaths.test.ts new file mode 100644 index 0000000000..f650971db5 --- /dev/null +++ b/src/application/MacHopperPaths.test.ts @@ -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 }); + } + }); +}); diff --git a/src/application/MacHopperPaths.ts b/src/application/MacHopperPaths.ts new file mode 100644 index 0000000000..5ffa6ffd8d --- /dev/null +++ b/src/application/MacHopperPaths.ts @@ -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, +): 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 { + yield* macHopperCanonicalLauncherCandidates(homeDirectory); + for (const root of macHopperApplicationRoots(homeDirectory)) + yield* macHopperRenamedLauncherCandidates( + homeDirectory, + new Map([[root, localBundleNames(root)]]), + ); +} diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts index 1eb6be56ba..d849e43983 100644 --- a/src/application/SetupPlan.ts +++ b/src/application/SetupPlan.ts @@ -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" diff --git a/src/config.test.ts b/src/config.test.ts index 0230d552b7..5fc1712560 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -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 }, + }); + }); +}); diff --git a/src/config/parseConfig.ts b/src/config/parseConfig.ts index 415af6b3df..1dbeda7578 100644 --- a/src/config/parseConfig.ts +++ b/src/config/parseConfig.ts @@ -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 diff --git a/tests/boundary/cli/doctorHopperDiscovery.test.ts b/tests/boundary/cli/doctorHopperDiscovery.test.ts new file mode 100644 index 0000000000..d14715a958 --- /dev/null +++ b/tests/boundary/cli/doctorHopperDiscovery.test.ts @@ -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", + }); + }, +);