diff options
Diffstat (limited to 'src/cdp/local-launch.ts')
| -rw-r--r-- | src/cdp/local-launch.ts | 104 |
1 files changed, 104 insertions, 0 deletions
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.`, + ); +}; |
