Files
metona-ai-desktop/electron/services/window-manager.service.ts
T
thzxx 4cd6e997b5
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s
feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
2026-09-08 14:30:27 +08:00

406 lines
14 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, 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<string, BrowserWindow>();
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 为同步 APIvoid 返回),崩溃循环保护路径直接弹窗
// 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;
}
}