This commit is contained in:
2026-09-12 14:15:26 +08:00
commit 9c06d3f4be
99 changed files with 41853 additions and 0 deletions

42
shared/ai-errors.js Normal file
View File

@@ -0,0 +1,42 @@
// AI 错误码 —— 主进程与 renderer 端共享的字符串字面值。
//
// audit fix (CQ-MED-7):之前错误码字面量分散在 main/ai.js#ERR_* 与
// src/ai/ai-status.js#AI_ERROR 两处,靠注释提醒同步 —— 实际曾漂移renderer 旧版
// 把 'AI_TIMEOUT' 写成 'ERR_TIMEOUT',导致 ai-controller 收到 main 返回的错误码
// 后 aiErrorMessage switch 永不命中、永远走 fallback 'AI 修改失败')。
// 现在所有跨进程错误码集中在本文件main / preload / renderer 三处共用同一份字面值,
// 任何一边改了另一边不会失配。
//
// 加载:
// - main/ai.js: const { AI_ERROR } = require('../shared/ai-errors.js');
// - preload.js: contextBridge.exposeInMainWorld('api', { aiErrors: { AI_ERROR } })
// - renderer: const { AI_ERROR } = window.api.aiErrors;
// fallback 见 src/ai/ai-status.jsjsdom 单测环境走本地副本)
//
// 命名规则AI_ERROR.* 的 value 是字符串字面值;调用方比较时用 AI_ERROR.FOO 引用,
// 永远不要直接引用字符串字面量。
'use strict';
const AI_ERROR = Object.freeze({
// ---- 跨进程main ↔ renderer----
// 主进程 ai.js 抛出 + renderer 端 controller 检测本地未配置时同样抛出。
NOT_CONFIGURED: 'AI_NOT_CONFIGURED',
// fetch 超时AbortError + timeout 信号)
ERR_TIMEOUT: 'AI_TIMEOUT',
// HTTP 4xx/5xx 或非 2xx 响应
ERR_PROVIDER: 'AI_PROVIDER_ERROR',
// 响应体 JSON 解析失败 / 不符合 OpenAI/Anthropic 协议
ERR_FORMAT: 'AI_BAD_RESPONSE',
// 用户主动取消renderer abort signal / controller.cancelled 标志)
ERR_CANCELLED: 'AI_CANCELLED',
// ---- renderer 端辅助判定main 不会产生这些值,但放一起便于集中引用)----
NO_FILE: 'NO_FILE',
EDITOR_UNAVAILABLE: 'EDITOR_UNAVAILABLE',
IPC_FAILED: 'IPC_FAILED',
GENERATION_STALE: 'GENERATION_STALE',
FILE_CHANGED: 'FILE_CHANGED',
});
module.exports = { AI_ERROR };

111
shared/extension-lists.js Normal file
View File

@@ -0,0 +1,111 @@
// 文件扩展名白名单main / preload / renderer 三处共用)
//
// ============================================================================
//
// 之前一批可编辑扩展名同时维护在两处(具体数量已不准):
// - main/file-ops.js 的 EDITABLE_EXTS SetclassifyEntry 入口决定文件
// entryType关系到 sidebar 展示 + 打开提示)
// - src/app.js 的 LOCAL_FILE_EXTS 数组resolveLocalFile 判断 md 内
// 链接是否可以跳转到该文件)
//
// 两边列表一旦漂移,「侧栏显示可编辑 / 点击却提示不支持」或「侧栏灰掉
// 但 md 里能点开」的体验割裂就会出现。本文件是单一事实源:
//
// • EDITABLE_EXTS — 可打开编辑的文件扩展名(小写、不含点)
// • MARKDOWN_EXTS — 支持 Markdown 渲染预览的扩展名EDITABLE_EXTS 子集)
//
// 加载方式:
// • main / preloadCJSconst exts = require('../shared/extension-lists.js')
// • renderer经 preload contextBridge 过桥window.api.EDITABLE_EXTS
// 因为 renderer 是 ESM + Chromium 原生,不能直接 import CJS。
// preload 内对 Set 调 Array.from 后再 exposeInMainWorld所以 renderer 端
// 拿到的是 Array不是 Set用 .includes / Array.isArray 判断即可。
//
// 修改流程只改本文件一处main + renderer 两侧自动同步。
// ============================================================================
'use strict';
/**
* 可打开编辑的文件扩展名白名单(小写、不含点)。
*
* 包含 Markdown + 常见纯文本 + 常见编程语言;其它扩展名一律视为 binary
* (侧栏仍展示但灰掉、点击提示"不支持的文件类型")。
*
* 范围决策2026-08-25 反馈):覆盖 Web 脚本 + 主流语言,避免「写笔记
* 时顺手维护个 .py / .sql 也打不开」的体验割裂。编辑器是 CodeMirror
* Markdown 6没有专门的语法高亮import map 里只配了 markdown /
* javascript / css / html所以 .py / .go / .rs 等虽然可编辑但会
* 是纯文本色 —— 后续要做 syntax highlighting 再补 lang-* 包。
*
* 列表约定:
* - 按「用途族」分组Markdown / 数据 / 配置 / 文档 / Python /
* JS-TS / Web 样式 / 组件框架 / JVM / Go / Rust / Ruby / PHP /
* Shell / Windows / SQL / C-C++ / C# / Swift / Scala / Lua /
* Perl / R / Dart
* - 排序方便 review 时一眼看清覆盖了哪些,没别的语义
*/
const EDITABLE_EXTS = new Set([
// Markdown
'md', 'markdown',
// 常见纯文本
'txt', 'text', 'log',
// 表格 / 结构化数据
'csv', 'tsv', 'json', 'xml', 'yaml', 'yml', 'toml',
// 配置 / 环境
'ini', 'cfg', 'conf', 'env',
'gitconfig', // 隐藏文件 .gitconfig 走 lastIndexOf('.') === 0 路径,需在白名单才能 editable
// 文档 / 排版
'rst', 'tex',
// Python
'py', 'pyi', 'pyw',
// JavaScript / TypeScriptjsx/tsx 让 React 用户也能直接编辑)
'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx',
// WebHTML / CSS 衍生
'html', 'htm', 'css', 'scss', 'sass', 'less',
// 组件框架
'vue', 'svelte',
// JVM 系
'java', 'kt', 'kts',
// Go
'go',
// Rust
'rs',
// Ruby
'rb',
// PHP
'php',
// Shell 系POSIX 主流)
'sh', 'bash', 'zsh',
// Windows 脚本
'ps1',
// SQL
'sql',
// C / C++
'c', 'h', 'cpp', 'hpp', 'cc', 'cxx',
// C#
'cs',
// Swift
'swift',
// Scala
'scala',
// Lua
'lua',
// Perl
'pl',
// R
'r',
// Dart
'dart',
]);
/**
* 支持 Markdown 渲染预览的扩展名EDITABLE_EXTS 的子集)。
*
* 仅 .md / .markdown 走 viewer 预览,其它可编辑文件(.txt/.py/.json/...
* 在 viewer 显示「不支持预览」空态,但仍可在编辑器里修改。
*/
const MARKDOWN_EXTS = new Set(['md', 'markdown']);
module.exports = { EDITABLE_EXTS, MARKDOWN_EXTS };

View File

@@ -0,0 +1,77 @@
// 文件系统 errno → 用户能看懂的提示main + renderer 共享)
// ============================================================================
//
// 单一事实源。解决 Round 3 之前的三份独立实现漂移:
// - main/file-ops.js#friendlyWriteError(e) —— 主进程内部,签名 e.code
// - src/app.js#friendlyWriteError(code, fallback) —— renderer save 路径
// - src/file-ops.js#friendlyFsError(code, fallback) —— renderer 文件 CRUD
//
// 三份在 EROFS / ENAMETOOLONG / ENOTDIR / ENOTEMPTY 上文案不同 —— 用户看到
// 不一致提示。每加一个 errno 都要同步改三处,每处都有人漏改。
//
// 修法Round 4 收尾):本文件 CJS export 函数 friendlyFsError(code, fallback)
// - main/file-ops.js 用 require('./shared/friendly-fs-error.js')
// 调用时友好FsError(e?.code, e?.message || '写入文件失败')
// - preload 用 require + contextBridge 暴露 window.api.friendlyFsError
// renderer 端走 window.api.friendlyFsError(...) 拿到同一份文案
//
// 加载方式:
// - main.js / main/file-ops.js / preload.js (CJS)const { friendlyFsError } = require('...')
// - renderer经 preload contextBridge 过桥window.api.friendlyFsError
// —— renderer 跑在 Chromium 原生 ESM不能直接 import CJS
//
// 【fallback 行为】
// - 已知 errno返回固定中文文案与 fileOps.friendlyFsError 测试矩阵对齐)
// - 未知 errno返回 fallback业务码如 PATH_NOT_ALLOWED / SYMLINK_NOT_ALLOWED /
// FILE_TOO_LARGE / FILE_CHANGED_EXTERNALLY 等不是 fs errno由 IPC 调用方
// 把 result.message 中文文案塞进 fallback
// - fallback 为空串 / undefined走兜底「未知错误」避免空 toast
// ============================================================================
'use strict';
/**
* 把后端返回的 errno 翻译成中文用户提示。
*
* @param {string|null|undefined} code - 原始 errnoEACCES / EPERM / ENOSPC ...
* 或业务码字符串PATH_NOT_ALLOWED 等),业务码一律走 fallback
* @param {string|undefined} fallback - errno 未匹配时使用的回退文案
* (主进程一般传 e?.messagerenderer 一般传 IPC result.message / result.error
* @returns {string} 中文提示(永不为空 —— 兜底走「未知错误」)
*/
function friendlyFsError(code, fallback) {
switch (code) {
case 'EACCES':
case 'EPERM':
return '文件被占用或没有写入权限(可能是只读文件 / 另一进程独占 / 权限不足)';
case 'ENOSPC':
return '磁盘空间不足';
case 'EROFS':
return '只读文件系统,无法写入';
case 'EIO':
return '磁盘 I/O 错误';
case 'EBUSY':
return '文件被其他程序占用';
case 'ENAMETOOLONG':
return '路径过长';
case 'ENOTDIR':
return '父目录不是目录';
case 'EISDIR':
return '目标路径是文件夹,无法写入';
case 'ENOTEMPTY':
return '目标文件夹不为空';
// audit fix (Settings P3 / ENOENT mapping):原本 ENOENT 没在 mapping 里,
// 走 default → fallback 兜底成「未知错误」。ENOENT 是最常见的 fs errno
// 之一rename 源文件已删 / delete 已被外部删 / 路径打错 / watch 到一半
// 文件被替换),用户看到一个空泛"未知错误"会以为 Notes 出 bug。补上。
// 措辞区分「文件不存在」与「路径里某一级目录不存在」也覆盖 ENOTDIR
// 已有的 case —— ENOENT 统一按"目标路径不存在"处理(精确到「文件还是
// 目录」要 main 端自己抛业务码,不该让 errno mapping 揣测)。
case 'ENOENT':
return '文件或目录不存在(可能已被移动、重命名或删除)';
default:
return fallback || '未知错误';
}
}
module.exports = { friendlyFsError };

