/** * Window Manager — 多窗口管理 * * 职责: * 1. 创建和管理多个窗口(每个工作空间可独立开窗口) * 2. 窗口状态持久化(位置、大小) * 3. 全局快捷键注册(Cmd+Shift+M 切换到 Metona) * 4. 关闭到托盘行为 * * @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 窗口管理 */ import { BrowserWindow, globalShortcut, shell, app, screen } from 'electron'; import { join } from 'path'; import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } 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; } /** v0.8.2 P3-5: 窗口状态持久化文件(userData 下,机器级) */ const WINDOW_STATE_FILE = join(app.getPath('userData'), 'window-state.json'); 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()}`; // v0.8.2 P3-5: 未显式传 state 时自动读取持久化状态(恢复上次位置/尺寸/最大化) const state = options.state ?? WindowManager.loadWindowState(); 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(); }); // v0.8.2 P3-5: 窗口状态持久化 —— move/resize 防抖 800ms 落盘 + close 时兜底 // 捕获一次(maximized 还原依赖 close 时的 isMaximized 标志)。此前状态通道 // (state?: WindowState)存在但 main.ts 从未读写,每次启动固定 1440×900 居中。 let saveStateTimer: NodeJS.Timeout | null = null; const captureState = (): WindowState => { const bounds = win.getBounds(); return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, isMaximized: win.isMaximized(), }; }; const scheduleSaveState = (): void => { if (saveStateTimer) clearTimeout(saveStateTimer); saveStateTimer = setTimeout(() => { saveStateTimer = null; WindowManager.saveWindowState(captureState()); }, 800); saveStateTimer.unref?.(); }; win.on('resize', scheduleSaveState); win.on('move', scheduleSaveState); win.on('close', () => { if (saveStateTimer) { clearTimeout(saveStateTimer); saveStateTimer = null; } WindowManager.saveWindowState(captureState()); }); win.on('closed', () => { this.windows.delete(id); // v0.8.2 P3-5: 崩溃自愈退避记录同步清理(窗口销毁后 Map 条目残留属泄漏) this.crashReloadAttempts.delete(win.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; } /** * v0.8.2 P3-5: 读取持久化的窗口状态。 * 文件缺失/损坏返回默认尺寸;恢复的坐标不在任何显示器可见范围时丢弃坐标 * (防止显示器拔除后窗口"消失")。 */ static loadWindowState(): WindowState { const fallback: WindowState = { width: 1440, height: 900 }; try { if (!existsSync(WINDOW_STATE_FILE)) return fallback; const raw = JSON.parse(readFileSync(WINDOW_STATE_FILE, 'utf-8')) as WindowState; if (typeof raw?.width !== 'number' || typeof raw?.height !== 'number') return fallback; if (raw.width < 400 || raw.height < 300) return fallback; // 坐标可见性校验:x/y 必须落在某个显示器的可见区域内 if (typeof raw.x === 'number' && typeof raw.y === 'number') { const visible = screen.getAllDisplays().some((d) => { const { x, y, width, height } = d.bounds; return ( raw.x! >= x - 100 && raw.x! < x + width && raw.y! >= y - 100 && raw.y! < y + height ); }); if (!visible) { return { width: raw.width, height: raw.height, isMaximized: raw.isMaximized }; } } else { delete raw.x; delete raw.y; } return raw; } catch { return fallback; } } /** v0.8.2 P3-5: 原子落盘窗口状态(tmp + rename;尽力而为,失败仅告警) */ static saveWindowState(state: WindowState): void { try { const tmp = `${WINDOW_STATE_FILE}.tmp`; writeFileSync(tmp, JSON.stringify(state), 'utf-8'); try { renameSync(tmp, WINDOW_STATE_FILE); } catch (err) { try { unlinkSync(tmp); } catch { /* ignore */ } throw err; } } catch (err) { log.warn(`[WindowManager] Failed to persist window state: ${(err as Error).message}`); } } /** * 获取窗口 */ 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; } }