// 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 { // 某些环境下 setDefaultEncoding 不可用,忽略 } } // GUI 启动场景下(双击图标 / 启动器退出后),主进程的 stdout/stderr // 没有真实终端可写,console.log 在 flush 时会触发 EPIPE: broken pipe, // 进而被 Electron 弹成「A JavaScript error occurred in the main process」。 // 给底层流挂 error 监听,把 EPIPE / ENOTCONN / EBADF 静默吞掉, // console.log 仍按原样输出,只是不再让异常冒到顶层炸窗。 try { for (const stream of [process.stdout, process.stderr]) { if (stream && typeof stream.on === 'function') { stream.on('error', (err) => { if (err && (err.code === 'EPIPE' || err.code === 'ENOTCONN' || err.code === 'EBADF')) { return; // 静默忽略:没有终端可写是正常的 } // 其他错误写到 tmp 文件,避免被 Electron 弹成主进程错误框。 // P2-12:fire-and-forget 异步写,不走 appendFileSync 阻塞 main。 try { require('fs').promises.appendFile( require('path').join(require('os').tmpdir(), 'notes-main-stderr.log'), `[${new Date().toISOString()}] ${err.stack || err}\n` ).catch(() => { /* 兜底逻辑本身不应再抛 */ }); } catch { /* 兜底逻辑本身不应再抛 */ } }); } } } catch { // 兜底逻辑本身不应再抛 } // 把主进程的 console.log / console.warn / console.error 同步写到日志文件, // 这样即便 stdout 断了(GUI 启动 / 启动器关闭),日志仍可追溯。 // 不影响原有行为:仍会尝试写到 stdout(如果可用)。 // // audit fix (M5):之前写 %TEMP%\notes-main.log,多用户系统上 tmp 互相可读, // 包含路径 / senderId 等的日志会被同机其他用户读到。改用 app.getPath('logs') // (用户私有)作为首选;app 还没 ready 时退回 userData;都拿不到再退回 // %LOCALAPPDATA%(Windows)或 ~/.local/share(POSIX)。 try { const _origLog = console.log; const _origWarn = console.warn; const _origError = console.error; const _path = require('path'); const _resolveLogDir = () => { try { const { app: _app } = require('electron'); if (_app && typeof _app.getPath === 'function') { // logs 路径可能在 app ready 之前未注册;用 userData 作为兜底 try { const logs = _app.getPath('logs'); if (logs) return logs; } catch {} try { const ud = _app.getPath('userData'); if (ud) return ud; } catch {} } } catch {} if (process.platform === 'win32' && process.env.LOCALAPPDATA) { return process.env.LOCALAPPDATA; } const home = require('os').homedir(); if (process.platform === 'darwin') return _path.join(home, 'Library', 'Application Support'); return _path.join(home, '.local', 'share'); }; const _logDir = _resolveLogDir(); const _logFile = _path.join(_logDir, 'notes-main.log'); // P2-10 / P2-12 (audit):旧版每行 mkdirSync + appendFileSync 同步阻塞 main // 事件循环;改 main/main-log-writer.js 异步缓冲(250ms 一把写,unref 定时器, // 失败静默丢弃 —— 与旧「catch 忽略」语义一致)。 const _writer = require('./main/main-log-writer.js').createMainLogWriter({ logDir: _logDir, logFile: _logFile, }); const _writeLog = (level, args) => { try { _writer.write( `[${new Date().toISOString()}] [${level}] ${args.map((a) => (typeof a === 'string' ? a : require('util').inspect(a))).join(' ')}\n` ); } catch { /* 写日志失败不应影响主流程 */ } }; console.log = (...args) => { _origLog(...args); _writeLog('log', args); }; console.warn = (...args) => { _origWarn(...args); _writeLog('warn', args); }; console.error = (...args) => { _origError(...args); _writeLog('error', args); }; } catch { // 重写 console 失败不应阻塞启动 } 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\\notes C:\\dev\\notes 或者创建一个符号链接(mklink /D,管理员命令提示符): mklink /D C:\\dev\\notes "${__dirname}" 然后在符号链接路径下运行 npm start ================================================================================ `; console.error(errMsg); try { const { app: appApi } = require('electron'); if (appApi && typeof appApi.whenReady === 'function') { appApi.whenReady().then(() => { const { BrowserWindow } = require('electron'); const win = new BrowserWindow({ width: 600, height: 380, resizable: false, title: 'Notes - 启动错误' }); win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(`

⚠ 启动失败

问题:项目路径包含非 ASCII 字符(如中文),导致 Electron 无法正确加载 API。

当前路径:
${__dirname}

解决方法:将项目移动到 ASCII 路径(如 D:\\Projects\\notes)。

详细说明请查看 README。

