/** * MetonaAI Desktop — Preload 脚本 * * 通过 contextBridge 安全地将 IPC 通道暴露给渲染进程。 * 渲染进程无 Node.js 访问权限,只能通过此桥接层通信。 */ import { contextBridge, ipcRenderer } from 'electron'; // v0.7.4 P2-1: 移除 @electron-toolkit/preload 的 electronAPI 暴露 —— 其把原始 // ipcRenderer(send/invoke/sendSync/on)整体暴露给渲染进程,等于绕过 metona // 窄化 API 的白名单语义:渲染层一旦被 XSS,攻击者可 invoke 任意 ipcMain.handle // 通道、sendSync 同步调用、订阅任意事件。渲染层实际零使用 window.electron // (grep 确认仅 global.d.ts 有类型占位),移除后全部 IPC 走 metona 白名单。 /** * Metona API — 暴露给渲染进程的安全接口 */ const metonaAPI = { // ===== Agent 交互 ===== agent: { /** 发送用户消息 */ sendMessage: (message: unknown, sessionId: string) => ipcRenderer.invoke('agent:sendMessage', message, sessionId), /** 监听流式事件 */ onStreamEvent: (callback: (event: unknown) => void) => { const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data); ipcRenderer.on('agent:streamEvent', listener); return () => ipcRenderer.removeListener('agent:streamEvent', listener); }, /** 监听状态变化 */ onStateChange: (callback: (state: unknown) => void) => { const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data); ipcRenderer.on('agent:stateChange', listener); return () => ipcRenderer.removeListener('agent:stateChange', listener); }, /** 中断会话 */ abortSession: (sessionId: string) => ipcRenderer.invoke('agent:abortSession', sessionId), /** 监听 Provider 切换通知 */ onProviderSwitched: (callback: (data: unknown) => void) => { const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data); ipcRenderer.on('agent:providerSwitched', listener); return () => ipcRenderer.removeListener('agent:providerSwitched', listener); }, /** v0.5.0: 监听 SubAgent 生命周期事件(委派/运行/完成/失败) */ onSubAgentEvent: (callback: (event: unknown) => void) => { const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data); ipcRenderer.on('subagent:event', listener); return () => ipcRenderer.removeListener('subagent:event', listener); }, }, // ===== 会话管理 ===== sessions: { list: () => ipcRenderer.invoke('sessions:list'), create: (title?: string) => ipcRenderer.invoke('sessions:create', title), rename: (sessionId: string, title: string) => ipcRenderer.invoke('sessions:rename', sessionId, title), delete: (sessionId: string) => ipcRenderer.invoke('sessions:delete', sessionId), getMessages: (sessionId: string) => ipcRenderer.invoke('sessions:getMessages', sessionId), // v0.7.4 P3-10: 更新单条用户消息内容(编辑"仅保存"落库) updateMessageContent: (sessionId: string, messageId: string, content: string) => ipcRenderer.invoke('sessions:updateMessageContent', sessionId, messageId, content), pin: (sessionId: string, pinned: boolean) => ipcRenderer.invoke('sessions:pin', sessionId, pinned), archive: (sessionId: string, archived: boolean) => ipcRenderer.invoke('sessions:archive', sessionId, archived), // v0.7.3 P1-3: sessions:deleteMessage 死通道已删除 —— 渲染层零调用方, // 且其实现不回减 message_count(计数漂移面)。消息删除语义由 // sessions:truncateAfter(编辑重发/重新生成/会话删除级联)完整覆盖。 clearMessages: (sessionId: string) => ipcRenderer.invoke('sessions:clearMessages', sessionId), /** P2-11: 截断消息(编辑重发/重新生成——删除锚点消息之后的所有消息) */ truncateAfter: (sessionId: string, messageId: string, inclusive?: boolean) => ipcRenderer.invoke('sessions:truncateAfter', sessionId, messageId, inclusive), /** v0.5.0: 全文搜索会话内容(FTS5,跨会话,按会话聚合) */ searchContent: (query: string) => ipcRenderer.invoke('sessions:searchContent', query) as Promise<{ success: boolean; error?: string; data?: Array<{ sessionId: string; sessionTitle: string; matchCount: number; lastMessageId: string; lastRole: string; snippet: string; timestamp: number; }>; }>, saveTrace: (sessionId: string, data: unknown) => ipcRenderer.invoke('sessions:saveTrace', sessionId, data), getTrace: (sessionId: string) => ipcRenderer.invoke('sessions:getTrace', sessionId), }, // ===== MCP 管理 ===== mcp: { listServers: () => ipcRenderer.invoke('mcp:listServers'), addServer: (config: unknown) => ipcRenderer.invoke('mcp:addServer', config), removeServer: (name: string) => ipcRenderer.invoke('mcp:removeServer', name), toggleServer: (name: string, enabled: boolean) => ipcRenderer.invoke('mcp:toggleServer', name, enabled), }, // ===== 记忆系统 ===== memory: { search: (query: string, options?: unknown) => ipcRenderer.invoke('db:searchMemories', query, options), listAll: (options?: { type?: string; limit?: number }) => ipcRenderer.invoke('memory:listAll', options), delete: (type: string, id: string) => ipcRenderer.invoke('memory:delete', type, id), }, // ===== v0.2.0: 任务管理 ===== tasks: { list: (sessionId?: string) => ipcRenderer.invoke('tasks:list', sessionId), create: (data: { sessionId: string; title: string; description?: string; priority?: string; parentId?: string; }) => ipcRenderer.invoke('tasks:create', data), update: ( id: string, updates: { title?: string; description?: string; status?: string; priority?: string; assignedTo?: string; }, sessionId: string, ) => ipcRenderer.invoke('tasks:update', id, updates, sessionId), delete: (id: string, sessionId: string) => ipcRenderer.invoke('tasks:delete', id, sessionId), // P2(v0.3.13): 订阅任务变更事件(Agent 通过 task_manager 写入后触发) onTaskChanged: (callback: (sessionId: string) => void) => { const listener = (_event: unknown, sessionId: string): void => callback(sessionId); ipcRenderer.on('task:changed', listener); return () => ipcRenderer.removeListener('task:changed', listener); }, }, // ===== v0.2.0: 审计日志 ===== audit: { verifyChain: () => ipcRenderer.invoke('audit:verifyChain'), query: (filters?: { sessionId?: string; eventType?: string; limit?: number }) => ipcRenderer.invoke('audit:query', filters), /** v0.5.0: 导出审计日志(jsonl / csv 两种格式) */ export: (format: 'jsonl' | 'csv') => ipcRenderer.invoke('audit:export', format) as Promise<{ success: boolean; error?: string; data?: string; format?: string; recordCount?: number; }>, }, // ===== v0.2.0: 工具确认请求 ===== tool: { onConfirmationRequest: (callback: (request: unknown) => void) => { const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data); ipcRenderer.on('tool:confirmationRequest', listener); return () => ipcRenderer.removeListener('tool:confirmationRequest', listener); }, sendConfirmationResponse: (response: { toolCallId: string; approved: boolean; remember: boolean; autoExecute?: boolean; }) => ipcRenderer.send('tool:confirmationResponse', response), // v0.3.2: 批量确认响应(并行工具一次性审批) sendConfirmationResponseBatch: (response: { toolCallIds: string[]; approved: boolean; remember: boolean; autoExecute?: boolean; }) => ipcRenderer.send('tool:confirmationResponseBatch', response), // v0.3.2: 拉取当前所有 pending 确认(前端弹框打开时调用,防止 state 覆盖丢失) getPendingConfirmations: () => ipcRenderer.invoke('tool:getPendingConfirmations') as Promise<{ success: boolean; data: Array<{ toolCallId: string; toolName: string; args: Record; riskLevel: string; reason: string; sessionId?: string; expiresAt?: number; }>; }>, // v0.4.1: 会话内拒绝记忆管理(拒绝记忆 10 分钟 TTL,支持手动恢复询问) // v0.5.0: sessionId 参数 — 拒绝记忆按会话隔离,传当前会话只查看/重置本会话的记忆 getRememberedDenials: (sessionId?: string) => ipcRenderer.invoke('tool:getRememberedDenials', sessionId) as Promise<{ success: boolean; data: Array<{ toolName: string; expiresInSeconds: number }>; }>, resetRememberedDenial: (toolName: string, sessionId?: string) => ipcRenderer.invoke('tool:resetRememberedDenial', toolName, sessionId) as Promise<{ success: boolean; error?: string; }>, // v0.2.0: 持久化自动执行设置 setAutoExecute: (toolName: string, enabled: boolean) => ipcRenderer.invoke('tool:setAutoExecute', toolName, enabled), getAutoExecuteList: () => ipcRenderer.invoke('tool:getAutoExecuteList'), }, // ===== 配置 ===== config: { get: (key: string) => ipcRenderer.invoke('config:get', key), set: (key: string, value: unknown) => ipcRenderer.invoke('config:set', key, value), // v0.3.9: 批量保存配置,避免串行保存中间态触发 reloadAdapter 失败 setBatch: (entries: Array<{ key: string; value: unknown }>) => ipcRenderer.invoke('config:setBatch', entries), // v0.3.17: 监听配置变更广播(后端 config:set/setBatch 后触发,用于前端 store 实时更新) onChanged: (callback: (data: { key: string; value: unknown }) => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { key: string; value: unknown }) => callback(data); ipcRenderer.on('config:changed', listener); return () => ipcRenderer.removeListener('config:changed', listener); }, }, // ===== 应用工具 ===== app: { getVersion: () => ipcRenderer.invoke('app:getVersion'), getAppDataPath: () => ipcRenderer.invoke('app:getAppDataPath'), openExternal: (url: string) => ipcRenderer.invoke('app:openExternal', url), showItemInFolder: (path: string) => ipcRenderer.invoke('app:showItemInFolder', path), selectFolder: (defaultPath?: string) => ipcRenderer.invoke('app:selectFolder', defaultPath), restart: () => ipcRenderer.invoke('app:restart'), // v0.6.4 P4-2: 检查更新(feed 比对式;electron-updater 接入时仅替换实现) updateCheck: () => ipcRenderer.invoke('app:updateCheck') as Promise< | { status: 'disabled' | 'error'; message: string } | { status: 'up-to-date'; latestVersion: string } | { status: 'available'; latestVersion: string; downloadUrl?: string; notes?: string } >, // #49 修复: 前端错误上报通道(主进程可注册 'error:report' handler 记录到 electron-log/审计日志) // 使用 send(单向)而非 invoke,即使主进程未注册 handler 也不会 reject reportError: (payload: unknown) => ipcRenderer.send('error:report', payload), // v0.7.3 P3-4: 健康快照(SLO 指标 + 最近健康检查报告) // 类型以内联形态声明(本文件在 tsconfig.node 域,不引用渲染端 global.d.ts) getHealthSnapshot: () => ipcRenderer.invoke('app:healthSnapshot') as Promise<{ success: boolean; error?: string; data?: { slo: { errorRate: number; throughput: number; avgLatencyMs: number; percentiles: Record; burnRate: number; target: number; violated: boolean; totalRequests: number; errorRequests: number; timestamp: string; }; health: { healthy: boolean; checks: Array<{ name: string; healthy: boolean; latencyMs?: number; error?: string }>; timestamp: string; } | null; generatedAt: number; }; }>, }, // ===== v0.7.3 P3-3: JSONL 录制文件生命周期(设置页展示/清理) ===== logs: { traceStats: () => ipcRenderer.invoke('logs:traceStats') as Promise<{ success: boolean; error?: string; data?: { count: number; totalBytes: number }; }>, pruneTraceFiles: () => ipcRenderer.invoke('logs:pruneTraceFiles') as Promise<{ success: boolean; error?: string; data?: { deleted: number }; }>, }, // ===== 工作空间管理 ===== workspace: { check: (targetPath: string) => ipcRenderer.invoke('workspace:check', targetPath), inheritFiles: (params: { targetPath: string; sourcePath: string; files: string[] }) => ipcRenderer.invoke('workspace:inheritFiles', params), getInfo: () => ipcRenderer.invoke('workspace:getInfo'), // #51 修复: 校验源数据库完整性(PRAGMA integrity_check),用于 inheritDatabase 前置校验 checkDatabaseIntegrity: (sourcePath: string) => ipcRenderer.invoke('workspace:checkDatabaseIntegrity', sourcePath), }, // ===== 工具管理 ===== tools: { list: () => ipcRenderer.invoke('tools:list'), toggle: (toolName: string, enabled: boolean) => ipcRenderer.invoke('tools:toggle', toolName, enabled), /** v0.3.18 修复: 监听工具就绪事件(MCP 初始化完成后触发) */ onReady: (callback: (data: { toolCount: number }) => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { toolCount: number }) => callback(data); ipcRenderer.on('tools:ready', listener); return () => ipcRenderer.removeListener('tools:ready', listener); }, /** v0.3.18 修复: 查询工具当前是否已就绪(解决事件竞态,前端注册监听器后立即查询一次) */ isReady: () => ipcRenderer.invoke('tools:isReady') as Promise<{ ready: boolean; toolCount: number }>, }, // ===== 数据管理 ===== data: { exportData: (sessionId?: string) => ipcRenderer.invoke('data:export', sessionId), clearSessions: () => ipcRenderer.invoke('data:clearSessions'), clearMemories: () => ipcRenderer.invoke('data:clearMemories'), clearAuditLogs: () => ipcRenderer.invoke('data:clearAuditLogs'), }, // ===== SearXNG ===== searxng: { testConnection: (url: string, authKey: string, authType: string) => ipcRenderer.invoke('searxng:testConnection', url, authKey, authType), }, // ===== LLM 运行时查询 ===== llm: { /** v0.5.0: DeepSeek 余额查询(仅 DeepSeek Provider 支持) */ getBalance: () => ipcRenderer.invoke('llm:getBalance') as Promise<{ success: boolean; error?: string; data?: { currency: string; totalBalance: string; grantedBalance: string; toppedUpBalance: string; }; }>, /** * v0.7.2 P3-9: 动态模型列表(基于已保存的 LLM 配置,与引擎 adapter 同源)。 * Ollama 走 /api/tags(含能力探测),DeepSeek/OpenAI 走 /models,其余回退静态列表。 */ listModels: () => ipcRenderer.invoke('llm:listModels') as Promise<{ success: boolean; error?: string; data?: Array<{ id: string; name?: string; contextWindow?: number; maxOutputTokens?: number; supportsToolCalling?: boolean; supportsThinking?: boolean; description?: string; }>; }>, /** * v0.7.2 P3-10: Ollama 模型下载(仅 Ollama Provider 支持)。 * 进度经 onOllamaPullProgress 推送;结束(成功/失败/取消)广播 onOllamaPullEnded。 */ pullModel: (model: string) => ipcRenderer.invoke('llm:ollamaPull', model) as Promise<{ success: boolean; error?: string; /** true 表示任务被用户取消(非故障) */ aborted?: boolean; }>, /** 取消进行中的 Ollama 模型下载 */ cancelPullModel: () => ipcRenderer.invoke('llm:ollamaPullCancel') as Promise<{ success: boolean; error?: string; }>, onOllamaPullProgress: ( callback: (data: { model: string; status: string; completed?: number; total?: number; }) => void, ) => { const listener = ( _event: Electron.IpcRendererEvent, data: { model: string; status: string; completed?: number; total?: number }, ): void => callback(data); ipcRenderer.on('llm:ollamaPullProgress', listener); return () => ipcRenderer.removeListener('llm:ollamaPullProgress', listener); }, onOllamaPullEnded: (callback: (data: { model: string }) => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { model: string }): void => callback(data); ipcRenderer.on('llm:ollamaPullEnded', listener); return () => ipcRenderer.removeListener('llm:ollamaPullEnded', listener); }, }, // ===== Toast 通知桥接 ===== toast: { onShow: (callback: (data: { type: string; message: string; options?: unknown }) => void) => { const listener = ( _event: Electron.IpcRendererEvent, data: { type: string; message: string; options?: unknown }, ) => callback(data); ipcRenderer.on('toast:show', listener); return () => ipcRenderer.removeListener('toast:show', listener); }, }, // ===== 托盘动作(v0.6.4: 死链接线 —— 此前 main.ts/tray 发出的 // 'tray:newSession' 在渲染进程没有任何消费者,菜单项等于摆设)===== tray: { onNewSession: (callback: () => void): (() => void) => { const listener = (): void => callback(); ipcRenderer.on('tray:newSession', listener); return () => ipcRenderer.removeListener('tray:newSession', listener); }, }, }; // ===== 安全暴露到渲染进程 ===== // v0.7.4 P2-1: 只暴露 metona 窄化 API —— 不再暴露原始 electronAPI //(含 ipcRenderer.send/invoke/sendSync),杜绝"白名单语义被绕过"的单点风险 if (process.contextIsolated) { try { contextBridge.exposeInMainWorld('metona', metonaAPI); } catch (error) { console.error('Failed to expose APIs via contextBridge:', error); // #7 修复: 生产环境强制失败,防止以不安全状态启动(contextBridge 失败意味着 // context isolation 边界已破坏,继续运行可能导致渲染进程直接访问 Node API) // 开发环境降级到 globalThis 以便调试 // 审查修复: process.defaultApp 在 sandbox: true 下可能为 undefined, // 导致开发环境也无法降级。补充 process.env.NODE_ENV 判断(sandbox 下 process.env 仍可用) if (process.env.NODE_ENV === 'development' || process.defaultApp) { console.warn('[DEV] Falling back to globalThis assignment — context isolation is weakened'); (globalThis as unknown as Record).metona = metonaAPI; } else { // 审查修复 M19: 在 throw 前记录详细错误,便于在 DevTools 中排查白屏问题。 // 生产环境 contextBridge 失败时用户只看到白屏,console.error 至少能在 DevTools 中留下线索。 console.error('[FATAL] contextBridge failed in production:', error); throw error; } } } else { // context isolation 未启用(不推荐,仅开发环境) (globalThis as unknown as Record).metona = metonaAPI; }