Files
Notes/main/config-store.js
2026-09-12 14:15:26 +08:00

519 lines
23 KiB
JavaScript
Raw Blame History

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