Files
geo/apps/desktop-client/src/main/playwright-cdp.ts
T
root 55e1c2e74b feat(desktop): add monitor scheduler, Playwright CDP layer, and account health
Move monitor-task scheduling authority onto the client with a durable
file-backed queue that survives restart, drops stale cross-day tasks,
enforces per-platform serialism, and adapts global concurrency from
Electron process metrics. Publish tasks keep their existing FIFO.

Add a hidden Playwright CDP manager that attaches to Electron Chromium
on account session partitions, lets adapters opt into `executionMode:
"playwright"`, and leaves the existing hidden WebContentsView path in
place for current adapters.

Introduce an account-health subsystem with silent probes, projected
health/auth states, and IPC invalidation events so the renderer can
show accurate auth/probe status and verification timestamps.

Server-side, derive and forward title/business_date/scheduler_group_key/
question_text metadata on desktop task events so the local scheduler
can defer same-question fan-out before leasing.
2026-04-20 17:16:15 +08:00

318 lines
8.0 KiB
TypeScript

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 { STANDARD_USER_AGENT } from "./user-agent";
const hiddenPlaywrightIdleTTLms = 5 * 60_000;
const hiddenPlaywrightReaperIntervalMs = 60_000;
const defaultConnectTimeoutMs = 10_000;
const defaultPageTimeoutMs = 45_000;
const cdpPort = Number.parseInt(process.env.GEO_ELECTRON_CDP_PORT ?? "9339", 10);
const cdpEndpointURL = `http://127.0.0.1:${cdpPort}`;
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<void>;
}
const records = new Map<string, HiddenPlaywrightRecord>();
let reaperHandle: ReturnType<typeof setInterval> | null = null;
let connectPromise: Promise<PlaywrightBrowser> | 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<HiddenPlaywrightLease> {
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 window = createHiddenWindow(options.title, options.session);
const page = await waitForNewPage(browser, knownPages);
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<void> {
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): ElectronBrowserWindow {
const window = new BrowserWindow({
show: false,
width: 1320,
height: 900,
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("about:blank");
return window;
}
async function ensureRecordTarget(record: HiddenPlaywrightRecord, targetURL: string): Promise<void> {
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<PlaywrightBrowser> {
if (connectedBrowser?.isConnected()) {
return connectedBrowser;
}
if (connectPromise) {
return connectPromise;
}
connectPromise = (async () => {
await waitForCDPEndpoint();
const { chromium } = await import("playwright-core");
const browser = await chromium.connectOverCDP(cdpEndpointURL, {
timeout: defaultConnectTimeoutMs,
});
browser.on("disconnected", () => {
connectedBrowser = null;
connectPromise = null;
});
connectedBrowser = browser;
return browser;
})();
try {
return await connectPromise;
} finally {
if (!connectedBrowser?.isConnected()) {
connectPromise = null;
}
}
}
async function waitForCDPEndpoint(timeoutMs = defaultConnectTimeoutMs): Promise<void> {
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 waitForNewPage(
browser: PlaywrightBrowser,
knownPages: Set<PlaywrightPage>,
): Promise<PlaywrightPage> {
const context = defaultContext(browser);
const startedAt = Date.now();
while (Date.now() - startedAt < defaultPageTimeoutMs) {
const nextPage = context.pages().find((page) => !knownPages.has(page));
if (nextPage) {
return nextPage;
}
await wait(100);
}
throw new Error("playwright_hidden_page_unavailable");
}
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 closeRecord(record: HiddenPlaywrightRecord): void {
try {
record.page.close().catch(() => undefined);
} catch {
// Ignore and fall through to window close.
}
if (!record.window.isDestroyed()) {
record.window.close();
}
}
function wait(timeMs: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, timeMs);
});
}