// 安全的 IPC 桥 // 通过 contextBridge 暴露最小化的 API 给渲染进程 // // marked 和 DOMPurify 是从主进程 require 进来再暴露给渲染端使用。 // 这是因为 Electron 渲染进程在 contextIsolation 下无法直接用 `import 'marked'` // 这种 bare specifier(需要打包工具 + import map),而我们坚持零打包。 // // 关于 DOMPurify 初始化: // DOMPurify 需要一个 window/document 才能工作。Preload 脚本运行在隔离的 // JS context 中(contextIsolation),但同一进程里的 window 对象已经存在。 // 我们在 preload 中用 `createDOMPurify(window)` 初始化,它返回的实例可以 // 把 sanitize 后的 HTML 字符串通过 contextBridge 暴露给渲染端 —— // 返回的是字符串而非 DOM 节点,跨 contextBridge 没问题。 // // IPC 通道配对由 scripts/check-ipc.js 自动审计(`npm run check`)。 // 新增 API 前先在 main.js 写好 ipcMain.handle / ipcMain.on / webContents.send, // 再回到 preload 加 wrapper;脚本会校验通道双向配对。 const { contextBridge, ipcRenderer, nativeImage } = require('electron'); const { marked } = require('marked'); const nodePath = require('path'); const nodeFs = require('fs'); // 把 icon.ico 在 preload 启动时一次性转成 PNG data URL,渲染端作为 用。 // nativeImage 直接读 .ico 会拿到 Windows 编码, 不一定能渲染;toDataURL() // 输出标准 PNG data URL,浏览器 100% 兼容。文件路径在 build.files 里已经包含, // 打包后路径仍指向资源目录。读不到 → 返回 null,渲染端继续走 .app-title::before 占位。 let APP_ICON_DATA_URL = null; try { const iconPath = nodePath.join(__dirname, 'icon.ico'); if (nodeFs.existsSync(iconPath)) { const img = nativeImage.createFromPath(iconPath); if (img && !img.isEmpty()) { APP_ICON_DATA_URL = img.toDataURL(); } } } catch { // nativeImage 在某些 headless 测试 / 非 Electron 上下文里会失败;吞掉走 null 路径 } const createDOMPurify = require('dompurify'); // 设置 schema(单一事实源)。preload 是 CJS,可以直接 require。 // // renderer 不能自己 import 这个文件 —— 它是 CommonJS(module.exports),而 // renderer 跑在 Chromium 里(nodeIntegration:false),浏览器原生 ESM 没有任何 // CommonJS 互操作:改扩展名(.cjs → .js)只能让 ESM loader 不因 MIME 拒绝它, // 模块本身依然一个具名导出都没有,import 会在链接阶段直接抛 // "does not provide an export named 'DEFAULT_SETTINGS'" 并让整个应用起不来。 // 所以 schema 必须和其他主进程能力一样,经 contextBridge 过桥。 const settingsSchema = require('./shared/settings-schema.js'); // Markdown → 安全 HTML 的核心规则(ALLOWED_URI_REGEXP + 钩子)。与 tests 共用同一份, // 防止规则在「实现」与「测试」之间漂移。 const { ALLOWED_URI_REGEXP, installHooks } = require('./shared/render-sanitize.js'); // 文件扩展名白名单(EDITABLE_EXTS / MARKDOWN_EXTS)—— main + renderer 共用一份, // 防止「侧栏显示可编辑但 md 链接打不开」或反之的体验割裂。renderer 经 contextBridge // 拿数组(contextBridge 结构化克隆对 Set/Map 的支持视 Electron 版本而定,数组 // 是 100% 可靠的形态,渲染端直接 Array.includes 即可)。 const { EDITABLE_EXTS, MARKDOWN_EXTS } = require('./shared/extension-lists.js'); const EDITABLE_EXTS_LIST = Array.from(EDITABLE_EXTS); const MARKDOWN_EXTS_LIST = Array.from(MARKDOWN_EXTS); // Heading slug 算法(preload + 锚点滚动共用同一份,否则点击文内锚点会定位失败)。 // 实现见 shared/slug.js;renderer 端通过 window.api.slugifyHeadingBase 调用同一份, // 不再源码级镜像,避免两边正则漂移(audit fix 3.1)。 const { slugifyHeading: sharedSlugify, slugifyHeadingBase: sharedSlugifyBase } = require('./shared/slug.js'); // AI 修改用的行级 + 词级 diff 算法。renderer 是 ESM + Chromium 原生,不能 // 直接 require CJS,所以经 contextBridge 暴露成纯函数(返回纯对象,结构化克隆 OK)。 const markdownDiff = require('./shared/markdown-diff.js'); // AI 错误码字面量(main / renderer 共享)—— renderer 是 ESM 不能直接 require CJS, // 所以经 contextBridge 把整个 AI_ERROR 对象暴露到 window.api.aiErrors, // renderer 在 src/ai/ai-status.js 顶层直接 window.api?.aiErrors?.AI_ERROR 取值。 const { AI_ERROR: sharedAiErrors } = require('./shared/ai-errors.js'); // errno → 中文提示(main + renderer 共享)—— 同一理由经 contextBridge 过桥, // 详见 shared/friendly-fs-error.js 注释。renderer 在 src/app.js / src/file-ops.js // 顶层取 window.api.friendlyFsError 调用,不再各自维护 mapping(避免 EROFS / // ENAMETOOLONG / ENOTDIR / ENOTEMPTY 三处文案漂移)。 const { friendlyFsError: sharedFriendlyFsError } = require('./shared/friendly-fs-error.js'); // 兜底 beforeunload 清理:单条 IPC 通道一个 helper,pagehide 未跑时再拆 listener。 // // 【必须在模块顶层定义,不能放成 contextBridge 对象的方法】 // contextBridge 暴露的 on* wrapper 都是箭头函数,箭头函数没有自己的 this, // 它们的 this 来自词法作用域 = 模块顶层(Node CJS 里是 module.exports, // 不是 contextBridge 的 API 对象)。如果 cleanupOn 是 API 对象的方法, // 下面的 onXxx 调 this.cleanupOn(...) 会拿不到函数,渲染端 bootstrap 在 // 第一个 subscribeIpc 调用就会抛「this.cleanupOn is not a function」直接挂掉。 // 抽到模块顶层用普通函数声明,下面 on* 直接 cleanupOn(...) 调用。 // // renderer 端 subscribeIpc 仍然有 pagehide 主清理路径,这里只是 pagehide // 顺序漂移时的兜底。 function cleanupOn(channel, handler) { const beforeUnload = () => { try { ipcRenderer.removeListener(channel, handler); } catch { /* ignore */ } try { window.removeEventListener('beforeunload', beforeUnload); } catch { /* ignore */ } }; try { window.addEventListener('beforeunload', beforeUnload, { once: true }); } catch { /* ignore */ } return () => { try { ipcRenderer.removeListener(channel, handler); } catch { /* ignore */ } try { window.removeEventListener('beforeunload', beforeUnload); } catch { /* ignore */ } }; } // 配置 marked —— 在 preload 一次性完成 const mdRenderer = new marked.Renderer(); const baseLink = mdRenderer.link.bind(mdRenderer); mdRenderer.link = (href, title, text) => { const html = baseLink(href, title, text); return html.replace(/^ `${text}\n`; marked.setOptions({ gfm: true, breaks: false, renderer: mdRenderer, }); // 拿到工厂函数(兼容 ESM/CJS 互操作差异) const DOMPurifyFactory = (typeof createDOMPurify === 'function') ? createDOMPurify : (createDOMPurify && typeof createDOMPurify.default === 'function') ? createDOMPurify.default : null; // DOMPurify 实例(绑定到 preload 的 window) let DOMPurifyInstance = null; if (DOMPurifyFactory && typeof window !== 'undefined') { try { DOMPurifyInstance = DOMPurifyFactory(window); } catch (e) { console.error('[preload] DOMPurify 初始化失败:', e); } } else if (!DOMPurifyFactory) { console.error('[preload] createDOMPurify 不可用'); } if (!DOMPurifyInstance) { console.warn('[preload] DOMPurify 未初始化;renderMarkdown 将跳过 XSS 清洗'); } // 允许的 URI 协议与 DOMPurify 钩子见 shared/render-sanitize.js。 // 顶部 require 已引入 ALLOWED_URI_REGEXP 与 installHooks;钩子在拿到 DOMPurify // 实例后立即注册。 installHooks(DOMPurifyInstance); contextBridge.exposeInMainWorld('api', { /** * 设置 schema —— 纯数据,供 renderer 同步读取。 * * preload 在 renderer 模块求值之前就跑完了,所以 settings-store.js / * settings-dialog.js 可以在模块顶层直接取 window.api.settingsSchema。 * contextBridge 会做结构化克隆,renderer 拿到的是只读副本。 */ settingsSchema: { DEFAULT_SETTINGS: settingsSchema.DEFAULT_SETTINGS, SETTINGS_UI_OPTIONS: settingsSchema.SETTINGS_UI_OPTIONS, }, /** * AI 错误码表 —— 与 main/ai.js 同一份字面量(shared/ai-errors.js), * 经 contextBridge 过桥后 renderer 端不会与主进程漂移。纯数据对象。 */ aiErrors: { AI_ERROR: sharedAiErrors, }, /** * errno → 中文提示(Round 4 收尾:合并三处独立 mapping)。 * 与 main/file-ops.js#friendlyWriteError 同一份事实源(shared/friendly-fs-error.js), * renderer 端友好提示文案不再与主进程漂移。 * * @param {string|null|undefined} code - errno 或业务码 * @param {string|undefined} fallback - 未知 code 时的回退文案 * @returns {string} */ friendlyFsError: (code, fallback) => sharedFriendlyFsError(code, fallback), /** * 用 schema 把任意对象归一成完整 settings(补默认值 + 丢未知键)。 * @param {*} raw * @returns {Record} */ coerceLoadedSettings: (raw) => settingsSchema.coerceLoadedSettings(raw), /** * Markdown → 安全 HTML * @param {string} markdown * @returns {string} */ renderMarkdown: (markdown) => { if (typeof markdown !== 'string' || !markdown) return ''; try { // 同名标题的去重后缀按文档计数,每次 parse 前重置 headingSlugs.clear(); const rawHtml = marked.parse(markdown); // 【fail-closed】DOMPurify 不可用 → 返回转义后的纯文本,绝不返回未清洗 HTML // 否则 attacker 写入 "" 就能 // 借 viewer.innerHTML 拿到 privileged API 调用权(preload 通过 contextBridge // 暴露的 api 在 renderer 同源策略下被认为是同源可执行)。 if (!DOMPurifyInstance) { const escaped = String(rawHtml) .replace(/&/g, '&') .replace(//g, '>'); return `
${escaped}
`; } return DOMPurifyInstance.sanitize(rawHtml, { ADD_ATTR: ['target', 'rel', 'id'], ALLOWED_URI_REGEXP, // uponSanitizeAttribute 钩子在文件顶部通过 addHook 全局注册 // (v3 不再支持 sanitize 配置传 hook),按 tag 白名单收口。 // audit fix (Round 13 / Sec-M): // - 拒绝 / / 显示在自定义 * 标题栏左侧。文件缺失 / nativeImage 加载失败时返回 null,渲染端继续用 * .app-title::before 占位(保持现有视觉)。 * @returns {string|null} */ getAppIconDataUrl: () => APP_ICON_DATA_URL, });