import { BrowserWindow, nativeTheme } from "electron/main"; import type { BrowserWindow as ElectronBrowserWindow, Session } from "electron/main"; import type { Browser as PlaywrightBrowser, Page as PlaywrightPage } from "playwright-core"; import { isReclaimableHiddenPlaywrightTarget, type CDPTargetDescriptor, } from "./playwright-cdp-targets"; import { STANDARD_USER_AGENT } from "./user-agent"; const hiddenPlaywrightIdleTTLms = 5 * 60_000; const hiddenPlaywrightReaperIntervalMs = 60_000; const defaultConnectTimeoutMs = 10_000; const defaultPageTimeoutMs = 45_000; const hiddenBootstrapMetaName = "geo-hidden-playwright-token"; const cdpPort = Number.parseInt(process.env.GEO_ELECTRON_CDP_PORT ?? "9339", 10); const cdpEndpointURL = `http://127.0.0.1:${cdpPort}`; const cdpTargetsListURL = `${cdpEndpointURL}/json/list`; interface HiddenPlaywrightRecord { key: string; accountId: string; title: string; targetURL: string; window: ElectronBrowserWindow; browser: PlaywrightBrowser; page: PlaywrightPage; retainCount: number; activatedAt: number; lastReleasedAt: number | null; } export interface HiddenPlaywrightLease { accountId: string; browser: PlaywrightBrowser; page: PlaywrightPage; release(): Promise; } const records = new Map(); let reaperHandle: ReturnType | null = null; let connectPromise: Promise | null = null; let connectedBrowser: PlaywrightBrowser | null = null; export function getPlaywrightCDPPort(): number { return cdpPort; } export function startHiddenPlaywrightReaper(): void { if (reaperHandle) { return; } reaperHandle = setInterval(() => { reapIdleHiddenPlaywrightPages(); }, hiddenPlaywrightReaperIntervalMs); reaperHandle.unref?.(); } export async function retainHiddenPlaywrightPage(options: { accountId: string; session: Session; targetURL: string; title: string; }): Promise { startHiddenPlaywrightReaper(); const key = hiddenPlaywrightKey(options.accountId, options.targetURL); const existing = records.get(key); if (existing && !existing.window.isDestroyed() && !existing.page.isClosed()) { await ensureRecordTarget(existing, options.targetURL); existing.retainCount += 1; existing.activatedAt = Date.now(); existing.lastReleasedAt = null; return createLease(existing); } const browser = await connectBrowserOverCDP(); const knownPages = new Set(defaultContext(browser).pages()); const bootstrapToken = createHiddenBootstrapToken(); const bootstrapURL = buildHiddenWindowBootstrapURL(options.title, bootstrapToken); const window = createHiddenWindow(options.title, options.session, bootstrapURL); const page = await waitForNewPage(browser, knownPages, bootstrapToken); page.setDefaultTimeout(defaultPageTimeoutMs); page.setDefaultNavigationTimeout(defaultPageTimeoutMs); await page.goto(options.targetURL, { timeout: defaultPageTimeoutMs, waitUntil: "domcontentloaded", }); const record: HiddenPlaywrightRecord = { key, accountId: options.accountId, title: options.title, targetURL: options.targetURL, window, browser, page, retainCount: 1, activatedAt: Date.now(), lastReleasedAt: null, }; window.once("closed", () => { records.delete(key); }); page.once("close", () => { records.delete(key); }); records.set(key, record); return createLease(record); } export async function releaseHiddenPlaywrightPage(accountId: string, targetURL: string): Promise { const key = hiddenPlaywrightKey(accountId, targetURL); const record = records.get(key); if (!record) { return; } if (record.retainCount > 0) { record.retainCount -= 1; } if (record.retainCount === 0) { record.lastReleasedAt = Date.now(); } } export function reapIdleHiddenPlaywrightPages( now = Date.now(), idleTTLms = hiddenPlaywrightIdleTTLms, ): string[] { const reclaimed: string[] = []; for (const [key, record] of records.entries()) { if (record.retainCount > 0) { continue; } const idleSince = record.lastReleasedAt ?? record.activatedAt; if (now - idleSince < idleTTLms) { continue; } closeRecord(record); records.delete(key); reclaimed.push(key); } return reclaimed; } export function getHiddenPlaywrightSnapshot() { return { cdpPort, cdpEndpointURL, connected: Boolean(connectedBrowser?.isConnected()), pageCount: records.size, idleTTLms: hiddenPlaywrightIdleTTLms, records: [...records.values()].map((record) => ({ accountId: record.accountId, title: record.title, targetURL: record.targetURL, retainCount: record.retainCount, activatedAt: record.activatedAt, lastReleasedAt: record.lastReleasedAt, windowDestroyed: record.window.isDestroyed(), pageClosed: record.page.isClosed(), })), }; } function createLease(record: HiddenPlaywrightRecord): HiddenPlaywrightLease { return { accountId: record.accountId, browser: record.browser, page: record.page, release: () => releaseHiddenPlaywrightPage(record.accountId, record.targetURL), }; } function createHiddenWindow( title: string, session: Session, bootstrapURL: string, ): ElectronBrowserWindow { const window = new BrowserWindow({ show: false, width: 1320, height: 900, skipTaskbar: true, focusable: false, hiddenInMissionControl: true, autoHideMenuBar: true, backgroundColor: nativeTheme.shouldUseDarkColors ? "#15191a" : "#f4f1ea", title, webPreferences: { session, sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false, }, }); window.webContents.setUserAgent(STANDARD_USER_AGENT); void window.loadURL(bootstrapURL); return window; } function createHiddenBootstrapToken(): string { if (typeof globalThis.crypto?.randomUUID === "function") { return globalThis.crypto.randomUUID(); } return `${Date.now()}-${Math.random().toString(16).slice(2)}`; } function escapeHtml(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll("\"", """) .replaceAll("'", "'"); } function buildHiddenWindowBootstrapURL(title: string, token: string): string { const html = [ "", "", "", " ", ` `, ` ${escapeHtml(title)} ยท hidden-playwright`, "", ` `, "", ].join(""); return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; } async function ensureRecordTarget(record: HiddenPlaywrightRecord, targetURL: string): Promise { record.targetURL = targetURL; const currentURL = safePageURL(record.page); if (currentURL === targetURL) { return; } await record.page.goto(targetURL, { timeout: defaultPageTimeoutMs, waitUntil: "domcontentloaded", }); } async function connectBrowserOverCDP(): Promise { if (connectedBrowser?.isConnected()) { return connectedBrowser; } if (connectPromise) { return connectPromise; } connectPromise = (async () => { await waitForCDPEndpoint(); const { chromium } = await import("playwright-core"); await pruneStaleCDPTargets(); try { return await openBrowserOverCDP(chromium); } catch (error) { if (!isConnectOverCDPTimeout(error)) { throw error; } const prunedTargetCount = await pruneStaleCDPTargets(); if (prunedTargetCount <= 0) { throw error; } return openBrowserOverCDP(chromium); } })(); try { return await connectPromise; } finally { if (!connectedBrowser?.isConnected()) { connectPromise = null; } } } async function waitForCDPEndpoint(timeoutMs = defaultConnectTimeoutMs): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { try { const response = await fetch(`${cdpEndpointURL}/json/version`); if (response.ok) { return; } } catch { // Ignore and continue polling. } await wait(200); } throw new Error("playwright_cdp_endpoint_unavailable"); } async function openBrowserOverCDP( chromium: typeof import("playwright-core").chromium, ): Promise { const browser = await chromium.connectOverCDP(cdpEndpointURL, { timeout: defaultConnectTimeoutMs, }); browser.on("disconnected", () => { connectedBrowser = null; connectPromise = null; }); connectedBrowser = browser; return browser; } async function pruneStaleCDPTargets(): Promise { let targets: CDPTargetDescriptor[] = []; try { const response = await fetch(cdpTargetsListURL); if (!response.ok) { return 0; } const payload = await response.json(); if (Array.isArray(payload)) { targets = payload as CDPTargetDescriptor[]; } } catch { return 0; } let closedCount = 0; for (const target of targets) { if (!isReclaimableHiddenPlaywrightTarget(target)) { continue; } try { const closeURL = `${cdpEndpointURL}/json/close/${encodeURIComponent(target.id ?? "")}`; const response = await fetch(closeURL); if (response.ok) { closedCount += 1; } } catch { // Ignore best-effort stale target cleanup failures. } } if (closedCount > 0) { await wait(150); } return closedCount; } function isConnectOverCDPTimeout(error: unknown): boolean { return error instanceof Error && error.name === "TimeoutError" && error.message.includes("connectOverCDP"); } async function waitForNewPage( browser: PlaywrightBrowser, knownPages: Set, bootstrapToken: string, ): Promise { const context = defaultContext(browser); const startedAt = Date.now(); while (Date.now() - startedAt < defaultPageTimeoutMs) { const nextPages = context.pages().filter((page) => !knownPages.has(page)); for (const nextPage of nextPages) { if (isDevtoolsPage(nextPage)) { knownPages.add(nextPage); continue; } if (await matchesHiddenBootstrapPage(nextPage, bootstrapToken)) { return nextPage; } } await wait(100); } throw new Error("playwright_hidden_page_unavailable"); } async function matchesHiddenBootstrapPage( page: PlaywrightPage, bootstrapToken: string, ): Promise { const url = safePageURL(page); if (url.includes(bootstrapToken) || url.includes(encodeURIComponent(bootstrapToken))) { return true; } try { return await page.evaluate( ({ token, metaName }) => { const metaToken = document.querySelector(`meta[name="${metaName}"]`)?.getAttribute("content") ?? document.body?.getAttribute(`data-${metaName}`) ?? null; return metaToken === token; }, { token: bootstrapToken, metaName: hiddenBootstrapMetaName, }, ); } catch { return false; } } function defaultContext(browser: PlaywrightBrowser) { const context = browser.contexts()[0]; if (!context) { throw new Error("playwright_default_context_unavailable"); } return context; } function hiddenPlaywrightKey(accountId: string, targetURL: string): string { return `${accountId}::${targetURL}`; } function safePageURL(page: PlaywrightPage): string { try { return page.url(); } catch { return ""; } } function isDevtoolsPage(page: PlaywrightPage): boolean { const url = safePageURL(page); return url.startsWith("devtools://"); } function closeRecord(record: HiddenPlaywrightRecord): void { if (!record.window.isDestroyed()) { record.window.close(); } } function wait(timeMs: number): Promise { return new Promise((resolve) => { setTimeout(resolve, timeMs); }); }