diff options
| -rw-r--r-- | FORK.md | 18 | ||||
| -rw-r--r-- | src/cdp/discovery.ts | 13 | ||||
| -rw-r--r-- | src/cdp/local-launch.ts | 104 | ||||
| -rw-r--r-- | test/cdp/local-launch-test.ts | 53 |
4 files changed, 188 insertions, 0 deletions
@@ -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/); + }); +}); |
