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