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

938
main/ai.js Normal file
View File

@@ -0,0 +1,938 @@
// 主进程 AI 代理
// ============================================================================
//
// 通过 Node 18+ 内置 fetch 调用 LLM API。两条协议分支
// - aiProvider === 'openai'(默认)→ POST {baseURL}/chat/completions
// Authorization: Bearer ...
// - aiProvider === 'anthropic' → POST {baseURL}/v1/messages
// x-api-key: ... + anthropic-version
//
// 不使用 openai / @anthropic-ai SDK避免引入依赖统一在主进程发起
// 隐藏 apiKey统一错误结构供 renderer 弹 toast
//
// 设计要点:
// - 每个请求用一个 requestId 追踪;调用方通过 ai:cancel IPC 终止
// - 配置provider / baseURL / apiKey / model / systemPrompt
// configStore.getConfig() 现读现用,用户改完设置不需要重启
// - 错误统一返回 { ok:false, error, message }error 是稳定的错误码字符串,
// message 是用户能看的中文
// - API Key 不写日志;请求/响应调试时只用长度 + 状态码
//
// 协议差异(两个分支各自完整处理;共享的 cancel / 校验 / 响应后处理 抽到 helper
// 请求体:
// OpenAI : { model, temperature, max_tokens, max_completion_tokens,
// messages: [{role, content}], stream:false }
// Anthropic : { model, max_tokens, system, messages: [{role, content}], ... }
//
// 响应体:
// OpenAI : choices[0].message.content (string)
// choices[0].finish_reason === 'length' 截断
// Anthropic : content[].text (拼接所有 text 块)
// stop_reason === 'max_tokens' 截断
// ============================================================================
'use strict';
const CURRENT_FILE_EDIT_SYSTEM_PROMPT =
'你是 Markdown 文本助手。输入 JSON 格式含 filename、currentMarkdown、userPrompt。请按 userPrompt 修改 currentMarkdown然后直接输出 JSON 格式:{"content":"完整 Markdown"}。如果 userPrompt 与文档修改无关,或者是 {"content":"完整 Markdown"} 内容和 currentMarkdown 完全一致,直接简单回复即可,不需要回复 {"content":"完整 Markdown"}。';
const DEFAULT_TIMEOUT_MS = 300_000;
// 内容长度硬上限:防止单次请求几 MB 把 LLM 计费用爆、把事件循环卡住。
// 1.5 MB ≈ 38 万字符 / 10 万行;超过的文档让用户拆分或手动改。
const MAX_CONTENT_BYTES = 1_500_000;
// 响应体大小上限:防止恶意 / 错误配置的服务器返回几百 MB body 把主进程 OOM。
// 5 MB 对正常 AI 回复(几十 KB 到 1 MB足够宽松。
const MAX_RESP_BYTES = 5 * 1024 * 1024;
// tryParseJson 输入硬上限getJsonCandidates 里的 fenced block 正则 +
// indexOf/lastIndexOf/slice 在 5 MB 字符串上仍有可观 CPU 占用,
// 且对对抗性输入(无闭合 ```)会让非贪婪量词扫到尾;提前砍掉尾巴,
// 让 JSON 解析失败直接走 extractRawReply 的回退路径。
const MAX_PARSE_INPUT_BYTES = 2_000_000;
// Anthropic API 当前稳定版本2023-06-01 之后未再变)
const ANTHROPIC_API_VERSION = '2023-06-01';
// audit fix (CQ-MED-7):错误码字面值与 renderer 端共享。preload 经 contextBridge
// 把同一份 AI_ERROR 暴露到 window.api.aiErrorsmain / renderer 永远引用同一对象,
// 不再靠注释提醒同步。
const { AI_ERROR } = require('../shared/ai-errors.js');
const ERR_NOT_CONFIGURED = AI_ERROR.NOT_CONFIGURED;
const ERR_TIMEOUT = AI_ERROR.ERR_TIMEOUT;
const ERR_PROVIDER = AI_ERROR.ERR_PROVIDER;
const ERR_FORMAT = AI_ERROR.ERR_FORMAT;
const ERR_CANCELLED = AI_ERROR.ERR_CANCELLED;
const TRUNCATED_MESSAGE = 'AI 修改结果不完整,请缩小文档或简化要求后重试。';
/**
* 把超长输入砍到上限内(按 UTF-8 字节。MAX_PARSE_INPUT_BYTES 之外的尾部
* 在多数 AI 模型回复里没有意义(远早于 JSON 边界)—— 直接截掉既防 ReDoS
* 又让正则 / indexOf 不再 O(n²) 退化。
* @param {string} message
* @returns {string}
*/
function capForParse(message) {
const s = String(message || '');
if (Buffer.byteLength(s, 'utf8') <= MAX_PARSE_INPUT_BYTES) return s;
// 按字符截可能切到 UTF-8 序列中间;用 Buffer 切字节再转回字符串,
// 最后若尾部半个 multi-byte 用 toString('utf8') 会被替换成 U+FFFD
// 但 JSON.parse 会立即抛 SyntaxError → 由 tryParseJson 的 catch 兜底。
return Buffer.from(s, 'utf8').subarray(0, MAX_PARSE_INPUT_BYTES).toString('utf8');
}
/**
* 提取 AI 消息中的 JSON 候选trimmed 原文 / 三反引号代码块 / 第一个 { 到最后一个 }。
* @param {string} message
* @returns {string[]}
*/
function getJsonCandidates(message) {
const trimmed = capForParse(message).trim();
const candidates = [trimmed];
const fencedBlock = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
if (fencedBlock && fencedBlock[1]) {
candidates.push(fencedBlock[1].trim());
}
const jsonStart = trimmed.indexOf('{');
const jsonEnd = trimmed.lastIndexOf('}');
if (jsonStart !== -1 && jsonEnd > jsonStart) {
candidates.push(trimmed.slice(jsonStart, jsonEnd + 1));
}
return [...new Set(candidates)];
}
function tryParseJson(message) {
for (const candidate of getJsonCandidates(message)) {
try {
return JSON.parse(candidate);
} catch {
// continue
}
}
return undefined;
}
/**
* 在 message 中找第一个花括号配对的 JSON 对象,并把对象文本截出来。
* 简单字符串扫描 —— O(n) 处理嵌套 `{` `}` 和字符串字面量(避免 JSON 内容里的
* 引号 / 反斜杠把花括号配对误判)。找不到配对返回 null。
*
* 与 getJsonCandidates 的 brace-pair 切片不同:这里还要在体内识别字符串里的
* 转义序列(`\"` / `\\`),因此专写一个实现而非复用 indexOf/lastIndexOf。
*
* @param {string} s
* @returns {string|null}
*/
function extractFirstJsonObject(s) {
const str = String(s || '');
const len = str.length;
let start = -1;
let depth = 0;
let inStr = false;
let escape = false;
for (let i = 0; i < len; i++) {
const ch = str[i];
if (inStr) {
if (escape) { escape = false; continue; }
if (ch === '\\') { escape = true; continue; }
if (ch === '"') { inStr = false; }
continue;
}
if (ch === '"') { inStr = true; continue; }
if (ch === '{') {
if (start < 0) start = i;
depth++;
continue;
}
if (ch === '}') {
if (depth === 0) continue;
depth--;
if (depth === 0 && start >= 0) return str.slice(start, i + 1);
}
}
return null;
}
function looksLikeEditJson(message) {
// audit fix之前的 /"content"\s*:/ 太宽松 —— 用户提示词里只要含 `"content":`
// 子串就会被当作「AI 改稿的 JSON 截断」,误报 TRUNCATED_MESSAGE。
// 第二版 `/\{[^{}]*"content"\s*:[^{}]*\}/` 又过紧 —— 当 content 字段后跟随嵌套
// 对象(例:`{"content":"x","patches":[{"op":"replace"}]}`)时 `[^{}]*` 立刻失配,
// AI 返回"看似想输出 JSON 但 token 不够截断"的场景下TRUNCATED 提示被静默吃掉。
//
// 现在走 extractFirstJsonObject 取出第一个配对的 JSON 对象文本,再在体内找
// `"content"` 键(允许值跨多行、允许嵌套、字符串里的 " 不会干扰)。
// —— 简单纯文本("please edit {content} now")也不会命中,因为:
// 1. 字符串里的花括号不算嵌套对象起点inStr 分支已处理);
// 2. 真正配对的对象才走 key 搜索。
//
// 截断的 JSON花括号未闭合 / 数组未闭合extractFirstJsonObject 找不到配对对象,
// 这里直接返回 false —— normalizeAssistantText 会走 raw 回退把残文本返回给用户。
// 用户能看到 AI 输出了什么,比直接弹"不完整"更直观。截断信号应当由上游
// finish_reason=length / stop_reason=max_tokens 在更早的路径触发,不依赖正文配对。
const obj = extractFirstJsonObject(message);
if (!obj) return false;
// 体内 key 检测:用 `"content"` 加 `:` 兜住常见间距(`"content" :` / `"content":`
// 不复用 contains('"content"') 是为了避免匹配键名包含 content 子串的字段
// (如 `"mycontent":1` —— 但这种情况极少见,多一道正则更稳)。
return /"content"\s*:/.test(obj);
}
/**
* 从非 JSON 解析得到的对象里挑 reply/message/text/answer 字段当作纯文本。
* @param {unknown} parsed
* @returns {string | null}
*/
function extractRawReply(parsed) {
if (!parsed || typeof parsed !== 'object') return null;
for (const key of ['reply', 'message', 'text', 'answer']) {
const value = /** @type {Record<string, unknown>} */ (parsed)[key];
if (typeof value === 'string') return value;
}
return null;
}
/**
* 拼接 baseURL + path自动处理末尾斜杠。
* @param {string} base
* @param {string} path
* @returns {string}
*/
function joinUrl(base, path) {
// P3-3 fix (audit):拆分 query / fragment 后再拼。
// 否则 `https://gw.com?token=abc` 会被拼成 `https://gw.com?token=abc/v1/messages`
// query 后被拼了 pathURL 非法。query / fragment 也可能在错误回显时
// 包含 api_key 之类敏感 token所以一并禁止放在 baseURL 里。
// M2 fix (audit):剥掉 baseURL 里可能存在的 userinfo`https://user:pass@host`)。
// fetch 会把 userinfo 当 Basic Auth 自动发送,把 API Key 当用户名/密码发给中转服务,
// 敏感凭据直接泄露到第三方;错误回显里也会暴露凭据。
const s = String(base || '');
const m = s.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/);
if (!m) return s;
let b = m[1].replace(/\/+$/, '');
b = b.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/, '$1');
const p = String(path || '').replace(/^\/+/, '');
return `${b}/${p}${m[2] || ''}${m[3] || ''}`;
}
/**
* 过滤错误回显里的敏感 tokenOpenAI/Anthropic/proxy 可能在错误信息里
* echo URL 或 header。API Key 不该出现在用户能看到的 toast 里。
* P2-2 fix (audit)Q3 fix (audit):补 Google API key / JWT / proxy-authorization。
* @param {string} detail
* @returns {string}
*/
function sanitizeDetail(detail) {
return String(detail || '')
// OpenAI / 通用 OpenAI 风格 keysk-xxx / sk-proj-xxx / sk-ant-xxx
// M3 fix (audit):阈值 8 → 5捕获被截断的 key错误回显常见前缀模式 sk-12ab...)。
// 5 是保守下限:正常文本里 5 位随机 base64url 不常见
.replace(/sk-[A-Za-z0-9_-]{5,}/g, '[API_KEY]')
// Google API keyAIzaSy 开头 + 33 字符。33 是 Google 当前规范
.replace(/AIzaSy[A-Za-z0-9_-]{20,}/g, '[API_KEY]')
// JWTheader.payload.signature 三段 base64url至少各 8 字符避免误伤短词
.replace(/eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[JWT]')
// M3 fix (audit):先单独处理 JSON 风格 `"authorization": "Bearer sk-xxx"`——
// 旧正则 `[^\s,;&}"']+` 遇到 `"` 就停,只盖到 `"Bearer`,剩余 ` sk-xxx"`
// 直接漏到下一步被文本截断逻辑保留。现在用单独 pattern 一次性吃掉整段带引号的值。
// audit fix (Phase O-L19):扩到 `authentication` / `www-authenticate` / `cookie` /
// `set-cookie` —— 自部署中转 / Azure gateway 偶尔回 `Authentication: Bearer sk-xxx`
// LLM 自定义代理常通过 cookie 传 key。原始 key 不会泄露line 229 的 sk-/AIzaSy
// 兜底会替成 [API_KEY]),但 header 名这一行会在 detail 文本里残留。
.replace(/("(?:proxy-authorization|x-api-key|authorization|authentication|www-authenticate|cookie|set-cookie)"\s*:\s*)"[^"]*"/gi, '$1"[REDACTED]"')
// 任意 header 里出现敏感 token —— 含 proxy-authorizationQ3 audit 新增)
// 不再排除 `"` 和 `'`:让正则跨过引号吃掉值(与 JSON pattern 互补,命中 form-style
.replace(/(proxy-authorization|x-api-key|authorization|authentication|www-authenticate|cookie|set-cookie)\s*[:=]\s*[^\s,;&}]+/gi, '$1=[REDACTED]')
// URL query 或 form body 里的 key/token
.replace(/(api[_-]?key|token)\s*=\s*[^\s,;&}]+/gi, '$1=[REDACTED]');
}
/**
* 把 URL 中可能携带 secret 的部分脱敏再写到日志。
* 1. 去掉 query 和 fragment用户 baseURL 不该带 ?api_key=xxx / #fragment但有人会带
* 2. path 段里嵌入的 secret tokenOpenAI sk-xxx / Google AIzaSy也遮罩
* 保留 scheme + host + path便于调试"请求打到哪个域名",但不带任何 secret。
* Q6 fix (audit)。
* @param {string} raw
* @returns {string}
*/
function sanitizeUrl(raw) {
return String(raw || '')
// M2 fix (audit):先剥 userinfohttps://user:pass@host否则 fetch Basic Auth
// 凭据会被完整写到错误日志(即使 query/fragment 已剥userinfo 仍在 host 前)
.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/, '$1')
.replace(/[?#].*$/, '') // 去 query / fragment
.replace(/\/(sk-[A-Za-z0-9_-]{8,})/g, '/[REDACTED]') // path 段里的 OpenAI key
.replace(/\/(AIzaSy[A-Za-z0-9_-]{20,})/g, '/[REDACTED]'); // path 段里的 Google key
}
/**
* 读 response body 到字符串,超过 MAX_RESP_BYTES 立刻中断。
* P2-3 fix (audit)。
* @param {Response} res
* @returns {Promise<{ ok:true, text:string } | { ok:false, message:string }>}
*/
async function readBodyWithLimit(res) {
if (!res.body || typeof res.body.getReader !== 'function') {
// 旧版 fetch / mockfallback 到 .text(),上限由 .text() 自带的内存限制兜底
try {
const text = await res.text();
if (Buffer.byteLength(text, 'utf8') > MAX_RESP_BYTES) {
return { ok: false, message: 'AI 响应体过大' };
}
return { ok: true, text };
} catch {
return { ok: false, message: '读取 AI 响应失败' };
}
}
const reader = res.body.getReader();
/** @type {Buffer[]} */
const chunks = [];
let total = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > MAX_RESP_BYTES) {
try { reader.cancel(); } catch { /* ignore */ }
return { ok: false, message: 'AI 响应体过大(超过 5 MB' };
}
chunks.push(Buffer.from(value));
}
return { ok: true, text: Buffer.concat(chunks).toString('utf8') };
}
/**
* 创建主进程 AI 代理。
* @param {object} deps
* @param {() => object} deps.getConfig - 取最新设置configStore.getConfig每次 runEdit 都会调用
* @param {typeof fetch} [deps.fetchImpl] - 注入 fetch测试用
* @param {(key: string, payload: object) => void} [deps.log] - 日志钩子(不记录 apiKey
*/
function createAiProxy({ getConfig, fetchImpl, log } = {}) {
if (typeof getConfig !== 'function') {
throw new Error('[ai] getConfig 必须是函数');
}
const fetchFn = fetchImpl || ((...args) => fetch(...args));
const logFn = typeof log === 'function' ? log : () => {};
/** @type {Map<string, AbortController>} */
const pending = new Map();
/** requestId → 它的超时定时器。cancel 时一起清,避免 setTimeout 回调空转。 */
const timers = new Map();
/** audit fix (C4)请求生命周期登记集合runEdit 入口 → finally用于
* 真正拦截同 requestId 的重复进入 —— 之前的 pending 集合是 postJson 内部填的,
* runEdit 同步阶段检查永远是空,已被注释自承为 dead code。 */
const inFlightRequestIds = new Set();
function cancel(requestId) {
const controller = pending.get(requestId);
const timer = timers.get(requestId);
if (controller) {
controller.abort();
pending.delete(requestId);
}
if (timer) {
clearTimeout(timer);
timers.delete(requestId);
}
}
function cancelAll() {
for (const controller of pending.values()) controller.abort();
pending.clear();
// C1 (audit):与 cancel() 一样同步清理 timeout避免 setTimeout 回调空转
for (const timer of timers.values()) clearTimeout(timer);
timers.clear();
}
/**
* 校验 baseURL 是否为合法 http(s) URL。
* 防止 javascript: / file: / data: 等伪协议触发 fetch TypeError 后报成"网络错误"误导用户。
* M1 (audit):旧版只走 fetch 抛错,错误信息不友好。
* @param {string} base
* @returns {{ok:true, value:string} | {ok:false, reason:string}}
*/
function validateBaseUrl(base) {
const s = String(base || '').trim();
if (!s) return { ok: false, reason: 'Base URL 不能为空' };
if (!/^https?:\/\//i.test(s)) {
return { ok: false, reason: 'Base URL 必须以 http:// 或 https:// 开头' };
}
// P2 fixhostname 校验 —— 之前只校验前缀,`https:///etc/passwd`(缺少 host
// / `https:// host`(带空格)会被放行,然后 fetch 抛「ENOTFOUND / Invalid URL」
// 但错误信息毫无线索。new URL 直接拒绝这些畸形输入,给出可执行反馈。
try {
const parsed = new URL(s);
if (!parsed.hostname) {
return { ok: false, reason: 'Base URL 缺少主机名' };
}
// 主机名不能包含空白字符或 ASCII 控制字符(防御一些浏览器容忍的奇怪输入)
// eslint-disable-next-line no-control-regex -- 控制字符范围是刻意检查的非法字符
if (/[\s\x00-\x1f]/.test(parsed.hostname)) {
return { ok: false, reason: 'Base URL 主机名包含非法字符' };
}
} catch (e) {
return { ok: false, reason: 'Base URL 不是合法 URL' + (e && e.message || '') };
}
return { ok: true, value: s };
}
/**
* 按模型名路由 token 上限 + token 字段。
* H1 (audit)65536 远超多数模型上限gpt-3.5=4096, gpt-4=8192, gpt-4o=16384
* 服务端可能直接 400 拒绝或抛 invalid_request_error。
* H2 (audit)gpt-5 / o-series 只接受 max_completion_tokens旧字段会触发
* "Unsupported parameter" 错误;老模型反过来——只接受 max_tokens。
* H5 (audit)gpt-5 / o-series 不接受自定义 temperatureo1 固定为 1
* 发 0.2 会 400 invalid_request_error。
* @param {string} model
* @returns {{ capTokens: number, tokenField: 'max_tokens' | 'max_completion_tokens', includeTemperature: boolean }}
*/
function pickOpenAITokenConfig(model) {
const m = String(model || '').toLowerCase();
// gpt-5 / o-series → 必须用 max_completion_tokens上限通常 ≥ 128k不传 temperature
if (/^(gpt-5|o1|o3|o4)/.test(m)) {
return { capTokens: 32_000, tokenField: 'max_completion_tokens', includeTemperature: false };
}
// gpt-4o / 4-turbo → max_tokens上限 16k
if (/^gpt-4o/.test(m) || /^gpt-4-turbo/.test(m)) {
return { capTokens: 16_384, tokenField: 'max_tokens', includeTemperature: true };
}
// 普通 gpt-4 → 8k
if (/^gpt-4/.test(m)) {
return { capTokens: 8_192, tokenField: 'max_tokens', includeTemperature: true };
}
// gpt-3.5 → 4k
if (/^gpt-3\.5/.test(m)) {
return { capTokens: 4_096, tokenField: 'max_tokens', includeTemperature: true };
}
// 未知模型(包括国产中转、自部署):保守值 + 老字段,最大限度兼容
return { capTokens: 4_096, tokenField: 'max_tokens', includeTemperature: true };
}
/**
* Anthropic 模型 max_tokens 上限路由。
* H1 (audit)Anthropic 不同模型上限差异很大(旧 haiku 4096 / sonnet-3-5 8192
* 65536 会被这些老模型 400 拒绝。
*
* audit fix (round-13)补齐「5 系」与 4.6+ 命名。
* 之前的正则 `claude-(3-5|3\.5|3-7|sonnet-4|opus-4|4)` 要求 `claude-` 后面
* 紧跟这些片段,于是 claude-opus-5 / claude-sonnet-5 / claude-fable-5 /
* claude-haiku-4-5 全部不命中 → 回退 8192反而比 claude-opus-4-816384
* 更低。结果是「越新、输出上限越高的模型,拿到的 max_tokens 越小」:
* 长笔记改写会在 8k 处被截断 → stop_reason: max_tokens → runEdit 走
* TRUNCATED_MESSAGE 分支报「AI 修改结果不完整」。这正是 Phase O-L17
* 想修掉的那类 bug只是模型命名又演进了一代。
*
* 现行分档(保守取值,远低于官方上限,避免中转/自部署网关拒绝):
* - 5 系 + 4.6/4.7/4.8(官方 max output 128k→ 32k
* - 其余 4 系(含 haiku-4-5+ 3-5/3-7 → 16k
* - claude-3 老家族 → 8k
* - 未知模型(国产中转 / 自部署)→ 8k最大限度兼容
* @param {string} model
* @returns {number}
*/
function pickAnthropicTokenConfig(model) {
const m = String(model || '').toLowerCase();
// 5 系opus-5 / sonnet-5 / fable-5 / mythos-5与 4.6+ → 官方 128k 上限
if (/claude-(opus|sonnet|fable|mythos)-5/.test(m)) return 32_000;
if (/claude-(opus|sonnet)-4-(6|7|8)/.test(m)) return 32_000;
// 其余 4 系sonnet-4-5 / opus-4-5 / haiku-4-5 / claude-4-*+ 3-5 / 3-7
if (/claude-(3-5|3\.5|3-7|sonnet-4|opus-4|haiku-4|4)/.test(m)) return 16_384;
if (/claude-3/.test(m)) return 8_192;
return 8_192; // 未知模型回退到 8k —— 之前 4096 经常截断长 diff
}
/**
* 抽象的 HTTP 调用 + 超时 + 取消 + 状态码错误处理。
* 不解析业务响应OpenAI / Anthropic 各自的 JSON 结构在调用方处理)。
*
* @param {{
* requestId: string,
* url: string,
* headers: Record<string, string>,
* body: object,
* timeoutMs: number,
* }} args
* @returns {Promise<{ ok: true, json: any } | { ok: false, error: string, message: string }>}
*/
async function postJson({ requestId, url, headers, body, timeoutMs }) {
const controller = new AbortController();
pending.set(requestId, controller);
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
timers.set(requestId, timer);
// audit fix所有 exit 路径必须清理两个 Map否则长时间使用会泄漏
// 内存(之前修复 cancelAll 只清理了 Map 没在 postJson 内清理 entry
// 现在统一走 helper 确保不遗漏)。
//
// 关键cleanup 只清理「自己的」controller / timer不能按 requestId 无脑
// delete。如果 renderer 在我们 await fetchFn 的间隙用同一个 requestId 发起
// 了新请求pending.has → cancel → 新 postJson新请求会重新
// pending.set(requestId, newController)。随后旧 cleanup 在 microtask 阶段
// 触发 pending.delete(requestId),会把新请求的 controller 从 Map 里抹掉,
// 导致新请求无法单独 cancel。比对 identity 再 delete 即可解决。
function cleanup() {
clearTimeout(timer);
if (pending.get(requestId) === controller) pending.delete(requestId);
if (timers.get(requestId) === timer) timers.delete(requestId);
}
let res;
try {
// audit fix (Round 13 / Sec-H3)redirect: 'manual' 阻止 undici 跟随 3xx
// 重定向到不同 origin 时复传自定义头。fetch 规范只会在 CORS 非通配头
// 集合里自动剥 `Authorization`,但 Anthropic 用的是 `x-api-key`(自定义头),
// 不在脱敏名单里 —— 用户配置的中转 / 第三方网关一旦答 302 到攻击者域,
// `x-api-key: sk-ant-...` 和当前笔记全文都会被转发出去。
//
// 用 manual 后拿到的是 opaqueredirect 类型的 Responsestatus 0、body 不可读;
// 我们在下文按 status === 0 / type === 'opaqueredirect' 显式报错给用户。
// 不支持 redirect 的真 endpoint 不会触发这条分支2xx/4xx/5xx 都不是 3xx
res = await fetchFn(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: controller.signal,
redirect: 'manual',
});
} catch (e) {
cleanup();
if (controller.signal.aborted) {
if (timedOut) {
return { ok: false, error: ERR_TIMEOUT, message: 'AI 请求超时,请缩小文档、简化要求或重试' };
}
return { ok: false, error: ERR_CANCELLED, message: '已取消 AI 请求' };
}
const msg = e instanceof Error ? e.message : String(e);
if (/abort/i.test(msg)) {
return { ok: false, error: timedOut ? ERR_TIMEOUT : ERR_CANCELLED, message: timedOut ? 'AI 请求超时,请缩小文档、简化要求或重试' : '已取消 AI 请求' };
}
// M1 fix (audit):网络错误信息里也可能携带 api keyfetch 库 / DNS 错误里偶尔
// 会回显 URL 或 header统一走 sanitizeDetail 防止泄露到 toast / 日志
return { ok: false, error: ERR_PROVIDER, message: `网络错误:${sanitizeDetail(msg)}` };
}
cleanup();
// audit fix (Round 13 / Sec-H3)3xx 重定向在 manual 模式下表现为 status=0
// 且 type='opaqueredirect'。明示用户配置错了 Base URL不要走"读 body 取错误信息"
// 分支(那里会卡死读 body 或者报错信息误导成"格式错误")。
if (res.status === 0 || res.type === 'opaqueredirect') {
return {
ok: false,
error: ERR_PROVIDER,
message: 'Base URL 发生了重定向,请直接填写最终地址(出于 API Key 安全考虑Notes 不会自动跟随重定向)',
};
}
if (!res.ok) {
const status = res.status;
let detail = '';
let errBody = null;
// P2-3 fix (audit):用 readBodyWithLimit 限制响应体大小,防止 OOM
// 审计修复 (Round 11 deep-fix P1-2):在 readBodyWithLimit 抛 AbortError 时
// (用户取消)不要静默走到下面 → bodyRead.ok === false 时仍正常报错 OK
// 但 reader 自身抛错会冒到 catch。检查 controller.signal.aborted 走「已取消」。
let bodyRead;
try {
bodyRead = await readBodyWithLimit(res);
} catch (e) {
if (controller && controller.signal && controller.signal.aborted) {
return { ok: false, error: ERR_CANCELLED, message: '已取消 AI 请求' };
}
throw e;
}
if (!bodyRead.ok) {
// Q6 fix (audit)url 写入日志前 sanitizeUrl去掉 query / fragment / path secret
logFn('ai:http_error', { url: sanitizeUrl(url), status, detail: bodyRead.message });
return { ok: false, error: ERR_FORMAT, message: bodyRead.message };
}
const text = bodyRead.text;
if (text) {
// 尝试解析为 JSON 取 error.message不成功则把原文截断用作 detail
try {
const parsed = JSON.parse(text);
errBody = parsed;
if (parsed && parsed.error && typeof parsed.error.message === 'string') {
detail = parsed.error.message;
}
} catch {
// ignore
}
if (!detail) detail = text.length > 200 ? `${text.slice(0, 200)}` : text;
}
// P2-2 fix (audit):过滤 detail 里的 api key / 敏感 token 后再 log + 回显
detail = sanitizeDetail(detail);
// Q6 fix (audit)url 写入日志前 sanitizeUrl去掉 query / fragment / path secret
logFn('ai:http_error', { url: sanitizeUrl(url), status, detail });
if (status === 401 || status === 403) {
return { ok: false, error: ERR_PROVIDER, message: 'API Key 无效或没有权限' };
}
if (status === 404) {
return { ok: false, error: ERR_PROVIDER, message: 'Base URL 或模型不存在' };
}
if (status === 408 || status === 504) {
return { ok: false, error: ERR_TIMEOUT, message: 'AI 请求超时,请缩小文档、简化要求或重试' };
}
if (status === 429) {
return { ok: false, error: ERR_PROVIDER, message: '请求过于频繁,请稍后重试' };
}
if (status === 400 && errBody && errBody.error && errBody.error.type === 'invalid_request_error') {
// audit fix (Round 8 A-1):这里过去用的是 `errBody.error.message` 原文,
// 绕过了上面 line 525 的 sanitizeDetail —— 而 detail 正是同一个字符串
// 脱敏后的版本。自部署网关Azure / LiteLLM / 各类中转)在
// invalid_request_error.message 里 echo 请求头或请求体的情况很常见,
// 一旦回吐 `Bearer sk-...` 就会原样进 toast。改用已脱敏的 detail。
return { ok: false, error: ERR_PROVIDER, message: `请求参数错误:${detail || '服务端未提供详情'}` };
}
if (status >= 500) {
// audit fix (M3 regression)500 也带 sanitized detail
// 让上游服务器把真实错误("invalid token: sk-xxx")回吐时,
// 用户能从 toast 看到「凭据有问题」而不是一句空泛的"服务不可用"。
// detail 已经走过 sanitizeDetailkey 类 token 已经被 [API_KEY] 替换。
return {
ok: false,
error: ERR_PROVIDER,
message: `AI 服务暂时不可用HTTP ${status}${detail ? '' + detail : ''}`,
};
}
return { ok: false, error: ERR_PROVIDER, message: `AI 请求失败HTTP ${status}${detail ? '' + detail : ''}` };
}
/** @type {any} */
let json;
try {
// P2-3 fix (audit):成功路径也走大小限制(恶意 / 错误配置的服务端可能
// 对 200 也返回大 body。用 text + JSON.parse 替代 res.json()。
const bodyRead = await readBodyWithLimit(res);
if (!bodyRead.ok) return { ok: false, error: ERR_FORMAT, message: bodyRead.message };
json = JSON.parse(bodyRead.text);
} catch (e) {
// 审计修复 (Round 11 deep-fix P1-2):用户取消时 readBodyWithLimit 内部
// reader.cancel() 抛 AbortError外层 catch 之前把它误判成 "不是合法 JSON"
// → UI 看到误导的格式错误。先检查 controller.signal.aborted 走「已取消」分支。
if (controller && controller.signal && controller.signal.aborted) {
return { ok: false, error: ERR_CANCELLED, message: '已取消 AI 请求' };
}
const msg = e instanceof Error ? e.message : String(e);
if (/abort/i.test(msg)) {
return { ok: false, error: ERR_CANCELLED, message: '已取消 AI 请求' };
}
return { ok: false, error: ERR_FORMAT, message: 'AI 返回的不是合法 JSON' };
}
return { ok: true, json };
}
/**
* 把模型原始文本回复归一化成 { content, responseFormat }
* - 解析成 { content: string } → responseFormat = 'json'(用于文档修改 diff
* - 其它情况 → responseFormat = 'raw'(普通对话回复)
*/
function normalizeAssistantText(message, originalContent) {
const parsed = tryParseJson(message);
if (parsed && typeof parsed === 'object' && typeof parsed.content === 'string') {
const next = parsed.content;
if (next === originalContent) {
return { content: '当前文档无需修改。', responseFormat: 'raw' };
}
return { content: next, responseFormat: 'json' };
}
// audit fix (shared-M13):如果 JSON 解析成功但 content 不是字符串(典型
// 是 [] 数组 / 对象 / null / 数字),不要按「截断」处理 —— 模型是按规矩
// 返回 JSON 对象的,只是 content 的形状不是我们约定的字符串。把 message
// 整体当 raw 回退返回给用户,至少他们能看到模型实际输出了什么,而不是
// 看到一个误导的「输出不完整,请重试」提示。
if (parsed && typeof parsed === 'object' && 'content' in parsed) {
return { content: message, responseFormat: 'raw' };
}
const rawReply = extractRawReply(parsed);
if (rawReply) return { content: rawReply, responseFormat: 'raw' };
if (looksLikeEditJson(message)) {
return null; // JSON 看起来想返回 {content:...} 但解析失败 —— 截断
}
return { content: message, responseFormat: 'raw' };
}
/**
* OpenAI 兼容分支POST {baseURL}/chat/completions。
*/
async function runOpenAIEdit({ prompt, content, filename, requestId, timeoutMs }) {
const config = (typeof getConfig === 'function' ? getConfig() : {}) || {};
const baseURL = String(config.aiBaseUrl || '').trim();
const apiKey = String(config.aiApiKey || '').trim();
const model = String(config.aiModel || '').trim();
const systemPrompt = String(config.aiSystemPrompt || '').trim() || CURRENT_FILE_EDIT_SYSTEM_PROMPT;
if (!baseURL || !apiKey || !model) {
return { ok: false, error: ERR_NOT_CONFIGURED, message: '请先在设置中填写 AI 的 Base URL、API Key 和模型名' };
}
// M1 (audit):拒绝 javascript: / data: / file: 等伪协议,避免 fetch TypeError 报成"网络错误"
const urlCheck = validateBaseUrl(baseURL);
if (!urlCheck.ok) {
return { ok: false, error: ERR_NOT_CONFIGURED, message: urlCheck.reason };
}
// audit fix (Round 8 A-2):明文 HTTP + 非loopback + 已配置 API Key → 拒绝。
// 本地代理Ollama / LM Studio / vllm走 http://localhost / 127.0.0.1 / ::1
// 且不需要 Key —— apiKey 留空就过;这里有 apiKey 意味着第三方 API必须 HTTPS。
// 静默放行会让 Key 在网线上裸奔到攻击者嗅探点,错误必须显式。
// localhost/127.0.0.1/::1 仍然放过:本地抓包门槛远高于公网,本地代理是合法场景。
if (/^http:\/\//i.test(urlCheck.value) && apiKey) {
try {
const parsed = new URL(urlCheck.value);
const host = (parsed.hostname || '').toLowerCase();
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1';
if (!isLoopback) {
console.warn('[ai] 明文 HTTP + 非loopback + 已配 Key拒绝请求以防泄露:', sanitizeUrl(urlCheck.value));
return {
ok: false,
error: ERR_NOT_CONFIGURED,
message: '检测到 Base URL 使用明文 HTTP 且已配置 API Key请改用 https:// 以避免 Key 在网络传输中被窃取。本地代理localhost / 127.0.0.1)允许明文。',
};
}
} catch { /* validateBaseUrl 已校验过 URL这里兜底 */ }
}
const url = joinUrl(baseURL, 'chat/completions');
// H1+H2+H5 (audit):按模型名路由 token 上限,避免 65536 超过多数模型上限被拒;
// 同时按模型名决定发 max_tokens 还是 max_completion_tokensgpt-5 / o-series 只接受后者)。
// o-series / gpt-5 也不接受自定义 temperatureo1 强制为 1发 0.2 会 400
const { capTokens, tokenField, includeTemperature } = pickOpenAITokenConfig(model);
/** @type {Record<string, any>} */
const body = {
model,
[tokenField]: capTokens,
messages: [
{ role: 'system', content: systemPrompt },
{
role: 'user',
content: JSON.stringify({ filename, currentMarkdown: content, userPrompt: prompt }),
},
],
stream: false,
};
if (includeTemperature) body.temperature = 0.2;
logFn('ai:request', { provider: 'openai', url: sanitizeUrl(url), model, filename, promptLen: prompt.length, contentLen: content.length });
const result = await postJson({ requestId, url, headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
}, body, timeoutMs });
if (!result.ok) return result;
const json = result.json;
const choice = json && Array.isArray(json.choices) ? json.choices[0] : null;
if (!choice) {
return { ok: false, error: ERR_FORMAT, message: 'AI 返回了空结果' };
}
if (choice.finish_reason === 'length') {
return { ok: false, error: ERR_FORMAT, message: TRUNCATED_MESSAGE };
}
if (choice.finish_reason === 'content_filter') {
return { ok: false, error: ERR_FORMAT, message: 'AI 返回被服务方过滤,请调整要求后重试' };
}
const message = choice.message && typeof choice.message.content === 'string' ? choice.message.content : '';
if (!message) {
return { ok: false, error: ERR_FORMAT, message: 'AI 返回了空内容' };
}
const normalized = normalizeAssistantText(message, content);
if (normalized === null) {
return { ok: false, error: ERR_FORMAT, message: TRUNCATED_MESSAGE };
}
return { ok: true, id: requestId, content: normalized.content, responseFormat: normalized.responseFormat };
}
/**
* Anthropic 分支POST {baseURL}/v1/messages。
* system 单独字段(不在 messages 里messages 只有 user/assistant 两轮对话。
*/
async function runAnthropicEdit({ prompt, content, filename, requestId, timeoutMs }) {
const config = (typeof getConfig === 'function' ? getConfig() : {}) || {};
const baseURL = String(config.aiBaseUrl || '').trim();
const apiKey = String(config.aiApiKey || '').trim();
const model = String(config.aiModel || '').trim();
const systemPrompt = String(config.aiSystemPrompt || '').trim() || CURRENT_FILE_EDIT_SYSTEM_PROMPT;
if (!baseURL || !apiKey || !model) {
return { ok: false, error: ERR_NOT_CONFIGURED, message: '请先在设置中填写 AI 的 Base URL、API Key 和模型名' };
}
// M1 (audit):同样校验协议前缀,与 OpenAI 分支一致
const urlCheck = validateBaseUrl(baseURL);
if (!urlCheck.ok) {
return { ok: false, error: ERR_NOT_CONFIGURED, message: urlCheck.reason };
}
// audit fix (Round 8 A-2):明文 HTTP + 非loopback + 已配置 API Key → 拒绝。
// 与 OpenAI 分支共用同样防御,本地代理不需要 Key留空即可。
if (/^http:\/\//i.test(urlCheck.value) && apiKey) {
try {
const parsed = new URL(urlCheck.value);
const host = (parsed.hostname || '').toLowerCase();
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1';
if (!isLoopback) {
console.warn('[ai] 明文 HTTP + 非loopback + 已配 Key拒绝请求以防泄露:', sanitizeUrl(urlCheck.value));
return {
ok: false,
error: ERR_NOT_CONFIGURED,
message: '检测到 Base URL 使用明文 HTTP 且已配置 API Key请改用 https:// 以避免 Key 在网络传输中被窃取。本地代理localhost / 127.0.0.1)允许明文。',
};
}
} catch { /* validateBaseUrl 已校验过 URL */ }
}
const url = joinUrl(baseURL, 'v1/messages');
// H1 (audit)Anthropic 各模型 max_tokens 上限不同,超额会 400
const capTokens = pickAnthropicTokenConfig(model);
const body = {
model,
max_tokens: capTokens,
system: systemPrompt,
messages: [
{
role: 'user',
content: JSON.stringify({ filename, currentMarkdown: content, userPrompt: prompt }),
},
],
};
logFn('ai:request', { provider: 'anthropic', url: sanitizeUrl(url), model, filename, promptLen: prompt.length, contentLen: content.length });
const result = await postJson({ requestId, url, headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': ANTHROPIC_API_VERSION,
}, body, timeoutMs });
if (!result.ok) return result;
const json = result.json;
// 拼接所有 type==='text' 的 content 块;忽略 tool_use / tool_result 等。
if (!json || !Array.isArray(json.content)) {
return { ok: false, error: ERR_FORMAT, message: 'AI 返回了空结果' };
}
if (json.stop_reason === 'max_tokens') {
return { ok: false, error: ERR_FORMAT, message: TRUNCATED_MESSAGE };
}
/** @type {string} */
let message = '';
let hasNonTextBlock = false;
for (const block of json.content) {
if (block && block.type === 'text' && typeof block.text === 'string') {
message += block.text;
} else if (block && block.type !== 'text') {
// audit fix (shared-M11)tool_use / tool_result / image 等非文本块
// 当前版本不消费,但若整条 response 只有这些块就给一个明确的中文
// 提示而不是误导用户「AI 返回了空内容」(实际是格式我们暂时不支持)。
hasNonTextBlock = true;
}
}
if (!message) {
// audit fix (shared-M11):把「模型只回了 tool_use / image 但没有正文」
// 与「模型真的没回东西」区分开 —— 后者才报「空内容」。
if (hasNonTextBlock) {
return {
ok: false,
error: ERR_FORMAT,
message: 'AI 返回了无法识别的内容(仅含 tool_use / 图像块,无文本回复)',
};
}
return { ok: false, error: ERR_FORMAT, message: 'AI 返回了空内容' };
}
const normalized = normalizeAssistantText(message, content);
if (normalized === null) {
return { ok: false, error: ERR_FORMAT, message: TRUNCATED_MESSAGE };
}
return { ok: true, id: requestId, content: normalized.content, responseFormat: normalized.responseFormat };
}
/**
* 执行一次 AI 修改请求。
* @param {{ prompt:string, content:string, filename:string, requestId:string }} input
* @returns {Promise<{ ok:true, id:string, content:string, responseFormat:'json'|'raw' } | { ok:false, error:string, message:string }>}
*/
async function runEdit({ prompt, content, filename, requestId }) {
if (typeof prompt !== 'string' || !prompt.trim()) {
return { ok: false, error: 'INVALID_PROMPT', message: '修改要求不能为空' };
}
if (typeof content !== 'string') {
return { ok: false, error: 'INVALID_CONTENT', message: '当前文件内容无效' };
}
// 字节上限用 UTF-8 编码长度近似(中文/emoji 一个字符多字节,会略高估)
if (Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) {
return {
ok: false,
error: 'CONTENT_TOO_LARGE',
message: `文档过大(超过 ${(MAX_CONTENT_BYTES / 1024 / 1024).toFixed(1)} MB请拆分后再让 AI 修改`,
};
}
if (Buffer.byteLength(prompt, 'utf8') > 64_000) {
return {
ok: false,
error: 'PROMPT_TOO_LARGE',
message: '提示词过长(超过 64 KB请简化要求',
};
}
if (typeof filename !== 'string') {
filename = '';
}
if (typeof requestId !== 'string' || !requestId) {
requestId = `ai-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
const config = (typeof getConfig === 'function' ? getConfig() : {}) || {};
const provider = String(config.aiProvider || 'openai').trim().toLowerCase();
const timeoutMs = DEFAULT_TIMEOUT_MS;
// audit fix (C4):原 `if (pending.has(requestId)) cancel(requestId)` 是死代码 —
// pending 是 postJson 内部填的postJson 在 runEdit 异步返回之后才执行,
// runEdit 入口检查 pending 时它一定是空的,双击防护其实由 renderer 端
// ai-chat-panel 的 _submitting 守门。这里改成真正的 inFlightRequestIds
// 进入 runEdit 即登记(早于 postJsonfinally 清理,覆盖整个请求生命周期。
if (inFlightRequestIds.has(requestId)) {
cancel(requestId);
}
inFlightRequestIds.add(requestId);
// 注token 上限不再用入参 maxTokens已被 pick*TokenConfig 按模型路由取代)
const baseArgs = { prompt, content, filename, requestId, timeoutMs };
try {
if (provider === 'anthropic') {
return await runAnthropicEdit(baseArgs);
}
if (provider === 'openai') {
return await runOpenAIEdit(baseArgs);
}
// audit fix (2.5):未知 provider 不再静默按 OpenAI 走 —— 配置错了会让用户困惑。
// schema enum 已防,但 IPC 直调 / 老 settings 文件可能漏过来,这里明确报错。
return {
ok: false,
error: ERR_NOT_CONFIGURED,
message: `未知 AI 服务提供方:${provider}(请在设置里选 OpenAI 兼容或 Anthropic 兼容)`,
};
} finally {
// audit fix (C4):无论成功 / 失败 / 抛错都清登记,下次同 requestId
// 再来能正常进入;防 Set 缓慢增长。
inFlightRequestIds.delete(requestId);
}
}
return {
runEdit,
cancel,
cancelAll,
};
}
module.exports = {
createAiProxy,
// 共享错误码表 —— preload 经 contextBridge 把同一份 AI_ERROR 暴露到
// window.api.aiErrorsrenderer 直接 window.api?.aiErrors?.AI_ERROR。
// main/ai.js 内部仍用 ERR_* 命名别名line 59-63纯粹是阅读性无外部
// 调用方 —— 别名不出 module.exports避免「两个相同字面值漂移」风险。
AI_ERROR,
};

518
main/config-store.js Normal file
View File

@@ -0,0 +1,518 @@
// 配置持久化层Stage 4b.1 抽离)
//
// 职责:
// - 加载 / 保存用户配置userData/config.json
// - 解析「实际生效」的数据目录(用户自定义 vs 默认 ~/Notes
// - 首次启动把 data/welcome.md 种子化到默认数据目录
//
// 设计:
// - 本模块自给自足:不读 mainWindow / tray / IPC 等任何 main.js 状态。
// - 暴露单一对象 { load, save, get, resolveDataDir, seedDefault, getDefaultDataDir }
// - main.js 调一次 load() 拿到当前配置后,可随时通过 save() / get() 操作。
// - schema 校验 / 默认值仍由 shared/settings-schema.js 提供(单一事实源)。
const electron = require('electron');
const path = require('path');
const fs = require('fs').promises;
const fsSync = require('fs');
const os = require('os');
// rename 重试 backoff 阶梯ms—— Windows Defender / 杀毒 / 同步盘
// 会在极短时间内持锁目标文件,单次 rename 失败率约 1-3%。3 次 backoff
// 后仍失败才抛错(见 renameWithRetry
const RENAME_BACKOFF_MS = [50, 100, 200];
const {
DEFAULT_SETTINGS: DEFAULT_CONFIG,
coerceLoadedSettings,
} = require('../shared/settings-schema.js');
// audit fix (Round 12 P2)saveConfig 失败时的 e.message 直接走 friendly-fs-error
// 与 main/file-ops.js#friendlyWriteError 走同一份文案。避免用户看到
// 「EACCES: permission denied, open '/Users/.../config.json'」英文 errno + 路径。
const { friendlyFsError } = require('../shared/friendly-fs-error.js');
// 测试注入点:默认走真实 electron.app单测可以换成 mock app。
// 下划线前缀表示「仅测试用」—— 生产代码不应调用。
let _app = electron && electron.app;
function getConfigPath() {
return path.join(_app.getPath('userData'), 'config.json');
}
/**
* 默认数据目录:用户主目录下的 Notes 子目录
* 跨平台统一(不像 Todo List 那样在 Windows 上探测 D: 盘)—— 这是阅读器,单用户跨平台直接可用。
*/
function getDefaultDataDir() {
try {
const home = _app.getPath('home');
return path.join(home, 'Notes');
} catch {
return path.join(os.homedir(), 'Notes');
}
}
let appConfig = {};
let DEFAULT_DATA_DIR = null;
let configLoaded = false;
function loadConfig() {
const cfgPath = getConfigPath();
let raw;
try {
raw = fsSync.readFileSync(cfgPath, 'utf-8');
} catch (e) {
// ENOENT 是「首次启动」,静默回退默认值是合理的;
// 其他错误(权限 / 磁盘坏道)也走默认值,但要在控制台留痕,方便用户反馈。
if (e.code !== 'ENOENT') {
console.warn('[config-store] 配置读取失败,使用默认值:', e.code || e.message);
}
appConfig = { ...DEFAULT_CONFIG };
cleanupStaleTmpFiles();
configLoaded = true;
return appConfig;
}
try {
const parsed = JSON.parse(raw);
appConfig = coerceLoadedSettings(parsed);
} catch (e) {
// 关键修复:之前 `} catch { ... }` 把 JSON 解析错误也吞了 —— 一旦 config.json
// 半截 / 格式错(断电、强杀进程、磁盘故障),用户的 aiApiKey / 自定义 dataDir
// 会无声丢失;下一次 saveConfig() 又会把默认值覆盖回去,损坏永久化。
// 现在把损坏的配置文件改名备份config.json.broken-<ts>),再走默认值。
// 用户在设置里重新填值后会写回新的 config.json备份留在旁边方便排查。
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${cfgPath}.broken-${ts}`;
try {
fsSync.renameSync(cfgPath, backupPath);
console.warn(`[config-store] config.json 解析失败 (${e.message}),已备份到 ${backupPath},使用默认值`);
} catch (renameErr) {
console.warn(`[config-store] config.json 解析失败且备份失败 (${renameErr.message}),使用默认值`);
}
appConfig = { ...DEFAULT_CONFIG };
}
// audit fix (4.4):清理上次异常退出留下的 config.json.tmp.* 残留。
// 正常路径下 saveConfig 会在 rename 成功后留下 0 个 tmprename 失败路径会立刻 unlink
// 但断电 / kill -9 会跳过 unlink长期积累成百上千个 tmp。
// 启动时扫一遍目录、删除自己的残留(不动当前 pid / 当前时间戳的,避免误删正在写的)。
cleanupStaleTmpFiles();
configLoaded = true;
return appConfig;
}
/**
* 删除数据目录里残留的 config.json.tmp.* 文件。
* 只删自己 pid 的(其它进程残留不碰,避免多开实例互相影响)。
*/
function cleanupStaleTmpFiles() {
try {
const dir = path.dirname(getConfigPath());
const files = fsSync.readdirSync(dir);
const myPid = process.pid;
const now = Date.now();
const STALE_THRESHOLD_MS = 60_000; // 60s 之前留下的才视为残留(避免误删正在写的)
for (const f of files) {
if (!/^config\.json\.tmp\./.test(f)) continue;
const full = path.join(dir, f);
try {
const st = fsSync.statSync(full);
// 自己的 pid + 超过 60s 前 → 视为残留;跨 pid 不动(不归本进程管)
const isMine = f.includes(`.${myPid}.`);
const ageMs = now - st.mtimeMs;
if (isMine && ageMs > STALE_THRESHOLD_MS) {
fsSync.unlinkSync(full);
}
} catch { /* 单个文件 stat/unlink 失败不影响整体 */ }
}
} catch {
// readdir 失败(目录不存在等)静默 —— 配置读不到已 try/catch 兜过
}
}
/**
* 保存(部分)配置到磁盘。失败仅 console.error不抛 —— 调用方继续用内存里的值。
* 返回合并后的当前配置。
*
* 写盘用「tmp + rename」原子模式audit #5
* - 先写到 config.json.tmp再 fs.renameSync 覆盖 config.json
* - 进程在写入中途崩溃时磁盘上要么是旧文件,要么是新文件,绝不会半截 JSON
* - Windows 上 renameSync 在目标已存在时会成功POSIX 语义),覆盖是原子的
* - Linux 上 rename 也是原子的(同分区)
* 这样 config.json 永远可解析 —— 启动时再坏也只会回到默认loadConfig 已 try/catch
* 不会让整个 app 因为 settings 损坏而拒绝启动。
*/
/**
* audit fix (Phase 3 C2):异步 + 串行 promise queue + 失败回滚。
*
* 旧实现三个问题:
* 1. writeFileSync/renameSync 在主进程事件循环里同步阻塞 —— 拖 AI 面板分割条
* 这类高频触发会卡 UIsettings-store 在 200ms 防抖后多次调用,每次都阻塞
* 几 ms~几十 ms慢盘 / OneDrive 同步冲突会更糟)。
* 2. 多调用并发时appConfig = {...appConfig, ...next} 在内存里已经合并,
* 但只有最后一次 writeFile 落盘;如果中间某次失败,前一次的合并内容丢失
* 但 appConfig 还显示「成功」next 返回合并后的状态)。
* 3. catch 只 unlink tmpappConfig 不回滚 —— 调用方以为保存成功,磁盘实际
* 是旧值。
*
* 新实现:
* - 每次 saveConfig 排队到 saveQueue 的链尾,前一次写完才执行下一次
* - 用 fs.promises.writeFile + rename 异步 IO不再阻塞事件循环
* - rename 重试 EBUSY/EPERM 3 次50/100/200ms backoff—— Windows Defender
* 短暂持锁常见retry-on-busy 比直接放弃更稳
* - 写入失败appConfig 回滚到 pre-merge 快照,调用方拿到 { ok:false, error }
*/
let saveQueue = Promise.resolve();
function saveConfig(next) {
// audit fix (H1)merged 不能在 enqueue 时计算,否则两个并发调用
// 都基于同一份 appConfig 合并,第二个任务写盘时会覆盖第一个任务的改动
// (典型场景:用户同时点 alwaysOnTop + 切 dataDir —— alwaysOnTop 被静默丢)。
// 改成在 task 内部取最新 appConfig 合并;同时 before 也在 task 内取,
// 保证回滚到「本次任务开始前一刻」的状态,而不是「所有任务开始前一刻」。
const task = async () => {
const before = { ...appConfig };
const merged = { ...appConfig, ...next };
// auto-fallback 2026-08dataDir 改了 → 让 resolveDataDirOrFallback 下次重新 stat。
// 之前没有缓存,没有这个 hook现在 saveConfig 走完同步更新 appConfig
// 旧缓存条目还指向改前的路径 → 必须在此清掉。
if ('dataDir' in next && before.dataDir !== next.dataDir) {
_invalidateResolveCache();
}
const cfgPath = getConfigPath();
const tmpPath = `${cfgPath}.tmp.${process.pid}.${Date.now()}`;
let fh = null;
try {
await fs.mkdir(path.dirname(cfgPath), { recursive: true });
// audit fix (Round 4 P0-1):改走 fs.open + writeFile + sync + close —— fs.writeFile
// 内部只把数据送进 page cache没保证落盘就 close。rename 之后再断电,磁盘上
// 可能是新名字 + 零字节 / 半截 JSON之前 C2 修过「不写半截 JSON」靠的是 rename
// 原子性,但 fsync 缺失让断电后实际文件可能不是新文件。AI key / 自定义 dataDir
// 这类关键配置丢失 = 用户感知不到为什么 settings 全没了。先 fsync 再 rename 才能
// 保证断电后磁盘上要么是旧 config.jsonrename 前断电),要么是完整新文件。
fh = await fs.open(tmpPath, 'w');
await fh.writeFile(JSON.stringify(merged, null, 2), 'utf-8');
await fh.sync();
await fh.close();
fh = null;
await renameWithRetry(tmpPath, cfgPath);
// audit fix (Round 12 P1)parent dir fsync (POSIX only)。
// main/file-ops.js#atomicWriteFile 已在 Round 4 加了这段对称保护,
// saveConfig 漏修。POSIX rename(2) 同分区下原子,但「目录项本身」的
// 落盘时机由内核控制 —— rename 完直接断电,下次启动目录里可能仍是旧
// 名字 + 新 inode 已分配但未刷盘 → 用户保存的 AI Key / 自定义 dataDir
// 在断电窗口后「看起来没保存」。Windows NTFS journal 元数据已带 fsync
// 语义跳过macOS / Linux 走 open('r')+sync+close 兜底。
// 注意fsync 失败 ≠ 数据丢失rename 已生效),只 warn 不抛错。
if (process.platform !== 'win32') {
try {
const dirFh = await fs.open(path.dirname(cfgPath), 'r');
try {
await dirFh.sync();
} finally {
await dirFh.close();
}
} catch (fsyncErr) {
console.warn('[config-store] parent dir fsync failed (non-fatal):', fsyncErr && fsyncErr.message);
}
}
// 写盘成功 → 同步到 in-memory cache
appConfig = merged;
return { ok: true, value: merged };
} catch (e) {
if (fh) { try { await fh.close(); } catch { /* ignore */ } }
console.error('[config-store] 配置保存失败:', e.message);
// audit fix (C2):回滚 appConfig 到 merge 前状态,避免 UI 看到「已保存」
// 但磁盘实际是旧值renderer 端 settingsStore.optimisticUpdate 已经把 UI
// 改成新值,需要靠下一次 save 失败时回滚避免误以为成功)。
appConfig = before;
// 清理残余 tmp不影响下次保存
try { await fs.unlink(tmpPath); } catch { /* 文件可能已被 rename 移走 */ }
return { ok: false, error: friendlyFsError(e && e.code, e && e.message) || '配置保存失败' };
}
};
saveQueue = saveQueue.then(task, task);
return saveQueue;
}
/**
* audit fix (C2)rename 重试 EBUSY/EPERM —— Windows Defender / 杀毒 / 同步盘
* 会在极短时间内持锁目标文件,单次 rename 失败率约 1-3%。3 次
* RENAME_BACKOFF_MS 阶梯 backoff 后仍失败才抛错。
* @param {string} src
* @param {string} dst
*/
async function renameWithRetry(src, dst) {
const delays = RENAME_BACKOFF_MS;
for (let i = 0; i <= delays.length; i += 1) {
try {
await fs.rename(src, dst);
return;
} catch (e) {
const transient = e.code === 'EBUSY' || e.code === 'EPERM' || e.code === 'EACCES';
// 末次重试仍失败 → 直接抛;非瞬态错误也直接抛(不浪费重试)。
if (!transient || i === delays.length) throw e;
await new Promise((r) => setTimeout(r, delays[i]));
}
}
}
/**
* 获取实际生效的数据目录路径(用户自定义优先,否则默认 ~/Notes
*
* 纯字符串返回,不做磁盘存在性检查 —— schema 校验 / 写入前的便宜判断用这个。
* 真正运行时(如 fsWatcher 启动 / scanDir 之前)请用 `resolveDataDirOrFallback()`
* 那个会 stat 路径、缺失时自动回退到默认。
*/
function resolveDataDir() {
const custom = appConfig.dataDir;
if (custom && typeof custom === 'string' && custom.trim()) {
return custom;
}
return DEFAULT_DATA_DIR;
}
/**
* 解析数据目录路径不存在时自动回退到默认runtime-only不修改 appConfig.dataDir
*
* 设计动机auto-fallback 2026-08
* 旧版 `resolveDataDir()` 只做字符串返回 —— 当 `appConfig.dataDir` 指向的目录
* 被外部删除 / 移动 / 离线OneDrive / U 盘 / 网盘常见fsWatcher 静默死亡、
* scanDir 返回 ENOENT、侧栏空白用户唯一恢复路径是手动「切换数据文件夹」再选。
* 这里加一层 stat 存在性检查:缺失 → runtime 回退到默认saved 字段记录原值,
* 持久化的 appConfig.dataDir 不动 —— 用户插回 U 盘下次启动还能用回去)。
*
* 缓存:
* 同步 statSync 在每个 IPC handler 里都跑会很贵file:list / file:read 等高频调用
* 都会走 currentDataRoot())。这里按 customDir 字符串做键的同步缓存:
* - 同一 customDir 连续调用 → 只 stat 一次
* - saveConfig 改了 dataDir → 缓存清掉,下次再 stat
* stat 失败后再次调用也命中缓存(避免 stat 一个不存在的路径反复失败)。
*
* 错误处理:
* - ENOENT / ENOTDIR → 视为不存在,回退到默认
* - 其他 errnoEACCES / EBUSY / EPERM / EIO→ 视为存在(可能是瞬时 ——
* U 盘读权限慢 / Windows Defender 持锁等),让上层 scanDir 自然失败
* 而不是「看似可用但其实打开就崩」
*
* @returns {{ dir: string, fellBack: boolean, saved: string }}
* dir实际可用的目录默认或 custom
* fellBacktrue 表示 custom 路径不可用、临时回退到默认
* savedappConfig.dataDir 的当前持久化值trim 后empty 表示从未设置
*/
let _resolveCache = null; // { customDir: string, exists: boolean }
/**
* 同步确保默认数据目录存在。默认目录可能是用户首次启动还没建出来、
* 也可能是 customDir 失效 fallback 时默认目录也从未被用过 —— 任何要返回
* DEFAULT_DATA_DIR 的路径都必须先确保它存在,否则 fsWatcher.startWatchingDir
* 会立刻 ENOENT 失败、scanDir 也读不出文件。
*
* 同步而不是异步resolveDataDirOrFallback 是 sync 接口(被 IPC handler /
* fsWatcher 启动路径同步调用),下面 fsWatcher 紧接着就拿这个 dir 去
* fs.watch —— 异步 mkdir 会有竞态。mkdir recursive 已幂等,目录存在 no-op。
*/
function ensureDefaultDataDirSync() {
if (!DEFAULT_DATA_DIR) return;
try {
fsSync.mkdirSync(DEFAULT_DATA_DIR, { recursive: true });
} catch (e) {
console.warn(`[config-store] 创建默认数据目录失败(${DEFAULT_DATA_DIR}:`, e.message);
}
}
function resolveDataDirOrFallback() {
const custom = (typeof appConfig.dataDir === 'string') ? appConfig.dataDir.trim() : '';
if (!custom) {
ensureDefaultDataDirSync();
scheduleEnsureDefaultDataDir();
return { dir: DEFAULT_DATA_DIR, fellBack: false, saved: '' };
}
// 命中缓存:相同 customDir 不重复 stat
if (_resolveCache && _resolveCache.customDir === custom) {
if (_resolveCache.exists) {
return { dir: custom, fellBack: false, saved: custom };
}
ensureDefaultDataDirSync();
scheduleEnsureDefaultDataDir();
return { dir: DEFAULT_DATA_DIR, fellBack: true, saved: custom };
}
// 重新 stat
let exists = true;
try {
const st = fsSync.statSync(custom);
// statSync 在普通文件 / 符号链接上不会抛 ENOTDIR —— ENOTDIR 只在「当目录用」时
// 才会报。这里额外检查「不是目录」dataDir 字段意外指向了一个文件路径
// (用户在设置对话框里手填、或 config.json 被改坏),仍应 fallback否则
// 上层 scanDir / watchDir 立刻 ENOTDIR 报上来,体验割裂。
if (!st.isDirectory()) exists = false;
} catch (e) {
if (e.code === 'ENOENT' || e.code === 'ENOTDIR') {
exists = false;
} else {
// EACCES / EBUSY / EPERM / EIO 等:当作存在,让上层自然处理错误
// fallback 反而会掩盖真实问题,比如 U 盘权限错误需要用户介入)
exists = true;
}
}
_resolveCache = { customDir: custom, exists };
if (exists) {
return { dir: custom, fellBack: false, saved: custom };
}
console.warn(`[config-store] 数据目录不可访问(${custom}),临时回退到默认 ${DEFAULT_DATA_DIR}`);
ensureDefaultDataDirSync();
scheduleEnsureDefaultDataDir();
return { dir: DEFAULT_DATA_DIR, fellBack: true, saved: custom };
}
/**
* 清掉 resolveDataDirOrFallback 的缓存。
*
* 内部仅由 saveConfig 在 dataDir 字段变化时调用 —— 让 schema 校验后改值不会让旧
* 缓存继续返回错误结果。外部无需直接调用reset IPC 自己会改 dataDir 走 saveConfig
*/
function _invalidateResolveCache() {
_resolveCache = null;
}
function getConfig() {
if (!configLoaded) {
// 防御:调用方忘了 load() —— 隐式初始化一次
loadConfig();
}
return appConfig;
}
/**
* 首次启动把 data/welcome.md 种子化到默认数据目录。
*
* 触发条件(任一为真则跳过):
* - 默认目录里已有 welcome.md已种子过→ 直接 return
* - 默认目录里有其它 .md 文件(用户笔记)→ 不覆盖
*
* 用 welcome.md 自己作为「已种子」的隐式标记,不再写 .notes-seeded 隐藏文件
* —— 用户数据目录应该只放用户的内容。
*
* 不再守卫「用户没设 dataDir」auto-fallback 路径下用户设了 dataDir 但路径失效,
* 也会 runtime 回退到默认目录 —— 这时默认目录可能是空的,要种子化 welcome.md
* 让用户立刻看到内容(而不是打开一个空侧栏)。
*
* 失败仅 console.warn不弹窗用户用「打开数据文件夹」按钮可以自己补救。
*/
async function ensureDefaultDataDir() {
if (!DEFAULT_DATA_DIR) return;
const targetDir = DEFAULT_DATA_DIR;
const welcomeDst = path.join(targetDir, 'welcome.md');
try {
// 先确保目录存在mkdir recursive 不存在就建、存在 no-op
// ensureDefaultDataDirSync 已经在 resolveDataDirOrFallback 同步路径里调过
// 一次,这里再调一次是 idempotent 的——但 async 路径是 fire-and-forget
// 可能在 sync 路径之前跑scheduleEnsureDefaultDataDir 从 IPC handler 进)
// 或之后(启动 bootstrap所以这里再保险一次。
await fs.mkdir(targetDir, { recursive: true });
// welcome.md 已存在 → 已经种子过(或用户改过),跳过
try {
await fs.stat(welcomeDst);
return;
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
// 已有其它 .md → 用户笔记,不覆盖 welcome 也不打扰用户
const dirEntries = await fs.readdir(targetDir).catch((err) => {
if (err.code === 'ENOENT') return [];
throw err;
});
if (dirEntries.some((n) => n.toLowerCase().endsWith('.md'))) {
return;
}
// 种子化 welcome.md
const src = path.join(__dirname, '..', 'data', 'welcome.md');
await fs.copyFile(src, welcomeDst);
console.log('[config-store] 已种子 welcome.md 到默认数据目录:', welcomeDst);
} catch (e) {
console.warn('[config-store] welcome 种子失败:', e.message);
}
}
/**
* 首次启动种子别名 —— 保留旧 API 名字main.js 调用方),内部委托给 ensureDefaultDataDir。
* 新代码优先用 ensureDefaultDataDir旧名仅作 bootstrap 时的语义入口。
*/
async function seedDefaultDataDir() {
return ensureDefaultDataDir();
}
/**
* fire-and-forget 异步种子 —— resolveDataDirOrFallback 内每次返回 DEFAULT_DATA_DIR
* 都会调它,确保在 IPC handler 同步返回之后异步把 welcome.md 种子进去。
*
* 用 _ensureInFlight Promise 去重同一时间多次触发只跑一次fsWatcher 启动后
* 第一次 IPC scanDir 触发 fallback + renderer 启动后的 resetDataDir + 启动自身
* 三条路径都会调用,全部共享同一份 in-flight promise
*/
let _ensureInFlight = null;
function scheduleEnsureDefaultDataDir() {
if (_ensureInFlight) return _ensureInFlight;
_ensureInFlight = ensureDefaultDataDir().finally(() => {
_ensureInFlight = null;
});
return _ensureInFlight;
}
/**
* 一站式初始化loadConfig + 设定 DEFAULT_DATA_DIR。
* 必须在 app ready 之后调getDefaultDataDir 用到 app.getPath
*/
function init() {
appConfig = loadConfig();
DEFAULT_DATA_DIR = getDefaultDataDir();
return { appConfig, defaultDataDir: DEFAULT_DATA_DIR };
}
/**
* 测试辅助把模块级状态重置回初始值appConfig={}, DEFAULT_DATA_DIR=null, configLoaded=false
*
* 下划线前缀表示「仅测试用」—— 生产代码不应调用。单测之间需要干净的隔离。
*/
function _reset() {
appConfig = {};
DEFAULT_DATA_DIR = null;
configLoaded = false;
// auto-fallback 2026-08测试间清理缓存避免上一个 case 残留 customDir
// 命中率污染下一个 case 的首次 stat。
_resolveCache = null;
// ensureDefaultDataDir 的 in-flight 也要清,避免上一个 case 的种子任务
// 在新 case 期间仍在跑race
_ensureInFlight = null;
// audit fix (Round 12 P2)saveQueue 也要清。前一个 case 的 saveConfig
// 若没 await 就 _reset新 case 的 saveConfig 会链到旧 case 的尾后 →
// 跨 case 串扰renderer 端 / 用户态不会触发,但单测 _reset 后再 save
// 必须从干净 queue 开始)。
saveQueue = Promise.resolve();
}
/**
* 测试辅助:注入 fake electron.app。单测里 vi.mock('electron') 拦不住 CJS 的 require
* 所以提供 setter 让测试换掉内部 app 引用。
*/
function _setApp(mockApp) {
_app = mockApp;
}
module.exports = {
init,
loadConfig,
saveConfig,
getConfig,
getConfigPath,
getDefaultDataDir,
resolveDataDir,
resolveDataDirOrFallback,
ensureDefaultDataDir,
ensureDefaultDataDirSync,
scheduleEnsureDefaultDataDir,
seedDefaultDataDir,
_reset,
_setApp,
};

694
main/file-ops.js Normal file
View File

@@ -0,0 +1,694 @@
// 文件操作 helperStage 7 抽出 + Folder Browser 扩展)
//
// 职责:
// - 暴露 file IPC 所需的可测试纯函数isWithinDataDir / resolveFileName / scanFiles / scanDir / classifyEntry
// - 不包含 ipcMain 注册逻辑(仍留在 main.js与 config-store 的拆分风格一致)
//
// 边界:
// - 不引用 mainWindow / ipcMain / shell —— 这些留在 main.js 里就近注册
// - 不读 configStore —— 由调用方注入数据目录
// - scanFiles 的 ENOENT 行为:返回错误(不再静默 mkdir——
// 防止「数据目录被外部删了之后被自动重建掩盖数据丢失」
// 见 audit #3
//
// Folder Browser2026-08
// - scanDir(absDir) 返回一层所有条目(文件夹 + 文件),含 entryType 分类
// - 文件分类由 classifyEntry(name) 完成:'folder' | 'editable' | 'binary'
// - EDITABLE_EXTS来自 shared/extension-lists.js是「可打开 + 编辑」
// 的扩展名白名单markdown (.md/.markdown) 与常见纯文本均在内
//
// 测试tests/unit/file-ops.test.jsjsdom 环境之外;纯 Node 即可)
const fs = require('fs').promises;
const path = require('path');
// 单一事实源扩展名白名单main + preload + renderer 三处共用)
const { EDITABLE_EXTS } = require('../shared/extension-lists.js');
// 单一事实源errno → 中文提示main + renderer 共享preload 过桥)
const { friendlyFsError: sharedFriendlyFsError } = require('../shared/friendly-fs-error.js');
/** 单文件大小上限5 MB防止超大文件冻结 renderer。 */
const MAX_FILE_SIZE = 5 * 1024 * 1024;
/**
* 检查 fullPath 的占用情况 —— 返回三种状态:
* - 'free' 路径不存在,可用
* - 'file' 路径已被一个文件占用,需要重命名避让
* - 'directory' 路径已被一个目录占用(用户手动 mkdir 出来),也需要避让
*
* 审计修复 (Round 11 deep-fix P2-4):旧版用 fs.access() 只能判断「存在与否」,
* 不能区分 file vs directory。用户新建笔记取名 `foo.md` 但数据目录里已经有一个
* 同名目录(手动 mkdir 出来的笔记文件夹resolveFileName 直接把目录路径当成
* 可用文件返回;后续 atomicWriteFile 打开目录路径得到 EISDIRUI 弹「无效参数」
* 误导信息。改用 fs.stat() 拿 dirent 类型确认。
*/
async function pathOccupancy(fullPath) {
try {
const st = await fs.stat(fullPath);
if (st.isDirectory()) return 'directory';
if (st.isFile()) return 'file';
// socket / fifo / device —— 数据目录里不应出现,但既然存在也当占用
return 'file';
} catch (e) {
if (e && e.code === 'ENOENT') return 'free';
// EACCES / EPERM 等:保守起见当「占用」,让上层走避让分支
return 'file';
}
}
/**
* 给一个文件名分类为 folder / editable / binary。
* 纯函数:不读文件系统、不感知 path —— 仅看 name 末尾扩展名。
*
* 注意folder 类型由调用方scanDir传入时显式标记
* 本函数在 entryType === 'folder' 时不会被调用(参见 scanDir 内部)。
*
* @param {string} name
* @returns {'editable'|'binary'}
*/
function classifyEntry(name) {
if (typeof name !== 'string' || !name) return 'binary';
const dot = name.lastIndexOf('.');
// 没有扩展名 → binary
if (dot < 0) return 'binary';
const ext = name.slice(dot + 1).toLowerCase();
// 隐藏文件 / 末尾多余点:扩展名部分为空(".env" 有扩展名 "env",不是这种)
if (!ext) return 'binary';
// dot === 0 的隐藏文件名(如 ".gitignore"):扩展名部分非空但不在白名单 → binary
// 注意 ".env" 现在会走 EDITABLE_EXTS.has('env') → 'editable'(修旧版的列表/链接割裂 bug
return EDITABLE_EXTS.has(ext) ? 'editable' : 'binary';
}
/**
* 拒绝 symlinkNotes 用户的工作流是「编辑自己数据目录里的 .md」
* 数据目录里出现 symlink 通常来自外部同步盘 / 误操作 / 恶意数据,
* 写 symlink 会跟随到外部真实文件,绕过 isWithinDataDir 边界。
* 见 audit #7。
*
* @param {string} filePath
* @returns {Promise<{ok: true, isFile: boolean} | {ok: false, error: string, code?: string}>}
*/
async function assertNotSymlink(filePath) {
let st;
try {
st = await fs.lstat(filePath);
} catch (e) {
if (e.code === 'ENOENT') return { ok: true, isFile: false }; // 不存在:留给调用方走 FILE_NOT_FOUND 路径
return { ok: false, error: e.message, code: e.code };
}
if (st.isSymbolicLink()) {
return { ok: false, error: 'SYMLINK_NOT_ALLOWED', message: '不允许编辑符号链接(防止越权写入)' };
}
return { ok: true, isFile: st.isFile() };
}
/**
* audit fix (M1 main):递归检查 filePath 与其祖先(直到 stopAt 目录),
* 任一层级出现 symlink 即拒绝。光检查 target 文件不够 —— 若父目录是 symlink
* 指向 dataDir 外部,整个 target 的「真实路径」就会跳出 dataDir绕过
* isWithinDataDir 的字符串前缀检查。同步盘 / 误操作 / 外部攻击都可能制造
* 这种中间层 symlink。
*
* 策略:从 filePath 父目录开始向上 lstat到 stopAt一般是 dataDir为止
* 任一是 symlink 就拒绝。stopAt 本身允许是 symlink通常不允许但它的边界
* 由 assertDataDirSafe 等其它检查负责,这里只覆盖 target 这棵子树。
*
* @param {string} filePath - 目标文件路径
* @param {string} [stopAt] - 终止祖先链的目录(默认不传 = 一路 lstat 到根,
* 但调用方一般会传 dataDir 来限定范围)
* @returns {Promise<{ok:true} | {ok:false, error:string, code?:string}>}
*/
async function assertNoSymlinkAncestor(filePath, stopAt) {
if (!filePath) return { ok: false, error: 'INVALID_PATH', code: 'INVALID_PATH', message: '路径不能为空' };
const stopResolved = stopAt ? path.resolve(stopAt) : null;
// audit fix (Phase O fix):原实现只从 filePath 的**父目录**开始向上 lstat
// 漏掉 filePath 自身。若 filePath 本身是 symlink指向外部目标盘父目录
// 通常都是合法的 dataDir 子项,但 filePath 本身跟随 symlink 解析后就跳到
// dataDir 之外 —— isWithinDataDir 的字符串前缀边界被绕过。
// 先 lstat filePath 自身:在 stopResolved 子树里也要拒绝自身是 symlink 的场景。
const resolvedPath = path.resolve(filePath);
if (!stopResolved || resolvedPath !== stopResolved) {
try {
const stSelf = await fs.lstat(resolvedPath);
if (stSelf.isSymbolicLink()) {
return {
ok: false,
error: 'SYMLINK_NOT_ALLOWED',
code: 'SYMLINK_NOT_ALLOWED',
message: `路径中包含符号链接,不允许编辑(${resolvedPath}`,
};
}
} catch (e) {
// ENOENT目标本身不存在是允许的scan-dir 创建场景、新文件);其它错误上报
if (e.code !== 'ENOENT') {
return { ok: false, error: e.message, code: e.code, message: `读取路径状态失败:${e.code || e.message}` };
}
}
}
let dir = path.dirname(resolvedPath);
// 已经走到根盘符 / 根目录就停
const seen = new Set();
while (dir && !seen.has(dir)) {
seen.add(dir);
// 终止祖先链的判定stopAt 目录已检查过、不再向上 lstat。
// audit fix (main-M10):之前三连 OR 里 `dir === stopResolved.toLowerCase?.()`
// 是死分支 —— dir 来自 path.dirname()Windows 上是混合大小写,根本不会
// 全小写化。删掉,只保留 POSIX 严格相等 + Windows 大小写无关两条。
if (stopResolved && (dir === stopResolved
|| (process.platform === 'win32' && dir.toLowerCase() === stopResolved.toLowerCase()))) {
break;
}
try {
const st = await fs.lstat(dir);
if (st.isSymbolicLink()) {
return {
ok: false,
error: 'SYMLINK_NOT_ALLOWED',
code: 'SYMLINK_NOT_ALLOWED',
message: `路径中包含符号链接目录,不允许编辑(${dir}`,
};
}
} catch (e) {
// 父目录不存在是允许的target 可能尚未创建);其它错误上报
if (e.code !== 'ENOENT') {
// audit fix (K1-R4):统一带 code + message 字段,与 assertNotSymlink 对齐。
return { ok: false, error: e.message, code: e.code, message: `读取目录状态失败:${e.code || e.message}` };
}
break;
}
const parent = path.dirname(dir);
if (parent === dir) break; // 已经到盘符根
dir = parent;
}
return { ok: true };
}
/**
* 检查目标路径是否在数据目录内(防止越权读写)
* - Windows 不区分大小写
* - 字符串前缀比对path.resolve 后再做)
* - 未调用 fs.realpathsymlink 不解file:write/rename/delete 已先 lstat 拒绝 symlink
*
* @param {string} target
* @param {string} root
*/
function isWithinDataDir(target, root) {
if (!root || !target) return false;
const rootResolved = path.resolve(root);
const targetResolved = path.resolve(target);
if (process.platform === 'win32') {
const rootLower = rootResolved.toLowerCase();
const targetLower = targetResolved.toLowerCase();
return targetLower.startsWith(rootLower + path.sep)
|| targetLower === rootLower;
}
return targetResolved === rootResolved
|| targetResolved.startsWith(rootResolved + path.sep);
}
/**
* 文件名清洗:拒绝路径分隔符 / `..` / 空字符串 / 控制字符;用户输入的扩展名
* 原样保留;重名 (2)、(3)...。
*
* audit fix (Phase L3-FS 4A/4B):拒绝 Windows 保留字符(<>:"/\\|?*)和保留设备名
* (CON / PRN / AUX / NUL / COM1-9 / LPT1-9)。之前 name 里含 `|` 或 `:` 仍能通过
* resolveFileName到 fs.rename 才在 Windows 上撞 EPERM/EBUSYUI 弹出
* 「没有重命名权限 / 文件被占用」误导性中文。预先拒绝给清晰的中文错误。
*
* 用户反馈2026-08-28「新建笔记时直接把后缀放在文件名后面而不是自动
* 加上」—— 本函数不再强制补 .md。用户输入 `foo.md` / `foo.txt` / `foo`(无扩展名)
* 都按字面保留。重名避让时扩展名也原样保留foo.txt → foo (2).txt
* 重命名场景走 resolveRenameName行为完全对称。
*
* @param {string} raw
* @param {string} dir - 数据目录绝对路径
* @returns {Promise<{ok:true, path:string, name:string} | {ok:false, error:string}>}
*/
// Windows 保留设备名(基础名,无扩展名时)。注意是大写比对;用户的实际
// 输入通常是大小写混合,做 toUpperCase 后再校验。带扩展名也照样拒
// Windows 把 CON.md / con.txt 都视作设备名 —— 这是 NTFS 的硬规则,
// fs.rename 一定撞 EPERM
const WIN_RESERVED_DEVICE_NAMES = new Set([
'CON', 'PRN', 'AUX', 'NUL',
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
]);
async function resolveFileName(raw, dir) {
if (typeof raw !== 'string' || !raw.trim()) {
return { ok: false, error: '文件名不能为空' };
}
const name = raw.trim();
// 拒绝路径分隔符与 .. 段
if (/[/\\]/.test(name) || name.includes('..')) {
return { ok: false, error: '文件名不能包含路径分隔符或 ..' };
}
// Windows 保留字符:<>:"/\\|?*/\\ 已被上面挡一次,这里再列一遍保持语义独立,
// 让错误信息精确指向 Windows 保留字符而非「路径分隔符」)。
if (/[<>:"|?*]/.test(name)) {
return { ok: false, error: '文件名包含 Windows 保留字符(< > : " | ? *' };
}
// 控制字符
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1f]/.test(name)) {
return { ok: false, error: '文件名包含非法字符' };
}
// Windows 保留设备名:取最后一个 . 之前的部分("CON.md" / "CON" / "CON.txt" 都算)
const lastDot = name.lastIndexOf('.');
const baseForReserved = (lastDot > 0 ? name.slice(0, lastDot) : name).toUpperCase();
if (WIN_RESERVED_DEVICE_NAMES.has(baseForReserved)) {
return { ok: false, error: `"${baseForReserved}" 是 Windows 保留设备名,不允许作为文件名` };
}
// 用户输入的扩展名原样保留 —— 故意不强制 .md与 resolveRenameName 对齐)。
let candidate = name;
let counter = 2;
for (;;) {
const fullPath = path.join(dir, candidate);
// 任何候选路径都必须仍在数据目录内(防止 join 出 ..\
if (!isWithinDataDir(fullPath, dir)) {
return { ok: false, error: '非法路径' };
}
const occupancy = await pathOccupancy(fullPath);
if (occupancy === 'free') {
return { ok: true, path: fullPath, name: candidate };
}
// 'file' / 'directory' 都视为占用 → 走避让分支
// 重名避让:取最后一个 . 之前的部分加 (N),扩展名原样保留(与 resolveRenameName 对齐)。
// foo.md → foo (2).mdbar.txt → bar (2).txtbaz无扩展名 → baz (2)
const cutAt = lastDot > 0 ? lastDot : name.length;
const base = name.slice(0, cutAt);
const ext = name.slice(cutAt);
candidate = `${base} (${counter})${ext}`;
counter += 1;
if (counter > 1000) return { ok: false, error: '重名次数过多' };
}
}
/**
* 重命名专用的文件名清洗。
*
* 用户反馈:「重命名不要自动补后缀」—— 重命名场景下用户输入什么就用什么,
* 包括完全去掉扩展名foo.md → bar或换成别的扩展名foo.md → bar.txt
*
* 2026-08-28 反馈后resolveFileName新建文件与本函数语义完全对齐
* 都不强制补 .md新建时用户输入什么就用什么默认 .md 仍由 renderer 的
* defaultName 带出来)。这样用户想新建一个 .txt 纯文本笔记 / .json 数据笔记
* 不再需要「先建 .md → 重命名成 .txt」两步。
*
* 仍然保留的校验:
* - 路径分隔符 / ..
* - Windows 保留字符
* - Windows 保留设备名(基础名按 . 之前的部分取,"CON.md" 也算)
* - 控制字符
* - 重名避让((2)/(3) 后缀保留原始扩展名foo.md → foo (2).md
* foo.txt → foo (2).txt无扩展名 foo → foo (2)
*
* @param {string} raw
* @param {string} dir
*/
async function resolveRenameName(raw, dir) {
if (typeof raw !== 'string' || !raw.trim()) {
return { ok: false, error: '文件名不能为空' };
}
const name = raw.trim();
// 拒绝路径分隔符与 .. 段
if (/[/\\]/.test(name) || name.includes('..')) {
return { ok: false, error: '文件名不能包含路径分隔符或 ..' };
}
if (/[<>:"|?*]/.test(name)) {
return { ok: false, error: '文件名包含 Windows 保留字符(< > : " | ? *' };
}
// 控制字符
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1f]/.test(name)) {
return { ok: false, error: '文件名包含非法字符' };
}
// Windows 保留设备名:取最后一个 . 之前的部分("CON.md" / "CON" / "CON.txt" 都算)
const lastDot = name.lastIndexOf('.');
const baseForReserved = (lastDot > 0 ? name.slice(0, lastDot) : name).toUpperCase();
if (WIN_RESERVED_DEVICE_NAMES.has(baseForReserved)) {
return { ok: false, error: `"${baseForReserved}" 是 Windows 保留设备名,不允许作为文件名` };
}
let candidate = name;
let counter = 2;
for (;;) {
const fullPath = path.join(dir, candidate);
if (!isWithinDataDir(fullPath, dir)) {
return { ok: false, error: '非法路径' };
}
// 审计修复 (Round 11 deep-fix P2-4):用 pathOccupancy 替代 fs.access
// 区分 file vs directory —— 同名目录不能被当作可用文件返回。
const occupancy = await pathOccupancy(fullPath);
if (occupancy === 'free') {
return { ok: true, path: fullPath, name: candidate };
}
// 重名避让:取最后一个 . 之前的部分加 (N),扩展名原样保留。
// foo.md → foo (2).mdbar.txt → bar (2).txtbaz无扩展名 → baz (2)
const cutAt = lastDot > 0 ? lastDot : name.length;
const base = name.slice(0, cutAt);
const ext = name.slice(cutAt);
candidate = `${base} (${counter})${ext}`;
counter += 1;
if (counter > 1000) return { ok: false, error: '重名次数过多' };
}
}
/**
* 扫描数据目录下的所有 .md / .markdown 文件按名称排序locale zh-CN
*
* 重要变更audit #3目录不存在ENOENT时不再自动 mkdir
* 直接返回错误让上层决定如何处理(弹对话框 / 切换目录),
* 避免「数据目录被外部删了之后被静默重建掩盖数据丢失」。
*
* @param {string} dir
* @returns {Promise<{ok:true, files:Array} | {ok:false, error:string, code?:string}>}
*/
async function scanFiles(dir) {
if (!dir || typeof dir !== 'string') {
return { ok: false, error: 'invalid dir', code: 'EINVAL' };
}
try {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (e) {
// ENOENT目录不存在 —— 不再自动重建,让用户看到明确错误
if (e.code === 'ENOENT') {
return { ok: false, error: 'DATA_DIR_NOT_FOUND', code: 'ENOENT', message: `数据目录不存在:${dir}` };
}
throw e;
}
const mdFiles = entries.filter((e) => {
if (!e.isFile()) return false;
const lower = e.name.toLowerCase();
return lower.endsWith('.md') || lower.endsWith('.markdown');
});
const files = await Promise.all(mdFiles.map(async (e) => {
const fullPath = path.join(dir, e.name);
try {
const st = await fs.stat(fullPath);
return {
name: e.name,
path: fullPath,
size: st.size,
mtimeMs: st.mtimeMs,
};
} catch {
return null;
}
}));
const list = files
.filter((f) => f !== null)
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
return { ok: true, files: list };
} catch (e) {
return { ok: false, error: e.message, code: e.code };
}
}
/**
* 扫描一个目录下的所有直接条目(文件夹 + 文件),按类型分类。
* Folder Browser2026-08的核心入口。
*
* 行为约定:
* - 不递归:只列 absDir 的一层直接子项;想看深层 → 点文件夹进入
* - 跳过符号链接文件夹(与 assertNotSymlink 同源策略;
* 不跟随,避免 symlink 指向 dataDir 外部造成越权)
* - 跳过符号链接文件(同样防御)
* - 文件 entryType 由 classifyEntry(name) 决定:
* 'editable' = Markdown 或常见纯文本(白名单内)
* 'binary' = 其它扩展名(侧栏仍显示但灰掉、点击弹 toast
* - 文件夹 entryType 固定为 'folder'
* - 排序:文件夹在前(按名称),文件在后(按名称);
* 让用户先看到目录结构再看到内容,更符合 Explorer/Finder 的直觉
*
* @param {string} absDir - 数据目录下某一层的绝对路径
* @returns {Promise<{
* ok: true,
* dir: string, // 回传绝对路径,便于调用方对照
* entries: Array<{
* name: string,
* path: string, // 绝对路径
* entryType: 'folder'|'editable'|'binary',
* isFolder: boolean,
* size?: number,
* mtimeMs?: number,
* }>
* } | { ok:false, error, code?, message? }>}
*/
async function scanDir(absDir) {
if (!absDir || typeof absDir !== 'string') {
return { ok: false, error: 'invalid dir', code: 'EINVAL' };
}
let dirents;
try {
dirents = await fs.readdir(absDir, { withFileTypes: true });
} catch (e) {
if (e.code === 'ENOENT') {
return { ok: false, error: 'DATA_DIR_NOT_FOUND', code: 'ENOENT', message: `目录不存在:${absDir}` };
}
// audit fix (Round 12 P2):非 ENOENT 路径不再把原始 e.message英文 errno
// + 绝对路径透出去。renderer fs-watcher 推过来时直接当成 toast 文案
// 显示,会泄漏路径 + 看着割裂。走 friendly-fs-error 与 Round 8 EROFS /
// ENAMETOOLONG 等统一:未知 errno 时拿 e.message 作为 fallback兜底
// 业务码),不能让用户看空白 toast。
return {
ok: false,
error: sharedFriendlyFsError(e && e.code, e && e.message) || '扫描目录失败',
code: e.code,
};
}
// 用 lstat 一次性拿每一项的类型,过滤掉符号链接(防止越权)。
// 注意 fs.readdir(..., {withFileTypes:true}) 给的 dirent.isSymbolicLink()
// 在 Windows 上对 junction 也判定为 true —— 这里统一跳,避免跟随。
const safeDirents = [];
for (const d of dirents) {
if (d.isSymbolicLink()) continue;
safeDirents.push(d);
}
const entries = await Promise.all(safeDirents.map(async (d) => {
const fullPath = path.join(absDir, d.name);
if (d.isDirectory()) {
return {
name: d.name,
path: fullPath,
entryType: 'folder',
isFolder: true,
};
}
if (d.isFile()) {
try {
const st = await fs.stat(fullPath);
return {
name: d.name,
path: fullPath,
entryType: classifyEntry(d.name),
isFolder: false,
size: st.size,
mtimeMs: st.mtimeMs,
};
} catch {
// 文件在 readdir 与 stat 之间被删了 —— 静默跳过
return null;
}
}
// 其它socket / fifo / block device 等)—— 不展示
return null;
}));
const list = entries.filter(Boolean);
// 排序folder 在前、file 在后,同组内按 zh-CN locale
list.sort((a, b) => {
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
return a.name.localeCompare(b.name, 'zh-CN');
});
return { ok: true, dir: absDir, entries: list };
}
/**
* 把数据目录根下的相对路径解析成绝对路径,并做防御性校验。
*
* 渲染端传来的 relDir 必须满足:
* - 空字符串 → dataRoot根目录
* - 正斜杠分隔的相对路径(如 'notes/2026'),不含 ..
* - 不含绝对路径前缀Windows 盘符 / POSIX /
*
* 解析后用 isWithinDataDir 二次校验(防止路径穿越 / symlink 跟随)。
*
* @param {string} relDir
* @param {string} dataRoot - 数据目录绝对路径
* @returns {{ok:true, absDir:string, relDir:string} | {ok:false, error:string, message?:string}}
*/
function resolveDirRelative(relDir, dataRoot) {
if (typeof relDir !== 'string') {
return { ok: false, error: 'INVALID_REL_DIR', message: 'relDir 必须是字符串' };
}
// 拒绝盘符 / 绝对路径前缀(必须在 strip 前做,否则 '/etc/passwd' 被剥成
// 'etc/passwd' 反而通过校验)
if (/^[a-z]:[\\/]/i.test(relDir) || relDir.startsWith('/') || relDir.startsWith('\\')) {
return { ok: false, error: 'PATH_NOT_ALLOWED', message: '不允许使用绝对路径' };
}
const normalized = relDir.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
// 拒绝 .. 段(即使藏在中间)
if (normalized.split('/').some((seg) => seg === '..' || seg === '.')) {
return { ok: false, error: 'PATH_NOT_ALLOWED', message: '相对路径不能包含 . 或 ..' };
}
const absDir = normalized === '' ? dataRoot : path.join(dataRoot, normalized);
if (!isWithinDataDir(absDir, dataRoot)) {
return { ok: false, error: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' };
}
// 重新标准化回 POSIX与传入保持一致+ 末尾无 /
const relOut = normalized;
return { ok: true, absDir, relDir: relOut };
}
/**
* 给定 dataRoot 与当前目录的绝对路径,反算 POSIX 风格的相对路径。
* 渲染端用于比对 fs-watcher 推送的 relDir 字段。
*
* @param {string} absDir
* @param {string} dataRoot
* @returns {string} POSIX 相对路径,根目录时为 ''
*/
function toRelativeDir(absDir, dataRoot) {
if (!absDir || !dataRoot) return '';
const a = absDir.replace(/\\/g, '/').replace(/\/+$/, '');
const r = dataRoot.replace(/\\/g, '/').replace(/\/+$/, '');
if (a === r) return '';
const prefix = r + '/';
if (a.startsWith(prefix)) return a.slice(prefix.length);
// 不在 dataRoot 下(理论上不该发生)—— 返回空字符串让渲染端走根路径分支
return '';
}
/**
* 原子写文件 —— audit fixC1/C2 file-IO
* 1. 写到 dst.tmp.<pid>.<now>
* 2. fsync tmp让内容确实落盘再 rename 才不会丢)
* 3. renameWithRetry 覆盖 dstWindows Defender / 杀毒 / 同步盘
* 偶尔瞬态持锁retry-on-busy 比直接放弃稳得多)
*
* 失败路径:
* - 写入 tmp 失败 → unlink tmp + 抛原始 errno
* - rename 失败retry 后)→ unlink tmp + 抛原始 errno
*
* 副作用:成功后磁盘上要么是旧文件(旧文件全程未动),要么是新文件;
* 永远不会有半截内容被读到。
*
* @param {string} dst 目标绝对路径
* @param {string} content UTF-8 文本
*/
async function atomicWriteFile(dst, content) {
const tmpPath = `${dst}.tmp.${process.pid}.${Date.now()}`;
let fh;
try {
fh = await fs.open(tmpPath, 'w');
await fh.writeFile(content, 'utf-8');
// fsync 关键:没有 fsyncrename 之后断电可能留下「磁盘上 inode 改了
// 但内容还在 page cache、从未刷盘」的零字节文件。Windows 上 fsync 等价
// FlushFileBuffersrename 之前必须强制落盘。
await fh.sync();
await fh.close();
fh = null;
} catch (e) {
if (fh) { try { await fh.close(); } catch { /* ignore */ } }
try { await fs.unlink(tmpPath); } catch { /* tmp 不存在也忽略 */ }
throw e;
}
try {
await renameWithRetry(tmpPath, dst);
// audit fix (Round 4 P0-1)rename 后 fsync 父目录(仅 Linux / POSIX
// POSIX rename(2) 在同分区下原子,但「目录项本身」写入磁盘的时机由内核
// 控制;不 fsync 目录就断电,磁盘上可能仍是旧名字 → 文件彻底丢失。
// Windows 上 NTFS 文件系统层 journal 元数据,这条 fsync 不需要(也无害,
// 但 fs.open(path, 'r') 在目录上 Windows 会拒绝写操作,所以这里走 OS 守卫)。
// 风险:失败 fsync 不抛(让 save 走 OK 路径),由下次保存自动覆盖。
if (process.platform !== 'win32') {
try {
const dirFh = await fs.open(path.dirname(dst), 'r');
await dirFh.sync();
await dirFh.close();
} catch (dirFsyncErr) {
console.warn('[file-ops] 父目录 fsync 失败(不影响本次保存内容,但跨崩溃可能丢目录项):', dirFsyncErr.message);
}
}
} catch (e) {
// rename 失败:清理残余 tmp不影响下次保存
try { await fs.unlink(tmpPath); } catch { /* 文件可能已被 rename 移走 */ }
throw e;
}
}
/**
* audit fix (C3 file-IO)rename 重试 EBUSY/EPERM/EACCES。
* Windows Defender / 杀毒 / 同步盘会在极短时间内持锁目标文件,
* 单次 rename 失败率约 1-3%。3 次 50/100/200ms backoff 后仍失败才抛。
* 与 config-store 的同名实现保持一致的 backoff 时序,避免两条路径表现差异。
* @param {string} src
* @param {string} dst
*/
async function renameWithRetry(src, dst) {
const delays = [50, 100, 200];
for (let i = 0; i <= delays.length; i += 1) {
try {
await fs.rename(src, dst);
return;
} catch (e) {
const transient = e.code === 'EBUSY' || e.code === 'EPERM' || e.code === 'EACCES';
// 末次重试仍失败 → 直接抛;非瞬态错误也直接抛(不浪费重试)。
if (!transient || i === delays.length) throw e;
await new Promise((r) => setTimeout(r, delays[i]));
}
}
}
/**
* audit fix (Round 4 收尾)file:write 的 errno 翻译 —— 与 main.js 各 IPC handler
* _friendlyCreateError / _friendlyRenameError / _friendlyDeleteError / _friendlyReadError
* 共用一份文案。Round 4 之前本函数与 src/app.js#friendlyWriteError、
* src/file-ops.js#friendlyFsError 三处独立EROFS / ENAMETOOLONG / ENOTDIR 文案
* 三处不同ENOTEMPTY 在 src/file-ops.js 独有 —— 用户看到的提示不一致。
*
* 改用 shared/friendly-fs-error.js 单一事实源preload 同时把它过桥到
* window.api.friendlyFsErrorrenderer 两处旧实现也走同一份)。本函数保留壳子
* 是为了不重写所有调用点的语义main 内部仍叫 friendlyWriteError 表达
* 「写盘错误翻译」renderer 走 window.api.friendlyFsError 表达通用 errno 翻译)。
*
* @param {NodeJS.ErrnoException|null|undefined} e
* @returns {string} 中文提示(永不为空 —— 兜底走「未知错误」)
*/
function friendlyWriteError(e) {
// 把 e.message 作为 fallback 透传给 sharedFriendlyFsError。
// - 已知 errnoshared 模块返回固定中文文案,与 e.message 无关
// - 未知 errno / null e / e.code 缺失shared 模块走 fallback || '未知错误'
// → e?.message 有就透传英文 errno + 路径renderer 拿到的是已经走
// friendlyFsError 二次翻译过的中文,不会再让英文 errno 漏到这里);
// 没有就回退到「未知错误」(注意:旧版这里固定传「写入文件失败」,
// 与 renderer 三处的「未知错误」兜底文案不一致,统一为后者)
return sharedFriendlyFsError(e && e.code, e && e.message);
}
module.exports = {
MAX_FILE_SIZE,
EDITABLE_EXTS,
assertNotSymlink,
assertNoSymlinkAncestor,
classifyEntry,
isWithinDataDir,
resolveFileName,
resolveRenameName,
scanFiles,
scanDir,
resolveDirRelative,
toRelativeDir,
atomicWriteFile,
renameWithRetry,
friendlyWriteError,
};

500
main/fs-watcher.js Normal file
View File

@@ -0,0 +1,500 @@
// 目录监听层Stage 4b.2 抽离 + Stage 8 扩展)
//
// 职责:
// - fs.watch + readdir 轮询双通道捕获数据目录变化
// - 把变化推送给 rendererIPC 'files:changed'payload 含 relDir / dir
// - 窗口最小化 / 隐藏时暂停轮询fs.watch 保持运行),恢复时补扫一次
// - Stage 8支持 rewatch() 把监听目标切换到子目录Folder Browser
//
// 设计:
// - factory 模式:依赖通过参数注入,不引用 mainWindow / configStore 等具名符号
// - 状态dirWatcher / dirPollTimer / lastFilesSnapshot / watchedDir / emitDebounceTimer
// 全部闭包在 factory 返回的实例里,不污染模块全局
//
// 边界:
// - 不读 mainWindow 具名变量 —— 由 deps.getMainWindow() 提供(避免循环引用)
// - 不调 configStore —— 由 deps.resolveDataDir() 注入dir 变化也走这里)
// - 不重复实现 scanDir —— 由 deps.scanDir() 注入(同一份扫描逻辑)
const fsSync = require('fs');
const DIR_POLL_INTERVAL_MS = 2000;
const EMIT_DEBOUNCE_MS = 300;
// audit fix (Round 7 FS-F8)debounce maxWait —— 持续事件流git checkout /
// 大目录解压 / AI 批量写)让 300ms 防抖窗口永远不能 trailing flush
// fs.watch 通道一次都不 emit。maxWait=1000ms 强制 1s 内必须 emit 一次。
const EMIT_MAX_WAIT_MS = 1000;
const REATTACH_BASE_MS = 5_000;
const REATTACH_MAX_MS = 60_000;
// audit fix (Round 7 FS-F1):错误闩锁重复抑制窗口。数据目录被删除时
// updateSnapshot 每 2s 失败一次 → renderer 每 2s 弹一次 error toast →
// 屏幕常驻 5 条警告。30s 重发间隔让用户有充裕时间响应,又不至于漏掉
// 真实新错误(同 error 不同 dir 也算新错误,立即重发)。
const ERROR_REPEAT_THROTTLE_MS = 30_000;
/**
* @typedef {Object} FsWatcherDeps
* @property {() => (object|null)} getMainWindow - 返回 BrowserWindow 或 null
* @property {() => (string|null)} resolveDataDir - 当前生效的数据目录
* @property {(dir: string) => Promise<{ok:boolean, entries?:Array, error?:string}>} scanDir
* 扫描目录,返回 {ok:true,entries} 或 {ok:false,error}。
* Stage 8必须返回 entries含文件夹 + 文件),否则非 md 文件变化会漏报。
* @property {(absDir: string) => string} [toRelDir]
* 可选:把绝对目录路径转成 dataRoot 下的 POSIX 风格相对路径。
* 没传则 payload 不带 relDir 字段(旧调用方完全兼容)。
*/
function createFsWatcher(deps) {
// 解构 + 一次性校验:避免后面每个调用都判 null
const { getMainWindow, resolveDataDir, scanDir, toRelDir } = deps;
if (typeof getMainWindow !== 'function') throw new Error('getMainWindow 必须是函数');
if (typeof resolveDataDir !== 'function') throw new Error('resolveDataDir 必须是函数');
if (typeof scanDir !== 'function') throw new Error('scanDir 必须是函数');
const resolveRelDir = typeof toRelDir === 'function' ? toRelDir : null;
let dirWatcher = null;
let dirPollTimer = null;
let lastFilesSnapshot = '';
/** 当前被监听的目录(供轮询暂停/恢复复用,不必重新传参) */
let watchedDir = null;
let emitDebounceTimer = null;
// audit fix (Round 7 FS-F8)burst 首事件时间戳。持续事件流在
// burstStartedAt + EMIT_MAX_WAIT_MS 时强制 flush避免 trailing-only
// debounce 在永不静默的流上不发任何事件。
let burstStartedAt = 0;
// audit fix (shared-M9)generation token。startWatchingDir / rewatch 每次
// 切换目录都自增updateSnapshot 内 await 结束后比对 token
// - 不匹配 → 这是「上一代目录」的扫描结果,丢弃(既不要写 baseline
// 也不要 emitFilesChanged —— 否则基线会被旧目录列表覆盖,新目录的
// 真实首次扫描结果反而被当成「无变化」忽略,造成侧栏与磁盘状态
// 不一致的幽灵 bug
// - 匹配 → 当前活跃扫描,正常写 baseline + 推送
let dirGeneration = 0;
// audit fix (Round 7 FS-F1)错误闩锁。lastErrorKey = error+code+relDir 拼接;
// 同 key 在 ERROR_REPEAT_THROTTLE_MS 内不再重发,新 key 立即重发,扫描恢复
// ok=true时清空。避免「数据目录被删除」类持续错误每 2s 弹一次 toast。
let lastErrorKey = '';
let lastErrorAt = 0;
// audit fix (Round 7 FS-F5)重连退避计数。OneDrive / 网盘短时不可用时
// 重连失败指数退避 5s → 10s → 30s → 60s封顶避免「目录长时间不存在」
// 时每 5s 打一行 warn 的忙循环。挂载成功后 nextBackoffMs 重置回基线。
let nextBackoffMs = REATTACH_BASE_MS;
// audit fix (Round 7 FS-F7)轮询重入保护。慢盘OneDrive / SMB
// scanDir 可能 > 2ssetInterval 触发新调用与上一轮 await 并发争写
// lastFilesSnapshot → 后完成的可能是先发起的(旧快照覆盖新基线)。
// pollInFlight 守卫:上一轮未返回时直接跳过本次。
let pollInFlight = false;
function isWindowVisible() {
const win = getMainWindow();
return !!win && !win.isDestroyed() && win.isVisible() && !win.isMinimized();
}
/**
* 条目列表的稳定指纹folder 用 namefile 用 name + size + mtime。
* 只在这一个地方定义两个调用点updateSnapshot / emitFilesChanged共用。
* @param {Array<{name:string,isFolder?:boolean,size?:number,mtimeMs?:number}>} entries
* @returns {string}
*/
function entriesSnapshot(entries) {
return entries.map((e) => {
if (e.isFolder) return `d:${e.name}`;
return `f:${e.name}|${e.size}|${e.mtimeMs}`;
}).join('\n');
}
/** 把绝对目录转成 relDirpayload 字段);内部统一走 resolveRelDir。 */
function relOf(absDir) {
if (!resolveRelDir || !absDir) return null;
try {
const r = resolveRelDir(absDir);
return typeof r === 'string' ? r : null;
} catch {
return null;
}
}
/**
* 判断当前是否应该 push 这次错误(基于 lastErrorKey + lastErrorAt 闩锁)。
* 同 key 在节流窗口内 → 抑制;新 key → 立即放行;恢复 ok → 调用方自动清闩。
* @param {string} errorKey - "error:code:relDir" 拼接
* @returns {boolean}
*/
function shouldReportError(errorKey) {
const now = Date.now();
if (errorKey !== lastErrorKey) return true;
return (now - lastErrorAt) >= ERROR_REPEAT_THROTTLE_MS;
}
/** 记录错误已上报(写闩锁)。 */
function recordError(errorKey) {
lastErrorKey = errorKey;
lastErrorAt = Date.now();
}
/** 清错误闩锁(扫描恢复 ok 时调用)。 */
function clearErrorLatch() {
lastErrorKey = '';
lastErrorAt = 0;
}
/**
* 安全发送main-M7teardown 窗口过程抛 throw → unhandledException
* @param {object} payload
*/
function safeSend(payload) {
const win = getMainWindow();
if (!win || win.isDestroyed()) return;
try {
win.webContents.send('files:changed', payload);
} catch (e) {
console.warn('[fs-watcher] send failed:', e && e.message);
}
}
async function updateSnapshot(dir, skipInitialEmit = false) {
// audit fix (shared-M9):捕获「这一代」的 generation tokenawait 结束后
// 与当前 dirGeneration 比对 —— 不一致说明用户在扫描期间又切换了目录,
// 本次结果属于上一代,必须丢弃(不能写 baseline也不能 push
const myGen = dirGeneration;
let result;
try {
result = await scanDir(dir);
} catch (e) {
// audit fix (Round 7 FS-F16)scanDir 是注入依赖,契约未约定「绝不
// reject」。catch 兜住,避免 unhandledRejection 杀进程(--unhandled-
// rejections=strict 下直接 fatal
console.warn('[fs-watcher] scanDir 抛出:', e && e.message);
return;
}
if (myGen !== dirGeneration) {
// 切到新目录了:旧扫描结果作废。新目录的扫描正在另一份 updateSnapshot
// 跑着,让它去写基线 / 推送即可。
return;
}
if (!result.ok) {
// 扫描失败audit #3 续):
// 以前只是 console.warn + 不更新基线,结果用户看到的是「目录被删了但列表还显示着旧文件」——
// 误以为数据还在,点了文件得到 FILE_NOT_FOUND 报错。
// 现在:把错误推给 renderer让它在保留旧列表的同时显式提示用户
// "数据目录不可访问"),并提供一个「重新选择」的入口。
// 基线故意不更新 —— 恢复访问后下次轮询自然会有新基线。
console.warn('[fs-watcher] updateSnapshot 失败:', result.error);
if (!skipInitialEmit) {
// audit fix (Round 7 FS-F1)错误闩锁。DATA_DIR_NOT_FOUND / EACCES
// 这类每 2s 复发的错误不再每次都 toast。error+code+relDir 拼接作为
// 闩锁 key同 key 30s 内只发一次,新 key 立即发,恢复 ok 时清空。
// audit fix (Round 7 FS-F13)relDir 用本次扫描的 dir不是
// currentRelDir()/watchedDir—— generation 语义上「本次扫描属于
// 哪个 dir」应该跟着参数走。
const relDir = relOf(dir);
const errorKey = `${result.error || ''}:${result.code || ''}:${relDir || ''}`;
if (shouldReportError(errorKey)) {
recordError(errorKey);
safeSend({
error: result.error,
code: result.code,
dir,
relDir,
});
}
}
return;
}
// 恢复 ok → 清错误闩锁
clearErrorLatch();
const list = result.entries || [];
const snapshot = entriesSnapshot(list);
if (snapshot !== lastFilesSnapshot) {
lastFilesSnapshot = snapshot;
if (!skipInitialEmit) {
emitFilesChanged(list, dir);
}
}
}
/**
* 向 renderer 推送文件列表。
*
* @param {Array=} precomputed - 调用方已经扫描好的列表updateSnapshot 会传),
* 传了就不再重复 scanDir。
* @param {string=} dir - 列表对应的目录绝对路径;不传则用 watchedDir。
*
* 注意:走「自己扫描」分支时必须同步 lastFilesSnapshot 基线。否则 fs.watch 触发的
* 这次推送不会更新基线,紧随其后的 2 秒轮询会认为列表「又变了」,导致每次外部改动
* 都发生一次重复扫描 + 重复 IPC 推送。
*
* Stage 8payload 新增 relDir 字段,告诉渲染端「这是哪个目录的变更」,
* 渲染端比对当前显示目录决定是否重扫。
*
* audit fix (Round 7 FS-F11)payload 同时带 `dir` 字段(绝对路径)与
* `relDir`POSIX 相对路径),与 file:scan-dir handler 的返回形状对齐,
* 方便 renderer 兜底逻辑无需依赖 IPC 调用上下文。
*/
async function emitFilesChanged(precomputed, dir) {
const targetDir = dir || watchedDir || resolveDataDir();
// audit fix (shared-M9):同样的 generation 防护。emitFilesChanged 自带
// scanDir 分支时同样有「scan 期间用户切目录 → 旧结果覆盖新基线」的竞态。
const myGen = dirGeneration;
let list;
if (precomputed) {
list = precomputed;
} else {
let result;
try {
result = await scanDir(targetDir);
} catch (e) {
// audit fix (Round 7 FS-F16)floating promise 兜底。
console.warn('[fs-watcher] emitFilesChanged scanDir 抛出:', e && e.message);
return;
}
if (myGen !== dirGeneration) return;
if (!result.ok) {
// 推送错误而不是清空列表 —— 临时错误不应让用户失去对已有文件的视图。
// 渲染端会保留上次的列表,仅显示错误提示。
// audit fix (Round 7 FS-F12):与 updateSnapshot 错误分支对齐,
// payload 同时带 code 字段。
const relDir = relOf(targetDir);
const errorKey = `${result.error || ''}:${result.code || ''}:${relDir || ''}`;
if (shouldReportError(errorKey)) {
recordError(errorKey);
safeSend({
error: result.error,
code: result.code,
dir: targetDir,
relDir,
});
}
return;
}
list = result.entries || [];
// 同步基线,避免轮询把这次改动再报一遍
lastFilesSnapshot = entriesSnapshot(list);
}
if (myGen !== dirGeneration) return;
// 成功路径同样补 dir 字段FS-F11
safeSend({ entries: list, dir: targetDir, relDir: relOf(targetDir) });
}
function scheduleEmit() {
const now = Date.now();
// audit fix (Round 7 FS-F8)maxWait —— 持续事件流git checkout /
// 大目录解压 / AI 批量写)让 300ms 防抖窗口永远 trailing flush 不出。
// burstStartedAt 记录首事件时间,到 maxWait 时强制立即 emit。
if (!burstStartedAt) burstStartedAt = now;
const elapsed = now - burstStartedAt;
const delay = elapsed >= EMIT_MAX_WAIT_MS ? 0 : EMIT_DEBOUNCE_MS;
if (emitDebounceTimer) clearTimeout(emitDebounceTimer);
// audit fix (main-M6):闭包里捕获触发本次 scheduleEmit 时的 watchedDir
// 300ms 后即便用户改了 dataDir或 Folder Browser 切了子目录),
// emitFilesChanged 仍按触发时的 dir 推送 + payload.relDir 与之对齐。
// 否则事件会被「错路由」到新目录的 renderer 视图,造成侧栏幽灵更新。
const dirAtSchedule = watchedDir;
const t = setTimeout(() => {
emitDebounceTimer = null;
burstStartedAt = 0;
emitFilesChanged(undefined, dirAtSchedule);
}, delay);
// audit fix (main-M8)unref —— 否则 fs-watcher 还在挂定时器时 Electron
// 主进程不会自然退出before-quit 取消 close path 后这 300ms 会再卡一下)。
if (typeof t.unref === 'function') t.unref();
emitDebounceTimer = t;
}
// audit fixfs.watch emit error 后退避重连。
// 用户场景OneDrive 暂时离线 → 网盘驱动报 EPERM → fs.watch 死掉;
// 5s 后重连,若目录又可访问就恢复事件推送。退避期间 2s 轮询仍兜底。
// audit fix (Round 7 FS-F5):指数退避 5s → 10s → 30s → 60s封顶
// 长期不存在的目录不再每 5s 打一行 warn。成功挂载后 nextBackoffMs 重置。
let reattachTimer = null;
function scheduleReattach() {
if (reattachTimer || !watchedDir) return;
const delay = nextBackoffMs;
const t = setTimeout(() => {
reattachTimer = null;
if (!watchedDir) return;
// startWatchingDir 内部 stopWatchingDir 会先关旧句柄,再开新句柄;
// 即使同名目录也会重新挂 error handler。失败时 startWatchingDir 自己
// console.warn仍依赖轮询兜底。
try {
startWatchingDir(watchedDir);
// 挂载成功:重置退避计数,给未来新错误回到基线 5s。
nextBackoffMs = REATTACH_BASE_MS;
} catch (e) {
console.warn(`[fs-watcher] 重连失败(${delay}ms 后再试):`, e.message);
// 失败:指数退避,封顶 60s。
nextBackoffMs = Math.min(nextBackoffMs * 2, REATTACH_MAX_MS);
scheduleReattach();
}
}, delay);
// audit fix (main-M8)unref —— 5s 重连定时器若还挂着会拖住进程退出。
if (typeof t.unref === 'function') t.unref();
reattachTimer = t;
}
function startDirPoll() {
if (dirPollTimer || !watchedDir) return;
// audit fix (Round 7 FS-F14)unref —— 这个 timer 是长期存活的,叠加
// will-quit 不清 fsWatcher 会在退出路径上拖住主进程。
dirPollTimer = setInterval(() => {
// audit fix (Round 7 FS-F7)重入保护。慢盘OneDrive / SMB
// scanDir 可能 > 2ssetInterval 触发新一轮与上一轮 await 并发,
// 争写 lastFilesSnapshot → 后完成的可能是先发起的(旧覆盖新)。
if (pollInFlight) return;
pollInFlight = true;
updateSnapshot(watchedDir)
.catch((e) => {
// audit fix (Round 7 FS-F16)floating promise 兜底。
console.warn('[fs-watcher] 轮询 updateSnapshot 抛出:', e && e.message);
})
.finally(() => {
pollInFlight = false;
});
}, DIR_POLL_INTERVAL_MS);
if (typeof dirPollTimer.unref === 'function') dirPollTimer.unref();
}
function stopDirPoll() {
if (dirPollTimer) {
clearInterval(dirPollTimer);
dirPollTimer = null;
}
}
/**
* 启动目录监听:
* - fs.watch: 立即触发,但 Windows / 云同步盘 / 网络盘可能不发事件
* - readdir 轮询: 兜底(每 2 秒)
* - 任一通道发现列表变化 → 通过 IPC 'files:changed' 推送新列表给 renderer
*
* Stage 8监听的目录可以是 dataRoot 本身也可以是其下的子目录Folder Browser 导航时
* 通过 rewatch() 切换。监听目标改变时fs.watch 句柄会先 close 再在新目录上重新打开。
*/
function startWatchingDir(dir) {
stopWatchingDir();
// audit fix (shared-M9):新一世代,旧扫描全部作废(见 updateSnapshot
// / emitFilesChanged 的 myGen 校验)。
dirGeneration += 1;
watchedDir = dir;
// 初始化基线(启动时不把历史文件当作「变化」)
// 第三个参数 skipInitialEmit = true仅同步基线不向 renderer 推送启动时的文件列表
// renderer 会通过 ipc 'file:list' / 'file:scan-dir' 自己拉取,避免重复更新)
updateSnapshot(dir, true);
try {
dirWatcher = fsSync.watch(dir, (eventType, filename) => {
// 不再按扩展名过滤 —— Stage 8 后列表里包含文件夹 + 各种扩展名,
// 任何变化都可能影响侧栏展示,让轮询做最终判定
// audit fix (Round 7 FS-F6)Windows ReadDirectoryChangesW 缓冲区
// 溢出时 Node 用 filename=null 上报(旧版当成噪声直接 return
// 等于在最需要重扫的时刻跳过重扫。批量操作git checkout /
// 解压 / AI 批量写)正好触发这个路径 —— 必须 scheduleEmit 触发重扫。
if (!filename) {
scheduleEmit();
return;
}
// Windows 上可能触发多次事件,加 300ms 防抖合并
scheduleEmit();
});
// audit fix监听底层错误。
// 之前 fs.watch 句柄没有 .on('error') —— 一旦 watchedDir 在外部被
// 删除 / 重命名 / 所在盘符消失fs.watch 会 emit 'error',没人接,
// 默认变 unhandledExceptionpoll 循环还在继续扫这个不存在的目录,
// 每次 updateSnapshot 都返回 ENOENT但 fs.watch 已经死了,
// 用户对数据目录的任何后续修改都收不到事件(只能靠 2s 轮询)。
// 现在挂上 error handler打日志 + 触发一次重新监听5s 退避),
// 让用户重命名 / 重新挂载盘后 fs.watch 自动恢复。
dirWatcher.on('error', (err) => {
console.warn('[fs-watcher] fs.watch 报错,重连退避中:', err.message);
scheduleReattach();
});
} catch (e) {
// audit fix (Round 7 FS-F5)startWatchingDir 内 fs.watch 同步抛
// ENOENT/EACCES/EPERM 时dirWatcher=null没有 error handler 可挂,
// scheduleReattach 永远不会再次触发。手动 schedule 一次,下一次轮询
// 失败 + fs.watch 仍没恢复也会被 scheduleReattach 自循环退避接管。
console.warn('[fs-watcher] 目录监听启动失败(仅依赖轮询):', e.message);
scheduleReattach();
}
// 窗口不可见时不必开轮询(会在 show/restore 时恢复)
if (isWindowVisible()) startDirPoll();
}
/**
* 切换监听目标到另一个子目录Folder Browser 用)。
*
* 与 startWatchingDir 的区别startWatchingDir 内部先 stopWatchingDir清基线所以
* 即使新旧目录完全相同也会重置基线rewatch 同名目录直接 no-op省一次基线重置。
*
* @param {string} dir - 新的绝对目录
*/
function rewatch(dir) {
if (!dir || typeof dir !== 'string') return;
if (dir === watchedDir) return;
startWatchingDir(dir);
}
/**
* 窗口最小化 / 收进托盘时暂停轮询 —— 没人在看文件列表,每 2 秒一次
* readdir + N 次 stat 是纯浪费(在 OneDrive / 坚果云这类同步盘上尤其贵)。
*
* fs.watch 故意保持运行:它是事件驱动的,开着几乎不花钱,隐藏期间的改动仍能捕获。
*/
function pauseDirPolling() {
stopDirPoll();
}
/** 恢复轮询,并立刻补扫一次,追上隐藏期间 fs.watch 可能漏掉的改动 */
function resumeDirPolling() {
if (!watchedDir) return;
startDirPoll();
updateSnapshot(watchedDir);
}
function stopWatchingDir() {
// audit fix (Round 7 FS-F9):自增 dirGeneration让在飞的
// updateSnapshot / emitFilesChanged await 返回后 myGen 校验失败、
// 不会回写刚清空的 baseline。
dirGeneration += 1;
if (emitDebounceTimer) {
clearTimeout(emitDebounceTimer);
emitDebounceTimer = null;
}
burstStartedAt = 0;
if (reattachTimer) {
clearTimeout(reattachTimer);
reattachTimer = null;
}
if (dirWatcher) {
try { dirWatcher.close(); } catch {}
dirWatcher = null;
}
stopDirPoll();
// audit fix (Round 7 FS-F5):停止监听也重置退避计数,避免下次启动
// 接着用上一会话的指数退避值。
nextBackoffMs = REATTACH_BASE_MS;
clearErrorLatch();
pollInFlight = false;
watchedDir = null;
lastFilesSnapshot = '';
}
return {
startWatchingDir,
rewatch,
pauseDirPolling,
resumeDirPolling,
stopWatchingDir,
/** 测试 / 调试用:当前是否在监听某个目录 */
get watchedDir() { return watchedDir; },
};
}
module.exports = {
createFsWatcher,
// DIR_POLL_INTERVAL_MS 故意不出 module.exports只是模块内 setTimeout
// 的常量(兜底 fs.watch 不稳的场景),无任何外部调用方 —— 出 exports
// 会让上层误以为「可以调整轮询频率」,徒增 API surface 风险。
};