Files
Todo-List/main.js
2026-09-12 13:55:57 +08:00

1780 lines
82 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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(`
<html><body style="font-family:sans-serif;padding:30px;background:#fee;color:#900;">
<h2>⚠ 启动失败</h2>
<p><b>问题:</b>项目路径包含非 ASCII 字符(如中文),导致 Electron 无法正确加载 API。</p>
<p><b>当前路径:</b><br><code>${__dirname}</code></p>
<p><b>解决方法:</b>将项目移动到 ASCII 路径(如 <code>D:\\Projects\\todo-list</code>)。</p>
<p style="margin-top:30px;color:#666;font-size:12px;">详细说明请查看 README。</p>
</body></html>
`));
});
// 不退出,让用户看到错误窗口
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
*
* 跨平台一律走 homeWindows 上就是 %USERPROFILE%/TodoListmacOS / 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 === undefinedtruthy 检查
* 把它当成"禁用"结果新用户的每日备份机制永远不触发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 不写回 appConfiglastBackupDate 保持原值,
// 下次 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—— 首次操作;只要还没备份过今天就被吸收
//
// 「每天一次」用 lastBackupDateYYYY-MM-DD本地日期做幂等
// - 今天 == lastBackupDate → return
// - 不等 → 复制 <dataDir>/todo.md → <dataDir>/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;
// 严格 === falseloadConfig 缺失字段时用 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 时已加载appConfigcreateWindow 是 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-quitflush-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-snapshotlabel=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+ 修复 H10file: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。直接拒绝避免
// - 主进程 OOMcontent 是 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-100msIO 期间)可能被外部编辑器插入:
// 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 文件通知 APIDropbox / 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-P2fs.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.openExternalrenderer 在 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<string>:成功时 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-P1flushInProgress 期间不重置 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 只写入了旧 snapshotdirty-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 IPCnative dialog那里必然存在且绝对。
// 这层校验只挡被入侵的 rendererXSS 注入恶意 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)) {
// 之前白名单漏了 fontSizeSettingsStore 内存里写、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.setContextMenuWindows 上偶尔有可见菜单项重绘抖动)。
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));
}