Files
metona-ai-desktop/electron/services/window-manager.service.ts
T
thzxx 80cf5b482c
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m41s
CI / 全量测试 (Electron ABI) (push) Failing after 5m21s
CI / 产物编译验证 (push) Successful in 10m1s
fix: v0.6.1 修复回复/推理期间偶发崩溃 — 浏览器回退窗口竞态 + 崩溃可观测性
【根因(实证归因,非猜测)】
分析 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 通过
2026-08-22 19:16:41 +08:00

253 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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: truepreload.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;
}
}