`)); }); return; } } catch { // fallthrough } 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'); // 设置 schema(单一事实源)—— 主进程、preload、renderer 三处共享。 // 新增/修改设置项只需改 shared/settings-schema.js 一处。 const { validateAndSanitize } = require('./shared/settings-schema.js'); const { isValidPartialSettings } = require('./main/save-settings-validate.js'); // R5 P1-10 / P1-11 / P1-14:IPC handler 入口 sender / frame / requestId 守卫。 // 详见 main/ipc-sender.js 顶部注释。getMainWindow 通过 main.js 内部函数注入。 const { assertMainSender, assertTopFrame, assertAiRequestId } = require('./main/ipc-sender.js'); // P1-6:file:rename / folder:rename 成功后 fsync 父目录(POSIX 强同步视图) const { fsyncParentDir } = require('./main/fsync-parent-dir.js'); // 配置持久化层(Stage 4b.1 抽离) const configStore = require('./main/config-store'); // 目录监听层(Stage 4b.2 抽离) // 依赖通过参数注入 —— 不直接引用 mainWindow / configStore / scanFiles // scanFiles 是函数声明,会被 hoist;这里注册时还未执行定义,但调用时已可用。 const { createFsWatcher } = require('./main/fs-watcher'); // 文件操作 helper(Stage 7 抽出):可测试的纯函数,IPC 注册仍在 main.js // 包含 isWithinDataDir / resolveFileName / scanFiles / scanDir / assertNotSymlink / classifyEntry const fileOps = require('./main/file-ops'); const { MAX_FILE_SIZE, isWithinDataDir, resolveFileName, resolveRenameName, resolveFolderName, resolveRenameFolderName, scanDir, assertNotSymlink, assertNoSymlinkAncestor, resolveDirRelative, toRelativeDir, renameWithRetry, recursiveCopy, } = fileOps; // AI 代理(用 Node 18+ 内置 fetch 调 OpenAI 兼容 API) const { createAiProxy } = require('./main/ai'); // 启动标记:方便用户在 DevTools / 主进程控制台确认加载了最新代码 // (修改 main.js / main/*.js 后必须完全退出重启,主进程代码只在启动时 require 一次) console.log('[main] AI proxy loaded — token field routing per model: gpt-5/o-series → max_completion_tokens, others → max_tokens'); // 窗口图标和系统托盘共用同一图标 const APP_ICON_PATH = path.join(__dirname, 'icon.ico'); // 日志路径脱敏 helper:console.error 写 tmp 文件时会带上 filePath(含用户名), // 多用户系统上 tmp 可被其他用户读,且 ENOENT 也打路径 = 无成本枚举文件名。 // 只保留 basename,定位「哪个文件」已经足够;完整路径不在 tmp 落地。 function _redactPath(p) { if (typeof p !== 'string' || !p) return p; return path.basename(p); } /** * 把 file:create 阶段的 errno 翻译成中文,name 用于「创建 X 失败」前缀; * 路径只放在 console(已 _redactPath),不让完整路径回到 renderer。 * 与 file:write 的 renderer friendlyWriteError 一致地走「中文 + errno code」。 */ function _friendlyCreateError(e, name) { const n = name || '文件'; switch (e && e.code) { case 'EACCES': case 'EPERM': return `没有写入权限,无法创建 ${n}`; case 'ENOSPC': return '磁盘空间不足,无法创建文件'; case 'EROFS': return '只读文件系统,无法创建文件'; case 'EISDIR': return '该路径是文件夹,无法写入'; case 'ENOENT': return '所在目录不存在,无法创建文件'; default: return `创建 ${n} 失败`; } } /** * audit fix (K1-H1):file:rename 的 errno 翻译 —— 与 _friendlyCreateError * 对称:原路径已过 isWithinDataDir / symlink 守卫,到这层基本只剩 EACCES / * EPERM / ENOSPC / EROFS / EEXIST(同名的目录挡住文件 rename 在不同平台错 * 误码不同)。新名字走 _friendlyCreateError 同款中文提示,不让英文 errno * 直接回到 renderer(之前的 e.message 含「EPERM: operation not permitted」 * + 完整旧路径,调试日志和 UI 都泄露)。 */ function _friendlyRenameError(e, newName) { const n = newName || '文件'; switch (e && e.code) { case 'EACCES': case 'EPERM': return `没有重命名权限,无法将文件改名为 ${n}`; case 'ENOSPC': return '磁盘空间不足,无法重命名'; case 'EROFS': return '只读文件系统,无法重命名'; case 'EEXIST': case 'ENOTEMPTY': return `已存在同名项,无法重命名为 ${n}`; case 'ENOENT': return '原文件已被删除,无法重命名'; case 'EBUSY': return '文件被占用,无法重命名'; default: return `重命名 ${n} 失败`; } } /** * audit fix (K1-L2):file:delete 的 errno 翻译 —— 与上面 _friendly*Error * 对称。shell.trashItem 失败的常见 errno:EACCES/EPERM(没权限)、 * EBUSY(文件被占)、ENOENT(已不存在)、EACCES+Windows 共享冲突。 */ function _friendlyDeleteError(e) { switch (e && e.code) { case 'EACCES': case 'EPERM': return '没有删除权限,请检查文件权限'; case 'EBUSY': return '文件被其他程序占用,无法移到回收站'; case 'ENOENT': return '文件已不存在'; case 'ENOTDIR': case 'EISDIR': return '目标路径不是文件'; default: return '无法移到回收站,请手动删除文件'; } } /** * 复制失败的中文翻译(file:copy 专用)。 * 常见 errno:EACCES/EPERM(权限)、ENOSPC(磁盘满)、EROFS(只读)、 * EBUSY(文件被占)、ENOENT(源已被删)、EEXIST(目标已存在)。 */ function _friendlyCopyError(e, name) { const n = name || '文件'; switch (e && e.code) { case 'EACCES': case 'EPERM': return `没有写入权限,无法复制 ${n}`; case 'ENOSPC': return '磁盘空间不足,无法复制'; case 'EROFS': return '只读文件系统,无法复制'; case 'EEXIST': case 'ENOTEMPTY': return `已存在同名项,无法复制到 ${n}`; case 'ENOENT': return '源文件已被删除,无法复制'; case 'EBUSY': return '文件被占用,无法复制'; default: return `复制 ${n} 失败`; } } /** * audit fix (K1-M2):file:read 的 errno 翻译。读路径常见 errno: * EACCES/EPERM(权限)、EIO(磁盘 I/O)、EISDIR(路径是目录)、 * ENAMETOOLONG(路径过长)。 */ function _friendlyReadError(e) { switch (e && e.code) { case 'EACCES': case 'EPERM': return '没有读取权限'; case 'EIO': return '磁盘 I/O 错误'; case 'EISDIR': return '该路径是文件夹,无法读取'; case 'ENAMETOOLONG': return '路径过长'; default: return '读取文件失败'; } } /** * audit fix (K1-R4):shell/app:* handler 的 errno 翻译 —— 之前直接返回 * 英文 e.message(带完整路径)到 renderer,3 个 handler(app:open-path / * shell:show-item-in-folder / shell:open-dir)都漏。统一走 _friendlyShellError * 翻译成中文,与 _friendlyReadError / _friendlyCreateError 对齐。 * * 这些 handler 多半是 stat 或 shell 操作,常见 errno: * EACCES/EPERM(权限)、ENOENT(路径不在)、EIO(磁盘 I/O)、EBUSY(被占)。 */ function _friendlyShellError(e, fallback) { switch (e && e.code) { case 'EACCES': case 'EPERM': return '没有访问权限'; case 'ENOENT': return '路径不存在'; case 'EIO': return '磁盘 I/O 错误'; case 'EBUSY': return '文件被占用'; case 'ENOTDIR': return '目标路径不是目录'; case 'EISDIR': return '目标路径是文件夹'; case 'ENAMETOOLONG': return '路径过长'; default: return fallback || '操作失败'; } } // 抑制 Windows 上常见的 "Unable to move the cache" / "Gpu Cache Creation failed" 警告 app.commandLine.appendSwitch('disable-gpu-cache'); app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion'); let mainWindow = null; // R5 P1-10:getMainWindow 是闭包(每次访问时读 mainWindow 当前值)—— 注入 // 到 assertMainSender,让单测可以传 mock,且主窗口销毁事件触发时后续 IPC // 自动拒掉(getter 返回已销毁的 BrowserWindow,assertMainSender 看到 // isDestroyed() true 直接拒绝)。 function getMainWindow() { return mainWindow; } let tray = null; let isQuitting = false; let currentDataDir = null; let forceShowTimer = null; let rendererDirty = false; // 渲染端是否有未保存改动 // 关闭按钮触发的脏检查防重入:用户连点 × 时二次点击若没有 this 守卫会跑 // 两次 confirmDiscardIfDirty(都 await promptRendererSave,让出事件循环), // 弹出两个原生 dialog,关闭确认也跟着弹两次。镜像 before-quit 的 // quitConfirmInFlight 模式。 let closeConfirmInFlight = false; // audit fix (Round 17 H2):fs 操作 30s 超时包裹。SMB / 坚果云 / OneDrive 在 // 网络抖动 / 杀毒持锁 / 用户挂起电脑后重新唤醒时,fs.read / fs.write / fs.stat // / fs.rename 可能挂死几分钟甚至永久。renderer 这边 `await window.api.xxx()` // 永远 resolve 不出来,UI 无 loading、无错误提示,用户只能强杀进程。 // // 设计要点: // - 用 Promise.race 而非 AbortController —— Node fs.* 没有内置 abort 语义, // 注入 abort signal 在不同版本兼容差;race 超时后底层 promise 仍会跑完, // 但其结果已被丢弃,不会影响调用方。 // - 超时错误带 code='FS_TIMEOUT',handler catch 块透传到 renderer; // renderer 看到 FS_TIMEOUT 走「磁盘暂时不可用,请稍后重试」友好文案。 // - 30s 是经验值:原子写 + fsync + rename 在 SMB 上实测 1-3s,30s 给 10x // 余量;超过这个值基本就是磁盘真的没了,再等也没意义。 // - timeout Timer 必须 .unref(),否则 fs 操作 hang 时主进程不会自然退出。 const FS_OP_TIMEOUT_MS = 30000; function withTimeout(promise, ms = FS_OP_TIMEOUT_MS, label = 'fs') { let timer; const timeout = new Promise((_, reject) => { timer = setTimeout(() => { const err = new Error(`${label} 操作超过 ${ms}ms 未完成`); err.code = 'FS_TIMEOUT'; reject(err); }, ms); if (typeof timer.unref === 'function') timer.unref(); }); return Promise.race([promise, timeout]) .finally(() => { clearTimeout(timer); }); } /** * 安全向渲染端发送 IPC。 * * mainWindow 已被销毁时(isDestroyed() true)会直接返回; * 但还有一种时序:BrowserWindow 对象还活着、webContents 已被拆毁 * (例如关窗时 did-finish-load / resize 事件晚到一步), * 此时 webContents.send 会同步抛 throw,没有调用方能接住 → unhandledException。 * main\fs-watcher.js:145 safeSend 用了同款防御;这里共用一份语义。 * * 参数名故意避开 channel:check-ipc.js 的静态扫描按变量名匹配 webContents.send * 与 const 字面量,channel 已被 promptRendererSave 的局部 const 占走同名,全局共用 * 会被错配。改用 eventName 避开冲突。 */ function safeSendToRenderer(eventName, payload) { if (!mainWindow || mainWindow.isDestroyed()) return; try { mainWindow.webContents.send(eventName, payload); } catch (e) { console.warn(`[main] send ${eventName} 失败:`, e && e.message); } } // 渲染进程崩溃计数器(防止 reload 死循环) let renderGoneCount = 0; const MAX_RENDER_GONE_RELOADS = 3; // 目录监听实例(Stage 4b.2 抽离到 main/fs-watcher.js) // 注入依赖:getMainWindow 是闭包(每次访问时读 mainWindow 当前值), // resolveDataDir 同理。scanDir / toRelDir 都是模块内函数,hoist 后可用。 // Stage 8:注入 scanDir + toRelDir,监听目标是「当前显示目录」而非固定根目录。 const fsWatcher = createFsWatcher({ getMainWindow: () => mainWindow, // 走 currentDataRoot 而不是 resolveDataDir:fsWatcher 的 updateSnapshot 里要 // 用 dataRoot 重新做相对路径转换,customDir 失效场景下默认目录才是真实 root。 resolveDataDir: () => currentDataRoot(), scanDir, toRelDir: (absDir) => toRelativeDir(absDir, currentDataRoot()), }); // AI 代理实例 —— 每次 runEdit 都现读 configStore.getConfig(), // 用户改完 BaseURL / API Key / Model 不需要重启。 // P3-2 fix (audit):注入 log hook 让 ai:request / ai:http_error 在主进程 // 控制台可见(线上排查 AI 失败时只能靠 DevTools network 面板)。 // payload 白名单字段(url/status/detail/model/filename/promptLen/contentLen) // 已经不含 apiKey / Authorization 头。 const aiProxy = createAiProxy({ getConfig: () => configStore.getConfig(), log: (key, payload) => console.log(`[main][${key}]`, payload), }); // ============================================ // 启动日志 // ============================================ console.log('[main] Electron 启动中...'); console.log('[main] Platform:', process.platform); console.log('[main] Electron version:', process.versions.electron); console.log('[main] Project path:', __dirname); // audit fix (Round 9):全局兜底异常日志。之前的代码里 IPC handler / fsWatcher / // AI 路径的异常基本都 try/catch + console.error,但仍有「外层 promise 链 // 没人 await」「fsWatcher 内部 setTimeout 回调抛」之类的边界场景 —— 没有 // 这里兜底会被 Node 默认行为吞掉(unhandledRejection 在新 Node 上是会 // terminate 进程的,uncaughtException 默认打印 + 退出),连日志都不留。 // R5 P1-9:log 完调 app.exit(1) —— Node 文档明确警告 uncaughtException 后 // 「process state may be inconsistent」,继续运行 IPC / fs / DB 操作更危险。 // 代价:用户写一半的笔记可能丢失;但 uncaught 在 Notes 这类纯本地 app 里 // 极少触发(99% 是 bug),出现时安全 > 完整。 process.on('uncaughtException', (err, origin) => { console.error('[main] uncaughtException:', err && (err.stack || err.message || err), 'origin:', origin); // flush 后再 exit —— console.error 是同步的,但保险起见给一个 tick setImmediate(() => app.exit(1)); }); process.on('unhandledRejection', (reason, promise) => { console.error('[main] unhandledRejection at:', promise, 'reason:', reason && (reason.stack || reason.message || reason)); setImmediate(() => app.exit(1)); }); // ============================================ // 窗口创建 // ============================================ function createWindow() { if (forceShowTimer) { clearTimeout(forceShowTimer); forceShowTimer = null; } mainWindow = new BrowserWindow({ width: 1200, height: 760, minWidth: 600, minHeight: 420, backgroundColor: '#0a0a0c', icon: APP_ICON_PATH, show: false, autoHideMenuBar: true, // 自定义窗口:去掉系统标题栏,改由 renderer 顶部工具栏的 -webkit-app-region: drag // 接管拖拽,并在工具栏右侧画出自定义最小化/还原/关闭按钮。 frame: false, titleBarStyle: 'hidden', webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: false, } }); mainWindow.loadFile('index.html'); // 启动时应用持久化的「始终置顶」状态 if (configStore.getConfig().alwaysOnTop === true) { mainWindow.setAlwaysOnTop(true); } mainWindow.once('ready-to-show', () => { console.log('[main] 窗口 ready-to-show,显示窗口'); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.show(); } broadcastMaximizeState(); }); // 兜底:2 秒后如果还没显示,强制显示 forceShowTimer = setTimeout(() => { forceShowTimer = null; if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) { console.warn('[main] ready-to-show 未触发,强制显示窗口'); mainWindow.show(); } }, 2000); // audit fix (Round 9):timer 句柄 .unref(),防止它单独阻塞进程退出。 // 没有 .unref 的话,正常退出路径上 forceShowTimer 还没到 2s 触发的窗口里, // electron 会等定时器到期才退(虽然窗口已 closed 但 event loop 还被它吊着)。 // audit fix (Round 9):窗口重建时也会创建新的 forceShowTimer(createWindow // 顶部清掉旧的)。unref 保证不阻塞退出。 if (forceShowTimer && typeof forceShowTimer.unref === 'function') { forceShowTimer.unref(); } // 错误监听 mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL, isMainFrame) => { console.error('[main] 页面加载失败:', errorCode, errorDescription, validatedURL, 'isMainFrame:', isMainFrame); // audit fix (Round 9):只对主框架错误弹致命 dialog。子框架(iframe)/ 主动 // abort(errorCode=-3 ERR_ABORTED —— 用户按 Esc / navigate 取消)属正常 // 路径,弹 dialog 反而扰民。errorCode=-3 是 Chromium 的 ERR_ABORTED。 if (!isMainFrame || errorCode === -3) return; if (!mainWindow || mainWindow.isDestroyed()) return; // 主框架加载失败 → renderer 完全没起来,UI 也没法弹 toast。在主进程弹 // 原生 dialog,让用户知道发生了什么并提供手动退出的按钮。 try { dialog.showMessageBoxSync(mainWindow, { type: 'error', title: '页面加载失败', message: '应用主页面加载失败', detail: `${errorDescription} (${validatedURL})\n\n请尝试重启应用或从任务管理器结束进程后重试。`, buttons: ['退出', '继续(已加载的页面可能不可用)'], defaultId: 0, cancelId: 0, }) === 1 && console.warn('[main] 用户选择继续,主框架加载失败但继续运行'); } catch (e) { console.error('[main] did-fail-load dialog 弹窗失败:', e); } }); mainWindow.webContents.on('render-process-gone', (event, details) => { console.error('[main] 渲染进程崩溃:', details); // 崩溃循环保护:3 次连续崩溃后不再 reload,避免无限循环占满 CPU renderGoneCount += 1; if (renderGoneCount > MAX_RENDER_GONE_RELOADS) { console.error('[main] 渲染进程连续崩溃超过上限,停止自动 reload,请手动重启'); // audit fix (Round 9):连续崩溃超上限弹致命 dialog 让用户决定。 // renderer 已经不可用,没法自己弹 toast,必须在主进程走 dialog。 if (mainWindow && !mainWindow.isDestroyed()) { try { dialog.showMessageBoxSync(mainWindow, { type: 'error', title: '渲染进程连续崩溃', message: '应用渲染进程连续崩溃,已停止自动恢复', detail: `请尝试重启应用。如果问题持续,请到 GitHub 仓库反馈(控制台有更多细节)。\n\n原因:${details.reason || 'unknown'}`, buttons: ['退出', '关闭此提示'], defaultId: 0, cancelId: 0, }); } catch (e) { console.error('[main] render-process-gone dialog 弹窗失败:', e); } } return; } if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.reload(); } }); // 崩溃计数器只在「连续崩溃」时累加;reload 成功后再清零, // 否则跨多次会话累积 3 次后就再也触发不了自动恢复了。 mainWindow.webContents.on('did-finish-load', () => { renderGoneCount = 0; // audit fix (Round 9):renderer 重新加载(崩溃恢复 / Ctrl+R / 显示引导 // 重启 renderer)后必须把 rendererDirty 重置为 false —— 否则上次崩溃 // 之前的状态被「缓存」在主进程,before-quit 会拿这个 stale true 弹「未 // 保存确认」但用户其实没东西可丢。renderer 重启后 state 是干净的,主 // 进程需要跟上。 rendererDirty = false; // auto-fallback 2026-08:renderer 就绪后广播一次 data-dir:resolved。 // 必须在 did-finish-load 里发,不能在 createWindow 末尾 —— // 后者调用时 renderer 的 ipcRenderer.on 监听还没装好,事件被丢。 // fellBack=false 时也发(让 renderer 拿到 defaultDir 用作 UI 文案); // 监听器只在 fellBack=true 时弹引导弹窗,false 时只更新状态。 broadcastDataDirResolved(); }); // 拦截新窗口请求:markdown 里的 `` 默认会被 Electron 真的弹一个新窗口。 // 我们要的是外部链接走 shell.openExternal(preload 已经处理),所以这里一律拒绝。 mainWindow.webContents.setWindowOpenHandler(({ url }) => { console.warn('[main] 拒绝新窗口请求:', url); return { action: 'deny' }; }); // audit fix (Round 13 / Sec-L):拒绝所有权限请求。当前 threat model 下没有 // 任何 feature 需要 media / geolocation / notifications / midi 等权限 —— 一旦 // 未来 XSS 落地,攻击者若能让 renderer 调 navigator.geolocation / webcam 等, // 没有这层兜底会直接拿到原始权限。Electron 默认对每个权限请求都会询问用户, // 但「弹窗让用户点」不是真安全 —— 默认 deny + 显式 allow 才能把攻击面关到 0。 // 设在 webContents 级:每窗口独立;若以后加 BrowserWindow 也要记得复制。 mainWindow.webContents.session.setPermissionRequestHandler((_wc, _permission, callback) => { callback(false); }); mainWindow.webContents.session.setPermissionCheckHandler(() => false); // audit fix (Round 9):F12 在所有平台都能打开 DevTools。 // 菜单 accelerator `F12` 在 macOS 可见菜单栏下生效,但 Windows / Linux // 上 autoHideMenuBar=true 让菜单隐藏 → accelerator 不再触发,README // 承诺「Windows / Linux 也可」变成谎言。webContents.on('before-input-event') // 是 Chromium 级的输入事件,不依赖菜单可见性,跨平台一致。 // 只对 `F12`(不含修饰)生效,编辑器内也让位(CM6 不占 F12 但未来可能)。 mainWindow.webContents.on('before-input-event', (event, input) => { if (input.type !== 'keyDown') return; if (input.key === 'F12' && !input.alt && !input.control && !input.meta && !input.shift) { event.preventDefault(); mainWindow.webContents.toggleDevTools(); } }); // 拦截任何页面内导航:只允许同源 file:// 加载 index.html。 // 防止恶意 markdown 中的 或 把我们带到外部页面。 mainWindow.webContents.on('will-navigate', (event, url) => { const allowed = mainWindow.webContents.getURL(); if (url === allowed) return; // 同 URL 重载 // 任何外部 URL → 阻止并打开外部浏览器 event.preventDefault(); console.warn('[main] 拦截外部导航:', url); if (/^https?:\/\//i.test(url)) { shell.openExternal(url).catch((e) => console.error('[main] openExternal 失败:', e)); } }); // console-message 事件签名:Electron 35+ 派发 5-arg 兼容路径(runtime 不论 listener.length // 是 1/2/5 都走同一内部接口),推荐用单参 `(event)` 直接读 WebContentsConsoleMessageEventParams: // event.message - 消息文本 // event.level - 'verbose' | 'info' | 'warning' | 'error' | 'debug'(字符串, // 不是 Electron 28 时代的 0-3 数字;要相等比较 === 'error') // event.lineNumber - 源代码行号 // event.sourceId - 源代码 URL // event.frame - WebFrameMain // 走单参路径运行时不会再打印 deprecated 警告(仅多参 `on(..., (a,b) => ...)` 才会触发), // 移除 console-message 监听会让 renderer 错误不可见,故保留。 mainWindow.webContents.on('console-message', (event) => { if (event.level === 'error') { console.error(`[renderer ${event.sourceId}:${event.lineNumber}]`, event.message); } }); // 开发模式下自动打开 DevTools if (process.env.NODE_ENV === 'development' || process.argv.includes('--dev')) { mainWindow.webContents.openDevTools({ mode: 'detach' }); } // 关闭事件:有托盘时拦截关闭按钮改为隐藏到托盘(不丢数据,无需确认); // 没有托盘时关闭就是退出,有未保存的改动必须先弹确认框。 mainWindow.on('close', async (e) => { // isQuitting 为 true 说明 before-quit 已经做过脏检查了,直接放行 if (isQuitting) return; e.preventDefault(); // 有托盘时关闭只是隐藏窗口,未保存的内容还在内存里,不该拿弹窗打扰用户; // 真正的退出(托盘菜单、Cmd+Q 等)由 before-quit 统一做脏检查 if (tray) { hideWindow(); return; } // audit fix (Round 9):防 close 重入。用户双击 × 时第一次 confirmDiscardIfDirty // 还在 await promptRendererSave(让出事件循环),第二次 close 事件进入 // → 又走 confirmDiscardIfDirty → 两个原生 dialog 同时挂起。镜像 // before-quit 的 quitConfirmInFlight 模式:第一次进入设标记,二次 click // 直接 e.preventDefault 走人。 if (closeConfirmInFlight) return; closeConfirmInFlight = true; try { // 没有托盘:关闭窗口就是退出,丢改动之前必须确认 const proceed = await confirmDiscardIfDirty(); if (!proceed) return; isQuitting = true; app.quit(); } finally { closeConfirmInFlight = false; } }); mainWindow.on('maximize', () => broadcastMaximizeState()); mainWindow.on('unmaximize', () => broadcastMaximizeState()); // 窗口最小化 / 收进托盘时暂停 readdir 轮询,重新可见时恢复并补扫一次 mainWindow.on('minimize', () => fsWatcher.pauseDirPolling()); mainWindow.on('hide', () => fsWatcher.pauseDirPolling()); mainWindow.on('restore', () => fsWatcher.resumeDirPolling()); mainWindow.on('show', () => fsWatcher.resumeDirPolling()); mainWindow.on('closed', () => { console.log('[main] 窗口已关闭'); // audit fix (Round 9):清理 forceShowTimer。createWindow 顶部也清,但 // 异常路径下(窗口被强制销毁没走 createWindow)会泄漏 timer 句柄。 // 这里 closed 兜底再清一次。forceShowTimer.unref() 之后不会阻塞退出, // 但留着没意义(窗口已 null,timer 内回调的 isDestroyed 守卫也会拦下)。 if (forceShowTimer) { clearTimeout(forceShowTimer); forceShowTimer = null; } fsWatcher.stopWatchingDir(); mainWindow = null; }); console.log('[main] 主窗口创建完成'); // audit fix (Round 7 FS-F4):createWindow 是重建路径(macOS activate / // showWindow 的 destroyed 分支 / 渲染进程崩溃恢复)的统一入口。窗口重建 // 后必须重新挂载目录监听,否则整个会话的文件监听彻底死掉,外部改动再也 // 不会出现在侧栏。startWatchingDir 内部 stopWatchingDir 幂等,无副作用。 try { fsWatcher.startWatchingDir(currentDataRoot()); } catch (e) { console.warn('[main] 重建目录监听失败(仅依赖轮询):', e && e.message); } } // ============================================ // 系统托盘 // ============================================ function showWindow() { if (!mainWindow || mainWindow.isDestroyed()) { createWindow(); return; } if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); mainWindow.focus(); } function hideWindow() { if (!mainWindow || mainWindow.isDestroyed()) return; if (forceShowTimer) { clearTimeout(forceShowTimer); forceShowTimer = null; } mainWindow.hide(); } function toggleWindow() { if (!mainWindow || mainWindow.isDestroyed()) { createWindow(); return; } if (mainWindow.isVisible() && !mainWindow.isMinimized()) { hideWindow(); } else { showWindow(); } } function buildTrayMenu() { return Menu.buildFromTemplate([ { label: '显示窗口', click: () => showWindow() }, { label: '隐藏窗口', click: () => hideWindow() }, { type: 'separator' }, { label: '始终置顶', type: 'checkbox', checked: configStore.getConfig().alwaysOnTop === true, click: (item) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.setAlwaysOnTop(item.checked); } // audit fix (C2):saveConfig 现在返回 Promise(异步写盘 + 串行 queue), // fire-and-forget 路径加 .catch 避免写盘失败时 unhandled rejection。 configStore.saveConfig({ alwaysOnTop: item.checked }).catch((e) => { console.error('[main] tray alwaysOnTop 保存失败:', e?.error || e?.message || e); }); // audit fix (H3):走 safeSendToRenderer —— mainWindow 已被 destroyed // 但引用还在的极小窗口里 .send() 同步抛 TypeError,托盘 click 处理器 // 没 try/catch → unhandledException 主进程侧崩溃。 safeSendToRenderer('always-on-top:changed', item.checked); } }, { type: 'separator' }, { label: '退出', click: () => { // 不在这里置 isQuitting:交给 before-quit 做「未保存改动」确认, // 否则托盘退出会跳过脏检查直接丢数据。 app.quit(); } } ]); } function createTray() { if (tray) return; try { tray = new Tray(APP_ICON_PATH); tray.setToolTip('Notes'); 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; } // ============================================ // 单实例锁 // ============================================ 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(async () => { if (!gotSingleInstanceLock) return; configStore.init(); // auto-fallback 2026-08 + auto-create 默认目录:currentDataRoot() 内部走 // resolveDataDirOrFallback,customDir 失效时回退到默认;返回 DEFAULT_DATA_DIR // 时还会 sync mkdir(确保 fsWatcher 启动时不撞 ENOENT)+ 异步种子 README.md // (fire-and-forget,下次 fsWatcher 第一次扫描大概率就绪)。 // 之前用 resolveDataDir() 不做 stat、不 mkdir —— 用户首次启动默认目录还没建、 // 或 U 盘掉线 fallback 路径下默认目录也从未被用过,都会让 fsWatcher.startWatchingDir // 立刻 ENOENT 失败、侧栏空白、用户毫无线索。 currentDataDir = currentDataRoot(); // 注意:不要打印完整 config —— aiApiKey 等敏感字段不能进日志。 // 只打印 "已加载" 这条确认信息;详情在出错时单独打。 console.log('[main] 已加载配置'); console.log('[main] 数据目录:', currentDataDir); // 等欢迎文档就绪再启动 fsWatcher —— 默认目录刚创建的情况下,先把 README.md // 写好再让 fsWatcher 开始监听,避免「fsWatcher 第一次扫描拿到空目录、几秒 // 后 README.md 突然冒出侧栏」的 UX 撕裂。resolveDataDirOrFallback 内部已 // fire-and-forget 异步种子一次,这里 await 的是同一份 in-flight promise。 await configStore.scheduleEnsureDefaultDataDir(); // audit fix (Round 16 R2):清理数据目录里残留的 `*.tmp...` // 孤儿文件。atomicWriteFile 失败路径会 unlink,但「进程被 kill -9 / 断电」 // 不会走那条路。configStore 自己的 `config.json.tmp.*` 在 configStore.init() // 内部已经处理;这里只覆盖 fileOps 这一组。fire-and-forget,不阻塞启动。 fileOps.cleanupStaleFileTmpFiles(currentDataDir).catch((e) => { console.warn('[main] 清理数据目录残留 tmp 失败:', e && e.message); }); console.log('[main] app ready'); buildMenu(); createTray(); createWindow(); // 启动目录监听器(默认目录已 sync mkdir + README.md 已 async 种子完毕) fsWatcher.startWatchingDir(currentDataDir); app.on('activate', () => { if (mainWindow && !mainWindow.isDestroyed()) { showWindow(); } else if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); }).catch(err => { console.error('[main] app ready 失败:', err); }); // 退出确认是否正在进行,防止用户连点托盘「退出」时叠出多个原生框 let quitConfirmInFlight = false; // 所有退出路径(托盘退出、Cmd+Q、系统关机请求)都会经过 before-quit。 // isQuitting 只有在这里确认过、或单实例锁失败时才会被置为 true。 app.on('before-quit', async (e) => { if (isQuitting) return; if (!rendererDirty) { isQuitting = true; return; } // 已经弹着一个确认框了,忽略这次退出请求 if (quitConfirmInFlight) { e.preventDefault(); return; } e.preventDefault(); quitConfirmInFlight = true; try { const proceed = await confirmDiscardIfDirty(); if (!proceed) return; isQuitting = true; app.quit(); } finally { quitConfirmInFlight = false; } }); app.on('will-quit', () => { // audit fix (P1-2):will-quit 时把 configStore 闸置 true,拒后续新 save。 // 时机:after before-quit(dirty 确认完,已经决定退)→ 不会再有新合法 save。 // 目的:renderer 错误风暴期间 IPC `await saveConfig(...)` 不挂在链尾阻塞 quit。 try { configStore._setIsQuitting(true); } catch (e) { console.warn('[main] _setIsQuitting 失败:', e && e.message); } // 退出时取消所有在飞的 AI 请求,避免响应被丢弃(付费用 API 也会浪费额度)。 // audit fix (main-M11):把同步清理拆出来,把 revealArmedBySender 兜底清理 // 也挪到这里(armed TTL 兜底 setTimeout 已经 .unref,正常退出流程不会等它, // 显式清理避免 reveal Map 在长跑测试 / 频繁 reload 场景下累积 entry)。 try { aiProxy.cancelAll(); } catch (e) { console.warn('[main] cancelAll 失败:', e && e.message); } // audit fix (main-M12a):revealArmedBySender 在 will-quit 显式清空。 // 既有 TTL 兜底(armed 5s 后 setTimeout 自动删),但用户开窗口→立刻退的 // 短路径上 entry 仍占着 Map。shutdown 时主动清掉,调试时也能在 heap // snapshot 看到模块级 Map 是干净的。 try { revealArmedBySender.clear(); } catch (e) { console.warn('[main] revealArmed 清理失败:', e && e.message); } // audit fix (Round 7 FS-F15):will-quit 显式停 fsWatcher。fsWatcher 持有 // 1 个 FSWatcher 句柄 + 1 个 setInterval + 最多 2 个 setTimeout,长期跑 // 测试 / 频繁 reload 场景下堆积。closed 路径已经 stopWatchingDir,但 // macOS `before-quit` 可能跳过 closed 直接 will-quit —— 这里兜底。 try { fsWatcher.stopWatchingDir(); } catch (e) { console.warn('[main] fsWatcher 清理失败:', e && e.message); } destroyTray(); }); app.on('window-all-closed', () => { console.log('[main] 所有窗口已关闭'); if (tray) return; if (process.platform !== 'darwin') app.quit(); }); // ============================================ // IPC: 渲染端脏状态同步 + 触发保存 // ============================================ ipcMain.handle('renderer:set-dirty', (event, isDirty) => { // audit fix (L5):sender 校验。rendererDirty 是退出路径上 confirmDiscardIfDirty // 决定「是否弹未保存确认框」的唯一开关。若 XSS 拿到 window.api.setDirty // (preload.js 暴露的 contextBridge API,渲染端任意 JS 都能调)就能把 // rendererDirty 强写为 false → 用户关窗时直接跳过保存确认 → 未保存内容静默 // 丢失。校验 event.sender 是 mainWindow.webContents、senderFrame 是顶级 // frame —— 拒掉所有来自子 frame / devtools / 其它 BrowserWindow 的调用。 if (!event?.sender || !mainWindow || event.sender !== mainWindow.webContents) { console.warn('[main] renderer:set-dirty 拒绝:sender 不是主窗口 webContents'); return false; } const senderFrame = event.senderFrame; if (senderFrame && senderFrame.parent) { console.warn('[main] renderer:set-dirty 拒绝:来自子 frame'); return false; } // 仅同步标志位,供 confirmDiscardIfDirty 在退出路径上判断「是否弹未保存确认框」。 // 不在此处刷新托盘菜单 —— dirty 状态与托盘菜单项(始终置顶、显示主窗口)无关。 rendererDirty = !!isDirty; return true; }); /** * 通知渲染端立刻保存当前文件。 * 通过 webContents.send + 一次性监听 IPC 返回值的方式拿到结果。 * * audit fix (M5 main):webContents.send 在 webContents 已 destroyed(但 * mainWindow 还没被回收的极小窗口)会抛 TypeError —— 旧实现没 try/catch, * 整个 Promise 永远 hang 在 unhandled 里;confirmDiscardIfDirty 调用方 * `await promptRendererSave()` 永远等不到 resolve,「关闭窗口」操作死锁。 * 收紧护栏:send 失败 → 立即解 timer + resolve(false);webContents 中途 * destroyed → 同样提前 resolve(false) 不再等 10s。 * * @returns {Promise} true 表示保存成功(或没有需要保存的内容) */ function promptRendererSave() { return new Promise((resolve) => { if (!mainWindow || mainWindow.isDestroyed()) { resolve(false); return; } const reqId = `save-${Date.now()}-${Math.random().toString(36).slice(2)}`; const channel = `renderer:save-result:${reqId}`; let settled = false; const settle = (value) => { if (settled) return; settled = true; // audit fix (M2):hardTimer 已被删(见下方),这里不再 clearTimeout(hardTimer)。 // 旧 hardTimer(10s) 的 callback 是空,只给 settle 一个 handle 来 clear —— 但 // settle 在 0-10s 之间必然先 fire(renderer 几乎总是 1-3s 内回结果),到 10s 时 // hardTimer 已 clearTimeout 掉,never fires;10s 后仍未 settle 的极小窗口里, // hardTimer fires 是个 no-op(listener 自身由 graceTimer 的 settle(false) 移除, // 或被任意 settle(ok) 移除)。删 hardTimer 后语义不变,注释中「进入宽限窗口」 // 的解释改为「10s 这一刻只是渲染承诺过期,监听器照常保留到 graceTimer」。 clearTimeout(graceTimer); try { ipcMain.removeAllListeners(channel); } catch { /* ignore */ } // Phase N M1 fix:早退路径(IPC 收到结果 / 窗口 destroyed / send 失败)清掉 // graceTimer 与 once('destroyed'),否则 onDestroyed 会留到窗口死亡才被拆、 // graceTimer 会留到 15s 才被 Node 自然 GC,延迟 process.exit 0.5s ~ 5s // (取决于哪条路径先 settle)。granted settled 守卫让它们是 no-op,但 timer/ // listener 仍占资源。 try { wc && wc.off('destroyed', onDestroyed); } catch { /* ignore */ } resolve(value); }; // audit fix (Main P1 / promptRendererSave grace timer 死代码): // 旧版 hardTimer(10s) + graceTimer(15s) 两个 timeout 都直接调 // settle(false) —— 因为 hardTimer 先 fire,settled 守卫让 graceTimer // 必是 no-op,「宽限期接受晚到的 ok=true 结果」根本没生效,等同于 // 一段被注释误导的 dead code。 // 修正:hardTimer fire 时只让出监听器(不再 settle),graceTimer fire // 时才 settle(false)。这样 10-15s 之间晚到的 renderer:save-result:{reqId} // 仍能走 settle(ok=true),避免大文件 / 慢盘下「renderer 实际写盘成功 // 但晚到 10s+」被错判为保存失败 → 用户看到「取消关闭」,实际文件已 // 保存的诡异状态。 const HARD_DEADLINE_MS = 10000; const GRACE_PERIOD_MS = 15000; // audit fix (M2):硬期限改成「软」语义 —— 没有任何 settle,10s 后 renderer 没 // 回结果只代表渲染侧可能慢(大文件 / 慢盘),监听器不摘、graceTimer 继续守。 // 旧版 hardTimer 是空 callback + 只为让 settle 能 clearTimeout 的 dead timer; // 删掉后行为完全一致:0-15s 之间晚到的 renderer 结果照样被 settle(ok) 接受, // 15s graceTimer 仍未回 → settle(false)。每次 quit attempt 少一个 timer。 void HARD_DEADLINE_MS; // 仅作注释常量保留(其它模块若想对齐可参考) let graceTimer = setTimeout(() => settle(false), GRACE_PERIOD_MS); ipcMain.once(channel, (_event, result) => { // 宽限期内(10-15s 之间)到达的结果一律接受;hardTimer 之前照常 // 接受;graceTimer 之后 / 已 settled 直接被 settled 守卫挡掉。 settle(!!(result && result.ok)); }); // webContents 中途被销毁(极少见但可能:before-quit 序列里被 GC) const wc = mainWindow.webContents; const onDestroyed = () => settle(false); if (wc && !wc.isDestroyed()) { wc.once('destroyed', onDestroyed); } try { wc.send('renderer:save-request', { reqId }); } catch (e) { console.error('[main] promptRendererSave send 失败:', e.message); settle(false); } }); } /** * 有未保存改动时弹原生三选一确认框(保存 / 丢弃 / 取消)。 * close(无托盘)和 before-quit 共用,保证任何退出路径都不会静默丢数据。 * 隐藏到托盘不算退出,不走这里。 * @returns {Promise} true = 可以继续退出;false = 用户取消 / 保存失败 */ /** * confirmDiscardIfDirty 递归上限。防止「用户一直敲字 → 保存后又脏 → 反复弹原生框」 * 死循环。Phase N-A1 修复用了递归弹框,第 MAX_CONFIRM_RECURSION 次直接返回 false * 强制用户取消或丢弃。 */ const MAX_CONFIRM_RECURSION = 3; /** * confirmDiscardIfDirty 的递归保护版本。depth=0 时不再弹原生框, * 直接返回 false(强制用户取消或丢弃),防"用户一直敲字 → 反复提示"的死循环。 */ async function _confirmDiscardIfDirtyBounded(depth) { if (depth <= 0) return false; // audit fix (Round 8 M-2):必须把 depth 透传下去。旧实现调 confirmDiscardIfDirty() // 不带参 → 内部又用常量 MAX_CONFIRM_RECURSION - 1 递归 → depth 恒为 2, // 「第 3 次强制 return false」的安全网从未生效,理论上可无限递归到栈溢出。 return await confirmDiscardIfDirty(depth); } async function confirmDiscardIfDirty(depth = MAX_CONFIRM_RECURSION) { if (!rendererDirty) return true; // 旧实现:mainWindow 缺失/被销毁时直接 return true("允许丢弃")。 // 这会让 rendererDirty=true 但窗口在弹框前就被拆掉的极端时序下 // 默默把未保存改动丢进黑洞 —— close/quit 路径在 call site `if (!proceed) return;` // 直接吞掉,等价于「不确认就退出」。 // 修复:拿不到窗口就拒绝(return false),call site 收到 false 后中止 // close/quit;用户重启 / 重新打开窗口时仍能拿到 promptRendererSave 流程 // 继续问「保存 / 丢弃 / 取消」。 if (!mainWindow || mainWindow.isDestroyed()) return false; // 窗口可能已经隐藏到托盘了,先显示出来,否则原生框会挂在看不见的窗口上 if (!mainWindow.isVisible() || mainWindow.isMinimized()) { showWindow(); } const choice = dialog.showMessageBoxSync(mainWindow, { type: 'warning', buttons: ['保存后退出', '丢弃并退出', '取消'], defaultId: 0, cancelId: 2, title: '未保存的改动', message: '当前文件有未保存的改动。\n退出将丢失这些改动。', detail: '「保存后退出」会写入磁盘再退出;「丢弃并退出」直接退出;「取消」回到窗口。', noLink: true, }); if (choice === 2) return false; if (choice === 0) { const ok = await promptRendererSave(); if (!ok) { dialog.showMessageBoxSync(mainWindow, { type: 'error', buttons: ['好'], title: '保存失败', message: '保存当前文件失败,已取消退出。', detail: '请回到窗口检查文件是否可写,或选择「丢弃并退出」。', noLink: true, }); return false; } // audit fix (Phase N-A1):save 飞行期用户输入的数据丢失路径。 // 旧实现:promptRendererSave 解析为 ok → confirmDiscardIfDirty 直接 return true // → before-quit 看到 isQuitting=true 放行 → app.quit() → 编辑器销毁 → // 飞行期用户敲的字(C2)永久丢失(盘上是 C1)。 // // 修复:在保存成功后再 re-check 一次 rendererDirty。rendererDirty 在 save 成功 // 路径里会被 syncSaveButton() → setDirty(false) 写回 false,但飞行期用户又敲字 // 会再次翻 true。如果保存后 dirty 重新为 true,说明用户在 save IPC 飞行期 // 输入了新内容 —— 我们没有这些内容的盘上版本,强制退出等于丢字。 // // 处理:弹"保存后又产生新改动"原生框,用户重新决策(继续保存 / 丢弃 / 取消)。 // 取消则用户继续编辑;继续保存走递归 → promptRendererSave 二次飞行再被异步 // 重读 dirty 时可能再次脏——但主进程 IPC 在 await 期间不阻塞(renderer 的 // editor/view dispatch 同步触发 setDirty IPC 写回 rendererDirty)。最坏 // 情况用户看到"保存后又有新改动"反复弹——明确告知比静默丢字好得多。 if (rendererDirty) { const retry = dialog.showMessageBoxSync(mainWindow, { type: 'warning', buttons: ['再次保存', '丢弃并退出', '取消'], defaultId: 0, cancelId: 2, title: '保存后又产生了新改动', message: '文件已保存到磁盘,但保存过程中又输入了新内容。', detail: '「再次保存」会写入最新的内容再退出;「丢弃并退出」直接退出,丢失保存后的新内容;「取消」回到窗口继续编辑。', noLink: true, }); if (retry === 2) return false; // 取消:继续编辑 if (retry === 1) return true; // 丢弃:直接退出 // retry === 0:再次保存。递归调 confirmDiscardIfDirty 重新走完整流程。 // 限制递归深度 ≤ MAX_CONFIRM_RECURSION 防极端时序死循环(用户一直敲字 → // 永远保存后又脏)。depth 沿调用链递减,耗尽后 _confirmDiscardIfDirtyBounded // 直接 return false(= 取消退出,回窗口继续编辑),不丢数据。 return await _confirmDiscardIfDirtyBounded(depth - 1); } } return true; } // ============================================ // IPC: 文件操作(只读) // ============================================ /** * 取当前数据目录路径(IPC handler 统一封装) * * auto-fallback 2026-08:改走 resolveDataDirOrFallback() —— custom 路径不存在时 * runtime 回退到默认(saved 字段保留原值,U 盘插回下次启动还能用回去)。 * 之前直接调 resolveDataDir() 不做 stat,缺失时整个文件 IO 全炸、用户侧栏空白。 */ function currentDataRoot() { return configStore.resolveDataDirOrFallback().dir; } /** * 启动时广播一次 data-dir:resolved 给 renderer,让 UI 在 fallback 时弹一次 * 「数据文件夹不可访问」的引导弹窗 / toast。fallBack=false 时不发(无意义噪音)。 */ function broadcastDataDirResolved() { if (!mainWindow || mainWindow.isDestroyed()) return; const info = configStore.resolveDataDirOrFallback(); safeSendToRenderer('data-dir:resolved', { dir: info.dir, defaultDir: configStore.getDefaultDataDir(), fellBack: info.fellBack, saved: info.saved, }); } // R5 P1-10:所有 IPC handler 入口的 sender 守卫辅助。每个 handler 顶部一行: // const denied = guardSender(event); if (denied) return denied; // 比直接展开 assertMainSender 三行更省眼。顶层 frame 守卫(assertTopFrame, // P1-14)单独走 guardTopFrame —— 只 arm-reveal 用得上。 function guardSender(event) { const r = assertMainSender(event, getMainWindow); return r.ok ? null : { ok: false, error: r.error }; } function guardTopFrame(event) { const r = assertTopFrame(event); return r.ok ? null : { ok: false, error: r.error }; } // audit fix (R7a P1-20):file:list 分页缓存。 // // 旧版 file:list 一次性返回全部 entries + files,10000 文件 ≈ 1.5-2MB // IPC payload,结构化克隆 + 渲染端 JSON.parse 都要 30-100ms,首次启动 cold // paint 明显卡顿。改成「首次 200 + 分页」+ 模块级 cache:scan 一次切片多次 // 返回,避免 loadMore 反复触发全量 scanDir。 // // 缓存逻辑抽到 main/file-list-cache.js(便于单测),本文件只调它。 // file:write / file:create / file:delete / file:rename 成功后调 // invalidateFileListCache(),下一次 file:list / file:list:more 重扫。 const fileListCache = require('./main/file-list-cache.js'); function invalidateFileListCache() { fileListCache.invalidate(); } /** 拉或建 file:list 缓存。dir 变化或缓存被显式失效时重扫。 */ async function getOrBuildFileListCache(dir) { return fileListCache.getOrBuild(dir, scanDir); } /** file:list 单页大小。10000 文件 ÷ 200 = 50 页,每页 30-50KB IPC, * 结构化克隆 < 5ms。配合 P1-15 virtual scroll,首次启动 cold paint * < 100ms。 */ const FILE_LIST_PAGE_SIZE = 200; ipcMain.handle('file:list', async (event) => { const denied = guardSender(event); if (denied) return denied; const dir = currentDataRoot(); currentDataDir = dir; // audit fix (Main P2 / file:list no validation):其他 handler(file:scan-dir // / file:read / file:write / file:create / file:rename / file:delete)都先 // 调 assertNoSymlinkAncestor 拒绝祖先链含 symlink 的目标;file:list 是早期 // 入口,漏了这一步。 // 威胁面:用户配置 dataDir 指到一个普通目录,但该目录里某个子项(含 // file:list 直接扫描的根)被替换成 symlink 指向外部(恶意诱导 / 备份恢复 // 时残留)—— 旧实现 fs.readdir + fs.stat 跟随 symlink,scanDir 把目标当 // 成普通目录返回,渲染端按内部文件展示,触发 NTLM 偷凭 + 路径穿越。 const ancestorCheck = await assertNoSymlinkAncestor(dir, dir); if (!ancestorCheck.ok) { return { ok: false, files: [], entries: [], error: ancestorCheck.error, code: ancestorCheck.code, message: ancestorCheck.message, }; } // audit fix (R7a P1-20):走分页缓存,返回第一页 + total/hasMore。 const cache = await getOrBuildFileListCache(dir); if (cache.error) { return { ok: false, files: [], entries: [], error: cache.error.error, code: cache.error.code, message: cache.error.message, }; } const offset = 0; const limit = FILE_LIST_PAGE_SIZE; return { ok: true, files: cache.files.slice(offset, offset + limit), entries: cache.entries.slice(offset, offset + limit), dir, relDir: '', total: cache.total, hasMore: cache.total > limit, offset, limit, }; }); /** * file:list:more —— 分页后续页(audit R7a P1-20) * * 入参 {offset, limit?},offset 必填(无 0 默认可避免 caller 把空参当 offset=0 * 漏 load 第一页),limit 默认 FILE_LIST_PAGE_SIZE,封顶 1000 防止恶意请求拉 * 爆。共享 file:list 的缓存;offset >= total 返回 ok + 空数组 + hasMore=false。 */ ipcMain.handle('file:list:more', async (event, payload) => { const denied = guardSender(event); if (denied) return denied; const dir = currentDataRoot(); currentDataDir = dir; // audit fix (R7a P1-20):分页也走 assertNoSymlinkAncestor 防御: // 共享 file:list 的 cache,但 cache 可能在 dir 被外部替换成 symlink 后 // 失效(cache.dir 没变但底层 symlink 改了),仍要每次校验。 const ancestorCheck = await assertNoSymlinkAncestor(dir, dir); if (!ancestorCheck.ok) { return { ok: false, files: [], entries: [], error: ancestorCheck.error, code: ancestorCheck.code, message: ancestorCheck.message, }; } const reqOffset = Number.isFinite(payload && payload.offset) ? payload.offset : -1; const reqLimit = Number.isFinite(payload && payload.limit) ? payload.limit : FILE_LIST_PAGE_SIZE; if (reqOffset < 0) { return { ok: false, error: 'INVALID_OFFSET', code: 'EINVAL' }; } const safeOffset = Math.max(0, Math.floor(reqOffset)); const safeLimit = Math.max(1, Math.min(Math.floor(reqLimit) || FILE_LIST_PAGE_SIZE, 1000)); const cache = await getOrBuildFileListCache(dir); if (cache.error) { return { ok: false, files: [], entries: [], error: cache.error.error, code: cache.error.code, message: cache.error.message, }; } const end = Math.min(safeOffset + safeLimit, cache.total); return { ok: true, files: cache.files.slice(safeOffset, end), entries: cache.entries.slice(safeOffset, end), hasMore: end < cache.total, offset: safeOffset, limit: safeLimit, total: cache.total, }; }); /** * file:scan-dir —— Folder Browser 的核心入口(Stage 8) * * 入参 relDir 是 dataRoot 下的 POSIX 风格相对路径('notes/2026'); * 空串表示根目录。返回一层所有条目(文件夹 + 文件 + binary)。 * * 渲染端用这个在子目录之间导航;fs-watcher 也通过 {relDir} 字段告诉 * 渲染端「变更发生在哪一层」,渲染端比对当前目录决定是否重扫。 */ ipcMain.handle('file:scan-dir', async (event, relDir) => { const denied = guardSender(event); if (denied) return denied; const dataRoot = currentDataRoot(); const resolved = resolveDirRelative(typeof relDir === 'string' ? relDir : '', dataRoot); if (!resolved.ok) { return { ok: false, error: resolved.error, message: resolved.message }; } // audit fix (Phase O-H1):祖先链 symlink 检查。 // 之前只 lstat(absDir) 自身,但 dataRoot/links 是 symlink 指向外部时, // absDir = dataRoot/links/foo 是合法路径(symlink 在祖先链上), // 自身不是 symlink → 通过检查。scanDir 跟随 symlink 读到外部目录文件列表, // 渲染后等于无成本枚举外部目标内容。 // assertNoSymlinkAncestor 从 absDir 一路 lstat 到 dataRoot , // 中间任一层是 symlink 就拒绝 —— 与 file:read/write/rename/delete 对齐。 const ancestorCheck = await assertNoSymlinkAncestor(resolved.absDir, dataRoot); if (!ancestorCheck.ok) { return { ok: false, error: ancestorCheck.error, code: ancestorCheck.code, message: ancestorCheck.message }; } const result = await scanDir(resolved.absDir); if (!result.ok) { return { ok: false, error: result.error, code: result.code, message: result.message, relDir: resolved.relDir, }; } // audit fix (R7a P1-20):Folder Browser 进入子目录 / 切换目录后, // 根目录 file:list 缓存的「dir 不等于 dataRoot」自然 stale(命中逻辑是 // _fileListCache.dir === dir,dataRoot 没变就仍命中 —— 根目录的 file:list // 缓存与子目录 file:scan-dir 解耦,不影响)。这里仅记一笔:scan-dir 不会 // 改根目录 file:list 缓存,不需要显式 invalidate。 return { ok: true, dir: result.dir, relDir: resolved.relDir, entries: result.entries }; }); /** * file:watch-dir —— 切换 fs-watcher 的监听目标到指定子目录(Folder Browser 用)。 * * 渲染端进入子目录时调用: * await api.watchDir('notes/2026'); * 之后 fs-watcher 推送的 files:changed 事件只与该子目录相关(payload.relDir 同步)。 * * 与 startWatchingDir / scanFiles 基线的区别:startWatchingDir 会清基线并按目录类型重新初始化, * 这里直接复用工厂内部的 rewatch(),避免「切到同名目录时无谓重置基线」。 * * relDir 必须经过 resolveDirRelative 校验,防止传入绝对路径或 .. 段越权。 */ ipcMain.handle('file:watch-dir', async (event, relDir) => { const denied = guardSender(event); if (denied) return denied; const dataRoot = currentDataRoot(); const resolved = resolveDirRelative(typeof relDir === 'string' ? relDir : '', dataRoot); if (!resolved.ok) { return { ok: false, error: resolved.error, code: resolved.code, message: resolved.message }; } // audit fix (Phase O-H2):祖先链 symlink 检查。 // 之前只 lstat(absDir) 自身。fs.watch(absDir) 会跟随 symlink 解析到外部目标的 inode, // watcher 在外部目标盘上持锁并把外部目录变更以 files:changed 推送回 renderer // (payload.relDir = 内部相对路径,renderer 误以为是 dataRoot 子目录活动)。 // assertNoSymlinkAncestor 挡住祖先链含 symlink 的目标。 const ancestorCheck = await assertNoSymlinkAncestor(resolved.absDir, dataRoot); if (!ancestorCheck.ok) { return { ok: false, error: ancestorCheck.error, code: ancestorCheck.code, message: ancestorCheck.message }; } try { fsWatcher.rewatch(resolved.absDir); return { ok: true, dir: resolved.absDir, relDir: resolved.relDir }; } catch (e) { // audit fix (K1-R4):与其它 handler 对齐 code/message 形状,不再直回 e.message。 return { ok: false, error: e.code || 'WATCH_FAILED', code: e.code, message: '无法切换监听目录' }; } }); ipcMain.handle('file:read', async (event, filePath) => { const denied = guardSender(event); if (denied) return denied; if (typeof filePath !== 'string' || !filePath) { // audit fix (Round 7 IPC-1):补齐 message 字段。renderer 走 // `result.message || result.error || '未知错误'` 兜底,没 message 会退到 // 英文原文 + 文案语言漂移。 return { ok: false, error: 'INVALID_PATH', code: 'INVALID_PATH', message: '路径必须是字符串', }; } // 路径必须在当前数据目录下(防御性:防止 renderer 越权访问) if (!isWithinDataDir(filePath, currentDataRoot())) { return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' }; } // symlink 防护(audit Q3):与 file:write/rename/delete 对齐。 // 之前 fs.stat(filePath) 会跟随 symlink → dataDir 内的 symlink 指向外部文件时, // renderer 可以通过 file:read 越权读到外部文件内容。lstat 拒绝 symlink。 const linkCheck = await assertNotSymlink(filePath); if (!linkCheck.ok) { return { ok: false, error: linkCheck.error, code: linkCheck.code, message: linkCheck.message }; } if (linkCheck.isFile === false) { return { ok: false, error: 'FILE_NOT_FOUND', code: 'FILE_NOT_FOUND', message: '文件已被删除' }; } // audit fix (main-M2):assertNotSymlink 只看 filePath 自身,而 fs.stat / // fs.readFile 会跟随 filePath 祖先链上的 symlink 目录。dataDir/sub 是 symlink // 指向外部目录时,readFile 实际读到 dataDir 之外的内容。补一次祖先检查, // 与 file:write/rename/delete 的 M1 修复保持对称。 const ancestorCheck = await assertNoSymlinkAncestor(filePath, currentDataRoot()); if (!ancestorCheck.ok) { return { ok: false, error: ancestorCheck.error, code: ancestorCheck.code, message: ancestorCheck.message }; } try { // audit fix (Round 17 H2):stat / readFile 走 withTimeout —— SMB / OneDrive // 挂死时 renderer 不会无 loading 一直等;超时错误带 FS_TIMEOUT 透传到 // renderer 走「磁盘暂时不可用」文案。 const st = await withTimeout(fs.stat(filePath), undefined, 'file:read.stat'); if (!st.isFile()) { return { ok: false, error: 'NOT_A_FILE', code: 'NOT_A_FILE', message: '不是普通文件' }; } if (st.size > MAX_FILE_SIZE) { return { ok: false, error: 'FILE_TOO_LARGE', code: 'FILE_TOO_LARGE', message: `文件过大(${(st.size / 1024 / 1024).toFixed(1)} MB),已超过 ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)} MB 上限` }; } const content = await withTimeout(fs.readFile(filePath, 'utf-8'), undefined, 'file:read.read'); // audit fix (Phase L3-FS 2A):去掉 UTF-8 BOM(Notepad 等 Windows 工具会带 // 字节序标记存 UTF-8)。BOM 在第一行 heading 之前渲染成零宽字符,会让 // 文内锚点的 slug 偏移 / 复制粘贴时污染 / DOMPurify 把它当成不可见字符 // 吞掉前的不一致。原子写会原样写回字节(BOM 是三字节 0xEFBBBF), // 这里 strip 后 save 也是无 BOM,写盘时已经清掉。 let stripped = content.charCodeAt(0) === 0xFEFF ? content.slice(1) : content; // audit fix (Phase L3-FS 1A):detect binary content misnamed as .md。 // fs.readFile(..., 'utf-8') 把无效字节替换成 U+FFFD(�)。JPEG/PNG/ZIP 等 // 二进制文件被外部改名成 .md 时直接命中 EDITABLE_EXTS 走 readFile, // 用户编辑保存 → atomicWriteFile 把污染后的 U+FFFD 字符串写回,原二进制 // 内容永久丢失。简单阈值:前 4 KB 内 U+FFFD 占比 > 1% → 当作二进制拒绝。 // (正常 UTF-8 文本几乎不会有 U+FFFD;CJK 偶发坏字符也不至于达到 1%。) if (stripped.length > 0) { const probeLen = Math.min(stripped.length, 4096); let replacements = 0; for (let i = 0; i < probeLen; i += 1) { if (stripped.charCodeAt(i) === 0xFFFD) replacements += 1; } if (replacements / probeLen > 0.01) { return { ok: false, error: 'NOT_TEXT', code: 'NOT_TEXT', message: '该文件不是有效的 UTF-8 文本(疑似二进制),无法编辑', }; } } return { ok: true, content: stripped, mtimeMs: st.mtimeMs, size: st.size }; } catch (e) { if (e.code === 'ENOENT') { return { ok: false, error: 'FILE_NOT_FOUND', code: 'ENOENT', message: '文件已被删除' }; } // audit fix (K1-M2):message 之前直回 e.message(英文 errno + 完整路径), // 跟 file:write 的 renderer friendlyWriteError 不一致 —— renderer 一律走 // 「未知错误」分支。按 errno 翻译。 console.error('[main] 读取文件失败:', _redactPath(filePath), e.code || e.message); return { ok: false, error: e.code || 'READ_FAILED', code: e.code, message: _friendlyReadError(e), }; } }); /** * file:write —— 新增 expectedMtimeMs 校验(audit #2) * * renderer 传入 expectedMtimeMs(state.lastSavedMtimeMs)时,主进程先 * 读当前磁盘 mtime,不一致则拒绝写入并返回 FILE_CHANGED_EXTERNALLY。 * 这样可以挡住「外部编辑器刚刚修改 → fs:changed 还没投到 renderer → 用户 Ctrl+S * 静默覆盖外部修改」的竞态。 * * 不传 expectedMtimeMs(老调用 / 强写场景)则跳过校验。 */ ipcMain.handle('file:write', async (event, filePath, content, expectedMtimeMs = null) => { const denied = guardSender(event); if (denied) return denied; if (typeof filePath !== 'string' || !filePath) { // audit fix (Round 7 IPC-3):补齐 code + message,对齐其它 file:* handler 的 // 字段集。renderer 一律 `result.code / result.message` 取值,没这两个字段 // 会兜底走 result.error 英文原文 + 文案漂移。 return { ok: false, error: 'INVALID_PATH', code: 'INVALID_PATH', message: '路径必须是字符串', }; } if (typeof content !== 'string') { return { ok: false, error: 'INVALID_CONTENT', code: 'INVALID_CONTENT', message: '内容必须是字符串', }; } // 写入大小限制:与 file:read 对齐(MAX_FILE_SIZE),避免恶意 / bug 写入巨型文件导致下次读不出 if (Buffer.byteLength(content, 'utf8') > MAX_FILE_SIZE) { return { ok: false, error: 'FILE_TOO_LARGE', code: 'FILE_TOO_LARGE', message: `文件超过 ${MAX_FILE_SIZE} 字节上限`, }; } // 路径必须在当前数据目录下(防御性:防止 renderer 越权写入) if (!isWithinDataDir(filePath, currentDataRoot())) { return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' }; } // symlink 防护(audit #7):写入 symlink 会跟随到外部文件,绕过 isWithinDataDir const linkCheck = await assertNotSymlink(filePath); if (!linkCheck.ok) { return { ok: false, error: linkCheck.error, code: linkCheck.code, message: linkCheck.message }; } // audit fix (M1):symlink 祖先链防护 —— 父目录是 symlink 时,target 的 // 真实路径会跳出 dataDir。光检查 target 文件不够。 const ancestorCheck = await assertNoSymlinkAncestor(filePath, currentDataRoot()); if (!ancestorCheck.ok) { return { ok: false, error: ancestorCheck.error, code: ancestorCheck.code, message: ancestorCheck.message }; } if (linkCheck.isFile === false) { // audit fix (M2):assertNotSymlink 不区分「目录」与「不存在」,与 file:rename 对齐: // 命中目录 → NOT_A_FILE,让 renderer 给出「该路径是文件夹」的中文提示;命中不存在 → FILE_NOT_FOUND。 // 否则 fs.writeFile 在目录上会抛 EISDIR 但被 catch 转成通用 WRITE_FAILED,用户只看到 errno。 let st; try { st = await fs.stat(filePath); } catch { st = null; } if (st && st.isDirectory()) { return { ok: false, error: 'NOT_A_FILE', code: 'NOT_A_FILE', message: '该路径是文件夹,不能写入' }; } return { ok: false, error: 'FILE_NOT_FOUND', code: 'FILE_NOT_FOUND', message: '文件已被删除' }; } try { // mtime 校验(audit #2) if (Number.isFinite(expectedMtimeMs)) { // audit fix (Round 17 H2):stat 走 withTimeout —— 挂死磁盘不挡 IPC 响应。 const stNow = await withTimeout(fs.stat(filePath), undefined, 'file:write.mtimeCheck'); // Math.floor 兼容 sub-ms 浮点误差 if (Math.floor(stNow.mtimeMs) !== Math.floor(expectedMtimeMs)) { return { ok: false, error: 'FILE_CHANGED_EXTERNALLY', code: 'FILE_CHANGED_EXTERNALLY', mtimeMs: stNow.mtimeMs, size: stNow.size, message: '文件在外部被修改,请重新加载或选择强制覆盖', }; } } // audit fix (C1 file-IO):原子写(tmp + fsync + rename),防止进程在 // writeFile 中途崩溃导致文件内容半截(用户丢失全部编辑)。 // audit fix (Round 17 H2):原子写整体包裹 —— renameWithRetry 的总耗时 // (fs.open + writeFile + sync + rename + 父目录 sync)在 SMB 上可能 // 超 30s 时基本就是磁盘掉了。 await withTimeout( fileOps.atomicWriteFile(filePath, content), undefined, 'file:write.atomic', ); const st = await withTimeout(fs.stat(filePath), undefined, 'file:write.stat'); // audit fix (Round 16 M1):self-write filter —— 慢盘(SMB / OneDrive)下 // atomicWriteFile 触发 fs.watch 事件的时刻可能早于本 IPC 响应回到 renderer, // renderer 没拿到 mtimeMs 就收 files:changed → 误判外部修改 → 弹 reload // 对话框。告诉 fsWatcher 「这个文件接下来 500ms 内的变更是我自己」,让 // dirWatcher 跳过对应 emit。 if (fsWatcher && typeof fsWatcher.markExpectedWrite === 'function') { try { fsWatcher.markExpectedWrite(filePath, st.mtimeMs); } catch (e) { console.warn('[main] markExpectedWrite 失败:', e?.message); } } // audit fix (R7a P1-20):file:write 成功后让 file:list cache 失效。 // 文件 size / mtimeMs 都变了;走精细 diff 反而比重扫慢(一次 slice 即可), // 直接 drop 缓存更省。 invalidateFileListCache(); return { ok: true, mtimeMs: st.mtimeMs, size: st.size }; } catch (e) { if (e.code === 'ENOENT') { return { ok: false, error: 'FILE_NOT_FOUND', code: 'ENOENT', message: '文件已被删除' }; } // P1-7:权限 / 只读 / 磁盘满 / 文件被占用 → 把原始 code 透出,renderer 才能给中文提示 // // audit fix (Phase N Q-fix):message 字段从 e.message(含英文 errno + 完整 // 路径,可能泄露)改为 fileOps.friendlyWriteError(e) 翻译成中文,与 _friendlyCreateError // / _friendlyRenameError / _friendlyDeleteError / _friendlyReadError 对齐。 // helper 放在 main/file-ops.js(不是本文件)方便单测 —— main.js 加载 Electron // app 实例副作用重,没法在 vitest 里 require。 console.error('[main] 写入文件失败:', _redactPath(filePath), e.message); return { ok: false, error: e.code || 'WRITE_FAILED', code: e.code, message: fileOps.friendlyWriteError(e), }; } }); ipcMain.handle('file:create', async (event, rawName, opts) => { const denied = guardSender(event); if (denied) return denied; if (typeof rawName !== 'string') { return { ok: false, error: 'INVALID_NAME', code: 'INVALID_NAME', message: '文件名必须是字符串' }; } const dataRoot = currentDataRoot(); if (!dataRoot) return { ok: false, error: 'DATA_DIR_UNAVAILABLE', code: 'DATA_DIR_UNAVAILABLE', message: '数据目录不可用' }; // Stage 8:file:create 扩展签名以支持子目录。 // 向后兼容旧调用:(name, initialContentString) —— 旧 IPC 把 initialContent 当字符串。 // 新调用:(name, { dir?, initialContent? })。 let targetDir = dataRoot; let initialContent = ''; if (typeof opts === 'string') { initialContent = opts; } else if (opts && typeof opts === 'object') { if (typeof opts.initialContent === 'string') initialContent = opts.initialContent; if (typeof opts.dir === 'string' && opts.dir) { // dir 必须是 dataRoot 下的绝对路径或根 if (!isWithinDataDir(opts.dir, dataRoot)) { return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '目标目录不在数据目录内' }; } targetDir = opts.dir; } } // audit fix:与 file:write 对齐写入大小上限(**在 mkdir 之前**)。 // 之前 file:create 漏掉 MAX_FILE_SIZE 检查,导致 initialContent 可塞任意大字符串 // (粘贴一篇 100MB 小说)→ 下次 file:read 直接返回 FILE_TOO_LARGE,应用被自己 // 写入的文件锁死、删都删不掉(rename/delete 也可能受影响)。在这里预先拒绝。 // 放在 mkdir 之前:超大 initialContent 没必要先在磁盘上建空目录再拒绝,浪费 IO。 if (Buffer.byteLength(initialContent, 'utf8') > MAX_FILE_SIZE) { return { ok: false, error: 'FILE_TOO_LARGE', code: 'FILE_TOO_LARGE', message: `初始内容超过 ${MAX_FILE_SIZE} 字节上限`, }; } // 子目录可能不存在(用户首次进入空子目录并新建文件)→ 自动创建。 // 这是有意的:用户在 UI 上明确选择了"在该目录下新建",自动 mkdir 是合理语义。 // // audit fix (Phase O-H3):祖先链 symlink 检查必须在 mkdir 之前。 // 之前只 lstat(targetDir) 自己:targetDir 不存在时 ENOENT 跳过;然后 // mkdir(targetDir, {recursive:true}) 会跟随 symlink 在外部目标盘上真实创建 // 子目录,副作用先发生;之后 line 1307 的 assertNoSymlinkAncestor(r.path) // 才拒绝 atomicWriteFile —— 用户没创建文件但外部目标盘上多了空目录。 // assertNoSymlinkAncestor 覆盖 targetDir 自身 + 全部祖先,等价且更严格。 if (targetDir !== dataRoot) { const ancestorCheck = await assertNoSymlinkAncestor(targetDir, dataRoot); if (!ancestorCheck.ok) { return { ok: false, error: ancestorCheck.error, code: ancestorCheck.code, message: ancestorCheck.message }; } } // 记录目标子目录在创建前是否已存在:仅当我们"这次新建"的空目录才在失败路径清理, // 避免误删用户原本就存在的空子文件夹(见下方 cleanupEmptySubdir)。 const targetDirExistedBefore = await fs.access(targetDir).then(() => true).catch(() => false); try { await fs.mkdir(targetDir, { recursive: true }); } catch (e) { // audit fix (K1-H2):mkdir 失败之前直回 e.message(英文 + 含完整路径), // 与上面 atomicWrite 的 _friendlyCreateError 不对称;renderer 一律走 // 「未知错误」分支。按 errno 翻译:EACCES/EPERM = 没权限, // ENOSPC = 磁盘满,EROFS = 只读,ENOENT = 路径里有不存在组件。 console.error('[main] file:create mkdir 失败:', _redactPath(targetDir), e.code || e.message); return { ok: false, error: e.code || 'MKDIR_FAILED', code: e.code, message: _friendlyCreateError(e, '所在目录'), }; } // 标记是否新建了子目录,用于失败路径下清理空目录。 // 之前 mkdir 成功后任何下游失败(resolveFileName / isWithinDataDir / symlink // 防护 / atomicWriteFile)都会留下 targetDir 这个空子目录 —— 数据目录里 // 出现不可见的垃圾文件夹,下次扫描会列入文件列表。 // 仅当我们本次新建了子目录才清理:pre-existing 的空子文件夹不在清理范围 // (避免原子写失败时把用户原有空目录也误删)。targetDir === dataRoot 时 // targetDirExistedBefore 必为 true,同样不会清理。 const didMkdirSubdir = !targetDirExistedBefore; // best-effort 清理函数:仅当目录为空时移除(已含文件的不动,避免误删)。 async function cleanupEmptySubdir() { if (!didMkdirSubdir) return; try { const entries = await fs.readdir(targetDir); if (entries.length === 0) { await fs.rm(targetDir, { recursive: true, force: false }); } } catch (cleanupErr) { // 清理本身失败不抛 —— 让原始错误的响应路径主导。 console.warn('[main] file:create 清理空子目录失败:', _redactPath(targetDir), cleanupErr.code || cleanupErr.message); } } // 把 mkdir 之后的所有失败路径统一收到一个 catch 里,方便清理逻辑只写一份。 // 早期错误(resolveFileName / isWithinDataDir / symlink 防护)抛带 _early // 标记的结构化对象;atomicWriteFile 抛原始 Error。catch 里按标记分流。 let resolvedName = null; try { const r = await resolveFileName(rawName, targetDir); // audit fix (Round 7 IPC-2):补齐 code + message 字段,与其它 file:* handler // 对齐。resolveFileName 返回的 r.error 已经是中文明文消息("文件名不能为空" // / "文件名包含 Windows 保留字符" 等),把它同时塞 message 让 renderer 走 // friendlyFsError(code, message) 时不再兜底到 result.error 字符串。 if (!r.ok) { throw Object.assign(new Error(r.error), { _early: { error: 'INVALID_NAME', code: 'INVALID_NAME', message: r.error } }); } // audit fix (1.1):与其他 file:* handler 对称地做 isWithinDataDir 防御 + symlink 防护。 // 理论上是新文件不会有 symlink,但 resolveFileName 已过滤路径分隔符 / '..', // 这层只为了与 read/write/rename/delete 保持对称,且挡住未来可能的边界输入。 if (!isWithinDataDir(r.path, dataRoot)) { throw Object.assign(new Error('路径不在数据目录内'), { _early: { error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' } }); } const linkCheck = await assertNotSymlink(r.path); if (!linkCheck.ok) { throw Object.assign(new Error(linkCheck.message || 'symlink 防护拒绝'), { _early: linkCheck }); } // audit fix:与 file:read/write/rename/delete 对称做 assertNoSymlinkAncestor。 // file:create 的 targetDir 上面只 lstat 了自己(如果非根),但 r.path 落在某个 // 中间是 symlink 的目录下时(如 dataRoot/links/sub/foo.md,其中 links 是 // 指向外部的 symlink),atomicWriteFile 会跟随 symlink 把文件写到外部, // 绕过 isWithinDataDir 的字符串前缀边界。assertNotSymlink 只判 r.path 本身 // (不存在 → 放过),挡不住中间路径是 symlink 的场景。 const ancestorCheck = await assertNoSymlinkAncestor(r.path, dataRoot); if (!ancestorCheck.ok) { throw Object.assign(new Error(ancestorCheck.message || '祖先链 symlink 防护拒绝'), { _early: ancestorCheck }); } // 初始内容默认为 `# 标题\n\n` —— 哪怕空字符串也保证文件可读 // 2026-08-28 反馈:resolveFileName 不再强制补 .md,所以这里按最后一个 . // 剥除任意扩展名(foo.md → foo;foo.txt → foo;无扩展名 → 原样)。 const baseForTitle = r.name.replace(/\.[^./\\]+$/, ''); const content = typeof initialContent === 'string' && initialContent.length > 0 ? initialContent : `# ${baseForTitle}\n\n`; // audit fix (C2 file-IO):原子写,与 file:write 对齐。 // 即便 create 失败也不该留半截文件——磁盘要么没这个文件,要么是完整的。 // audit fix (Round 17 H2):包裹 withTimeout —— 与 file:write 同源。 await withTimeout( fileOps.atomicWriteFile(r.path, content), undefined, 'file:create.atomic', ); resolvedName = r.name; const st = await withTimeout(fs.stat(r.path), undefined, 'file:create.stat'); // audit fix (Round 16 M1):self-write filter —— 与 file:write 同源(见 // 上方 file:write 的注释)。新建文件的 fs.watch 事件同样会在 IPC 响应 // 之前到达 renderer,导致侧栏触发不必要的重渲染 / 「文件已存在」误判。 if (fsWatcher && typeof fsWatcher.markExpectedWrite === 'function') { try { fsWatcher.markExpectedWrite(r.path, st.mtimeMs); } catch (e) { console.warn('[main] markExpectedWrite 失败:', e?.message); } } // audit fix (R7a P1-20):file:create 成功后让 file:list cache 失效(新增条目)。 invalidateFileListCache(); return { ok: true, path: r.path, name: r.name, mtimeMs: st.mtimeMs, size: st.size }; } catch (e) { // 失败路径:先清理可能新建的空子目录,再按错误类型返回响应。 await cleanupEmptySubdir(); if (e && e._early) { // 早期错误(resolveFileName / isWithinDataDir / symlink 防护):直接透传结构化字段。 return { ok: false, error: e._early.error, code: e._early.code, message: e._early.message }; } // atomicWriteFile 错误:按 errno 翻译成中文提示,路径走 _redactPath 防泄露到 tmp 日志。 console.error('[main] file:create 失败:', _redactPath(targetDir + '/' + rawName), e.code || e.message); return { ok: false, error: e.code || 'CREATE_FAILED', code: e.code, message: _friendlyCreateError(e, resolvedName || rawName), }; } }); /** * folder:create —— 在指定目录下新建文件夹。 * * 设计要点: * - 与 file:create 对称: * symlink 防护(assertNotSymlink + assertNoSymlinkAncestor)→ * resolveFolderName(输入合法性 + 重名避让)→ * mkdir → fsync parent → 返回 {path, name} 强同步视图 * - targetDir 缺省 = dataRoot;可在 opts.dir 指定 POSIX 相对子目录。 * 子目录不存在则自动 mkdir recursive(一层);symlink 父链被 assertNoSymlinkAncestor 拦下。 * - 不递归创建深链 —— 只在 opts.dir 那一层做 mkdir recursive(resolveFolderName 之前的 mkdir), * 与 file:create 同源语义:用户主动「在这里建文件夹」 → 自动创建所在目录合理。 * - 不写文件:mkdir 之后不原子写任何东西。返回字段不含 mtimeMs/size(folder 没有这两项内容展示)。 * * 失败路径语义: * - INVALID_NAME:resolveFolderName 返回 !ok(捕获在 _early) * - PATH_NOT_ALLOWED / SYMLINK_NOT_ALLOWED:防御栈统一返回 * - mkdir errno:EEXIST(已被 resolveFolderName 避让,正常不会撞)/ ENOENT(祖先链断)/ * EACCES/EPERM(无权限)走统一 _friendly*Error 翻译 * - 任何 mkdir 后的失败路径都做 best-effort 清理(仿 file:create 行为): * 只清空子目录,不动根 dataRoot */ ipcMain.handle('folder:create', async (event, rawName, opts) => { const denied = guardSender(event); if (denied) return denied; if (typeof rawName !== 'string') { return { ok: false, error: 'INVALID_NAME', code: 'INVALID_NAME', message: '文件夹名必须是字符串' }; } const dataRoot = currentDataRoot(); if (!dataRoot) return { ok: false, error: 'DATA_DIR_UNAVAILABLE', code: 'DATA_DIR_UNAVAILABLE', message: '数据目录不可用' }; // opts 处理:可选 { dir } —— root 下还是某个子目录下 let targetDir = dataRoot; if (opts && typeof opts === 'object' && typeof opts.dir === 'string' && opts.dir) { if (!isWithinDataDir(opts.dir, dataRoot)) { return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '目标目录不在数据目录内' }; } targetDir = opts.dir; } // 关键防御:祖先链 symlink 检查必须在 mkdir 之前 —— 与 file:create 同源(见 line 1580 注释)。 if (targetDir !== dataRoot) { const ancestorCheck = await assertNoSymlinkAncestor(targetDir, dataRoot); if (!ancestorCheck.ok) { return { ok: false, error: ancestorCheck.error, code: ancestorCheck.code, message: ancestorCheck.message }; } } let didMkdirSubdir = false; try { // mkdir recursive:targetDir 不存在则自动创建(与 file:create 同源); // 已存在 → noop(下面 resolveFolderName 内的 pathOccupancy 仍会判定 finalPath 是否冲突) await fs.mkdir(targetDir, { recursive: true }); if (targetDir !== dataRoot) didMkdirSubdir = true; } catch (e) { console.error('[main] folder:create mkdir 失败:', _redactPath(targetDir), e.code || e.message); return { ok: false, error: e.code || 'MKDIR_FAILED', code: e.code, message: _friendlyCreateError(e, '所在目录'), }; } // mkdir 成功后失败路径清理 —— 仅当新建了子目录时清(root 不清) async function cleanupEmptySubdir() { if (!didMkdirSubdir) return; try { const entries = await fs.readdir(targetDir); if (entries.length === 0) { await fs.rm(targetDir, { recursive: true, force: false }); } } catch (cleanupErr) { console.warn('[main] folder:create 清理空子目录失败:', _redactPath(targetDir), cleanupErr.code || cleanupErr.message); } } try { const r = await resolveFolderName(rawName, targetDir); if (!r.ok) { throw Object.assign(new Error(r.error), { _early: { error: 'INVALID_NAME', code: 'INVALID_NAME', message: r.error } }); } if (!isWithinDataDir(r.path, dataRoot)) { throw Object.assign(new Error('路径不在数据目录内'), { _early: { error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' } }); } // target path 的祖先链复查(防御 TOCTOU:resolveFolderName 与 mkdir 之间可能有竞态) const finalAncestorCheck = await assertNoSymlinkAncestor(r.path, dataRoot); if (!finalAncestorCheck.ok) { throw Object.assign(new Error(finalAncestorCheck.message || '祖先链 symlink 防护拒绝'), { _early: finalAncestorCheck }); } // mkdir(target):already-exists 走 noop(resolveFolderName 已避让,正常不应撞); // 同步 fsync 父目录(POSIX 下与 atomicWriteFile 同源语义 —— 「目录项写入磁盘」语义) // mkdir 本身在 fsync 之前;POSIX-only 防御,Win NTFS journal 自带元数据持久化无需 fsync。 try { await withTimeout(fs.mkdir(r.path, { recursive: false }), undefined, 'folder:create.mkdir-target'); } catch (e) { if (e.code === 'EEXIST') { // 极小概率:resolveFolderName 后某个并发进程恰好放入同名 file/dir → 回到避让再试一次 throw Object.assign(new Error('目标路径已被占用'), { _early: { error: 'PATH_CONFLICT', code: 'EEXIST', message: '该名称已被占用' } }); } throw e; } if (process.platform !== 'win32') { // 与 atomicWriteFile 同源:POSIX rename/mkdir 后 fsync 父目录项 const dirFh = await fs.open(targetDir, 'r'); try { await dirFh.sync(); } finally { try { await dirFh.close(); } catch { /* ignore */ } } } invalidateFileListCache(); return { ok: true, path: r.path, name: r.name }; } catch (e) { await cleanupEmptySubdir(); if (e && e._early) { return { ok: false, error: e._early.error, code: e._early.code, message: e._early.message }; } console.error('[main] folder:create 失败:', _redactPath(targetDir + '/' + rawName), e.code || e.message); return { ok: false, error: e.code || 'CREATE_FOLDER_FAILED', code: e.code, message: _friendlyCreateError(e, rawName), }; } }); /** * file:rename —— 修复两点(audit #4 + audit #12 + audit #7): * - 返回 mtimeMs / size 让 renderer 立刻同步侧边栏与状态栏 * - Windows 上大小写不敏感比对(path.resolve 保留原大小写) * - symlink 防护:拒绝重命名 symlink */ ipcMain.handle('file:rename', async (event, oldPath, newName) => { const denied = guardSender(event); if (denied) return denied; if (typeof oldPath !== 'string' || typeof newName !== 'string') { return { ok: false, error: 'INVALID_ARGS', code: 'INVALID_ARGS', message: '参数类型不合法' }; } const dir = currentDataRoot(); if (!isWithinDataDir(oldPath, dir)) { return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '原路径不在数据目录内' }; } // symlink 防护(audit #7) const linkCheck = await assertNotSymlink(oldPath); if (!linkCheck.ok) { return { ok: false, error: linkCheck.error, code: linkCheck.code, message: linkCheck.message }; } // audit fix (M4 main):拒绝目录 —— file:rename 契约是「重命名文件」。 // assertNotSymlink 在 isFile=false 时不区分「目录」与「不存在」,调用方 // 以前会把目录也当作文件 rename 进去(POSIX 上 fs.rename 真能把目录改名), // 与 file:create / file:delete 的 file-only 契约不一致。把目录提前拒掉, // 让 Folder Browser 用专门的 folder:rename 路径(见后)。 if (linkCheck.isFile === false) { let st; try { st = await fs.stat(oldPath); } catch { st = null; } if (st && st.isDirectory()) { return { ok: false, error: 'NOT_A_FILE', code: 'NOT_A_FILE', message: '该路径是文件夹,请使用文件夹重命名' }; } return { ok: false, error: 'FILE_NOT_FOUND', code: 'FILE_NOT_FOUND', message: '文件已被删除' }; } // audit fix (M1):原路径父目录链上的 symlink 也要拒 —— 否则 rename 会 // 跟随到 dataDir 外。 const oldAncestorCheck = await assertNoSymlinkAncestor(oldPath, dir); if (!oldAncestorCheck.ok) { return { ok: false, error: oldAncestorCheck.error, code: oldAncestorCheck.code, message: oldAncestorCheck.message }; } // 解析新文件名(重命名专用:不强制 .md 后缀,用户输入什么就用什么 —— 见 [[project-no-auto-md-on-rename-2026-08]]; // 仍做路径分隔符 / Windows 保留字符 / 控制字符 / 重名避让校验)。 // 解析新文件名(重命名专用):用文件自身的父目录而非 dataRoot 作为 join 基准, // 否则嵌套目录里的文件重命名会被静默移到根目录顶层(audit 修复)。 const r = await resolveRenameName(newName, path.dirname(oldPath)); // audit fix (Round 7 IPC-3):补齐 message 字段,并保证 code 一定存在。 // 旧版直接 `code: r.code`,但 resolveRenameName 历史上不返回 code 字段 // (参见 main/file-ops.js#resolveRenameName:返回 `{ ok:false, error }`), // 导致 code=undefined → renderer 兜底走 result.error 文案语言漂移。 // 这里给所有失败分支兜底 code='INVALID_NAME'(rename 路径专属)。 if (!r.ok) { return { ok: false, error: 'INVALID_NAME', code: 'INVALID_NAME', message: r.error, }; } // audit 防御性修复:resolveFileName 内部已 access 失败才返回 r.path, // 但 resolve 与 rename 之间存在 TOCTOU 窗口(本地攻击者 / 并发进程可能 // 在 r.path 位置抢先放一个 symlink,fs.rename 在 POSIX 上会跟随 symlink // 重命名到外部目标)。在 rename 前再 lstat 一次,命中 symlink 就拒。 // 若中间有进程在该位置放了普通文件,fs.rename 会自己撞 EEXIST。 const targetLinkCheck = await assertNotSymlink(r.path); if (!targetLinkCheck.ok) { return { ok: false, error: targetLinkCheck.error, code: targetLinkCheck.code, message: targetLinkCheck.message }; } // audit fix (M1):新路径的父目录链同样要无 symlink。 const newAncestorCheck = await assertNoSymlinkAncestor(r.path, dir); if (!newAncestorCheck.ok) { return { ok: false, error: newAncestorCheck.error, code: newAncestorCheck.code, message: newAncestorCheck.message }; } // 新路径不能等于原路径(Windows 上大小写不敏感比对,audit #12)。 // 同名 rename(basename 相同)会被下方的 isSamePath 捕获 —— r.path 是 // `path.join(dirname(oldPath), newName)`,basename 相同时必然等于 oldPath, // 走 unchanged:true + stat 早返回,不需要额外的 isSameName 分支。 const oldResolved = path.resolve(oldPath); const newResolved = path.resolve(r.path); const isSamePath = process.platform === 'win32' ? oldResolved.toLowerCase() === newResolved.toLowerCase() : oldResolved === newResolved; if (isSamePath) { // 同名 short-circuit 时拿真实 mtime/size(状态栏需要)。 // 若 stat 失败(权限 / 瞬时错误),不要用 0 值污染调用方 —— 状态栏会显示 // 1970-01-01 让用户怀疑数据丢失。直接返回错误,让 UI 不被 0 值污染。 let st; try { st = await fs.stat(oldPath); } catch (e) { return { ok: false, error: e.code || 'STAT_FAILED', code: e.code, message: '读取文件状态失败', }; } return { ok: true, path: r.path, name: r.name, unchanged: true, mtimeMs: st.mtimeMs, size: st.size, }; } try { // audit fix (Round 17 H2):rename 走 withTimeout —— SMB / OneDrive 持锁时 // rename 可能挂死数分钟;超时透传 FS_TIMEOUT 让 renderer 弹友好提示。 await withTimeout(fs.rename(oldPath, r.path), undefined, 'file:rename.rename'); // P1-6:rename 成功后 fsync 父目录(POSIX 强同步视图)。失败只 warn: // 文件已改名成功,崩溃时只是「新名可能不持久」,回滚成本高于收益。 const fsyncResult = await fsyncParentDir(r.path); if (!fsyncResult.ok) { console.warn('[main] file:rename fsync 父目录失败(不影响 rename 成功):', fsyncResult.error); } // 重新 stat 拿到新 mtime/size(audit #4:之前漏掉,renderer 状态栏会短暂无日期) const st = await withTimeout(fs.stat(r.path), undefined, 'file:rename.stat'); // audit fix (Round 16 M1):self-write filter —— rename 触发 fs.watch 两个 // 事件(oldPath 消失 + r.path 出现)。只要新路径上 500ms 内是 self-write 就 // 拦住后续 emit,避免 renderer 把「自己刚重命名」当成外部修改触发侧栏闪烁。 if (fsWatcher && typeof fsWatcher.markExpectedWrite === 'function') { try { fsWatcher.markExpectedWrite(r.path, st.mtimeMs); } catch (e) { console.warn('[main] markExpectedWrite 失败:', e?.message); } } // audit fix (R7a P1-20):file:rename 成功后让 file:list cache 失效 // (name / path 都变了,缓存里旧条目失效)。 invalidateFileListCache(); return { ok: true, path: r.path, name: r.name, mtimeMs: st.mtimeMs, size: st.size }; } catch (e) { // audit fix (K1-H1):console 走 _redactPath(之前 e.message 直接打到日志, // 含完整旧路径,tmp 日志 / heap snapshot 里泄漏);message 走 _friendlyRenameError // 翻译成中文,不让英文 errno + 完整路径直接回到 renderer。 console.error('[main] file:rename 失败:', _redactPath(oldPath), '→', _redactPath(r.path), e.code || e.message); return { ok: false, error: e.code || 'RENAME_FAILED', code: e.code, message: _friendlyRenameError(e, r.name), }; } }); /** * folder:rename —— 重命名一个文件夹。镜像 file:rename 的防御栈,但: * - 旧路径必须存在且**是目录**(不是文件、不是不存在) * - 新路径在父目录下(folder rename 不跨父目录) * - 用 resolveRenameFolderName 校验 + 重名避让(无扩展名切割,与 file 版本对齐) * * 返回 { ok:true, path, name } 让 renderer 把 state.currentDir / breadcrumb / fs-watcher 都同步过去。 * self-rename(新名 === 旧名)走 short-circuit,返回 unchanged:true。 */ ipcMain.handle('folder:rename', async (event, oldPath, newName) => { const denied = guardSender(event); if (denied) return denied; if (typeof oldPath !== 'string' || typeof newName !== 'string') { return { ok: false, error: 'INVALID_ARGS', code: 'INVALID_ARGS', message: '参数类型不合法' }; } const dataRoot = currentDataRoot(); if (!isWithinDataDir(oldPath, dataRoot)) { return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '原路径不在数据目录内' }; } // 原路径必须是目录(folder:rename 契约)。不像 file:rename 需要查 isFile 区分, // 这里反过来:要的就是目录、文件则拒。ENOENT 也拒(防止「重命名不存在的文件夹」)。 let oldStat; try { oldStat = await fs.stat(oldPath); } catch (e) { if (e.code === 'ENOENT') { return { ok: false, error: 'FOLDER_NOT_FOUND', code: 'FOLDER_NOT_FOUND', message: '文件夹已被删除' }; } return { ok: false, error: e.code || 'STAT_FAILED', code: e.code, message: _friendlyShellError(e, '读取状态失败') }; } if (!oldStat.isDirectory()) { return { ok: false, error: 'NOT_A_FOLDER', code: 'NOT_A_FOLDER', message: '该路径不是文件夹' }; } // 原路径祖先链 symlink 防护 const oldAncestorCheck = await assertNoSymlinkAncestor(oldPath, dataRoot); if (!oldAncestorCheck.ok) { return { ok: false, error: oldAncestorCheck.error, code: oldAncestorCheck.code, message: oldAncestorCheck.message }; } // symlink 拒绝(数据目录里不应出现 symlink 目录,但万一同步盘 / 误操作塞进来) const linkCheck = await assertNotSymlink(oldPath); if (!linkCheck.ok) { return { ok: false, error: linkCheck.error, code: linkCheck.code, message: linkCheck.message }; } // 解析新名(重命名专用,无扩展名切割;带字符校验 + 重名避让) const parentDir = path.dirname(oldPath); const r = await resolveRenameFolderName(newName, parentDir); if (!r.ok) { return { ok: false, error: 'INVALID_NAME', code: 'INVALID_NAME', message: r.error }; } // 新路径必须在父目录下(folder rename 不跨父目录 —— 即使 newName 没 /,resolveRenameFolderName 也只在 parentDir 内 join) if (!isWithinDataDir(r.path, parentDir)) { return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '新路径不在原文件夹内' }; } const targetLinkCheck = await assertNotSymlink(r.path); if (!targetLinkCheck.ok) { return { ok: false, error: targetLinkCheck.error, code: targetLinkCheck.code, message: targetLinkCheck.message }; } const newAncestorCheck = await assertNoSymlinkAncestor(r.path, dataRoot); if (!newAncestorCheck.ok) { return { ok: false, error: newAncestorCheck.error, code: newAncestorCheck.code, message: newAncestorCheck.message }; } // 同名 short-circuit(Windows 大小写不敏感比对) const oldResolved = path.resolve(oldPath); const newResolved = path.resolve(r.path); const isSamePath = process.platform === 'win32' ? oldResolved.toLowerCase() === newResolved.toLowerCase() : oldResolved === newResolved; if (isSamePath) { return { ok: true, path: r.path, name: r.name, unchanged: true }; } try { await withTimeout(fs.rename(oldPath, r.path), undefined, 'folder:rename.rename'); // P1-6:rename 成功后 fsync 父目录(POSIX 强同步视图)。失败只 warn: // 文件夹已改名成功,崩溃时只是「新名可能不持久」,回滚成本高于收益。 const fsyncResult = await fsyncParentDir(r.path); if (!fsyncResult.ok) { console.warn('[main] folder:rename fsync 父目录失败(不影响 rename 成功):', fsyncResult.error); } // audit fix (Round 16 M1):self-write filter —— rename 触发 fs.watch 事件(旧路径消失 + 新路径出现)。 // rename 不修改 mtime(POSIX rename(2) 保留 inode mtime),但 window 上的 fs.watch // 仍会 emit;先标 self-write 让 fsWatcher 在 500ms 内不推向 renderer。 if (fsWatcher && typeof fsWatcher.markExpectedWrite === 'function') { // folder 没有 mtime,用 0 占位标记路径即可 —— markExpectedWrite 已支持这种 fallback try { fsWatcher.markExpectedWrite(r.path, 0); } catch (e) { console.warn('[main] markExpectedWrite(folder) 失败:', e?.message); } } // 同时把旧路径也标上,避免 fs-watcher 推「旧路径已不在」被误读为外部删除 if (fsWatcher && typeof fsWatcher.markExpectedWrite === 'function') { try { fsWatcher.markExpectedWrite(oldPath, 0); } catch (e) { console.warn('[main] markExpectedWrite(folder-old) 失败:', e?.message); } } invalidateFileListCache(); return { ok: true, path: r.path, name: r.name }; } catch (e) { console.error('[main] folder:rename 失败:', _redactPath(oldPath), '→', _redactPath(r.path), e.code || e.message); return { ok: false, error: e.code || 'RENAME_FAILED', code: e.code, message: _friendlyRenameError(e, r.name), }; } }); /** * file:delete —— 修复一点(audit #7): * - symlink 防护:拒绝删除 symlink(避免 trashItem 跟随到外部真实文件) * - Stage 8:支持删除文件夹(Folder Browser)。空判断逻辑: * `assertNotSymlink` 返回 isFile:false 可能是「目录」或「不存在」; * 用 stat 二次判定,存在但不是文件 → 当作目录走 trashItem。 */ ipcMain.handle('file:delete', async (event, filePath) => { const denied = guardSender(event); if (denied) return denied; if (typeof filePath !== 'string' || !filePath) { return { ok: false, error: 'INVALID_PATH', code: 'INVALID_PATH', message: '路径必须是字符串' }; } if (!isWithinDataDir(filePath, currentDataRoot())) { return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' }; } // symlink 防护(audit #7) const linkCheck = await assertNotSymlink(filePath); if (!linkCheck.ok) { return { ok: false, error: linkCheck.error, code: linkCheck.code, message: linkCheck.message }; } // audit fix (M1):祖先链 symlink 防护 —— 父目录是 symlink 时 trashItem // 会跟随到外部真实文件,造成越权删除。 const ancestorCheck = await assertNoSymlinkAncestor(filePath, currentDataRoot()); if (!ancestorCheck.ok) { return { ok: false, error: ancestorCheck.error, code: ancestorCheck.code, message: ancestorCheck.message }; } // isFile=false 时再 stat 一次区分「目录」与「不存在」; // 目录走 trashItem(macOS/Windows 原生支持),ENOENT 报 FILE_NOT_FOUND。 if (linkCheck.isFile === false) { let st; try { st = await fs.stat(filePath); } catch (e) { if (e.code === 'ENOENT') { return { ok: false, error: 'FILE_NOT_FOUND', code: 'ENOENT', message: '路径已不存在' }; } // audit fix (K1-R4):与其它 handler 对齐 code/message 形状,不再直回 e.message。 return { ok: false, error: e.code || 'STAT_FAILED', code: e.code, message: _friendlyShellError(e, '读取状态失败') }; } if (!st.isDirectory()) { return { ok: false, error: 'NOT_A_FILE_OR_DIR', code: 'NOT_A_FILE_OR_DIR', message: '该路径既不是文件也不是目录' }; } // 走系统回收站 —— 目录也行;失败兜底 fs.rmdir 删空目录 try { await shell.trashItem(filePath); // audit fix (R7a P1-20):file:delete 目录成功后让 file:list cache 失效。 invalidateFileListCache(); return { ok: true, path: filePath, recycled: true, isFolder: true }; } catch (e) { console.warn('[main] trashItem(目录) 失败,降级到 rm:', e.message); try { await fs.rm(filePath, { recursive: true, force: false }); // audit fix (R7a P1-20):同上(rmdir 降级路径)。 invalidateFileListCache(); return { ok: true, path: filePath, recycled: false, isFolder: true }; } catch (e2) { // 不拼 e2.message(英文 errno + 路径)—— 其它 file:* handler 都用纯中文 message return { ok: false, error: e2.code || 'DELETE_DIR_FAILED', code: e2.code, message: '删除目录失败(可能非空)', }; } } } // 文件路径:走系统回收站,失败时**不再静默 unlink**。 // 旧版「trashItem 失败 → fallback unlink」在以下场景会让用户永久失去文件: // - 回收站被禁用 / 满(Linux + 部分 Windows 配置) // - trashItem 因权限 / EBUSY 失败 // - 文件在外部存储 / 网络盘等 trash 不可达的位置 // 现在:trashItem 失败时直接报错,让 UI 提示用户(而不是悄悄 unlink 把文件抹掉)。 try { // audit fix (Round 17 H2):trashItem 也走 withTimeout —— 网络盘 trash 不可达时 // Electron 内部可能挂死(直到 trash 处理超时才会 reject)。30s 强制让 // renderer 拿到结果。 await withTimeout(shell.trashItem(filePath), undefined, 'file:delete.trash'); // audit fix (R7a P1-20):file:delete 文件成功后让 file:list cache 失效。 invalidateFileListCache(); return { ok: true, path: filePath, recycled: true }; } catch (e) { // audit fix (K1-L2):之前 message 拼 e.message(英文 errno + 路径)回到 renderer // —— 漏完整路径到 UI。其他 file:* handler 都用纯中文 message,这里对齐。 // 按 errno 翻译:EACCES/EPERM = 没权限、EBUSY = 文件被占、ENOENT = 已不在。 console.warn('[main] shell.trashItem 失败,不降级 unlink:', _redactPath(filePath), e.code || e.message); return { ok: false, error: 'TRASH_FAILED', code: e.code || 'TRASH_FAILED', message: _friendlyDeleteError(e), }; } }); /** * file:move —— 把文件 / 文件夹移动到目标文件夹(跨目录 rename,同盘原子)。 * 拖拽到文件夹 = move;右键「剪切 → 粘贴」也走这里。 * * 防御栈与 file:rename 同源: * - guardSender + 参数类型校验 * - isWithinDataDir(src 与 dest 都在数据目录内) * - assertNotSymlink + assertNoSymlinkAncestor(src 与 dest 两侧) * - 目标必须是真实存在的目录;源必须存在 * - 文件夹不能移入自身或其子孙(prevent 循环) * - 目标名走 resolveRenameName / resolveRenameFolderName 做合法性 + 重名避让 * - rename 成功 → fsync 父目录 + markExpectedWrite + invalidateFileListCache */ ipcMain.handle('file:move', async (event, srcPath, destDir) => { const prepared = await _prepareRelocate(event, srcPath, destDir); if (prepared.error) return prepared.error; const { srcPath: src, destPath, destName, srcIsDir } = prepared.value; try { await withTimeout(renameWithRetry(src, destPath), undefined, 'file:move.rename'); // 重命名后 stat 拿 mtime/size(status bar 需要);文件夹无 mtime 概念用 0 占位 const st = srcIsDir ? null : await withTimeout(fs.stat(destPath), undefined, 'file:move.stat'); const fsyncResult = await fsyncParentDir(destPath); if (!fsyncResult.ok) { console.warn('[main] file:move fsync 父目录失败(不影响移动成功):', fsyncResult.error); } if (fsWatcher && typeof fsWatcher.markExpectedWrite === 'function') { try { fsWatcher.markExpectedWrite(destPath, srcIsDir ? 0 : st.mtimeMs); } catch (e) { console.warn('[main] markExpectedWrite(file) 失败:', e?.message); } } invalidateFileListCache(); // 移动目标所在目录的 POSIX 相对路径(相对 dataRoot),供 renderer 同步 // state.currentFileRelDir:移动当前打开的文件后,onFilesChanged 才不会把 // 旧目录的"文件已移除"事件误判为"打开的文件被删除"(见 src/file-ops.js _applyMoveToOpenFile)。 // 走 toRelativeDir helper(main/file-ops.js 导出)而非内联 normalize —— 主进程多处 // (fs-watcher、file:list)共用同一份 POSIX 相对路径语义,不在此处再实现一遍。 const movedRelDir = toRelativeDir(path.dirname(destPath), currentDataRoot()); return srcIsDir ? { ok: true, path: destPath, name: destName, entryType: 'folder', relDir: movedRelDir } : { ok: true, path: destPath, name: destName, entryType: 'file', mtimeMs: st.mtimeMs, size: st.size, relDir: movedRelDir }; } catch (e) { console.error('[main] file:move 失败:', _redactPath(src), '→', _redactPath(destPath), e.code || e.message); return { ok: false, error: e.code || 'MOVE_FAILED', code: e.code, message: _friendlyRenameError(e, destName), }; } }); /** * file:copy —— 把文件 / 文件夹复制到目标文件夹。 * 拖拽(按住 Ctrl/Cmd)到文件夹 = copy;右键「复制 → 粘贴」也走这里。 * * 防御栈与 file:move 同源(见 _prepareRelocate)。 * 文件 → fs.copyFile;文件夹 → recursiveCopy(跳过 symlink、逐文件复制)。 * 复制失败 → 清理半成品目标,避免留下半截目录。 */ ipcMain.handle('file:copy', async (event, srcPath, destDir) => { const prepared = await _prepareRelocate(event, srcPath, destDir); if (prepared.error) return prepared.error; const { srcPath: src, destPath, destName, srcIsDir } = prepared.value; try { if (srcIsDir) { await withTimeout(recursiveCopy(src, destPath), undefined, 'file:copy.recursive'); } else { // recursiveCopy 内部已 mkdir 目标目录;单文件复制需先确保父目录存在 // (destDir 已确认存在,destPath 是 destDir 下的新名字,父目录即 destDir) await withTimeout(fs.copyFile(src, destPath), undefined, 'file:copy.copyFile'); } const st = srcIsDir ? null : await withTimeout(fs.stat(destPath), undefined, 'file:copy.stat'); const fsyncResult = await fsyncParentDir(destPath); if (!fsyncResult.ok) { console.warn('[main] file:copy fsync 父目录失败(不影响复制成功):', fsyncResult.error); } if (fsWatcher && typeof fsWatcher.markExpectedWrite === 'function') { try { fsWatcher.markExpectedWrite(destPath, srcIsDir ? 0 : st.mtimeMs); } catch (e) { console.warn('[main] markExpectedWrite(file) 失败:', e?.message); } } invalidateFileListCache(); return srcIsDir ? { ok: true, path: destPath, name: destName, entryType: 'folder' } : { ok: true, path: destPath, name: destName, entryType: 'file', mtimeMs: st.mtimeMs, size: st.size }; } catch (e) { // 复制失败清理半成品目标,避免留下半截目录 / 文件 try { await fs.rm(destPath, { recursive: true, force: true }); } catch { /* ignore */ } console.error('[main] file:copy 失败:', _redactPath(src), '→', _redactPath(destPath), e.code || e.message); return { ok: false, error: e.code || 'COPY_FAILED', code: e.code, message: _friendlyCopyError(e, destName), }; } }); /** * 移动 / 复制共用的前置校验(file:move / file:copy 均走这里)。 * 返回 { error: }(失败时)或 { value: {...} }(通过时)。 * envelope 形状统一 { ok:false, error, code?, message },与 file:rename 等对齐。 */ async function _prepareRelocate(event, srcPath, destDir) { const denied = guardSender(event); if (denied) return { error: denied }; if (typeof srcPath !== 'string' || !srcPath || typeof destDir !== 'string' || !destDir) { return { error: { ok: false, error: 'INVALID_ARGS', code: 'INVALID_ARGS', message: '参数类型不合法' } }; } const dataRoot = currentDataRoot(); if (!dataRoot) { return { error: { ok: false, error: 'DATA_DIR_UNAVAILABLE', code: 'DATA_DIR_UNAVAILABLE', message: '数据目录不可用' } }; } if (!isWithinDataDir(srcPath, dataRoot)) { return { error: { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '源路径不在数据目录内' } }; } if (!isWithinDataDir(destDir, dataRoot)) { return { error: { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '目标目录不在数据目录内' } }; } // symlink 防护(audit #7):src / dest 两侧都不允许含 symlink const linkCheck = await assertNotSymlink(srcPath); if (!linkCheck.ok) { return { error: { ok: false, error: linkCheck.error, code: linkCheck.code, message: linkCheck.message } }; } const srcAncestorCheck = await assertNoSymlinkAncestor(srcPath, dataRoot); if (!srcAncestorCheck.ok) { return { error: { ok: false, error: srcAncestorCheck.error, code: srcAncestorCheck.code, message: srcAncestorCheck.message } }; } const destAncestorCheck = await assertNoSymlinkAncestor(destDir, dataRoot); if (!destAncestorCheck.ok) { return { error: { ok: false, error: destAncestorCheck.error, code: destAncestorCheck.code, message: destAncestorCheck.message } }; } // 目标必须是真实存在的目录 let destStat; try { destStat = await fs.stat(destDir); } catch (e) { if (e.code === 'ENOENT') { return { error: { ok: false, error: 'FOLDER_NOT_FOUND', code: 'ENOENT', message: '目标文件夹已不存在' } }; } return { error: { ok: false, error: e.code || 'STAT_FAILED', code: e.code, message: _friendlyShellError(e, '读取目标状态失败') } }; } if (!destStat.isDirectory()) { return { error: { ok: false, error: 'NOT_A_DIRECTORY', code: 'NOT_A_DIRECTORY', message: '目标路径不是文件夹' } }; } // 源必须存在 + 确定类型 let srcStat; try { srcStat = await fs.stat(srcPath); } catch (e) { if (e.code === 'ENOENT') { return { error: { ok: false, error: 'FILE_NOT_FOUND', code: 'ENOENT', message: '源文件已被删除' } }; } return { error: { ok: false, error: e.code || 'STAT_FAILED', code: e.code, message: _friendlyShellError(e, '读取源状态失败') } }; } const srcIsDir = srcStat.isDirectory(); // 文件夹不能移入自身或其子孙(防止循环 / 路径穿越)。 // audit #12 惯例(见 file:rename 的 isSamePath):先 resolve、win32 再折叠 // 大小写 —— path 字符串可能以混合拼写到达('d:/notes/A' vs 'D:\notes\A', // 同一 inode:陈旧 clipboard 跨 data-dir 重选 / 手改 config.json),原始串 // 比对会绕过本守卫,之后 recursiveCopy 把副本套进自身目录无限嵌套直到 // ENAMETOOLONG(move 场景则是 rename 到自身产生静默改名)。 if (srcIsDir) { const fold = (s) => (process.platform === 'win32' ? s.toLowerCase() : s); const srcFolded = fold(path.resolve(srcPath)); const destFolded = fold(path.resolve(destDir)); if (destFolded === srcFolded || destFolded.startsWith(srcFolded + path.sep)) { return { error: { ok: false, error: 'TARGET_INSIDE_SOURCE', code: 'TARGET_INSIDE_SOURCE', message: '不能把文件夹移入自身或其子文件夹' } }; } } // 解析目标名 + 重名避让(文件 / 文件夹走各自 resolver) const srcBase = path.basename(srcPath); const r = srcIsDir ? await resolveRenameFolderName(srcBase, destDir) : await resolveRenameName(srcBase, destDir); if (!r.ok) { return { error: { ok: false, error: 'INVALID_NAME', code: 'INVALID_NAME', message: r.error } }; } // 目标路径祖先链复查(防御 TOCTOU) const finalAncestorCheck = await assertNoSymlinkAncestor(r.path, dataRoot); if (!finalAncestorCheck.ok) { return { error: { ok: false, error: finalAncestorCheck.error, code: finalAncestorCheck.code, message: finalAncestorCheck.message } }; } return { value: { srcPath, destPath: r.path, destName: r.name, srcIsDir, dataRoot } }; } // ============================================ // IPC: 应用信息 // ============================================ ipcMain.handle('app:get-data-dir', () => currentDataRoot()); ipcMain.handle('app:get-default-data-dir', () => configStore.getDefaultDataDir()); ipcMain.handle('app:get-version', () => app.getVersion()); ipcMain.handle('app:open-data-dir', async (event) => { const denied = guardSender(event); if (denied) return denied; let dir; try { dir = path.resolve(currentDataRoot()); const st = await fs.stat(dir); if (!st.isDirectory()) { return { ok: false, error: 'NOT_A_DIRECTORY', code: 'NOT_A_DIRECTORY', message: '数据路径不是一个目录' }; } } catch (e) { // audit fix (K1-M1):补齐 ok / code / message 字段,与其它 handler 形状一致; // message 不再拼 e.message(英文 errno + 路径)。 console.warn('[main] app:open-data-dir stat 失败:', _redactPath(dir), e.code || e.message); return { ok: false, error: e.code || 'STAT_FAILED', code: e.code, message: '无法访问数据目录', }; } try { // audit fix (K1-M5):shell.openPath 返回 Promise,未 await 时空字符串 = 失败 // 会被静默吞掉,调用方以为「打开成功」实际啥都没发生。await 后检查空串。 const failure = await shell.openPath(dir); if (failure) { // shell.openPath 失败时返回错误消息字符串(不是抛错) console.warn('[main] shell.openPath 返回失败:', failure); return { ok: false, error: 'OPEN_FAILED', code: 'OPEN_FAILED', message: `无法打开数据目录:${failure}`, }; } return { ok: true, path: dir }; } catch (e) { console.warn('[main] shell.openPath 抛错:', _redactPath(dir), e.message); return { ok: false, error: e.code || 'OPEN_FAILED', code: e.code, message: '无法打开数据目录', }; } }); // 仅打开路径,不修改持久化设置(用于设置对话框的"打开"预览按钮) // // 安全:renderer 不能传任意路径触发 shell.openPath(即使只是打开文件管理器, // 也是无谓的权限暴露)。这里强制 target 必须是当前数据目录(settings-dialog.js // 也只拿 _configDir 来调用)。路径比对走 path.resolve + 大小写规整化 // (Windows 不区分大小写、POSIX 区分),避免通过 '..' / 大小写绕过。 ipcMain.handle('app:open-path', async (event, target) => { const denied = guardSender(event); if (denied) return denied; if (typeof target !== 'string' || !target) { // audit fix (Round 8 IPC-5):补齐 message 字段,与 file:read / file:write / // shell:open-dir 等 handler 对齐。renderer 走 `result.message || result.error // || '未知错误'` 兜底,没 message 会拿到英文业务码。 return { ok: false, error: 'INVALID_PATH', code: 'INVALID_PATH', message: '路径必须是字符串', }; } // path 已在文件顶部 require,这里不再重复(避免阅读时被遮蔽误以为是局部变量) // 走 currentDataRoot 而不是 resolveDataDir:customDir 失效时回退到默认, // 让预览按钮跟用户实际在用的目录一致(与 fallback 语义对齐)。 const dataDir = currentDataRoot(); const resolvedTarget = path.resolve(target); const resolvedData = path.resolve(dataDir); const samePath = process.platform === 'win32' ? resolvedTarget.toLowerCase() === resolvedData.toLowerCase() : resolvedTarget === resolvedData; if (!samePath) { return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '仅允许打开当前数据目录' }; } try { const st = await fs.stat(resolvedTarget); if (!st.isDirectory()) { // audit fix (K1-R4):error 改用 NOT_A_DIRECTORY 业务码(与 shell:open-dir 对齐), // 之前用中文 '不是一个目录' 与全文件英文 error 风格不一致。 return { ok: false, error: 'NOT_A_DIRECTORY', code: 'NOT_A_DIRECTORY', message: '不是一个目录' }; } await shell.openPath(resolvedTarget); return { ok: true, path: resolvedTarget }; } catch (e) { // audit fix (K1-R4):走 _friendlyShellError 翻译 errno,不再直回 e.message。 return { ok: false, error: e.code || 'OPEN_FAILED', code: e.code, message: _friendlyShellError(e, '无法打开数据目录') }; } }); ipcMain.handle('app:choose-data-dir', async (event) => { const denied = guardSender(event); if (denied) return denied; // audit fix (H5):补 isDestroyed() —— !mainWindow 只能挡 null/undefined, // 异步窗口销毁的窗口期(mainWindow 引用还在但 isDestroyed===true)会进 // showOpenDialog 抛错。X 紧接菜单点「选择数据目录」的场景能复现。 if (!mainWindow || mainWindow.isDestroyed()) 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]; }); /** * app:reset-data-dir —— 「回到默认」按钮的 IPC(auto-fallback 2026-08): * 1) saveConfig({ dataDir: '' }) 把 custom 持久化值清空,下次启动也走默认 * 2) 重启 fsWatcher(监听目标从缺失的旧路径切到默认) * 3) 刷新托盘菜单(托盘「打开数据文件夹」缓存了旧路径) * 4) 返回 { dir, defaultDir } 让 renderer 弹 toast * * 与 runtime fallback 的区别:runtime fallback 只在内存里走默认,config.json 里 * 的 dataDir 不动 —— U 盘插回下次启动还能用回去;这里走 saveConfig 是「永久放弃 * custom 路径」的语义。auto-fallback 已尽量减少用户误触发,这里只对应用户主动点 * 「回到默认」按钮的明确指令。 */ ipcMain.handle('app:reset-data-dir', async (event) => { const denied = guardSender(event); if (denied) return denied; if (!mainWindow || mainWindow.isDestroyed()) { return { ok: false, error: 'NO_WINDOW' }; } try { const saveResult = await configStore.saveConfig({ dataDir: '' }); if (!saveResult.ok) { return { ok: false, error: saveResult.error || '配置保存失败' }; } // 走 currentDataRoot 而不是 resolveDataDir —— 内部会 sync mkdir(默认目录可能 // 从未被用过)+ 异步种子 README.md,让 fsWatcher 启动时不撞 ENOENT、侧栏里 // 立刻有欢迎文档可见。 const newDir = currentDataRoot(); currentDataDir = newDir; // 等欢迎文档就绪再重启 fsWatcher,避免「fsWatcher 启动时拿到空目录、 // 几秒后 README.md 突然冒出侧栏」的 UX 撕裂。 await configStore.scheduleEnsureDefaultDataDir(); // 重启目录监听:旧监听在缺失的 custom 路径上,新监听切到默认 try { fsWatcher.startWatchingDir(newDir); } catch (e) { console.warn('[main] 重启目录监听失败(仅依赖轮询):', e && e.message); } if (tray) { tray.setContextMenu(buildTrayMenu()); } return { ok: true, dir: newDir, defaultDir: configStore.getDefaultDataDir(), }; } catch (e) { console.error('[main] app:reset-data-dir 失败:', e && (e.message || e)); return { ok: false, error: e?.message || '未知错误' }; } }); // ============================================ // IPC: shell.openExternal(链接点击跳转默认浏览器) // ============================================ ipcMain.handle('shell:open-external', async (event, url) => { const denied = guardSender(event); if (denied) return denied; // audit fix (Round 7 IPC-10):改成 `{ ok, code, message }` envelope,与全文件 // 其它 IPC handler 对齐。renderer 端 `if (!ok)` 无法区分「URL 不合法」/「shell // 调用失败」/「系统级错误」,统一走 envelope 让 toast 文案可针对原因。 if (typeof url !== 'string' || !url) { return { ok: false, error: 'INVALID_URL', code: 'INVALID_URL', message: '链接必须是字符串', }; } // 防御性:只允许 http(s) 和 mailto,避免被利用打开 file:// / 其他协议 if (!/^(https?:\/\/|mailto:)/i.test(url)) { console.warn('[main] 拒绝打开非 http(s)/mailto URL:', url); return { ok: false, error: 'INVALID_URL', code: 'INVALID_URL', message: '只允许 http(s) / mailto 链接', }; } // 进一步校验:scheme 后必须有合法主机部分。 // 之前只校验前缀,会放过畸形 URL(如 'http:///etc/passwd' / 'http://\x00/foo' / // 'http:// host' 带空格)。new URL 在解析失败时抛错,刚好兜底。 try { const parsed = new URL(url); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:' && parsed.protocol !== 'mailto:') { console.warn('[main] 拒绝打开:协议不匹配', parsed.protocol, url); return { ok: false, error: 'INVALID_URL', code: 'INVALID_URL', message: '协议不被允许', }; } // mailto 不要求 hostname;http/https 必须有非空 hostname if ((parsed.protocol === 'http:' || parsed.protocol === 'https:') && !parsed.hostname) { console.warn('[main] 拒绝打开:缺少 hostname', url); return { ok: false, error: 'INVALID_URL', code: 'INVALID_URL', message: '链接缺少主机名', }; } } catch (e) { console.warn('[main] 拒绝打开:URL 解析失败', url, e.message); return { ok: false, error: 'INVALID_URL', code: 'INVALID_URL', message: '链接格式不合法', }; } try { // 必须 await:openExternal 返回 Promise,不接住的话失败会变成 // unhandledRejection,而渲染端还以为打开成功了 await shell.openExternal(url); return { ok: true }; } catch (e) { console.error('[main] openExternal 失败:', e.message); return { ok: false, error: e.code || 'OPEN_FAILED', code: e.code || 'OPEN_FAILED', message: '无法打开链接', }; } }); /** * 在系统文件管理器中显示文件(macOS = Reveal,Win/Linux = 选中文件)。 * * 旧版走 `app:open-path` 但那个 IPC 要求 path 必须是目录, * 传文件会被拒绝("不是一个目录")。这里单独走 shell.showItemInFolder, * 是 Electron 提供的"显示文件"标准入口。 */ ipcMain.handle('shell:show-item-in-folder', async (event, fullPath) => { const denied = guardSender(event); if (denied) return denied; if (typeof fullPath !== 'string' || !fullPath) { // audit fix (Round 8 IPC-6):补齐 message 字段,与 shell:open-dir / file:read // 等 handler 对齐。renderer friendlyFsError 兜底走 result.message,没 message // 会退到英文业务码。 return { ok: false, error: 'INVALID_PATH', code: 'INVALID_PATH', message: '路径必须是字符串', }; } // isWithinDataDir 需要数据目录作为第二参;不传则内部 root 为 undefined → 永远 false → 误拒 if (!isWithinDataDir(fullPath, currentDataRoot())) { return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' }; } try { // audit fix (Main P1 / shell:show-item-in-folder symlink check): // 之前 isWithinDataDir 只检「路径字面值是否在数据目录下」—— 但路径若 // 是数据目录里的符号链接(symlink)且指向外部,shell.showItemInFolder // 实际会在文件管理器里打开 symlink 真实目标。攻击向量: // - 攻击者诱导用户把外部恶意目录 / 网络共享(`\\evil\share`)做成 // 数据目录里的 symlink; // - 渲染端走 fs.stat(跟随 symlink)拿到合法 st.isFile()=true 通过; // - shell.showItemInFolder 在 Windows 上解析为 SMB UNC,系统静默做 // NTLM 认证(与 Sec-H2 同一族风险,CVE-2023-23397 的笔记版本)。 // 修复:realpath 拿到「symlink 真实指向」,再次 isWithinDataDir 校验。 // 真实路径不在数据目录 → 拒(错误码 SYMLINK_OUTSIDE,与 file:write // 路径的 SYMLINK_NOT_ALLOWED 对齐但语义更准确)。 const real = await fs.realpath(fullPath); if (!isWithinDataDir(real, currentDataRoot())) { return { ok: false, error: 'SYMLINK_OUTSIDE', code: 'SYMLINK_OUTSIDE', message: '符号链接指向数据目录外', }; } const st = await fs.stat(fullPath); if (!st.isFile()) { return { ok: false, error: 'NOT_A_FILE', code: 'NOT_A_FILE', message: '不是一个文件' }; } shell.showItemInFolder(fullPath); return { ok: true }; } catch (e) { if (e.code === 'ENOENT') return { ok: false, error: 'FILE_NOT_FOUND', code: 'FILE_NOT_FOUND', message: '文件不存在' }; // audit fix (K1-R4):走 _friendlyShellError 翻译 errno,不再直回 e.message。 return { ok: false, error: e.code || 'STAT_FAILED', code: e.code, message: _friendlyShellError(e, '无法在文件夹中显示') }; } }); /** * 在系统文件管理器中打开数据目录下的任意目录(子目录 / 数据根)。 * * 与 `shell:show-item-in-folder` 的差别:showItemInFolder 只接受文件路径 * ("在文件夹中显示并选中文件"语义),无法用来打开一个目录供用户浏览。 * 状态栏左侧的路径 chip 在「没打开文件」时会显示当前浏览目录,此时点击应该 * 真的打开那个目录(Win/Linux = 资源管理器进目录;macOS = Finder 进目录), * 而不是静默无副作用。 * * 安全:与 `shell:show-item-in-folder` 对称 —— 强制 isWithinDataDir 边界检查, * 不让 renderer 通过这条 IPC 打开任意系统目录。 */ ipcMain.handle('shell:open-dir', async (event, dirPath) => { const denied = guardSender(event); if (denied) return denied; if (typeof dirPath !== 'string' || !dirPath) { // audit fix (Round 7 IPC-4):补齐 code + message 字段,与其它 shell:* handler // 对齐。renderer 走 friendlyFsError(code, message) 没 message 兜底到英文。 return { ok: false, error: 'INVALID_PATH', code: 'INVALID_PATH', message: '路径必须是字符串', }; } const resolved = path.resolve(dirPath); if (!isWithinDataDir(resolved, currentDataRoot())) { // audit fix (Round 7 IPC-4 风格补齐):与 file:read/write 等对齐 error + code + message。 // renderer 走 friendlyFsError(code, message) 没 code 字段会落到 error 字符串, // 文案与其它通道不一致。 return { ok: false, error: 'PATH_NOT_ALLOWED', code: 'PATH_NOT_ALLOWED', message: '路径不在数据目录内' }; } // MEDIUM #7 修复:仅查末段 lstat 不够 —— 若路径中某祖先是软链指向数据目录外, // path.resolve 不跟随,isWithinDataDir 与末段 lstat 都通过,shell.openPath 仍会 // 跟随祖先软链打开外部目录(与 file:scan-dir 的祖先链防护对齐)。 const ancestorCheck = await assertNoSymlinkAncestor(resolved, currentDataRoot()); if (!ancestorCheck.ok) { return { ok: false, error: ancestorCheck.error, code: ancestorCheck.code, message: ancestorCheck.message }; } try { // audit fix:用 lstat 拒绝 symlink 目录(与 file:scan-dir 对齐)。 // fs.stat 跟随 symlink —— resolved 若指向外部目录,stat 仍能拿到目标目录 // 信息,导致下面的 shell.openPath 跑出数据目录边界。lstat + isSymbolicLink // 检查与 file:scan-dir 一致。 const lst = await fs.lstat(resolved); if (lst.isSymbolicLink()) { return { ok: false, error: 'SYMLINK_NOT_ALLOWED', code: 'SYMLINK_NOT_ALLOWED', message: '不允许在符号链接目录上执行此操作' }; } if (!lst.isDirectory()) { // audit fix (Round 7 IPC-9):补齐 message 字段,与 line 1705/1717/1720 对齐。 // renderer 走 `friendlyFsError(code, message || error || '未知错误')` 时 // 缺 message 兜底到 result.error 业务码字符串 'NOT_A_DIRECTORY',用户看到 // 的是代码不是中文。 return { ok: false, error: 'NOT_A_DIRECTORY', code: 'NOT_A_DIRECTORY', message: '路径不是文件夹', }; } const failure = await shell.openPath(resolved); if (failure) { // shell.openPath 失败时返回错误消息字符串(不是抛错),参考 app:open-path return { ok: false, error: 'OPEN_FAILED', code: 'OPEN_FAILED', message: failure }; } return { ok: true }; } catch (e) { if (e.code === 'ENOENT') return { ok: false, error: 'DIR_NOT_FOUND', code: 'DIR_NOT_FOUND', message: '目录不存在' }; // audit fix (K1-R4):走 _friendlyShellError 翻译 errno,不再直回 e.message。 return { ok: false, error: e.code || 'OPEN_FAILED', code: e.code, message: _friendlyShellError(e, '无法打开目录') }; } }); // ============================================ // IPC: 窗口控制 // ============================================ ipcMain.handle('window:set-always-on-top', (event, enabled) => { const denied = guardSender(event); if (denied) return denied; if (!mainWindow || mainWindow.isDestroyed()) return false; 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; } }); ipcMain.handle('window:minimize', (event) => { const denied = guardSender(event); if (denied) return denied; if (!mainWindow || mainWindow.isDestroyed()) return false; // audit fix (K1-L4):try/catch 兜底 —— minimize() 在窗口被销毁过程中可能抛 // "Object has been destroyed",与 setAlwaysOnTop 的 L4 修复对齐。 try { mainWindow.minimize(); return true; } catch (e) { console.warn('[main] window:minimize 失败:', e.message); return false; } }); ipcMain.handle('window:toggle-maximize', (event) => { const denied = guardSender(event); if (denied) return denied; if (!mainWindow || mainWindow.isDestroyed()) return false; try { if (mainWindow.isMaximized()) { mainWindow.unmaximize(); } else { mainWindow.maximize(); } return mainWindow.isMaximized(); } catch (e) { console.warn('[main] window:toggle-maximize 失败:', e.message); return false; } }); ipcMain.handle('window:close', (event) => { const denied = guardSender(event); if (denied) return denied; if (!mainWindow || mainWindow.isDestroyed()) return false; try { mainWindow.close(); return true; } catch (e) { console.warn('[main] window:close 失败:', e.message); return false; } }); function broadcastMaximizeState() { safeSendToRenderer('window:maximize-state', mainWindow?.isMaximized?.() ?? false); } // ============================================ // IPC: AI 修改 // ============================================ /** * 渲染端发来修改请求:调 OpenAI 兼容 API,返回 { id, content, responseFormat }。 * 错误统一返回 { ok:false, error, message } 供 renderer 弹 toast。 */ ipcMain.handle('ai:edit', async (event, payload) => { const denied = guardSender(event); if (denied) return denied; if (!payload || typeof payload !== 'object') { return { ok: false, error: 'INVALID_PAYLOAD', message: '请求体无效' }; } const { prompt, content, filename, requestId } = payload; return aiProxy.runEdit({ prompt, content, filename, requestId }); }); /** * 渲染端发来取消请求:找到对应 requestId 的 AbortController 并触发 abort。 * 找不到(请求已完成 / 超时)静默忽略。 */ ipcMain.on('ai:cancel', (event, requestId) => { // R5 P1-10 / P1-11:sender 守卫 + requestId 格式校验。攻击场景: // 1) 恶意 webContents 调 ai:cancel 取消别人正在跑的 AI 请求 // 2) 注入畸形 requestId 探测 aiProxy 内部 Map 结构 // 两者都拒。 const denied = guardSender(event); if (denied) return; const idCheck = assertAiRequestId(requestId); if (!idCheck.ok) { console.warn('[main] ai:cancel 拒绝:requestId 格式不合法'); return; } aiProxy.cancel(idCheck.requestId); }); // ============================================ // IPC: 应用设置(持久化) // ============================================ ipcMain.handle('app:get-settings', (event) => { // R5 P1-10:app:get-settings 返回整份配置(已脱敏 aiApiKey),仍需 sender // 守卫 —— 防止恶意 webContents 拉配置嗅探 dataDir / theme 等元信息。 const denied = guardSender(event); if (denied) return denied; const cfg = configStore.getConfig() || {}; const { aiApiKey, ...rest } = cfg; // P1-1 fix (audit):过滤 aiApiKey 真值,只露布尔标记。renderer 任何位置 // 通过 window.api.getSettings() 都拿不到完整 Key —— 必须显式调 // app:reveal-ai-key 才能拿真值(settings 对话框回显用)。 // hasPendingAiKeyEnvelope:loadConfig 解密失败(密钥库不可用 / 包络损坏)时 // 包络被挪出 appConfig(内存 aiApiKey 恒为明文的契约 → ''),但磁盘上 Key // 真实存在 —— _hasAiKey 继续报 true,设置对话框走 arm/reveal 流程,reveal // 显式失败 → _aiKeyRevealFailed 提示 + diff 守卫(空白输入不覆盖磁盘 Key)。 return { ...rest, aiApiKey: '', _hasAiKey: !!aiApiKey || configStore.hasPendingAiKeyEnvelope() }; }); /** * Q7/Q9 fix (audit):「显示 API Key」必须先 arm 再 reveal: * * 1. settings-dialog 在用户主动点击「显示 Key」时先调 app:arm-reveal-ai-key * —— 主进程记录一个 5 秒 armed 窗口(per-sender,按 webContents.id 区分)。 * 2. 然后调 app:reveal-ai-key —— 主进程检查 armed 窗口仍在有效期内。 * 3. reveal 后立即 disarm。 * * DevTools / 第三方 hook / 任何意外注入的 JS 不会先经过「用户点按钮」事件, * 自然不会调 arm。一旦 arm 流程跑过,主进程日志记下 time + senderId,方便 * 事后审计。这条链路**不能**完全防御已被攻陷的 renderer(renderer 拿到的 * IPC 桥跟合法代码一样),但能在「合法流程之外」显著抬高成本(attacker 必须 * 也调 arm 才能拿 key,会留下可追溯的日志条目)。 * * 不入 renderer 常驻缓存;调用方应在用完后立即从 DOM 清掉。 */ const REVEAL_ARMED_TTL_MS = 5_000; /** @type {Map} webContents.id → 截止时间戳 */ const revealArmedBySender = new Map(); ipcMain.handle('app:arm-reveal-ai-key', (event) => { // R5 P1-14:顶层 frame 守卫。app:reveal-ai-key 流程是「先 arm,5s 内 // reveal」—— 防子 frame(未来 webview 嵌入)通过调 arm 推进 reveal 状态机。 const denied = guardSender(event) || guardTopFrame(event); if (denied) return denied; const senderId = event.sender && event.sender.id; if (typeof senderId !== 'number') { return { ok: false, error: 'INVALID_SENDER' }; } const until = Date.now() + REVEAL_ARMED_TTL_MS; revealArmedBySender.set(senderId, until); // L4 fix (audit):去掉 ISO 时间戳(精确到 ms 让 arm 时刻成为可关联的 fingerprint), // 改用 ttlMs 表明窗口长度即可。 console.log('[reveal-ai-key] armed by sender', senderId, 'ttlMs=', REVEAL_ARMED_TTL_MS); // 兜底:armed 窗口过期自动清理(防止 reveal 永远不来,Map 一直占位) setTimeout(() => { const cur = revealArmedBySender.get(senderId); if (cur === until) revealArmedBySender.delete(senderId); }, REVEAL_ARMED_TTL_MS + 100).unref?.(); return { ok: true, ttlMs: REVEAL_ARMED_TTL_MS }; }); ipcMain.handle('app:reveal-ai-key', (event) => { // R5 P1-10:纵深防御。app:reveal-ai-key 已有 revealArmedBySender Map // 按 sender.id 二次确认,但加 assertMainSender 让恶意 webContents 根本 // 进不到 sender-id 提取逻辑(更早 fail)。 const denied = guardSender(event); if (denied) return denied; const senderId = event.sender && event.sender.id; const until = (typeof senderId === 'number') ? revealArmedBySender.get(senderId) : undefined; // 任意一条命中即拒绝:没 arm / arm 已过期 / sender 不匹配 if (typeof until !== 'number' || until <= Date.now()) { if (typeof senderId === 'number') revealArmedBySender.delete(senderId); // 不打印 apiKey 内容;只记"拒绝了一次 reveal 尝试",方便事后追查 console.warn('[reveal-ai-key] rejected: no valid arm window for sender', senderId); return { ok: false, error: 'NOT_AUTHORIZED' }; } // 一次性:reveal 后立即 disarm,避免同一窗口被重放 revealArmedBySender.delete(senderId); const cfg = configStore.getConfig() || {}; // 显式记一条"成功 reveal"日志(含 key 长度而非真值,便于事后核对"有没有人偷 key") // L4 fix (audit):单行结构、sender + length,去掉任何 key 相关指纹; // 配 arm 日志一起能看出 arm→reveal 时序关系。 const keyStr = String(cfg.aiApiKey || ''); if (!keyStr && configStore.hasPendingAiKeyEnvelope()) { // 磁盘有包络但解密失败(密钥库不可用 / 包络损坏):显式报失败。 // 不能回 ok:true + 空串 —— 虽然 renderer Round-11 守卫也会标记失败, // 但这里显式拒绝保住「成功 reveal」日志的语义(length=0 的记录会误导 // 事后审计「有人偷到了空 Key」)。 console.warn('[reveal-ai-key] rejected: envelope present but undecryptable (keychain unavailable?)'); return { ok: false, error: 'KEY_UNAVAILABLE' }; } console.log('[reveal-ai-key] revealed by sender', senderId, 'length=', keyStr.length); return { ok: true, aiApiKey: keyStr }; }); /** * 返回 config.json 的真实路径 + 所在目录。 * 设置对话框用:让用户看到 API Key / 设置到底存在了哪个文件, * 并能一键打开所在目录自行核对/备份。 * * audit fix (M4):路径在 tmp 日志 / 屏幕共享 / 截图里出现会暴露 Windows 用户名 * (`C:\Users\\AppData\...`)。设置面板只需要「哪个文件 / 哪个目录」足够 * 用户理解位置,把 basename / dir basename 返回即可,完整路径仅在「打开所在 * 目录」按钮内部使用(不再经过 IPC)。 */ ipcMain.handle('app:get-config-path', () => { const filePath = configStore.getConfigPath(); return { name: path.basename(filePath), dirName: path.basename(path.dirname(filePath)), }; }); /** * 打开 config.json 所在目录(设置对话框 AI 段的 [打开文件夹] 按钮)。 * * audit fix (Round 9 CFG-1):这个按钮之前完全是死的,两处同时坏掉 —— * 1) M4 把 `app:get-config-path` 的返回从 `{path, dir}` 改成 `{name, dirName}` * 做隐私脱敏,但 settings-dialog 仍读 `cfg.path` / `cfg.dir` → 都是 * undefined → `initial._configPath` 永不赋值 → hint 里的路径 + 按钮 * 整段模板不渲染(文案还断在「存储于 config.json」和「。每次请求」之间)。 * 2) 即便按钮渲染出来,它调的 `app:open-path` 强制 target === 当前数据目录, * 传 config 目录会被 PATH_NOT_ALLOWED 拒掉(那个 handler 的注释说 * 「settings-dialog 也只拿 _configDir 来调用」,但 _configDir 是 userData * 目录、不是数据目录 —— 注释与事实不符)。 * * 这里用「不接参数」的专用 handler 同时满足两个既有约束:完整路径不过 IPC * (隐私),renderer 也无法传任意路径(安全)。形状与 app:open-data-dir 对齐。 */ // P2-7(audit):app:open-config-dir 审计日志 + 高频调用 warn。 // 单次打开配置目录无害,但脚本化高频调用能弹一串文件管理器窗口骚扰用户; // 只检测不拦截(拦截会误伤合法连点),超阈值 console.warn 进 notes-main.log。 const _openConfigDirRate = require('./main/audit-rate.js').createAuditRateTracker({ windowMs: 60_000, threshold: 10, }); ipcMain.handle('app:open-config-dir', async (event) => { const denied = guardSender(event); if (denied) return denied; // 审计行:sender id + ISO 时间戳。不写路径(与 _redactPath 的隐私约束同源)。 console.log('[audit] app:open-config-dir', 'ts=', new Date().toISOString(), 'senderId=', event && event.sender && event.sender.id); const _rate = _openConfigDirRate.hit('app:open-config-dir'); if (_rate.exceeded) { console.warn('[main] app:open-config-dir 高频调用:', _rate.count, '次/分钟, senderId=', event && event.sender && event.sender.id); } let dir; try { dir = path.dirname(configStore.getConfigPath()); const st = await fs.stat(dir); if (!st.isDirectory()) { return { ok: false, error: 'NOT_A_DIRECTORY', code: 'NOT_A_DIRECTORY', message: '配置路径不是一个目录' }; } } catch (e) { console.warn('[main] app:open-config-dir stat 失败:', _redactPath(dir), e.code || e.message); return { ok: false, error: e.code || 'STAT_FAILED', code: e.code, message: '无法访问配置目录' }; } try { const failure = await shell.openPath(dir); if (failure) { console.warn('[main] shell.openPath 返回失败:', failure); return { ok: false, error: 'OPEN_FAILED', code: 'OPEN_FAILED', message: `无法打开配置目录:${failure}` }; } // 不回传 dir —— 完整路径带 Windows 用户名,与 app:get-config-path 的脱敏一致。 return { ok: true }; } catch (e) { console.warn('[main] shell.openPath 抛错:', _redactPath(dir), e.message); return { ok: false, error: e.code || 'OPEN_FAILED', code: e.code, message: _friendlyShellError(e, '无法打开配置目录') }; } }); /** * 保存设置。 * 返回结构化结果 { ok:true, settings } / { ok:false, error },而不是抛错 —— * ipcMain.handle 抛出的错误会被 Electron 包装成 * "Error invoking remote method 'app:save-settings': Error: 目录不存在", * 渲染端直接把这串东西塞进 toast 很难看。 */ ipcMain.handle('app:save-settings', async (event, partial) => { // audit fix (P1-1):null / undefined / primitive / Array / Function 一律拒。 // 旧版 `!partial || typeof partial !== 'object'` 把非法 payload 当「无更新」 // 静默放过,renderer 缓存与磁盘脱钩(settingsStore.update 拿不到 error 也不 // 会重试,直到下次合法保存)。改为显式拒返,settingsStore.update 能看到 // 错误并提示用户。 if (!isValidPartialSettings(partial)) return { ok: false, error: 'INVALID_PAYLOAD' }; // 目录存在性校验(schema 里用 opts.resolveDir 注入,因为要异步 fs.lstat + access) // audit fix (Security-L4):旧版用 fs.stat 会跟随 symlink —— 用户 / 同步盘 / // 配置注入可以塞一个 symlink 指到任意目录,本会话内该 symlink 目标就成了 active // dataDir,违反「dataDir 不得是 symlink」的不变量(resolveDataDirOrFallback 是 // 用 lstat 拒的;本入口是同一不变量,必须对齐)。改用 fs.lstat 后 isSymbolicLink // 即拒。 const resolveDir = async (p) => { try { const st = await fs.lstat(p); if (st.isSymbolicLink()) return { ok: false, error: '不允许将符号链接作为数据目录' }; if (!st.isDirectory()) return { ok: false, error: '路径不是一个目录' }; await fs.access(p, fsSync.constants.R_OK); return { ok: true }; } catch (e) { if (e.code === 'ENOENT') return { ok: false, error: '目录不存在' }; if (e.code === 'EACCES' || e.code === 'EPERM') return { ok: false, error: '没有访问权限' }; return { ok: false, error: e.message || '无法访问该目录' }; } }; const result = await validateAndSanitize(partial, { resolveDir }); if (!result.ok) return { ok: false, error: result.error }; const sanitized = result.sanitized; // audit fix (main-M4):先 saveConfig 再 apply 窗口 / 托盘副作用。旧实现先 // setAlwaysOnTop / 重建 tray menu,saveConfig 失败时 UI 已变更、磁盘还是 // 旧值,重启后置顶状态被撤回 + 用户以为配置损坏。修复后语义:「磁盘先 // 落定,UI 再跟上」—— saveConfig 失败直接返回 {ok:false},副作用一段都 // 不跑。 // audit fix (Round 8 M-1):必须在 saveConfig 之前快照旧值。saveConfig 成功时 // 会把 appConfig 就地换成 merged(config-store.js:188),之后再读 // configStore.getConfig().alwaysOnTop 拿到的已经是新值 → 与 sanitized 恒等 → // `!==` 恒为 false → setAlwaysOnTop / 托盘菜单刷新一次都不跑。表现:用户勾选 // 「始终置顶」后磁盘和 UI 都变了,但窗口实际没置顶、托盘勾选状态也不同步, // 要重启(createWindow 重新 apply)才生效。 const prevAlwaysOnTop = configStore.getConfig().alwaysOnTop; const saveResult = await configStore.saveConfig(sanitized); if (!saveResult.ok) { return { ok: false, error: saveResult.error || '配置保存失败' }; } const next = saveResult.value; // alwaysOnTop 变更需要同步到窗口 + tray(schema 不应耦合 Electron API,所以副作用留在 main.js) if ('alwaysOnTop' in sanitized && prevAlwaysOnTop !== sanitized.alwaysOnTop) { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.setAlwaysOnTop(sanitized.alwaysOnTop); } if (tray) { tray.setContextMenu(buildTrayMenu()); } } // dataDir 变更后重新启动目录监听 + 刷新托盘菜单(托盘「打开数据文件夹」缓存了旧路径) if ('dataDir' in sanitized) { // 走 currentDataRoot 而不是 resolveDataDir:customDir 失效时 fallback 到默认 // 并 sync mkdir,确保 fsWatcher 启动时不撞 ENOENT。 const newDirRaw = currentDataRoot(); await configStore.scheduleEnsureDefaultDataDir(); // audit fix:直接比较字符串会因为尾斜杠 / 大小写 / `..` 残留而误判。 // 用 path.resolve 归一化后再比;Windows 上大小写不敏感。 const newResolved = path.resolve(newDirRaw); // audit fix (M3):currentDataDir === null 兜底 —— 当前总是非 null(whenReady // 已先 set),但 app:save-settings 的早期触发 / future splash 路径可能命中。 // TypeError: path.resolve(null) 会把整个 settings 保存流程打挂。 const curResolved = path.resolve(currentDataDir || newDirRaw); const same = process.platform === 'win32' ? newResolved.toLowerCase() === curResolved.toLowerCase() : newResolved === curResolved; if (!same) { currentDataDir = newDirRaw; // audit fix (H4):与 app:reset-data-dir 同款 try/catch —— startWatchingDir // 同步路径可能 ENOENT / EACCES(旧目录已被删 / 权限收紧),失败也不应该 // 让 IPC 抛回 renderer(磁盘已 saveConfig 成功)。失败只丢 watcher,配置 // 与数据目录写入照常生效;下次启动会自然拉起。 try { fsWatcher.startWatchingDir(newDirRaw); } catch (e) { console.warn('[main] restart fsWatcher 失败:', e?.message || e); } if (tray) { tray.setContextMenu(buildTrayMenu()); } } } // Phase N 安全修复:save-settings 之前直接 echo `next`,里面含 aiApiKey 明文。 // renderer settingsStore.update 会拿这个 settings 调 coerceLoadedSettings // 覆盖 _settings —— 把真值灌进内存。后续 getSettings / getAll / IPC echo 都 // 走同一份内存,AI Key 明文在内存里能待到下次 restart。 // 与 get-settings 同款 redaction:剥掉 aiApiKey 真值、用 aiApiKey='' 占位、 // 用 _hasAiKey 标记"是否配置过 Key"。renderer settings-store 已在 update 路径 // 把 aiApiKey='' 落进 _settings 与 cachePartial,"清空 Key" 的写入也无副作用 // 走空字符串 diff。 const { aiApiKey: realKey, ...rest } = next; return { ok: true, settings: { ...rest, aiApiKey: '', _hasAiKey: !!realKey } }; }); // ============================================ // 应用菜单 // ============================================ 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+Shift+T', // audit fix (H3):应用菜单 click 走 safeSendToRenderer —— 关闭中窗口 // 的 webContents.send 同款 TypeError 风险。 click: () => safeSendToRenderer('menu:toggle-theme') }, { type: 'separator' }, { label: '设置...', accelerator: 'CmdOrCtrl+,', click: () => safeSendToRenderer('menu:settings') }, { type: 'separator' }, { label: '开发者工具', accelerator: 'F12', click: () => mainWindow?.webContents.toggleDevTools() }, { role: 'reload', label: '重新加载' } ] } ]; Menu.setApplicationMenu(Menu.buildFromTemplate(template)); }