// 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)); }