【根因(实证归因,非猜测)】 分析 userData/logs/main.log 全部 33 次启动会话,定位 3 处异常终止点 (07-25 ×2 / 08-22 ×1,启动标记前无 Database closed)。三处 100% 共享 同一模式:web_search 并行抓取 → 多个 web_fetch 同时进入浏览器回退 → 共享单例 BrowserWindowManager 中后到 open() 销毁前一个正在加载/执行 JS 的窗口。关键统计:56 次浏览器回退中 ERR_ABORTED(并发互毁的直接 证据)仅 3 次,而这 3 次恰好全部对应 3 个崩溃点;无并发销毁的 53 次 回退从未崩溃 —— 触发条件完全收敛。 缺陷链(三层叠加): 1. browserFetch 直接 open/evaluate 共享单例,无跨调用序列化 — 并发 回退互相销毁窗口(ERR_ABORTED / "Object has been destroyed") 2. destroy() 对仍在使用中的 partition fire-and-forget clearStorageData/clearCache,与紧随其后的新窗口创建并发 — 原生存储层竞态(崩溃引爆点) 3. ensureReady 检查与实际 executeJavaScript/loadURL 之间存在竞态窗口; loadURLWithTimeout 的 Race 落败方 rejection 无人处理 【修复(browser-window-manager.ts + web-fetch.ts + browser.ts)】 - 新增 fetchPageText:排队版页面抓取,串行化完整 open→等待→evaluate 序列(与 open 共用单一操作链,destroy 只会在链上发生,跨链互毁彻底 消除);web_fetch 浏览器回退改走此入口 - open() 拆分 openInternal(链内直调);open 与 fetchPageText 共用 单一串行链,排队不分死锁 - destroy() 移除 session 存储清理(终态清理迁移至 close(),await 执行, 不再与窗口创建并发) - safeWebContents() 即时校验替代 racy 的 ensureReady;evaluate/extract/ screenshot/click/type/scroll/waitForSelector 全部加固,消除对已销毁 webContents 的调用 - loadURLWithTimeout 落败方 rejection 兜底(防 unhandledRejection) - cleanupBrowser/cleanup/close 异步化适配(main.ts 退出链路 await) 【崩溃可观测性(此前崩溃无迹可查 — 日志无声截断)】 - process.on(uncaughtException/unhandledRejection) → [FATAL] 落盘 - app.on(render-process-gone/child-process-gone) → [FATAL] 落盘 - WindowManager: 每窗口 render-process-gone 日志 + 自动 reload 自愈 (渲染进程 OOM/崩溃不再白屏卡死,可自动恢复) 【验证】 - lint 0/0;typecheck 双工程 0 错误;test:electron 252/252;build 通过
253 lines
6.9 KiB
TypeScript
253 lines
6.9 KiB
TypeScript
/**
|
||
* Window Manager — 多窗口管理
|
||
*
|
||
* 职责:
|
||
* 1. 创建和管理多个窗口(每个工作空间可独立开窗口)
|
||
* 2. 窗口状态持久化(位置、大小)
|
||
* 3. 全局快捷键注册(Cmd+Shift+M 切换到 Metona)
|
||
* 4. 关闭到托盘行为
|
||
*
|
||
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 窗口管理
|
||
*/
|
||
|
||
import { BrowserWindow, globalShortcut, shell } from 'electron';
|
||
import { join } from 'path';
|
||
import { existsSync } from 'fs';
|
||
import { is } from '@electron-toolkit/utils';
|
||
import log from 'electron-log';
|
||
|
||
export interface WindowState {
|
||
x?: number;
|
||
y?: number;
|
||
width: number;
|
||
height: number;
|
||
isMaximized?: boolean;
|
||
}
|
||
|
||
export class WindowManager {
|
||
private windows = new Map<string, BrowserWindow>();
|
||
private activeWindowId: string | null = null;
|
||
|
||
/**
|
||
* 创建新窗口
|
||
*/
|
||
createWindow(options: {
|
||
id?: string;
|
||
workspacePath?: string;
|
||
title?: string;
|
||
state?: WindowState;
|
||
beforeLoad?: (win: BrowserWindow) => void;
|
||
}): BrowserWindow {
|
||
const id = options.id ?? `window_${Date.now()}`;
|
||
const state = options.state ?? { width: 1440, height: 900 };
|
||
|
||
const win = new BrowserWindow({
|
||
width: state.width,
|
||
height: state.height,
|
||
x: state.x,
|
||
y: state.y,
|
||
minWidth: 960,
|
||
minHeight: 600,
|
||
icon: this.getIconPath(),
|
||
show: false,
|
||
titleBarStyle: 'hiddenInset',
|
||
title: options.title ?? 'MetonaAI Desktop',
|
||
webPreferences: {
|
||
preload: join(__dirname, '../preload/preload.mjs'),
|
||
// sandbox: false — preload 使用 ESM 格式(.mjs + import 语法),
|
||
// Electron sandbox 不支持 ESM preload(官方 ESM 支持矩阵: Sandboxed = Unsupported)。
|
||
// 若启用 sandbox: true,preload.mjs 的 import 语句无法解析,contextBridge 不执行,
|
||
// window.metona 为 undefined,所有 IPC 调用静默失败。
|
||
// 要启用 sandbox,需先将 preload 改为 CJS 格式 + require 语法(工程改动较大)。
|
||
sandbox: false,
|
||
contextIsolation: true,
|
||
nodeIntegration: false,
|
||
},
|
||
});
|
||
|
||
// P2-11 修复: 在 loadURL 之前执行回调,确保 IPC handler 在渲染进程加载前注册
|
||
if (options.beforeLoad) options.beforeLoad(win);
|
||
|
||
// 加载页面
|
||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||
win.loadURL(process.env['ELECTRON_RENDERER_URL']);
|
||
} else {
|
||
win.loadFile(join(__dirname, '../../dist/index.html'));
|
||
}
|
||
|
||
// 窗口事件
|
||
win.on('ready-to-show', () => {
|
||
if (state.isMaximized) {
|
||
win.maximize();
|
||
}
|
||
win.show();
|
||
});
|
||
|
||
win.on('closed', () => {
|
||
this.windows.delete(id);
|
||
if (this.activeWindowId === id) {
|
||
this.activeWindowId =
|
||
this.windows.size > 0 ? (this.windows.keys().next().value ?? null) : null;
|
||
}
|
||
});
|
||
|
||
// 崩溃可观测性 + 自愈: 渲染进程崩溃(OOM/原生崩溃)时记录归因日志并自动重载,
|
||
// 替代静默白屏(用户此前感知为"应用崩溃"且无从恢复)
|
||
win.webContents.on('render-process-gone', (_event, details) => {
|
||
log.error(
|
||
`[WindowManager] render-process-gone (id=${win.id}): reason=${details.reason} exitCode=${details.exitCode}`,
|
||
);
|
||
if (!win.isDestroyed() && details.reason !== 'clean-exit') {
|
||
try {
|
||
win.webContents.reload();
|
||
log.info(`[WindowManager] Window ${id} reloaded after renderer crash`);
|
||
} catch (err) {
|
||
log.error(`[WindowManager] Reload after crash failed:`, err);
|
||
}
|
||
}
|
||
});
|
||
|
||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||
// C-8 修复: 校验 URL 协议,只允许 http/https,防止 javascript:/file: 等协议执行代码
|
||
try {
|
||
const parsed = new URL(url);
|
||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||
shell.openExternal(url);
|
||
} else {
|
||
log.warn(`[WindowManager] Blocked window.open with unsafe protocol: ${parsed.protocol}`);
|
||
}
|
||
} catch {
|
||
log.warn(`[WindowManager] Blocked window.open with invalid URL: ${url.slice(0, 100)}`);
|
||
}
|
||
return { action: 'deny' };
|
||
});
|
||
|
||
this.windows.set(id, win);
|
||
this.activeWindowId = id;
|
||
|
||
log.info(`Window created: ${id} (${options.title ?? 'MetonaAI Desktop'})`);
|
||
|
||
return win;
|
||
}
|
||
|
||
/**
|
||
* 获取窗口
|
||
*/
|
||
getWindow(id: string): BrowserWindow | undefined {
|
||
return this.windows.get(id);
|
||
}
|
||
|
||
/**
|
||
* 获取活动窗口
|
||
*/
|
||
getActiveWindow(): BrowserWindow | null {
|
||
if (this.activeWindowId) {
|
||
return this.windows.get(this.activeWindowId) ?? null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 获取所有窗口
|
||
*/
|
||
getAllWindows(): BrowserWindow[] {
|
||
return Array.from(this.windows.values());
|
||
}
|
||
|
||
/**
|
||
* 聚焦到窗口(从任意应用切换)
|
||
*/
|
||
focusWindow(): void {
|
||
const win = this.getActiveWindow();
|
||
if (win) {
|
||
if (win.isMinimized()) {
|
||
win.restore();
|
||
}
|
||
win.show();
|
||
win.focus();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 注册全局快捷键
|
||
*/
|
||
registerGlobalShortcuts(): void {
|
||
// Cmd/Ctrl+Shift+M — 从任意应用切换到 Metona
|
||
const registered = globalShortcut.register('CommandOrControl+Shift+M', () => {
|
||
this.focusWindow();
|
||
log.info('Global shortcut: Switch to Metona');
|
||
});
|
||
|
||
if (!registered) {
|
||
log.warn('Failed to register global shortcut: Cmd+Shift+M');
|
||
} else {
|
||
log.info('Global shortcut registered: Cmd+Shift+M');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 注销全局快捷键
|
||
*/
|
||
unregisterGlobalShortcuts(): void {
|
||
globalShortcut.unregisterAll();
|
||
log.info('Global shortcuts unregistered');
|
||
}
|
||
|
||
/**
|
||
* 获取窗口状态(用于持久化)
|
||
*/
|
||
getWindowState(id: string): WindowState | null {
|
||
const win = this.windows.get(id);
|
||
if (!win) return null;
|
||
|
||
const bounds = win.getBounds();
|
||
return {
|
||
x: bounds.x,
|
||
y: bounds.y,
|
||
width: bounds.width,
|
||
height: bounds.height,
|
||
isMaximized: win.isMaximized(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 关闭所有窗口
|
||
*/
|
||
closeAll(): void {
|
||
for (const [id, win] of this.windows) {
|
||
try {
|
||
win.destroy();
|
||
} catch {
|
||
log.warn(`Failed to close window: ${id}`);
|
||
}
|
||
}
|
||
this.windows.clear();
|
||
this.activeWindowId = null;
|
||
}
|
||
|
||
/**
|
||
* 获取窗口数量
|
||
*/
|
||
get count(): number {
|
||
return this.windows.size;
|
||
}
|
||
|
||
/**
|
||
* 获取应用图标路径
|
||
*/
|
||
private getIconPath(): string | undefined {
|
||
const candidates = [
|
||
join(__dirname, '../../assets/logo.ico'),
|
||
join(__dirname, '../../assets/logo.png'),
|
||
join(process.resourcesPath || '', 'app.asar.unpacked', 'assets/logo.ico'),
|
||
join(process.resourcesPath || '', 'app.asar.unpacked', 'assets/logo.png'),
|
||
join(process.resourcesPath || '', 'assets/logo.ico'),
|
||
join(process.resourcesPath || '', 'assets/logo.png'),
|
||
];
|
||
for (const p of candidates) {
|
||
if (existsSync(p)) return p;
|
||
}
|
||
return undefined;
|
||
}
|
||
}
|