基于全量源码审计(electron/ 70+ 文件、src/ 45+ 文件完整读取)的 四阶段迭代,修复 1 项安全缺陷、8 项功能缺陷、依赖与文档系统性脱节。 【P0 安全与数据正确性】 - S-1 数据导出泄露明文密钥:data:export 全量导出直接透传 configService.getAll()(敏感 key 解密返回),导出文件含明文 API Key。 新增 sanitizeExportConfig(shared.ts)逐 key 脱敏 + 5 项回归测试 - F-3 session_summaries 无级联删除:删会话后摘要永久残留。建表语句补 FOREIGN KEY ON DELETE CASCADE + 迁移 8 重建存量表(幂等检测)+ 2 项测试 - F-4 macOS activate 重建窗口后确认弹框失效:窗口创建收敛为 createMainWindow 单一入口(beforeLoad 补 setMainWindow/IPC 注册),TrayManager 补 rebindWindow,app:selectFolder 改 event.sender 动态解析窗口 【P1 死代码激活与功能补全】 - F-1 会话右键菜单挂载:ContextMenu session 分支(重命名/置顶/归档/ 导出/删除)约 200 行此前无任何触发点,Sidebar SessionItem 挂载 onContextMenu;置顶/归档 label 随状态切换显示 - F-2 归档会话不可找回:Sidebar 新增「已归档」折叠面板(恢复入口), 归档功能形成完整闭环 - F-7 Provider 白名单校验:createAdapter/buildFallbackAdapter 未知 provider 显式拒绝(此前静默落入 DeepSeekAdapter 以空配置失败) - F-8 死配置治理:接通 5 项(llm.temperature/llm.maxTokens 注入引擎、 security.promptInjectionDefense 控制 SecurityScanHook+消息检测、 logging.auditEnabled 控制 AuditLogHook、logging.traceEnabled 控制 SessionRecorder,均 fail-secure 仅显式 false 关闭);删除 4 项 无消费者配置(requireWriteConfirmation/maxFileWriteSizeKB/fontSize/ animationMode) 【P2 依赖治理与 UX 修缮】 - D-1 移除僵尸依赖 electron-store/zod/rehype-raw(源码零引用, 共裁 24 包);README 技术栈表同步删除虚假宣称 - metona-toast 升级 0.2.1 → 0.5.0(API 全兼容:107 种图标类型、 配置项超集,default/configure/use 接口不变) - F-5 工作空间手输路径实时落库中间态:改 pendingPath 草稿 + 显式 「校验」按钮(选择文件夹与手输共用 validatePath 流程) - F-6 确认弹框超时滞留:倒计时归零时主动 refreshPending(后端超时 已删条目,拉取后弹框自然消解) - D-3 ChatInput accept 移除 .pdf(分类器不识别,误导性入口); Onboarding 切换 Provider 自动填充默认 URL(与 LLMSettings 一致) 【P3 文档口径收敛】 - README:工具数 30+→28、版本 0.6.0、测试数 252、配置表补 F-8 接通项 - built-in/index.ts 计数注释 30→28 - docs/网络工具 v2 存储键名统一为点号口径(searxng.enabled) - docs/完整设计指南修正 db.handlers.ts 失效路径引用为 data.ts 【验证】 - lint 0 error / 0 warning - typecheck 双工程(node+web)0 错误 - test:electron 24 文件 252 用例全通过(+7 新增:导出脱敏 ×5、 级联删除 ×2) - electron-vite build 成功(metona-toast 0.5.0 chunk 正常) - 系统 Node 模式 npm test 225 通过 + 27 ABI skip(符合预期)
241 lines
6.0 KiB
TypeScript
241 lines
6.0 KiB
TypeScript
/**
|
|
* Tray Manager — 系统托盘管理
|
|
*
|
|
* 职责:
|
|
* 1. 创建系统托盘图标
|
|
* 2. 托盘右键菜单(新建会话、显示窗口、退出)
|
|
* 3. 托盘图标状态指示(空闲/思考中/执行中)
|
|
* 4. 点击托盘图标显示/隐藏窗口
|
|
*
|
|
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 窗口管理
|
|
*/
|
|
|
|
import { Tray, Menu, BrowserWindow, app, nativeImage, Notification } from 'electron';
|
|
import { join } from 'path';
|
|
import { existsSync } from 'fs';
|
|
import log from 'electron-log';
|
|
|
|
export type TrayStatus = 'idle' | 'thinking' | 'executing' | 'error';
|
|
|
|
export class TrayManager {
|
|
private tray: Tray | null = null;
|
|
private mainWindow: BrowserWindow | null = null;
|
|
private currentStatus: TrayStatus = 'idle';
|
|
private static isQuitting = false;
|
|
|
|
constructor(private resourcesPath: string) {}
|
|
|
|
/**
|
|
* 初始化系统托盘
|
|
*/
|
|
initialize(mainWindow: BrowserWindow): void {
|
|
this.mainWindow = mainWindow;
|
|
|
|
// 创建托盘图标
|
|
const iconPath = this.getTrayIconPath();
|
|
if (!iconPath) {
|
|
log.warn('Tray icon not found, skipping tray initialization');
|
|
return;
|
|
}
|
|
|
|
this.tray = new Tray(iconPath);
|
|
this.tray.setToolTip('MetonaAI Desktop');
|
|
|
|
// 构建右键菜单
|
|
this.updateContextMenu();
|
|
|
|
// 点击托盘图标:显示/隐藏窗口
|
|
this.tray.on('click', () => {
|
|
if (this.mainWindow) {
|
|
if (this.mainWindow.isVisible()) {
|
|
this.mainWindow.hide();
|
|
} else {
|
|
this.mainWindow.show();
|
|
this.mainWindow.focus();
|
|
}
|
|
}
|
|
});
|
|
|
|
// 窗口关闭时隐藏到托盘(不退出)
|
|
mainWindow.on('close', (event) => {
|
|
if (!TrayManager.isQuitting) {
|
|
event.preventDefault();
|
|
mainWindow.hide();
|
|
}
|
|
});
|
|
|
|
log.info('System tray initialized');
|
|
}
|
|
|
|
/**
|
|
* 标记应用正在退出(窗口关闭时不阻止)
|
|
*/
|
|
static markQuitting(): void {
|
|
TrayManager.isQuitting = true;
|
|
}
|
|
|
|
/**
|
|
* F-4 修复: 重新绑定主窗口引用(macOS activate / 托盘重建窗口后调用)
|
|
*
|
|
* 旧窗口销毁后 this.mainWindow 指向已 destroyed 实例,托盘点击(显示/隐藏)
|
|
* 与"关闭到托盘"行为全部失效。此方法更新引用并为新窗口重新挂载 close 拦截
|
|
* (原监听随旧窗口销毁自动释放,新窗口需要同样的 hide-to-tray 行为)。
|
|
*/
|
|
rebindWindow(mainWindow: BrowserWindow): void {
|
|
this.mainWindow = mainWindow;
|
|
mainWindow.on('close', (event) => {
|
|
if (!TrayManager.isQuitting) {
|
|
event.preventDefault();
|
|
mainWindow.hide();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 更新托盘状态
|
|
*/
|
|
setStatus(status: TrayStatus): void {
|
|
if (!this.tray) return;
|
|
this.currentStatus = status;
|
|
|
|
const statusLabels: Record<TrayStatus, string> = {
|
|
idle: 'MetonaAI — 空闲',
|
|
thinking: 'MetonaAI — 思考中...',
|
|
executing: 'MetonaAI — 执行中...',
|
|
error: 'MetonaAI — 错误',
|
|
};
|
|
|
|
this.tray.setToolTip(statusLabels[status]);
|
|
this.updateContextMenu();
|
|
|
|
// 更新图标(如果有多状态图标)
|
|
this.updateTrayIcon(status);
|
|
}
|
|
|
|
/**
|
|
* 发送系统通知
|
|
*/
|
|
sendNotification(title: string, body: string, onClick?: () => void): void {
|
|
if (!Notification.isSupported()) return;
|
|
|
|
const notification = new Notification({
|
|
title,
|
|
body,
|
|
icon: this.getTrayIconPath() ?? undefined,
|
|
});
|
|
|
|
if (onClick) {
|
|
notification.on('click', onClick);
|
|
}
|
|
|
|
notification.show();
|
|
}
|
|
|
|
/**
|
|
* 销毁托盘
|
|
*/
|
|
destroy(): void {
|
|
if (this.tray) {
|
|
this.tray.destroy();
|
|
this.tray = null;
|
|
}
|
|
}
|
|
|
|
// ===== 私有方法 =====
|
|
|
|
/**
|
|
* 获取托盘图标路径
|
|
*
|
|
* 优先级:process.resourcesPath(打包后)> resourcesPath 参数(开发模式) > __dirname fallback
|
|
*/
|
|
private getTrayIconPath(): string | null {
|
|
const searchDirs: string[] = [
|
|
join(process.resourcesPath || '', 'app.asar.unpacked', 'assets'),
|
|
join(process.resourcesPath || '', 'assets'),
|
|
this.resourcesPath,
|
|
join(__dirname, '../../assets'),
|
|
];
|
|
|
|
const candidates = ['logo.ico', 'logo.png', 'icon.ico', 'icon.png'];
|
|
|
|
for (const dir of searchDirs) {
|
|
for (const name of candidates) {
|
|
const fullPath = join(dir, name);
|
|
if (existsSync(fullPath)) return fullPath;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 更新托盘图标
|
|
*
|
|
* 始终使用原始 logo 图标(状态已在右键菜单中显示,图标保持品牌识别)。
|
|
*/
|
|
private updateTrayIcon(_status: TrayStatus): void {
|
|
if (!this.tray) return;
|
|
|
|
const basePath = this.getTrayIconPath();
|
|
if (!basePath) return;
|
|
|
|
const image = nativeImage.createFromPath(basePath);
|
|
this.tray.setImage(image.resize({ width: 16, height: 16 }));
|
|
}
|
|
|
|
/**
|
|
* 更新右键菜单
|
|
*/
|
|
private updateContextMenu(): void {
|
|
if (!this.tray) return;
|
|
|
|
const statusIcons: Record<TrayStatus, string> = {
|
|
idle: '⚪',
|
|
thinking: '🔵',
|
|
executing: '🟢',
|
|
error: '🔴',
|
|
};
|
|
|
|
const contextMenu = Menu.buildFromTemplate([
|
|
{
|
|
label: `MetonaAI Desktop`,
|
|
enabled: false,
|
|
},
|
|
{
|
|
label: `状态: ${statusIcons[this.currentStatus]} ${this.currentStatus}`,
|
|
enabled: false,
|
|
},
|
|
{ type: 'separator' },
|
|
{
|
|
label: '显示窗口',
|
|
click: () => {
|
|
if (this.mainWindow) {
|
|
this.mainWindow.show();
|
|
this.mainWindow.focus();
|
|
}
|
|
},
|
|
},
|
|
{
|
|
label: '新建会话',
|
|
click: () => {
|
|
if (this.mainWindow) {
|
|
this.mainWindow.show();
|
|
this.mainWindow.focus();
|
|
this.mainWindow.webContents.send('tray:newSession');
|
|
}
|
|
},
|
|
},
|
|
{ type: 'separator' },
|
|
{
|
|
label: '退出',
|
|
click: () => {
|
|
TrayManager.isQuitting = true;
|
|
app.quit();
|
|
},
|
|
},
|
|
]);
|
|
|
|
this.tray.setContextMenu(contextMenu);
|
|
}
|
|
}
|