feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
This commit is contained in:
@@ -15,6 +15,8 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { createHash } from 'crypto';
|
||||
import log from 'electron-log';
|
||||
// v0.8.2 P1-5: 审计 args 深度脱敏(与配置层脱敏同源)
|
||||
import { deepMaskSensitive } from '../utils/mask';
|
||||
|
||||
/**
|
||||
* #36 修复: 稳定序列化,递归按 key 字典序排序后序列化
|
||||
@@ -194,6 +196,10 @@ export class AuditService {
|
||||
|
||||
/**
|
||||
* 记录工具调用
|
||||
*
|
||||
* v0.8.2 P1-5: args 深度脱敏后落库 —— 工具参数中的密钥/鉴权头/token 此前
|
||||
* 以明文进入 audit_logs(safeStorage 只保护配置层),构成敏感信息二次扩散面。
|
||||
* 键名匹配与配置层单源(utils/mask → secure-config 归一化匹配)。
|
||||
*/
|
||||
logToolCall(params: {
|
||||
sessionId: string;
|
||||
@@ -212,7 +218,7 @@ export class AuditService {
|
||||
actor: 'agent',
|
||||
target: params.toolName,
|
||||
details: {
|
||||
args: params.args,
|
||||
args: deepMaskSensitive(params.args),
|
||||
result: typeof params.result === 'string' ? params.result.slice(0, 1000) : params.result,
|
||||
error: params.error,
|
||||
},
|
||||
|
||||
@@ -132,8 +132,9 @@ export class DatabaseService {
|
||||
* 迁移 12(清理已废除的 llm.contextWindow 分 Provider 键与 ollama.numCtx)。
|
||||
* v0.8.1 review: 4 → 5 —— 迁移 13(working_memories 补 sessions 外键 CASCADE,
|
||||
* 孤儿行清理;根治历史 schema 缺失级联导致的孤儿数据)。
|
||||
* v0.8.2: 5 → 6 —— 迁移 14(记忆表 embedding_model 列,嵌入模型指纹)。
|
||||
*/
|
||||
static readonly SCHEMA_VERSION = 5;
|
||||
static readonly SCHEMA_VERSION = 6;
|
||||
|
||||
constructor(workspacePath?: string) {
|
||||
const baseDir = workspacePath ?? join(app.getPath('userData'), 'MetonaWorkspaces', 'default');
|
||||
@@ -751,6 +752,13 @@ export class DatabaseService {
|
||||
}
|
||||
}
|
||||
|
||||
// v0.8.2 P3-1 迁移 14: 记忆表 embedding_model 列(嵌入模型指纹)。
|
||||
// 用户更换 memory.embeddingModel 后,旧 BLOB 以新查询向量算余弦 —— 维度
|
||||
// 不同静默余弦为 0(降级 TF-IDF),同维不同模型产生噪声分数。现记录每条
|
||||
// 向量的来源模型,检索时模型不匹配的向量视为缺失(惰性重算自愈)。
|
||||
tryAddColumn('episodic_memories', 'embedding_model', 'TEXT');
|
||||
tryAddColumn('semantic_memories', 'embedding_model', 'TEXT');
|
||||
|
||||
// v0.7.4 P4-4 迁移 9: messages_fts 升级 trigram tokenizer
|
||||
// 存量库的 messages_fts 建表语句不含 trigram —— 直接 DROP + 重建 + rebuild,
|
||||
// 使中文非连续子串搜索(trigram ≥3 字符)可用。检测方式:读 sqlite_master 的
|
||||
|
||||
@@ -23,7 +23,15 @@
|
||||
|
||||
import { app } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import log from 'electron-log';
|
||||
import { CONFIG_DEFAULTS, DEPRECATED_CONFIG_KEYS } from './database.service';
|
||||
import {
|
||||
@@ -34,6 +42,8 @@ import {
|
||||
|
||||
/** 全局配置文件路径(userData 下,与工作空间无关) */
|
||||
const GLOBAL_CONFIG_FILE = join(app.getPath('userData'), 'global-config.json');
|
||||
/** 最近一次成功落盘的备份(主文件损坏时的恢复源) */
|
||||
const GLOBAL_CONFIG_BACKUP = join(app.getPath('userData'), 'global-config.backup.json');
|
||||
|
||||
/** 全局配置 key 前缀清单(匹配这些前缀的 key 视为全局配置) */
|
||||
const GLOBAL_KEY_PREFIXES = [
|
||||
@@ -105,7 +115,28 @@ export class GlobalConfigService {
|
||||
try {
|
||||
if (existsSync(GLOBAL_CONFIG_FILE)) {
|
||||
const raw = readFileSync(GLOBAL_CONFIG_FILE, 'utf-8');
|
||||
this.data = JSON.parse(raw) as GlobalConfigData;
|
||||
try {
|
||||
this.data = JSON.parse(raw) as GlobalConfigData;
|
||||
} catch (parseErr) {
|
||||
// v0.8.2 P0-3: 主文件损坏自愈 —— 依次尝试:① 最近一次成功落盘的备份
|
||||
// 恢复(恢复成功即回写主文件);② 归档损坏文件后以空配置启动。
|
||||
// 此前 parse 失败会静默以空配置运行,用户全部全局配置(含 LLM 凭据)
|
||||
// 在下一次 set() 落盘时被覆盖丢失。
|
||||
log.error('[GlobalConfig] Main config file corrupted:', parseErr);
|
||||
this.data = this.recoverFromBackup();
|
||||
if (Object.keys(this.data).length > 0) {
|
||||
this.flush();
|
||||
log.warn('[GlobalConfig] Restored global config from backup file');
|
||||
} else {
|
||||
const archived = `${GLOBAL_CONFIG_FILE}.corrupt-${Date.now()}`;
|
||||
try {
|
||||
renameSync(GLOBAL_CONFIG_FILE, archived);
|
||||
log.warn(`[GlobalConfig] Corrupted config archived to ${archived}`);
|
||||
} catch {
|
||||
/* 归档失败不阻断启动 */
|
||||
}
|
||||
}
|
||||
}
|
||||
// v0.8.1 review: 清除已废除的配置键(分 Provider contextWindow / ollama.numCtx),
|
||||
// 与工作空间 DB 迁移 12 对齐 —— 全局层残留会使双源语义复活
|
||||
let purged = 0;
|
||||
@@ -140,6 +171,23 @@ export class GlobalConfigService {
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.8.2 P0-3: 从备份恢复(仅 initialize 的损坏自愈路径调用)。
|
||||
* 备份不存在或损坏时返回空对象。
|
||||
*/
|
||||
private recoverFromBackup(): GlobalConfigData {
|
||||
try {
|
||||
if (!existsSync(GLOBAL_CONFIG_BACKUP)) return {};
|
||||
const raw = readFileSync(GLOBAL_CONFIG_BACKUP, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as GlobalConfigData;
|
||||
if (parsed && typeof parsed === 'object') return parsed;
|
||||
return {};
|
||||
} catch (err) {
|
||||
log.error('[GlobalConfig] Backup recovery failed:', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取全局配置(P0-1: 敏感 key 自动解密)
|
||||
*/
|
||||
@@ -236,12 +284,40 @@ export class GlobalConfigService {
|
||||
|
||||
/**
|
||||
* 落盘到 JSON 文件
|
||||
*
|
||||
* v0.8.2 P0-3 根治:此前直接 writeFileSync 覆写主文件 —— 写盘中途崩溃
|
||||
* (断电/强杀)会产生半截 JSON,下次启动 parse 失败后以空配置启动,
|
||||
* **全部全局配置(含 LLM 凭据)随之丢失**。现改为:
|
||||
* 1. 原子替换:先写同目录 tmp,再 renameSync 原子改名(与 write_file 工具 /
|
||||
* workspace.rewriteMemory 同口径,Windows 下 rename 覆盖已存在目标);
|
||||
* 2. 写后备份:成功落盘后同步维护 global-config.backup.json(尽力而为,
|
||||
* 失败仅告警)—— 主文件因外部因素损坏时的恢复源。
|
||||
*/
|
||||
private flush(): void {
|
||||
const tmpPath = `${GLOBAL_CONFIG_FILE}.tmp_${Date.now()}_${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`;
|
||||
try {
|
||||
writeFileSync(GLOBAL_CONFIG_FILE, JSON.stringify(this.data, null, 2), 'utf-8');
|
||||
writeFileSync(tmpPath, JSON.stringify(this.data, null, 2), 'utf-8');
|
||||
try {
|
||||
renameSync(tmpPath, GLOBAL_CONFIG_FILE);
|
||||
} catch (err) {
|
||||
try {
|
||||
unlinkSync(tmpPath);
|
||||
} catch {
|
||||
/* 忽略清理失败 */
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('[GlobalConfig] Failed to write config file:', err);
|
||||
return;
|
||||
}
|
||||
// 备份为尽力而为:失败不影响主流程(备份缺失仅降低自愈成功率)
|
||||
try {
|
||||
copyFileSync(GLOBAL_CONFIG_FILE, GLOBAL_CONFIG_BACKUP);
|
||||
} catch (err) {
|
||||
log.warn(`[GlobalConfig] Backup write failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import type Database from 'better-sqlite3';
|
||||
import log from 'electron-log';
|
||||
import type { ToolRegistry } from '../harness/tools/registry';
|
||||
import type { IMetonaTool, ToolExecutionContext } from '../harness/types/metona-tool';
|
||||
import type { MetonaToolDef } from '../harness/types';
|
||||
import type { MetonaToolDef, MetonaParamField } from '../harness/types';
|
||||
import { MetonaToolCategory, MetonaRiskLevel } from '../harness/types';
|
||||
// v0.7.3 P3-2: 子进程环境净化收敛到 utils/safe-env.ts 单源(与 run_command 共用)
|
||||
import { buildSafeChildEnv } from '../utils/safe-env';
|
||||
@@ -289,24 +289,53 @@ class MCPToolAdapter implements IMetonaTool {
|
||||
name: this.mcpTool.name,
|
||||
arguments: args,
|
||||
});
|
||||
return result.content;
|
||||
const content = result.content as Array<Record<string, unknown>> | undefined;
|
||||
|
||||
// v0.8.2 P2-7 根治: MCP 返回的 image block 此前被直接透传 —— registry 的
|
||||
// 内联图片白名单只识别顶层 dataUrl/image 字段,嵌套在 content 数组中的图片
|
||||
// 块不匹配白名单,被 50KB 截断为破损 base64。现将 text/image block 归并:
|
||||
// text 拼接为顶层文本,首个 image block 提升为顶层 `image` 字段(data URI,
|
||||
// 命中 registry 白名单整段放行 → 渲染端可内联预览)。
|
||||
if (Array.isArray(content)) {
|
||||
const texts: string[] = [];
|
||||
let imageDataUri: string | null = null;
|
||||
for (const item of content) {
|
||||
const type = item?.type;
|
||||
if (type === 'text' && typeof item.text === 'string') {
|
||||
texts.push(item.text);
|
||||
} else if (type === 'image' && !imageDataUri) {
|
||||
const data = typeof item.data === 'string' ? item.data : '';
|
||||
const mimeType =
|
||||
typeof item.mimeType === 'string' && item.mimeType ? item.mimeType : 'image/png';
|
||||
if (data) {
|
||||
imageDataUri = `data:${mimeType};base64,${data}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (imageDataUri || texts.length > 0) {
|
||||
return {
|
||||
...(texts.length > 0 ? { text: texts.join('\n\n') } : {}),
|
||||
...(imageDataUri ? { image: imageDataUri } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 MCP JSON Schema 转换为 MetonaToolParams
|
||||
*
|
||||
* v0.8.2 P2-7 根治: 旧实现只保留顶层 properties 的 type/description ——
|
||||
* 丢弃 enum/anyOf/oneOf/嵌套对象/items/default,复杂 MCP 工具的参数约束
|
||||
* 对 LLM 不可见,易产生非法参数。现递归保留 IR 支持的全部结构(见
|
||||
* MetonaParamField 的 P2-7 扩展字段)。
|
||||
*/
|
||||
private convertSchema(schema: Record<string, unknown>): MetonaToolDef['parameters'] {
|
||||
const properties: Record<
|
||||
string,
|
||||
{ type: 'string' | 'number' | 'boolean' | 'object' | 'array'; description: string }
|
||||
> = {};
|
||||
const properties: Record<string, MetonaToolDef['parameters']['properties'][string]> = {};
|
||||
const schemaProps = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
|
||||
for (const [key, prop] of Object.entries(schemaProps)) {
|
||||
properties[key] = {
|
||||
type: (prop.type as 'string' | 'number' | 'boolean' | 'object' | 'array') ?? 'string',
|
||||
description: (prop.description as string) ?? '',
|
||||
};
|
||||
properties[key] = this.convertSchemaField(prop);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -315,6 +344,43 @@ class MCPToolAdapter implements IMetonaTool {
|
||||
required: schema.required as string[] | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 单个参数字段递归转换(P2-7 schema 保真) */
|
||||
private convertSchemaField(prop: Record<string, unknown>): MetonaParamField {
|
||||
const field: MetonaParamField = {
|
||||
type: (prop.type as MetonaParamField['type']) ?? 'string',
|
||||
description: (prop.description as string) ?? '',
|
||||
};
|
||||
if (Array.isArray(prop.enum)) {
|
||||
field.enum = prop.enum.map((v) => String(v));
|
||||
}
|
||||
if (prop.items && typeof prop.items === 'object') {
|
||||
field.items = this.convertSchemaField(prop.items as Record<string, unknown>);
|
||||
}
|
||||
if (prop.properties && typeof prop.properties === 'object') {
|
||||
const nested: Record<string, MetonaParamField> = {};
|
||||
for (const [k, v] of Object.entries(
|
||||
prop.properties as Record<string, Record<string, unknown>>,
|
||||
)) {
|
||||
nested[k] = this.convertSchemaField(v);
|
||||
}
|
||||
field.properties = nested;
|
||||
}
|
||||
if (Array.isArray(prop.required)) {
|
||||
field.required = prop.required.map((v) => String(v));
|
||||
}
|
||||
for (const combinator of ['anyOf', 'oneOf'] as const) {
|
||||
if (Array.isArray(prop[combinator])) {
|
||||
field[combinator] = (prop[combinator] as Array<Record<string, unknown>>).map((v) =>
|
||||
this.convertSchemaField(v),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (prop.default !== undefined) {
|
||||
field.default = prop.default;
|
||||
}
|
||||
return field;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== MCP Manager =====
|
||||
@@ -819,6 +885,11 @@ export class MCPManager {
|
||||
|
||||
/**
|
||||
* 获取所有 Server 状态
|
||||
*
|
||||
* v0.8.2 P2-7 根治: 已配置但**禁用**的 server 此前不出现在列表中 ——
|
||||
* initialize() 只连 enabled=1,getServerStates 只映射 servers Map,设置面板
|
||||
* 无法展示"已配置但禁用"的完整清单。现从 DB 补齐缺失条目(status=disconnected、
|
||||
* enabled=false),并为每个条目标注 enabled。
|
||||
*/
|
||||
getServerStates(): Array<{
|
||||
name: string;
|
||||
@@ -827,15 +898,45 @@ export class MCPManager {
|
||||
error?: string;
|
||||
/** v0.7.3 P4-2: reconnecting 状态下的已尝试次数(第 N/3 次排程) */
|
||||
reconnectAttempt?: number;
|
||||
/** v0.8.2 P2-7: 是否为启用状态(DB enabled=1)—— 禁用 server 以 disconnected 呈现 */
|
||||
enabled: boolean;
|
||||
}> {
|
||||
return Array.from(this.servers.values()).map((s) => ({
|
||||
// DB 全量配置(enabled 标注 + 补齐禁用条目);DB 不可用时退回仅已连接集合
|
||||
let enabledMap = new Map<string, boolean>();
|
||||
try {
|
||||
const db = this.getDB();
|
||||
const rows = db.prepare('SELECT name, enabled FROM mcp_servers').all() as Array<{
|
||||
name: string;
|
||||
enabled: number;
|
||||
}>;
|
||||
enabledMap = new Map(rows.map((r) => [r.name, r.enabled === 1]));
|
||||
} catch (err) {
|
||||
log.warn(`[MCPManager] getServerStates DB lookup failed: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const states = Array.from(this.servers.values()).map((s) => ({
|
||||
name: s.config.name,
|
||||
status: s.status,
|
||||
toolCount: s.tools.length,
|
||||
error: s.error,
|
||||
reconnectAttempt:
|
||||
s.status === 'reconnecting' ? this.reconnectAttempts.get(s.config.name) : undefined,
|
||||
enabled: enabledMap.get(s.config.name) ?? true,
|
||||
}));
|
||||
|
||||
for (const [name, enabled] of enabledMap) {
|
||||
if (!enabled && !states.some((s) => s.name === name)) {
|
||||
states.push({
|
||||
name,
|
||||
status: 'disconnected' as MCPServerStatus,
|
||||
toolCount: 0,
|
||||
error: undefined,
|
||||
reconnectAttempt: undefined,
|
||||
enabled: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,8 @@ import { Tray, Menu, BrowserWindow, app, nativeImage, Notification } from 'elect
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import log from 'electron-log';
|
||||
// v0.8.2 P2-5: 托盘菜单文案双语(ui.locale 驱动)
|
||||
import { mt } from '../utils/main-locale';
|
||||
|
||||
export type TrayStatus = 'idle' | 'thinking' | 'executing' | 'error';
|
||||
|
||||
@@ -202,12 +204,15 @@ export class TrayManager {
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
label: `状态: ${statusIcons[this.currentStatus]} ${this.currentStatus}`,
|
||||
// v0.8.2 P2-5: 托盘菜单出层(ui.locale 驱动 mt(),语言切换即热生效)
|
||||
label: mt('tray.menu.status', {
|
||||
status: `${statusIcons[this.currentStatus]} ${mt(`tray.status.${this.currentStatus}`)}`,
|
||||
}),
|
||||
enabled: false,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '显示窗口',
|
||||
label: mt('tray.menu.showWindow'),
|
||||
click: () => {
|
||||
if (this.mainWindow) {
|
||||
this.mainWindow.show();
|
||||
@@ -216,7 +221,7 @@ export class TrayManager {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '新建会话',
|
||||
label: mt('tray.menu.newSession'),
|
||||
click: () => {
|
||||
if (this.mainWindow) {
|
||||
this.mainWindow.show();
|
||||
@@ -227,7 +232,7 @@ export class TrayManager {
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '退出',
|
||||
label: mt('tray.menu.quit'),
|
||||
click: () => {
|
||||
TrayManager.isQuitting = true;
|
||||
app.quit();
|
||||
|
||||
@@ -108,7 +108,13 @@ export class UpdateService {
|
||||
/** AutoUpdater 句柄(IPC 层消费;null = 未启用/dev 模式/未配置 feed) */
|
||||
export interface AutoUpdaterHandle {
|
||||
checkForUpdates: () => Promise<void>;
|
||||
/** 仅下载更新包(下载完成后广播 downloaded,不自动重启) */
|
||||
downloadAndInstall: () => Promise<void>;
|
||||
/**
|
||||
* v0.8.2 P1-3: 安装已下载的更新并重启应用。
|
||||
* 仅在收到 downloaded 状态后由用户显式确认调用。
|
||||
*/
|
||||
installNow: () => void;
|
||||
}
|
||||
|
||||
let activeHandle: AutoUpdaterHandle | null = null;
|
||||
@@ -173,6 +179,16 @@ export async function startAutoUpdater(
|
||||
try {
|
||||
await autoUpdater.downloadUpdate();
|
||||
onEvent({ status: 'downloaded' });
|
||||
// v0.8.2 P1-3 根治: 不再下载完成立即 quitAndInstall —— 渲染层刚收到
|
||||
// downloaded 事件应用就退出,用户可能丢失未保存内容(写了一半的输入、
|
||||
// 进行中的会话操作)。安装动作拆分为独立的 installNow,由用户在收到
|
||||
// "更新已下载"提示后显式确认触发。
|
||||
} catch (err) {
|
||||
onEvent({ status: 'error', message: (err as Error).message });
|
||||
}
|
||||
},
|
||||
installNow: () => {
|
||||
try {
|
||||
autoUpdater.quitAndInstall();
|
||||
} catch (err) {
|
||||
onEvent({ status: 'error', message: (err as Error).message });
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 窗口管理
|
||||
*/
|
||||
|
||||
import { BrowserWindow, globalShortcut, shell } from 'electron';
|
||||
import { BrowserWindow, globalShortcut, shell, app, screen } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
|
||||
import { is } from '@electron-toolkit/utils';
|
||||
import log from 'electron-log';
|
||||
|
||||
@@ -24,6 +24,9 @@ export interface WindowState {
|
||||
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;
|
||||
@@ -51,7 +54,8 @@ export class WindowManager {
|
||||
beforeLoad?: (win: BrowserWindow) => void;
|
||||
}): BrowserWindow {
|
||||
const id = options.id ?? `window_${Date.now()}`;
|
||||
const state = options.state ?? { width: 1440, height: 900 };
|
||||
// v0.8.2 P3-5: 未显式传 state 时自动读取持久化状态(恢复上次位置/尺寸/最大化)
|
||||
const state = options.state ?? WindowManager.loadWindowState();
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: state.width,
|
||||
@@ -94,8 +98,42 @@ export class WindowManager {
|
||||
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;
|
||||
@@ -209,6 +247,59 @@ export class WindowManager {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取窗口
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user