3332 lines
155 KiB
JavaScript
3332 lines
155 KiB
JavaScript
// 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(`
|
||
<html><body style="font-family:sans-serif;padding:30px;background:#fee;color:#900;">
|
||
<h2>⚠ 启动失败</h2>
|
||
<p><b>问题:</b>项目路径包含非 ASCII 字符(如中文),导致 Electron 无法正确加载 API。</p>
|
||
<p><b>当前路径:</b><br><code>${__dirname}</code></p>
|
||
<p><b>解决方法:</b>将项目移动到 ASCII 路径(如 <code>D:\\Projects\\notes</code>)。</p>
|
||
<p style="margin-top:30px;color:#666;font-size:12px;">详细说明请查看 README。</p>
|
||
</body></html>
|
||
`));
|
||
});
|
||
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 里的 `<a target="_blank">` 默认会被 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 中的 <meta http-equiv="refresh"> 或 <a href> 把我们带到外部页面。
|
||
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.<pid>.<ts>.<rand>`
|
||
// 孤儿文件。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<boolean>} 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<boolean>} 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(<44>)。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: <envelope> }(失败时)或 { 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();
|
||
// 文件夹不能移入自身或其子孙(防止循环 / 路径穿越)
|
||
if (srcIsDir && (destDir === srcPath || destDir.startsWith(srcPath + 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 对话框回显用)。
|
||
return { ...rest, aiApiKey: '', _hasAiKey: !!aiApiKey };
|
||
});
|
||
|
||
/**
|
||
* 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<number, number>} 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 || '');
|
||
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\<name>\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));
|
||
} |