import type { BrowserWindow as ElectronBrowserWindow, Session } from 'electron/main' import { BrowserWindow, nativeTheme } 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) }) }