Files
metona-ai-desktop/electron/services/window-manager.service.ts
T

236 lines
6.2 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, 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<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'),
// #37 修复: 启用沙箱,强化渲染进程隔离
// preload.ts 仅使用 contextBridge/ipcRenderersandbox 下可用)和
// @electron-toolkit/preload(兼容 sandbox),不依赖 fs/child_process 等 Node API
// 因此可安全启用 sandbox: true。启用后渲染进程无法直接访问 Node API,
// 缩小恶意网页通过 preload 漏洞访问 fs/child_process 的攻击面。
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;
}
});
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;
}
}