import * as net from "node:net"; import { Process, ProcessStatus } from "@oh-my-pi/pi-natives"; import type { Browser, Page } from "puppeteer-core"; import { ToolError, throwIfAborted } from "../tool-errors"; const ATTACH_TARGET_SKIP_PATTERN = /request[\s_-]?handler|devtools|background[\s_-]?(?:page|host)|service[\s_-]?worker/i; /** * Allocate an unused TCP port on 127.0.0.1 by binding to port 0 and reading * back the kernel-assigned port. There's a small race between close and the * subsequent bind in the launched app, but Chromium's listener will retry. */ export async function findFreeCdpPort(): Promise { const { promise, resolve, reject } = Promise.withResolvers(); const server = net.createServer(); server.unref(); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const addr = server.address(); if (addr && typeof addr === "object" && typeof addr.port === "number") { const port = addr.port; server.close(closeErr => (closeErr ? reject(closeErr) : resolve(port))); } else { server.close(); reject(new Error("Failed to allocate ephemeral CDP port")); } }); return promise; } /** Poll `${cdpUrl}/json/version` until it responds with 200, with abort + timeout support. */ export async function waitForCdp(cdpUrl: string, timeoutMs: number, signal?: AbortSignal): Promise { const deadline = Date.now() + timeoutMs; let lastErr: unknown; const probeUrl = `${cdpUrl.replace(/\/+$/, "")}/json/version`; while (Date.now() < deadline) { throwIfAborted(signal); const probeTimeout = AbortSignal.timeout(2000); const probeSignal = signal ? AbortSignal.any([signal, probeTimeout]) : probeTimeout; try { const res = await fetch(probeUrl, { signal: probeSignal }); if (res.ok) { await res.body?.cancel(); return; } lastErr = new Error(`HTTP ${res.status}`); await res.body?.cancel(); } catch (err) { if (signal?.aborted) throwIfAborted(signal); lastErr = err; } await Bun.sleep(150); } throw new ToolError( `Timed out waiting for CDP endpoint ${cdpUrl}${lastErr instanceof Error ? `: ${lastErr.message}` : ""}`, ); } /** * Pull a `--remote-debugging-port=` value out of an argv array (Chromium * accepts both `--flag=value` and `--flag value`). Returns null if absent or * malformed. */ function findCdpPortInArgs(args: string[]): number | null { for (const arg of args) { const m = /^--remote-debugging-port=(\d+)$/.exec(arg); if (m) { const port = Number.parseInt(m[1]!, 10); if (Number.isFinite(port) && port > 0) return port; } } for (let i = 0; i < args.length - 1; i++) { if (args[i] === "--remote-debugging-port") { const port = Number.parseInt(args[i + 1]!, 10); if (Number.isFinite(port) && port > 0) return port; } } return null; } /** One-shot probe: returns true when `/json/version` answers 200 within the timeout. */ async function probeCdpAt(port: number, signal?: AbortSignal): Promise { const probeTimeout = AbortSignal.timeout(1500); const probeSignal = signal ? AbortSignal.any([signal, probeTimeout]) : probeTimeout; try { const res = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: probeSignal }); await res.body?.cancel(); return res.ok; } catch { return false; } } /** * If any running instance of `exe` was launched with `--remote-debugging-port` * and that endpoint actually answers, return it so attach can reuse it instead * of killing and respawning. Idempotent re-attaches are the common case. */ export async function findReusableCdp( exe: string, signal?: AbortSignal, ): Promise<{ cdpUrl: string; pid: number } | null> { const candidates = Process.fromPath(exe).filter(p => p.status() === ProcessStatus.Running); for (const proc of candidates) { let args: string[]; try { args = proc.args(); } catch { continue; } const port = findCdpPortInArgs(args); if (port === null) continue; if (await probeCdpAt(port, signal)) { return { cdpUrl: `http://127.0.0.1:${port}`, pid: proc.pid }; } } return null; } /** * Pick the best page target on an attached browser. Without a matcher, prefer * a page that doesn't look like a helper window (devtools, request handler, * background pages); with a matcher, return the first url+title substring hit. */ export async function pickElectronTarget(browser: Browser, matcher?: string): Promise { const pages = await browser.pages(); if (!pages.length) { throw new ToolError("No page targets available on the attached browser"); } const enriched = await Promise.all( pages.map(async page => ({ page, url: page.url(), title: ((await page.title().catch(() => "")) ?? "").trim(), })), ); if (matcher) { const needle = matcher.toLowerCase(); const hit = enriched.find(p => p.url.toLowerCase().includes(needle) || p.title.toLowerCase().includes(needle)); if (hit) return hit.page; const summary = enriched.map(p => `- ${p.title || "(untitled)"} ${p.url}`).join("\n"); throw new ToolError(`No page target matched ${JSON.stringify(matcher)}. Available pages:\n${summary}`); } return ( enriched.find(p => !ATTACH_TARGET_SKIP_PATTERN.test(p.url) && !ATTACH_TARGET_SKIP_PATTERN.test(p.title))?.page ?? enriched[0]!.page ); } /** * SIGTERM the process tree, wait briefly, then SIGKILL anything still alive. * Single-process variant for our own spawned children. */ export async function gracefulKillTreeOnce(pid: number, gracePeriodMs = 2000): Promise { const process = Process.fromPid(pid); if (!process) return; await process.terminate({ gracefulMs: gracePeriodMs, timeoutMs: 500 }); } /** * Multi-process variant for attach: find every PID running `executablePath` * (single-instance apps may keep an orphan around) and tear them all down. */ export async function killExistingByPath(executablePath: string, signal?: AbortSignal): Promise { const processes = Process.fromPath(executablePath); if (!processes.length) return 0; const results = await Promise.all( processes.map(async process => { throwIfAborted(signal); return await process.terminate({ gracefulMs: 3000, timeoutMs: 1000 }); }), ); return results.length; }