From ff321dc574cd510e036fe5910fba79058e5c015b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:19:29 +0800 Subject: [PATCH] fix(hopper): launch analysis without stealing focus --- scripts/verify-real-hopper.mjs | 32 ++++++++++++++++++++++++ src/application/BinarySession.ts | 17 ++++++++++++- src/hopper/BridgeLauncher.ts | 42 ++++++++++++++++++++++++++++++++ tests/binarySession.test.ts | 30 ++++++++++++++++++++--- 4 files changed, 116 insertions(+), 5 deletions(-) diff --git a/scripts/verify-real-hopper.mjs b/scripts/verify-real-hopper.mjs index f823a38b8..2f64e9d62 100644 --- a/scripts/verify-real-hopper.mjs +++ b/scripts/verify-real-hopper.mjs @@ -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; + } + } +} diff --git a/src/application/BinarySession.ts b/src/application/BinarySession.ts index 9a1785be1..61bbe1c29 100644 --- a/src/application/BinarySession.ts +++ b/src/application/BinarySession.ts @@ -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 { + 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> { diff --git a/src/hopper/BridgeLauncher.ts b/src/hopper/BridgeLauncher.ts index 74c6a4cf3..741c482bf 100644 --- a/src/hopper/BridgeLauncher.ts +++ b/src/hopper/BridgeLauncher.ts @@ -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 => { + 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 => { + try { + const processes = await execFileAsync("/bin/ps", ["-ax", "-o", "command="]); + return processes.stdout + .split("\n") + .some((command) => command.trim() === executablePath); + } catch { + return false; + } +}; diff --git a/tests/binarySession.test.ts b/tests/binarySession.test.ts index fb6381565..e96eb3cf1 100644 --- a/tests/binarySession.test.ts +++ b/tests/binarySession.test.ts @@ -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(); }); });