基于全量源码审计(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(符合预期)
232 lines
8.5 KiB
TypeScript
232 lines
8.5 KiB
TypeScript
/**
|
|
* IPC App Handlers — 应用工具域(P2-9 从 handlers.ts 拆分)
|
|
*
|
|
* 版本/路径查询、外部链接、文件夹选择、重启、SearXNG 连接测试、
|
|
* 审计日志链验证/查询、渲染进程错误上报(P0-3 修复断链)。
|
|
*/
|
|
|
|
import { ipcMain, shell, app, dialog, BrowserWindow } from 'electron';
|
|
import type { IPCContext } from './context';
|
|
import type { AuditEventType } from '../services/audit.service';
|
|
import log from 'electron-log';
|
|
|
|
export function registerAppHandlers(ctx: IPCContext): void {
|
|
const { mainWindow, auditService } = ctx;
|
|
|
|
ipcMain.handle('app:getVersion', async () => {
|
|
return app.getVersion();
|
|
});
|
|
|
|
ipcMain.handle('app:getAppDataPath', async () => {
|
|
return app.getPath('userData');
|
|
});
|
|
|
|
ipcMain.handle('app:openExternal', async (_event, url: unknown) => {
|
|
// M-12 修复: URL 协议白名单校验,防止打开 file:///smb:// 等危险协议
|
|
if (typeof url !== 'string' || !url) {
|
|
return { success: false, error: 'Invalid URL' };
|
|
}
|
|
try {
|
|
const parsed = new URL(url);
|
|
const ALLOWED_PROTOCOLS = ['http:', 'https:', 'mailto:'];
|
|
if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) {
|
|
log.warn(`[IPC] openExternal blocked: protocol "${parsed.protocol}" not in whitelist`);
|
|
return { success: false, error: `Protocol not allowed: ${parsed.protocol}` };
|
|
}
|
|
await shell.openExternal(url);
|
|
return { success: true };
|
|
} catch (error) {
|
|
log.warn('[IPC] openExternal failed:', (error as Error).message);
|
|
return { success: false, error: (error as Error).message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('app:showItemInFolder', async (_event, path: unknown) => {
|
|
// M-36 修复: path 类型校验
|
|
if (typeof path !== 'string' || !path) {
|
|
return { success: false, error: 'Invalid path' };
|
|
}
|
|
try {
|
|
shell.showItemInFolder(path);
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: (error as Error).message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('app:selectFolder', async (event, defaultPath?: string) => {
|
|
// F-4 补充: 从 event.sender 动态解析窗口 —— ctx.mainWindow 是注册时捕获的引用,
|
|
// macOS activate 重建窗口后为已销毁实例(dialog 挂 destroyed 窗口行为未定义)。
|
|
// fromWebContents 对任何存活窗口(含未来多窗口)都正确,解析失败回退无父对话框。
|
|
const callerWin = BrowserWindow.fromWebContents(event.sender) ?? mainWindow;
|
|
const result = await dialog.showOpenDialog(callerWin, {
|
|
properties: ['openDirectory', 'createDirectory'],
|
|
defaultPath: defaultPath ?? app.getPath('home'),
|
|
title: '选择工作空间目录',
|
|
});
|
|
if (result.canceled || result.filePaths.length === 0) return { canceled: true, path: '' };
|
|
return { canceled: false, path: result.filePaths[0] };
|
|
});
|
|
|
|
// 重启应用(工作空间切换后调用)
|
|
ipcMain.handle('app:restart', async () => {
|
|
log.info('[APP] Restart requested');
|
|
// 延迟 200ms 让 IPC 响应先返回
|
|
setTimeout(() => {
|
|
app.relaunch();
|
|
app.exit(0);
|
|
}, 200);
|
|
return { success: true };
|
|
});
|
|
|
|
// ===== P0-3 修复: 渲染进程错误上报通道(原 preload 发送端存在但主进程无 handler,上报被静默丢弃) =====
|
|
ipcMain.on('error:report', (_event, payload: unknown) => {
|
|
try {
|
|
if (!payload || typeof payload !== 'object') return;
|
|
const p = payload as Record<string, unknown>;
|
|
// 截断超长字段(stack 可达数十 KB),防止日志膨胀
|
|
const truncateStr = (v: unknown, max = 4000): unknown =>
|
|
typeof v === 'string' ? v.slice(0, max) : v;
|
|
const details = {
|
|
type: truncateStr(p.type, 100),
|
|
error: truncateStr(p.error, 1000),
|
|
stack: truncateStr(p.stack),
|
|
componentStack: truncateStr(p.componentStack, 4000),
|
|
timestamp: p.timestamp,
|
|
};
|
|
log.error('[Renderer Error]', JSON.stringify(details));
|
|
// 同步写入审计日志(TOOL 层),便于事后追溯
|
|
auditService.log({
|
|
sessionId: '',
|
|
eventType: 'error',
|
|
actor: 'user',
|
|
target: 'renderer',
|
|
details,
|
|
outcome: 'error',
|
|
});
|
|
} catch (err) {
|
|
// 错误上报自身失败仅记录,不抛出(单向通道无响应方)
|
|
log.warn('[IPC] error:report handler failed:', err);
|
|
}
|
|
});
|
|
|
|
// ===== SearXNG 连接测试 =====
|
|
|
|
ipcMain.handle(
|
|
'searxng:testConnection',
|
|
async (_event, url: string, authKey: string, authType: string) => {
|
|
try {
|
|
if (!url || !/^https?:\/\//.test(url)) {
|
|
return { success: false, error: 'URL 需以 http:// 或 https:// 开头' };
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
const headers: Record<string, string> = {};
|
|
|
|
if (authKey) {
|
|
if (authType === 'bearer') {
|
|
headers['Authorization'] = `Bearer ${authKey}`;
|
|
} else if (authType === 'basic') {
|
|
headers['Authorization'] = `Basic ${Buffer.from(authKey).toString('base64')}`;
|
|
}
|
|
}
|
|
|
|
const testUrl = `${url.replace(/\/$/, '')}/search?q=test&format=json&pageno=1`;
|
|
const response = await fetch(testUrl, {
|
|
headers,
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
|
|
const latencyMs = Date.now() - startTime;
|
|
|
|
if (response.ok) {
|
|
return { success: true, statusCode: response.status, latencyMs };
|
|
}
|
|
return {
|
|
success: false,
|
|
statusCode: response.status,
|
|
latencyMs,
|
|
error: `HTTP ${response.status} ${response.statusText}`,
|
|
};
|
|
} catch (error) {
|
|
return { success: false, error: (error as Error).message };
|
|
}
|
|
},
|
|
);
|
|
|
|
// ===== v0.2.0: 审计日志链式哈希验证 =====
|
|
|
|
ipcMain.handle('audit:verifyChain', async () => {
|
|
try {
|
|
const result = auditService.verifyChain();
|
|
log.info(
|
|
`[AUDIT] Chain verification: ${result.valid ? 'valid' : 'TAMPERED'} (${result.verifiedRecords}/${result.totalRecords})`,
|
|
);
|
|
return { success: true, ...result };
|
|
} catch (error) {
|
|
log.error('[AUDIT] Chain verification failed:', error);
|
|
return { success: false, error: (error as Error).message };
|
|
}
|
|
});
|
|
|
|
// v0.5.0: 审计日志导出(JSONL / CSV 两种格式)
|
|
ipcMain.handle('audit:export', async (_event, format: unknown) => {
|
|
const fmt = format === 'csv' ? 'csv' : 'jsonl';
|
|
try {
|
|
const content = fmt === 'csv' ? auditService.exportCSV() : auditService.exportJSONL();
|
|
const recordCount = content.trim()
|
|
? fmt === 'csv'
|
|
? content.trim().split('\n').length - 1
|
|
: content.trim().split('\n').length
|
|
: 0;
|
|
log.info(`[AUDIT] Exported ${recordCount} records as ${fmt.toUpperCase()}`);
|
|
return { success: true, data: content, format: fmt, recordCount };
|
|
} catch (error) {
|
|
log.error('[AUDIT] Export failed:', error);
|
|
return { success: false, error: (error as Error).message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle('audit:query', async (_event, filters?: unknown) => {
|
|
// M-45 修复: 校验 filters 参数类型和范围
|
|
const VALID_AUDIT_EVENT_TYPES: readonly AuditEventType[] = [
|
|
'tool_call',
|
|
'permission_check',
|
|
'error',
|
|
'llm_request',
|
|
'llm_response',
|
|
'session_start',
|
|
'session_end',
|
|
'config_change',
|
|
];
|
|
const safeFilters: { sessionId?: string; eventType?: AuditEventType; limit?: number } = {};
|
|
if (filters && typeof filters === 'object') {
|
|
const f = filters as Record<string, unknown>;
|
|
if (typeof f.sessionId === 'string' && f.sessionId) safeFilters.sessionId = f.sessionId;
|
|
if (typeof f.eventType === 'string' && f.eventType) {
|
|
// 校验 eventType 是否在合法枚举内
|
|
if (!(VALID_AUDIT_EVENT_TYPES as readonly string[]).includes(f.eventType)) {
|
|
return {
|
|
success: false,
|
|
error: `Invalid eventType (must be one of: ${VALID_AUDIT_EVENT_TYPES.join(', ')})`,
|
|
};
|
|
}
|
|
safeFilters.eventType = f.eventType as AuditEventType;
|
|
}
|
|
if (f.limit !== undefined) {
|
|
const limit = Number(f.limit);
|
|
// 限制 1-1000 范围,防止过大查询拖慢性能
|
|
if (!Number.isFinite(limit) || limit < 1 || limit > 1000) {
|
|
return { success: false, error: 'Invalid limit (must be 1-1000)' };
|
|
}
|
|
safeFilters.limit = Math.floor(limit);
|
|
}
|
|
}
|
|
try {
|
|
return { success: true, data: auditService.query(safeFilters) };
|
|
} catch (error) {
|
|
return { success: false, error: (error as Error).message };
|
|
}
|
|
});
|
|
}
|