// 安全的 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):
// - 拒绝