604
shared/markdown-diff.js Normal file
View File

@@ -0,0 +1,604 @@
// 行级 + 词级 Markdown diff —— 用于 AI 修改预览
// ============================================================================
//
// 从参考项目 markdown.guanjihuan.com 的 src/lib/markdownDiff.ts 移植为 CJS
// 以便在 main / preload / renderer / 单元测试之间共用同一份算法。
//
// 核心思路:
// 1. 行级 LCS diff动态规划O(n*m) 但有上限保护)找到 add/remove/context 序列
// 2. 把连续的 add/remove 序列打包成"region"(一个修改块)
// 3. region 内部做词级 diff同上tokenize 后 LCS
// 4. applyDiffRegionSafely 用 region 上下文(前后各 2 行)安全定位并替换
//
// 关键保护:
// - MAX_LINE_DIFF_CELLS / MAX_TOKEN_DIFF_CELLS / MAX_TOKEN_DIFF_CHARS
// 防止大文档(>1MB触发指数级内存 / CPU 爆炸
// - endsWithNewline 跟踪:拼接回去时保留尾换行,避免与原文件 diff
'use strict';
/**
* @typedef {Object} DiffSegment
* @property {'equal'|'removed'|'added'} type
* @property {string} text
*/
/**
* @typedef {Object} FullDiffRow
* @property {string} id
* @property {string} [regionId]
* @property {'context'|'removed'|'added'} type
* @property {number} [oldLineNumber]
* @property {number} [newLineNumber]
* @property {DiffSegment[]} segments
*/
/**
* @typedef {Object} AiDiffRegion
* @property {string} id
* @property {number} oldStart
* @property {string[]} oldLines
* @property {number} newStart
* @property {string[]} newLines
* @property {string[]} beforeContext
* @property {string[]} afterContext
* @property {string} [conflict]
*/
/**
* @typedef {Object} PendingAiDiffProposal
* @property {string} id
* @property {string} baseContent
* @property {string} nextContent
* @property {boolean} [isReadOnly]
* @property {FullDiffRow[]} rows
* @property {AiDiffRegion[]} regions
*/
/**
* @typedef {Object} ComputeFullMarkdownDiffOptions
* @property {boolean} [tokenDiff]
*/
/**
* @typedef {Object} DiffOp
* @property {'context'|'removed'|'added'} type
* @property {string} text
* @property {number} [oldLineNumber]
* @property {number} [newLineNumber]
*/
const CONTEXT_SIZE = 2;
const MAX_LINE_DIFF_CELLS = 200_000;
const MAX_TOKEN_DIFF_CHARS = 4_000;
const MAX_TOKEN_DIFF_CELLS = 40_000;
function normalizeContent(content) {
// audit fixWindows Notepad / 某些 PowerShell pipeline 会写 UTF-8 BOM
// (U+FEFF) 在文件头。不剥 BOM 会让第一行变成 "\uFEFF# Title"
// computeDiffOps 里 oldLines[0] === newLines[0] 永远 false
// 每个 BOM-prefixed 文件都会在 AI diff 面板里把首行当成「被改」渲染,
// 即使内容一字未动。仅在文件起始位置剥一次intra-content 的 BOM 保留)。
// \u5BA1\u8BA1\u4FEE\u590D (Round 11 deep-fix P2-1)\uFF1A\u628A BOM \u5265\u9664\u4ECE\u300C\u4EC5\u6587\u4EF6\u5934\u300D\u6269\u5230\u300C\u5168\u6587 BOM \u5B57\u7B26\u300D\u3002
// \u65E7\u7248 intra-file BOM\uFF08\u4F8B\u5982\u591A\u6B21 cat \u62FC\u63A5\u65F6\u5076\u5C14\u51FA\u73B0\uFF09\u4F1A\u88AB\u5F53\u6210\u6B63\u6587\u4E00\u90E8\u5206\uFF0C
// \u8BA9\u5BF9\u5E94\u884C\u6C38\u4E0D\u7B49\u4E8E oldLines \u91CC\u540C\u6837\u4F4D\u7F6E\u7684\u884C \u2192 \u6574\u884C\u88AB\u8BEF\u5224\u4E3A\u300C\u4FEE\u6539\u300D\u3002
// \uFEFF \u5728 Markdown \u91CC\u6CA1\u6709\u4EFB\u4F55\u6709\u610F\u4E49\u7684\u8BED\u4E49\uFF08\u96F6\u5BBD BOM \u6807\u8BB0\uFF09\uFF0C\u5168\u6587\u5265\u662F\u5B89\u5168\u7684\u3002
//
// \u884C\u5C3E\u89C4\u6574\uFF1A\r\n \u2192 \n\uFF1B**\u4FDD\u7559\u88F8 \r**\uFF08\u65E7 Mac \u98CE\u683C\u5408\u6CD5\u53EF\u542B\uFF09\u3002
// \u539F `replace(/\r\n?/g, '\n')` \u4F1A\u541E\u6389\u88F8 \r \u628A\u4E00\u884C\u62C6\u6210\u4E24\u884C\uFF08Round 8 BOM \u4FEE\u590D\u7684\u526F\u4F5C\u7528\uFF09\uFF0C
// \u73B0\u5728\u6539\u7528 /\r\n/g \u53EA\u5339\u914D CRLF\u3002
return String(content).replace(/\r\n/g, '\n').replace(/\uFEFF/g, '');
}
function splitLines(content) {
const normalized = normalizeContent(content);
if (normalized.length === 0) return [];
// audit fix「空文件」与「只有一个换行的文件」语义上等价 —— 都是「无内容行」,
// 但前者 splitLines 返回 [],后者返回 [""]diff LCS 在这两种输入下会产生
// 不同的 opsadded 与 nothing让 AI diff 偶现「空文件被加了一行」的幽灵。
// 统一成 []。
const lines = normalized.endsWith('\n')
? normalized.slice(0, -1).split('\n')
: normalized.split('\n');
if (lines.length === 1 && lines[0] === '') return [];
return lines;
}
function joinLines(lines, endsWithNewline, eol = '\n') {
if (lines.length === 0) return endsWithNewline ? eol : '';
return `${lines.join(eol)}${endsWithNewline ? eol : ''}`;
}
function computeDiffOps(oldLines, newLines, onFallback) {
let prefixLength = 0;
const maxPrefixLength = Math.min(oldLines.length, newLines.length);
while (prefixLength < maxPrefixLength && oldLines[prefixLength] === newLines[prefixLength]) {
prefixLength += 1;
}
let oldSuffixStart = oldLines.length;
let newSuffixStart = newLines.length;
while (
oldSuffixStart > prefixLength
&& newSuffixStart > prefixLength
&& oldLines[oldSuffixStart - 1] === newLines[newSuffixStart - 1]
) {
oldSuffixStart -= 1;
newSuffixStart -= 1;
}
const oldMiddle = oldLines.slice(prefixLength, oldSuffixStart);
const newMiddle = newLines.slice(prefixLength, newSuffixStart);
/** @type {DiffOp[]} */
const ops = [];
for (let index = 0; index < prefixLength; index += 1) {
ops.push({ type: 'context', text: oldLines[index], oldLineNumber: index + 1, newLineNumber: index + 1 });
}
if (oldMiddle.length * newMiddle.length > MAX_LINE_DIFF_CELLS) {
// H3 fix (audit)cells 超过 MAX_LINE_DIFF_CELLS 时退化到「整段删 + 整段加」
// fallback。原来的实现不告诉调用方 —— UI 看到「整篇被改」会误以为 AI 整篇重写。
// onFallback 让 controller toast「diff 过大,已退化为整段替换」。
if (typeof onFallback === 'function') {
onFallback(`行级 diff 超过 ${MAX_LINE_DIFF_CELLS} cells 上限,已退化为整段替换(${oldMiddle.length} 行 removed + ${newMiddle.length} 行 added`);
}
oldMiddle.forEach((text, index) => {
ops.push({ type: 'removed', text, oldLineNumber: prefixLength + index + 1 });
});
newMiddle.forEach((text, index) => {
ops.push({ type: 'added', text, newLineNumber: prefixLength + index + 1 });
});
} else {
// LCS 动态规划
const dp = Array.from({ length: oldMiddle.length + 1 }, () => new Array(newMiddle.length + 1).fill(0));
for (let i = oldMiddle.length - 1; i >= 0; i -= 1) {
for (let j = newMiddle.length - 1; j >= 0; j -= 1) {
dp[i][j] = oldMiddle[i] === newMiddle[j]
? dp[i + 1][j + 1] + 1
: Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
let i = 0;
let j = 0;
while (i < oldMiddle.length && j < newMiddle.length) {
if (oldMiddle[i] === newMiddle[j]) {
ops.push({
type: 'context',
text: oldMiddle[i],
oldLineNumber: prefixLength + i + 1,
newLineNumber: prefixLength + j + 1,
});
i += 1;
j += 1;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
ops.push({ type: 'removed', text: oldMiddle[i], oldLineNumber: prefixLength + i + 1 });
i += 1;
} else {
ops.push({ type: 'added', text: newMiddle[j], newLineNumber: prefixLength + j + 1 });
j += 1;
}
}
while (i < oldMiddle.length) {
ops.push({ type: 'removed', text: oldMiddle[i], oldLineNumber: prefixLength + i + 1 });
i += 1;
}
while (j < newMiddle.length) {
ops.push({ type: 'added', text: newMiddle[j], newLineNumber: prefixLength + j + 1 });
j += 1;
}
}
for (let index = oldSuffixStart; index < oldLines.length; index += 1) {
ops.push({
type: 'context',
text: oldLines[index],
oldLineNumber: index + 1,
newLineNumber: newSuffixStart + index - oldSuffixStart + 1,
});
}
return ops;
}
function findSequence(lines, sequence) {
if (sequence.length === 0) return [];
const matchesAt = (index) => sequence.every((line, offset) => lines[index + offset] === line);
// fix(audit 2026-08):原版在 preferredIndex 命中时直接 [preferredIndex] 返回,
// 跳过扫描全文 → 其它位置的重复匹配被静默忽略。背景AI 从 base 算 diff 时只
// 有 1 个匹配;但用户编辑后可能在其它位置粘了相同行 → 当前文档有 N 个匹配,
// preferredIndex hint 仍指向原位置。直接套用 hint 会改错位置(旧位置可能是
// 用户新增的副本,而非 AI 原意要改的那一行)。
// 修复:总是扫描全文,仅当只有 1 个匹配时才信任 hint 返回单元素数组。
// hint 现在在调用方applyDiffRegionSafely通过 beforeContext/afterContext 实现,
// 见 :547+ region clamp 切片。
const matches = [];
for (let i = 0; i <= lines.length - sequence.length; i += 1) {
if (matchesAt(i)) matches.push(i);
}
return matches;
}
function tokenize(text) {
const tokens = [];
let index = 0;
while (index < text.length) {
const char = text[index];
// 用贪婪匹配,匹配 $...$ 行内数学Katex 风格的简单 token
const mathMatch = char === '$' ? text.slice(index).match(/^\$[^$]+\$/) : null;
const wordMatch = text.slice(index).match(/^[A-Za-z0-9_]+/);
const spaceMatch = text.slice(index).match(/^\s+/);
if (mathMatch) {
tokens.push(mathMatch[0]);
index += mathMatch[0].length;
} else if (spaceMatch) {
tokens.push(spaceMatch[0]);
index += spaceMatch[0].length;
} else if (wordMatch) {
tokens.push(wordMatch[0]);
index += wordMatch[0].length;
} else {
tokens.push(char);
index += 1;
}
}
return tokens;
}
function diffTokens(removedText, addedText, onFallback) {
if (removedText.length + addedText.length > MAX_TOKEN_DIFF_CHARS) {
// H3 fix (audit)token 字符数超过上限 → 退化到行级 fallback。
// 通过 onFallback 把警告挂到 computeFullMarkdownDiff 的 warnings 字段。
if (typeof onFallback === 'function') {
onFallback(`词级 diff 超过 ${MAX_TOKEN_DIFF_CHARS} 字符上限,已退化为行级(${removedText.length}+${addedText.length} chars`);
}
return {
removedSegments: [{ type: 'removed', text: removedText }],
addedSegments: [{ type: 'added', text: addedText }],
};
}
const oldTokens = tokenize(removedText);
const newTokens = tokenize(addedText);
if (oldTokens.length * newTokens.length > MAX_TOKEN_DIFF_CELLS) {
// H3 fix (audit)token cells 超上限同样退化。
if (typeof onFallback === 'function') {
onFallback(`词级 diff cells 超过 ${MAX_TOKEN_DIFF_CELLS} 上限,已退化为行级(${oldTokens.length}×${newTokens.length} tokens`);
}
return {
removedSegments: [{ type: 'removed', text: removedText }],
addedSegments: [{ type: 'added', text: addedText }],
};
}
const dp = Array.from({ length: oldTokens.length + 1 }, () => new Array(newTokens.length + 1).fill(0));
for (let i = oldTokens.length - 1; i >= 0; i -= 1) {
for (let j = newTokens.length - 1; j >= 0; j -= 1) {
dp[i][j] = oldTokens[i] === newTokens[j]
? dp[i + 1][j + 1] + 1
: Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
/** @type {DiffSegment[]} */
const removedSegments = [];
/** @type {DiffSegment[]} */
const addedSegments = [];
let i = 0;
let j = 0;
const pushSegment = (segments, type, text) => {
const last = segments[segments.length - 1];
if (last && last.type === type) {
last.text += text;
} else {
segments.push({ type, text });
}
};
while (i < oldTokens.length && j < newTokens.length) {
if (oldTokens[i] === newTokens[j]) {
pushSegment(removedSegments, 'equal', oldTokens[i]);
pushSegment(addedSegments, 'equal', newTokens[j]);
i += 1;
j += 1;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
pushSegment(removedSegments, 'removed', oldTokens[i]);
i += 1;
} else {
pushSegment(addedSegments, 'added', newTokens[j]);
j += 1;
}
}
while (i < oldTokens.length) {
pushSegment(removedSegments, 'removed', oldTokens[i]);
i += 1;
}
while (j < newTokens.length) {
pushSegment(addedSegments, 'added', newTokens[j]);
j += 1;
}
return { removedSegments, addedSegments };
}
function buildContextRows(lines) {
return lines.map((text, index) => ({
id: `context-${index}`,
type: 'context',
oldLineNumber: index + 1,
newLineNumber: index + 1,
segments: [{ type: 'equal', text }],
}));
}
function buildChangedRows(regionId, changedOps, options, onFallback) {
/** @type {FullDiffRow[]} */
const rows = [];
const removedOps = changedOps.filter((op) => op.type === 'removed');
const addedOps = changedOps.filter((op) => op.type === 'added');
const pairCount = Math.min(removedOps.length, addedOps.length);
const useTokenDiff = options.tokenDiff !== false;
for (let i = 0; i < pairCount; i += 1) {
const removedOp = removedOps[i];
const addedOp = addedOps[i];
const { removedSegments, addedSegments } = useTokenDiff
? diffTokens(removedOp.text, addedOp.text, onFallback)
: {
removedSegments: [{ type: 'removed', text: removedOp.text }],
addedSegments: [{ type: 'added', text: addedOp.text }],
};
rows.push({
id: `${regionId}-removed-${i}`,
regionId,
type: 'removed',
oldLineNumber: removedOp.oldLineNumber,
segments: removedSegments.length > 0 ? removedSegments : [{ type: 'removed', text: removedOp.text }],
});
rows.push({
id: `${regionId}-added-${i}`,
regionId,
type: 'added',
newLineNumber: addedOp.newLineNumber,
segments: addedSegments.length > 0 ? addedSegments : [{ type: 'added', text: addedOp.text }],
});
}
for (let i = pairCount; i < removedOps.length; i += 1) {
rows.push({
id: `${regionId}-removed-${i}`,
regionId,
type: 'removed',
oldLineNumber: removedOps[i].oldLineNumber,
segments: [{ type: 'removed', text: removedOps[i].text }],
});
}
for (let i = pairCount; i < addedOps.length; i += 1) {
rows.push({
id: `${regionId}-added-${i}`,
regionId,
type: 'added',
newLineNumber: addedOps[i].newLineNumber,
segments: [{ type: 'added', text: addedOps[i].text }],
});
}
return rows;
}
/**
* 计算两段 Markdown 文本的完整 diff。
* @param {string} baseContent
* @param {string} nextContent
* @param {ComputeFullMarkdownDiffOptions} [options]
* @returns {{ rows: FullDiffRow[], regions: AiDiffRegion[] }}
*/
function computeFullMarkdownDiff(baseContent, nextContent, options = {}) {
const normalizedBaseContent = normalizeContent(baseContent);
const normalizedNextContent = normalizeContent(nextContent);
// H3 fix (audit):收集 fallback 警告(行级 cells 超 MAX_LINE_DIFF_CELLS /
// 词级超 MAX_TOKEN_DIFF_CHARS 触发整段替换 / 退化为行级时挂警告),
// 让 controller toast「diff 过大,已退化为整段替换」/「词级已退化为行级」,
// 避免 UI 上看到「整篇被改」以为是 AI 整篇重写。
const warnings = [];
const emitWarning = (msg) => { warnings.push(msg); };
if (normalizedBaseContent === normalizedNextContent) {
return { rows: buildContextRows(splitLines(normalizedNextContent)), regions: [] };
}
const oldLines = splitLines(normalizedBaseContent);
const newLines = splitLines(normalizedNextContent);
const ops = computeDiffOps(oldLines, newLines, emitWarning);
/** @type {FullDiffRow[]} */
const rows = [];
/** @type {AiDiffRegion[]} */
const regions = [];
let index = 0;
while (index < ops.length) {
const op = ops[index];
if (op.type === 'context') {
rows.push({
id: `context-${rows.length}`,
type: 'context',
oldLineNumber: op.oldLineNumber,
newLineNumber: op.newLineNumber,
segments: [{ type: 'equal', text: op.text }],
});
index += 1;
continue;
}
const changeStart = index;
while (index < ops.length && ops[index].type !== 'context') index += 1;
const changedOps = ops.slice(changeStart, index);
const oldLinesInRegion = changedOps.filter((item) => item.type === 'removed').map((item) => item.text);
const newLinesInRegion = changedOps.filter((item) => item.type === 'added').map((item) => item.text);
const beforeContextStart = Math.max(0, changeStart - CONTEXT_SIZE);
const afterContextEnd = Math.min(ops.length, index + CONTEXT_SIZE);
const beforeContext = ops.slice(beforeContextStart, changeStart).filter((item) => item.type === 'context').map((item) => item.text);
const afterContext = ops.slice(index, afterContextEnd).filter((item) => item.type === 'context').map((item) => item.text);
const firstOldLine = changedOps.find((item) => item.oldLineNumber !== undefined);
const firstNewLine = changedOps.find((item) => item.newLineNumber !== undefined);
const firstOldLineNumber = firstOldLine ? firstOldLine.oldLineNumber : undefined;
const firstNewLineNumber = firstNewLine ? firstNewLine.newLineNumber : undefined;
const regionId = `region-${regions.length + 1}`;
// C1 fix (audit):若一个 region 完全没有 beforeContext + afterContext
// 「应用此处」无法唯一定位applyDiffRegionSafely 对纯新增/整篇删除 fallback
// 到 currentLines.length===0 才生效,正常文档永远 conflict。在 UI 显式标 conflict
// 引导用户走「应用全部」。这是 AI 返回「整篇替换」或「在空文档插入内容」时的合理退化。
const needsWholeDocHint = beforeContext.length === 0 && afterContext.length === 0;
regions.push({
id: regionId,
oldStart: firstOldLineNumber !== undefined ? firstOldLineNumber - 1 : Math.max(0, (firstNewLineNumber || 1) - 1),
oldLines: oldLinesInRegion,
newStart: firstNewLineNumber !== undefined ? firstNewLineNumber - 1 : Math.max(0, (firstOldLineNumber || 1) - 1),
newLines: newLinesInRegion,
beforeContext,
afterContext,
...(needsWholeDocHint ? { conflict: '无上下文定位,请使用「应用全部」' } : {}),
});
rows.push(...buildChangedRows(regionId, changedOps, options, emitWarning));
}
// H3 fix (audit):只有真有 fallback 才带 warnings 字段,避免污染 return shape。
return warnings.length > 0 ? { rows, regions, warnings } : { rows, regions };
}
/**
* 用 region 安全替换 currentContent 中对应位置的内容。
* 找不到唯一匹配0 个或多个)时返回 { ok:false, reason },供 UI 标红提示。
* @param {string} currentContent
* @param {AiDiffRegion} region
* @returns {{ ok: true, content: string } | { ok: false, reason: string }}
*/
function applyDiffRegionSafely(currentContent, region) {
const original = String(currentContent);
const normalized = normalizeContent(original);
const currentLines = splitLines(normalized);
const endsWithNewline = normalized.endsWith('\n');
// 保留原稿行尾:检测是否含 CRLF含至少一处即视为 CRLF 文件)。
// 之前 normalizeContent 把 \r\n → \n 后 joinLines 又硬写 \n
// 会把 Windows 用户的 .md 文件静默改成 LF —— 污染文件格式。
const eol = /\r\n/.test(original) ? '\r\n' : '\n';
if (region.oldLines.length > 0) {
// findSequence 现在总是扫描全文(不再走 preferredIndex early-return
// 拿到所有 oldLines 匹配的下标。再用 beforeContext/afterContext 二次过滤,
// 既保留 C3 上下文漂移检测,又能在「多个匹配但只有一个上下文一致」时安全应用。
const allMatches = findSequence(currentLines, region.oldLines, region.oldStart);
if (allMatches.length === 0) {
return { ok: false, reason: '无法安全定位该修改位置' };
}
// C3 fix (audit):多匹配时用 context 过滤,定位唯一正确的位置。
// 场景:用户复制了相同行到多处(如原 c 后面又粘一个 cpreferredIndex 仍
// 命中旧位置,但旧位置的 context 已和 region 算出时不同;其它新位置的
// context 同样不对。三个候选都漂移 → "上下文漂移"。
// 单匹配且有 context 时也照常校验(防止单匹配也漂移的极端情况)。
if (region.beforeContext.length > 0 || region.afterContext.length > 0) {
const contextMatches = allMatches.filter((idx) => {
const beforeActual = currentLines.slice(
Math.max(0, idx - region.beforeContext.length),
idx,
);
const afterActual = currentLines.slice(
idx + region.oldLines.length,
idx + region.oldLines.length + region.afterContext.length,
);
return beforeActual.join('\n') === region.beforeContext.join('\n')
&& afterActual.join('\n') === region.afterContext.join('\n');
});
if (contextMatches.length === 0) {
// 所有匹配位置的 context 都不对 → drift。
return { ok: false, reason: '上下文漂移,无法确定该修改的位置' };
}
if (contextMatches.length > 1) {
// 多处 context 都和 region 一致,但 oldLines 重复了 → 真歧义。
return { ok: false, reason: '找到多个相同位置,无法判断应应用到哪一处' };
}
// 唯一匹配context 一致的位置就是 base 时的位置,安全应用。
const nextLines = currentLines.slice();
nextLines.splice(contextMatches[0], region.oldLines.length, ...region.newLines);
return { ok: true, content: joinLines(nextLines, endsWithNewline, eol) };
}
// 无 context要求 oldLines 全局唯一。
if (allMatches.length !== 1) {
return { ok: false, reason: '找到多个相同位置,无法判断应应用到哪一处' };
}
const nextLines = currentLines.slice();
nextLines.splice(allMatches[0], region.oldLines.length, ...region.newLines);
return { ok: true, content: joinLines(nextLines, endsWithNewline, eol) };
}
// 纯新增:尝试用 beforeContext + afterContext 定位插入点
const preferredIndex = Math.min(Math.max(region.oldStart, 0), currentLines.length);
const beforeMatches = findSequence(currentLines, region.beforeContext);
const afterMatches = findSequence(currentLines, region.afterContext);
/** @type {number | null} */
let insertionIndex = null;
if (
region.beforeContext.length > 0
&& region.afterContext.length > 0
// clamp 切片端点preferredIndex - region.beforeContext.length 在文档开头
// 可能是负数 → JS 的 slice(-N, 0) 会拿"末尾 N 项"误匹配;显式 clamp 到 0。
&& preferredIndex - region.beforeContext.length >= 0
&& currentLines.slice(preferredIndex - region.beforeContext.length, preferredIndex).join('\n') === region.beforeContext.join('\n')
&& currentLines.slice(preferredIndex, preferredIndex + region.afterContext.length).join('\n') === region.afterContext.join('\n')
) {
insertionIndex = preferredIndex;
} else if (beforeMatches.length === 1 && afterMatches.length === 1) {
// P3 fixbefore + after 同时各只有一个匹配,但 preferredIndex 错位了。
// 此时必须校验 before 末尾 === after 起点(即「这两段上下文在文档里相邻」),
// 否则 before 和 after 是两个独立匹配,盲选 before 会插到错位置。
const beforeEnd = beforeMatches[0] + region.beforeContext.length;
if (beforeEnd === afterMatches[0]) {
insertionIndex = beforeEnd;
} else {
// 上下文冲突:让 UI 走 conflict 分支(标灰、应用按钮 disable
return { ok: false, reason: '前后上下文位置冲突,无法确定插入点' };
}
} else if (beforeMatches.length === 1) {
insertionIndex = beforeMatches[0] + region.beforeContext.length;
} else if (afterMatches.length === 1) {
insertionIndex = afterMatches[0];
} else if (currentLines.length === 0) {
insertionIndex = 0;
}
if (insertionIndex === null) {
return { ok: false, reason: '无法安全定位插入位置' };
}
const nextLines = currentLines.slice();
nextLines.splice(insertionIndex, 0, ...region.newLines);
return { ok: true, content: joinLines(nextLines, endsWithNewline, eol) };
}
module.exports = {
computeFullMarkdownDiff,
applyDiffRegionSafely,
};

239
shared/render-sanitize.js Normal file
View File

@@ -0,0 +1,239 @@
// Markdown → 安全 HTML 的核心规则Stage 8与 preload.js 共用)
//
// 职责:
// - 定义 ALLOWED_URI_REGEXPURI scheme 白名单 + 相对路径分支)
// - 注册 uponSanitizeAttribute 钩子(统一走 ALLOWED_URI_REGEXP
// 危险协议 javascript:/vbscript:/data:text\/html/file: 在所有标签上都剥)
// - 导出 renderMarkdown(markdown, dompurifyInstance) → safeHtml
//
// 边界:
// - 不引用 electron / contextBridge / marked —— 由 preload 注入
// - 不导出 DOMPurify 实例(每个进程各自创建,避免泄漏 window
//
// preload.js 与 tests/unit/render-sanitize.test.js 都 require 本文件,
// 保证规则不会在「实现」与「测试」之间漂移。
/**
* 允许的 URI 协议:
* - 前半段:显式 scheme 白名单http/https/mailto/tel/callto/file/data:image/raster
* —— file: 必须先放行DOMPurify 才会回调 uponSanitizeAttribute 钩子
* —— data:image 限定为栅格格式png/jpeg/gif/webp/bmp/ico/tiff—— 显式
* 拒绝 svg+xml/svg 等可携带 JS / XML 外部实体的格式。SVG 数据 URL 在
* `<a href>` 上点击会导航到 top-level SVG 上下文,现代 Chromium 多半
* 拦截脚本执行,但跨浏览器 / 跨版本一致性差。Markdown 用例几乎不需
* SVG 内嵌图raster 已覆盖 99% 场景。
* - 后半段:相对路径分支('img/a.png'、'./other.md'、'/abs/x.png'、'../up.md'
* —— `(?![/\\]{2})[/\\]|[^a-z/\\]` 覆盖 `#` 锚点 + 单 `/` 或 `\` 开头;
* `(?![/\\]{2})` 显式拒绝「协议相对 URL」`//evil.com/x.png` /
* `\\evil.com\x.png`),避免静默导航到外站
* —— `[a-z+.-]+(?:[\\/][^a-z]*|$)` 覆盖字母开头的相对路径
*
* 危险协议 (javascript:/vbscript:/data:text\/html) 走「字母+冒号」分支——因
* scheme 不在前列、不以 `#` 开头、不含 `/`,全部不匹配。
*
* Windows 路径分隔符 `\`:用户在 Windows 上常写 `![](img\foo.png)`
* 浏览器对 URL 会自动把 `\` 规范化成 `/`Chrome/Firefox 都如此),
* 校验放行 `\` 与 `/` 都不会引入新风险 —— 真实加载由 resolveRelativeImages 控。
*/
const ALLOWED_URI_REGEXP =
/^(?:(?:https?|mailto|tel|callto|file):|data:image\/(?:png|jpe?g|gif|webp|bmp|ico|tiff);base64,|#|(?:(?![/\\]{2})[/\\]|[^a-z/\\])|[a-z+.-]+(?:[\\/][^a-z]*|$))/i;
// data:image 单 URL 字节上限(防 OOM。10 MB 对正常 AI 输出 + 用户内嵌图
// 已经远超合理上限 —— Notes 单文件 5 MB 限制会先卡住写入,所以 10 MB 留余量。
const DATA_IMAGE_MAX_URL_LENGTH = 10 * 1024 ** 2;
/**
* 危险 URI scheme —— 在非 IMG 标签(&lt;a href&gt; / &lt;form action&gt; / iframe 等)
* 上剥,包括 file:(导航/数据外流通道。IMG/src 例外file:/// 在 IMG 上是
* Notes 数据目录本地图片路径,需要保留。
*
* audit fix (C1):钩子里先 trim 再匹配。原正则 `^...javascript:...` 锚定首字符,
* 若 DOMPurify 未规范化前导空格 / 控制字符(`&#x20;` 等实体),
* 像 `<a href=" javascript:alert(1)">` 就能绕过钩子。trim 后再 `^` 匹配,
* 同时把 NUL/控制字符一并处理C0 控制字符 0x00-0x1F + 0x7F
*/
const DANGEROUS_URI_NON_IMG_REGEXP = /^(?:javascript|vbscript|data(?!:image\/)|file):/i;
function isDangerousUriNonImg(value) {
if (typeof value !== 'string') return false;
// 剥前导空白 + 控制字符HTML 实体解码后可能留下的 \x00-\x1F / \x7F / 空格 / 换行。
// 用循环 + charCodeAt 比较而非 regex —— ESLint no-control-regex 会拒绝 regex 字面里的控制字符。
let i = 0;
while (i < value.length) {
const code = value.charCodeAt(i);
// 空白:\t(9) \n(10) \v(11) \f(12) \r(13) 空格(32) NBSP(160) 等
// 控制字符0-31 与 127
if (code <= 32 || code === 127 || code === 160) {
i += 1;
continue;
}
break;
}
return DANGEROUS_URI_NON_IMG_REGEXP.test(value.slice(i));
}
/**
* audit fix (Round 9)style 属性里的危险 CSS 模式防御。
*
* 场景:`<a style="background:url(javascript:alert(1))">` —— 上面
* isDangerousUriNonImg 检的是 attrValue **开头**,对「值里嵌套 javascript: URL
* 的 style」无能为力。同理 `style="width:expression(alert(1))"`legacy IE
* `style="-moz-binding:url(...)"`legacy Mozilla XBL、`behavior:url(...)`
* legacy IE HTC
*
* DOMPurify v3 默认对这些模式有部分保护CSS sanitizer 拒 url(javascript:)
* 但行为跨版本 / 跨浏览器一致性差。本函数做防御性深度扫描:发现任一危险模式
* → 整条 style 属性剥掉(保守:宁可错杀不可漏过)。
*
* 同时扫描 @import可绕过 background-image 限定的外链资源加载)和
* url(javascript:|vbscript:|data:text/html|file:) 等危险 URL scheme。
*
* 注意url() 内的空白 / 引号 / 大小写都要容忍 —— 用不区分大小写的 regex
* 容忍 url 关键字后的可选空白。
*/
const DANGEROUS_STYLE_PATTERNS = [
// url(javascript:...) / url("javascript:...") / url('javascript:...')
// 容忍可选空白 + 单/双引号包裹 + 大小写
/url\s*\(\s*['"]?\s*(?:javascript|vbscript|data\s*:\s*text\s*\/\s*html|file)\s*:/i,
// legacy IE CSS expression()
/expression\s*\(/i,
// legacy IE HTC behavior
/\bbehavior\s*:\s*url\s*\(/i,
// legacy Mozilla XBL binding
/-moz-binding\s*:/i,
// CSS @import外链资源加载 / CSP 绕过)
/@import/i,
];
// audit fix (Round 13 / Sec-M)CSS 属性 denylist —— 阻止攻击者把整窗当画布。
// 思路Markdown 的合法 inline style 几乎只用 color / font-size / text-align /
// background:url(https://...) 这类纯视觉属性;任何「能改变布局 / 跳出文档流 /
// 遮挡 UI / 隐藏元素 / 让用户看不清真实界面」的属性都属于攻击面。
//
// 受保护列表(每个测试过:
// - `<span style="position:fixed;left:0;top:0;width:100vw;height:100vh;z-index:2147483647">`
// —— 全屏透明覆盖层,配合外链跳转 = 整窗 UI 欺骗 / 点击劫持
// - `<svg><style>.app-shell{display:none}</style></svg>` —— 隐藏 chrome
// 靠 FORBID_TAGS:['svg'] 拦死,这条是给「未来如果放宽 svg」做兜底
// - `.toolbar,.statusbar{visibility:hidden}` —— 视觉欺骗
// - `body::after{content:"会话过期请重新输入 API Key"}` —— 钓鱼覆盖层
// - `pointer-events:none` 让 UI 看着可点但透传到下层
// - `transform: ...` 在 markdown 里几乎不合法使用,且可绕开父级 contain
// - `opacity:0` 让链接看着没东西实际可点
//
// 注意:\b 词边界要求属性名是独立 token`font-size` 不会被误命中 `size`。
const DANGEROUS_STYLE_PROPS = [
'position', 'inset', 'top', 'left', 'right', 'bottom',
'z-index', 'zindex',
'float', 'clear',
'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
'margin', 'padding',
'transform', 'translate',
'visibility', 'opacity',
'pointer-events',
'content',
// display:none 是合法用法(折叠冗余段落)但 XSS 高敏,宁可错杀。
// 想要折叠段落用引用 styled 容器而不是内联 style。
'display',
];
function isDangerousStyleValue(value) {
if (typeof value !== 'string') return false;
// style 值的注释 / 字符串里也照样扫 —— 攻击者可借 `/* xxx */` 试图绕过;
// 保守策略:任一危险模式出现 → 剥整条 style。
for (const pat of DANGEROUS_STYLE_PATTERNS) {
if (pat.test(value)) return true;
}
// 属性名扫描 —— 把 value 按 `;` 拆成 declaration逐个看属性名是否在
// 黑名单。\b 词边界防 `font-size` 误命中 `size`、防 `text-align` 误命中
// 不存在的 `align`。兼容 `:property` 与 `: property` 两种写法。
for (const decl of value.split(';')) {
const m = decl.match(/^\s*([a-zA-Z-]+)\s*:/);
if (m && DANGEROUS_STYLE_PROPS.includes(m[1].toLowerCase())) return true;
}
return false;
}
/**
* 注册 uponSanitizeAttribute 钩子:
* - &lt;img src&gt; 走 ALLOWED_URI_REGEXP 显式校验(统一策略);
* file:/// 放行ALLOWED 已含javascript:/vbscript:/data:text\/html 等
* 未在白名单的 scheme 一律剥
* - 其他标签的 href/src/action 见到 javascript:/vbscript:/data:text\/html/file:
* → 主动剥
*
* 之前 &lt;img src&gt; 是 keepAttr=true 全放行,靠 DOMPurify v3 内置兜底处理 javascript:
* vbscript: / data:text\/html 在 img 上不会被 DOMPurify 兜底剥掉靠「img 不渲染 html」
* 的策略假设——跨浏览器 / 跨版本一致性差。现在所有 src/href/action 都按 ALLOWED 过滤,
* 单一事实源;同时非 IMG 标签的 file: 仍主动剥(导航 / 数据外流风险)。
*
* 必须在调用 DOMPurifyInstance.sanitize 之前注册一次v3 的钩子是实例级,
* 重启进程后丢失;不需要 removeHook
*
* @param {object} DOMPurifyInstance - 由 createDOMPurify(window) 创建的实例
*/
function installHooks(DOMPurifyInstance) {
if (!DOMPurifyInstance || typeof DOMPurifyInstance.addHook !== 'function') return;
DOMPurifyInstance.addHook('uponSanitizeAttribute', (node, data) => {
const isImgSrc = data.attrName === 'src' && node.tagName === 'IMG';
// <img src> 走显式白名单 —— ALLOWED_URI_REGEXP 已含 file:/data:image/,未匹配即剥
if (isImgSrc) {
if (!ALLOWED_URI_REGEXP.test(data.attrValue)) {
data.keepAttr = false;
return;
}
// audit fix (Round 13 / Sec-H2)file:// 必须严格 file:///... 三斜杠形式
// —— 拒绝 file://host/... 与 file:////host/...。
//
// 威胁Windows 上 Chromium 把 `file://ATTACKER-HOST/share/x.png` 解析为
// UNC 路径 `\\ATTACKER-HOST\share\x.png` 并交给 SMB 客户端去取 —— 系统
// 静默做 NTLMv2 认证,攻击者能直接抓受害者的 domain\user + NTLM response
// CVE-2023-23397 Outlook 那个原语,这里通过 markdown 图片复现)。
// 三斜杠 = "无 host + 绝对路径",是浏览器渲染本地图片的唯一合法形式。
// 写法:要求 file: 后恰好 3 个 /,且第 4 个字符不是 /(即 `////` UNC 也拒)。
if (/^\s*file:/i.test(data.attrValue) && !/^file:\/{3}[^/]/i.test(data.attrValue)) {
data.keepAttr = false;
return;
}
if (
// audit fix (Sec-M3)data:image 单 URL 上限。恶意 markdown 可嵌 5MB+
// base64 图AI 修改场景下尤其:用户 prompt + content + 嵌入图可叠到
// MB 级DOMPurify v3 内置不会卡 base64 长度 → OOM 风险。
// 10 MB base64 ≈ 7.5 MB 二进制,对单张笔记内嵌图已远超合理范围。
// 文件绝对路径/file:/https: 不进此分支,行为不变。
data.attrValue.length > DATA_IMAGE_MAX_URL_LENGTH
&& /^data:image\//i.test(data.attrValue)
) {
data.keepAttr = false;
}
return;
}
// audit fix (Round 9)style 属性走 isDangerousStyleValue 深度扫描。
// 嵌在 url() 里的 javascript:/expression()/@import 等光靠 attrValue
// 开头扫描抓不到。这里保守:任一危险模式命中 → 整条 style 剥。
if (data.attrName === 'style' && isDangerousStyleValue(data.attrValue)) {
data.keepAttr = false;
return;
}
// audit fix (Round 4 P1-5):非 IMG 的 URI 属性也走 ALLOWED 校验剥 data:image/svg+xml。
// 限制为 URI 类属性href / src / action / formaction / xlink:href / cite / longdesc /
// poster / usemap避免误剥 DOMPurify 自动注入的安全属性(如 target=_blank 时
// 自动加的 rel="noopener noreferrer")—— 那些值不是 URI不该过 ALLOWED 校验。
const URI_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'xlink:href', 'cite', 'longdesc', 'poster', 'usemap']);
const isUriAttr = URI_ATTRS.has(data.attrName);
if (isDangerousUriNonImg(data.attrValue)) {
data.keepAttr = false;
return;
}
if (isUriAttr && !ALLOWED_URI_REGEXP.test(data.attrValue)) {
// 非 IMG 标签也调一次 ALLOWED_URI_REGEXP.test与 IMG 路径策略对称。
// 之前非 IMG 只走 DANGEROUS负向预查放过 data:image不二次 ALLOWED
// —— 完全依赖 DOMPurify v3 内置 ALLOWED_URI_REGEXP 全局应用兜底。若未来
// DOMPurify 配置改动或行为变更,<a href="data:image/svg+xml;base64,...">
// 会落地;点击进入 top-level SVG 上下文,跨浏览器脚本执行行为不一致。
data.keepAttr = false;
}
});
}
module.exports = { ALLOWED_URI_REGEXP, installHooks };

566
shared/settings-schema.js Normal file
View File

@@ -0,0 +1,566 @@
// 设置 schema —— 单一事实源
// ============================================================================
//
// 这是 main / preload / renderer 三处设置定义的唯一权威来源。
// 之前四处漂移main.js 的 DEFAULT_CONFIG + save-settings 80 行校验链 + renderer
// DEFAULT_SETTINGS + settings-dialog 的选项表)统一收敛到本文件:
//
// • DEFAULT_SETTINGS — 各键的默认值
// • SETTINGS_SCHEMA — 每键的 type / enum / min / max / custom validator
// • validateAndSanitize() — 用 schema 校验+clamp+enumeration
// • SETTINGS_UI_OPTIONS — 对话框用的枚举列表readerFontSize / palette / sort
//
// 加载方式:
// • main.js / preload.jsCJSconst schema = require('../shared/settings-schema.js')
// • renderer经 preload contextBridge 过桥window.api.settingsSchema / coerceLoadedSettings
// —— renderer 跑在 Chromium 原生 ESM不能直接 import CJS无 CJS 互操作),
// .cjs / .js 扩展名都不能解决这个问题,文件 *格式* 决定有没有 named export。
// 所以 renderer 拿到的始终是经 preload 包装过的纯数据 / 函数schema 内部
// 结构不暴露。
//
// 新增/修改设置项流程:
// 1. 在 DEFAULT_SETTINGS 加默认值
// 2. 在 SETTINGS_SCHEMA 加类型/范围/枚举/自定义校验
// 3. 若需要在 UI 显示,在 SETTINGS_UI_OPTIONS 加选项
// 4. 跑 npm run check 验证 — validateAndSanitize 会用 schema 拒绝非法值
// ============================================================================
'use strict';
/**
* 默认值(单一事实源)。所有键都在这里定义,缺一不可。
* @type {Readonly<Record<string, *>>}
*/
const DEFAULT_SETTINGS = Object.freeze({
dataDir: null, // null = 用主进程默认目录home/Notes
theme: 'dark', // 'dark' | 'light'
themePalette: 'default', // 调色板 ID见 SETTINGS_UI_OPTIONS.palettes
alwaysOnTop: false,
editorMode: 'split', // 'preview' | 'edit' | 'split' — 默认双栏
readerFontSize: 17, // 阅读字号 (px),范围 12..24
readerLineHeight: 1.85, // 阅读行距,范围 1.4..2.2
fileListSort: 'name', // 'name' | 'mtime-desc'
autoSaveDebounceMs: 500, // 自动保存防抖延迟ms0 = 关闭;默认 500ms = 「停打后立刻存」
splitRatio: 0.5, // 双栏模式左侧占比 0.2..0.8
sidebarWidth: null, // 侧栏拖拽后的宽度pxnull = 使用 CSS 默认 --w-sidebar
aiWidth: null, // AI 中间面板拖拽后的宽度pxnull = 使用 CSS 默认 --w-ai
focusMode: false, // 聚焦模式:隐藏工具栏/侧栏/状态栏Ctrl+Shift+F 切换
// AI 修改功能(用户自填 API Key / BaseURL / Model / System Prompt
// 详见 main/ai.js。空值时 AI 入口点击会提示去设置。
aiProvider: 'openai', // 'openai' | 'anthropic' —— 决定 main/ai.js 走哪条协议
aiBaseUrl: '', // baseURLOpenAI 含 /v1如 https://api.openai.com/v1Anthropic 不含(如 https://api.anthropic.com
aiApiKey: '', // API Key敏感数据只在主进程内存里使用不写日志
aiModel: '', // 模型名OpenAI 如 gpt-4o-miniAnthropic 如 claude-opus-5
aiSystemPrompt: '', // 自定义系统提示词;空 = 用 main/ai.js 内置中文 prompt
});
/**
* 每个键的校验规则。
* type: 'string' | 'number' | 'boolean' | 'enum' | 'nullable-string' | 'nullable-path'
* enum?: 仅 enum 类型:允许的字面量数组
* min?: number / integer / string(长度)
* max?: number / integer / string(长度)
* clamp?: number: 是否 clamp 到 [min,max]
* integer?:boolean number 是否取整
* round?: number: 保留几位小数
* choices?:Array<{value, label, hint?}> UI 选项(仅供 dialog 渲染,不参与校验)
*
* 自定义校验(如 dataDir 必须存在)放在 validateAndSanitize() 里集中处理,
* 因为它需要 fs 调用,不能纯声明式表达。
*
* @type {Readonly<Record<string, object>>}
*/
const SETTINGS_SCHEMA = Object.freeze({
dataDir: {
type: 'nullable-path',
description: '数据目录null = 用默认目录',
},
theme: {
type: 'enum',
enum: ['dark', 'light'],
description: 'UI 主题',
},
themePalette: {
type: 'enum',
enum: ['default', 'ocean', 'forest', 'lavender', 'sunset'],
description: '调色板',
},
alwaysOnTop: {
type: 'boolean',
description: '窗口置顶',
},
editorMode: {
type: 'enum',
enum: ['preview', 'edit', 'split'],
description: '视图模式',
},
readerFontSize: {
type: 'number',
min: 12,
max: 24,
integer: true,
clamp: true,
description: '阅读字号 (px)',
},
readerLineHeight: {
type: 'number',
min: 1.4,
max: 2.2,
// fix(audit 2026-08)round:1 会把 1.85 静默四舍五入到 1.9。UI 选项列出
// 的是 [1.5, 1.7, 1.85, 2.0] 两位小数round:2 才能保留用户的选择。
round: 2,
description: '阅读行距',
},
fileListSort: {
type: 'enum',
enum: ['name', 'mtime-desc'],
description: '侧栏文件列表排序',
},
autoSaveDebounceMs: {
type: 'number',
min: 0,
max: 60000,
clamp: true,
integer: true,
description: '编辑停止后多少毫秒触发自动保存0 = 关闭',
},
splitRatio: {
type: 'number',
min: 0.2,
max: 0.8,
round: 3,
clamp: true,
description: '双栏模式左侧占比',
},
sidebarWidth: {
type: 'nullable-number',
min: 120,
max: 480,
integer: true,
description: '侧栏宽度 (px)null = 用 CSS 默认',
},
aiWidth: {
type: 'nullable-number',
min: 220,
max: 720,
integer: true,
description: 'AI 中间面板宽度 (px)null = 用 CSS 默认 --w-ai (360)',
},
focusMode: {
type: 'boolean',
description: '聚焦模式(隐藏工具栏/侧栏/状态栏)',
},
aiProvider: {
type: 'enum',
enum: ['openai', 'anthropic'],
description: 'AI 服务提供方openai = /chat/completionsanthropic = /v1/messages',
},
aiBaseUrl: {
type: 'string',
// URL 不会超过几 KB留 4 KB 足够;防止有人塞几 MB 把请求体打爆
max: 4_096,
// audit fix (#9 shared):格式校验。空串放行(用户主动清空 = 关闭 AI
// 非空必须是可解析的 http(s) URL避免「abc / www.foo.com / file:///xxx」
// 这类带空格 / 漏 scheme / 协议错误的值被静默接受,最后在主进程 fetch 时
// 才抛 TypeError错误信息很难定位到 settings。trim 后用 URL 解析,
// 协议限定 http: 或 https:(不接 ftp / file / data 等)。
format: 'url-https',
description: 'baseURLOpenAI 含 /v1如 https://api.openai.com/v1Anthropic 不含(如 https://api.anthropic.com',
},
aiApiKey: {
// 密码字段renderer 通过 IPC 传给主进程,不在 schema 校验链里打印
type: 'string',
// API key 通常 50~200 字符;留 4 KB 上限足够
max: 4_096,
description: 'API Key',
},
aiModel: {
type: 'string',
max: 256,
description: '模型名',
},
aiSystemPrompt: {
type: 'string',
// 系统提示词较长是合理的,但单个几十 MB 的 prompt 会拖慢 JSON.stringify
// 且让 AI 计费爆炸 —— 200 KB 对应约 5 万中文字,足够绝大多数场景
max: 200_000,
description: '自定义系统提示词;空 = 用内置默认',
},
});
/**
* UI 选项表 —— 仅供对话框渲染使用。
* key 与 SETTINGS_SCHEMA 的 enum 对齐,但额外带 label / hint。
* @type {Readonly<Record<string, ReadonlyArray<{value: *, label: string, hint?: string}>>>}
*/
const SETTINGS_UI_OPTIONS = Object.freeze({
themePalette: Object.freeze([
{ value: 'default', label: '默认' },
{ value: 'ocean', label: '海洋' },
{ value: 'forest', label: '森林' },
{ value: 'lavender', label: '薰衣草' },
{ value: 'sunset', label: '夕阳' },
]),
fileListSort: Object.freeze([
{ value: 'name', label: '按名称', hint: 'A → ZlocaleCompare(zh-CN)' },
{ value: 'mtime-desc', label: '按修改时间', hint: '最近修改排在最前' },
]),
// 自动保存不再需要枚举选项debounce 延迟是连续数值0..60000 ms
// 由 toolbar 按钮直接 toggle 0 ↔ 500UI 也不再有「选几秒」的下拉。
readerFontSize: Object.freeze([14, 15, 17, 19, 22]),
readerLineHeight: Object.freeze([1.5, 1.7, 1.85, 2.0]),
// 两个选项都标"兼容":突出是「按这个协议实现的兼容 API」而不是特定厂商
// 用户可填任意走该协议的 baseURL中转、自部署、官方 API 都行)。
aiProvider: Object.freeze([
{ value: 'openai', label: 'OpenAI 兼容', hint: '/chat/completions · 含 DeepSeek / Moonshot / Azure 等' },
{ value: 'anthropic', label: 'Anthropic 兼容', hint: '/v1/messages · Claude 系列 · 含第三方中转' },
]),
});
/**
* 校验 + sanitize 单个键。
* 返回 { ok:true, value } 或 { ok:false, error }。
*
* @param {string} key
* @param {*} raw
* @param {object} [opts] - { resolveDir?: async (path) => { ok, error? } }
* 注入目录存在性校验(默认走 fs调用方可传 mock
* @returns {Promise<{ok: true, value: *} | {ok: false, error: string}>}
*/
async function validateKey(key, raw, opts = {}) {
const rule = SETTINGS_SCHEMA[key];
if (!rule) {
// 未知键直接拒绝(防止 renderer 误传)
return { ok: false, error: `未知设置项: ${key}` };
}
// nullable 类型null 一律放行
if ((rule.type === 'nullable-string' || rule.type === 'nullable-path' || rule.type === 'nullable-number') && raw === null) {
return { ok: true, value: null };
}
// 类型分发
switch (rule.type) {
case 'nullable-path': {
if (typeof raw !== 'string') return { ok: false, error: `${key} 必须是字符串或 null` };
const trimmed = raw.trim();
if (!trimmed) return { ok: true, value: null };
if (opts.resolveDir) {
const r = await opts.resolveDir(trimmed);
if (!r.ok) return { ok: false, error: r.error };
}
return { ok: true, value: trimmed };
}
case 'nullable-string': {
if (typeof raw !== 'string') return { ok: false, error: `${key} 必须是字符串或 null` };
if (typeof rule.max === 'number' && raw.length > rule.max) {
return { ok: false, error: `${key} 过长(超过 ${rule.max} 字符)` };
}
return { ok: true, value: raw };
}
case 'string': {
if (typeof raw !== 'string') return { ok: false, error: `${key} 必须是字符串` };
// P2 fixtrim 首尾空白 —— 用户复制粘贴 AI Key / Model 经常带回车 / 空格,
// 不 trim 会让 main/ai.js 把它当字面量放进 Authorization header / 请求体,
// 服务端校验「key 不匹配」但错误信息没有「多打了空格」的提示,用户无从下手。
// 用户手动改 settings.json 也经常留尾空格。
const trimmed = raw.trim();
// rule.max 限定字符串长度 —— 防止几 MB 的值把请求体撑爆 / 拖慢序列化。
// 用户看到的错误直接说"过长",避免报"非法 JSON"。
// audit fix之前误写成 opts.max导致 aiApiKey/aiBaseUrl/aiModel/
// aiSystemPrompt 声明的上限从未生效。)
if (typeof rule.max === 'number' && trimmed.length > rule.max) {
return { ok: false, error: `${key} 过长(超过 ${rule.max} 字符)` };
}
// audit fix (#9 shared):可选 format 校验。空串放行(清空合法);
// 非空按 format 规则走,失败给中文错误。
if (trimmed && rule.format === 'url-https') {
let parsed;
try {
parsed = new URL(trimmed);
} catch {
return { ok: false, error: `${key} 不是合法的 URL需以 http:// 或 https:// 开头)` };
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { ok: false, error: `${key} 必须是 http(s) URL当前协议 ${parsed.protocol}` };
}
}
return { ok: true, value: trimmed };
}
case 'boolean': {
if (typeof raw !== 'boolean') return { ok: false, error: `${key} 必须是布尔` };
return { ok: true, value: raw };
}
case 'number': {
// audit C3拒绝 null / undefined / 空串 / 非数字 —— Number('') === 0 会
// 让 `readerFontSize: ''` 静默通过 clamp 到 min这是 bug不是「合法 0」。
// 合法输入:实际数字 或 非空数字字符串。
if (raw === null || raw === undefined) {
return { ok: false, error: `${key} 必须是数字` };
}
if (typeof raw === 'string' && raw.trim() === '') {
return { ok: false, error: `${key} 必须是数字` };
}
const n = Number(raw);
if (!Number.isFinite(n)) return { ok: false, error: `${key} 必须是数字` };
let v = n;
if (rule.clamp && (v < rule.min || v > rule.max)) {
v = Math.min(Math.max(v, rule.min), rule.max);
}
if (rule.integer) v = Math.round(v);
if (typeof rule.round === 'number') {
const k = 10 ** rule.round;
v = Math.round(v * k) / k;
}
if (v < rule.min || v > rule.max) {
return { ok: false, error: `${key} 超出范围 [${rule.min}, ${rule.max}]` };
}
return { ok: true, value: v };
}
case 'nullable-number': {
// audit fix (Round 9):与 'number' 分支对齐显式拒绝 null / undefined /
// 空串 / 非数字。Number('') === 0 会让 nullable-number 字段把空串静默
// 转成 0 再 clamp 到 min —— 与上面 'number' 分支同款 bug。null 是
// 合法值(保留为 null让 UI 显示「未设置」),但空串 / 非数字必须拒绝。
//
// 审计修复 (Round 11 deep-fix P2-3)undefined 在 nullable 字段应等价于 null。
// 旧版异步路径拒绝 undefined → 同步 sanitizeSync 路径却把 undefined 当作 null
// 手改 config.json 时如果字段被删JSON.stringify 会序列化成 undefined → 字段缺失,
// 但 settings-dialog applySetting 走异步路径)会出现 update() reject / load() accept 的不对称。
// 现在 undefined 走「视为 null」分支。
if (raw === null || raw === undefined) return { ok: true, value: null };
if (typeof raw === 'string' && raw.trim() === '') {
return { ok: false, error: `${key} 必须是数字或 null` };
}
const n = Number(raw);
if (!Number.isFinite(n)) return { ok: false, error: `${key} 必须是数字或 null` };
let v = n;
if (rule.integer) v = Math.round(v);
if (v < rule.min || v > rule.max) {
return { ok: false, error: `${key} 超出范围 [${rule.min}, ${rule.max}]` };
}
return { ok: true, value: v };
}
case 'enum': {
// enum值在列表内即放行兼容 string / number 字面量;如 0 也要命中)
const allowed = rule.enum.some((e) => e === raw) ||
(typeof raw === 'string' && rule.enum.includes(raw));
if (!allowed) return { ok: false, error: `${key} 取值非法: ${raw}` };
return { ok: true, value: raw };
}
default:
return { ok: false, error: `${key} 类型未定义: ${rule.type}` };
}
}
/**
* 批量校验 + sanitize。
* 跳过未在 partial 里出现的键(局部更新)。
*
* @param {object} partial
* @param {object} [opts] - 同 validateKey
* @returns {Promise<{ok: true, sanitized: object} | {ok: false, error: string}>}
*/
async function validateAndSanitize(partial, opts = {}) {
if (!partial || typeof partial !== 'object' || Array.isArray(partial)) {
return { ok: false, error: '请求体必须是对象' };
}
/** @type {Record<string, *>} */
const sanitized = {};
for (const key of Object.keys(partial)) {
const r = await validateKey(key, partial[key], opts);
if (!r.ok) return r;
sanitized[key] = r.value;
}
return { ok: true, sanitized };
}
/**
* 与默认合并 + 校验完整 settings 对象(启动 / 读取配置文件时用)。
*
* 同步校验 —— 故意不 await fs 检查 dataDir 存在性load 阶段不阻塞);
* dataDir 存在性推迟到 settings-store.update 时再走 validateAndSanitize。
* 但同步可校验的部分enum / 范围 / 类型)必须现在就做,否则手改的
* config.json 会把整个 UI 弄坏audit #8
*
* @param {*} raw
* @returns {Record<string, *>}
*/
function coerceLoadedSettings(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return { ...DEFAULT_SETTINGS };
}
// 旧版 autoSaveIntervalSec (enum: 0/3/10 秒 轮询) → autoSaveDebounceMs (ms 防抖)
// 语义变了:「每隔 X 秒轮询」变成「停打 X ms 后保存」。所有「开启」档位
// (旧 3 秒、10 秒)一律映射到新的 500ms 默认值,避免老用户升级后自动保存
// 突然变得非常激进1s 内反复触发或太迟10s 才一次)。
if ('autoSaveIntervalSec' in raw) {
const old = raw.autoSaveIntervalSec;
if (!('autoSaveDebounceMs' in raw)) {
const offish = old === 0 || old === '0' || old === false;
raw.autoSaveDebounceMs = offish ? 0 : 500;
}
delete raw.autoSaveIntervalSec;
}
/** @type {Record<string, *>} */
const out = { ...DEFAULT_SETTINGS };
for (const key of Object.keys(DEFAULT_SETTINGS)) {
if (!(key in raw)) continue;
const sanitized = sanitizeSync(key, raw[key]);
// sanitizeSync 返回 undefined 表示「未知键 / 不可修复」,跳过即可
if (sanitized !== undefined) out[key] = sanitized;
}
// Phase N 修复:保留白名单内的 `_`-前缀元数据键(当前只有 `_hasAiKey`)。
// 之前只迭代 Object.keys(DEFAULT_SETTINGS) 把未声明的键全 drop —— get-settings
// 返回的 `_hasAiKey: !!cfg.aiApiKey` 在 coerce 阶段被吃掉renderer 永远拿不到
// "已配置 API Key" 信号,"显示已填 key" + reveal 流程全失效。
//
// 防御:必须用白名单而不是"所有下划线前缀键都过"。否则攻击者 / 误用方可用
// `_xxx` 形式把任意字段塞进内存 settings虽然不会写盘但能在内存里残留
// 白名单维护成本低(已知元数据键只有少数几个),但放行成本高。
const METADATA_KEYS = new Set(['_hasAiKey']);
for (const key of Object.keys(raw)) {
if (METADATA_KEYS.has(key) && !(key in out)) {
out[key] = raw[key];
}
}
return out;
}
/**
* 同步版本的单键 sanitize —— 只做不依赖 IO 的检查。
* 与 validateKey 共享规则,但把 fs 检查nullable-path推迟到 update 阶段。
*
* @param {string} key
* @param {*} raw
* @returns {*} 合法值;不可修复时返回 undefined调用方应忽略这个键
*/
function sanitizeSync(key, raw) {
const rule = SETTINGS_SCHEMA[key];
if (!rule) return undefined; // 未知键:丢弃
// nullablenull 合法
if ((rule.type === 'nullable-string' || rule.type === 'nullable-path' || rule.type === 'nullable-number') && raw === null) {
return null;
}
switch (rule.type) {
case 'nullable-path':
case 'string': {
if (typeof raw !== 'string') return undefined;
// audit fix (Round 4 P1-1)nullable-path 与 validateKey 异步路径对齐——空串归一为 null。
// 之前 sanitizeSync 直接返回 ''coerceLoadedSettings 把磁盘上残留的
// `"dataDir": ""` 保留为 '',但 validateKey 异步路径会归一为 null两条路径
// 语义不同步 → 任何依赖 dataDir === null 判断的代码失配resolveDataDir
// 靠 .trim() 兜底不崩但漏检 null 路径)。
if (rule.type === 'nullable-path' && raw.trim() === '') return null;
// 同步路径coerceLoadedSettings / sanitizeSync也要尊重 max —— 用户
// 手工改坏 settings.json 时同样不能让几 MB 的字符串进入运行时。
if (typeof rule.max === 'number' && raw.length > rule.max) return undefined;
// 审计修复 (Round 11 deep-fix P2-3):同步路径也 trim前后空白不再让 URL 校验
// 失败。async validateKey 早就 trimRound 8 fix同步路径遗漏导致
// load(): 不 trim 直接 new URL(' https://api.example.com ')
// update(): trim 后校验
// 两次读同一字段返回不同值UI 看着值变了(实际上是同一字符串的展示差异)。
// 注意trim 后可能变空字符串,与 max > 0 但被 trim 成空的 case 区分;
// 这里把 trim 后空串仍走原 length 检查(空串会让 url-https 校验短路,
// 但保留 nullable-path 上面已经拦截过的场景)。
const trimmed = (rule.type === 'string' || rule.type === 'nullable-string' || rule.type === 'path') ? raw.trim() : raw;
if (typeof rule.max === 'number' && trimmed.length > rule.max) return undefined;
// fix(audit 2026-08):同步路径也要校验 format。旧版只 validateKeyasync 路径)
// 校验 formatcoerceLoadedSettings 直接放行 → 手改 settings.json 把 aiBaseUrl
// 写成 "not-a-url" / "ftp://xxx" 也会被加载,渲染端拿到的值是无效 URL
// 真正 fetch 时才报 TypeError: fetch failed错误链很难定位到 settings。
if (trimmed.length > 0 && rule.format === 'url-https') {
// 必须前缀严格是 http:// 或 https://,避免 'http:/missing-slash' 这种
// URL 构造器能解析但实际 fetch 行为不一致的 case。
if (!/^https?:\/\//i.test(trimmed)) return undefined;
let parsed;
try {
parsed = new URL(trimmed);
} catch {
return undefined;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;
}
return trimmed;
}
case 'nullable-string': {
if (typeof raw !== 'string') return undefined;
if (typeof rule.max === 'number' && raw.length > rule.max) return undefined;
// fix(audit 2026-08)nullable-string 与 string 一样需要 format 校验,
// 否则未来 schema 加 nullable-string + format 字段会静默失效。
if (raw.length > 0 && rule.format === 'url-https') {
if (!/^https?:\/\//i.test(raw)) return undefined;
let parsed;
try {
parsed = new URL(raw);
} catch {
return undefined;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;
}
return raw;
}
case 'boolean':
return typeof raw === 'boolean' ? raw : undefined;
case 'number': {
// fix(audit 2026-08):拒绝 Boolean 输入。Number(true) === 1,
// Number(false) === 0 —— 旧版会让 autoSaveDebounceMs: true 静默变 1ms
// (激进到每个 keystroke 都存splitRatio: false 变 0无预览面板
// 合法输入限定为 number 或非空数字字符串。
if (raw === null || raw === undefined) return undefined;
if (typeof raw !== 'number' && typeof raw !== 'string') return undefined;
if (typeof raw === 'string' && raw.trim() === '') return undefined;
const n = Number(raw);
if (!Number.isFinite(n)) return undefined;
let v = n;
if (rule.clamp && (v < rule.min || v > rule.max)) {
v = Math.min(Math.max(v, rule.min), rule.max);
}
if (rule.integer) v = Math.round(v);
if (typeof rule.round === 'number') {
const k = 10 ** rule.round;
v = Math.round(v * k) / k;
}
// clamp 后还在范围外(例如 raw 是 NaN / Infinity→ 拒收
if (v < rule.min || v > rule.max) return undefined;
return v;
}
case 'nullable-number': {
// audit fix (Round 4 P2-3):与 number 分支对称——拒绝 Boolean 输入。
// 原版无 typeof 守卫Number(true) === 1 隐式通过 isFinite目前 schema
// 用 nullable-number 的字段sidebarWidth / aiWidthmin 检查会拦下 1
// 但语义上与 number 不一致,且未来加更宽 min 范围的字段会绕过。复制上方
// number 分支的 typeof 守卫保持两条路径对称。
if (raw === null || raw === undefined) return null;
if (typeof raw !== 'number' && typeof raw !== 'string') return undefined;
if (typeof raw === 'string' && raw.trim() === '') return undefined;
const n = Number(raw);
if (!Number.isFinite(n)) return undefined;
let v = n;
if (rule.integer) v = Math.round(v);
if (v < rule.min || v > rule.max) return undefined;
return v;
}
case 'enum': {
// 数字 enum 接受 number字符串 enum 接受 string 字面量
const ok = rule.enum.some((e) => e === raw)
|| (typeof raw === 'string' && rule.enum.includes(raw));
return ok ? raw : undefined;
}
default:
return undefined;
}
}
module.exports = {
DEFAULT_SETTINGS,
SETTINGS_SCHEMA,
SETTINGS_UI_OPTIONS,
validateKey,
validateAndSanitize,
coerceLoadedSettings,
};

78
shared/slug.js Normal file
View File

@@ -0,0 +1,78 @@
// Heading slug 的唯一算法preload + renderer 共用)
//
// 唯一调用点:把 heading 文本转成 [A-Za-z0-9-]+ 形式的 slug id。
// 因为 preloadmarked renderer.heading和 renderer 端都会
// 产生「指向同一 DOM 节点」的 id必须用同一份算法 —— 否则
// 文内锚点会因 id 不一致定位失败。
//
// 设计取舍:
// - 保留 \p{L} / \p{N} / \p{M}CJK含扩展平面 A/B/...+ 拉丁扩展字母都能保留
// - 删除 markdown 行内 HTML 标签 / 反引号 / 星号 / 下划线 / 波浪号:
// 这些「标记符号」不应进 id否则直接复制渲染出的 id 会得到带引号的字符串
// (下划线一并剥离,与 marked 旧默认行为一致GitHub 是保留的,
// 但那会让 `hello_world` / `_em_` 这种 heading 算成同一个 base撞名重
// - 空白 → `-`;不裁首尾 `-`:避免 `# --foo--` → `foo` 这种出乎用户意料的别名
// - 纯符号 / 空白输入返回 `''`:由 `slugifyHeading`(带 `seenSlugs` 的版本)
// 兜底成 `'section'`,保证渲染出的 DOM id 非空可点击
//
// 这份文件被两类消费者使用:
// - preload.jsCJSrequiremarked renderer 决定 DOM 上的真实 id
// - src/outline.jsrenderer ESM源码级镜像一份renderer 不能 import CJS
// 改算法 = 同步改两边 + 测试 + 检查 outline.test.js 与 slug.test.js 的期望值。
'use strict';
/**
* 把 heading 文本转成「基础 slug」无重复检测、无空值兜底
*
* 返回空串意味着:原文剥完 HTML + 行内标记后什么都不剩(纯符号 heading
* outline.js 用这一点判断是否要跳过这个 heading导航没意义
* preload.js 的 slugifyHeading 把它当 base再走 `|| 'section'` 兜底 + 撞名加后缀。
*
* @param {string} raw
* @returns {string}
*/
function slugifyHeadingBase(raw) {
// audit fix (shared-M5):先 normalize('NFC') 把 NFD 字符串(如 macOS
// 默认文件系统产出的 café 这种「e + 组合 ́」)合并成预组合字符,
// 再做后续 replace。否则 NFD 与 NFC 的同一逻辑 heading 会生成两个不同
// DOM idoutline 点击就会跳到错误锚点。Windows / WSL / 云盘同步经常会
// 带来混合 normalization这个守卫保证 slug 只看逻辑字符。
return String(raw)
.normalize('NFC')
.replace(/<[^>]*>/g, '') // 行内 HTML
.replace(/[`*_~]/g, '') // 行内标记符号
.trim()
.toLowerCase()
// \p{L} = 任意 Unicode 字母(含中日韩),\p{N} = 数字,\p{M} = 组合记号
.replace(/[^\p{L}\p{N}\p{M}\s-]/gu, '')
.replace(/\s+/g, '-');
}
/**
* 带重复检测的 slug 生成器。
*
* 同名 heading 在文档内会得到 `-1`、`-2`、... 后缀marked 默认行为)。
* 调用方负责在每次「解析整篇文档」前清空 `seenSlugs`,让计数按文档重置。
* 空 base 回退 `'section'`,保证 id 始终非空可点击。
*
* @param {string} raw
* @param {Set<string>} seenSlugs - 已被本次解析用过的 slug 集合
* @returns {string}
*/
function slugifyHeading(raw, seenSlugs) {
const base = slugifyHeadingBase(raw) || 'section';
let slug = base;
let i = 1;
while (seenSlugs.has(slug)) {
slug = `${base}-${i}`;
i += 1;
}
seenSlugs.add(slug);
return slug;
}
module.exports = {
slugifyHeadingBase,
slugifyHeading,
};