本次升级基于完整代码审查,修复 Critical/High/Medium/Low 四级共 96 项问题, 并通过返工审计修复 10 项遗留问题,tsc 双端类型检查零错误。 Critical (10/10 完成): - C-4: command.ts 接入 shell-quote 进行 token-level 注入检测,替代原有正则匹配 可防御 r"m" -rf /、$'rm'、$(echo rm) 等字符串拼接绕过 High (11/11 完成): - 竞态保护、Promise.allSettled、AbortController 资源泄漏、IPC 参数校验等 Medium (55/55 完成): - 事务保护、敏感数据脱敏、枚举校验、MUI v9 Stack prop 迁移、 React 组件 cancelled 标志、类型收窄等 Low (20/20 完成): - 辅助方法提取(flushToolCallBuffer/scoreAndPushMemory/tryAddColumn 等) - nanoid 统一替代 Date.now()+Math.random() - confirm() 替换为 MUI Dialog、useMemo 缓存、魔法数字命名化等 返工审计修复 (10/10 完成): - L-11: LogsSettings 残留的原生 confirm()/alert() 全部替换为 MUI Dialog/Alert - M-53: MemoryViewer handleSearch 独立 ref,修复 searching 状态卡死 - M-42: 脱敏短值(length <= 4)泄露修复 - M-47: tasks:update 补全 title/description 类型校验 - L-9: ollama.adapter 非流式路径 nanoid 统一 - M-45: audit:query limit 策略与 memory:listAll 一致化 - SettingsModal handleConfirmRemove 补全 try/catch + loadServers cleanup - L-15: CommandPalette useMemo 补全 sessions 响应式依赖 - useAgentStream 事件类型补全 seq/timestamp 字段 新增依赖: shell-quote + @types/shell-quote 版本号: 0.3.0 -> 0.3.1
227 lines
5.5 KiB
TypeScript
227 lines
5.5 KiB
TypeScript
/**
|
||
* 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;
|
||
}): 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,
|
||
},
|
||
});
|
||
|
||
// 加载页面
|
||
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;
|
||
}
|
||
}
|