import * as path from "node:path"; import { logger } from "@oh-my-pi/pi-utils"; import type { Subprocess } from "bun"; import type { Browser, CDPSession } from "puppeteer-core"; import { ToolAbortError, ToolError } from "../tool-errors"; import { findFreeCdpPort, findReusableCdp, gracefulKillTreeOnce, killExistingByPath, waitForCdp } from "./attach"; import { BROWSER_PROTOCOL_TIMEOUT_MS, launchHeadlessBrowser, loadPuppeteer, type UserAgentOverride } from "./launch"; export type BrowserKind = | { kind: "headless"; headless: boolean } | { kind: "spawned"; path: string } | { kind: "connected"; cdpUrl: string }; export type BrowserKindTag = BrowserKind["kind"]; export interface BrowserHandle { key: string; kind: BrowserKind; browser: Browser; cdpUrl?: string; pid?: number; subprocess?: Subprocess; refCount: number; stealth: { browserSession: CDPSession | null; override: UserAgentOverride | null }; } const browsers = new Map(); function browserKey(kind: BrowserKind): string { switch (kind.kind) { case "headless": return `headless:${kind.headless ? "1" : "0"}`; case "spawned": return `spawned:${kind.path}`; case "connected": return `connected:${kind.cdpUrl}`; } } export interface AcquireBrowserOptions { cwd: string; viewport?: { width: number; height: number; deviceScaleFactor?: number }; appArgs?: string[]; signal?: AbortSignal; } export async function acquireBrowser(kind: BrowserKind, opts: AcquireBrowserOptions): Promise { const key = browserKey(kind); const existing = browsers.get(key); if (existing) { if (existing.browser.connected) return existing; browsers.delete(key); await disposeBrowserHandle(existing, { kill: false }); } const handle = await openBrowserHandle(kind, opts); browsers.set(key, handle); return handle; } async function openBrowserHandle(kind: BrowserKind, opts: AcquireBrowserOptions): Promise { if (kind.kind === "headless") { const browser = await launchHeadlessBrowser({ headless: kind.headless, viewport: opts.viewport }); return { key: browserKey(kind), kind, browser, refCount: 0, stealth: { browserSession: null, override: null }, }; } if (kind.kind === "connected") { const cdpUrl = kind.cdpUrl.replace(/\/+$/, ""); await waitForCdp(cdpUrl, 5_000, opts.signal); const puppeteer = await loadPuppeteer(); const browser = await puppeteer.connect({ browserURL: cdpUrl, defaultViewport: null, protocolTimeout: BROWSER_PROTOCOL_TIMEOUT_MS, }); return { key: browserKey(kind), kind, browser, cdpUrl, refCount: 0, stealth: { browserSession: null, override: null }, }; } const exe = kind.path; if (!path.isAbsolute(exe)) { throw new ToolError( `app.path must be absolute (got ${JSON.stringify(exe)}). Pass the binary inside Foo.app/Contents/MacOS/, not the .app bundle.`, ); } const reused = await findReusableCdp(exe, opts.signal); let cdpUrl: string; let pid: number; let subprocess: Subprocess | undefined; if (reused) { logger.debug("Reusing existing CDP endpoint for attach", { exe, pid: reused.pid, cdpUrl: reused.cdpUrl }); cdpUrl = reused.cdpUrl; pid = reused.pid; } else { const killed = await killExistingByPath(exe, opts.signal); if (killed > 0) logger.debug("Killed existing instances before attach", { exe, killed }); const port = await findFreeCdpPort(); const launchArgs = [...(opts.appArgs ?? []), `--remote-debugging-port=${port}`]; const child = Bun.spawn([exe, ...launchArgs], { stdout: "ignore", stderr: "ignore", stdin: "ignore", }); child.unref(); subprocess = child; pid = child.pid; cdpUrl = `http://127.0.0.1:${port}`; try { await waitForCdp(cdpUrl, 30_000, opts.signal); } catch (err) { await gracefulKillTreeOnce(child.pid).catch(() => undefined); if (err instanceof ToolAbortError) throw err; if (err instanceof Error && err.name === "AbortError") throw err; throw new ToolError(`Failed to attach to ${path.basename(exe)} on ${cdpUrl}: ${(err as Error).message}`); } } const puppeteer = await loadPuppeteer(); let browser: Browser; try { browser = await puppeteer.connect({ browserURL: cdpUrl, defaultViewport: null, protocolTimeout: BROWSER_PROTOCOL_TIMEOUT_MS, }); } catch (err) { if (subprocess) await gracefulKillTreeOnce(subprocess.pid); throw new ToolError(`Connected to ${cdpUrl} but puppeteer.connect failed: ${(err as Error).message}`); } return { key: browserKey(kind), kind, browser, cdpUrl, pid, subprocess, refCount: 0, stealth: { browserSession: null, override: null }, }; } export function holdBrowser(handle: BrowserHandle): void { handle.refCount++; } export async function releaseBrowser(handle: BrowserHandle, opts: { kill: boolean }): Promise { handle.refCount = Math.max(0, handle.refCount - 1); if (handle.refCount === 0) { browsers.delete(handle.key); await disposeBrowserHandle(handle, opts); } } async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise { if (handle.kind.kind === "headless") { if (handle.browser.connected) { try { await handle.browser.close(); } catch (err) { logger.debug("Failed to close headless browser", { error: (err as Error).message }); } } return; } if (handle.kind.kind === "connected") { if (handle.browser.connected) { try { handle.browser.disconnect(); } catch (err) { logger.debug("Failed to disconnect from remote browser", { error: (err as Error).message }); } } return; } if (handle.browser.connected) { try { handle.browser.disconnect(); } catch (err) { logger.debug("Failed to disconnect from spawned browser", { error: (err as Error).message }); } } if (opts.kill && handle.pid !== undefined) await gracefulKillTreeOnce(handle.pid); }