519 lines
23 KiB
JavaScript
519 lines
23 KiB
JavaScript
// 配置持久化层(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,
|
||
};
|