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

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

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