/** * Opening a browser window for a chosen profile. * * CDP cannot do this: `Target.createTarget` rejects another profile's * browserContextId ("Failed to find browser context with id"), and a bare * createTarget lands in `defaultBrowserContextId`, which follows window focus. * The only way in is the browser's own command line — Chromium's * ProcessSingleton hands a second launch's argv to the running browser, which * "does what it would have done", i.e. opens a window in `--profile-directory`. * * The window is then identified by a unique `file://` sentinel page. That * matters: a bare `about:blank#token` argument is discarded by Chrome (it opens * chrome://newtab instead), so the token has to ride on a real URL. Polling for * the sentinel URL — rather than diffing the target list — also keeps * identification correct when the user opens tabs at the same moment. */ import { spawn, type ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import { rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { type Result, err, ok } from "../util/result"; export type ProfileWindowRequest = { /** Browser binary to invoke. */ readonly exePath: string; /** Profile subdirectory, e.g. "Profile 1". */ readonly profileDir: string; /** * Only set when the RUNNING browser was launched with an explicit * --user-data-dir. ProcessSingleton keys on this path, so passing one that * differs by case or normalisation would start a second browser instead of * delegating to the running one. */ readonly explicitUserDataDir?: string; }; export type ProfileWindowHandle = { /** file:// URL that will appear as the new window's page target. */ readonly sentinelUrl: string; /** Remove the sentinel file. Safe to call more than once. */ readonly cleanup: () => Promise; readonly kill: () => void; }; const SENTINEL_HTML = (token: string): string => `${token}` + `pi browser harness — opening this profile…`; /** * Ask the running browser to open a window in `profileDir`, marked with a * unique sentinel page the caller can then find over CDP. */ export const openProfileWindow = async ( req: ProfileWindowRequest, ): Promise> => { const token = `pi-harness-${randomUUID()}`; const file = join(tmpdir(), `${token}.html`); try { await writeFile(file, SENTINEL_HTML(token), "utf8"); } catch (e) { return err(`could not write the profile handshake page: ${e instanceof Error ? e.message : String(e)}`); } const sentinelUrl = pathToFileURL(file).href; const args: string[] = []; if (req.explicitUserDataDir) args.push(`--user-data-dir=${req.explicitUserDataDir}`); args.push(`--profile-directory=${req.profileDir}`, "--new-window", sentinelUrl); let child: ChildProcess; try { // No shell: arguments are passed as an array, so profile names and paths // containing spaces survive intact on every platform. child = spawn(req.exePath, args, { detached: true, stdio: "ignore" }); } catch (e) { await rm(file, { force: true }).catch(() => {}); return err(`could not launch ${req.exePath}: ${e instanceof Error ? e.message : String(e)}`); } // spawn() reports a missing or non-executable binary ASYNCHRONOUSLY, as an // 'error' event — it does not throw. Ignoring that event made an ENOENT // indistinguishable from a browser that simply never opened the window, so // the caller's 15s sentinel poll reported a timeout for what was really a bad // executable path. Node guarantees exactly one of 'spawn' or 'error', so // waiting for it costs nothing and turns the failure into a named cause. const spawnFailure = await new Promise((resolve) => { child.once("spawn", () => resolve(undefined)); child.once("error", (e: Error) => resolve(e.message)); }); // A later 'error' event with no listener would throw; keep one attached for // the lifetime of the detached launcher. child.on("error", () => {}); child.unref(); if (spawnFailure) { await rm(file, { force: true }).catch(() => {}); return err(`could not launch ${req.exePath}: ${spawnFailure}`); } let live: ChildProcess | null = child; return ok({ sentinelUrl, cleanup: async () => { await rm(file, { force: true }).catch(() => {}); }, kill: () => { if (live) { try { live.kill("SIGTERM"); } catch {} live = null; } }, }); };