/** * Window Manager — 多窗口管理 * * 职责: * 1. 创建和管理多个窗口(每个工作空间可独立开窗口) * 2. 窗口状态持久化(位置、大小) * 3. 全局快捷键注册(Cmd+Shift+M 切换到 Metona) * 4. 关闭到托盘行为 * * @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 窗口管理 */ import { BrowserWindow, globalShortcut, app, 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; /** * 创建新窗口 */ 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, 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; } }); 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; } }