/** * 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(); private activeWindowId: string | null = null; /** * v0.8.0 P1-3.7: 渲染进程崩溃自愈退避 —— 按 window id 记录 { count, windowStart }。 * 60s 窗口内连续崩溃 ≥3 次不再自动 reload(避免 crash→reload→crash 死循环), * 改弹系统错误对话框;自上次 reload 起 5 分钟无崩溃则计数复位。 */ private crashReloadAttempts = new Map< number, { count: number; windowStart: number; lastAt: number } >(); private static readonly CRASH_RELOAD_MAX = 3; private static readonly CRASH_RELOAD_WINDOW_MS = 60_000; private static readonly CRASH_RELOAD_RESET_MS = 5 * 60_000; /** * 创建新窗口 */ 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: { // v0.6.4 安全加固: preload 已迁移为 CJS 产物(preload.cjs,见 electron.vite.config.ts // 的 rollupOptions.output.format:'cjs'),原 ESM .mjs 与 sandbox 不兼容的限制解除。 // sandbox:true 后渲染进程即使被 XSS 也无法触碰 Node/加载任意模块 —— 这是 // Electron 官方推荐的最高优先级防线(Electron 安全清单第 1 条)。 preload: join(__dirname, '../preload/preload.cjs'), sandbox: true, 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/原生崩溃)时记录归因日志并自动重载, // 替代静默白屏(用户此前感知为"应用崩溃"且无从恢复) // v0.8.0 P1-3.7: 增加退避 —— 60s 内连续 ≥3 次崩溃停止自动 reload 并弹窗 //(根因持久存在时旧实现会 crash→reload→crash 死循环);稳定 5 分钟后计数复位。 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') { const now = Date.now(); const record = this.crashReloadAttempts.get(win.id) ?? { count: 0, windowStart: now, lastAt: 0, }; // 稳定 5 分钟 → 计数复位 if (record.lastAt > 0 && now - record.lastAt > WindowManager.CRASH_RELOAD_RESET_MS) { record.count = 0; record.windowStart = now; } // 60s 滑动窗口外 → 重新起算 if (now - record.windowStart > WindowManager.CRASH_RELOAD_WINDOW_MS) { record.count = 0; record.windowStart = now; } record.count += 1; record.lastAt = now; this.crashReloadAttempts.set(win.id, record); if (record.count > WindowManager.CRASH_RELOAD_MAX) { log.error( `[WindowManager] Crash reload threshold exceeded (${record.count} in ${WindowManager.CRASH_RELOAD_WINDOW_MS / 1000}s) — NOT reloading automatically (crash loop protection)`, ); try { // showErrorBox 为同步 API(void 返回),崩溃循环保护路径直接弹窗 // eslint-disable-next-line @typescript-eslint/no-require-imports const { dialog } = require('electron') as typeof import('electron'); dialog.showErrorBox( 'MetonaAI Desktop 反复崩溃', `渲染进程在 ${WindowManager.CRASH_RELOAD_WINDOW_MS / 1000} 秒内连续崩溃 ${record.count} 次,已停止自动恢复。\n\n请重启应用;若反复出现,请携带日志(设置 → 日志与数据 → 打开日志目录)反馈。`, ); } catch { /* dialog 不可用时仅保留日志 */ } return; } 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' }; }); // v0.6.4 安全加固: will-navigate 拦截 —— SPA 应用主框架不应发生顶层导航; // 除开发服务器热更新入口外的一切导航一律取消,http(s) 外链转系统浏览器。 // (此前渲染进程若被注入 webContents.location=... 可静默替换页面。) win.webContents.on('will-navigate', (event, url) => { const isDevServer = process.env['ELECTRON_RENDERER_URL'] !== undefined && (() => { try { return new URL(url).origin === new URL(process.env['ELECTRON_RENDERER_URL']!).origin; } catch { return false; } })(); const isAppFile = url.startsWith('file://'); if (!isDevServer && !isAppFile) { event.preventDefault(); log.warn(`[WindowManager] Blocked top-level navigation to ${url.slice(0, 120)}`); try { const parsed = new URL(url); if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { void import('electron').then(({ shell }) => shell.openExternal(url)); } } catch { /* 非 URL 一律丢弃 */ } } }); 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'); } /** * 关闭所有窗口 */ 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; } }