// Electron 主进程
// 负责窗口管理、文件 IO、IPC 桥接
// 关键:如果系统环境变量 ELECTRON_RUN_AS_NODE=1 被设置,
// electron.exe 会作为普通 Node 运行而非 Electron。
// 这会让 `require('electron')` 返回路径字符串而非 API。
// 这里主动清除它,确保应用始终作为 Electron 运行。
if (process.env.ELECTRON_RUN_AS_NODE) {
console.log('[main] 检测到 ELECTRON_RUN_AS_NODE=1,已清除(避免被当作 Node)');
delete process.env.ELECTRON_RUN_AS_NODE;
}
// Windows 控制台编码修复:默认是 GBK(cp936),导致中文日志乱码。
// 强制切换到 UTF-8,使 console.log 的中文能正确显示。
if (process.platform === 'win32') {
try {
process.stdout.setDefaultEncoding('utf8');
process.stderr.setDefaultEncoding('utf8');
} catch (e) {
// 某些环境下 setDefaultEncoding 不可用,忽略
}
}
const electronModule = require('electron');
// 检查 require('electron') 是否返回正确的 API(而非路径字符串)
// 在 Windows 上,如果项目路径包含非 ASCII 字符(如中文),可能会返回路径字符串
if (typeof electronModule === 'string' || !electronModule.app) {
const errMsg = `
================================================================================
[FATAL] require('electron') 返回了无效值!
问题原因: 项目路径包含非 ASCII 字符(如中文)
当前路径: ${__dirname}
返回值: ${typeof electronModule === 'string' ? '字符串 (路径)' : '无效对象'}
解决方法: 将项目移动到 ASCII 路径,例如:
D:\\Projects\\todo-list
C:\\dev\\todo-list
或者创建一个符号链接(mklink /D):
mklink /D C:\\dev\\todo-list "D:\\data\\正在工作\\智能体管理的项目\\Todo List"
然后在符号链接路径下运行 npm start
================================================================================
`;
console.error(errMsg);
// 显示一个图形化错误窗口
// v4+ 修复:Node require 缓存命中后第二次 require('electron') 仍返回同一个字符串;
// 旧实现 `const { app: appApi } = require('electron')` 解构得到 undefined,
// 错误窗口分支被跳过、直接 process.exit(1),用户看到"启动后什么都没发生"。
// 必须先 delete require.cache 让第二次 require 真的拿到 API 对象。
// 进一步兜底:先写 stderr 让用户从启动器日志里至少能看到原因(即使 GUI 窗口也起不来)。
process.stderr.write(errMsg);
let appApi = null;
let BrowserWindowApi = null;
try {
delete require.cache[require.resolve('electron')];
const freshElectron = require('electron');
if (typeof freshElectron === 'object' && freshElectron.app) {
appApi = freshElectron.app;
BrowserWindowApi = freshElectron.BrowserWindow;
}
} catch (e) {
console.error('[main] 第二次 require electron 仍失败:', e.message);
}
if (appApi && typeof appApi.whenReady === 'function' && BrowserWindowApi) {
appApi.whenReady().then(() => {
const win = new BrowserWindowApi({
width: 600,
height: 380,
resizable: false,
title: 'Todo List - 启动错误'
});
win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(`
⚠ 启动失败
问题:项目路径包含非 ASCII 字符(如中文),导致 Electron 无法正确加载 API。
当前路径:
${__dirname}
解决方法:将项目移动到 ASCII 路径(如 D:\\Projects\\todo-list)。
详细说明请查看 README。
`));
});
// 不退出,让用户看到错误窗口
return;
}
// 拿不到真正的 electron API —— 兜底退出,stderr 已经写过原因
process.exit(1);
}
const { app, BrowserWindow, ipcMain, dialog, Menu, shell, Tray } = electronModule;
const path = require('path');
const fs = require('fs').promises;
const fsSync = require('fs');
// 窗口图标和系统托盘共用同一图标
const APP_ICON_PATH = path.join(__dirname, 'icon.ico');
// 抑制 Windows 上常见的 "Unable to move the cache" / "Gpu Cache Creation failed" 警告
// 这些是 Electron 在受限目录下的良性警告,不影响功能。
app.commandLine.appendSwitch('disable-gpu-cache');
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion');
let mainWindow = null;
let tray = null;
// 区分"用户主动退出"与"关闭按钮隐藏到托盘"。
// 仅在主动退出时,close 事件才不会被拦截隐藏。
let isQuitting = false;
// 退出时是否已经尝试刷写未保存数据。
// 第一次 before-quit 刷写后设为 true,第二次 before-quit(刷写完成后重试退出)直接放行,
// 避免写入失败时无限重试。用户再次修改后 renderer 会通知 dirty 变化,重置此标志。
let saveFlushed = false;
// 渲染进程报告的 dirty 状态(由 renderer 通过 app:dirty-changed 主动同步)
// 主进程据此判断退出前是否需要请求渲染进程刷写保存
let rendererDirty = false;
// 当前是否在等待渲染进程的刷写结果。flush-done / 兜底定时器共用同一标志,
// 防止「before-quit 被多次触发」或「渲染进程 flush-done 乱入未发起刷写」时
// 直接 app.quit(),绕过保存或留一个永远不释放的定时器。
let flushInProgress = false;
let flushTimeoutHandle = null;
// ready-to-show 兜底定时器的句柄。
// 当用户主动隐藏窗口后,必须取消它,避免 2 秒后被强制拉回。
let forceShowTimer = null;
let currentFilePath = null;
let fileWatcher = null;
// 抑制自己写入触发的文件监视事件(避免误弹"外部修改"对话框)。
//
// 四代方案对比:
// v1(被淘汰): 1.5s 时间窗口盲目抑制 → 用户紧接着的外部保存被吞。
// v2(已废): mtime+size 指纹 → Node fs.watch 在 Windows 上的 callback 在 syscall
// 返回前就触发,fingerprint 永远来不及设置。race condition 不可解。
// v3(也废): 内容字符串指纹 → 同样被 fs.watch 时序问题击穿。
// v4(上一代): 主进程不过滤。所有 fs.watch 事件都发 IPC,渲染端 readFile 后
// 与 store.serialize() 比对 —— 完全相同视为自己写入的产物静默,
// 任何字节差异视为外部修改正常 reload + toast。判断在渲染端做,
// 不依赖主进程的时序/事件触发顺序,零误伤。
// v5(当前): v4 的内容比对保留,同时再加 250ms statSync(mtime+size) 轮询兜底。
// 原因:用户实际报告「修改 todo.md 后软件没及时更新」,说明他
// 的环境里 fs.watch 根本没触发(云同步目录 / 网络盘 / 某些编辑器
// 的非标准保存路径)。fs.watch 依赖 OS 文件通知 API,不可靠。
// polling 用 statSync 探测 mtime+size 变化触发相同 IPC —— 普适。
// v4 的内容比对继续承担「自写入抑制」职责:polling 检测到变化
// 后发 IPC,渲染端读出来与 store.serialize() 比对,相同则静默。
// 250ms 是普适体验甜点(500ms 是可感知延迟,250ms 用户几乎无感,
// statSync 对静态文件开销可忽略 —— 每秒 4 次 stat 不影响性能)。
//
// 主进程这里只做基础去重:同一保存的 burst 事件(Windows 上可能 2-3 个)只发 1 次 IPC。
// ============================================
// 配置管理(持久化用户设置)
// ============================================
/**
* 默认数据目录:用户主目录下的 TodoList(~/TodoList)。
*
* 跨平台一律走 home:Windows 上就是 %USERPROFILE%/TodoList,macOS / Linux
* 同理。不要再针对 Windows 特判 D:\TodoList —— 单盘笔记本根本没 D:,
* 多盘用户也未必想把任务数据丢到非系统盘;统一走 ~/TodoList 最直观、最可移植。
*
* 历史:D 盘特判最初是为了"有一台专门放数据的机器"的用户;后来证明
* 1) 大部分笔记本 / CI 没 D:,首次启动必然落入「临时模式」
* 2) 有 D: 的用户也常常忘了同步自己设的 dataDir
* 综合下来还是 home 最稳:自带云盘同步(OneDrive / iCloud Documents /
* Dropbox)默认就覆盖 ~/TodoList,迁移也无须改代码。
*/
function getDefaultDataDir() {
try {
const home = app.getPath('home');
return path.join(home, 'TodoList');
} catch {
const os = require('os');
return path.join(os.homedir(), 'TodoList');
}
}
// 默认目录在 app ready 之后才能解析(需要 app.getPath),先用占位值
let DEFAULT_DATA_DIR = null;
function getConfigPath() {
// 配置存储在用户应用数据目录(Electron 推荐位置)
return path.join(app.getPath('userData'), 'config.json');
}
/**
* 主进程自己用到的配置默认值。
*
* 用途:loadConfig() 用这份兜底缺失字段,确保 appConfig 启动时就自洽 —— 任何
* `appConfig.X` 访问拿到的是确定值,而不是 undefined。这件事不能用 truthy 检查
* 凑合:loadConfig 返回 {} 时 appConfig.backupEnabled === undefined,truthy 检查
* 把它当成"禁用",结果新用户的每日备份机制永远不触发(config.json 不会被主进程
* 自动写入 backupEnabled 字段),UI 却又显示开启 —— 主进程 / 渲染端不一致。
*
* 注意:dataDir / theme / filter / sortBy / fontSize 等不在这里 —— 它们要么是
* null 语义(恢复默认),要么仅 renderer 用、main 自身不读。lastBackupDate 虽
* 是主进程内部状态,但写入端(saveConfig)和读取端(maybeBackupToday)都明确
* 处理 null,所以也不需要在这里兜底。
*/
const DEFAULT_CONFIG = {
backupEnabled: true, // 默认开启每日备份
alwaysOnTop: false // 默认关闭窗口置顶
};
// 安全审计 M2 修复:递归过滤危险 key,防止外部篡改的 config.json 通过
// `__proto__` / `constructor.prototype` 污染 Object.prototype。
// 应用场景:loadConfig 把磁盘 JSON 合并进内存配置;任何代码随后访问
// `appConfig.someKey` 时都会走原型链查找 —— 攻击者只需让 config.json
// 出现 `{"__proto__":{"isAdmin":true}}` 就能挂全局标志位。
function _stripProtoKeys(value) {
if (Array.isArray(value)) return value.map(_stripProtoKeys);
if (value && typeof value === 'object') {
const out = {};
for (const k of Object.keys(value)) {
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
out[k] = _stripProtoKeys(value[k]);
}
return out;
}
return value;
}
function loadConfig() {
try {
const raw = fsSync.readFileSync(getConfigPath(), 'utf-8');
const parsed = JSON.parse(raw);
// 防御性:配置文件被破坏或被外部改成 null / 数组 / 标量时,避免后续
// `appConfig.dataDir` 之类的访问炸掉整个启动。
const valid = (parsed && typeof parsed === 'object' && !Array.isArray(parsed));
const safe = valid ? _stripProtoKeys(parsed) : {};
return { ...DEFAULT_CONFIG, ...safe };
} catch {
return { ...DEFAULT_CONFIG };
}
}
let appConfig = {};
async function saveConfig(next) {
// v3.7+ 修复 H11:先构造候选 next,**写盘成功后再 commit 内存**。
// 旧实现是先 appConfig = {...appConfig, ...next} 再 writeFileSync:
// - 写盘失败时 catch 只 console.error,内存已被覆盖
// - maybeBackupToday 看到 appConfig.lastBackupDate === today 直接 return,
// 当天再也不会产生备份
// 新实现:写盘失败时 next 不写回 appConfig,lastBackupDate 保持原值,
// 下次 maybeBackupToday 仍能正确触发;同时不再抛出(保留原行为:失败仅记日志)。
//
// v4+ 修复:原子写入(tmp + rename)。
// 旧实现 fsSync.writeFileSync 在电源掉电 / 进程被杀中途会留下「半截 JSON」——
// 下次启动 parseConfig 抛 SyntaxError → 用户设置(主题、置顶、数据目录等)全部
// 回到默认,等于无声重置。tmp + rename 在同盘上是原子的:磁盘要么是旧完整配置、
// 要么是新完整配置,永远不会半截。
//
// v4+ 修复:改为 async 函数。旧实现 EPERM/EBUSY 重试时用 while 循环 spin-lock
// 阻塞主线程 50-100ms,期间 IPC / 定时器全部停滞。改为 setTimeout 异步等待,
// 不阻塞事件循环。
const candidate = { ...appConfig, ...next };
const configPath = getConfigPath();
const tmpPath = configPath + `.${process.pid}.${Date.now()}.tmp`;
try {
fsSync.mkdirSync(path.dirname(configPath), { recursive: true });
fsSync.writeFileSync(tmpPath, JSON.stringify(candidate, null, 2), 'utf-8');
// rename 在同盘上是原子的;Windows 上偶尔会因杀毒软件占文件失败,
// 重试一次常见且无副作用。
try {
fsSync.renameSync(tmpPath, configPath);
} catch (e) {
if ((e.code === 'EPERM' || e.code === 'EBUSY') && fsSync.existsSync(tmpPath)) {
// 杀毒软件/索引器短暂占用 —— 异步退避后重试一次(旧实现是 spin-lock,
// 阻塞主线程 50-100ms;改为 setTimeout 不阻塞事件循环)。
const delay = 50 + Math.floor(Math.random() * 50);
await new Promise(resolve => setTimeout(resolve, delay));
fsSync.renameSync(tmpPath, configPath);
} else {
throw e;
}
}
} catch (e) {
console.error('[main] 配置保存失败:', e.message);
// 清理可能残留的 tmp 文件
try { if (fsSync.existsSync(tmpPath)) fsSync.unlinkSync(tmpPath); } catch {}
// 失败时不 commit —— 保持原 appConfig
return appConfig;
}
appConfig = candidate;
return appConfig;
}
/**
* 获取实际生效的数据目录路径
*/
function resolveDataDir() {
const custom = appConfig.dataDir;
if (custom && typeof custom === 'string' && custom.trim()) {
return custom;
}
return DEFAULT_DATA_DIR;
}
// ============================================
// 每日备份
// ============================================
//
// 开关:settingsStore.backupEnabled(默认 true)。关闭时整个机制空转,
// 即便调用 maybeBackupToday() 也直接 return —— 用户改回去当天再次开启
// 也照常触发,因为 disable 不清 lastBackupDate。
//
// 触发点:
// - 启动时(app-start)—— 首次打开软件
// - 写入文件前(file-write)—— 首次操作;只要还没备份过今天就被吸收
//
// 「每天一次」用 lastBackupDate(YYYY-MM-DD,本地日期)做幂等:
// - 今天 == lastBackupDate → return
// - 不等 → 复制 /todo.md → /backup/todo-YYYY-MM-DD.md → 落盘 lastBackupDate
// - 文件不存在(用户从未保存过)→ return,什么也不写
// - 复制失败(盘满 / 权限)→ 不更新 lastBackupDate,下次再试
//
// 并发:短时间内多次写入 / 启动 + 写入撞上时,backupInFlight 守卫只让一次进入;
// 后到的调用直接 return(同步检查 lastBackupDate 也兜底),不会写出多份同一天备份。
/**
* 本地日期 YYYY-MM-DD(用本地时区;toISOString 走 UTC 在跨时区用户处会偏一天)
*/
function getTodayString() {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
let backupInFlight = false;
async function maybeBackupToday(reason) {
// 关闭 / 已备份过今天 / 别的调用正在跑 —— 都直接跳过。
// 注意:备份成功前不更新 lastBackupDate,所以「今天还没备份过」的判断
// 必须以磁盘上 lastBackupDate 字段为准,不能用 in-memory flag。
if (backupInFlight) return;
// 严格 === false:loadConfig 缺失字段时用 DEFAULT_CONFIG.backupEnabled = true
// 兜底,appConfig.backupEnabled 永远是 boolean。truthy 检查会把 undefined 当成
// 「禁用」—— 即便 IPC 写入侧和 SettingsStore 都做了 boolean 校验,未来若引入
// 字符串污染(手工编辑 config.json 等),strict false 仍能正确识别为「禁用」。
if (appConfig.backupEnabled === false) return;
const today = getTodayString();
if (appConfig.lastBackupDate === today) return;
const dataDir = resolveDataDir();
// 源文件路径固定 todo.md(与 renderer 中 createDefaultFile 的约定一致)。
// 不存在就什么都不做 —— 用户可能首次启动、还没创建过文件。
const sourcePath = path.join(dataDir, 'todo.md');
const backupDir = path.join(dataDir, 'backup');
const backupPath = path.join(backupDir, `todo-${today}.md`);
// 路径合法性:内部构造的 path.join(dataDir, ...) 理论上必在 dataDir 内,
// 但保留一道 assertInDataDir 兜底 —— 万一未来 dataDir 校验逻辑收紧
// (如允许用户从快捷方式里指带 .. 的相对路径),不会在这里漏出 RCE。
try {
assertInDataDir(sourcePath);
assertInDataDir(backupPath);
} catch (e) {
console.warn('[main] 备份路径校验失败:', e.message);
return;
}
let stat;
try {
stat = await fs.stat(sourcePath);
} catch {
// 源文件不存在 —— 用户第一次启动还没保存过,跳过
return;
}
if (!stat.isFile()) return;
backupInFlight = true;
try {
await fs.mkdir(backupDir, { recursive: true });
// 用 copyFile 而非 read+write:与同文件 .bak 单次备份走同一条路径,
// 性能更好(OS 内核级 sendfile / CopyFileEx,无用户态缓冲)。
// todo.md 量级下撞上「copy 中途源被外部写」的窗口极短,且 fs.rename 替换
// 语义下源文件句柄仍指向原 inode —— 实际上不会产生不一致。
await fs.copyFile(sourcePath, backupPath);
saveConfig({ lastBackupDate: today });
console.log(`[main] 已备份到 ${backupPath} (原因: ${reason})`);
} catch (e) {
console.warn('[main] 备份失败:', e.message);
// 不更新 lastBackupDate —— 下次再试
} finally {
backupInFlight = false;
}
}
// ============================================
// 启动日志
// ============================================
console.log('[main] Electron 启动中...');
console.log('[main] Platform:', process.platform);
console.log('[main] Electron version:', process.versions.electron);
console.log('[main] Project path:', __dirname);
// ============================================
// 窗口创建
// ============================================
function createWindow() {
console.log('[main] 创建主窗口...');
// 防御性清理:若上一次 createWindow 设置的 2s 兜底定时器尚未触发,
// 先清掉它 —— 否则旧定时器会在窗口创建后触发,对「新」窗口执行 show,
// 虽然 isVisible 检查能挡住多余 show,但悬空定时器是隐患。
// 典型触发场景:窗口刚启动不久因 render-process-gone 被销毁重建。
if (forceShowTimer) {
clearTimeout(forceShowTimer);
forceShowTimer = null;
}
mainWindow = new BrowserWindow({
width: 960,
height: 800,
minWidth: 600,
minHeight: 420,
backgroundColor: '#0d1117',
icon: APP_ICON_PATH, // 标题栏 + 任务栏图标
show: false, // 先隐藏,ready-to-show 后再显示(避免白屏闪烁)
autoHideMenuBar: true, // 自动隐藏菜单栏(替代 setMenuBarVisibility)
// 自定义窗口:去掉系统标题栏,改由 renderer 顶部工具栏的 -webkit-app-region: drag
// 接管拖拽,并在工具栏右侧画出自定义最小化/还原/关闭按钮。
// 注意:保留 default 行为(无 frame 时仍可拖边缘调整大小、Win+Aero Snap 仍生效)。
frame: false,
titleBarStyle: 'hidden', // macOS 上隐藏标题栏;Windows 上被 frame:false 完全接管
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
// 允许 ES 模块加载(renderer 中的 type="module")
// 必需,否则 ES 模块会失败
}
});
mainWindow.loadFile('index.html');
// 启动时应用持久化的「始终置顶」状态。
// config.json 在 app.ready 时已加载(appConfig),createWindow 是 ready 回调里调用,
// 此时 appConfig 已就绪。如果上次会话开着,窗口创建后立刻置顶。
if (appConfig.alwaysOnTop === true) {
mainWindow.setAlwaysOnTop(true);
}
// ready-to-show:渲染完成后显示窗口,并同步自定义标题栏的最大化按钮状态
mainWindow.once('ready-to-show', () => {
console.log('[main] 窗口 ready-to-show,显示窗口');
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.show();
}
broadcastMaximizeState();
});
// 回兜:2 秒后如果还没显示,强制显示(防止 ready-to-show 静默失败)
forceShowTimer = setTimeout(() => {
forceShowTimer = null;
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) {
console.warn('[main] ready-to-show 未触发,强制显示窗口');
mainWindow.show();
}
}, 2000);
// 错误监听
mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL) => {
console.error('[main] 页面加载失败:', errorCode, errorDescription, validatedURL);
});
mainWindow.webContents.on('render-process-gone', (event, details) => {
console.error('[main] 渲染进程崩溃:', details);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.reload();
}
});
mainWindow.webContents.on('console-message', (event, level, message, line, source) => {
// level: 0=verbose, 1=info, 2=warning, 3=error
if (level >= 3) {
console.error(`[renderer ${source}:${line}]`, message);
}
});
// 开发模式下自动打开 DevTools(方便调试)
if (process.env.NODE_ENV === 'development' || process.argv.includes('--dev')) {
mainWindow.webContents.openDevTools({ mode: 'detach' });
}
// 关闭事件
// 默认拦截关闭按钮,改为隐藏到托盘(除非应用正在主动退出,或托盘不可用)
mainWindow.on('close', (e) => {
if (isQuitting) return;
// 没有托盘就无法恢复窗口,让窗口正常关闭(window-all-closed 会处理退出)
if (!tray) return;
e.preventDefault();
hideWindow();
});
// 最大化/还原事件:把状态推给 renderer 同步自定义按钮图标。
// 不监听 'resize' 是因为它会因每像素变化连续触发;'maximize' / 'unmaximize'
// 是状态切换点,只触发一次,正好对应按钮图标的二态切换。
// 初始状态由上方 ready-to-show 监听器推一次(renderer 还没挂 IPC 监听就丢消息,
// 而 ready-to-show 时 renderer 已经初始化、IPC 可用)。
mainWindow.on('maximize', () => broadcastMaximizeState());
mainWindow.on('unmaximize', () => broadcastMaximizeState());
mainWindow.on('closed', () => {
console.log('[main] 窗口已关闭');
stopWatchingFile();
mainWindow = null;
});
console.log('[main] 主窗口创建完成');
}
// ============================================
// 系统托盘
// ============================================
function showWindow() {
if (!mainWindow || mainWindow.isDestroyed()) {
createWindow();
return;
}
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
// 显隐态变了,托盘菜单的「显示/隐藏窗口」文案要跟着翻转。
if (tray) tray.setContextMenu(buildTrayMenu());
}
function hideWindow() {
if (!mainWindow || mainWindow.isDestroyed()) return;
// 取消 ready-to-show 兜底定时器,避免用户主动隐藏后被强制拉回
if (forceShowTimer) {
clearTimeout(forceShowTimer);
forceShowTimer = null;
}
mainWindow.hide();
if (tray) tray.setContextMenu(buildTrayMenu());
}
/**
* 切换窗口显隐。托盘左键单击时触发。
*/
function toggleWindow() {
if (!mainWindow || mainWindow.isDestroyed()) {
createWindow();
return;
}
if (mainWindow.isVisible() && !mainWindow.isMinimized()) {
hideWindow();
} else {
showWindow();
}
}
function buildTrayMenu() {
// 根据窗口当前显隐态,只显示相关的那一项 —— 同时列出「显示 / 隐藏」会让用户
// 困惑(不知哪个是当前态),点错了也只是再点一次,但体验不佳。
const isVisible = mainWindow && !mainWindow.isDestroyed() &&
mainWindow.isVisible() && !mainWindow.isMinimized();
const toggleItem = isVisible
? { label: '隐藏窗口', click: () => hideWindow() }
: { label: '显示窗口', click: () => showWindow() };
return Menu.buildFromTemplate([
toggleItem,
{ type: 'separator' },
{
label: '始终置顶',
type: 'checkbox',
checked: appConfig.alwaysOnTop === true,
click: (item) => {
// 托盘菜单勾选时同步到窗口 + 落盘。
// 直接复用 renderer 持久化路径:通知渲染端写入 settingsStore,
// settingsStore.update 会通过 save-settings IPC 落盘,主进程这里也立刻
// 应用到窗口,避免 renderer 还没起来(重启首次启动时)就先一步生效。
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.setAlwaysOnTop(item.checked);
}
saveConfig({ alwaysOnTop: item.checked });
// 通知 renderer 同步工具栏按钮状态(如果窗口存在)。
mainWindow?.webContents.send('always-on-top:changed', item.checked);
}
},
{ type: 'separator' },
{
label: '设置...',
click: () => {
showWindow();
// 与菜单栏"设置..."一致:通知渲染进程打开设置对话框
mainWindow?.webContents.send('menu:settings');
}
},
{ type: 'separator' },
{
label: '退出',
click: () => {
isQuitting = true;
app.quit();
}
}
]);
}
function createTray() {
if (tray) return;
try {
tray = new Tray(APP_ICON_PATH);
tray.setToolTip('Todo List');
tray.setContextMenu(buildTrayMenu());
// 左键单击:切换窗口显示
tray.on('click', () => toggleWindow());
console.log('[main] 系统托盘已创建');
} catch (e) {
console.error('[main] 系统托盘创建失败:', e.message);
tray = null;
}
}
function destroyTray() {
if (!tray) return;
try {
tray.destroy();
} catch (e) {
console.warn('[main] 销毁托盘失败:', e.message);
}
tray = null;
}
// ============================================
// 单实例锁
// ============================================
// 应用现在会驻留托盘(关闭窗口不退出),用户很容易忘记它还在运行而重复启动。
// 两个实例会各自 fs.watch 同一个 todo.md 并各自防抖自动保存,导致互相覆盖丢数据。
// 因此只允许一个实例:后启动的那个把已有窗口叫到前台,然后自己退出。
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
console.log('[main] 已有实例在运行,激活已有窗口后退出本实例');
isQuitting = true;
app.quit();
} else {
app.on('second-instance', () => {
console.log('[main] 检测到第二个实例启动,激活已有窗口');
showWindow();
});
}
// ============================================
// 应用生命周期
// ============================================
app.whenReady().then(() => {
// 未拿到单实例锁时不应继续初始化(app.quit() 期间 ready 仍可能已触发)
if (!gotSingleInstanceLock) return;
// 启动时加载用户配置
appConfig = loadConfig();
// 解析默认数据目录(需要 app ready 才能访问 user paths)
DEFAULT_DATA_DIR = getDefaultDataDir();
console.log('[main] 已加载配置:', JSON.stringify(appConfig));
console.log('[main] 数据目录:', resolveDataDir());
// 启动时尝试今天的每日备份。异步触发、不阻塞启动 —— 即便备份耗时
// 几百毫秒,UI 也已经在 ready-to-show 里 show 了。
maybeBackupToday('app-start');
console.log('[main] app ready');
buildMenu();
createWindow();
createTray();
app.on('activate', () => {
// macOS: dock 图标被点击
// 优先复用现有的窗口(隐藏时也要能恢复显示),否则才新建
if (mainWindow && !mainWindow.isDestroyed()) {
showWindow();
} else if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
}).catch(err => {
// v4+ 修复:旧实现只 console.error,用户看到「双击启动 → 什么都没发生 → 任务管理器
// 进程在但窗口不见」。给一个原生错误对话框,至少让用户能搜到关键字、决定是否重启。
console.error('[main] app ready 失败:', err);
// dialog 依赖 app.ready —— whenReady().catch 触发时 app 已经 ready(否则这个 catch
// 不会被调用),所以直接用同步 dialog.showMessageBox。BrowserWindow 可能从未创建,
// 不传 parent 让它独立显示。
try {
const { dialog: dlg } = require('electron');
dlg.showMessageBoxSync({
type: 'error',
title: 'Todo List - 启动失败',
message: '应用初始化失败',
detail: (err && err.stack) ? String(err.stack) : String(err),
noLink: true
});
} catch (dialogErr) {
// dialog 自己抛了(极端环境,比如没有 display)—— 静默走 stderr
console.error('[main] 无法弹出错误对话框:', dialogErr);
}
});
app.on('before-quit', (event) => {
// 标记为主动退出,让 close 事件不再被拦截到托盘
isQuitting = true;
// 已有 flush 在跑:第一次 before-quit 触发的 flush 还没完。
// 这里**不能** preventDefault —— 否则退出被永久卡住,flush-done 永远等不到再次 app.quit()。
// 直接放行让 Electron 走完这次 before-quit;flush-done 处理器会在保存完成后自己再调 app.quit()。
if (flushInProgress) return;
// 退出前刷写未保存数据:
// 自动保存有 800ms 防抖,用户如果在修改后 800ms 内退出(关闭托盘/窗口/系统关机),
// 排队的保存会被丢弃 → 数据丢失。这里拦截退出,先让渲染进程立即保存。
if (!saveFlushed && rendererDirty && mainWindow && !mainWindow.isDestroyed()) {
event.preventDefault();
flushInProgress = true;
mainWindow.webContents.send('app:flush-pending-save');
// 给渲染进程 5 秒完成保存,之后强制退出。句柄存进 flushTimeoutHandle,
// flush-done 提前到时由它清掉,避免一个永不被释放的 setTimeout。
flushTimeoutHandle = setTimeout(() => {
flushTimeoutHandle = null;
// 兜底备份:超时意味着 renderer 未在 5 秒内完成保存。无论 renderer 是
// 已销毁还是活着但慢(大文件 IO / 主线程阻塞),磁盘上的内容可能不是最新的,
// 强制退出前都做一份 .pre-crash.bak,用户至少能从数据目录恢复上次成功写入的内容。
// 旧实现仅在 renderer 已销毁时备份 —— 活着但慢的场景下数据无声丢失。
if (currentFilePath) {
try {
fsSync.copyFileSync(currentFilePath, currentFilePath + '.pre-crash.bak');
console.warn('[main] 兜底:flush 超时,已写入 .pre-crash.bak');
} catch (e) {
console.warn('[main] 兜底备份失败:', e.message);
}
}
saveFlushed = true;
flushInProgress = false;
app.quit();
}, 5000);
return;
}
});
app.on('will-quit', () => {
destroyTray();
});
app.on('window-all-closed', () => {
console.log('[main] 所有窗口已关闭');
// 有托盘时,应用保持在后台运行(仅由托盘菜单的"退出"或 app.quit() 真正退出)
if (tray) return;
if (process.platform !== 'darwin') app.quit();
});
// ============================================
// IPC: 文件操作
// ============================================
// 注:不提供 'file:open-dialog' —— UI 上已去掉「打开文件」入口,
// 文件位置只由设置里的数据目录决定,留着一个无人调用的对话框只是多一个攻击面
/**
* .bak 文件轮转:保留最近三份(.bak = 上次保存;.bak.1 = 上上次;.bak.2 = 再上一次)。
*
* 不做无限制累积:每次 file:write 都会创建一份 .bak,无清理时代码库会在数据目录
* 里堆出与「写入次数 × 文件大小」等量的空间。普通用户一天保存几十次到几百次,
* 一个文件 10 KB 一年下来也能堆到几百 MB —— 用户大概率不会手动去翻 .bak。
*
* 保留三份而不是两份(v3.4+ 改为三份):多了「丢弃本地编辑前」的快照位
* (见 file:create-snapshot,label=discard 的快照与 .bak 是不同命名空间,不会冲突)。
* 三份为「连续三次 saveNow 失败后的连续 reload 留一阶余地」—— 典型用户场景下足够。
*
* 文件命名约定:.bak / .bak.1 / .bak.2 —— 不带时间戳是故意的,轮转时只需要 rename 三份文件,
* 无需解析 / 生成时间戳,路径相关的旁路代码(用户自己 shell 写的恢复脚本)也好懂。
*
* @param {string} absPath 目标文件绝对路径
*/
async function rotateBakFiles(absPath) {
const bakPath = absPath + '.bak';
const bak1Path = absPath + '.bak.1';
const bak2Path = absPath + '.bak.2';
// v3.7+ 修复 M-P3:先复制到临时 .bak.new,再降级 .bak.1 → .bak.2 → .bak → .bak.new。
// 旧顺序:删 .bak.2 → rename .bak.1 → rename .bak → copyFile(abs, .bak)
// - 降级 rename 中途失败(如 .bak.1 → .bak.2 成功但 .bak → .bak.1 失败),
// .bak 已被 .bak 的旧内容占着,copyFile 后 .bak 内容是「新文件 + .bak 旧内容竞争」
// 极端下旧的"再上一次保存"丢失到 .bak.2 位置但若 .bak.2 删除已做则"再上一次"完全消失
// 新顺序:先 copyFile 到 .bak.new(新内容已落盘)→ 删 .bak.2 → 降级 → 最后 rename .bak.new → .bak
// - 任一 rename 失败:.bak.new 已含新内容,原 .bak/.bak.1/.bak.2 都还在
// (除已删的 .bak.2),用户至少有"上次保存"+".bak.new"两个回退点
// - 最后一步 rename 成功:恢复"先删最旧 → 降级 → 新内容就位"的最终三份状态
const bakNewPath = absPath + '.bak.new';
try {
// 1) 先把磁盘当前文件复制为 .bak.new —— 这是新内容,必须先落盘才能安全降级。
if (fsSync.existsSync(absPath)) {
await fs.copyFile(absPath, bakNewPath).catch((e) => {
console.warn('[main] .bak.new 创建失败:', e.message);
// 如果连新内容都没法落盘,跳过整个轮转(原 .bak 都不动)——
// 用户至少保留完整的旧三份回退点
throw e;
});
} else {
// 源文件不存在时不做轮转(极少见,可能是文件被删后立即触发)
return;
}
// 2) 删最旧的 .bak.2(无论如何都要保证最终只有三份)
if (fsSync.existsSync(bak2Path)) {
await fs.unlink(bak2Path).catch(() => {});
}
// 3) 把 .bak.1 降级为 .bak.2
if (fsSync.existsSync(bak1Path)) {
await fs.rename(bak1Path, bak2Path).catch((e) => {
console.warn('[main] .bak.1 降级失败:', e.message);
});
}
// 4) 把现有 .bak 降级为 .bak.1
if (fsSync.existsSync(bakPath)) {
await fs.rename(bakPath, bak1Path).catch((e) => {
console.warn('[main] .bak 降级失败:', e.message);
});
}
// 5) 把 .bak.new 重命名为 .bak —— 最后一步;rename 在同盘上原子,
// 即便中途进程被杀,磁盘要么是旧三份、要么是完整新三份,永远不会半新半旧。
await fs.rename(bakNewPath, bakPath).catch((e) => {
console.warn('[main] .bak.new → .bak 重命名失败:', e.message);
// 失败时 .bak.new 已存在但未就位 —— 下次轮转会再覆盖,文件不会丢
});
} catch (e) {
// 整个轮转失败不应阻塞主流程 —— 用户至多少一份回退点,比写入失败好得多
console.warn('[main] .bak 轮转失败:', e.message);
}
}
/**
* 读取磁盘当前内容(用于外部修改冲突场景的载荷增强)。
*
* 调用点:file:write 检测到 EXTERNAL_CHANGE_DETECTED 时,把磁盘内容一并塞回错误响应,
* 渲染端拿到后立刻可以拿去做 diff —— 不用再额外发一次 file:read。
*
* 文件不存在 / 读取失败一律返回 null(调用方需要兼容)—— 让渲染端走「文件已删除」分支。
*/
async function readCurrentDiskContent(absPath) {
try {
return await fs.readFile(absPath, 'utf-8');
} catch (e) {
console.warn('[main] 读取磁盘当前内容失败:', absPath, e.message);
return null;
}
}
/**
* 把磁盘当前文件复制为「带标签的快照」—— 与 .bak 轮转的命名空间不同,
* 用于「主动丢弃本地编辑前」「退出前刷写时」「重载前」等用户可见意图。
*
* 与 .bak 轮转的核心区别:
* - .bak 是「隐式回退点」,不带时间戳,每 saveNow 自动轮转(保留最近 3 份)
* - 带标签快照是「显式意图记录」,带时间戳,**不轮转不清理** —— 用户主动丢弃的
* 内容必须能找回,几个月后某天突然想回看时还能找得到
*
* 命名:${filePath}.${label}-${YYYYMMDD-HHmmss}.bak —— 与 .bak / .bak.1 / .bak.2 区分开,
* 不会污染 rotateBakFiles 的命名空间。
*
* 合法 label 集合(白名单避免任意字符串被拼到文件名里):
* - discard —— 用户主动丢弃本地编辑前的快照
* - autosave-on-exit —— 退出前自动保存检测到外部修改时的快照
* - before-reload —— reload 之前的快照
* - user-merge —— 用户合并后保留旧版本(可选,将来用得到)
*/
const VALID_SNAPSHOT_LABELS = new Set([
'discard', 'autosave-on-exit', 'before-reload', 'user-merge'
]);
ipcMain.handle('file:create-snapshot', async (event, { filePath, label }) => {
let absPath;
try {
absPath = assertInDataDir(filePath);
} catch (e) {
return { ok: false, error: e.message };
}
if (typeof label !== 'string' || !VALID_SNAPSHOT_LABELS.has(label)) {
return { ok: false, error: `invalid label: ${label}` };
}
try {
if (!fsSync.existsSync(absPath)) {
// 文件已被外部删除 —— 快照源不存在,没法复制
return { ok: false, error: 'FILE_NOT_FOUND' };
}
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
const ts =
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
`-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
const snapshotPath = `${absPath}.${label}-${ts}.bak`;
// 文件名冲突(同秒内两次 snapshot)—— 在末尾加 -N 后缀,N 从 1 开始
let finalPath = snapshotPath;
let collision = 1;
while (fsSync.existsSync(finalPath)) {
finalPath = `${absPath}.${label}-${ts}-${collision}.bak`;
collision++;
}
await fs.copyFile(absPath, finalPath);
console.log(`[main] 已创建快照 (label=${label}):`, finalPath);
return { ok: true, snapshotPath: finalPath };
} catch (e) {
console.error('[main] 创建快照失败:', absPath, e.message);
return { ok: false, error: e.message };
}
});
/**
* 把任意传入路径收敛到「当前生效的数据目录」之内。
*
* 必要性:renderer 拿到的是普通字符串,所有 file:read / file:write / file:create-if-missing
* 此前完全信任调用方。配合一个 XSS(任务文本流过 innerHTML)就能让 renderer 把任意
* 路径喂进来 —— 等于任意文件读写。Drop 一个 .bat 进 Startup 目录就是持久 RCE。
*
* 策略:path.resolve 后必须以数据目录的绝对路径为前缀;非字符串、穿越、空路径一律拒绝。
* 目录存在与否不影响:写入端有自己的 mkdir -p,读取端只信任路径合法性。
*/
function assertInDataDir(p) {
if (typeof p !== 'string' || !p) {
throw new Error('path must be a non-empty string');
}
const root = path.resolve(resolveDataDir());
const abs = path.resolve(p);
// Windows 上 NTFS 是大小写不敏感的:`D:\TodoList\foo.md` 和 `d:\TODOLIST\foo.md`
// 指向同一文件,但 `startsWith` 走字符串相等 —— 直接用 abs.startsWith(root)
// 会被大小写差异绕过,逃出数据目录的「任意文件读写」防御。统一 toLowerCase 比对。
const rootCmp = process.platform === 'win32' ? root.toLowerCase() : root;
const absCmp = process.platform === 'win32' ? abs.toLowerCase() : abs;
if (absCmp !== rootCmp && !absCmp.startsWith(rootCmp + path.sep)) {
throw new Error('path outside data directory');
}
return abs;
}
ipcMain.handle('file:save-dialog', async () => {
// 默认定位到数据目录 + 'todo.md',避免用户在「桌面」「下载」之类位置创建文件 —
// 写文件路径会被 assertInDataDir 卡在数据目录外,事后才报错,徒增一次往返。
// 让对话框一开始就落在合法范围内,UX 上更顺。
const result = await dialog.showSaveDialog(mainWindow, {
title: '保存 Todo 文件',
defaultPath: path.join(resolveDataDir(), 'todo.md'),
filters: [{ name: 'Markdown', extensions: ['md'] }]
});
if (result.canceled) return null;
return result.filePath;
});
ipcMain.handle('file:read', async (event, filePath) => {
let absPath;
try {
absPath = assertInDataDir(filePath);
} catch (e) {
return { ok: false, error: e.message };
}
try {
const content = await fs.readFile(absPath, 'utf-8');
// 读取成功后启动文件监听,使外部修改能被检测到
// (写入路径已经在 file:write 中启动,这里补齐读取路径)
currentFilePath = absPath;
startWatchingFile(absPath);
// 关键:刷新 lastSeen 到刚读到的文件的当前 stat。
// startWatchingFile 只挂 fs.watch、不刷新基线 —— lastSeenMtimeMs/lastSeenSize
// 仍停在「上一个文件」的状态。若用户在两次会话之间打开同一目录的另一份 todo
// (例如 todo.md 与 archive.md),第一次写新文件时 preStat 与陈旧 lastSeen 比对
// 会不相等、误判 EXTERNAL_CHANGE_DETECTED、弹「外部修改」冲突对话框。
// 与 file:write / file:create-if-missing 后的 recordFileStat 对称。
recordFileStat(absPath);
return { ok: true, content };
} catch (e) {
console.error('[main] 读取文件失败:', absPath, e.message);
// 文件被外部删除时返回专用错误码,让渲染进程能给出友好提示,
// 而不是把 ENOENT 这种系统错误码直接展示给用户
if (e.code === 'ENOENT') {
return { ok: false, error: 'FILE_NOT_FOUND', message: '文件已被删除' };
}
return { ok: false, error: e.message };
}
});
ipcMain.handle('file:exists', async (event, filePath) => {
// v3.7+ 修复 H10:file:exists 必须和 file:write / file:read 同款走 assertInDataDir。
// 旧实现直接 fs.access(filePath) —— 配合一个 XSS 就能枚举整盘存在性
// (for(let p of commonList) await api.fileExists(p))。补上数据目录前缀校验后,
// renderer 只能探测数据目录内的路径,对外仍能拿到 boolean。
try {
const absPath = assertInDataDir(filePath);
await fs.access(absPath);
return true;
} catch {
return false;
}
});
ipcMain.handle('file:write', async (event, { filePath, content, options = {} }) => {
let absPath;
try {
absPath = assertInDataDir(filePath);
} catch (e) {
return { ok: false, error: e.message };
}
// v4+ 修复:内容大小上限 50 MB。正常 todo.md 远小于此值(数千条任务约几十 KB),
// 任何超过这条线的请求几乎都是异常:渲染端被 XSS / 第三方插件注入的巨型字符串、
// 调试代码误传测试 fixture、或者把别的文件路径指向 todo.md。直接拒绝避免:
// - 主进程 OOM(content 是 UTF-8 字符串,写 tmp 时被 Buffer.from 复制)
// - 把数据目录撑爆
// 比配置写入的上限(settingsStore.configSchema 有 sizeLimit)更宽松,
// 因为 todo.md 是用户真实数据;50 MB 约可容纳 5-10 万条任务,仍是合理边界。
if (typeof content !== 'string') {
return { ok: false, error: 'CONTENT_NOT_STRING' };
}
if (content.length > 50 * 1024 * 1024) {
return { ok: false, error: 'CONTENT_TOO_LARGE', message: '内容超过 50 MB 上限' };
}
try {
// 目标目录可能尚不存在(首次启动、用户新设的数据目录)
await fs.mkdir(path.dirname(absPath), { recursive: true });
// 每次保存前尝试今天的每日备份(在 .bak 单次备份之前;备份源就是磁盘上的
// 当前文件,捕获的是本次写入之前的状态 —— 即「上次保存」的内容)
await maybeBackupToday('file-write');
// 防覆盖外部修改(仅非 force 模式)—— 双重检查 + 二次确认
//
// 真实数据丢失场景:用户在软件里改任务 → 800ms 内外部编辑器保存 →
// autoSave 触发比 IPC(外部修改通知)到达更快 → autoSave 直接覆盖外部编辑。
//
// 必须在 writeFile 真正写盘之前用 mtime+size 探测:和 lastSeen 对不上就说明
// 文件被外部动过,本次拒绝写盘并返回 EXTERNAL_CHANGE_DETECTED,让渲染端走
// 外部修改冲突流程(弹对话框让用户选保留/丢弃)。force=true 跳过此检查,
// 用于用户已明确同意覆盖的场景("保留本地" 按钮 / Ctrl+S 手动保存等)。
//
// 二次确认(双 statSync)原因 —— 即使 pre-check 通过,写 tmp → rename 之间
// 还有一个窗口(通常是 10-100ms,IO 期间)可能被外部编辑器插入:
// pre-check 时 mtime = T1(外部编辑前)
// ← 这之间外部编辑器保存 → 文件 mtime 变成 T2
// rename 写入 → 静默覆盖外部编辑(数据丢失)
// 第二次 statSync 在 rename 前再次比对 pre-check 时的快照,能把这个窗口缩到
// 几乎不可见(< 1ms)。fs.rename 在同文件系统内是原子的,剩余窗口外部编辑要么
// 完全看不见(被覆盖)要么完全可见(rename 后下次轮询检测到)—— 不会再有半新半旧。
// 通用 pre-stat 检查:捕获「外部编辑器保存 vs 我们的写入」race window
//
// force=false 的额外语义 — 与 lastSeenMtimeMs/lastSeenSize 比对:fs.watch / 轮询
// 已经更新过 lastSeen,任何 mtime/size 不一致说明文件已被外部动过,本次拒绝
// 写入并走冲突对话框。
//
// force=true 跳过 lastSeen 比对 —— 用户已经明确表态「覆盖外部修改」,但
// **仍**做 preStat 抓取(写完后做 confirm-stat 比对),捕获 race window 内
// 出现的**新一轮**外部修改 —— 用户按"保留本地"瞬间外部又改了一次 → 拒绝并
// 走冲突对话框,而不是静默覆盖。一致性优先。
const exists = fsSync.existsSync(absPath);
let preStat = null;
if (exists) {
try {
preStat = await fs.stat(absPath);
} catch (e) {
// statSync 失败(文件锁 / 权限)—— 宁可放过一次(保持旧行为)也不要阻塞保存
console.warn('[main] statSync 失败,跳过冲突检查:', e.message);
}
}
// 严格 === true:渲染端传 options.force = "false"(字符串)会被 truthy
// 检查放过,等于「保留本地」语义失效 —— 用户的外部修改会被悄悄覆盖。
// 强类型校验确保只有 boolean true 才能跳过外部修改检测。
if (options.force !== true && preStat && (preStat.mtimeMs !== lastSeenMtimeMs || preStat.size !== lastSeenSize)) {
console.warn(`[main] 拒绝写入:检测到外部修改 ${absPath}`);
// 增强载荷:把磁盘当前内容一并塞回去,让渲染端不用再发一次 file:read
// 就能走冲突对话框(v3.4+ 用户期望的体验)。
const diskContent = await readCurrentDiskContent(absPath);
return { ok: false, error: 'EXTERNAL_CHANGE_DETECTED', diskContent };
}
// .bak 轮转:把当前 .bak 降级为 .bak.1,再把磁盘当前文件复制为 .bak。
// 在 race 检测通过之后执行 —— 万一二次确认发现 race,.bak 仍是外部编辑前的内容,
// 回退效果与「上上次保存」相当(用户至多丢一份 autoSave 中间态,不是静默丢外部编辑)。
// 不轮转的话 .bak 文件会无限膨胀(每次写一份,几千次写盘后磁盘占用难承受)。
// 首次写入(文件不存在)跳过轮转 —— 没有「磁盘当前文件」可复制。
if (exists) {
await rotateBakFiles(absPath);
}
// 写 tmp(异步期间磁盘原文件不动,可被外部读取 / stat)
// 命名:Date.now + pid + 随机串 —— 单纯 pid 后缀**不够**(同进程内 pid 恒定,
// 800ms 防抖窗口内连按 Ctrl+S 可能落在同一毫秒 → tmp 同名 → 后写覆盖先写 tmp →
// rename 失败 → 数据丢失)。再追加 6 字符随机串,碰撞 ≈ 36^6 = 21 亿。
// force 与 !force 共用此约定。
const tmpPath = absPath + '.tmp.' + Date.now() + '_' + process.pid + '_' + Math.random().toString(36).slice(2, 8);
let tmpWritten = false;
try {
await fs.writeFile(tmpPath, content, 'utf-8');
tmpWritten = true;
// 二次确认:检查 pre-check 期间到现在,磁盘文件是否被外部动过
// force 与 !force 都做 —— 这是 race window 防护的核心,把覆盖窗口缩到
// 几乎不可见(< 1ms)。fs.rename 在同文件系统内是原子的,剩余窗口外部编辑
// 要么完全看不见(被覆盖)要么完全可见(rename 后下次轮询检测到)。
if (preStat) {
try {
const confirmStat = await fs.stat(absPath);
if (confirmStat.mtimeMs !== preStat.mtimeMs || confirmStat.size !== preStat.size) {
// race window 内被外部改了 —— 拒绝这次写入
await fs.unlink(tmpPath).catch(() => {});
console.warn(`[main] 拒绝写入:检测到 race window 内的外部修改 ${absPath}`);
const diskContent = await readCurrentDiskContent(absPath);
return { ok: false, error: 'EXTERNAL_CHANGE_DETECTED', diskContent };
}
} catch (e) {
// 二次 stat 失败(极端情况:文件被外部删除)—— 视为外部修改拒绝
await fs.unlink(tmpPath).catch(() => {});
console.warn(`[main] 二次 statSync 失败,按外部修改处理:`, e.message);
const diskContent = await readCurrentDiskContent(absPath);
return { ok: false, error: 'EXTERNAL_CHANGE_DETECTED', diskContent };
}
}
// 原子 rename:同文件系统下原子替换,外部编辑要么完全丢失(已被替换)
// 要么完全可见(rename 之后发生,下次轮询 / fs.watch 检测到)。
await fs.rename(tmpPath, absPath);
} catch (e) {
// rename 失败(AV / 索引器锁、EPERM、EXDEV 等)会留下 .tmp 残骸,
// 每次写入都堆一份,几年下来把数据目录撑爆。这里无论成功失败都尝试清掉。
if (tmpWritten) await fs.unlink(tmpPath).catch(() => {});
throw e;
}
currentFilePath = absPath;
startWatchingFile(absPath);
// 主动写入完成:刷新 polling 基线,让 250ms 轮询跳过这次变化
// (fs.watch 因 v4 注释里描述的时序问题可能来不及反应)
recordFileStat(absPath);
return { ok: true };
} catch (e) {
console.error('[main] 写入文件失败:', absPath, e.message);
return { ok: false, error: e.message };
}
});
/**
* 仅在文件不存在时创建(用于首次启动写出初始文档)。
* 使用 'wx' 标志:存在性检查与创建由内核原子完成,
* 不存在"先检查再写入"的竞态窗口,绝不会覆盖已有用户数据。
*/
ipcMain.handle('file:create-if-missing', async (event, { filePath, content }) => {
let absPath;
try {
absPath = assertInDataDir(filePath);
} catch (e) {
return { ok: false, error: e.message };
}
// 与 file:write 同样的内容上限兜底 —— 防御性,防止恶意 / 异常渲染端
// 绕过 file:write 的 50 MB 检查直接打 create-if-missing 把数据目录撑爆。
if (typeof content !== 'string') {
return { ok: false, error: 'CONTENT_NOT_STRING' };
}
if (content.length > 50 * 1024 * 1024) {
return { ok: false, error: 'CONTENT_TOO_LARGE', message: '内容超过 50 MB 上限' };
}
try {
await fs.mkdir(path.dirname(absPath), { recursive: true });
await fs.writeFile(absPath, content, { encoding: 'utf-8', flag: 'wx' });
// 主进程层不做抑制 —— 渲染端 readFile 后与 serialize() 比对判断是否自己写入
currentFilePath = absPath;
startWatchingFile(absPath);
recordFileStat(absPath);
return { ok: true };
} catch (e) {
if (e.code === 'EEXIST') {
console.log('[main] 文件已存在,跳过创建:', absPath);
return { ok: false, exists: true };
}
console.error('[main] 创建文件失败:', absPath, e.message);
return { ok: false, error: e.message };
}
});
// 文件监听
// fs.watch 在 Windows 上对一次保存可能触发 1-3 个事件(修改 + 重命名 + 再次修改),
// 不去重的话渲染端会连续弹「外部修改」对话框 —— 用户切回应用看到的是第一个
// 还没确认的对话框,第二第三个再叠上去,按钮位置错位且语义完全重复。
// 这里在源头做 400ms 去重,保证「一次外部保存 = 一次提示」。
let lastExternalChangeSentAt = 0;
const EXTERNAL_CHANGE_DEDUP_MS = 400;
// === 250ms 轮询兜底:fs.watch 在云同步目录 / 网络盘 / 某些编辑器下不可靠 ===
//
// 用户的真实反馈:修改 todo.md 后软件没及时更新 —— 说明 fs.watch 没触发。
// fs.watch 依赖 OS 文件通知 API,Dropbox / OneDrive / 远程盘映射 / 某些
// 编辑器的非原子保存路径下会丢事件。250ms 一次的 statSync 是普适可靠兜底:
// - 比 500ms 体感响应快一倍(云盘场景下 fs.watch 失效时仍有可接受延迟)
// - 每秒 4 次 statSync 对一个静态文件的开销可忽略(mtime+size 比对,不读内容)
//
// 自写入抑制不归这里管:polling 检测到变化就发 IPC,渲染端 readFile 后与
// store.serialize() 比对,字节相同视为自己写入的产物静默(v4 方案继续兜底)。
// 这里只做:(1) 不在主进程层做过滤;(2) 主动写入后刷新基线让 polling 跳过那次变化。
let lastSeenMtimeMs = 0;
let lastSeenSize = 0;
let pollTimer = null;
const POLL_INTERVAL_MS = 250;
function recordFileStat(filePath) {
// 主进程刚写完文件后调用:让 polling 下一次 tick 看到 (mtime,size) 与
// lastSeen 一致,不再误触发「外部修改」。fs.watch 因 v4 注释里的时序
// 问题可能根本来不及响应,polling 的基线刷新是最可靠的兜底。
try {
const st = fsSync.statSync(filePath);
lastSeenMtimeMs = st.mtimeMs;
lastSeenSize = st.size;
} catch {
/* 文件尚未落盘 / 已被外部删 —— 忽略,polling 下一轮会自己探测 */
}
}
function startPolling(filePath) {
stopPolling();
// 初始化基线,避免刚启动时把「上次会话之前的 mtime」当成「外部修改」
recordFileStat(filePath);
pollTimer = setInterval(() => {
if (!currentFilePath) return;
// v4+ 修复:窗口被最小化 / 隐藏时跳过 statSync。
// 旧实现每 250ms 一次无脑 statSync —— 用户切到别的窗口、托盘里挂着、本应用最小化,
// CPU 仍每秒钟 4 次磁盘 IO。窗口回到前台时本轮 polling 自然会发现 mtime/size
// 变化(基线没刷新),与之前行为一致。
// 兜底:窗口刚销毁(app 退出流程)也跳过,否则 BrowserWindow 已死、
// isVisible() 会抛。
if (mainWindow?.isDestroyed?.()) return;
if (mainWindow && (mainWindow.isMinimized() || !mainWindow.isVisible())) return;
let st;
try {
st = fsSync.statSync(currentFilePath);
} catch {
// 文件被外部删除 / 重命名等场景 —— 把基线清零让下一次 tick 把「文件被重建」
// 视为外部修改。
//
// 为什么不能直接 return + 保留旧 lastSeen:
// 若文件随后被外部以**同样的 mtime + size** 重建(delete + write 完全相同
// 内容、文件系统沿用原 inode),下一轮 statSync 会拿到与 lastSeen 一致的结果
// → polling 会以「和基线一致」为由漏报。这种 case 在云同步目录偶有发生
// (客户端做去重优化时整文件删除 + 重建)。
lastSeenMtimeMs = 0;
lastSeenSize = 0;
return;
}
if (st.mtimeMs === lastSeenMtimeMs && st.size === lastSeenSize) return;
lastSeenMtimeMs = st.mtimeMs;
lastSeenSize = st.size;
// 与 fs.watch 共用 dedup 窗口,避免 fs.watch 已在 400ms 内发过则重复 IPC
const now = Date.now();
if (now - lastExternalChangeSentAt < EXTERNAL_CHANGE_DEDUP_MS) return;
lastExternalChangeSentAt = now;
// 增强载荷:把磁盘当前内容一并塞回去(v3.4+ 用户期望 —— 渲染端不用再发 file:read)。
// setInterval 回调是 sync 函数,用 fire-and-forget 异步读取避免阻塞 polling。
// 修复:在调度异步读取前捕获当前路径,避免 .then() 执行时 currentFilePath
// 已被 startWatchingFile 切换到新路径,导致路径与内容错配。
const capturedPath = currentFilePath;
readCurrentDiskContent(capturedPath).then((diskContent) => {
mainWindow?.webContents.send('file:external-change', {
filePath: capturedPath,
diskContent,
});
});
}, POLL_INTERVAL_MS);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function startWatchingFile(filePath) {
stopWatchingFile();
try {
fileWatcher = fsSync.watch(filePath, (eventType) => {
// 同时响应 'change'(内容修改)和 'rename'(重命名 / 原子替换)。
// 很多外部编辑器(VSCode、Sublime 等)保存时采用「写到临时文件再 rename」,
// 在 Windows 上 fs.watch 通常只会触发 'rename'。旧实现只认 'change',
// 导致这些编辑器的保存永远唤不醒「外部修改」提示 —— 用户切回应用看到的
// 还是旧内容,必须手动重载。
if (eventType !== 'change' && eventType !== 'rename') return;
// 关键:触发时立刻 statSync 把 lastSeen 刷新到「当前磁盘状态」。
// 这一步是 race 修复的核心:
// 旧实现:fs.watch 只发 IPC,不更新 lastSeen。polling 在 250ms 后才
// 检测到同样的变化,期间 force=false 的 autoSave 拿陈旧的 lastSeen
// 做 mtime 检查,与 statSync 拿到的「外部编辑后」状态对比仍是「一致」——
// 静默覆盖外部编辑。
// 现在:fs.watch 触发时立刻 statSync 拿到新状态,更新 lastSeen。
// 若 autoSave 的 statSync 在此之后跑到,对比「新 lastSeen」与「新磁盘
// 状态」也一致 —— 但后续 IPC 已发给 renderer 让用户介入,所以即使
// force=false 的写入跑通,也是「renderer 已知外部修改、用户尚未回复」
// 的窗口期(< 100ms),数据丢失窗口被压缩到几乎不可见。
//
// 自写入场景无副作用:自己写入后 recordFileStat 已经把 lastSeen 刷成
// 「自己刚写入」的状态,fs.watch 在 rename 时再次触发 → 再 statSync
// 仍是同一份(rename 之后的磁盘状态)→ lastSeen 不变 → IPC 走 content 比对静默。
try {
const st = fsSync.statSync(filePath);
lastSeenMtimeMs = st.mtimeMs;
lastSeenSize = st.size;
} catch {
// 文件被外部删除 / 锁住 —— 立刻清零基线,避免「文件被同 mtime+size 重建」
// 时漏报(同 startPolling 的处理)。下次 polling tick 会再探测。
lastSeenMtimeMs = 0;
lastSeenSize = 0;
}
// 不做自写入抑制 —— 见文件顶部三代方案的说明,fs.watch 时序无法支撑。
// 一次外部保存可能在 fs.watch 里触发多个事件(Windows 尤其明显),
// 在源头做去重避免渲染端堆叠对话框 / IPC 风暴。
const now = Date.now();
if (now - lastExternalChangeSentAt < EXTERNAL_CHANGE_DEDUP_MS) return;
lastExternalChangeSentAt = now;
// 增强载荷:把磁盘当前内容一并塞回去(v3.4+ 用户期望 —— 渲染端不用再发 file:read)。
// fs.watch 的 listener 是 sync 函数,用 fire-and-forget 异步读取避免阻塞 watch 调度。
readCurrentDiskContent(filePath).then((diskContent) => {
mainWindow?.webContents.send('file:external-change', {
filePath,
diskContent,
});
});
});
// v3.7+ 修复 M-P2:fs.watch 在 Windows 上遇到 EPERM / ENOENT 后会直接 close
// (不是再次触发事件)。整个 fileWatcher 实例变 dead,后续即使文件被
// 重新创建,watcher 也不会重新监听 → 只剩 250ms polling 兜底,丢失实时性。
// 修复:监听 'error' 事件,记录并重建 watcher。重建失败时 polling 仍可兜底。
try {
fileWatcher.on('error', (err) => {
console.warn('[main] fs.watch error, 重建 watcher:', err && err.message);
const cachedPath = filePath;
try { fileWatcher && fileWatcher.close(); } catch {}
fileWatcher = null;
// 异步重建,避免在 'error' 回调里同步调用 fs.watch 引发二次报错
setImmediate(() => startWatchingFile(cachedPath));
});
} catch {
// 极个别 Node 版本 fileWatcher.on 自身抛错(极罕见)—— 静默兜底
}
} catch (e) {
console.warn('[main] 文件监听启动失败:', e.message);
}
// fs.watch 在云同步目录 / 网络盘 / 某些编辑器下不触发事件 —— 250ms 轮询兜底
startPolling(filePath);
}
function stopWatchingFile() {
if (fileWatcher) {
try { fileWatcher.close(); } catch {}
fileWatcher = null;
}
stopPolling();
}
// ============================================
// IPC: 应用信息
// ============================================
ipcMain.handle('app:get-data-dir', () => {
return resolveDataDir();
});
ipcMain.handle('app:get-default-data-dir', () => DEFAULT_DATA_DIR);
// 用户主目录:renderer 用来把绝对路径转成 `~/...` 显示形式。
// 不暴露整个 fs,只读 home —— renderer 自己用 process.platform 检测不到
// contextIsolation 下的用户目录。
ipcMain.handle('app:get-home-dir', () => {
try {
return app.getPath('home');
} catch {
return require('os').homedir();
}
});
ipcMain.handle('app:get-version', () => app.getVersion());
ipcMain.handle('app:get-electron-version', () => process.versions.electron || null);
// 打开外部链接 —— 由渲染端通过设置面板的「关于」区触发。
// 必须经过主进程 + shell.openExternal:renderer 在 sandbox 下无权直接调 shell,
// 而且走 https/http(s) 协议白名单能挡住把 `file:` / `javascript:` 之类的危险协议
// 塞进 href 后再回传给主进程的钓鱼路径。
ipcMain.handle('app:open-external', async (_event, url) => {
if (typeof url !== 'string' || !url) return { ok: false, error: '链接无效' };
let parsed;
try {
parsed = new URL(url);
} catch {
return { ok: false, error: '链接格式不正确' };
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { ok: false, error: '仅支持 http / https 链接' };
}
try {
await shell.openExternal(url);
return { ok: true };
} catch (e) {
return { ok: false, error: e.message };
}
});
ipcMain.handle('app:open-data-dir', async () => {
// 只允许打开真实存在且是目录的路径。防止渲染端把恶意字符串塞进
// dataDir 设置后让 shell.openPath 跑去执行一个 .exe。
let dir;
try {
dir = path.resolve(resolveDataDir());
const st = await fs.stat(dir);
if (!st.isDirectory()) {
return { ok: false, error: '数据路径不是一个目录' };
}
} catch (e) {
return { ok: false, error: e.message };
}
try {
// shell.openPath 返回 Promise:成功时 resolve 空字符串,失败时 resolve
// 错误描述字符串(注意不是 reject)。必须 await 并检查返回值,否则错误会被
// 静默吞掉,IPC 永远返回 { ok: true },用户看到按钮"点了没反应"却无从排查。
const err = await shell.openPath(dir);
if (err) {
return { ok: false, error: err, path: dir };
}
return { ok: true, path: dir };
} catch (e) {
return { ok: false, error: e.message };
}
});
// ============================================
// IPC: 选择文件夹对话框
// ============================================
ipcMain.handle('app:choose-data-dir', async () => {
if (!mainWindow) return null;
const result = await dialog.showOpenDialog(mainWindow, {
title: '选择数据文件夹',
properties: ['openDirectory', 'createDirectory'],
buttonLabel: '选择此文件夹',
});
if (result.canceled || !result.filePaths.length) return null;
return result.filePaths[0];
});
// ============================================
// IPC: 窗口控制
// ============================================
// 设置窗口是否始终置顶。
// renderer 主动调 —— 启动时根据 settingsStore.alwaysOnTop 应用一次,
// 用户点击工具栏按钮时再 toggle。持久化由 app:save-settings 走。
ipcMain.handle('window:set-always-on-top', (_event, enabled) => {
if (!mainWindow || mainWindow.isDestroyed()) return false;
// BrowserWindow.setAlwaysOnTop 接受布尔;非法值直接抛 TypeError。
// 严格校验:仅 boolean 透传,字符串 / 数字 / null 等一律拒掉。
if (typeof enabled !== 'boolean') {
console.warn('[main] setAlwaysOnTop 收到非布尔值:', enabled);
return false;
}
try {
mainWindow.setAlwaysOnTop(enabled);
return true;
} catch (e) {
console.error('[main] setAlwaysOnTop 失败:', e.message);
return false;
}
});
// 自定义窗口控制按钮(frame: false 后接管系统标题栏的最小化/还原/关闭)。
// 注意:close 必须走与 close 事件一致的"隐藏到托盘"逻辑 —— 直接 mainWindow.close()
// 会被 mainWindow.on('close') 拦下隐藏,所以这里发信号走同一条路:
// 1) 主动退出(isQuitting=true)→ close 不拦截,正常销毁
// 2) 默认(isQuitting=false)→ close 拦截 + hideWindow 隐藏到托盘
// 与 X 按钮语义对齐:"最小化"是任务栏可见但前台不可见,"关闭"是托盘后台驻留。
ipcMain.handle('window:minimize', () => {
if (!mainWindow || mainWindow.isDestroyed()) return false;
mainWindow.minimize();
return true;
});
// 最大化/还原二合一按钮:根据当前状态切换,返回新的 isMaximized 给 renderer 同步图标。
// 单独传 maximize / restore 会让 renderer 必须跟踪状态,状态机两边各持一份容易漂移。
// 这里统一在主进程判断、返回最新值,renderer 收到后只负责把图标刷成返回值对应的样子。
ipcMain.handle('window:toggle-maximize', () => {
if (!mainWindow || mainWindow.isDestroyed()) return false;
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
return mainWindow.isMaximized();
});
// 关闭按钮:与窗口右上角 X 同义 —— 默认隐藏到托盘(与 mainWindow.on('close') 一致),
// 仅在用户主动退出(isQuitting=true,托盘菜单/快捷键)时才会真正销毁。
// 实现上不需要单独设 isQuitting:调用 mainWindow.close() 会被 close handler 拦下走托盘分支。
ipcMain.handle('window:close', () => {
if (!mainWindow || mainWindow.isDestroyed()) return false;
mainWindow.close();
return true;
});
// 把当前窗口的 isMaximized 状态推给 renderer(用于启动时按钮图标对齐,以及
// 系统级最大化/还原时同步 UI —— 例如用户双击标题栏或 Win+Up 触发的最大化)。
// 不走 invoke 是因为这是单向广播,且在 createWindow 之后就要推一次。
function broadcastMaximizeState() {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('window:maximize-state', mainWindow.isMaximized());
}
// ============================================
// IPC: 应用设置(持久化)
// ============================================
ipcMain.handle('app:get-settings', () => appConfig);
// 渲染进程 dirty 状态变化时通知主进程,用于退出前判断是否需要刷写保存。
// 同时在 dirty 重新变为 true 时重置 saveFlushed,避免「退出 → 保存 → 取消 → 再改 → 再退出」
// 场景下第二次退出跳过保存检查。
ipcMain.on('app:dirty-changed', (_event, isDirty) => {
// 防御:renderer 发 null / 0 / "" / undefined 进来时,typeof !== 'boolean' 直接拒掉。
// 否则 rendererDirty 会变成 falsy → before-quit 看到 rendererDirty && ... 判定为 false
// → 跳过刷写 → 直接退出 → 防抖窗口内未落盘的修改永久丢失。
if (typeof isDirty !== 'boolean') {
console.warn('[main] app:dirty-changed 收到非 boolean 值:', typeof isDirty, isDirty);
return;
}
rendererDirty = isDirty;
// v3.7+ 修复 M-P1:flushInProgress 期间不重置 saveFlushed。
// 旧行为是 isDirty=true 无条件 saveFlushed=false —— 如果用户在 before-quit
// 触发的 flush 期间(flushInProgress=true)又敲键盘,dirty-changed=true 会把
// saveFlushed 提前重置;其后 flush-done 回调第一段由于 !rendererDirty 不成立
// 进入再 flush 分支 → saveFlushed 又被重置 → app.quit() 5s timeout 兜底放过,
// 用户最后敲的内容可能落盘失败。
// 修复:flushInProgress 期间收到的 dirty=true 不重置 saveFlushed,等 flush-done
// 回调内统一处理(flush-done 自己会重核 rendererDirty 并决定是否再 flush)。
if (isDirty && !flushInProgress) saveFlushed = false;
});
// 渲染进程完成刷写保存后通知主进程,立即放行退出(无需等待 5 秒超时)。
// 必须守卫 flushInProgress:渲染进程在 before-quit 之外任何时刻发的 flush-done
// 都不应触发 app.quit(),否则一旦这条 IPC 失序到达,整个应用会被无条件退出,
// 且把 saveFlushed 提前设上让下一次真正退出跳过刷写检查(数据丢失)。
//
// 兜底:渲染端在 await saveNow 期间又改东西(rendererDirty 在我们处理完 flush-done
// 后才上报),单次 saveNow 只写入了旧 snapshot,dirty-changed 会把 saveFlushed 重置
// 但 quit 已经发生 —— 新编辑凭空消失。处理这条 IPC 时再核一次 rendererDirty,
// 仍脏就再起一轮 flush。两个通道(dirty-changed / flush-done)独立排队、不保证
// 到达顺序,所以「flush-done 收到时 dirty 仍为 true」才是真相 —— 渲染端在
// onFlushPendingSave 里的多轮 saveNow 已经把这条窗口压缩到最小。
ipcMain.on('app:flush-done', () => {
if (!flushInProgress) return;
flushInProgress = false;
if (flushTimeoutHandle) {
clearTimeout(flushTimeoutHandle);
flushTimeoutHandle = null;
}
if (rendererDirty && mainWindow && !mainWindow.isDestroyed()) {
// 渲染端仍脏 —— 再 flush 一次,不 quit
saveFlushed = false;
flushInProgress = true;
mainWindow.webContents.send('app:flush-pending-save');
flushTimeoutHandle = setTimeout(() => {
flushTimeoutHandle = null;
saveFlushed = true;
flushInProgress = false;
app.quit();
}, 5000);
return;
}
saveFlushed = true;
app.quit();
});
ipcMain.handle('app:save-settings', async (event, partial) => {
// 防御:渲染端发了 null / undefined / 原始类型进来时,'dataDir' in partial 会抛
// TypeError 并把主进程栈泄给 renderer;白名单遍历也都会崩。直接当无效请求拒掉。
if (!partial || typeof partial !== 'object') return appConfig;
// 仅持久化已知的设置项,并对每项做白名单校验,防止 renderer 写入恶意值
const sanitized = {};
if ('dataDir' in partial) {
// null = 恢复默认;非空字符串 = 用户自定义目录;其它一律忽略
if (partial.dataDir === null) {
sanitized.dataDir = null;
// 数据目录改变 → 旧目录的「今天的备份」对新目录毫无意义。
// 强制下次操作重新触发备份,让新目录也能享受今天的快照。
// (主进程自己写 lastBackupDate 走 saveConfig,不走 IPC 白名单 —— 这里一并清掉)
sanitized.lastBackupDate = null;
} else if (typeof partial.dataDir === 'string') {
// 防御:dataDir 是 assertInDataDir 的信任根 —— 渲染端把它设为 '/etc',
// 后续 file:read('/etc/passwd') 就能逃出"用户数据目录"沙箱。强制要求:
// - 绝对路径(避免相对路径在不同 cwd 下绕过)
// - path.normalize 后没有 .. 残留(path.resolve 已处理;这里再防御一道)
// - 真实存在的目录(renderer 选了不存在的目录会立即暴露,UX 上也不该静默接受)
// 正常路径:renderer 走 chooseDataDir IPC(native dialog),那里必然存在且绝对。
// 这层校验只挡被入侵的 renderer(XSS 注入恶意 settings 调用)—— 不挡正常用户流。
//
// v4+ 修复:旧实现是「校验失败 → console.warn 静默吞掉」,于是 renderer 的
// settingsStore.update() 看不到失败(IPC 返回 appConfig 旧值、Promise resolve),
// 用户在 settings dialog 里选了目录、看到「✓ 已保存」、磁盘却是旧值,重启后回到旧
// 目录才会察觉。现在改为:校验失败直接抛错,让 IPC reject → settingsStore._persist
// 返回 false → emit 'save-error' → toolbar.js 的 save-error 监听器 toast 报错。
//
// 安全审计 H1 修复:增加允许根目录白名单。即便 renderer 被攻陷也最多写到
// 用户已知位置,不会被重定向到 /etc、/var/lib 之类的系统目录。
// 白名单:用户主目录、文档、下载、桌面、AppData。允许子目录(含一层新建的)。
const allowedRoots = [];
try { allowedRoots.push(app.getPath('home')); } catch {}
try { allowedRoots.push(app.getPath('documents')); } catch {}
try { allowedRoots.push(app.getPath('downloads')); } catch {}
try { allowedRoots.push(app.getPath('desktop')); } catch {}
try { allowedRoots.push(app.getPath('userData')); } catch {}
try { allowedRoots.push(app.getPath('appData')); } catch {}
const resolved = path.resolve(partial.dataDir);
if (resolved !== path.normalize(resolved)) {
throw new Error(`数据目录路径不安全:${partial.dataDir}`);
}
// 解析符号链接后再次校验 —— 防止用 symlink 间接绕过白名单。
// realpathSync 失败(路径不存在)由下面的 existsSync 兜底。
let realResolved = resolved;
try { realResolved = fsSync.realpathSync(resolved); } catch {}
const inAllowedRoot = allowedRoots.some(root => {
const r = root.toLowerCase();
const p = realResolved.toLowerCase();
return p === r || p.startsWith(r + path.sep);
});
if (!inAllowedRoot) {
throw new Error(`数据目录必须在用户已知目录内:${resolved}`);
}
if (!fsSync.existsSync(resolved) || !fsSync.statSync(resolved).isDirectory()) {
throw new Error(`数据目录不存在或不是文件夹:${resolved}`);
}
sanitized.dataDir = resolved;
sanitized.lastBackupDate = null;
}
}
if ('theme' in partial && ['dark', 'light', 'auto'].includes(partial.theme)) {
sanitized.theme = partial.theme;
}
if ('filter' in partial && ['all', 'current', 'completed', 'important'].includes(partial.filter)) {
// v3.7+ 修复:旧白名单用的是「pending」(v3.x 之前 PENDING 语义)。
// 现在 settings-store.js 的 VALID_VALUES 是 ['all', 'current', 'completed', 'important']
// (v3.x 起 PENDING 被替换为 CURRENT,「当前」语义对齐智能列表)。
// 旧白名单下 renderer 发 'current' 会被这里**静默丢弃**——用户的「当前任务」
// 全局过滤偏好重启就回到默认。修齐白名单。
sanitized.filter = partial.filter;
}
if ('sortBy' in partial && ['manual', 'alphabet'].includes(partial.sortBy)) {
sanitized.sortBy = partial.sortBy;
}
if ('fontSize' in partial && ['small', 'medium', 'large'].includes(partial.fontSize)) {
// 之前白名单漏了 fontSize:SettingsStore 内存里写、UI 也立刻应用,
// 但主进程落盘时把它静默丢掉了 —— 重启后字号回到 medium。补上。
sanitized.fontSize = partial.fontSize;
}
if ('colorStyle' in partial && ['indigo', 'ocean', 'forest', 'sunset', 'rose', 'mono'].includes(partial.colorStyle)) {
// 配色风格:与 theme 正交。同样以前漏过,UI 选了 ocean 重启后回到 indigo。
sanitized.colorStyle = partial.colorStyle;
}
if ('backupEnabled' in partial && typeof partial.backupEnabled === 'boolean') {
// 每日备份开关:renderer 通过 settingsStore.update 写入,主进程只接收 boolean
sanitized.backupEnabled = partial.backupEnabled;
}
// 六个 *Position 设置(任务插入位置偏好):front | back。
// 与 settings-store.js 的 VALID_VALUES 对齐 —— 渲染端已校验过,主进程再守一道
// 防止配置文件被直接编辑成脏值。白名单缺失会导致「UI 选了生效、重启回默认」。
for (const key of ['newTaskPosition', 'completedPosition', 'uncompletePosition', 'trashPosition', 'restorePosition', 'movePosition']) {
if (key in partial && (partial[key] === 'front' || partial[key] === 'back')) {
sanitized[key] = partial[key];
}
}
// 导入顺序偏好:order | front(与五个 front/back 不同,不在此循环处理)
if ('importPosition' in partial && (partial.importPosition === 'order' || partial.importPosition === 'front')) {
sanitized.importPosition = partial.importPosition;
}
if ('alwaysOnTop' in partial && typeof partial.alwaysOnTop === 'boolean') {
// 始终置顶开关:与 BrowserWindow.setAlwaysOnTop 同步。
// 注意此 IPC 仅落盘,窗口状态由 renderer 在 onStartup 时主动调 setAlwaysOnTop 应用。
const valueChanged = appConfig.alwaysOnTop !== partial.alwaysOnTop;
sanitized.alwaysOnTop = partial.alwaysOnTop;
// v4+ 修复:先 saveConfig 让磁盘先落地,再应用窗口副作用。
// 旧顺序下 saveConfig 失败时(磁盘满 / 权限不足),托盘菜单 / 窗口 setAlwaysOnTop
// 已经按新值执行 → 用户看到托盘菜单「已勾选」+ 窗口置顶,但磁盘仍是旧值;
// 下次启动 appConfig 加载旧值 → 托盘/窗口都回滚、用户一脸懵。
// 修正顺序:saveConfig 失败则不进入副作用分支,菜单 / 窗口与磁盘三方一致。
const committed = await saveConfig(sanitized);
if (valueChanged) {
// 仅在真变化时调 setAlwaysOnTop + 重建托盘菜单。
// 反复保存相同值会无谓触发窗口属性重设(可能引发平台层闪烁)和
// Tray.setContextMenu(Windows 上偶尔有可见菜单项重绘抖动)。
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.setAlwaysOnTop(committed.alwaysOnTop);
}
if (tray) {
tray.setContextMenu(buildTrayMenu());
}
}
return committed;
}
return await saveConfig(sanitized);
});
// ============================================
// 应用菜单
// ============================================
function buildMenu() {
const isMac = process.platform === 'darwin';
const template = [
...(isMac ? [{
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'quit' }
]
}] : []),
{
label: '文件',
submenu: [
{
label: '保存',
accelerator: 'CmdOrCtrl+S',
click: () => mainWindow?.webContents.send('menu:save')
},
{ type: 'separator' },
isMac ? { role: 'close' } : { role: 'quit' }
]
},
{
label: '编辑',
submenu: [
{ role: 'undo', label: '撤销' },
{ role: 'redo', label: '重做' },
{ type: 'separator' },
{ role: 'cut', label: '剪切' },
{ role: 'copy', label: '复制' },
{ role: 'paste', label: '粘贴' },
{ role: 'selectAll', label: '全选' }
]
},
{
label: '视图',
submenu: [
{
label: '切换主题',
accelerator: 'CmdOrCtrl+Shift+T',
click: () => mainWindow?.webContents.send('menu:toggle-theme')
},
{ type: 'separator' },
{
label: '设置...',
accelerator: 'CmdOrCtrl+,',
click: () => mainWindow?.webContents.send('menu:settings')
},
{ type: 'separator' },
{
label: '重新加载',
accelerator: 'CmdOrCtrl+R',
click: () => mainWindow?.webContents.send('menu:reload')
},
{ type: 'separator' },
{
label: '开发者工具',
accelerator: 'F12',
click: () => mainWindow?.webContents.toggleDevTools()
}
]
}
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
}