aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--FORK.md18
-rw-r--r--src/cdp/discovery.ts13
-rw-r--r--src/cdp/local-launch.ts104
-rw-r--r--test/cdp/local-launch-test.ts53
4 files changed, 188 insertions, 0 deletions
diff --git a/FORK.md b/FORK.md
index ee041c9..c3a1815 100644
--- a/FORK.md
+++ b/FORK.md
@@ -135,6 +135,24 @@ tool runs, so a fresh session reports "not connected yet" rather than an error.
from npm, so the patch cannot be silently reverted — which is exactly what the
previous approach (patching `node_modules` in place) suffered from.
+## Launching a local Chrome
+
+With an explicit `local` target (`/browser-target local` or `BU_CDP_HTTP=local`) and no browser to
+attach to, discovery starts one (`src/cdp/local-launch.ts`) and waits for `127.0.0.1:9222` to answer.
+It never starts a browser for the castle default, and never when something already answers.
+
+It always uses a dedicated profile, `~/.chrome-cdp-profile`, never the user's own: Chrome ignores
+`--remote-debugging-port` on its default user-data-dir, and opening the signed-in profile unasked
+would be a surprise. macOS only; elsewhere discovery fails as before.
+
+| Variable | Effect |
+|---|---|
+| `BU_LOCAL_CHROME_LAUNCH=off` (also `0`, `no`, `false`, `none`) | never launch |
+| `BU_LOCAL_CHROME_PROFILE` | profile directory, default `~/.chrome-cdp-profile` |
+| `BU_LOCAL_CHROME_EXE` | Chrome binary, default the one in `/Applications` |
+
+There is no watchdog: if the launched Chrome dies, the next discovery starts another.
+
## Taking upstream releases
The patch is a short series of fork commits on top of an upstream commit, so
diff --git a/src/cdp/discovery.ts b/src/cdp/discovery.ts
index 7aa35f6..ea75c5c 100644
--- a/src/cdp/discovery.ts
+++ b/src/cdp/discovery.ts
@@ -8,6 +8,7 @@ import { type CdpError, cdpError } from "./errors";
import { asString, errnoCode, isRecord } from "../util/guards";
import { describeCdpTarget, resolveCdpTarget } from "./endpoint";
import { readTarget } from "./target-store";
+import { defaultLaunchDeps, launchLocalChrome, planLocalLaunch } from "./local-launch";
const PORT_PROBE_DEADLINE_MS = 30_000;
const PORT_PROBE_INTERVAL_MS = 1_000;
@@ -153,6 +154,18 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
// through to the local browser the extension was originally written for.
}
+ const found = await discoverLocalEndpoint();
+ if (found.success || target.kind !== "local") return found;
+
+ // FORK: an explicit local target with no browser to attach to starts one (src/cdp/local-launch.ts).
+ const plan = planLocalLaunch();
+ if (plan.kind === "skip") return found;
+ const launched = await launchLocalChrome(plan.plan, defaultLaunchDeps((port) => queryLiveWsUrl(port)));
+ if (launched.success) return ok({ wsUrl: launched.data });
+ return err(cdpError("discovery_failed", `${found.error.message}\n\nAuto-launch of local Chrome failed: ${launched.error}`));
+};
+
+const discoverLocalEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>> => {
const dirs = userDataDirCandidates();
const candidates: Candidate[] = [];
diff --git a/src/cdp/local-launch.ts b/src/cdp/local-launch.ts
new file mode 100644
index 0000000..6e74afd
--- /dev/null
+++ b/src/cdp/local-launch.ts
@@ -0,0 +1,104 @@
+// FORK: start a Chrome of our own when the target is an explicit `local` and none is reachable.
+//
+// Only for an explicit local target (BU_CDP_HTTP=local or /browser-target local), never for the
+// castle default: an explicit target is an instruction, and a default must not start browsers.
+// Always the dedicated profile, never the user's own: Chrome ignores --remote-debugging-port on its
+// default user-data-dir, and launching the signed-in profile unasked would be a surprise.
+//
+// Runs inside the daemon, so builtins only (see test/daemon/entry-imports-test.ts).
+
+import { spawn } from "node:child_process";
+import { existsSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { type Result, err, ok } from "../util/result";
+
+export const LOCAL_LAUNCH_PORT = 9222;
+const MAC_CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
+const OFF = new Set(["0", "off", "no", "false", "none"]);
+const READY_DEADLINE_MS = 15_000;
+const READY_INTERVAL_MS = 250;
+
+export type LaunchPlan = {
+ readonly exe: string;
+ readonly args: ReadonlyArray<string>;
+ readonly profileDir: string;
+ readonly port: number;
+};
+
+// "skip" means launching does not apply here (disabled, wrong platform, no Chrome); the caller keeps its own error.
+export type LaunchPlanResult = { readonly kind: "plan"; readonly plan: LaunchPlan } | { readonly kind: "skip"; readonly reason: string };
+
+export const planLocalLaunch = (
+ env: NodeJS.ProcessEnv = process.env,
+ platform: string = process.platform,
+ exists: (path: string) => boolean = existsSync,
+): LaunchPlanResult => {
+ const switchValue = env["BU_LOCAL_CHROME_LAUNCH"]?.trim().toLowerCase();
+ if (switchValue !== undefined && OFF.has(switchValue)) {
+ return { kind: "skip", reason: "disabled by BU_LOCAL_CHROME_LAUNCH" };
+ }
+ if (platform !== "darwin") return { kind: "skip", reason: `auto-launch is macOS only (this is ${platform})` };
+
+ const exe = env["BU_LOCAL_CHROME_EXE"]?.trim() || MAC_CHROME;
+ if (!exists(exe)) return { kind: "skip", reason: `no Chrome at ${exe}` };
+
+ const profileDir = env["BU_LOCAL_CHROME_PROFILE"]?.trim() || join(homedir(), ".chrome-cdp-profile");
+ const port = LOCAL_LAUNCH_PORT;
+ return {
+ kind: "plan",
+ plan: {
+ exe,
+ profileDir,
+ port,
+ args: [
+ `--remote-debugging-port=${port}`,
+ `--user-data-dir=${profileDir}`,
+ "--no-first-run",
+ "--no-default-browser-check",
+ "--hide-crash-restore-bubble",
+ ],
+ },
+ };
+};
+
+export type LaunchDeps = {
+ readonly start: (exe: string, args: ReadonlyArray<string>) => Promise<string | undefined>;
+ readonly probe: (port: number) => Promise<string | null>;
+ readonly sleep: (ms: number) => Promise<void>;
+ readonly now: () => number;
+};
+
+const startDetached = (exe: string, args: ReadonlyArray<string>): Promise<string | undefined> =>
+ new Promise((resolve) => {
+ const child = spawn(exe, [...args], { detached: true, stdio: "ignore" });
+ child.once("spawn", () => {
+ child.unref();
+ resolve(undefined);
+ });
+ child.once("error", (e: Error) => resolve(e.message));
+ });
+
+export const defaultLaunchDeps = (probe: LaunchDeps["probe"]): LaunchDeps => ({
+ start: startDetached,
+ probe,
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
+ now: () => Date.now(),
+});
+
+// Resolves to the new browser's webSocketDebuggerUrl, or says why it never answered.
+export const launchLocalChrome = async (plan: LaunchPlan, deps: LaunchDeps): Promise<Result<string, string>> => {
+ const failure = await deps.start(plan.exe, plan.args);
+ if (failure !== undefined) return err(`could not start ${plan.exe}: ${failure}`);
+
+ const end = deps.now() + READY_DEADLINE_MS;
+ while (deps.now() < end) {
+ const ws = await deps.probe(plan.port);
+ if (ws) return ok(ws);
+ await deps.sleep(READY_INTERVAL_MS);
+ }
+ return err(
+ `started Chrome with profile ${plan.profileDir}, but nothing answered on 127.0.0.1:${plan.port} within ${READY_DEADLINE_MS / 1000}s. ` +
+ `If that profile is already open in a Chrome started without --remote-debugging-port, quit it and retry.`,
+ );
+};
diff --git a/test/cdp/local-launch-test.ts b/test/cdp/local-launch-test.ts
new file mode 100644
index 0000000..27915d9
--- /dev/null
+++ b/test/cdp/local-launch-test.ts
@@ -0,0 +1,53 @@
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import { launchLocalChrome, planLocalLaunch, type LaunchDeps, type LaunchPlan } from "../../src/cdp/local-launch";
+
+const plan: LaunchPlan = { exe: "/x/chrome", args: ["--a"], profileDir: "/p", port: 9222 };
+
+const deps = (over: Partial<LaunchDeps>): LaunchDeps => {
+ let t = 0;
+ return { start: async () => undefined, probe: async () => null, sleep: async (ms) => { t += ms; }, now: () => t, ...over };
+};
+
+describe("planLocalLaunch", () => {
+ test("plans a launch on macOS with the dedicated profile and the debugging port", () => {
+ const r = planLocalLaunch({ HOME: "/h" }, "darwin", () => true);
+ assert.equal(r.kind, "plan");
+ if (r.kind !== "plan") return;
+ assert.ok(r.plan.args.includes("--remote-debugging-port=9222"));
+ assert.ok(r.plan.args.some((a) => a.startsWith("--user-data-dir=") && a.endsWith(".chrome-cdp-profile")));
+ });
+
+ test("BU_LOCAL_CHROME_PROFILE and BU_LOCAL_CHROME_EXE override the defaults", () => {
+ const r = planLocalLaunch({ BU_LOCAL_CHROME_PROFILE: "/tmp/p", BU_LOCAL_CHROME_EXE: "/tmp/c" }, "darwin", () => true);
+ assert.equal(r.kind === "plan" && r.plan.exe, "/tmp/c");
+ assert.ok(r.kind === "plan" && r.plan.args.includes("--user-data-dir=/tmp/p"));
+ });
+
+ test("is skipped when switched off, off macOS, or without a Chrome", () => {
+ assert.equal(planLocalLaunch({ BU_LOCAL_CHROME_LAUNCH: "off" }, "darwin", () => true).kind, "skip");
+ assert.equal(planLocalLaunch({}, "linux", () => true).kind, "skip");
+ assert.equal(planLocalLaunch({}, "darwin", () => false).kind, "skip");
+ });
+});
+
+describe("launchLocalChrome", () => {
+ test("resolves with the websocket url once the port answers", async () => {
+ let calls = 0;
+ const r = await launchLocalChrome(plan, deps({ probe: async () => (++calls >= 3 ? "ws://127.0.0.1:9222/x" : null) }));
+ assert.deepEqual(r.success && r.data, "ws://127.0.0.1:9222/x");
+ });
+
+ test("reports a spawn failure without probing", async () => {
+ let probed = false;
+ const r = await launchLocalChrome(plan, deps({ start: async () => "ENOENT", probe: async () => { probed = true; return null; } }));
+ assert.equal(r.success, false);
+ assert.equal(probed, false);
+ });
+
+ test("gives up after the deadline and says what to check", async () => {
+ const r = await launchLocalChrome(plan, deps({}));
+ assert.equal(r.success, false);
+ if (!r.success) assert.match(r.error, /nothing answered on 127\.0\.0\.1:9222/);
+ });
+});