fix(hopper): launch analysis without stealing focus

This commit is contained in:
morluto 2026-07-12 08:19:29 +08:00
parent 3d70812c37
commit ff321dc574
4 changed files with 116 additions and 5 deletions

View File

@ -9,6 +9,8 @@ import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
const execFileAsync = promisify(execFile);
const timeout = 180_000;
const hopperProcessPrefix = "/Applications/Hopper Disassembler.app/Contents/";
const hopperProcessesBefore = await hopperProcessIds();
const sessionsBefore = new Set(
(await readdir("/tmp")).filter((name) => name.startsWith("bbm-")),
);
@ -155,6 +157,7 @@ try {
await transport.close();
} finally {
clearInterval(keepAlive);
await terminateNewHopperProcesses(hopperProcessesBefore);
}
}
@ -190,3 +193,32 @@ await new Promise((resolve, reject) => {
},
);
});
async function hopperProcessIds() {
const result = await execFileAsync("ps", ["-ax", "-o", "pid=,command="]);
return new Set(
result.stdout
.split("\n")
.map((line) => line.trim().match(/^(\d+)\s+(.+)$/))
.filter((match) => match?.[2]?.startsWith(hopperProcessPrefix) === true)
.map((match) => Number(match[1])),
);
}
async function terminateNewHopperProcesses(previous) {
const current = await hopperProcessIds();
const owned = [...current].filter((pid) => !previous.has(pid));
for (const pid of owned) process.kill(pid, "SIGTERM");
await new Promise((resolve) => setTimeout(resolve, 1_000));
for (const pid of owned) {
try {
process.kill(pid, 0);
process.kill(pid, "SIGKILL");
} catch (cause) {
if (
!(cause instanceof Error && "code" in cause && cause.code === "ESRCH")
)
throw cause;
}
}
}

View File

@ -39,18 +39,21 @@ export class BinarySession implements HopperToolPort {
return ok(parsed.value);
await this.#drainCalls();
const previous = this.#active;
this.#active = undefined;
await previous?.client.close();
const client = this.createClient(parsed.value);
const started = await client.callTool("health", {}, options);
if (!started.ok) {
await client.close();
await this.#restore(previous);
return started;
}
if (isAborted(options.signal)) {
await client.close();
await this.#restore(previous);
return err(new HopperCancelledError());
}
this.#active = { target: parsed.value, client };
await previous?.client.close();
return ok(parsed.value);
});
}
@ -111,6 +114,18 @@ export class BinarySession implements HopperToolPort {
await Promise.allSettled([...this.#calls]);
}
async #restore(
previous:
| { readonly target: BinaryTarget; readonly client: BinaryClient }
| undefined,
): Promise<void> {
if (previous === undefined) return;
const client = this.createClient(previous.target);
const started = await client.callTool("health", {});
if (started.ok) this.#active = { target: previous.target, client };
else await client.close();
}
async #waitForTransition(
signal?: AbortSignal,
): Promise<Result<undefined, HopperCancelledError>> {

View File

@ -1,9 +1,15 @@
import { spawn, type ChildProcess } from "node:child_process";
import { execFile } from "node:child_process";
import { chmod, writeFile } from "node:fs/promises";
import { basename, dirname, extname, join } from "node:path";
import { promisify } from "node:util";
import { HopperStartError } from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
const execFileAsync = promisify(execFile);
const HOPPER_BACKGROUND_STARTUP_MS = 5_000;
/** Coordinates for one private bridge session. */
export interface BridgeSession {
readonly directory: string;
@ -64,6 +70,7 @@ export class HopperApplicationLauncher implements BridgeLauncher {
this.options.targetPath,
];
try {
await prepareHopperApplication(this.options.launcherPath);
const child = spawn(this.options.launcherPath, args, {
stdio: ["ignore", "ignore", "pipe"],
});
@ -88,3 +95,38 @@ export class HopperApplicationLauncher implements BridgeLauncher {
}
}
}
const prepareHopperApplication = async (
launcherPath: string,
): Promise<void> => {
const appBundle = hopperApplicationBundle(launcherPath);
if (appBundle === undefined) return;
const executablePath = join(appBundle, "Contents/MacOS/Hopper Disassembler");
if (await processIsRunning(executablePath)) return;
await execFileAsync("/usr/bin/open", [
"--hide",
"--background",
"-a",
appBundle,
]);
await new Promise((resolve) =>
setTimeout(resolve, HOPPER_BACKGROUND_STARTUP_MS),
);
};
const hopperApplicationBundle = (launcherPath: string): string | undefined => {
if (basename(launcherPath) !== "hopper") return undefined;
const candidate = dirname(dirname(dirname(launcherPath)));
return extname(candidate) === ".app" ? candidate : undefined;
};
const processIsRunning = async (executablePath: string): Promise<boolean> => {
try {
const processes = await execFileAsync("/bin/ps", ["-ax", "-o", "command="]);
return processes.stdout
.split("\n")
.some((command) => command.trim() === executablePath);
} catch {
return false;
}
};

View File

@ -36,7 +36,7 @@ describe("binary session", () => {
expect((await session.open(second)).ok).toBe(false);
expect(session.status()).toMatchObject({ open: true });
expect(JSON.stringify(session.status())).toContain("first.hop");
expect(created).toBe(2);
expect(created).toBe(3);
});
it("serializes concurrent opens and leaves the last target active", async () => {
@ -119,7 +119,7 @@ describe("binary session", () => {
await opening;
});
it("closes a failed candidate and preserves the previous target", async () => {
it("closes a failed candidate and reopens the previous target", async () => {
const [first, second] = await targets();
const clients: TestClient[] = [];
const session = new BinarySession(() => {
@ -130,11 +130,33 @@ describe("binary session", () => {
await session.open(first);
await session.open(second);
expect(clients[1]?.closed).toBe(1);
expect(clients[0]?.closed).toBe(0);
expect(clients[0]?.closed).toBe(1);
expect(clients[2]?.closed).toBe(0);
expect(JSON.stringify(session.status())).toContain("first.hop");
await session.close();
await session.close();
expect(clients[0]?.closed).toBe(1);
expect(clients[2]?.closed).toBe(1);
});
it("closes the active bridge before starting a replacement", async () => {
const [first, second] = await targets();
let liveClients = 0;
let overlapped = false;
const session = new BinarySession(() => ({
callTool: () => {
if (liveClients > 0) overlapped = true;
liveClients += 1;
return Promise.resolve(ok(null));
},
close: () => {
liveClients -= 1;
return Promise.resolve();
},
}));
await session.open(first);
await session.open(second);
expect(overlapped).toBe(false);
await session.close();
});
});