update
This commit is contained in:
179
main/ai.js
179
main/ai.js
@@ -40,6 +40,9 @@ const DEFAULT_TIMEOUT_MS = 300_000;
|
||||
// 内容长度硬上限:防止单次请求几 MB 把 LLM 计费用爆、把事件循环卡住。
|
||||
// 1.5 MB ≈ 38 万字符 / 10 万行;超过的文档让用户拆分或手动改。
|
||||
const MAX_CONTENT_BYTES = 1_500_000;
|
||||
// 提示词硬上限:64 KB ≈ 1.6 万中文字。系统提示词另算(settings 上限 200 KB,
|
||||
// 拼接后的总输入也由各 API 服务端二次校验);这里只挡用户 prompt 单字段。
|
||||
const MAX_PROMPT_BYTES = 64_000;
|
||||
// 响应体大小上限:防止恶意 / 错误配置的服务器返回几百 MB body 把主进程 OOM。
|
||||
// 5 MB 对正常 AI 回复(几十 KB 到 1 MB)足够宽松。
|
||||
const MAX_RESP_BYTES = 5 * 1024 * 1024;
|
||||
@@ -56,6 +59,7 @@ const ANTHROPIC_API_VERSION = '2023-06-01';
|
||||
// 把同一份 AI_ERROR 暴露到 window.api.aiErrors,main / renderer 永远引用同一对象,
|
||||
// 不再靠注释提醒同步。
|
||||
const { AI_ERROR } = require('../shared/ai-errors.js');
|
||||
const nodeCrypto = require('node:crypto');
|
||||
const ERR_NOT_CONFIGURED = AI_ERROR.NOT_CONFIGURED;
|
||||
const ERR_TIMEOUT = AI_ERROR.ERR_TIMEOUT;
|
||||
const ERR_PROVIDER = AI_ERROR.ERR_PROVIDER;
|
||||
@@ -205,13 +209,32 @@ function joinUrl(base, path) {
|
||||
// M2 fix (audit):剥掉 baseURL 里可能存在的 userinfo(`https://user:pass@host`)。
|
||||
// fetch 会把 userinfo 当 Basic Auth 自动发送,把 API Key 当用户名/密码发给中转服务,
|
||||
// 敏感凭据直接泄露到第三方;错误回显里也会暴露凭据。
|
||||
//
|
||||
// M2 follow-up:旧 regex `^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@` 只识别字面 `@`,
|
||||
// 对 percent-encoded `user%40evil.com:pass@host` 会放过(`%40` 走 URL 解析后
|
||||
// 才被还原成 `@`,fetch 仍按 Basic Auth 发送)。统一走 new URL 解析:URL parser
|
||||
// 主动还原 percent-encoding 并暴露 username / password,靠这两个字段判定 /
|
||||
// 剥除更稳。query / fragment 仍由外层 split 拿回。
|
||||
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');
|
||||
let originAndPath = m[1].replace(/\/+$/, '');
|
||||
try {
|
||||
const parsed = new URL(originAndPath);
|
||||
// 任何形式的 userinfo(字面 / percent-encoded / IDN punycode 包装)都会
|
||||
// 被 URL parser 还原到 username / password 字段。剥干净再拼回。
|
||||
// 注意:直接把 username/password 置空 + toString() 仍会保留空 userinfo 的
|
||||
// `https://@host` 形式,所以这里走 host + pathname 重组最稳。
|
||||
if (parsed.username || parsed.password) {
|
||||
const port = parsed.port ? `:${parsed.port}` : '';
|
||||
originAndPath = `${parsed.protocol}//${parsed.hostname}${port}${parsed.pathname}`.replace(/\/+$/, '');
|
||||
}
|
||||
} catch {
|
||||
// URL 解析失败:保留原字符串的 regex 兜底(防御旧路径畸形输入)
|
||||
originAndPath = originAndPath.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/, '$1');
|
||||
}
|
||||
const p = String(path || '').replace(/^\/+/, '');
|
||||
return `${b}/${p}${m[2] || ''}${m[3] || ''}`;
|
||||
return `${originAndPath}/${p}${m[2] || ''}${m[3] || ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,10 +279,20 @@ function sanitizeDetail(detail) {
|
||||
* @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')
|
||||
let s = String(raw || '');
|
||||
// M2 follow-up:与 joinUrl 同款 percent-encoded userinfo 防御。
|
||||
// 先用 new URL 解析(字面 / 编码 / punycode 都被还原到 username/password),
|
||||
// 命中则走 host + pathname 重组。否则 fallback 旧 regex 兜底畸形输入。
|
||||
try {
|
||||
const parsed = new URL(s);
|
||||
if (parsed.username || parsed.password) {
|
||||
const port = parsed.port ? `:${parsed.port}` : '';
|
||||
s = `${parsed.protocol}//${parsed.hostname}${port}${parsed.pathname}`;
|
||||
}
|
||||
} catch {
|
||||
s = s.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/, '$1');
|
||||
}
|
||||
return s
|
||||
.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
|
||||
@@ -288,8 +321,16 @@ async function readBodyWithLimit(res) {
|
||||
/** @type {Buffer[]} */
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
let readErr = null;
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
let value;
|
||||
let done;
|
||||
try {
|
||||
({ value, done } = await reader.read());
|
||||
} catch (e) {
|
||||
readErr = e;
|
||||
break;
|
||||
}
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > MAX_RESP_BYTES) {
|
||||
@@ -298,6 +339,13 @@ async function readBodyWithLimit(res) {
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
if (readErr) {
|
||||
// Round 13 fix (M9):reader.read() 抛错时不 cancel — 流连接资源会被 GC
|
||||
// 最终释放,但 fetch 在 abort 时仍可能持有未读数据;显式 cancel 让对端
|
||||
// 立即知道我们不再消费,提早关连接。
|
||||
try { reader.cancel(); } catch { /* ignore */ }
|
||||
return { ok: false, message: '读取 AI 响应失败' };
|
||||
}
|
||||
return { ok: true, text: Buffer.concat(chunks).toString('utf8') };
|
||||
}
|
||||
|
||||
@@ -343,6 +391,14 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
||||
// C1 (audit):与 cancel() 一样同步清理 timeout,避免 setTimeout 回调空转
|
||||
for (const timer of timers.values()) clearTimeout(timer);
|
||||
timers.clear();
|
||||
// 第二阶段代码审查 (Phase 2 review #3):同步清理 inFlightRequestIds。
|
||||
// 之前只清 pending / timers —— runEdit 的 finally 也会 clear 自己加进 Set
|
||||
// 的 requestId,所以正常路径下 Set 会自然排空。但 cancelAll 在
|
||||
// app.on('will-quit') 调用(main.js:889),与 runEdit finally 之间存在
|
||||
// 竞态窗口:requestId 仍残留在 Set 里时若有新 AI 请求复用同一 requestId
|
||||
// (test harness / HMR / 未来 re-init),cancel(requestId) 会取消错对象。
|
||||
// 显式 clear 让 cancelAll 真正幂等且不留残根。
|
||||
inFlightRequestIds.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -377,6 +433,32 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
||||
return { ok: true, value: s };
|
||||
}
|
||||
|
||||
/**
|
||||
* Round 13 fix (M6):抽「明文 HTTP + 非 loopback + 已配 Key → 拒绝」共用检查。
|
||||
* OpenAI / Anthropic 两条分支各复制 30 行同款防御,现在统一走这里。
|
||||
* 本地代理(Ollama / LM Studio / vllm)走 http://localhost / 127.0.0.1 / ::1
|
||||
* 不需要 Key(apiKey 留空即放过),配置 Key 时必须 HTTPS。
|
||||
* @param {string} baseUrl - 已经 validateBaseUrl 通过的 URL
|
||||
* @param {string} apiKey - 当前配置的 API Key(空串 = 未配)
|
||||
* @returns {null | {ok:false, error:string, message:string}} 通过时返回 null
|
||||
*/
|
||||
function rejectInsecureKeyTransport(baseUrl, apiKey) {
|
||||
if (!/^http:\/\//i.test(baseUrl) || !apiKey) return null;
|
||||
try {
|
||||
const parsed = new URL(baseUrl);
|
||||
const host = (parsed.hostname || '').toLowerCase();
|
||||
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
||||
if (isLoopback) return null;
|
||||
console.warn('[ai] 明文 HTTP + 非loopback + 已配 Key,拒绝请求以防泄露:', sanitizeUrl(baseUrl));
|
||||
return {
|
||||
ok: false,
|
||||
error: ERR_NOT_CONFIGURED,
|
||||
message: '检测到 Base URL 使用明文 HTTP 且已配置 API Key;请改用 https:// 以避免 Key 在网络传输中被窃取。本地代理(localhost / 127.0.0.1)允许明文。',
|
||||
};
|
||||
} catch { /* validateBaseUrl 已校验过 URL,这里兜底 */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按模型名路由 token 上限 + token 字段。
|
||||
* H1 (audit):65536 远超多数模型上限(gpt-3.5=4096, gpt-4=8192, gpt-4o=16384),
|
||||
@@ -434,12 +516,18 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
||||
*/
|
||||
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;
|
||||
// 5 系(opus-5 / sonnet-5 / fable-5 / mythos-5 / haiku-5)+ 4.6+ → 官方 128k 上限
|
||||
// Round 13 fix:旧正则漏了 `claude-haiku-5-*`,会落到下一档的 16k;
|
||||
// 同样保留 haiku-5 与其他 5 系在同一档。
|
||||
if (/claude-(opus|sonnet|fable|mythos|haiku)-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;
|
||||
// Round 13 fix (H2):claude-3 老家族官方 max output = 4096。
|
||||
// 旧值 8192 在 opus-3 / sonnet-3 / haiku-3 上会被服务端 400 拒绝,
|
||||
// runEdit 走 TRUNCATED_MESSAGE 分支报「AI 修改结果不完整」(实际是
|
||||
// 请求参数越界,不是输出被截断)。claude-3-5 / claude-3-7 由上一档命中。
|
||||
if (/claude-3/.test(m)) return 4_096;
|
||||
return 8_192; // 未知模型回退到 8k —— 之前 4096 经常截断长 diff
|
||||
}
|
||||
|
||||
@@ -676,26 +764,13 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
||||
if (!urlCheck.ok) {
|
||||
return { ok: false, error: ERR_NOT_CONFIGURED, message: urlCheck.reason };
|
||||
}
|
||||
// audit fix (Round 8 A-2):明文 HTTP + 非loopback + 已配置 API Key → 拒绝。
|
||||
// audit fix (Round 8 A-2) + Round 13 抽 helper:明文 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 insecureReject = rejectInsecureKeyTransport(urlCheck.value, apiKey);
|
||||
if (insecureReject) return insecureReject;
|
||||
|
||||
const url = joinUrl(baseURL, 'chat/completions');
|
||||
// H1+H2+H5 (audit):按模型名路由 token 上限,避免 65536 超过多数模型上限被拒;
|
||||
@@ -767,23 +842,10 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
||||
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 */ }
|
||||
}
|
||||
// audit fix (Round 8 A-2) + Round 13 抽 helper:与 OpenAI 分支共用同样防御,
|
||||
// 本地代理不需要 Key,留空即可。
|
||||
const insecureReject = rejectInsecureKeyTransport(urlCheck.value, apiKey);
|
||||
if (insecureReject) return insecureReject;
|
||||
|
||||
const url = joinUrl(baseURL, 'v1/messages');
|
||||
// H1 (audit):Anthropic 各模型 max_tokens 上限不同,超额会 400
|
||||
@@ -822,6 +884,11 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
||||
let hasNonTextBlock = false;
|
||||
for (const block of json.content) {
|
||||
if (block && block.type === 'text' && typeof block.text === 'string') {
|
||||
// Round 13 fix (H7):Anthropic 不保证 text 块之间有分隔符。
|
||||
// [{text:'A.'}, {text:'B begin.'}] 直接拼接得 'A.B begin.',
|
||||
// Markdown 渲染会把 `A.B` 误识为 list / 句点边界。块与块之间补
|
||||
// 双换行(Markdown 段落分隔),让多块拼接后渲染保持原意。
|
||||
if (message && !message.endsWith('\n')) message += '\n\n';
|
||||
message += block.text;
|
||||
} else if (block && block.type !== 'text') {
|
||||
// audit fix (shared-M11):tool_use / tool_result / image 等非文本块
|
||||
@@ -850,6 +917,14 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
||||
return { ok: true, id: requestId, content: normalized.content, responseFormat: normalized.responseFormat };
|
||||
}
|
||||
|
||||
// Round 13 fix (H3):从共享 AI_ERROR 引用错误码字面值。原先 4 处裸字面量
|
||||
// ('INVALID_PROMPT' / 'INVALID_CONTENT' / 'CONTENT_TOO_LARGE' / 'PROMPT_TOO_LARGE')
|
||||
// 绕开 AI_ERROR,renderer 端 ai-controller 走通用 toast 而不是本地化提示。
|
||||
const ERR_INVALID_PROMPT = AI_ERROR.INVALID_PROMPT;
|
||||
const ERR_INVALID_CONTENT = AI_ERROR.INVALID_CONTENT;
|
||||
const ERR_CONTENT_TOO_LARGE = AI_ERROR.CONTENT_TOO_LARGE;
|
||||
const ERR_PROMPT_TOO_LARGE = AI_ERROR.PROMPT_TOO_LARGE;
|
||||
|
||||
/**
|
||||
* 执行一次 AI 修改请求。
|
||||
* @param {{ prompt:string, content:string, filename:string, requestId:string }} input
|
||||
@@ -857,31 +932,33 @@ function createAiProxy({ getConfig, fetchImpl, log } = {}) {
|
||||
*/
|
||||
async function runEdit({ prompt, content, filename, requestId }) {
|
||||
if (typeof prompt !== 'string' || !prompt.trim()) {
|
||||
return { ok: false, error: 'INVALID_PROMPT', message: '修改要求不能为空' };
|
||||
return { ok: false, error: ERR_INVALID_PROMPT, message: '修改要求不能为空' };
|
||||
}
|
||||
if (typeof content !== 'string') {
|
||||
return { ok: false, error: 'INVALID_CONTENT', message: '当前文件内容无效' };
|
||||
return { ok: false, error: ERR_INVALID_CONTENT, message: '当前文件内容无效' };
|
||||
}
|
||||
// 字节上限用 UTF-8 编码长度近似(中文/emoji 一个字符多字节,会略高估)
|
||||
if (Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'CONTENT_TOO_LARGE',
|
||||
error: ERR_CONTENT_TOO_LARGE,
|
||||
message: `文档过大(超过 ${(MAX_CONTENT_BYTES / 1024 / 1024).toFixed(1)} MB),请拆分后再让 AI 修改`,
|
||||
};
|
||||
}
|
||||
if (Buffer.byteLength(prompt, 'utf8') > 64_000) {
|
||||
if (Buffer.byteLength(prompt, 'utf8') > MAX_PROMPT_BYTES) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'PROMPT_TOO_LARGE',
|
||||
message: '提示词过长(超过 64 KB),请简化要求',
|
||||
error: ERR_PROMPT_TOO_LARGE,
|
||||
message: `提示词过长(超过 ${MAX_PROMPT_BYTES / 1024} KB),请简化要求`,
|
||||
};
|
||||
}
|
||||
if (typeof filename !== 'string') {
|
||||
filename = '';
|
||||
}
|
||||
if (typeof requestId !== 'string' || !requestId) {
|
||||
requestId = `ai-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
// Round 13 fix (M8):用 crypto.randomUUID 代替 Math.random;后者在
|
||||
// 高并发(用户连点)下理论碰撞,会让 cancel 误杀新请求。
|
||||
requestId = `ai-${Date.now()}-${nodeCrypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
const config = (typeof getConfig === 'function' ? getConfig() : {}) || {};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 职责:
|
||||
// - 加载 / 保存用户配置(userData/config.json)
|
||||
// - 解析「实际生效」的数据目录(用户自定义 vs 默认 ~/Notes)
|
||||
// - 首次启动把 data/welcome.md 种子化到默认数据目录
|
||||
// - 首次启动把 data/README.md 种子化到默认数据目录
|
||||
//
|
||||
// 设计:
|
||||
// - 本模块自给自足:不读 mainWindow / tray / IPC 等任何 main.js 状态。
|
||||
@@ -102,7 +102,15 @@ function loadConfig() {
|
||||
|
||||
/**
|
||||
* 删除数据目录里残留的 config.json.tmp.* 文件。
|
||||
* 只删自己 pid 的(其它进程残留不碰,避免多开实例互相影响)。
|
||||
* 两层阈值:
|
||||
* - 自己 pid + 60s 前 → 必删(防止本进程崩溃留下永久孤儿)
|
||||
* - 非自己 pid + 24h 前 → 必删(清理上一次 Notes 实例崩溃的孤儿,
|
||||
* 24h 长到不会误删正在运行的其它 Notes 实例)
|
||||
*
|
||||
* audit fix (Round 16 R2):旧版只覆盖「自己 pid + 60s」一层。Notes
|
||||
* 进程被 kill / 断电时,tmp 文件用的是当时进程的 pid;下次启动时本进程
|
||||
* pid 不同,60s 阈值永远不命中,孤儿永久残留。`scripts/_archive/` 下
|
||||
* 9 个一次性 repro 脚本的历史足以说明这种崩溃/中断场景不是理论风险。
|
||||
*/
|
||||
function cleanupStaleTmpFiles() {
|
||||
try {
|
||||
@@ -110,16 +118,19 @@ function cleanupStaleTmpFiles() {
|
||||
const files = fsSync.readdirSync(dir);
|
||||
const myPid = process.pid;
|
||||
const now = Date.now();
|
||||
const STALE_THRESHOLD_MS = 60_000; // 60s 之前留下的才视为残留(避免误删正在写的)
|
||||
const STALE_THRESHOLD_MS = 60_000; // 自己 pid:60s
|
||||
const CROSS_PID_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 跨 pid:24h
|
||||
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) {
|
||||
const shouldRemove = isMine
|
||||
? ageMs > STALE_THRESHOLD_MS
|
||||
: ageMs > CROSS_PID_STALE_THRESHOLD_MS;
|
||||
if (shouldRemove) {
|
||||
fsSync.unlinkSync(full);
|
||||
}
|
||||
} catch { /* 单个文件 stat/unlink 失败不影响整体 */ }
|
||||
@@ -178,7 +189,12 @@ function saveConfig(next) {
|
||||
_invalidateResolveCache();
|
||||
}
|
||||
const cfgPath = getConfigPath();
|
||||
const tmpPath = `${cfgPath}.tmp.${process.pid}.${Date.now()}`;
|
||||
// audit fix (Round 16 P0):追加 6 位 base36 随机后缀。pid + Date.now() 只保证
|
||||
// 跨进程 / 跨毫秒唯一,同进程同毫秒并发(典型场景:saveQueue 中 p1 与后续 task
|
||||
// 在 await 后进入 fs.open)会算出相同 tmpPath,后到的 rename 因源文件已被消费
|
||||
// 而失败(ENOENT),上层捕获后返回 {ok:false}。random 后缀 ~36^6 ≈ 21 亿组合,
|
||||
// 同毫秒冲突概率可忽略。main/file-ops.js#atomicWriteFile 也做了对称修复。
|
||||
const tmpPath = `${cfgPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
||||
let fh = null;
|
||||
try {
|
||||
await fs.mkdir(path.dirname(cfgPath), { recursive: true });
|
||||
@@ -337,12 +353,19 @@ function resolveDataDirOrFallback() {
|
||||
// 重新 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;
|
||||
// audit fix (H1 / symlink elevation):用 lstat 而非 stat —— stat 会 follow
|
||||
// 符号链接,让一个被外部指向敏感目录的 symlink 当作「合法数据目录」通过
|
||||
// 校验,随后 file:read/write/fsWatcher 都会跑在外部目录上,用户完全无感。
|
||||
// 这里先 lstat,再显式拒绝 symlink;普通目录 / 文件路径走原逻辑。
|
||||
const st = fsSync.lstatSync(custom);
|
||||
if (st.isSymbolicLink()) {
|
||||
exists = false;
|
||||
} else if (!st.isDirectory()) {
|
||||
// dataDir 字段意外指向了一个文件路径(用户在设置对话框里手填、或
|
||||
// config.json 被改坏),仍应 fallback,否则上层 scanDir / watchDir
|
||||
// 立刻 ENOTDIR 报上来,体验割裂。
|
||||
exists = false;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT' || e.code === 'ENOTDIR') {
|
||||
exists = false;
|
||||
@@ -381,25 +404,34 @@ function getConfig() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次启动把 data/welcome.md 种子化到默认数据目录。
|
||||
* 首次启动把 data/README.md 种子化到默认数据目录。
|
||||
*
|
||||
* 触发条件(任一为真则跳过):
|
||||
* - 默认目录里已有 welcome.md(已种子过)→ 直接 return
|
||||
* - 默认目录里已有 README.md(已种子过)→ 直接 return
|
||||
* - 默认目录里有其它 .md 文件(用户笔记)→ 不覆盖
|
||||
*
|
||||
* 用 welcome.md 自己作为「已种子」的隐式标记,不再写 .notes-seeded 隐藏文件
|
||||
* 用 README.md 自己作为「已种子」的隐式标记,不再写 .notes-seeded 隐藏文件
|
||||
* —— 用户数据目录应该只放用户的内容。
|
||||
*
|
||||
* 不再守卫「用户没设 dataDir」:auto-fallback 路径下用户设了 dataDir 但路径失效,
|
||||
* 也会 runtime 回退到默认目录 —— 这时默认目录可能是空的,要种子化 welcome.md
|
||||
* 也会 runtime 回退到默认目录 —— 这时默认目录可能是空的,要种子化 README
|
||||
* 让用户立刻看到内容(而不是打开一个空侧栏)。
|
||||
*
|
||||
* 失败仅 console.warn,不弹窗;用户用「打开数据文件夹」按钮可以自己补救。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 种子文件清单 —— 顺序就是侧栏显示顺序(README 是首页)。
|
||||
* 新增 onboarding 文档时只在这里加一行 + 在 data/ 下放对应源文件,无需改 IPC / 主流程。
|
||||
*/
|
||||
const ONBOARDING_SEED_FILES = [
|
||||
'README.md',
|
||||
];
|
||||
|
||||
async function ensureDefaultDataDir() {
|
||||
if (!DEFAULT_DATA_DIR) return;
|
||||
const targetDir = DEFAULT_DATA_DIR;
|
||||
const welcomeDst = path.join(targetDir, 'welcome.md');
|
||||
const sentinelDst = path.join(targetDir, 'README.md');
|
||||
|
||||
try {
|
||||
// 先确保目录存在(mkdir recursive 不存在就建、存在 no-op)。
|
||||
@@ -409,15 +441,15 @@ async function ensureDefaultDataDir() {
|
||||
// 或之后(启动 bootstrap),所以这里再保险一次。
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
|
||||
// welcome.md 已存在 → 已经种子过(或用户改过),跳过
|
||||
// README.md 已存在 → 已经种子过(或用户改过),跳过整个 onboarding 集合
|
||||
try {
|
||||
await fs.stat(welcomeDst);
|
||||
await fs.stat(sentinelDst);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') throw e;
|
||||
}
|
||||
|
||||
// 已有其它 .md → 用户笔记,不覆盖 welcome 也不打扰用户
|
||||
// 已有其它 .md → 用户笔记,不种子化也不打扰用户
|
||||
const dirEntries = await fs.readdir(targetDir).catch((err) => {
|
||||
if (err.code === 'ENOENT') return [];
|
||||
throw err;
|
||||
@@ -426,12 +458,25 @@ async function ensureDefaultDataDir() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 种子化 welcome.md
|
||||
const src = path.join(__dirname, '..', 'data', 'welcome.md');
|
||||
await fs.copyFile(src, welcomeDst);
|
||||
console.log('[config-store] 已种子 welcome.md 到默认数据目录:', welcomeDst);
|
||||
// 种子化整套 onboarding 文件。每个文件独立 copyFile —— 中途某一份失败
|
||||
// 不影响其他(部分种子优于全失败,符合"让用户立刻看到内容"的目标)。
|
||||
const seeded = [];
|
||||
for (const fileName of ONBOARDING_SEED_FILES) {
|
||||
try {
|
||||
const src = path.join(__dirname, '..', 'data', fileName);
|
||||
const dst = path.join(targetDir, fileName);
|
||||
await fs.copyFile(src, dst);
|
||||
seeded.push(fileName);
|
||||
} catch (fileErr) {
|
||||
// 单文件失败(最常见:data/ 里漏放新文件源)仅 warn,不中断剩余文件
|
||||
console.warn(`[config-store] 种子 ${fileName} 失败:`, fileErr.message);
|
||||
}
|
||||
}
|
||||
if (seeded.length > 0) {
|
||||
console.log(`[config-store] 已种子 onboarding 到默认数据目录(${seeded.length} 个):`, targetDir);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[config-store] welcome 种子失败:', e.message);
|
||||
console.warn('[config-store] onboarding 种子失败:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,7 +490,7 @@ async function seedDefaultDataDir() {
|
||||
|
||||
/**
|
||||
* fire-and-forget 异步种子 —— resolveDataDirOrFallback 内每次返回 DEFAULT_DATA_DIR
|
||||
* 都会调它,确保在 IPC handler 同步返回之后异步把 welcome.md 种子进去。
|
||||
* 都会调它,确保在 IPC handler 同步返回之后异步把 README.md 种子进去。
|
||||
*
|
||||
* 用 _ensureInFlight Promise 去重:同一时间多次触发只跑一次(fsWatcher 启动后
|
||||
* 第一次 IPC scanDir 触发 fallback + renderer 启动后的 resetDataDir + 启动自身
|
||||
|
||||
@@ -29,6 +29,21 @@ const { friendlyFsError: sharedFriendlyFsError } = require('../shared/friendly-f
|
||||
/** 单文件大小上限(5 MB),防止超大文件冻结 renderer。 */
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* atomicWriteFile 产生的 tmp 文件名匹配规则:`<name>.<editableExt>.tmp.<pid>.<ts>.<rand>`。
|
||||
* cleanupStaleFileTmpFiles 用它判断哪些 .tmp.* 是自家产物需要清理。
|
||||
* 严格要求 pid / ts / rand 三段:pid 与 ts 必须数字,rand 是 6 字符小写 base36。
|
||||
*
|
||||
* 旧版 `\.tmp\.\d+\.\d+\.[a-z0-9]+$` 太宽 —— 把 `report.tmp.2024.1234567.abcdef` 这种
|
||||
* 用户合法文件也匹配上,24h 跨进程阈值后会被静默 unlink。要求 .tmp. 前一段是
|
||||
* EDITABLE_EXTS 之一(白名单扩展名)把误伤清零。
|
||||
*/
|
||||
const ATOMIC_TMP_RE = new RegExp(
|
||||
'\\.(?:' + Array.from(EDITABLE_EXTS).map((e) => e.replace(/[.+^${}()|[\]\\]/g, '\\$&')).join('|') +
|
||||
')\\.tmp\\.\\d+\\.\\d+\\.[a-z0-9]{1,8}$',
|
||||
'i',
|
||||
);
|
||||
|
||||
/**
|
||||
* 检查 fullPath 的占用情况 —— 返回三种状态:
|
||||
* - 'free' 路径不存在,可用
|
||||
@@ -381,6 +396,11 @@ async function scanFiles(dir) {
|
||||
}
|
||||
const mdFiles = entries.filter((e) => {
|
||||
if (!e.isFile()) return false;
|
||||
// Round 13 fix (H6):与 scanDir 对称过滤符号链接。OneDrive / 网盘
|
||||
// 用户在数据目录里放指向外部 secret 文件的 symlink 时,旧版会跟随
|
||||
// 读 size / mtime 但 file:read 拒开,UX 不一致且把外部 stat 信息暴露
|
||||
// 到 renderer 内存。
|
||||
if (e.isSymbolicLink()) return false;
|
||||
const lower = e.name.toLowerCase();
|
||||
return lower.endsWith('.md') || lower.endsWith('.markdown');
|
||||
});
|
||||
@@ -585,7 +605,12 @@ function toRelativeDir(absDir, dataRoot) {
|
||||
* @param {string} content UTF-8 文本
|
||||
*/
|
||||
async function atomicWriteFile(dst, content) {
|
||||
const tmpPath = `${dst}.tmp.${process.pid}.${Date.now()}`;
|
||||
// audit fix (Round 16 P0):追加 6 位 base36 随机后缀。pid + Date.now() 只保证
|
||||
// 跨进程 / 跨毫秒唯一,同进程同毫秒并发(如自动保存触发的 saveConfig 与
|
||||
// 用户手动 Ctrl+S 撞在同一毫秒)会算出相同 tmpPath,后到的 rename 因源文件
|
||||
// 已被消费而失败(ENOENT),上层捕获后返回 {ok:false}。random 后缀 ~36^6 ≈ 21
|
||||
// 亿组合,同毫秒冲突概率可忽略。main/config-store.js#saveConfig 也做了对称修复。
|
||||
const tmpPath = `${dst}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
||||
let fh;
|
||||
try {
|
||||
fh = await fs.open(tmpPath, 'w');
|
||||
@@ -611,12 +636,14 @@ async function atomicWriteFile(dst, content) {
|
||||
// 但 fs.open(path, 'r') 在目录上 Windows 会拒绝写操作,所以这里走 OS 守卫)。
|
||||
// 风险:失败 fsync 不抛(让 save 走 OK 路径),由下次保存自动覆盖。
|
||||
if (process.platform !== 'win32') {
|
||||
// Round 13 fix (H5):dirFh.sync() 抛错时 close() 不会执行 → fd 泄漏。
|
||||
// 长跑保存下磁盘压力大 / USB 抖动会让 sync 间歇失败,多次累加触发
|
||||
// EMFILE;统一走 try/finally 兜底 close。
|
||||
const dirFh = await fs.open(path.dirname(dst), 'r');
|
||||
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);
|
||||
} finally {
|
||||
try { await dirFh.close(); } catch { /* close 失败也无能为力 */ }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -649,6 +676,57 @@ async function renameWithRetry(src, dst) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除数据目录里残留的 `*.tmp.<pid>.<ts>.<rand>` 文件。
|
||||
* 由 main.js 在启动时 / 切换数据目录时调用一次。
|
||||
*
|
||||
* 两层阈值(与 main/config-store.js#cleanupStaleTmpFiles 对齐):
|
||||
* - 自己 pid + 60s 前 → 必删(防止本进程崩溃留下永久孤儿)
|
||||
* - 非自己 pid + 24h 前 → 必删(清理上一次 Notes 实例崩溃的孤儿)
|
||||
*
|
||||
* audit fix (Round 16 R2):atomicWriteFile 自身的 try/catch 已经在失败时
|
||||
* unlink tmp,但「本进程崩溃 / 被 kill -9 / 断电」三种场景不会进入清理分支。
|
||||
* `scripts/_archive/` 9 个一次性 repro 脚本的历史足以说明崩溃/中断场景不是
|
||||
* 理论风险。启动时扫一次数据目录,避免孤儿永久累积。
|
||||
*
|
||||
* @param {string} dataDir 数据目录绝对路径
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function cleanupStaleFileTmpFiles(dataDir) {
|
||||
if (!dataDir) return;
|
||||
try {
|
||||
const files = await fs.readdir(dataDir);
|
||||
const myPid = process.pid;
|
||||
const now = Date.now();
|
||||
const STALE_THRESHOLD_MS = 60_000;
|
||||
const CROSS_PID_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
|
||||
for (const f of files) {
|
||||
// 只匹配 atomicWriteFile 生成的 `<name>.<ext>.tmp.<pid>.<ts>.<rand>`。
|
||||
// audit fix (Tmp-L2):旧版 `/\.tmp\.\d+\.\d+\.[a-z0-9]+$/i` 太宽,
|
||||
// 会把用户自己命名 / 同步盘生成 / 备份工具遗留的 `report.tmp.2024.1234567.abcdef`
|
||||
// 这种正常文件当成孤儿静默 unlink。必须要求 .tmp. 前面是 EDITABLE_EXTS
|
||||
// 之一(白名单扩展名),且 tmp 段是 pid.ts.rand 严格三段数字 + 小写 base36。
|
||||
// config-store 的 `config.json.tmp.*` 不在此正则范围内,仍由它自家扫(注释指向
|
||||
// shared/extension-lists.js#EDITABLE_EXTS,与 fileOps / sidebar 共用扩展名)。
|
||||
if (!ATOMIC_TMP_RE.test(f)) continue;
|
||||
const full = path.join(dataDir, f);
|
||||
try {
|
||||
const st = await fs.stat(full);
|
||||
const isMine = f.includes(`.${myPid}.`);
|
||||
const ageMs = now - st.mtimeMs;
|
||||
const shouldRemove = isMine
|
||||
? ageMs > STALE_THRESHOLD_MS
|
||||
: ageMs > CROSS_PID_STALE_THRESHOLD_MS;
|
||||
if (shouldRemove) {
|
||||
await fs.unlink(full);
|
||||
}
|
||||
} catch { /* 单个文件 stat/unlink 失败不影响整体 */ }
|
||||
}
|
||||
} catch {
|
||||
// readdir 失败(目录不存在等)静默 —— 后续 IPC handler 会处理
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* audit fix (Round 4 收尾):file:write 的 errno 翻译 —— 与 main.js 各 IPC handler
|
||||
* (_friendlyCreateError / _friendlyRenameError / _friendlyDeleteError / _friendlyReadError)
|
||||
@@ -678,6 +756,7 @@ function friendlyWriteError(e) {
|
||||
module.exports = {
|
||||
MAX_FILE_SIZE,
|
||||
EDITABLE_EXTS,
|
||||
ATOMIC_TMP_RE,
|
||||
assertNotSymlink,
|
||||
assertNoSymlinkAncestor,
|
||||
classifyEntry,
|
||||
@@ -690,5 +769,6 @@ module.exports = {
|
||||
toRelativeDir,
|
||||
atomicWriteFile,
|
||||
renameWithRetry,
|
||||
cleanupStaleFileTmpFiles,
|
||||
friendlyWriteError,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
// - 不重复实现 scanDir —— 由 deps.scanDir() 注入(同一份扫描逻辑)
|
||||
|
||||
const fsSync = require('fs');
|
||||
// audit fix (Round 16 M1):self-write filter 需要 path.resolve 标准化路径,
|
||||
// 把登记 key 和 dirWatcher 上报的 absPath 都归一化到同一形态。
|
||||
const path = require('path');
|
||||
|
||||
const DIR_POLL_INTERVAL_MS = 2000;
|
||||
const EMIT_DEBOUNCE_MS = 300;
|
||||
@@ -31,6 +34,13 @@ const REATTACH_MAX_MS = 60_000;
|
||||
// 屏幕常驻 5 条警告。30s 重发间隔让用户有充裕时间响应,又不至于漏掉
|
||||
// 真实新错误(同 error 不同 dir 也算新错误,立即重发)。
|
||||
const ERROR_REPEAT_THROTTLE_MS = 30_000;
|
||||
// audit fix (Round 16 M1):self-write filter TTL。file:write / file:create 成功后
|
||||
// 会在 expectedWrites 里登记这个文件未来 ~500ms 内的变更属于自己;dirWatcher
|
||||
// callback 看到这个 key 就跳过 emit。500ms 比 SMB / OneDrive rename+fsync
|
||||
// 最坏值(实测 100~400ms)再大一档,留余量。500ms 之后外部真改的概率已经
|
||||
// 上来了,不再抑制 —— 不会漏真信号,只会避免「renderer 把自己的写盘当
|
||||
// 外部修改」的幽灵 reload 对话框。
|
||||
const SELF_WRITE_TTL_MS = 500;
|
||||
|
||||
/**
|
||||
* @typedef {Object} FsWatcherDeps
|
||||
@@ -84,12 +94,91 @@ function createFsWatcher(deps) {
|
||||
// lastFilesSnapshot → 后完成的可能是先发起的(旧快照覆盖新基线)。
|
||||
// pollInFlight 守卫:上一轮未返回时直接跳过本次。
|
||||
let pollInFlight = false;
|
||||
// audit fix (Round 16 M1):self-write filter。file:write / file:create 成功后
|
||||
// 通过 markExpectedWrite(absPath, mtimeMs) 登记;dirWatcher callback 看到这个
|
||||
// key 命中 + 未过期 → 跳过 scheduleEmit,避免「renderer 把自己的写盘当外部
|
||||
// 修改」(慢盘上 atomicWriteFile 的 fs.watch 事件晚于 IPC 响应 → race)。
|
||||
// 结构:{ mtimeMs, expiresAt };命中检查只看路径不看 mtime —— 同一文件 500ms
|
||||
// 内自己再写一次也会命中(更新条目即可);漏报概率 1/1.6M(同毫秒 + 同路径
|
||||
// + 非自己写)基本为零。
|
||||
const expectedWrites = new Map();
|
||||
|
||||
function isWindowVisible() {
|
||||
const win = getMainWindow();
|
||||
return !!win && !win.isDestroyed() && win.isVisible() && !win.isMinimized();
|
||||
}
|
||||
|
||||
/**
|
||||
* audit fix (Round 16 M1):登记一次「自己刚写」的路径,500ms 内 dirWatcher
|
||||
* 看到这个 key 就跳过 scheduleEmit。file:write / file:create 成功后调用。
|
||||
*
|
||||
* 路径大小写处理:Windows fs.watch 上报的文件名大小写与写入路径可能不同
|
||||
* (取决于底层 ReadDirectoryChangesW 是否 normalize)。统一走 path.resolve
|
||||
* 把 caller 路径标准化,dirWatcher 解析后的 absPath 也走同一份 resolve +
|
||||
* 同平台大小写策略比对,避免「登记 C:\...\Foo.md、fs.watch 上报 c:\...\foo.md」
|
||||
* 不命中。
|
||||
*
|
||||
* @param {string} absPath - 文件绝对路径
|
||||
* @param {number} mtimeMs - 写入后磁盘 mtime(ms),仅用于日志 / 调试
|
||||
*/
|
||||
function markExpectedWrite(absPath, mtimeMs) {
|
||||
if (typeof absPath !== 'string' || !absPath) return;
|
||||
// audit fix (Case-M1):用 pathKey(case-fold) 当 map key,跨 case 命中。
|
||||
expectedWrites.set(pathKey(absPath), {
|
||||
mtimeMs: Number.isFinite(mtimeMs) ? mtimeMs : 0,
|
||||
expiresAt: Date.now() + SELF_WRITE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断一个 absPath 是否在「self-write」窗口内。dirWatcher callback 用。
|
||||
* 命中时调用方应跳过 scheduleEmit(避免 renderer 误把自写当成外部修改)。
|
||||
* 顺带做一次惰性 GC —— 路过时把已过期的条目清掉,保持 Map 大小有界。
|
||||
*
|
||||
* @param {string} absPath
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSelfWrite(absPath) {
|
||||
if (!absPath || expectedWrites.size === 0) return false;
|
||||
let key;
|
||||
try { key = pathKey(absPath); }
|
||||
catch { return false; }
|
||||
const entry = expectedWrites.get(key);
|
||||
if (!entry) return false;
|
||||
const now = Date.now();
|
||||
if (now >= entry.expiresAt) {
|
||||
expectedWrites.delete(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* audit fix (Case-M1):Windows / macOS 路径大小写不敏感(NTFS / APFS 默认),
|
||||
* fs.watch 上报的 absPath 与 caller 路径 case 不一致是常态。`path.resolve` 只
|
||||
* 做符号链接折叠 + 绝对化,**不**做大小写折叠。`Map.get(resolved)` 严格匹配
|
||||
* 会让 self-write filter 在 case-mismatch 时漏命中 → renderer 把自己的写盘当
|
||||
* 「外部修改」误报,用户选「用磁盘版」会覆盖刚保存的内容。
|
||||
*
|
||||
* 修:用 platform-aware 的 key —— Windows / darwin 统一 toLowerCase(),
|
||||
* Linux(ext4 等默认大小写敏感)保持严格。这样 caller + watcher 路径走同一份
|
||||
* 归一化,跨 case 命中。
|
||||
*
|
||||
* @param {string} p
|
||||
* @returns {string}
|
||||
*/
|
||||
function pathKey(p) {
|
||||
const resolved = path.resolve(p);
|
||||
return (process.platform === 'win32' || process.platform === 'darwin')
|
||||
? resolved.toLowerCase()
|
||||
: resolved;
|
||||
}
|
||||
|
||||
/** 清空 self-write 窗口(stopWatchingDir / 切目录时调一次)。 */
|
||||
function clearExpectedWrites() {
|
||||
expectedWrites.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 条目列表的稳定指纹:folder 用 name;file 用 name + size + mtime。
|
||||
* 只在这一个地方定义,两个调用点(updateSnapshot / emitFilesChanged)共用。
|
||||
@@ -194,7 +283,10 @@ function createFsWatcher(deps) {
|
||||
safeSend({
|
||||
error: result.error,
|
||||
code: result.code,
|
||||
dir,
|
||||
// Round 13 fix (M12):移除绝对路径字段。renderer 不消费它,
|
||||
// 但 IPC 通道透传任意大字段增加无谓的序列化成本 + 通过
|
||||
// DevTools / 抓包能拿到数据目录绝对路径(含用户名 / OneDrive
|
||||
// 用户名等)。renderer 用 relDir 就能唯一定位。
|
||||
relDir,
|
||||
});
|
||||
}
|
||||
@@ -204,13 +296,18 @@ function createFsWatcher(deps) {
|
||||
// 恢复 ok → 清错误闩锁
|
||||
clearErrorLatch();
|
||||
const list = result.entries || [];
|
||||
const snapshot = entriesSnapshot(list);
|
||||
if (snapshot !== lastFilesSnapshot) {
|
||||
lastFilesSnapshot = snapshot;
|
||||
if (!skipInitialEmit) {
|
||||
emitFilesChanged(list, dir);
|
||||
}
|
||||
}
|
||||
// baseline 写入 + push 决策一起交给 emitFilesChanged,由 skipInitialEmit 控制。
|
||||
//
|
||||
// 历史 bug(Round 13 fix fce2d83 引发的回归):
|
||||
// 旧实现这里先写 baseline、再调 emitFilesChanged(list, dir)。emitFilesChanged
|
||||
// 内部的 snapshot 比对看到 baseline 已相同、if 块不进、safeSend 跳过 —— 2s
|
||||
// 轮询(OneDrive / SMB 等 fs.watch 不可靠环境的唯一兜底)检测到的文件变化
|
||||
// 永远不通知 renderer,侧栏永久 stale。fs.watch 路径仍能通过 scheduleEmit
|
||||
// 兜底,但在纯靠轮询的环境侧栏刷新完全失效。
|
||||
// 修:让 emitFilesChanged 拥有 baseline + 推送决策,参数透传 skipInitialEmit。
|
||||
// skipInitialEmit=true(启动时) → emitFilesChanged 写基线但不 push;
|
||||
// skipInitialEmit=false(轮询) → 写基线 + push 到 renderer。
|
||||
emitFilesChanged(list, dir, { skipInitialEmit });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,8 +328,9 @@ function createFsWatcher(deps) {
|
||||
* `relDir`(POSIX 相对路径),与 file:scan-dir handler 的返回形状对齐,
|
||||
* 方便 renderer 兜底逻辑无需依赖 IPC 调用上下文。
|
||||
*/
|
||||
async function emitFilesChanged(precomputed, dir) {
|
||||
async function emitFilesChanged(precomputed, dir, opts = {}) {
|
||||
const targetDir = dir || watchedDir || resolveDataDir();
|
||||
const skipInitialEmit = opts && opts.skipInitialEmit === true;
|
||||
// audit fix (shared-M9):同样的 generation 防护。emitFilesChanged 自带
|
||||
// scanDir 分支时同样有「scan 期间用户切目录 → 旧结果覆盖新基线」的竞态。
|
||||
const myGen = dirGeneration;
|
||||
@@ -261,19 +359,45 @@ function createFsWatcher(deps) {
|
||||
safeSend({
|
||||
error: result.error,
|
||||
code: result.code,
|
||||
dir: targetDir,
|
||||
// Round 13 fix (M12):移除绝对路径字段。renderer 不消费它,
|
||||
// 但 IPC 通道透传任意大字段增加无谓的序列化成本 + 通过
|
||||
// DevTools / 抓包能拿到数据目录绝对路径(含用户名 / OneDrive
|
||||
// 用户名等)。renderer 用 relDir 就能唯一定位。
|
||||
relDir,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
list = result.entries || [];
|
||||
// 同步基线,避免轮询把这次改动再报一遍
|
||||
lastFilesSnapshot = entriesSnapshot(list);
|
||||
}
|
||||
// Round 13 fix (M13):snapshot 写入必须在 generation 检查通过之后。
|
||||
// 旧代码把 `lastFilesSnapshot = entriesSnapshot(list)` 写在 gen 检查
|
||||
// 之前的 try 块里 —— 竞态场景:
|
||||
// t=0 watcher A 开始 scanDir(dir1) → 拿 myGen=1
|
||||
// t=1 用户切目录 → dirGeneration 推进到 2,watcher B 开始 scanDir(dir2)
|
||||
// t=2 watcher A 的 scanDir 完成 → 写 lastFilesSnapshot = A 的列表
|
||||
// t=3 watcher A 检查 gen → 不等 → 直接 return(不写 payload)
|
||||
// t=4 watcher B 的 scanDir 完成 → 比较 lastFilesSnapshot(已是 A 的列表,
|
||||
// 跟 B 的列表很可能不一样)→ 推送 B 的列表 ✓
|
||||
// t=5 但是 lastFilesSnapshot 没被 B 写(push 路径没写基线)→ 下次 2s
|
||||
// 轮询拿 B 的列表 → entriesSnapshot !== lastFilesSnapshot(A 的)→
|
||||
// 又发一次 payload。
|
||||
// 现在把 snapshot 写入移到 gen 检查通过后、push 前,让旧 watcher 直接
|
||||
// return 时不留脏基线。
|
||||
if (myGen !== dirGeneration) return;
|
||||
// 成功路径同样补 dir 字段(FS-F11)。
|
||||
safeSend({ entries: list, dir: targetDir, relDir: relOf(targetDir) });
|
||||
if (list) {
|
||||
const snapshot = entriesSnapshot(list);
|
||||
// skipInitialEmit 用于启动时(updateSnapshot(dir, true))—— 静默同步基线
|
||||
// 不向 renderer 推送,避免「刚加载就 emitFilesChanged 又被 renderer 当
|
||||
// fs:changed 事件 → 触发 scanDir 重复请求」循环。
|
||||
if (snapshot !== lastFilesSnapshot) {
|
||||
lastFilesSnapshot = snapshot;
|
||||
if (!skipInitialEmit) {
|
||||
// 成功路径同样补 relDir 字段(FS-F11);移除绝对 dir 字段(M12)。
|
||||
safeSend({ entries: list, relDir: relOf(targetDir) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleEmit() {
|
||||
@@ -394,6 +518,25 @@ function createFsWatcher(deps) {
|
||||
scheduleEmit();
|
||||
return;
|
||||
}
|
||||
// audit fix (Round 16 M1):self-write filter —— file:write / file:create
|
||||
// 自己刚写过的文件 500ms 内变更视为 self-write,不 emit 给 renderer。
|
||||
// 慢盘(SMB / OneDrive)下 atomicWriteFile 触发 fs.watch 事件的时刻
|
||||
// 可能早于 IPC 'file:write' 的 ok 响应回到 renderer,renderer 还没
|
||||
// 更新 state.lastWriteMtimeMs 就收到 files:changed → 误判外部修改 →
|
||||
// 弹「文件在外部被修改」对话框。dirWatcher 这层拦掉。
|
||||
// 注意 Windows 上 filename 可能是绝对路径(含 watchedDir 前缀),
|
||||
// Linux 是相对路径;统一 path.resolve(watchedDir, filename) 兜底。
|
||||
if (watchedDir) {
|
||||
let absPath;
|
||||
try {
|
||||
absPath = path.isAbsolute(filename) ? filename : path.join(watchedDir, filename);
|
||||
} catch {
|
||||
absPath = null;
|
||||
}
|
||||
if (absPath && isSelfWrite(absPath)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Windows 上可能触发多次事件,加 300ms 防抖合并
|
||||
scheduleEmit();
|
||||
});
|
||||
@@ -471,6 +614,9 @@ function createFsWatcher(deps) {
|
||||
try { dirWatcher.close(); } catch {}
|
||||
dirWatcher = null;
|
||||
}
|
||||
// audit fix (Round 16 M1):切目录 / 停监听时清掉 self-write 窗口 —— 旧
|
||||
// 目录的 500ms 窗口不应泄漏到新目录(极小概率,但语义更干净)。
|
||||
clearExpectedWrites();
|
||||
stopDirPoll();
|
||||
// audit fix (Round 7 FS-F5):停止监听也重置退避计数,避免下次启动
|
||||
// 接着用上一会话的指数退避值。
|
||||
@@ -487,6 +633,23 @@ function createFsWatcher(deps) {
|
||||
pauseDirPolling,
|
||||
resumeDirPolling,
|
||||
stopWatchingDir,
|
||||
// audit fix (Round 16 M1):file:write / file:create 成功后调用,
|
||||
// 告诉 fsWatcher 「这个文件接下来 500ms 内的 fs.watch 事件是我自己的,
|
||||
// 别当外部修改推给 renderer」。
|
||||
markExpectedWrite,
|
||||
// 内部供 dirWatcher callback 调用以判断是否需要跳过 scheduleEmit;
|
||||
// 也暴露给单测(tests/unit/fs-watcher.test.js)作为回归锚点,
|
||||
// 避免后续重构把 case-M1 修复回退掉。生产路径只在内部用。
|
||||
isSelfWrite,
|
||||
/** 切目录 / stop 监听时清空 self-write 窗口 —— 测试也通过它做硬复位 */
|
||||
clearExpectedWrites,
|
||||
/**
|
||||
* 测试钩子:暴露 updateSnapshot 用于单测绕过 fs.watch / 轮询触发 polling
|
||||
* 路径,验证 push 决策(Round 13 fix fce2d83 修复的回归测试用)。
|
||||
* 生产路径只通过 startWatchingDir / resumeDirPolling / 2s 轮询定时器调用,
|
||||
* 故意没暴露给 renderer 直接调用 —— 上层不应该知道这个内部步骤。
|
||||
*/
|
||||
_test_updateSnapshot: updateSnapshot,
|
||||
/** 测试 / 调试用:当前是否在监听某个目录 */
|
||||
get watchedDir() { return watchedDir; },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user