update
This commit is contained in:
938
main/ai.js
Normal file
938
main/ai.js
Normal 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.aiErrors,main / 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 后被拼了 path,URL 非法)。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] || ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤错误回显里的敏感 token(OpenAI/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 风格 key:sk-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 key:AIzaSy 开头 + 33 字符。33 是 Google 当前规范
|
||||
.replace(/AIzaSy[A-Za-z0-9_-]{20,}/g, '[API_KEY]')
|
||||
// JWT:header.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-authorization(Q3 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 token(OpenAI 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):先剥 userinfo(https://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 / mock:fallback 到 .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 fix:hostname 校验 —— 之前只校验前缀,`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 不接受自定义 temperature(o1 固定为 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-8(16384)
|
||||
* 更低。结果是「越新、输出上限越高的模型,拿到的 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 类型的 Response,status 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 key(fetch 库 / 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 已经走过 sanitizeDetail,key 类 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_tokens(gpt-5 / o-series 只接受后者)。
|
||||
// o-series / gpt-5 也不接受自定义 temperature(o1 强制为 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 即登记(早于 postJson),finally 清理,覆盖整个请求生命周期。
|
||||
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.aiErrors,renderer 直接 window.api?.aiErrors?.AI_ERROR。
|
||||
// main/ai.js 内部仍用 ERR_* 命名别名(line 59-63),纯粹是阅读性,无外部
|
||||
// 调用方 —— 别名不出 module.exports,避免「两个相同字面值漂移」风险。
|
||||
AI_ERROR,
|
||||
};
|
||||
518
main/config-store.js
Normal file
518
main/config-store.js
Normal 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 个 tmp,rename 失败路径会立刻 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 面板分割条
|
||||
* 这类高频触发会卡 UI(settings-store 在 200ms 防抖后多次调用,每次都阻塞
|
||||
* 几 ms~几十 ms;慢盘 / OneDrive 同步冲突会更糟)。
|
||||
* 2. 多调用并发时,appConfig = {...appConfig, ...next} 在内存里已经合并,
|
||||
* 但只有最后一次 writeFile 落盘;如果中间某次失败,前一次的合并内容丢失
|
||||
* 但 appConfig 还显示「成功」(next 返回合并后的状态)。
|
||||
* 3. catch 只 unlink tmp,appConfig 不回滚 —— 调用方以为保存成功,磁盘实际
|
||||
* 是旧值。
|
||||
*
|
||||
* 新实现:
|
||||
* - 每次 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-08:dataDir 改了 → 让 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.json(rename 前断电),要么是完整新文件。
|
||||
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 → 视为不存在,回退到默认
|
||||
* - 其他 errno(EACCES / EBUSY / EPERM / EIO)→ 视为存在(可能是瞬时 ——
|
||||
* U 盘读权限慢 / Windows Defender 持锁等),让上层 scanDir 自然失败
|
||||
* 而不是「看似可用但其实打开就崩」
|
||||
*
|
||||
* @returns {{ dir: string, fellBack: boolean, saved: string }}
|
||||
* dir:实际可用的目录(默认或 custom)
|
||||
* fellBack:true 表示 custom 路径不可用、临时回退到默认
|
||||
* saved:appConfig.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
694
main/file-ops.js
Normal file
@@ -0,0 +1,694 @@
|
||||
// 文件操作 helper(Stage 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 Browser(2026-08):
|
||||
// - scanDir(absDir) 返回一层所有条目(文件夹 + 文件),含 entryType 分类
|
||||
// - 文件分类由 classifyEntry(name) 完成:'folder' | 'editable' | 'binary'
|
||||
// - EDITABLE_EXTS(来自 shared/extension-lists.js)是「可打开 + 编辑」
|
||||
// 的扩展名白名单;markdown (.md/.markdown) 与常见纯文本均在内
|
||||
//
|
||||
// 测试:tests/unit/file-ops.test.js(jsdom 环境之外;纯 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 打开目录路径得到 EISDIR,UI 弹「无效参数」
|
||||
* 误导信息。改用 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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝 symlink:Notes 用户的工作流是「编辑自己数据目录里的 .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.realpath(symlink 不解);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/EBUSY,UI 弹出
|
||||
* 「没有重命名权限 / 文件被占用」误导性中文。预先拒绝给清晰的中文错误。
|
||||
*
|
||||
* 用户反馈(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).md;bar.txt → bar (2).txt;baz(无扩展名) → 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).md;bar.txt → bar (2).txt;baz(无扩展名) → 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 Browser(2026-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 fix(C1/C2 file-IO):
|
||||
* 1. 写到 dst.tmp.<pid>.<now>
|
||||
* 2. fsync tmp(让内容确实落盘,再 rename 才不会丢)
|
||||
* 3. renameWithRetry 覆盖 dst(Windows 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 关键:没有 fsync,rename 之后断电可能留下「磁盘上 inode 改了
|
||||
// 但内容还在 page cache、从未刷盘」的零字节文件。Windows 上 fsync 等价
|
||||
// FlushFileBuffers,rename 之前必须强制落盘。
|
||||
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.friendlyFsError,renderer 两处旧实现也走同一份)。本函数保留壳子
|
||||
* 是为了不重写所有调用点的语义(main 内部仍叫 friendlyWriteError 表达
|
||||
* 「写盘错误翻译」,renderer 走 window.api.friendlyFsError 表达通用 errno 翻译)。
|
||||
*
|
||||
* @param {NodeJS.ErrnoException|null|undefined} e
|
||||
* @returns {string} 中文提示(永不为空 —— 兜底走「未知错误」)
|
||||
*/
|
||||
function friendlyWriteError(e) {
|
||||
// 把 e.message 作为 fallback 透传给 sharedFriendlyFsError。
|
||||
// - 已知 errno:shared 模块返回固定中文文案,与 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
500
main/fs-watcher.js
Normal file
@@ -0,0 +1,500 @@
|
||||
// 目录监听层(Stage 4b.2 抽离 + Stage 8 扩展)
|
||||
//
|
||||
// 职责:
|
||||
// - fs.watch + readdir 轮询双通道捕获数据目录变化
|
||||
// - 把变化推送给 renderer(IPC '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 可能 > 2s,setInterval 触发新调用与上一轮 await 并发争写
|
||||
// lastFilesSnapshot → 后完成的可能是先发起的(旧快照覆盖新基线)。
|
||||
// pollInFlight 守卫:上一轮未返回时直接跳过本次。
|
||||
let pollInFlight = false;
|
||||
|
||||
function isWindowVisible() {
|
||||
const win = getMainWindow();
|
||||
return !!win && !win.isDestroyed() && win.isVisible() && !win.isMinimized();
|
||||
}
|
||||
|
||||
/**
|
||||
* 条目列表的稳定指纹:folder 用 name;file 用 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');
|
||||
}
|
||||
|
||||
/** 把绝对目录转成 relDir(payload 字段);内部统一走 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-M7:teardown 窗口过程抛 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 token,await 结束后
|
||||
// 与当前 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 8:payload 新增 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 fix:fs.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 可能 > 2s,setInterval 触发新一轮与上一轮 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',没人接,
|
||||
// 默认变 unhandledException;poll 循环还在继续扫这个不存在的目录,
|
||||
// 每次 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 风险。
|
||||
};
|
||||
Reference in New Issue
Block a user