// 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; 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) => Promise; readonly probe: (port: number) => Promise; readonly sleep: (ms: number) => Promise; readonly now: () => number; }; const startDetached = (exe: string, args: ReadonlyArray): Promise => 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> => { 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.`, ); };