fix(hopper): launch analysis without stealing focus
This commit is contained in:
parent
3d70812c37
commit
ff321dc574
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue