/** * BrowserWindowManager — 单例浏览器窗口管理器 * * 提供网页加载、截图、JS 执行、内容提取、交互操作能力。 * 单例懒加载,首次调用时创建,browserClose() 显式销毁。 * * @see docs/Agent网络工具通用设计-v2.md — 第 4 章 browser 浏览器设计 */ import { BrowserWindow, session } from 'electron'; import log from 'electron-log'; // v0.7.3 P2-2: CORS Origin 回显(纯函数在 network-utils,可表测) import { corsAllowOrigin, extractOriginHeader } from './network-utils'; /** Agent 浏览器专用 session partition — 与主应用 default session 完全隔离 */ const AGENT_PARTITION = 'persist:metona-agent-browser'; export interface BrowserOpenOptions { url: string; waitSelector?: string; } export interface BrowserOpenResult { title: string; url: string; } export interface ScreenshotOptions { fullPage?: boolean; selector?: string; } export interface ScreenshotResult { data: string; // Base64 PNG width: number; height: number; } export interface ExtractResult { text: string; links: Array<{ text: string; url: string }>; } export interface ScrollOptions { direction?: 'down' | 'up' | 'top' | 'bottom'; selector?: string; } export interface WaitOptions { selector?: string; timeMs?: number; } export class BrowserWindowManager { private win: BrowserWindow | null = null; private currentUrl: string | null = null; /** * v0.3.0 修复 + 崩溃修复: 单一操作串行链 — open 与 fetchPageText 共用。 * * 崩溃实证(main.log 三处异常终止点 100% 相关):web_search 并行抓取触发多个 * web_fetch 同时进入浏览器回退时,后到的 open() 因 URL 不同销毁前一个正在 * 加载/执行 JS 的窗口(ERR_ABORTED ×3 = 崩溃 ×3;无并发销毁的 53 次回退从未崩溃)。 * 串行化完整抓取序列(open→等待→evaluate)后,destroy 只会在链上发生。 */ private openChain: Promise = Promise.resolve(); /** 窗口是否就绪 */ get ready(): boolean { return this.win !== null && !this.win.isDestroyed(); } /** * 崩溃修复: 安全获取当前 webContents — 窗口已销毁时抛出可捕获错误。 * * 原实现的 ensureReady 检查与实际 executeJavaScript/loadURL 调用之间存在 * 竞态窗口(检查通过后窗口被并发 open() 的 destroy() 销毁),对已销毁 * webContents 调用 API 会同步抛 "Object has been destroyed" 或触发原生层 * use-after-free。所有窗口方法改为此守卫 + 即时校验。 */ private safeWebContents(): Electron.WebContents { if (!this.win || this.win.isDestroyed()) { throw new Error('Browser window not ready or destroyed. Call open first.'); } return this.win.webContents; } // ===== browserOpen ===== async open(options: BrowserOpenOptions): Promise { // v0.3.0 修复: 并发互斥锁 — 串行化所有 open 调用,避免竞态导致窗口状态混乱 const run = async (): Promise => this.openInternal(options); // 串行化:等待前一个操作完成(open 与 fetchPageText 共用一条链) this.openChain = this.openChain.then(run, run); return this.openChain as Promise; } /** * 内部实际执行 open(不排队 — 供已在链上的调用方直接使用) * * 崩溃修复: destroy() 仅在链上(openInternal / close)发生 —— 消除 * "后到 open 销毁前一个正在加载/执行 JS 的窗口" 的跨链竞态。 */ private async openInternal(options: BrowserOpenOptions): Promise { // 若已有窗口加载了不同 URL → 先关闭重建 if (this.win && this.currentUrl !== options.url) { this.destroy(); } if (!this.win) { this.win = new BrowserWindow({ width: 1280, height: 800, show: false, webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true, // v0.3.0 修复: 独立 partition,与主应用 default session 完全隔离 // 防止 Agent 浏览产生的 Cookie/缓存/存储污染主应用 partition: AGENT_PARTITION, // v0.3.0 修复: 恢复 webSecurity,CORS 需求通过 session.webRequest 处理 webSecurity: true, plugins: false, webviewTag: false, }, }); // v0.3.0 修复: 拦截 window.open,Agent 浏览的页面不允许再开新窗口 this.win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); // v0.3.0 修复: 使用 CORS 放行替代 webSecurity: false // 仅对 agent session 放行 CORS,不影响主应用 // v0.7.3 P2-2 收紧: ACAO 从通配 '*' 改为回显请求 Origin —— 通配值让任意 // 第三方页面都能借该分区跨域读取;回显等价保留截图/页面自身跨域能力, // 并附加 Vary: Origin 防止共享缓存把定向值串到其他 Origin。 const agentSession = session.fromPartition(AGENT_PARTITION); agentSession.webRequest.onHeadersReceived((details, callback) => { // Electron 类型在此版本的 OnHeadersReceivedListenerDetails 上不暴露 // requestHeaders —— 显式声明读取面(Origin 大小写不敏感提取) const requestHeaders = ( details as unknown as { requestHeaders?: Record } ).requestHeaders; const originHeader = extractOriginHeader(requestHeaders); callback({ responseHeaders: { ...details.responseHeaders, 'Access-Control-Allow-Origin': corsAllowOrigin(originHeader), Vary: [...(details.responseHeaders?.Vary ?? []), 'Origin'], }, }); }); } if (this.win.isDestroyed()) { throw new Error('Browser window destroyed before navigation'); } await this.loadURLWithTimeout(this.win, options.url, 30_000); this.currentUrl = options.url; // 可选等待选择器 if (options.waitSelector) { await this.waitForSelector(options.waitSelector, 10_000); } const title = await this.evaluate('document.title'); return { title: String(title ?? ''), url: options.url }; } // ===== browserScreenshot ===== async screenshot(options: ScreenshotOptions = {}): Promise { // 崩溃修复: safeWebContents 即时校验(消除检查-使用间竞态) const wc = this.safeWebContents(); const win = this.win!; // 元素截图 if (options.selector) { const rect = (await wc.executeJavaScript( `(() => { const el = document.querySelector(${JSON.stringify(options.selector)}); if (!el) return null; const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; })()`, true, )) as { x: number; y: number; width: number; height: number } | null; if (!rect) throw new Error(`Element not found: ${options.selector}`); const image = await wc.capturePage({ x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height), }); return { data: image.toPNG().toString('base64'), width: rect.width, height: rect.height }; } // 全页截图 if (options.fullPage) { const dims = (await wc.executeJavaScript( `({ width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight })`, true, )) as { width: number; height: number }; // 先滚动到底部触发懒加载 await wc.executeJavaScript(`window.scrollTo(0, document.body.scrollHeight)`, true); await this.sleep(500); await wc.executeJavaScript(`window.scrollTo(0, 0)`, true); await this.sleep(300); const image = await wc.capturePage({ x: 0, y: 0, width: dims.width, height: dims.height, }); return { data: image.toPNG().toString('base64'), width: dims.width, height: dims.height }; } // 视口截图 const image = await wc.capturePage(); const size = win.isDestroyed() ? [0, 0] : win.getContentSize(); return { data: image.toPNG().toString('base64'), width: size[0], height: size[1] }; } // ===== browserEvaluate ===== /** * 在浏览器页面上下文中执行 JS * * #46 修复: 增强安全防护 * - 审计日志:记录所有 executeJavaScript 调用,便于事后追溯恶意操作 * - 超时控制:防止恶意 JS 无限阻塞主进程(Electron 原生不支持超时,用 Promise.race 模拟) * * 注意:完全沙箱隔离较难实现(需要 iframe/Web Worker + API 白名单), * 当前先实现审计 + 超时作为缓解措施。webPreferences 已配置 contextIsolation: true * 和 sandbox: true,Electron API 不会被暴露给页面。 */ async evaluate(js: string): Promise { // 崩溃修复: safeWebContents 即时校验(ensureReady 与实际调用间的竞态会引发 // "Object has been destroyed" 同步抛出 / 原生层 use-after-free) const wc = this.safeWebContents(); // #46 修复: 审计日志 — 记录所有 executeJavaScript 调用(截断前 500 字符),便于追溯 log.info('[BrowserWindowManager] evaluate JS (first 500 chars):', js.substring(0, 500)); // #46 修复: 超时控制 — 防止恶意 JS 无限阻塞主进程 const EVAL_TIMEOUT_MS = 10_000; let timer: ReturnType | undefined; try { const result = await Promise.race([ wc.executeJavaScript(js, true), new Promise((_, reject) => { timer = setTimeout(() => { // 审查修复 M17: 超时后中止页面 JS 执行。 // executeJavaScript 返回的 Promise 无法取消,页面脚本仍会继续运行, // 调用 webContents.stop() 中止页面正在执行的脚本(win 可能已销毁,try/catch 兜底)。 try { this.win?.webContents.stop(); } catch { /* 窗口可能已销毁,忽略 */ } reject(new Error(`evaluate timed out after ${EVAL_TIMEOUT_MS}ms`)); }, EVAL_TIMEOUT_MS); }), ]); if (typeof result === 'string') return result; return result; } finally { if (timer) clearTimeout(timer); } } // ===== browserExtract ===== /** * 崩溃修复: 排队版页面抓取 — 串行化完整 open→等待→evaluate 序列。 * * web_fetch 浏览器回退的入口。并行回退(web_search 自动抓取并发 3)此前 * 各自直接 open/evaluate 共享单例,后到者销毁前者的窗口(崩溃根因, * 见 openChain 注释)。排队后同一时刻只有一个抓取在使用窗口。 * * @returns 提取的页面正文;失败/内容过短返回 null(调用方走失败路径) */ async fetchPageText(url: string): Promise { const run = async (): Promise => { await this.openInternal({ url }); await this.sleep(2_500); const text = (await this.evaluate(` (function() { var clone = document.body.cloneNode(true); var noise = clone.querySelectorAll('script, style, noscript, nav, header, footer, aside, iframe, svg'); noise.forEach(function(el) { el.remove(); }); return clone.innerText || ''; })(); `)) as string; return text && text.trim() ? text : null; }; this.openChain = this.openChain.then(run, run); return this.openChain as Promise; } async extract(selector?: string): Promise { const wc = this.safeWebContents(); const result = (await wc.executeJavaScript( `(() => { const root = ${selector ? `document.querySelector(${JSON.stringify(selector)})` : 'document.body'}; if (!root) return null; const clone = root.cloneNode(true); clone.querySelectorAll('script, style, noscript, iframe, svg, nav, header, footer, aside').forEach(el => el.remove()); const text = (clone.innerText || '').trim(); const links = []; const anchors = (selector ? root : document).querySelectorAll('a[href^="http"]'); for (const a of anchors) { if (links.length >= 50) break; const text = (a.textContent || '').trim(); if (text && a.href) links.push({ text, url: a.href }); } return { text, links }; })()`, true, )) as { text: string; links: Array<{ text: string; url: string }> } | null; if (!result) throw new Error(selector ? `Element not found: ${selector}` : 'No body content'); return { text: result.text, links: result.links }; } // ===== browserClick ===== async click(selector: string, wait = false): Promise { if (wait) { await this.waitForSelector(selector, 10_000); } const wc = this.safeWebContents(); const found = await wc.executeJavaScript( `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.scrollIntoView({ behavior: 'smooth', block: 'center' }); return true; })()`, true, ); if (!found) throw new Error(`Element not found: ${selector}`); await this.sleep(300); await this.safeWebContents().executeJavaScript( `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return; el.click(); })()`, true, ); await this.sleep(500); } // ===== browserType ===== async type( selector: string, text: string, options: { clear?: boolean; submit?: boolean } = {}, ): Promise { const wc = this.safeWebContents(); const found = await wc.executeJavaScript( `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.focus(); return true; })()`, true, ); if (!found) throw new Error(`Element not found: ${selector}`); await this.sleep(300); // 清空 + 赋值 + 触发事件(兼容 React/Vue) await this.safeWebContents().executeJavaScript( `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return; ${options.clear ? 'el.value = "";' : ''} el.value += ${JSON.stringify(text)}; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); })()`, true, ); if (options.submit) { await this.safeWebContents().executeJavaScript( `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return; if (el.form) el.form.submit(); else el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); })()`, true, ); await this.sleep(1_000); } else { await this.sleep(300); } } // ===== browserScroll ===== async scroll(options: ScrollOptions = {}): Promise { const wc = this.safeWebContents(); if (options.selector) { await wc.executeJavaScript( `(() => { const el = document.querySelector(${JSON.stringify(options.selector)}); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); })()`, true, ); return; } const direction = options.direction ?? 'down'; const js = { down: 'window.scrollBy(0, 500)', up: 'window.scrollBy(0, -500)', top: 'window.scrollTo(0, 0)', bottom: 'window.scrollTo(0, document.body.scrollHeight)', }[direction] ?? 'window.scrollBy(0, 500)'; await wc.executeJavaScript(js, true); await this.sleep(300); } // ===== browserWait ===== async wait(options: WaitOptions = {}): Promise { if (options.selector) { await this.waitForSelector(options.selector, options.timeMs ?? 10_000); } else { await this.sleep(options.timeMs ?? 1_000); } } // ===== browserClose ===== /** * 关闭并清理(应用退出 / browser close 动作时调用) * * 崩溃修复: session 存储清理从 destroy() 迁移至此 —— destroy() 此前对 * 仍在使用中的 partition(紧随其后就会新建窗口)fire-and-forget 调用 * clearStorageData/clearCache,与新窗口初始化并发执行,构成原生存储层 * 竞态(崩溃引爆点)。close() 是终态路径,await 清理与窗口销毁不再交叠。 */ async close(): Promise { this.destroy(); try { const ses = session.fromPartition(AGENT_PARTITION); await ses.clearStorageData({ storages: [ 'cookies', 'localstorage', 'indexdb', 'shadercache', 'serviceworkers', 'cachestorage', ], }); await ses.clearCache(); } catch { /* ignore */ } } // ===== 内部辅助 ===== /** 带超时的 loadURL(Electron 原生不支持 timeout 选项) */ private async loadURLWithTimeout( win: BrowserWindow, url: string, timeoutMs: number, ): Promise { let timer: NodeJS.Timeout | null = null; const timeoutPromise = new Promise((_, reject) => { timer = setTimeout( () => reject(new Error(`Page load timeout after ${timeoutMs}ms: ${url}`)), timeoutMs, ); }); // 崩溃修复: Race 落败方的 rejection 必须兜底 —— 超时/窗口销毁先触发时, // loadURL 随后以 ERR_ABORTED 拒绝,无人处理会成为 unhandledRejection const loadPromise = win.loadURL(url); loadPromise.catch(() => { /* 落败方 rejection 已由 race 胜者处理,此处仅防漏 */ }); try { await Promise.race([loadPromise, timeoutPromise]); } catch (e) { // v0.3.0 修复: 超时后停止页面加载,避免后台继续消耗网络和 CPU 资源 if (!win.isDestroyed()) { try { win.webContents.stop(); } catch { /* ignore */ } } throw e; } finally { if (timer) clearTimeout(timer); } } private async waitForSelector(selector: string, timeoutMs: number): Promise { const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { // 崩溃修复: 循环内即时校验(窗口可能在等待期间被链上操作销毁) const found = await this.safeWebContents().executeJavaScript( `!!document.querySelector(${JSON.stringify(selector)})`, true, ); if (found) return; await this.sleep(300); } throw new Error(`Timeout waiting for selector: ${selector}`); } private destroy(): void { if (this.win && !this.win.isDestroyed()) { try { this.win.webContents.stop(); } catch { /* ignore */ } try { this.win.destroy(); } catch { /* ignore */ } } this.win = null; this.currentUrl = null; // 崩溃修复: 移除 session 清理 —— 原实现 fire-and-forget 清理仍在使用的 // partition,与紧随其后的新窗口创建并发,构成原生存储层竞态。 // 存储清理迁移至 close()(终态路径,见方法注释)。 } private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** 应用退出时清理(close 现为异步 — 含 session 存储终态清理) */ static async cleanup(manager: BrowserWindowManager | null): Promise { if (manager) await manager.close(); log.info('[BrowserWindowManager] Cleaned up'); } }