mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
864314ac85
commit
7582f0dff2
11 files changed
+275
-25
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,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[] => [
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)]]),
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user