P0 会话可靠性收口(根治"模型思考着会话就停止"): - P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值 - P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷 - P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示 - P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层 - P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径 FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效 P1 修复面收口: - 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫 - 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码 - 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并 - 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口 P2 能力演进: - 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板 - 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口 - electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置 - @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入 - MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图 - 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表 P3 测试基建: - 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2) - 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3 - 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强 全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
648 lines
23 KiB
TypeScript
648 lines
23 KiB
TypeScript
/**
|
||
* 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';
|
||
// v0.8.0 P1-4: 浏览器通道 SSRF 根治 —— 分区流量走本地 Pinned CONNECT 代理
|
||
import { ensurePinnedProxy } from '../../../utils/pinned-proxy';
|
||
import { isProxyActive } from '../../../utils/network-proxy';
|
||
|
||
/** 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<unknown> = 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<BrowserOpenResult> {
|
||
// v0.3.0 修复: 并发互斥锁 — 串行化所有 open 调用,避免竞态导致窗口状态混乱
|
||
const run = async (): Promise<BrowserOpenResult> => this.openInternal(options);
|
||
// 串行化:等待前一个操作完成(open 与 fetchPageText 共用一条链)
|
||
this.openChain = this.openChain.then(run, run);
|
||
return this.openChain as Promise<BrowserOpenResult>;
|
||
}
|
||
|
||
/**
|
||
* 内部实际执行 open(不排队 — 供已在链上的调用方直接使用)
|
||
*
|
||
* 崩溃修复: destroy() 仅在链上(openInternal / close)发生 —— 消除
|
||
* "后到 open 销毁前一个正在加载/执行 JS 的窗口" 的跨链竞态。
|
||
*/
|
||
private async openInternal(options: BrowserOpenOptions): Promise<BrowserOpenResult> {
|
||
// 若已有窗口加载了不同 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.7.4 P2-2 修正: 页面内部整页导航(点击链接/服务端重定向)后同步 currentUrl,
|
||
// 否则 CORS allowedOrigin 停留在旧 origin,新页面跨域请求被误拦(fail-closed 方向,
|
||
// 属功能过度收紧而非安全漏洞)。destroy 时置 null 覆盖清理。
|
||
this.win.webContents.on('did-navigate', (_event, url) => {
|
||
this.currentUrl = url;
|
||
});
|
||
|
||
// v0.3.0 修复: 使用 CORS 放行替代 webSecurity: false
|
||
// 仅对 agent session 放行 CORS,不影响主应用
|
||
// v0.7.3 P2-2 收紧: ACAO 从通配 '*' 改为回显请求 Origin —— 通配值让任意
|
||
// 第三方页面都能借该分区跨域读取;回显等价保留截图/页面自身跨域能力,
|
||
// 并附加 Vary: Origin 防止共享缓存把定向值串到其他 Origin。
|
||
// v0.7.4 P2-2 根治: 回显任意 Origin 仍等价"允许凭据的全开放 CORS"(回显值
|
||
// 非 '*' 时浏览器会携带 cookies 跨域读取)。现仅当请求 Origin 与当前浏览
|
||
// 页面 Origin 完全一致时回显(同源请求本就不需要 CORS,放行无副作用);
|
||
// 跨域 Origin / 无 Origin 一律不加 ACAO 头 —— 保持默认同源策略阻止读取,
|
||
// 彻底堵死"第三方页面借该分区凭据读取跨域资源"的通道。
|
||
const agentSession = session.fromPartition(AGENT_PARTITION);
|
||
|
||
// v0.8.0 P1-4 根治: 浏览器通道 SSRF —— 分区全部流量经本地 Pinned CONNECT
|
||
// 代理(入口同源校验 + 校验期 IP pinning,Chromium 端 TLS/SNI 端到端不变),
|
||
// 关闭"入口校验通过 → Chromium 独立 DNS 解析"之间的 rebinding 残余窗口。
|
||
// 用户已显式配置 network.proxyUrl 时不启用(代理模式下 DNS 在代理端解析、
|
||
// pinning 不可实现,保持 M7 已知限制口径);代理启动失败降级可用性优先。
|
||
if (!isProxyActive()) {
|
||
const proxyPort = await ensurePinnedProxy();
|
||
if (proxyPort) {
|
||
try {
|
||
await agentSession.setProxy({
|
||
proxyRules: `http=127.0.0.1:${proxyPort};https=127.0.0.1:${proxyPort}`,
|
||
});
|
||
log.info(
|
||
`[BrowserWindowManager] agent partition routed via pinned proxy :${proxyPort}`,
|
||
);
|
||
} catch (err) {
|
||
log.warn('[BrowserWindowManager] pinned proxy setProxy failed:', err);
|
||
}
|
||
}
|
||
}
|
||
|
||
agentSession.webRequest.onHeadersReceived((details, callback) => {
|
||
// Electron 类型在此版本的 OnHeadersReceivedListenerDetails 上不暴露
|
||
// requestHeaders —— 显式声明读取面(Origin 大小写不敏感提取)
|
||
const requestHeaders = (
|
||
details as unknown as { requestHeaders?: Record<string, string | string[] | undefined> }
|
||
).requestHeaders;
|
||
const originHeader = extractOriginHeader(requestHeaders);
|
||
// 允许的 Origin = 当前浏览页面的 origin(URL 解析失败视为未知 → 不放行)
|
||
let allowedOrigin: string | null = null;
|
||
if (this.currentUrl) {
|
||
try {
|
||
allowedOrigin = new URL(this.currentUrl).origin;
|
||
} catch {
|
||
allowedOrigin = null;
|
||
}
|
||
}
|
||
const acao = corsAllowOrigin(originHeader, allowedOrigin);
|
||
if (!acao) {
|
||
// 跨域/无 Origin 请求 —— 不加 ACAO,保持浏览器默认同源策略
|
||
callback({ responseHeaders: details.responseHeaders });
|
||
return;
|
||
}
|
||
callback({
|
||
responseHeaders: {
|
||
...details.responseHeaders,
|
||
'Access-Control-Allow-Origin': acao,
|
||
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<ScreenshotResult> {
|
||
// 崩溃修复: 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<unknown> {
|
||
// 崩溃修复: 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<typeof setTimeout> | undefined;
|
||
try {
|
||
const result = await Promise.race([
|
||
wc.executeJavaScript(js, true),
|
||
new Promise<never>((_, 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 注释)。排队后同一时刻只有一个抓取在使用窗口。
|
||
*
|
||
* v0.8.0 P1-2: 新增可选 abort signal —— 中断时停止页面加载(webContents.stop)
|
||
* 并在序列各步骤间检查,终止整个 open→等待→evaluate 链。
|
||
*
|
||
* @returns 提取的页面正文;失败/内容过短返回 null(调用方走失败路径)
|
||
*/
|
||
async fetchPageText(url: string, signal?: AbortSignal): Promise<string | null> {
|
||
const run = async (): Promise<string | null> => {
|
||
const throwIfAborted = (): void => {
|
||
if (signal?.aborted) throw new Error('Browser fetch aborted');
|
||
};
|
||
// abort 时停止页面加载 —— loadURL/evaluate 会以 ERR_ABORTED 拒绝,
|
||
// 序列随之终止(窗口由单例管理,不在此销毁)
|
||
let onAbort: (() => void) | null = null;
|
||
if (signal && !signal.aborted) {
|
||
onAbort = () => {
|
||
try {
|
||
this.win?.webContents.stop();
|
||
} catch {
|
||
/* 窗口可能已销毁 */
|
||
}
|
||
};
|
||
signal.addEventListener('abort', onAbort, { once: true });
|
||
}
|
||
try {
|
||
throwIfAborted();
|
||
await this.openInternal({ url });
|
||
throwIfAborted();
|
||
await this.sleep(2_500);
|
||
throwIfAborted();
|
||
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;
|
||
throwIfAborted();
|
||
return text && text.trim() ? text : null;
|
||
} finally {
|
||
if (signal && onAbort) signal.removeEventListener('abort', onAbort);
|
||
}
|
||
};
|
||
this.openChain = this.openChain.then(run, run);
|
||
return this.openChain as Promise<string | null>;
|
||
}
|
||
|
||
async extract(selector?: string): Promise<ExtractResult> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
let timer: NodeJS.Timeout | null = null;
|
||
const timeoutPromise = new Promise<never>((_, 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<void> {
|
||
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<void> {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
/** 应用退出时清理(close 现为异步 — 含 session 存储终态清理) */
|
||
static async cleanup(manager: BrowserWindowManager | null): Promise<void> {
|
||
if (manager) await manager.close();
|
||
log.info('[BrowserWindowManager] Cleaned up');
|
||
}
|
||
}
|