diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-17 13:20:04 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-17 13:20:04 +0300 |
| commit | f61c43418d704cccb67132540ed7feceacef1d2a (patch) | |
| tree | 8741f1683b2bffc9e2437beede9cc1fbbf4a3452 /src/cdp | |
| parent | 99d5702cb2ff0ead8e3a2b7a95fd057eefa89862 (diff) | |
Start a local Chrome when the target is an explicit local and none answers
Dedicated profile (~/.chrome-cdp-profile), macOS only, never for the
castle default. BU_LOCAL_CHROME_LAUNCH=off disables it.
Diffstat (limited to 'src/cdp')
| -rw-r--r-- | src/cdp/discovery.ts | 13 | ||||
| -rw-r--r-- | src/cdp/local-launch.ts | 104 |
2 files changed, 117 insertions, 0 deletions
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.`, + ); +}; |
