This commit is contained in:
2026-10-21 14:42:59 +08:00
commit 954236d3f9
399 changed files with 85885 additions and 0 deletions

31
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,31 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint', 'react', 'react-hooks'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
],
settings: { react: { version: 'detect' } },
env: { browser: true, node: true, es2022: true },
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
rules: {
'react/react-in-jsx-scope': 'off',
// IPC payload 类型从主进程桥接过来,故意用 any 接住,避免双重类型断言噪声
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{
// 通用约定:`_` / `_xxx` 显式表达"故意未使用"
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
// React 状态机驱动 useEffect 依赖,人为控制触发时机比 ESLint 自动推断更可靠
'react-hooks/exhaustive-deps': 'warn',
},
ignorePatterns: ['out/', 'dist/', 'node_modules/'],
};

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
out/
dist/
release/

5
.lintstagedrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{md,json,css}": ["prettier --write"],
"*.cjs": ["prettier --write"]
}

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22.11.0

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 文献管理
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

93
README.md Normal file
View File

@@ -0,0 +1,93 @@
# 文献管理
本地优先的文献管理桌面应用:文献库、Markdown 笔记、收藏。数据全部存在自己的硬盘上,不联网、不注册、不上传。
笔记是纯 Markdown,文献保留原始文件,任何编辑器都能打开。数据目录放进云盘就自动备份,换工具也不用迁移。
## 快速开始
```bash
npm install
npm run dev
```
启动后把 PDF 拖进左侧文献区就能读;新建一篇笔记写读后感;`Ctrl+Shift+F` 全文搜索笔记正文,也能按文件名找文献。
系统要求:Windows 10+ / macOS 11+ / Ubuntu 20.04+。从源码运行需要 Node.js 20.16 – 24(仓库 `.nvmrc` 锁 22.11.0)。
`npm install` 会为 Electron 重编 better-sqlite3,需要联网下载预编译产物或本地有 MSVC 工具链。启动失败先跑 `npm run rebuild:native`;vite 缓存异常先跑 `npm run dev:clean`。
## 功能
- 文献:拖入任意文件即复制入库,内嵌阅读器直接看 PDF、图片、代码、Markdown、纯文本。文件夹可任意嵌套、拖拽移动,外部改动自动感知。
- 笔记:CodeMirror 编辑,带语法高亮、撤销重做、源码与预览切换。停止输入 1.5 秒自动保存,带冲突检测。自定义 frontmatter 字段原样保留。
- 双列工作台:左边读文献、右边写笔记,互不顶掉。分隔条可拖拽,宽度自动记住。左侧栏可折叠(`Ctrl+B`)。
- 搜索:SQLite FTS5 覆盖笔记正文全文,文献按文件名命中,命中处给高亮片段。内置中文分词,中英混排不用打空格也能命中。
- 收藏:文献一键收藏,列表可切「仅收藏」筛选。收藏就是一个 Markdown 文件,可以手写。
- 界面:明/暗/跟随系统三档,另有 15 套配色预设(每套自带明暗),3 族字体,中英文界面切换。
## 快捷键
| 快捷键 | 作用 |
| -------------------------- | ------------------------- |
| `Ctrl+1` / `Ctrl+2` | 切换文献 / 笔记列表 |
| `Ctrl+B` | 折叠 / 展开左侧栏 |
| `Ctrl+Shift+S` | 显示 / 隐藏状态栏 |
| `Ctrl+N` | 新建笔记 |
| `Ctrl+S` | 保存当前笔记 |
| `Ctrl+Shift+F` | 全文搜索 |
| `Ctrl+Shift+N` | 新建文件夹 |
| `Ctrl+,` | 设置 |
| `F2` | 重命名(笔记、文件夹) |
| `Ctrl+C` / `X` / `V` / `A` | 复制 / 剪切 / 粘贴 / 全选 |
| `Del` | 删除(移入系统回收站) |
| `Esc` | 关闭弹窗 / 取消拖拽 |
列表相关快捷键(`Ctrl+Shift+N`、`F2`、`Del`、复制剪切粘贴)需要先点一下列表;文献文件的重命名用右键菜单。macOS 用 `⌘` 替代 `Ctrl`。
## 数据
默认数据目录:`<文档>/ResearchHub/data`。在设置页可以改位置,迁移是复制方式,目标目录须为空,旧目录保留。
```
<数据目录>/
├── settings.json 应用设置
├── notes/ 笔记(.md + frontmatter,可嵌套子目录)
├── literature/ 文献副本(原始格式)
└── workspace/favorites/favorites.md 收藏
```
备份就是复制这个文件夹。全文搜索的 SQLite 索引单独放在系统用户目录,删掉重启会自动重建,备份不用带。
## 常见问题
**PDF 正文能搜到吗?** 不能。当前不做 PDF 文本抽取,文献只按文件名命中,与 PDF 有无文本层无关。
**有什么大小限制?** 入库单文件 500MB,内嵌阅读器 10MB,笔记正文 5MB。超过阅读器上限用阅读器里的「在系统应用打开」。接近上限的大段代码 / Markdown 会按渲染预算降级渲染(顶部有提示条),内容不丢,只是高亮和折叠从简。
**能同时开多篇文献吗?** 文献是单选阅读,双列的另一边是笔记。需要对照多篇就用系统应用另开。
**删掉的东西能找回吗?** 能。删除会移进系统回收站,还原后自动重新入库,收藏标记需要重新打。
**换电脑怎么搬?** 复制整个数据目录,在新机器上装好后到设置页指过去即可,索引会自动重建。
## 开发
技术栈:Electron 30 · React 18 · TypeScript 5 · Vite 5 · Tailwind 3 · Zustand 4 · CodeMirror 6 · better-sqlite3。
| 命令 | 作用 |
| ----------------------- | --------------------------- |
| `npm run dev` | 开发模式,热重载 |
| `npm run test:run` | 跑测试 |
| `npm run test:coverage` | 覆盖率报告 |
| `npm run typecheck` | 类型检查 |
| `npm run lint` | ESLint |
| `npm run build` | 编译,产出 `out/`,不含打包 |
打包安装包:`npm run build` 后跑 `npx electron-builder --win`(`--mac` / `--linux` 同理),产物在 `release/`。
代码分三层:主进程独占文件系统和原生模块,preload 只做桥,渲染进程只碰 DOM 和 `window.api`。所有 IPC 统一返回 `{ ok, data }` 或 `{ ok, code, message }`。写入统一走「临时文件 + fsync + rename」,断电不留半截文件。接受绝对路径的操作一律校验目标在数据目录内。
## 许可
MIT,见 [LICENSE](LICENSE)。

33
commitlint.config.cjs Normal file
View File

@@ -0,0 +1,33 @@
// commitlint.config.cjs
//
// 验证规则仅作用于 conventional 格式(feat: ... / fix(scope): ... / chore: ...)。
// 项目风格的 [prefix] subject 会先由 .husky/commit-msg 的 preprocess 脚本
// 转成 conventional 格式,这里不需要识别 [prefix]。
//
// type-enum 接受项目所有 prefix + 常规 conventional type:
// workbench / cleanup / ui / docs / spec / plan / test / audit
// feat / fix / refactor / perf / style / build / ci / chore / revert
// audit round 8 (2026-09-14):把 type-enum 提到 module.exports.types ——
// scripts/commit-msg-preprocess.cjs 之前内联一份 FALLBACK_TYPES,与这里
// 的 type-enum 漂移风险(double source of truth)。现在 preprocess require
// 本文件拿 .types,仅在 require 失败时才走自身 FALLBACK_TYPES,白名单永远
// 单一真相。规则仍然在 rules.type-enum 里持有数组,commitlint 自己也读那处,
// .types 只是导出别名,两边永远同步。
const TYPES = [
'workbench', 'cleanup', 'ui', 'docs', 'spec', 'plan', 'test', 'audit',
'feat', 'fix', 'refactor', 'perf', 'style', 'build', 'ci', 'chore', 'revert',
];
module.exports = {
extends: ['@commitlint/config-conventional'],
types: TYPES,
rules: {
'header-max-length': [2, 'always', 80],
'type-enum': [2, 'always', TYPES],
'type-case': [2, 'always', 'lower-case'],
'type-empty': [2, 'never'],
'subject-empty': [2, 'never'],
'subject-full-stop': [2, 'never', '.'],
'header-case': [0], // 项目习惯混用大小写,不强制
},
};

182
electron.vite.config.ts Normal file
View File

@@ -0,0 +1,182 @@
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
import { existsSync, realpathSync } from 'fs';
import { cp } from 'fs/promises';
// audit r44 (2026-10-15):src/main/db/migrations/*.sql 是 r44 WIP 落地后
// `applyMigrations` 的输入。electron-vite 主进程构建只 emit TS 入口的 JS 产物,
// 静态 .sql 不在入口依赖图里,默认不会被拷贝到 out/main/。运行时 sqlite.ts
// 走 `applyMigrations(db, join(__dirname, 'migrations'))`(`__dirname` =
// packaged 形态下 `resources/app/out/main/`),目录不存在 → `readdirSync` 抛
// ENOENT → `initDB` 拒 → 致命对话框 → 应用无法启动。inline rollup 插件
// 在 closeBundle 阶段把 SQL 拷到 `out/main/migrations/`(r50 修正:原本
// 写 `out/main/db/migrations/` 多一段 `db/`,与 `__dirname/migrations/` 解析
// 路径不一致 → 启动 ENOENT),无需新增 vite-plugin-static-copy 依赖。
const sharedAlias = { '@shared': resolve(__dirname, 'src/shared') };
// start.cjs 在 Windows + CJK 项目路径下会 chdir 到 ASCII junction。
// rollup 内部对 input 走 realpathSync,把 junction 解析回真实 CJK 路径。
// vite 的 html 插件用 path.relative(config.root, normalizedId) 算 emit
// fileName,root 和 normalizedId 必须同盘才能算出相对路径 'index.html';
// 否则跨盘返回绝对路径,触发 "fileName must be neither absolute nor
// relative paths"。所以把 root 和 input 都钉到 realpath 后的 CJK 路径。
const projectRoot = realpathSync(process.cwd());
export default defineConfig({
main: {
plugins: [
externalizeDepsPlugin(),
{
// audit r44 + r50:把 src/main/db/migrations/*.sql 拷到 out/main/migrations/,
// 让 `applyMigrations(db, join(__dirname, 'migrations'))` 在 packaged
// 形态下能找到 SQL 入口(`__dirname` = `out/main/`,join 'migrations'
// = `out/main/migrations/`;r50 修正:原 dst 写 `out/main/db/migrations/`
// 多一段 `db/`,与 __dirname 对齐失败 → 启动 ENOENT)。closeBundle 在
// emit 完成、产物已写盘后跑;cp({ recursive: true }) 是幂等的(目标目录
// 不存在则创建,存在则覆盖同名文件 + 保留新增文件——和 bundler 产物
// 语义对齐)。
name: 'copy-migrations',
closeBundle: async () => {
const src = resolve(projectRoot, 'src/main/db/migrations');
const dst = resolve(projectRoot, 'out/main/migrations');
if (!existsSync(src)) return; // r44 WIP 未落地时 src 不存在,静默跳过
await cp(src, dst, { recursive: true });
},
},
],
resolve: { alias: sharedAlias },
build: {
sourcemap: false, // 主进程产物不外发,关 sourcemap 减小
minify: true,
rollupOptions: {
input: { index: resolve(__dirname, 'src/main/index.ts') },
},
},
},
preload: {
plugins: [externalizeDepsPlugin()],
resolve: { alias: sharedAlias },
build: {
sourcemap: false,
minify: true,
rollupOptions: {
input: { index: resolve(__dirname, 'src/preload/index.ts') },
},
},
},
renderer: {
// 见 projectRoot 注释:rollup 内部把 input realpath 回 CJK 路径,
// root 必须同盘才能让 path.relative 算出 'index.html'。
root: resolve(projectRoot, 'src/renderer'),
// fix(2026-08-27):Vite 5.4 自带 esbuild 0.21.5,在 Node 24 + Windows CJK
// 项目路径下会触发 TypeError: Cannot read properties of undefined
// (reading 'imports') (发生在 optimizeDeps 的 depsInfo 合并阶段)。
// 不升级 esbuild —— vite@5.4.21 的兼容矩阵锁在 ^0.21.3,顶包升级会破坏
// vite 内部假设。三层兜底走 vite 配置:
// 1) entries 锁死只扫 main.tsx + i18n,不让 vite 自动扫整个图触发 race
// 2) include 强制列出真正被顶层引用的 bare deps,跳过自动发现
// 3) esbuildOptions 显式钉住 vite 默认的 browser/esnext(见下方注释)
// 副作用:首屏 esbuild 预构建略慢 200~500ms;后面 transform 不受影响。
// 注:根治手段是清缓存(`npm run dev:clean`)—— 该 race 一旦写坏
// node_modules/.vite/deps/_metadata.json,后续启动会持续复现。
optimizeDeps: {
entries: [
resolve(projectRoot, 'src/renderer/src/main.tsx'),
resolve(projectRoot, 'src/renderer/src/i18n/index.ts'),
].filter((p) => existsSync(p)) as string[],
include: [
'react',
'react-dom',
'react-dom/client',
'react-i18next',
'i18next',
'zustand/shallow',
// 动态 import 的重 dep 必须显式 include — Vite 5.4.21 的 dep
// optimizer 在 on-the-fly discovery 阶段(markdown-it / codemirror /
// highlight.js 这些 dynamic import 触发的预构建)会撞到
// dep-BK3b2jBa.js:50835 的 race,输出 undefined.imports 抛错。
// 启动期强制 pre-bundle 就绕开 discovery 路径。这是 5.4.x 已知
// bug,Vite 6 才修。
'markdown-it',
'highlight.js/lib/core',
'@codemirror/state',
'@codemirror/view',
'@codemirror/commands',
'@codemirror/lang-markdown',
// lucide-react — 整个 renderer 用得最广的图标库(40 个文件 import,
// 每个图标单独 ESM)。任何 lazy-loaded 路由/视图加载都会触发它的
// on-the-fly 优化,5.4.21 在它身上稳定撞 dep-BK3b2jBa.js:50835。
// 预 bundle 解决。
// audit 2026-09 (V1.9):原注释举例 "Dashboard / Sidebar / RecentView"
// —— 三个组件均已下线(Sidebar 被 master-detail + 顶栏取代,
// RecentView 随 recent module 一并删除,Dashboard 从未是单一
// 组件)。改为泛化描述 "任何 lazy-loaded 路由/视图"。
'lucide-react',
// audit r40 (2026-10-15):react-virtuoso — 虚拟列表,用于 FavoritesList /
// NotesList 等列表组件,首次访问触发 dep discovery race,与
// lucide-react 同款机制(5.4.21 dep-BK3b2jBa.js:50835)。预 bundle
// 避免首屏「new dependencies optimized」+ 整页 reload(+1-2s 卡顿)。
'react-virtuoso',
],
esbuildOptions: {
target: 'esnext',
// platform 与 target 都只是显式钉住 vite 的默认值(browser/esnext),
// 防止未来改动或插件把 renderer 的预构建误推到 node 分支。
// 注:renderer 跑在 Electron 30 的 Chromium 里,esnext 完全安全。
platform: 'browser',
},
},
resolve: {
alias: {
...sharedAlias,
'@': resolve(__dirname, 'src/renderer/src'),
},
},
server: {
// 修复:vite dev server 启动时 pre-transform HTML 引用的入口脚本
// (index.html → /src/main.tsx),在 Windows + CJK 项目路径下抛
// "Failed to load url /src/main.tsx ... Does the file exist?"。
// 根因:vite 的 searchForWorkspaceRoot 走 fs.realpathSync 把
// ASCII junction 解析回真实 CJK 路径,pre-transform 拿到 CJK
// 绝对路径后,在 worker 里读文件失败。把 server.fs.allow 显式
// 扩到 junction + 真实路径,让 vite 接受两种路径表达。
fs: {
strict: false,
allow: [
resolve(__dirname, 'src/renderer'),
// start.cjs 在 chdir 到 junction 后会设置这个环境变量。
// dev 时 vite 的 cwd 实际是 junction,所以允许这个路径即可。
...(process.env.RKMS_JUNCTION_PATH ? [process.env.RKMS_JUNCTION_PATH] : []),
],
},
},
build: {
// perf (2026-09-06):sourcemap 改 hidden —— 仍 emit .map 文件给线上
// stack trace 反解(主进程 / 渲染进程出错能定位源码),但产物
// JS 不再带 `//# sourceMappingURL=` 注释 → Sentry / 用户浏览器
// devtools 不会自动尝试拉远程 sourcemap(默认行为)。
// 省:主 bundle 内嵌 sourcemap-data 体积 + 网络请求一个 .map。
sourcemap: 'hidden',
// 现代 Electron 内核(Chrome 120+)
target: 'chrome120',
// chunk 大小阈值, 超过 1500KB 才告警
chunkSizeWarningLimit: 1500,
rollupOptions: {
// 用 projectRoot(realpath 后)算 input,跟 root 同盘才能让
// html 插件的 path.relative(config.root, normalizedId) 算出
// 相对路径 'index.html',否则跨盘返回绝对路径触发 rollup 报错。
input: { index: resolve(projectRoot, 'src/renderer/index.html') },
// 不设 vendor manualChunks — vite 的 dynamic import 已经做 code-splitting;
// 强制 vendor 反让 index.html 解析路径出错。
},
// perf(2026-08-22):polyfill:false — 现代 Chrome 120+ 原生支持 ESM dynamic
// import + import.meta,无需 vite 注入 ~3KB polyfill。settings 必须保留
// 显式 modulePreload(真),否则 modulepreload 链接不会生成 → 关键 chunk
// 串行下载,首屏 FCP 退化。
modulePreload: { polyfill: false },
},
plugins: [react()],
},
});

BIN
icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

13400
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

133
package.json Normal file
View File

@@ -0,0 +1,133 @@
{
"name": "research-knowledge-management-system",
"version": "0.1.0",
"private": true,
"description": "本地优先的文献管理桌面应用 —— 论文库 + 笔记 + 收藏",
"sideEffects": [
"**/*.css"
],
"main": "out/main/index.js",
"scripts": {
"dev": "node scripts/start.cjs dev",
"predev": "node scripts/start.cjs verify-abi",
"build": "node scripts/start.cjs build",
"start": "node scripts/start.cjs start",
"rebuild:native": "node scripts/rebuild-native.cjs",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.test.json",
"lint": "eslint . --ext .ts,.tsx",
"lint:fix": "eslint . --ext .ts,.tsx --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,css,md}\"",
"test": "node scripts/run-tests.cjs",
"test:run": "node scripts/run-tests.cjs run",
"test:watch": "node scripts/run-tests.cjs watch",
"test:coverage": "node scripts/run-tests.cjs run --coverage",
"dev:clean": "node -e \"const fs=require('fs');for(const p of ['node_modules/.vite','node_modules/.vite-temp']){try{fs.rmSync(p,{recursive:true,force:true});console.log('removed',p)}catch(e){console.log('skip',p,e.message)}}\"",
"postinstall": "electron-builder install-app-deps",
"rebuild": "electron-rebuild",
"prepare": "husky || true"
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{md,json,css}": [
"prettier --write"
]
},
"build": {
"appId": "com.researchhub.rkms",
"productName": "Literature Management",
"directories": {
"output": "release"
},
"files": [
"out/**/*",
"package.json",
"icon.ico",
"!**/*.map"
],
"asarUnpack": [
"**/node_modules/better-sqlite3/**"
],
"win": {
"target": [
"nsis"
],
"icon": "icon.ico"
},
"mac": {
"target": [
"dmg"
],
"category": "public.app-category.education"
},
"linux": {
"target": [
"AppImage",
"deb"
],
"category": "Education"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
},
"engines": {
"node": ">=20.16.0 <25"
},
"dependencies": {
"better-sqlite3": "12.11.1",
"chokidar": "^4.0.1",
"gray-matter": "^4.0.3"
},
"devDependencies": {
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-markdown": "^6.3.0",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.34.1",
"@commitlint/cli": "^21.2.2",
"@commitlint/config-conventional": "^21.2.2",
"@lezer/highlight": "^1.2.3",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/better-sqlite3": "^7.6.11",
"@types/markdown-it": "^14.1.2",
"@types/node": "^20.16.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^2.1.9",
"autoprefixer": "^10.4.20",
"electron": "^30.5.0",
"electron-builder": "^25.0.0",
"electron-vite": "^2.3.0",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.35.0",
"eslint-plugin-react-hooks": "^4.6.0",
"highlight.js": "^11.10.0",
"husky": "^9.1.7",
"i18next": "^26.3.6",
"jsdom": "^26.0.0",
"lint-staged": "^17.3.0",
"lucide-react": "^0.453.0",
"markdown-it": "^14.3.1",
"memfs": "^4.63.0",
"postcss": "^8.4.0",
"prettier": "^3.3.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-i18next": "^17.0.11",
"react-virtuoso": "^4.18.12",
"tailwindcss": "^3.4.0",
"typescript": "^5.5.0",
"vite": "^5.4.0",
"vitest": "^2.0.0",
"zod": "^3.25.76",
"zustand": "^4.5.0"
}
}

3
postcss.config.cjs Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
plugins: { tailwindcss: {}, autoprefixer: {} },
};

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env node
// scripts/commit-msg-preprocess.cjs
//
// 把 commit message 转成 commitlint 能验证的 conventional 格式,绝不卡用户:
//
// [audit] phase 2 — ...
// → audit: phase 2 — ... (项目主流 [prefix] 风格;prefix 强制小写)
//
// feat add new button
// → feat: add new button (bare 首词命中 type-enum → 用作 type)
//
// feat:word (常见 typo —— 冒号后忘空格)
// → feat: word (顺带补空格)
//
// update xxx
// → chore: update xxx (bare 首词不在 enum → 默认 chore)
//
// update (单字 bare)
// → chore: update (默认 chore)
//
// feat: existing conventional
// → feat: existing conventional (已 conventional 不动)
//
// 只动第一行(header),body/footer 不动。type-enum 从 commitlint.config.cjs
// 同步读取 —— audit round 8 (2026-09-14) commitlint.config 现在顶层导出
// .types 数组作为单一真相,这里优先读 config.types,失败再退到
// config.rules['type-enum'][2](老格式),再不行才用本文件末尾 FALLBACK_TYPES。
//
// 调用方式(在 .husky/commit-msg 里):node scripts/commit-msg-preprocess.cjs "$1"
const fs = require('node:fs');
const path = require('node:path');
// FALLBACK_TYPES 现在是真正的兜底(双 require 失败 / config 损坏),
// 不再与 commitlint.config 双源同步。commitlint.config 是唯一真相,
// 这里 hardcode 是为了让 loadTypeEnum 在异常路径上仍能给一个非空集合。
const FALLBACK_TYPES = new Set([
'workbench', 'cleanup', 'ui', 'docs', 'spec', 'plan', 'test', 'audit',
'feat', 'fix', 'refactor', 'perf', 'style', 'build', 'ci', 'chore', 'revert',
]);
function loadTypeEnum() {
try {
const configPath = path.resolve(__dirname, '..', 'commitlint.config.cjs');
delete require.cache[require.resolve(configPath)];
const config = require(configPath);
// audit round 8 (2026-09-14):优先读 config.types —— 单一真相。
// 旧实现走 config.rules['type-enum'][2] 拿数组,需要先校验结构。
// 现在 commitlint.config.cjs 顶层直接导出 types 数组,这里 require
// 拿到即可,避免双重结构解码。失败时仍可走 type-enum 数组兜底
// (兼容老 commitlint.config 没升级 .types 字段的过渡期),再不行
// 才退回 FALLBACK_TYPES。
if (Array.isArray(config && config.types)) {
return new Set(config.types);
}
const enumRule = config && config.rules && config.rules['type-enum'];
if (Array.isArray(enumRule) && Array.isArray(enumRule[2])) {
return new Set(enumRule[2]);
}
} catch (_) {
/* fall through to fallback */
}
return FALLBACK_TYPES;
}
const CONVENTIONAL_HEADER = /^\w+(?:\([^)]+\))?!?:\s.+$/;
const BRACKET_HEADER = /^\[(\w+)\]\s+(.+)$/;
function preprocess(message, typeSet) {
const set = typeSet || loadTypeEnum();
const newline = message.includes('\r\n') ? '\r\n' : '\n';
const lines = message.split(/\r?\n/);
const header = lines[0] || '';
// 空 header —— 让 commitlint 自然报错,不要硬塞一个虚假 chore:
if (!header.trim()) {
return message;
}
// [prefix] subject → prefix: subject (prefix 强制小写,匹配 type-case 规则;
// 未注册的 prefix 直接落到 bare fallback,避免 "[WIP] stuff" 这种被静默转成
// "wip: stuff" 然后 commitlint 用 "type not in enum" 二次卡住用户)
const bracketMatch = header.match(BRACKET_HEADER);
if (bracketMatch) {
const [, type, subject] = bracketMatch;
const lower = type.toLowerCase();
if (set.has(lower)) {
lines[0] = `${lower}: ${subject}`;
return lines.join(newline);
}
}
// 已 conventional → 原样不动(commitlint 自家规则会校验)
if (CONVENTIONAL_HEADER.test(header)) {
return message;
}
// bare:首词是已知 type(忽略大小写)且后面有内容 → "<type>: <rest>"
// 同时吞掉紧跟首词的 ":"(常见 typo "feat:word"),保证输出干净
const firstWord = header.match(/^(\w+)\b/);
if (firstWord) {
const typeCandidate = firstWord[1].toLowerCase();
if (set.has(typeCandidate)) {
const rest = header
.slice(firstWord[0].length)
.replace(/^:\s*/, '')
.trimStart();
if (rest.length > 0) {
lines[0] = `${typeCandidate}: ${rest}`;
return lines.join(newline);
}
}
}
// bare fallback → "chore: <header>" —— 兜底,确保 commit 永远成功
lines[0] = `chore: ${header}`;
return lines.join(newline);
}
if (require.main === module) {
const file = process.argv[2];
if (!file) {
console.error(
'[commit-msg-preprocess] usage: commit-msg-preprocess.cjs <commit-msg-file>',
);
process.exit(2);
}
const original = fs.readFileSync(file, 'utf8');
const rewritten = preprocess(original);
if (rewritten !== original) {
// audit round 8 (2026-09-14):写 tmp + rename 替直接 writeFileSync ——
// 旧实现写中断(磁盘满 / OS kill / power loss)会让 commit-msg 文件
// 半空,git 直接 reject "no commit message",用户措手不及。改成
// tmp 写 + renameSync:POSIX rename 原子,Windows 上 NTFS MoveFileEx
// 同 target volume 时也是原子的;失败时 tmp 文件残留但原文件完好。
// tmp 用 file + '.tmp' 与 git 临时文件命名约定一致,git 重启可识别。
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, rewritten, 'utf8');
try {
fs.renameSync(tmp, file);
} catch (e) {
try {
fs.unlinkSync(tmp);
} catch (_) {
/* tmp 清理失败不阻塞 — 用户重试一次 commit 就会覆盖 */
}
console.error(`[commit-msg-preprocess] rename tmp -> file failed: ${e.message}`);
process.exit(1);
}
// 静默不输出 —— git commit 行为不要污染 stdout,提交消息自身就是反馈
}
process.exit(0);
}
module.exports = {
preprocess,
loadTypeEnum,
CONVENTIONAL_HEADER,
BRACKET_HEADER,
FALLBACK_TYPES,
};

203
scripts/rebuild-native.cjs Normal file
View File

@@ -0,0 +1,203 @@
#!/usr/bin/env node
// 重编 better-sqlite3 (项目唯一的运行时 native module) 匹配当前 Electron ABI。
//
// 调用方:
// - scripts/start.cjs 在检测到 .node mtime 早于 electron.exe 时自动调
// - 用户手动跑 `npm run rebuild:native`
//
// 关键:
// - ELECTRON_RUN_AS_NODE=1 让 child process 的 require('electron') 返回
// electron.exe 路径,而不是 electron 模块本身 —— 这样 electron-rebuild
// 才能正确读取 Electron 内嵌的 Node 版本并匹配 NODE_MODULE_VERSION
// - stdio: 'inherit' 让 electron-rebuild 的进度输出原样透传
// - 不捕获 stdout,失败时直接非零退出码交给 caller
const { spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const projectRoot = path.resolve(__dirname, '..');
const electronRebuildCli = path.resolve(
projectRoot,
'node_modules',
'@electron',
'rebuild',
'lib',
'cli.js',
);
// node-gyp 失败时会把 build/Release/ 整个删掉 —— 包括原来那个**能用**的 .node。
// 在没有 MSVC 工具链、或 GitHub prebuild 下载超时的机器上(离线 / 网络受限),
// 这会把一个本来能跑的仓库变成跑不起来的仓库;更糟的是 start.cjs 检测到 .node
// 缺失会再次触发 rebuild,于是每次 npm run dev 都重复这个破坏。
// 所以:rebuild 前快照,失败就还原。
const nodeFile = path.join(
projectRoot,
'node_modules',
'better-sqlite3',
'build',
'Release',
'better_sqlite3.node',
);
const backupDir = path.join(projectRoot, 'node_modules', '.cache', 'rkms');
const backupFile = path.join(backupDir, 'better_sqlite3.node.bak');
// --no-snapshot:调用方已经知道当前 .node 是坏的(缺失 / ABI 不匹配),别把它
// 当成"上一个可用版本"存进快照 —— 那会把之前真正可用的快照覆盖掉,把安全网变成陷阱。
// start.cjs 的自动 rebuild 永远走这个分支(它只在探测失败时才调用本脚本);
// 人手跑 `npm run rebuild:native` 时默认做快照,这样 rebuild 失败还能退回去。
const noSnapshot = process.argv.includes('--no-snapshot');
/**
* 当前 build/Release/.node 是否真的能在 Electron 里 dlopen。
*
* 只用 existsSync 判断"值得快照"是不够的 —— 本仓库的 .node 在两个 ABI 之间来回换:
* `npm run test:run` 的 pretest 会把它换成系统 Node ABI(137),`npm run dev` 需要的
* 是 Electron ABI(123)。文件一直存在,但对 Electron 而言可能是坏的。若把这种
* 文件存成"上一个可用版本",rebuild 失败后还原回去,安全网就变成了陷阱:
* 真正可用的旧快照被覆盖,而且日志还会谎称"已还原可用的 .node"。
*
* 探测手段与 start.cjs 的 probeBetterSqliteInElectron 一致(ELECTRON_RUN_AS_NODE=1
* 跑 Electron 内嵌 Node,ABI 与主进程完全一致)。探测不了就保守返回 false —— 宁可
* 不快照,也不要存一个来路不明的二进制。
*/
function existingBindingLoadsInElectron() {
let electronExePath;
try {
electronExePath = require('electron');
} catch {
return false;
}
if (typeof electronExePath !== 'string' || !fs.existsSync(electronExePath)) {
return false;
}
const probe = [
"try {",
" const Db = require('better-sqlite3');",
" const db = new Db(':memory:');",
" db.close();",
" process.exit(0);",
"} catch (e) {",
" process.exit(1);",
"}",
].join('\n');
try {
const r = spawnSync(electronExePath, ['--no-deprecation', '-e', probe], {
cwd: projectRoot,
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
encoding: 'utf8',
timeout: 30000,
});
return r.status === 0;
} catch {
return false;
}
}
if (!noSnapshot && fs.existsSync(nodeFile)) {
if (existingBindingLoadsInElectron()) {
try {
fs.mkdirSync(backupDir, { recursive: true });
fs.copyFileSync(nodeFile, backupFile);
console.log(`[rebuild-native] Snapshotted existing binding -> ${backupFile}`);
} catch (e) {
console.warn('[rebuild-native] Snapshot failed (continuing):', e.message);
}
} else {
console.log(
'[rebuild-native] 跳过快照:现有 .node 在 Electron 里加载失败(ABI 不匹配或已损坏),',
);
console.log('[rebuild-native] 存它只会覆盖掉真正可用的旧快照。');
}
}
function restoreBackup() {
// tri-state: 'restored' 成功还原,'no-backup' 快照不存在,'kept-existing'
// rebuild 失败但原地留了个文件 —— 这是最危险的中间态(node-gyp 异常退出
// 时可能留个 0 字节 .node),以前被 if/else 漏掉,用户看不到任何提示。
if (!fs.existsSync(backupFile)) return 'no-backup';
if (fs.existsSync(nodeFile)) return 'kept-existing';
try {
fs.mkdirSync(path.dirname(nodeFile), { recursive: true });
fs.copyFileSync(backupFile, nodeFile);
return 'restored';
} catch (e) {
console.error('[rebuild-native] Restore failed:', e.message);
return 'kept-existing';
}
}
console.log('[rebuild-native] Rebuilding better-sqlite3 against Electron ABI...');
console.log('[rebuild-native] electron-rebuild CLI:', electronRebuildCli);
const result = spawnSync(
process.execPath,
[
electronRebuildCli,
'-f', // force:即使 mtime 一致也重新编译
'-w', 'better-sqlite3', // 只重编这一个模块(避免动到其它 native module)
'--module-dir', projectRoot,
'--only', // 不读 package.json 里的所有 deps,只处理 -w 指定的
],
{
stdio: 'inherit',
env: {
...process.env,
// 关键:让 spawn 出来的 node 进程"扮演"成 electron 内嵌 node,
// 这样 require('electron') 仍能拿到 electron 二进制路径供 electron-rebuild 推断 ABI
ELECTRON_RUN_AS_NODE: '1',
},
},
);
if (result.error) {
console.error('[rebuild-native] spawn failed:', result.error.message);
if (restoreBackup() === 'restored') {
console.error('[rebuild-native] 已还原之前可用的 better_sqlite3.node,仓库仍可运行。');
}
process.exit(1);
}
const status = result.status ?? 1;
if (status !== 0) {
// 失败诊断:这两个是实战里最常见的原因,直接点名比让用户去读 node-gyp 堆栈快得多。
console.error('');
console.error('=====================================================');
console.error('[rebuild-native] Rebuild 失败。常见原因:');
console.error(' 1) 没有 C++ 工具链 —— node-gyp 报 "Could not find any Visual Studio installation"。');
console.error(' 装 VS Build Tools(勾选 "Desktop development with C++"):');
console.error(' winget install Microsoft.VisualStudio.2022.BuildTools');
console.error(' 2) prebuild 下载超时 —— better-sqlite3 的预编译包在 GitHub Releases 上,');
console.error(' 网络受限时会 timeout。配好代理后重试:');
console.error(' set HTTPS_PROXY=http://127.0.0.1:<port>');
console.error(' npm run rebuild:native');
console.error('=====================================================');
const restoreOutcome = restoreBackup();
if (restoreOutcome === 'restored') {
console.error('');
console.error('[rebuild-native] 已还原 rebuild 之前那个可用的 better_sqlite3.node。');
console.error('[rebuild-native] 仓库仍可 npm run dev —— 只要 Electron 版本没变,ABI 依旧匹配。');
} else if (restoreOutcome === 'no-backup') {
console.error('');
console.error('[rebuild-native] 警告:build/Release/better_sqlite3.node 现在不存在,');
console.error('[rebuild-native] 且没有可还原的快照。修好上面的原因后再跑一次。');
} else if (restoreOutcome === 'kept-existing') {
console.error('');
console.error('[rebuild-native] 警告:rebuild 失败,但原地还留着一个 better_sqlite3.node。');
let size = -1;
try {
size = fs.statSync(nodeFile).size;
} catch {}
console.error(
`[rebuild-native] 该文件大小 = ${size} 字节 —— 很可能是 node-gyp 异常退出时留下的残骸。`,
);
console.error('[rebuild-native] 手动删掉后再重跑:`rm build/Release/better_sqlite3.node`');
console.error(
'[rebuild-native] 不删的话下次探测照样不过,start.cjs 会反复触发 rebuild 死循环。',
);
}
}
process.exit(status);

109
scripts/run-tests.cjs Normal file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env node
// 自适应 vitest 启动器 —— 解决 better-sqlite3 的「双 ABI 互相覆盖」问题。
//
// 背景(2026-10-19 实地复现):
// better-sqlite3 不是 N-API,一个 .node 只对应一个 ABI:
// - Electron 30.5(NODE_MODULE_VERSION 123)要 Electron 版 —— app 运行时用
// - 系统 Node 24(NODE_MODULE_VERSION 137)要 Node 版 —— vitest 用
// 两者共用同一个路径:
// node_modules/better-sqlite3/build/Release/better_sqlite3.node
//
// 旧做法(pretest / pretest:run / pretest:coverage 都是这一行):
// node scripts/verify-abi-node.cjs || npm rebuild better-sqlite3
// → 为了让测试能跑,**破坏性地**把 app 那份覆盖成 Node 版。
// Windows 上 .node 被运行中的 electron 进程锁住 → unlink 报
// EPERM/EBUSY → 重建失败 → pretest 非 0 退出 → npm 把整条
// `npm run test:run` 一起中止(哪怕测试本身全绿)。
// 反向也成立:测试跑完 .node 变成 Node 版,下一次 `npm run dev` 又要
// 重建回 Electron 版 —— 每次切换都可能被运行中的 app 锁死。
//
// 策略:不再为了跑测试而重建。改成二选一启动器,按「当前 .node 到底是谁的」
// 决定用谁来跑 vitest:
// 1. 系统 Node 能直接 require('better-sqlite3') → 用系统 Node 跑 vitest。
// (Linux/macOS 的 napi prebuilt 天然两用;Windows 上若已是 Node 版 .node
// 也走这条)
// 2. 否则(Windows + Electron 版 .node,也就是常态)→ 用 Electron 自带的
// Node 跑 vitest(ELECTRON_RUN_AS_NODE=1)。此时测试与 app 共用同一份
// 原生模块:零重建、零 EBUSY、也不会把 app 那份覆盖掉。
//
// 附带收益:Electron 30.5 内嵌 Node 是 v20.16.0,仍在 package.json 的
// engines(>=20.16.0 <25)范围内,所以这条路不引入新的版本约束。
//
// 退出码透传 vitest 的退出码;CI 语义不变。
'use strict';
const { spawnSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const ROOT = path.resolve(__dirname, '..');
const VITEST_ENTRY = path.join(ROOT, 'node_modules', 'vitest', 'vitest.mjs');
const argv = process.argv.slice(2);
function log(msg) {
process.stderr.write(`[run-tests] ${msg}\n`);
}
/** 系统 Node 当前能否加载 better-sqlite3(= .node 是否是 Node ABI 版本)。 */
function nativeLoadable() {
try {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.close();
return true;
} catch {
return false;
}
}
/** electron npm 包在 Node 下导出的是二进制路径字符串;拿不到就退回 dist 猜路径。 */
function electronBinary() {
try {
const p = require('electron');
if (typeof p === 'string' && fs.existsSync(p)) return p;
} catch {
/* 落到下面的猜测 */
}
const exe = process.platform === 'win32' ? 'electron.exe' : 'electron';
const guess = path.join(ROOT, 'node_modules', 'electron', 'dist', exe);
return fs.existsSync(guess) ? guess : null;
}
function run(cmd, cmdArgs, env) {
const res = spawnSync(cmd, cmdArgs, { stdio: 'inherit', cwd: ROOT, env });
if (res.error) {
log(`启动失败: ${res.error.message}`);
return 1;
}
// 被信号杀死时 status 为 null
return typeof res.status === 'number' ? res.status : 1;
}
if (!fs.existsSync(VITEST_ENTRY)) {
log(`找不到 vitest 入口 ${VITEST_ENTRY} —— 先执行 npm install`);
process.exit(1);
}
let code;
if (nativeLoadable()) {
// 路线 1:系统 Node 直跑(app 那份 .node 已经是 Node 版,或 napi 通用版)
code = run(process.execPath, [VITEST_ENTRY, ...argv], process.env);
} else {
// 路线 2:用 Electron 自带 Node 跑 —— 与 app 共用同一份原生模块
const electron = electronBinary();
if (!electron) {
log('better-sqlite3 在系统 Node 下不可加载,且找不到 electron 可执行文件。');
log(' 手动修复(选一条):');
log(' npx electron-rebuild -f -w better-sqlite3 # 给 app 用');
log(' npm rebuild better-sqlite3 # 给系统 Node 用');
process.exit(1);
}
log('better-sqlite3 当前为 Electron ABI —— 改用 Electron 自带 Node 跑 vitest');
log(' (与 app 共用同一份原生模块:不重建、不覆盖,规避 EBUSY)');
code = run(electron, [VITEST_ENTRY, ...argv], {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
});
}
process.exit(code);

72
scripts/shutdown.cjs Normal file
View File

@@ -0,0 +1,72 @@
// scripts/shutdown.cjs
//
// audit r40 (2026-10-15):抽出 start.cjs 的子进程树清理逻辑,便于 vitest
// 单元测试(start.cjs 自身是顶层脚本,直接 require 会副作用跑整个 dev
// 链路,不便测)。
//
// 设计目标:
// - 输入 child (Node ChildProcess-like,需要 .pid 字段) + 可选 signal
// - Windows 走 taskkill /F /T /PID;POSIX 走 process.kill(-pid, ...)
// - 1500ms 后 SIGKILL 兜底
// - 任意抛错 swallow(启动期容错,本 helper 不该再抛出)
//
// 注意:Windows 上 Node 不支持 process.kill(-pid) 的 process group 语义,
// POSIX 分支仅在非 win32 跑;Windows 必须走 taskkill,负 PID 在 win32 上
// 会 ESRCH,这是已知差异。
const { spawnSync } = require('child_process');
/**
* 递归杀进程树。
* @param {{ pid?: number }} child : 需要 .pid;可无(空对象 → no-op)
* @param {string} [signalName] : POSIX 下首次发的信号;默认 'SIGTERM'
* @param {object} [opts]
* @param {string} [opts.platform] : 默认 process.platform;测时 mock
* @param {(pid: number) => unknown} [opts.posixGroupKill] : POSIX 杀进程的 helper;默认 process.kill
* @param {() => void} [opts.posixEscalate] : 1500ms 升级到 SIGKILL 的 schedule;默认 setTimeout
* @returns {{ killed: boolean, method: 'taskkill'|'posix'|'noop' }}
*/
function shutdownTree(child, signalName, opts) {
const pid = child && child.pid;
// PID 0 是 kernel idle process,不可能是 user-space child;统一视为 noop
if (typeof pid !== 'number' || !Number.isFinite(pid) || pid <= 0) {
return { killed: false, method: 'noop' };
}
const platform = (opts && opts.platform) || process.platform;
const posixGroupKill = (opts && opts.posixGroupKill) || ((p) => process.kill(p));
const posixEscalate = (opts && opts.posixEscalate) || ((fn) => setTimeout(fn, 1500));
if (platform === 'win32') {
try {
// windowsHide: 子进程在父 console handle 内隐藏,不弹黑色 cmd 窗口
spawnSync('taskkill', ['/F', '/T', '/PID', String(pid)], {
stdio: 'ignore',
windowsHide: true,
});
return { killed: true, method: 'taskkill' };
} catch {
// taskkill 自身失败 —— child 大概率已死,fallthrough
return { killed: false, method: 'taskkill' };
}
}
// POSIX:负 PID = process group(shell:true 时 child.pid 是 cmd 的 PID,
// cmd 自己起的 electron + vite 子进程都在同一 group)
try {
posixGroupKill(-pid, signalName || 'SIGTERM');
posixEscalate(() => {
try {
posixGroupKill(-pid, 'SIGKILL');
} catch {
// already exited
}
});
return { killed: true, method: 'posix' };
} catch {
// ESRCH = group 已不存在
return { killed: false, method: 'posix' };
}
}
module.exports = { shutdownTree };

529
scripts/start.cjs Normal file
View File

@@ -0,0 +1,529 @@
#!/usr/bin/env node
// 启动包装器:
// 1) 取消 ELECTRON_RUN_AS_NODE(用户的 shell 里可能有)
// 2) Windows 路径兜底:如果 cwd 含 CJK 或超长(>240),在 %LOCALAPPDATA%\rkms-junctions\<hash>
// 建一个 ASCII junction,然后 chdir 进去再启动 electron-vite。
// 这能避免 better-sqlite3 dlopen 时 \\?\ 前缀把 CJK 路径解读成 mojibake。
// 3) Windows 终端 codepage 切到 65001,避免 electron-vite 输出中文时乱码。
//
// 逃生通道(给用户控制权,不让工具绑架项目路径):
// - RKMS_NO_JUNCTION=1 跳过 junction,直接用原始 cwd(现代 Win11 + Electron 通常能直接吃 CJK 路径)
// - RKMS_JUNCTION_DIR=... 自定义 ASCII junction 父目录(LOCALAPPDATA 被污染时用这个)
const { spawn, execFileSync, spawnSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
// audit r40 (2026-10-15):shutdownTree 抽到 scripts/shutdown.cjs 便于单测;
// 详见该文件头注。
const { shutdownTree } = require('./shutdown.cjs');
// Windows 终端默认 codepage 是 cp936(中文系统),会把后续 console.* 里的 UTF-8 字节
// 按 GBK 解读 → 中文路径变成 鎴... 这种乱码。这个乱码连 start.cjs 自己的 ABI 探针
// 失败回显都吃(实测:探针的 stderr 是 UTF-8,Node 用 utf8 解码后 console.warn 写到终端,
// 终端 codepage=cp936 时显示成 `The module '\\?\C:\data\æ£å¨å·¥ä½\æºè½ä½ç®¡çç`)。
//
// 必须发生在**任何** console.* 调用之前 —— 一旦第一段 UTF-8 中文进了终端缓冲,
// 就已经被 cp936 错误解码了,改 codepage 也救不回来。所以这条不能下移到 spawn 之前,
// 必须放在文件顶部。
//
// 实现:cmd /c chcp 65001 通过共享的 console handle 改 codepage,影响同一 console
// 上的所有进程(包括 node 自身的后续 stdout)。失败时(非交互 console / 已 UTF-8
// 终端)忽略 —— 顶多中文乱码,不影响功能。RKMS_SKIP_CHCP 给 CI / 自动化场景逃生。
if (process.platform === 'win32' && !process.env.RKMS_SKIP_CHCP) {
try {
// audit 2026-09-14:windowsHide: true —— 避免 cmd.exe 启动瞬间的 console
// 窗口闪烁。cmd 是 console-subsystem,默认 spawn 会弹一个黑色 cmd 窗口
// 即使 stdio: 'ignore' 也可见;windowsHide 让子进程在父 console handle
// 内隐藏运行,chcp 65001 改 codepage 仍生效(共享 console)。
spawnSync('cmd.exe', ['/c', 'chcp', '65001'], { stdio: 'ignore', windowsHide: true });
} catch {
// codepage 切换失败,忽略 —— 后续输出可能乱码但不影响功能
}
}
const command = process.argv[2];
if (!command) {
console.error('Usage: node scripts/start.cjs <dev|build|start>');
process.exit(1);
}
const commandMap = {
dev: ['electron-vite', ['dev']],
build: ['electron-vite', ['build']],
start: ['electron-vite', ['preview']],
};
// 轻量 verify 子命令:只检查 better_sqlite3.node 的 mtime vs electron.exe 的 mtime,
// 不启动 electron-vite。给 predev / CI / 用户手动诊断用。
// node scripts/start.cjs verify-abi
// exit 0 = OK 或已自动修复; exit 1 = 修复失败(需要人工介入)
if (command === 'verify-abi') {
const fixed = checkAndFixNativeAbi({ silent: true });
if (!fixed) {
console.error('[start.cjs] verify-abi: ABI mismatch detected and auto-rebuild failed.');
console.error('[start.cjs] 请手动执行: npx electron-rebuild -f -w better-sqlite3');
process.exit(1);
}
process.exit(0);
}
const [cmd, args] = commandMap[command];
if (!cmd) {
console.error(`Unknown command: ${command}. Use dev|build|start|verify-abi.`);
process.exit(1);
}
// 创建干净的环境
const env = { ...process.env };
// 关键:取消这两个变量
delete env.ELECTRON_RUN_AS_NODE;
delete env.ELECTRON_NO_ATTACH_CONSOLE;
if (process.platform === 'linux') {
// 在 Linux 上,sandbox 经常需要 disable
env.NO_SANDBOX = '1';
}
// 启动期 pre-check:better-sqlite3 的 NODE_MODULE_VERSION 是否匹配当前 Electron ABI。
// 早于所有 junction / RKMS_NO_JUNCTION / build-skip 分支,这样:
// 1) 错误信息里路径还指向原始 cwd(用户能一眼看出是项目目录,不是 LOCALAPPDATA junction)
// 2) 即使后面 junction / build-skip 改了 cwd,ABI 检查也已完成
// 3) 三个子命令 (dev/build/start) 统一受益
//
// mtime 嗅探不是 100% 准确(系统时钟回拨 / 同步工具篡改 mtime 会误判),
// 但 Electron 升级 + ABI 漂移是 mtime 漂移最常见的诱因,实战足够。
// 万一漏判,主进程 path-check.ts 还有第二道兜底 + 清晰错误提示。
//
// 拿到返回值:checkAndFixNativeAbi() 在探测+自动 rebuild 都失败时返回 false,
// 这种情况下继续 spawn electron-vite 会导致 Electron 主进程 dlopen better-sqlite3
// 抛 "Could not locate the bindings file",一路被 unhandledRejection 吞掉,最后
// 表现为"DB not initialized"—— 这正是这个 precheck 想避免的失败模式。
// dev/build/start 三个子命令都没有 pre* hook 走 verify-abi 子命令路径,所以必须
// 自己处理返回值。
if (checkAndFixNativeAbi() === false) {
console.error('[start.cjs] better-sqlite3 ABI 不匹配且自动重建失败,中止启动。');
console.error('[start.cjs] 请手动执行: npx electron-rebuild -f -w better-sqlite3');
console.error('[start.cjs] 或:`npm run rebuild:native`(离线 / 工具链缺失请先看脚本输出)。');
process.exit(1);
}
// 路径策略(2026-08-28 修订):ASCII junction 默认**关闭**,改为 RKMS_FORCE_JUNCTION=1 显式开启。
//
// 历史:D2-020 认为 Windows + CJK cwd 会让 better-sqlite3 dlopen 拿到 GBK 乱码路径,
// 于是在 %LOCALAPPDATA% 建 ASCII junction 再 chdir 进去。实测这个防御无效且有害:
//
// 1) 无效 —— Node 的模块解析默认走 realpath。从 junction 里 require('better-sqlite3')
// 仍然解析回真实 CJK 路径才 dlopen;junction 只改了 process.cwd(),没改最终传给
// LoadLibraryEx 的路径。证据:junction 模式下 bindings 报错列出的全是 CJK 路径。
//
// 2) 有害 —— cwd(junction)与 vite 的 cacheDir(realpath 后的 CJK 路径)不同源。
// vite 的 esbuildOutputFromId() 用 path.relative(process.cwd(), ...) 去匹配 esbuild
// metafile 的 key,跨源匹配不上就返回 undefined,而调用方直接解引用 output.imports:
// TypeError: Cannot read properties of undefined (reading 'imports')
// dev server 整个进程崩掉。
//
// 3) 不必要 —— Electron 30 能直接 dlopen CJK 路径。verify-abi 探针就是在真实 CJK cwd 下
// spawn electron.exe 跑 new Database(':memory:') 成功的。
//
// 万一某个 Windows / Electron 组合真的碰到 CJK dlopen 失败,用 RKMS_FORCE_JUNCTION=1 开回来
//(代价:vite optimizeDeps 可能重现上面的崩溃)。
let effectiveCwd = process.cwd();
if (process.platform === 'win32') {
const originalCwd = process.cwd();
if (process.env.RKMS_FORCE_JUNCTION === '1' && command === 'build') {
// build 永不走 junction:vite 5 的 build-html transform 在 junction 下会把
// renderer/index.html 解析回 CJK 绝对路径,抛 "The 'fileName' or 'name'
// properties of emitted chunks and assets must be strings that are neither
// absolute nor relative paths"。build 阶段也不 dlopen native module,没必要。
console.log('[start.cjs] build 忽略 RKMS_FORCE_JUNCTION(与 vite build-html 不兼容)');
} else if (process.env.RKMS_FORCE_JUNCTION === '1') {
const junctionPath = setupWindowsJunction(originalCwd);
if (!junctionPath) {
console.error('[start.cjs] RKMS_FORCE_JUNCTION=1 但 junction 创建失败,见上方原因。');
process.exit(1);
}
try {
process.chdir(junctionPath);
effectiveCwd = junctionPath;
console.log(`[start.cjs] cwd -> ${junctionPath} (junction of ${originalCwd})`);
} catch (e) {
console.error('[start.cjs] chdir to junction failed:', e.message);
process.exit(1);
}
} else if (originalCwd.length > 240) {
// audit round 8 (2026-09-14):硬 fallback —— 之前只 warn,继续在超长 cwd
// 里 spawn electron-vite,后面 native module dlopen / vite pre-transform
// 才报错,失败模式不直观。修:超长路径直接走 junction 兜底,与
// RKMS_FORCE_JUNCTION=1 同条 setupWindowsJunction 路径,只是无 opt-in。
// junction 创建失败时再降级到 warn(老行为),让用户有最后机会绕开。
const junctionPath = setupWindowsJunction(originalCwd);
if (junctionPath) {
try {
process.chdir(junctionPath);
effectiveCwd = junctionPath;
console.log(
`[start.cjs] cwd 长度 ${originalCwd.length} > 240,自动 junction 兜底: ${junctionPath}`,
);
} catch (e) {
console.error('[start.cjs] chdir to junction failed:', e.message);
process.exit(1);
}
} else {
console.warn(`[start.cjs] 警告:cwd 长度 ${originalCwd.length} > 240,junction 兜底创建失败。`);
console.warn('[start.cjs] 继续在原始 cwd 启动,可能遇到 native module 加载失败。');
}
}
}
// Windows 终端 codepage 已在文件顶部 chcp 65001 切到 UTF-8,这里再加一次是
// belt-and-suspenders:某些 spawn 场景下(罕见,比如 detached console)子进程
// 不一定继承父 codepage,在 spawn shell 里再切一次是 0 成本的保险。
let spawnCmd = cmd;
let spawnArgs = args;
if (process.platform === 'win32') {
spawnCmd = `chcp 65001 >nul && ${cmd} ${args.map((a) => (a.includes(' ') ? `"${a}"` : a)).join(' ')}`;
spawnArgs = [];
}
// 把 effectiveCwd (junction 或原始 ASCII cwd) 透传给子进程。
// electron.vite.config.ts 读 RKMS_JUNCTION_PATH 加入 vite server.fs.allow,
// 解决 Windows + CJK 项目路径下 dev server pre-transform 抛
// "Failed to load url /src/main.tsx ... Does the file exist?"。
if (process.platform === 'win32' && effectiveCwd && /[A-Za-z]:\\/.test(effectiveCwd)) {
env.RKMS_JUNCTION_PATH = effectiveCwd;
}
const child = spawn(spawnCmd, spawnArgs, {
stdio: 'inherit',
env,
shell: true,
cwd: effectiveCwd,
});
// audit r40 (2026-10-15):Ctrl+C / SIGTERM 时递归清子进程树。
// 之前只监听 child 'exit' / 'error',用户按 Ctrl+C 时 Node 收到 SIGINT,
// 但 shell:true 下 child.pid 是 cmd.exe 的 PID,Node 默认 SIGTERM 转发
// 只触达 cmd.exe —— cmd.exe 死后 electron.exe + 多个 vite + electron helper
// 进程**不一定**跟着挂(实测常留 5+ 个僵尸)。下次启动时:
// 1) electron Cache_Data 文件被旧实例锁 → "cache_util_win.cc: Unable
// to move the cache: 拒绝访问" + 多个 Gpu Cache Creation failed
// 2) 多份 vite dev server 占 5173 / 5174 → "Port ... in use, trying
// another one..." 落 5175
// 修:本进程收 SIGINT/SIGTERM/SIGHUP 时主动杀进程树。
// 详见 scripts/shutdown.cjs 头注。
// child.on('exit') 仍保留作"子进程自己死掉"的兜底(spawn 链路本身异常
// 退出,父 Node 也要退出),但 shutdown 在前;两者用 shutdownInFlight 防
// 重入(防止 taskkill 触发的 child exit 又走 child.on('exit') 二次退出)。
let shutdownInFlight = false;
['SIGINT', 'SIGTERM', 'SIGHUP'].forEach((sig) => {
process.on(sig, () => {
if (shutdownInFlight) return;
shutdownInFlight = true;
shutdownTree(child, sig);
// 立即给上层一个非 0 退出码,不等 child.on('exit') 兜底
process.exit(128 + (sig === 'SIGINT' ? 2 : sig === 'SIGTERM' ? 15 : 1));
});
});
// audit 2026-10-01:补 error 监听 + 修正信号退出码。
// 1) 只监听 exit 时,spawn 失败(EAGAIN/EMFILE、cmd.exe 被策略软件拦截、
// cwd 不存在)产生的 'error' 事件无人处理 —— Node 会打一段内部栈,
// 与本文件其它分支精心设计的 "[start.cjs] ..." 中文提示风格割裂。
// 2) `code ?? 0` 把「被信号杀死」(code === null、signal 有值)吞成退出码 0,
// 于是 Ctrl+C / 任务管理器结束进程后 npm 看到的是成功,predev 链路
// 判定通过。信号退出应返回非 0。
child.on('error', (err) => {
console.error('[start.cjs] 启动子进程失败:', err && err.message ? err.message : err);
process.exit(1);
});
child.on('exit', (code, signal) => {
if (shutdownInFlight) {
// shutdownTree 已写过 process.exit,这里不再二次调用,防状态错乱
return;
}
if (signal) {
// 被信号终止:signal 名(如 'SIGTERM')转成 128+n 是 shell 惯例
const names = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 };
process.exit(128 + (names[signal] || 1));
}
process.exit(code ?? 1);
});
/**
* 在 %LOCALAPPDATA%\rkms-junctions\<hash16> 建一个指向 realCwd 的 junction。
* 复用规则:
* - 已存在且指向 realCwd → 直接返回
* - 已存在但指向别的目标 → 删除重建
* - 不存在 → 创建
* - 已存在但不是 junction(实目录) → 返回 null(让上层走错误分支)
* - 创建失败 → 返回 null
*
* 可通过环境变量 RKMS_JUNCTION_DIR 覆盖父目录位置(给 LOCALAPPDATA 被 CJK 污染的场景用)。
*/
function setupWindowsJunction(realCwd) {
// audit round 8 (2026-09-14):显式 win32 guard —— 函数名带 "Windows",且
// 内部拼的是 Windows 专属的 %LOCALAPPDATA% 路径。加这条防御让函数自描述
// + 防未来跨平台误用。
if (process.platform !== 'win32') {
console.error('[start.cjs] setupWindowsJunction called on non-win32 platform');
return null;
}
const customBase = process.env.RKMS_JUNCTION_DIR;
// audit 2026-10-01:兜底从硬编码的 'C:\Users\guan'(原作者本机用户名)换成
// os.homedir()。原值有两处问题:一是把个人路径固化进要分发的脚本;
// 二是 LOCALAPPDATA 与 USERPROFILE 都缺失时会去**别人的** profile 下
// 建 junction,报出的错完全指不到真因。os.homedir() 走的是同样的系统
// 解析(USERPROFILE → HOMEDRIVE+HOMEPATH → 兜底),不需要自己拼。
const localAppData =
process.env.LOCALAPPDATA ||
path.join(process.env.USERPROFILE || os.homedir(), 'AppData', 'Local');
// 用 sha256 前 16 位而非 8 位,降低多项目/重建路径时的碰撞概率
const hash = crypto.createHash('sha256').update(realCwd).digest('hex').slice(0, 16);
const baseDir = customBase || path.join(localAppData, 'rkms-junctions');
const junctionDir = path.join(baseDir, hash);
const parentDir = path.dirname(junctionDir);
// 防御:junction 父目录自身不能含 CJK,否则 junction 路径还是坏的
if (/[^\x00-\x7F]/.test(junctionDir)) {
console.error(`[start.cjs] Computed junction path contains non-ASCII: ${junctionDir}`);
console.error('[start.cjs] Check your LOCALAPPDATA / RKMS_JUNCTION_DIR environment variable.');
return null;
}
// 1. 检查是否已存在
try {
const lst = fs.lstatSync(junctionDir);
if (lst.isDirectory()) {
let target = null;
try {
target = fs.readlinkSync(junctionDir);
} catch {
// 路径是目录但不是 junction/link → 冲突,不让 start.cjs 误删用户数据
console.error(`[start.cjs] ${junctionDir} exists but is not a junction.`);
console.error(
'[start.cjs] Remove it manually, or set RKMS_JUNCTION_DIR to a fresh ASCII dir.',
);
return null;
}
if (path.resolve(target) === path.resolve(realCwd)) {
console.log(`[start.cjs] Reusing junction: ${junctionDir}`);
return junctionDir;
}
// 指向别的目标 → 删了重建
console.log(`[start.cjs] Removing stale junction ${junctionDir} (-> ${target})`);
try {
fs.rmSync(junctionDir, { recursive: true, force: true });
} catch (e) {
console.error(`[start.cjs] Failed to remove stale junction:`, e.message);
return null;
}
} else {
// 路径存在但不是目录(可能是文件) → 删掉后重建
console.log(`[start.cjs] Removing non-directory at ${junctionDir}`);
try {
fs.unlinkSync(junctionDir);
} catch (e) {
console.error(`[start.cjs] Failed to unlink non-directory:`, e.message);
return null;
}
}
} catch (e) {
if (e.code !== 'ENOENT') {
console.error(`[start.cjs] Junction probe failed:`, e.message);
return null;
}
// 不存在 → 继续创建
}
// 2. 确保父目录存在
try {
fs.mkdirSync(parentDir, { recursive: true });
} catch (e) {
console.error(`[start.cjs] Failed to create parent dir ${parentDir}:`, e.message);
return null;
}
// 3. 建 junction
try {
// mklink /J 是 cmd 内置,必须经 cmd.exe 调用
execFileSync('cmd.exe', ['/c', 'mklink', '/J', junctionDir, realCwd], { stdio: 'pipe' });
console.log(`[start.cjs] Created junction: ${junctionDir} -> ${realCwd}`);
// 4. 创建后立刻验证 — 极少数情况下 mklink 成功但 readlink 行为异常
try {
const verify = fs.readlinkSync(junctionDir);
if (path.resolve(verify) !== path.resolve(realCwd)) {
console.error(`[start.cjs] Junction created but verification failed:`);
console.error(` expected -> ${realCwd}`);
console.error(` got -> ${verify}`);
return null;
}
} catch (e) {
console.error(`[start.cjs] Junction verify readlink failed:`, e.message);
return null;
}
return junctionDir;
} catch (e) {
// execFileSync 失败时 e.stderr 是 cmd 输出的多行文本(GBK/UTF-8 都有)
const stderr = e.stderr ? e.stderr.toString().trim() : e.message;
console.error(`[start.cjs] mklink failed:`);
console.error(` ${stderr.replace(/\r?\n/g, '\n ')}`);
return null;
}
}
/**
* 在 Electron 子进程里 try-load better-sqlite3 —— 这是判断 ABI 是否匹配最可靠的信号。
* 之前用 .node mtime vs electron.exe mtime 的方案有致命缺陷:electron-rebuild 解包
* prebuilt 后 mtime 永远是 2025-05-08(打包时间),无论 ABI 是否对齐,导致每次启动
* 都误判为 mismatch 而陷入 rebuild 死循环。
*
* 思路:用 spawnSync 起一个 electron.exe,设 ELECTRON_RUN_AS_NODE=1 让它像 node 一样
* 执行 probe 脚本。脚本里 require('better-sqlite3') + new Database(':memory:'),成功就
* exit 0,失败就 exit 1。这跑的是 Electron 内嵌的 Node,ABI 与主进程完全一致 —— 与主
* 进程 dlopen 是同一个动作的子集。
*
* @returns {{ok: boolean, reason?: string}}
*/
function probeBetterSqliteInElectron() {
let electronExePath;
try {
// 普通 node 进程下 require('electron') 返回 electron.exe 的字符串绝对路径
electronExePath = require('electron');
} catch {
return { ok: true, reason: 'electron module not resolvable (skipped)' };
}
if (typeof electronExePath !== 'string' || !fs.existsSync(electronExePath)) {
return { ok: true, reason: 'electron binary not at expected path (likely packaged; skipped)' };
}
const probeScript = [
'try {',
" const Db = require('better-sqlite3');",
" const db = new Db(':memory:');",
' db.close();',
' process.exit(0);',
'} catch (e) {',
" process.stderr.write('ABI_PROBE_FAIL: ' + (e && e.message ? e.message : String(e)));",
' process.exit(1);',
'}',
].join('\n');
let result;
try {
result = spawnSync(electronExePath, ['--no-deprecation', '-e', probeScript], {
cwd: process.cwd(),
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
encoding: 'utf8',
timeout: 30000,
});
} catch (e) {
return { ok: false, reason: 'spawn failed: ' + (e.message || String(e)) };
}
if (result.error) {
return { ok: false, reason: 'spawn error: ' + result.error.message };
}
if (result.status === 0) {
return { ok: true };
}
const reason =
(result.stderr || result.stdout || '').toString().trim() || `exit ${result.status}`;
return { ok: false, reason };
}
/**
* 检测 better-sqlite3 的 .node 是否匹配当前 Electron 内嵌 Node 的 ABI。
* 不匹配时(典型场景:升级了 Electron 但没重跑 npm install,postinstall 跳过)自动 rebuild。
*
* 依赖 probeBetterSqliteInElectron() —— 真正加载一次来确认 ABI。
* mtime 比较不可靠(prebuilt mtime 永远是打包时间),所以这里不再做。
*
* @param {object} [opts]
* @param {boolean} [opts.silent=false] verify-abi 子命令用 — 不打警告只返回结果
* @returns {boolean} true = OK 或已修复;false = 检测到 mismatch 但修复失败
*/
function checkAndFixNativeAbi(opts = {}) {
const { silent = false } = opts;
// Linux/macOS 上 .node 由 better-sqlite3 的 prebuilt 直接发,napi v3 跨 Node 版本 ABI 一致;
// 只在 Windows 上有可能出现 postinstall 装错 .node 的历史问题。
if (process.platform !== 'win32') return true;
const cwd = process.cwd();
const sqlitePath = path.resolve(
cwd,
'node_modules',
'better-sqlite3',
'build',
'Release',
'better_sqlite3.node',
);
// .node 缺失时**不能**放行。
// 旧逻辑在这里 return true(理由是"第一次 install,交给 postinstall"),但 postinstall
// 只在 npm install 时跑。一旦 build/ 被清空、或 electron-builder install-app-deps 静默失败,
// predev 的 verify-abi 就一路绿灯,直到主进程 dlopen 才炸 "Could not locate the bindings
// file" —— 而且是在后台 DB init 里被吞成 unhandledRejection + 满屏 "DB not initialized",
// 根因离现场很远。所以:缺失 == 需要 rebuild,和 ABI 不匹配走同一条修复路径。
let reason;
if (!fs.existsSync(sqlitePath)) {
reason = `missing ${path.relative(cwd, sqlitePath)}`;
} else {
// 真正的 ABI 检查 —— 在 Electron 子进程里 try-load
const probe = probeBetterSqliteInElectron();
if (probe.ok) {
if (!silent && probe.reason && probe.reason.includes('skipped')) {
console.log(`[start.cjs] ABI check: ${probe.reason}`);
}
return true;
}
reason = probe.reason || 'unknown';
}
if (!silent) {
console.warn('');
console.warn('=====================================================');
console.warn('[start.cjs] better-sqlite3 native binding unusable:');
console.warn(` ${reason}`);
console.warn('[start.cjs] Rebuilding better-sqlite3 against current Electron ABI...');
console.warn('=====================================================');
}
try {
execFileSync(
process.execPath,
// --no-snapshot:走到这里说明当前 .node 已经确认不可用(缺失或 ABI 不匹配),
// 不能把它快照成"上一个可用版本",否则会覆盖掉真正可用的那份快照。
[path.resolve(__dirname, 'rebuild-native.cjs'), '--no-snapshot'],
{ stdio: silent ? 'pipe' : 'inherit' },
);
} catch (e) {
if (silent) {
// verify-abi 路径:失败时仍输出 stderr 让 caller 看到 rebuild-native 的报错
if (e.stderr) process.stderr.write(e.stderr);
if (e.stdout) process.stdout.write(e.stdout);
} else {
console.error('[start.cjs] Auto-rebuild failed.');
console.error(' 请手动执行: npx electron-rebuild -f -w better-sqlite3');
console.error(' 或: npm run rebuild:native');
}
return false;
}
// Rebuild 完再 probe 一次,确认真的修好了 (理论上 rebuild 不会失败,但保险)
const recheck = probeBetterSqliteInElectron();
if (recheck.ok) {
if (!silent) console.log('[start.cjs] Rebuild succeeded, ABI matches.');
return true;
}
if (!silent) {
console.error('[start.cjs] Rebuild completed but ABI still mismatches:');
console.error(` ${recheck.reason || 'unknown'}`);
console.error(' 建议手动执行: npm rebuild better-sqlite3');
}
return false;
}

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env node
// 检查 better-sqlite3 在当前 Node (system Node, ABI=N) 下能否加载。
// 不可加载时 exit 1,由 npm pretest hook 触发 `npm rebuild better-sqlite3`。
//
// 与 scripts/start.cjs 的 verify-abi 互为镜像:
// - verify-abi → 检查 Electron 内嵌 Node 的 ABI(ELECTRON_RUN_AS_NODE 子进程)
// - verify-abi-node → 检查 system Node 的 ABI(直接 require)
//
// 两者都需要的原因:better-sqlite3 不是 N-API,每个 .node 只对应一个 ABI。
// Electron 30.5.1 用 ABI 123,系统 Node 20.15.x 用 ABI 115 —— 不能共用。
// dev 时调 verify-abi(配合 electron-rebuild),test 时调本脚本(配合 npm rebuild)。
try {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.close();
process.exit(0);
} catch (e) {
process.stderr.write(`[verify-abi-node] better-sqlite3 failed under system Node: ${e && e.message ? e.message : e}\n`);
process.exit(1);
}

196
src/main/db/cjk-segment.ts Normal file
View File

@@ -0,0 +1,196 @@
// src/main/db/cjk-segment.ts
//
// r196:CJK 分词 —— 让中文全文搜索真正能用。
//
// ── 问题(2026-10-20 内存库实测)────────────────────────────────────────
// FTS5 的 unicode61 分词器把**连续的中文串**当成**一个** token。
// unicode61 不区分文字系统 —— 汉字是 Unicode 字母(Lo),于是
// 「本文介绍注意力机制」整串 = 1 个 token
// 「Attention注意力机制」整串 = 1 个 token(中英混排同样中招)
// 后果:搜「学习」「机制」「注意力」这类**词内子串**一律 0 结果,
// 只有恰好在整串开头的前缀能命中。实测(unicode61 + remove_diacritics 2):
// 学习 → 0 行 / 注意力 → 1 行(恰好在句首) / 机制 → 0 行
// 即中文笔记的全文搜索基本等于不可用,这是 r196 要修的根因。
//
// ── 解法:索引侧插 ZWSP 分隔 + 查询侧改用短语 ────────────────────────
// 索引侧:「深度学习」→「深␣度␣学␣习」(␣ = U+200B 零宽空格)
// ⇒ unicode61 视 ZWSP 为分隔符(它属 Cf 类,不是字母/数字),
// 于是每个汉字成为独立 token。
// 查询侧:「学习」→ FTS5 短语 "学 习"
// ⇒ 要求两个 token **相邻** ⇒ 命中所有含「学习」的文本且保序。
// CJK↔拉丁边界同样用 ZWSP 断开(否则「Attention注意力」仍是**一个**
// token,两边都搜不到)。
//
// ── 为什么用 ZWSP(U+200B)而不是别的分隔符 ─────────────────────────
// * unicode61 视其为分隔符(实测);
// * 视觉不可见 ⇒ 万一漏了清洗也不会被用户看见(我们仍然清洗);
// * 不占用 ASCII 可见字符 ⇒ 不污染搜索词 / 文件名 / 片段文本。
//
// ── 为什么不用 trigram 分词器 ───────────────────────────────────────
// trigram 需要**至少 3 个字符**才能表达一次查询,中文双字词
// (「学习」「机制」「注意力」)根本没法用 trigram 查 —— 恰恰是中文
// 最常见的查询形态。
//
// ── 为什么不用自定义 tokenizer ───────────────────────────────────────
// FTS5 的自定义分词器必须是 C 扩展,better-sqlite3 不允许在 JS 层注册。
//
// ── 模块边界 ─────────────────────────────────────────────────────────
// * 本模块是**纯函数**(无 electron / 无 DB 依赖)⇒ 可直接单测;
// * 索引侧(segmentCjk)只在写 FTS 前调用,主表 resources.title 保持原样;
// * 查询侧(segmentQueryToken / splitScriptRuns)在 sanitizeFtsQuery 里用;
// * 展示侧(stripZwsp)在 searchResources 返回前把 ZWSP 洗掉。
// 类型导入(编译期擦除)⇒ 本模块运行时**不依赖** better-sqlite3 原生模块,
// 纯函数部分可直接单测。
import type Database from 'better-sqlite3';
/**
* 分隔符:零宽空格(U+200B)。unicode61 当它是分隔符。
*
* ⚠ 必须写成转义序列而不是字面量 —— U+200B 是**不可见字符**,写进源码后
* 在编辑器 / diff / 复制粘贴里都看不出来,极易退化成空串(实测踩过:
* 一旦变成 '' 分词就完全不生效,而代码看着完全正常,只能靠断言抓住)。
*/
export const ZWSP = String.fromCharCode(0x200b);
const ZWSP_RE = new RegExp(ZWSP, 'g');
/**
* CJK 字符判定。
*
* 覆盖:汉字(CJK 统一表意文字 + 扩展 A + 兼容表意文字 + 扩展 B)、
* 日文假名(平假名 / 片假名)、谚文音节。
*
* 为何包含假名与谚文:这些文字同样「字之间无空格」,unicode61 会把整串
* 当一个 token,与汉字同病。谚文音节虽然以词为单位,但按音节切开仍能
* 保持短语相邻性 ⇒ 词内子串查询照样成立(见 segmentQueryToken)。
*
* 为何不含 CJK 标点:标点在 unicode61 下本就是分隔符,不需要额外处理。
*/
const CJK_RE =
/[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]|[\u{20000}-\u{2A6DF}]/u;
/** 单字符是否是 CJK。 */
export function isCjkChar(ch: string): boolean {
return CJK_RE.test(ch);
}
/** 文本里是否含 CJK(查询侧据此决定用短语还是前缀)。 */
export function hasCjk(text: string): boolean {
return CJK_RE.test(text);
}
/** 去掉 ZWSP(展示侧 / 幂等重分词前用)。 */
export function stripZwsp(text: string): string {
return text.replace(ZWSP_RE, '');
}
/**
* 索引侧分词:在 CJK 字符之间、以及 CJK 与非 CJK 的边界插入 ZWSP。
*
* 幂等:先剥掉已有的 ZWSP 再切 ⇒ 对同一文本反复调用结果相同
* (存量迁移 0009 依赖这一点:万一某行已被分词,重跑不会累积 ZWSP)。
*/
export function segmentCjk(text: string): string {
if (!text) return '';
const clean = stripZwsp(text);
let out = '';
let prevCjk = false;
// for...of 按码点迭代 ⇒ 代理对(扩展 B 汉字)不会被拆成两半
for (const ch of clean) {
const c = isCjkChar(ch);
if (out.length > 0 && (c || prevCjk)) out += ZWSP;
out += ch;
prevCjk = c;
}
return out;
}
/**
* 查询侧分词:把一个查询 token 拆成 FTS5 短语的组成部分。
*
* '学习' → ['学', '习'] ⇒ 短语 "学 习"
* '机器学习' → ['机', '器', '学', '习']
* 'Attention机制' → ['Attention', '机', '制'] ⇒ 短语 "Attention 机 制"
*
* 非 CJK 连续片段整体保留为一个部分(它会被 FTS5 自己按 unicode61 切开,
* 与索引侧的处理一致)。
*/
export function segmentQueryToken(token: string): string[] {
const parts: string[] = [];
let buf = '';
for (const ch of token) {
if (isCjkChar(ch)) {
if (buf) {
parts.push(buf);
buf = '';
}
parts.push(ch);
} else {
buf += ch;
}
}
if (buf) parts.push(buf);
return parts;
}
/**
* 按「文字系统」把 token 切成若干 run —— **连续的同族字符合成一个 run**。
*
* 'CNN模型' → [{cjk:false, text:'CNN'}, {cjk:true, text:'模型'}]
* '模型' → [{cjk:true, text:'模型'}]
* 'Attention机制' → [{cjk:false, text:'Attention'}, {cjk:true, text:'机制'}]
*
* 与 segmentQueryToken 的分工:
* - segmentQueryToken 把**每个汉字**单独成一份 ⇒ 用来拼 FTS5 短语
* (短语要求逐字 token 相邻);
* - splitScriptRuns 把**连续汉字合成一个 run** ⇒ 用来判断一个 token
* 是不是「混排」,以及混排时该按几个子句去查(r208)。
*/
export interface ScriptRun {
/** run 是否由 CJK 字符组成(决定用短语还是前缀通配)。 */
cjk: boolean;
/** run 的原文(不含任何分隔符)。 */
text: string;
}
export function splitScriptRuns(token: string): ScriptRun[] {
const runs: ScriptRun[] = [];
let text = '';
let cjk = false;
for (const ch of token) {
const c = isCjkChar(ch);
if (text && c !== cjk) {
runs.push({ cjk, text });
text = '';
}
cjk = c;
text += ch;
}
if (text) runs.push({ cjk, text });
return runs;
}
// ── 迁移层:把分词暴露成 SQL 函数 ───────────────────────────────────────
// 存量数据重分词需要「在 SQL 里调用 JS 分词」的能力(迁移 0009 的
// cjk_seg(title) / cjk_seg(body))。SQLite 的自定义函数必须由宿主注册,
// better-sqlite3 提供 db.function()。
//
// 注册时机:migrator.applyMigrations 开头(所有迁移跑之前)—— 0009 依赖它。
// 幂等:同一个 db 重复注册同名函数会抛错,故用 WeakSet 记住已注册的实例。
const registeredDbs = new WeakSet<object>();
/**
* 注册迁移期需要的确定性 SQL 函数(cjk_seg)。
*
* - deterministic:true ⇒ SQLite 可缓存结果 / 允许在索引表达式里用;
* - 参数可能是 NULL(列可空)⇒ 一律按空串处理,返回空串。
*/
export function registerMigrationFunctions(db: Database.Database): void {
if (registeredDbs.has(db)) return;
db.function('cjk_seg', { deterministic: true }, (v: unknown) =>
segmentCjk(typeof v === 'string' ? v : ''),
);
registeredDbs.add(db);
}

View File

@@ -0,0 +1,76 @@
-- 0001_init.sql (r44, 2026-10-15)
-- SQLite schema 快照 —— 切换自原本 in-line 的 SCHEMA 字符串常量。
-- 与原 SCHEMA 的差异:
-- * 移除 sidecar_mtime / sidecar_exists / ai_exists 三列(由 0002 显式 drop,
-- 保证「已经在生产库里有这 3 列」的旧用户也能平迁)
-- * notes_fts 不再创建 AFTER INSERT/UPDATE/DELETE 触发器(notes 在本轮仍是普通
-- FTS5 表,FTS 同步由 buildTransactionWrappers 显式发 INSERT OR REPLACE/DELETE;
-- 触发器方案留给 0003 一次性切换 —— 与原 SCHEMA 行为一致)
--
-- 事务:本文件**不**显式包含 BEGIN; ... COMMIT;。atomicity 由 migrator.ts 的
-- `db.transaction(() => { db.exec(sql); db.pragma(...) })()` 包裹,后者底层发
-- BEGIN/COMMIT。SQL 文件内嵌显式 BEGIN 会与 better-sqlite3 的事务包装
-- 嵌套冲突 ("cannot start a transaction within a transaction"),所以迁移级
-- BEGIN/COMMIT 由 migrator 统一发。
-- 其余结构(CREATE TABLE IF NOT EXISTS / DROP INDEX IF EXISTS / FTS5 虚表)逐字镜像
-- sqlite.ts 原 SCHEMA,确保从 r43 升级到 r44 的 DB 在跑完 0001 + 0002 后与原库等价。
-- 文件元数据
CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
rel_path TEXT NOT NULL UNIQUE,
filename TEXT NOT NULL,
extension TEXT NOT NULL,
size INTEGER NOT NULL,
mtime INTEGER NOT NULL,
-- 元数据字段(title / authors / year / doi / tags 冗余存储便于搜索 + FTS5 索引)
title TEXT,
authors TEXT,
year INTEGER,
doi TEXT,
tags TEXT,
abs_library_path TEXT NOT NULL
);
-- perf(2026-08-28):删 idx_files_rel / idx_notes_rel —— rel_path 已经是 UNIQUE 列,
-- UNIQUE 自带隐式 B-tree 索引,显式再建一份是 duplicate 写放大。
-- audit r29 (2026-09-21):删 idx_files_year / idx_files_extension —— 之前注释承认
-- 「没有任何 SQL 真正使用」,但保留 CREATE 仍让每次 upsertFile 都维护两份
-- 写不写都不读的 B-tree,5000 文件量级下 write amplification +10-20%。
-- searchAll 走 FTS5(files_fts 已有 column 索引) + listFiles 走 rowid 扫描,
-- 都不需要这两个 secondary index。DROP IF EXISTS 兜底老库,新库不创建。
DROP INDEX IF EXISTS idx_files_rel;
DROP INDEX IF EXISTS idx_notes_rel;
DROP INDEX IF EXISTS idx_files_year;
DROP INDEX IF EXISTS idx_files_extension;
-- 独立笔记
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
rel_path TEXT NOT NULL UNIQUE,
filename TEXT NOT NULL,
title TEXT,
tags TEXT,
mtime INTEGER NOT NULL,
abs_path TEXT NOT NULL
);
-- FTS5 虚表 (文件) - 使用外部内容模式,可以 DELETE/UPDATE
-- perf(2026-08-24):unicode61 'remove_diacritics 2' 让 é/e、ü/u 等归一化匹配
-- (此前 é 只能查 é,中文无影响,西文文献检索显著改善)
CREATE VIRTUAL TABLE IF NOT EXISTS files_fts USING fts5(
title, authors, tags, year, doi,
content='files', content_rowid='rowid', tokenize='unicode61 remove_diacritics 2'
);
-- FTS5 虚表 (笔记)
-- audit round 17 (2026-09-07):只索引 title + tags,body 走磁盘 grep。
-- 不索引 body 的两个收益:
-- 1. 用户外部改 MD 文件后无需重建索引 → 消除"外部改 MD 索引过期"的隐患
-- 2. upsertNote/deleteNote/renameNoteInDB 不再需要 body 参数,API 更窄
-- 保留 body 列在 schema 中只是为了兼容老用户升级时表已存在的场景:升级后
-- body 列仍在但 INSERT 不再写入,body 数据自然衰减;snippet 走列 0/1,不依赖 body。
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
title, tags, body,
tokenize='unicode61 remove_diacritics 2'
);

View File

@@ -0,0 +1,46 @@
-- 0002_drop_dead_cols.sql (r44, 2026-10-15)
-- audit r44:drop files 表上 0 生产 reader 的死列(sidecar_mtime / sidecar_exists /
-- ai_exists)。index-builder.buildFileRow 在同一 commit 不再写入这三列。
--
-- 实现说明(与 brief 的差异):
-- brief 推荐 `ALTER TABLE files DROP COLUMN x3`(3 行),但 SQLite 的 ALTER TABLE
-- DROP COLUMN 在目标列不存在时**硬报错**("no such column: sidecar_mtime")。
-- 这条迁移对两种 install 路径都得正确:
-- a) 全新安装:0001 创 files 表,**不带**这三个列(brief step 3 明示)
-- b) r43 升级:0001 的 CREATE TABLE IF NOT EXISTS 是 no-op,files 表保留 r43
-- schema 的三个死列
-- 路径 (a) 下 brief 的 3 行 ALTER 全部抛错,migration 卡住。
-- 改用 table-rebuild 模式:CREATE TABLE IF NOT EXISTS files_new(无死列) → 把
-- files 的非死列复制过去(INSERT OR IGNORE 兼容「上一次崩溃后残留 files_new」)
-- → DROP 旧 files → RENAME files_new → files。无论 (a) 还是 (b),最终 schema
-- 与 r43 之后再无死列一致。
--
-- 事务:由 migrator.ts 的 `db.transaction(...)` 包裹,本文件不显式 BEGIN/COMMIT
-- (见 0001_init.sql 头部说明)。
CREATE TABLE IF NOT EXISTS files_new (
id TEXT PRIMARY KEY,
rel_path TEXT NOT NULL UNIQUE,
filename TEXT NOT NULL,
extension TEXT NOT NULL,
size INTEGER NOT NULL,
mtime INTEGER NOT NULL,
title TEXT,
authors TEXT,
year INTEGER,
doi TEXT,
tags TEXT,
abs_library_path TEXT NOT NULL
);
INSERT OR IGNORE INTO files_new (
id, rel_path, filename, extension, size, mtime,
title, authors, year, doi, tags, abs_library_path
)
SELECT
id, rel_path, filename, extension, size, mtime,
title, authors, year, doi, tags, abs_library_path
FROM files;
DROP TABLE files;
ALTER TABLE files_new RENAME TO files;

View File

@@ -0,0 +1,52 @@
-- 0003_fts_external_content.sql (r45, 2026-10-15)
-- notes_fts 从「普通 FTS5 表 + body 列」切到「external-content + title/tags 列」
-- + 3 个 AFTER 触发器(notes_ai / notes_ad / notes_au)自动同步。
--
-- 设计取舍:
-- * 为什么 external-content:body 内容实际在磁盘 MD 文件上,索引 body 会让
-- 用户外部改 MD 文件后索引过期;只索引 title + tags 后,FTS 是「DB 内元数据」
-- 的视图,与外部 MD 文件天然无关联问题。round 17 决策延续。
-- * 为什么不要 body 列:索引里塞 body 既扩索引体积又引失效问题,得不偿失。
-- body 检索靠磁盘 grep(由 P2 阶段 searchNotes 走 fs.readFile + 正则匹配)。
-- * 为什么用 3 个触发器替代 buildTransactionWrappers 里的 manual upsertNoteFts
-- / delNoteFts:让 FTS 同步在 SQL 层声明,与主表 INSERT/UPDATE/DELETE 同事务
-- 同进退,自然解决「主表成 / FTS 不成」(r44 那个 ghost token 之父)。原 manual
-- 路径彻底删除。
-- * 为什么 UPDATE 用 'delete' + 二次 INSERT:external-content 表只接受 'delete'
-- 命令带旧值来移除旧 rowid 的倒排 token(参 files_fts 同款修复,audit
-- 2026-10-04);先发 'delete'(带 old),再发新 INSERT,模拟 ROHA 模式,无 rowid
-- 漂移(旧 FTS rowid 仍是 new.rowid,因为 rowid 不因 UPDATE 改变)。
--
-- 事务:由 migrator.ts 的 `db.transaction(...)` 包裹,本文件不显式 BEGIN/COMMIT
-- (见 0001_init.sql 头部说明)。
--
-- 注意:0003 跑前 notes_fts 是普通 FTS5 + 带 body 列(由 0001 创建),DROPTABLE
-- 必须在新事务开头;否则 FTS5 'shadow table name collision' 会让 CREATE 失败。
DROP TABLE notes_fts;
CREATE VIRTUAL TABLE notes_fts USING fts5(
title, tags,
content='notes', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
);
CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN
INSERT INTO notes_fts(rowid, title, tags)
VALUES (new.rowid, new.title, new.tags);
END;
CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, title, tags)
VALUES ('delete', old.rowid, old.title, old.tags);
END;
CREATE TRIGGER notes_au AFTER UPDATE ON notes BEGIN
-- 顺序:旧 rowid 的旧值先 'delete',再发新值;rowid 在 UPDATE 后不变,
-- 同 rowid 写两次即 replace 语义,external-content 表对外表现为「旧词消失、
-- 新词出现」。
INSERT INTO notes_fts(notes_fts, rowid, title, tags)
VALUES ('delete', old.rowid, old.title, old.tags);
INSERT INTO notes_fts(rowid, title, tags)
VALUES (new.rowid, new.title, new.tags);
END;

View File

@@ -0,0 +1,58 @@
-- 0004_resources.sql (audit-r64, 2026-10-16)
-- Resource 模型:单一 resources 表 + 单一 resources_fts,取代
-- files / notes / files_fts / notes_fts 四表(旧表数据由 0005 迁入并 DROP)。
-- 设计来源:docs/superpowers/specs/2026-10-16-resource-model-design.md §2,
-- 落地时有两处与 spec SQL 块的**有意偏差**(spec §2.1 的「简化决定」已预告):
--
-- 1. resources_fts 是**普通(standalone)FTS5 表**,不是 external-content。
-- spec SQL 块里的 ad/au 触发器发的是 external-content 专用 'delete'
-- 命令,在普通 FTS5 表上是**非法语句**(SQLite 文档:'delete' 命令只对
-- external-content / contentless 表有效)。普通表上正确的删除是
-- `DELETE FROM resources_fts WHERE rowid = ?`(完整内容存于 FTS 表内,
-- token 随行删除)。
-- 2. 因此**不建任何触发器**,FTS 同步完全 application-managed:
-- ResourcesRepo(src/main/db/resources-repo.ts)在单事务内做
-- 「FTS DELETE → 主表 upsert → FTS INSERT」,顺序硬约束与旧
-- buildTransactionWrappers 一致。'rebuild' 命令同样**禁止**用在本表
-- —— 对普通 FTS5 表它会先清空再从「自身」重读,等于把索引清空
-- (旧 runFtsIntegrityCheck 的 rebuild 路径随本迁移一并退役)。
--
-- body 列:note 的 markdown 全文 / literature 抽取文本。body 由 repo upsert
-- 时写入;0005 迁移的行 body='' + indexed_at=0:
-- * notes:应用层启动期回填(NotesService.resyncIfNeeded 的 stale-index 补索引)。
-- * literature:**没有回填消费者** —— 设想过但未落地;新导入行同样写
-- indexed_at=0 + 'filename-only'(见 src/main/ipc/literature.ts 的诚实
-- 标注),即 title 恒可搜、body 留给未来正文抽取流水线。本列现状对迁移
-- 行与导入行语义一致,不算腐化,但引用本头注的旧文案承诺过回填,属谎。
--
-- 事务:由 migrator.ts 的 `db.transaction(...)` 包裹,本文件不显式 BEGIN/COMMIT
-- (见 0001_init.sql 头部说明)。
CREATE TABLE resources (
id TEXT PRIMARY KEY, -- ResourceId 字面:'note:<uuid>' / 'lit:<relPath>'
kind TEXT NOT NULL, -- 'note' | 'literature'
title TEXT NOT NULL,
rel_path TEXT NOT NULL, -- note: 相对 notes/;literature: 相对所属 root
abs_path TEXT NOT NULL, -- 主进程读盘用;note = <rootPath>/notes/<rel_path>
root_id TEXT, -- 仅 literature;root 概念未落地:迁移行与导入行都恒 NULL(见 ipc/literature.ts)
size INTEGER NOT NULL,
mtime INTEGER NOT NULL, -- epoch ms
indexed_at INTEGER NOT NULL, -- epoch ms;0 = 从未索引(0005 迁移标记,启动期补 body)
extract_status TEXT NOT NULL DEFAULT 'ok', -- note 永远 'ok';literature: pending/ok/failed/filename-only
page_count INTEGER, -- 仅 PDF 文本层成功时有
tags TEXT NOT NULL DEFAULT '[]' -- JSON 数组(仅 note 用)
);
CREATE INDEX resources_kind ON resources(kind);
CREATE INDEX resources_root ON resources(root_id);
CREATE INDEX resources_rel ON resources(rel_path);
-- 单一 FTS5 虚表(unicode61 + remove_diacritics 2,é/e、ü/u 归一化匹配)。
-- id / kind UNINDEXED:只做 JOIN 回主表与 kind 过滤,不进倒排。
CREATE VIRTUAL TABLE resources_fts USING fts5(
id UNINDEXED,
kind UNINDEXED,
title,
body,
tokenize='unicode61 remove_diacritics 2'
);

View File

@@ -0,0 +1,54 @@
-- 0005_migrate_files_notes_to_resources.sql (audit-r64, 2026-10-16)
-- 旧 files + notes 数据迁入 resources,然后 DROP 全部旧表。
--
-- 对两种 install 路径都正确(与 0002 同款防御):
-- a) 全新安装:0001-0003 建过旧表但 0 行 → INSERT 是 no-op,DROP 正常;
-- b) 老用户升级:旧行全量迁入。
-- 因此 migrator 不需要条件分支,本条永远跟在 0004 后跑。
--
-- 关键决策:
-- * indexed_at = 0(不是 mtime)—— 标记「FTS body 未填」。note body 在磁盘
-- .md 上,SQL 层读不到;老 notes_fts 也只索引 title/tags,没有 body 可搬。
-- 启动期 NotesService.resyncIfNeeded 捡 indexed_at=0 的 note 行重读磁盘
-- 回填 body;literature 行 extract_status 标成 'filename-only' ——
-- 语义诚实:只有文件名/标题可搜,且**没有** indexer 会回填它(设想过的
-- R63 indexer 未落地,新导入行同款终态,见 0004 头注与 ipc/literature.ts)。
-- * resources_fts 种子行 body='' —— 不能用 'rebuild' 命令(普通 FTS5 表
-- 上 rebuild = 清空,见 0004 头注)。种子只填 title,body 由应用层回填时
-- 走 repo 的 DELETE+INSERT 覆盖。
-- * literature 的 abs_path = abs_library_path 原样直拷。列名骗人:老代码
-- (初版起,含 r44/r20 era)写入的是**该文件自己的绝对路径**(f.absPath),
-- 读回时 absPath: row.abs_library_path,自洽。audit-r119 前本迁移误按
-- 「库目录」语义 join '/'||rel_path,产出 <file>/<file> 双后缀 → 迁移行
-- reader 必 ENOENT(实证:磁盘完好的 PDF 打不开)。存量已中弹的行由
-- 0006 修复;root_id 推不出来(root 概念 R63 才引入),置 NULL。
-- * title 兜底 filename:老 files.title 可空,resources.title NOT NULL。
--
-- 事务:由 migrator.ts 的 `db.transaction(...)` 包裹,本文件不显式 BEGIN/COMMIT。
INSERT INTO resources (id, kind, title, rel_path, abs_path, root_id,
size, mtime, indexed_at, extract_status, tags)
SELECT 'note:' || id, 'note', COALESCE(title, filename),
rel_path, abs_path, NULL,
0, mtime, 0, 'ok', COALESCE(tags, '[]')
FROM notes;
INSERT INTO resources (id, kind, title, rel_path, abs_path, root_id,
size, mtime, indexed_at, extract_status, tags)
SELECT 'lit:' || rel_path, 'literature', COALESCE(title, filename),
rel_path, abs_library_path, NULL,
size, mtime, 0, 'filename-only', '[]'
FROM files;
-- FTS 种子:只填 title,body 待应用层回填(indexed_at=0 标记)。
INSERT INTO resources_fts (rowid, id, kind, title, body)
SELECT rowid, id, kind, title, '' FROM resources;
-- 删旧表。FTS 虚表先于其 content 表 DROP(files_fts 是 files 的
-- external-content 表);notes 上的 0003 触发器随表 DROP 自动消失。
DROP TABLE files_fts;
DROP TABLE files;
DROP TABLE notes_fts;
DROP TABLE notes;
-- 更早迁移(P1 前)的残留,IF EXISTS 兜底不存在的新库路径。
DROP TABLE IF EXISTS notes_delete_log;

View File

@@ -0,0 +1,44 @@
-- 0006_repair_doubled_lit_abs.sql (audit-r119, 2026-10-18)
-- 修复老版 0005 产出的 literature abs_path 双后缀损坏行。
--
-- 事故链:老 files 表的 abs_library_path 列名骗人,实际存的是**每个文件自己
-- 的绝对路径**(写 f.absPath / 读回 absPath,全生命周期自洽)。r64 写的 0005
-- 误按「库根目录」语义 join '/'||rel_path → 迁移行 abs_path 变成
-- <file_abs>/<rel_path>(典型:C:\...\library\NAME.pdf/NAME.pdf),
-- reader/协议层必 ENOENT——文件在磁盘好好的,点开就是打不开。0005 已同步改
-- 为直拷,但已执行过老 0005 的存量库需要本迁移把数据修回来。
--
-- 判定(三条件合取,保证只有老 0005 的双后缀形态被命中):
-- 1) id = 'lit:' || rel_path —— 0005 行专属。r71+ 新导入行的 id 是
-- litResourceId(absPath)(分隔符归一化),其 rel_path 退化为完整
-- abs_path,两式无法同时成立;即便 POSIX 下 id 形态巧合相等,条件 2
-- 的长度下限(abs ≥ 2*rel+1)也把它们挡掉(rel=abs 时不满足)。
-- 2) length(abs_path) >= 2*length(rel_path) + 1 —— 双段结构长度下限。
-- 3) 尾部恰为 '/'||rel_path,且再往前 rel_path 长度的片段仍是 rel_path
-- (或其反斜杠变体)—— 双后缀形状。单后缀干净行:Windows 的分隔符是
-- '\' 不匹配 '/'+rel,POSIX 深路径的 rel 长度片段落在目录中段而非
-- rel 本身,均不被命中。REPLACE 分支专修 Windows 子目录行
-- (rel 用 '/' 而 per-file abs 用 '\')。
--
-- 修复动作 = 剥掉尾部 '/'||rel_path,还原 per-file abs。
-- 幂等性的诚实论证(audit-r124 修正旧文案「条件 3 不再成立」的不精确):
-- Windows 行:修好后尾窗是 '\'||rel,'/'+rel 直接不匹配,天然出局。
-- POSIX 行:修好后尾窗 '/'+rel **仍然匹配**,挡住重复剥离的是条件 3 的
-- 内层双形状合取(abs 再往前 rel 长度的片段通常落在目录名而非 rel)。
-- 真正硬保证是 migrator 的 user_version 单跑:0006 每库一生只执行一次。
-- 理论残余(如实记录,不修):若某迁移行的**真** per-file abs 本就形如
-- 'dir/NAME.pdf/NAME.pdf'(目录与文件同名),单跑剥一层回到真值安全,但
-- 该行修后仍整体匹配 WHERE —— 一旦将来有人重跑 0006 会多剥一层。重跑
-- 前须先读该行现状。
-- FTS 种子行只含 title,abs_path 不进索引,无需重同步。
UPDATE resources
SET abs_path = substr(abs_path, 1, length(abs_path) - length(rel_path) - 1)
WHERE kind = 'literature'
AND id = 'lit:' || rel_path
AND rel_path <> ''
AND length(abs_path) >= 2 * length(rel_path) + 1
AND substr(abs_path, -length(rel_path) - 1) = '/' || rel_path
AND (
substr(abs_path, -2 * length(rel_path) - 1, length(rel_path)) = rel_path
OR substr(abs_path, -2 * length(rel_path) - 1, length(rel_path)) = REPLACE(rel_path, '/', '\')
);

View File

@@ -0,0 +1,31 @@
-- 0007_folders.sql —— 虚拟文件夹(侧栏 VS Code 式文件管理的组织层)
--
-- 设计:文件夹是 **DB-only 概念**。不建磁盘目录、不移动文件、不改资源 id ——
-- 「把条目移进文件夹」= UPDATE resources.folder_id。好处:
-- * note id = relPath 哈希、lit id = 'lit:'+absPath,物理不动 ⇒ id 稳定,
-- favorites.md 收藏键 / SearchPanel 直达 / selectedId 全部不断链;
-- * 两棵 kind 树(notes / literature)与 FS 真实目录(notes/ 下可以有物理
-- 子目录)正交,互不解释。
-- 真相源是本表 + resources.folder_id 列;fs 目录树不是 UI 输入。
--
-- 删除语义:deleteFolder **不删内容** —— 子文件夹 parent 上提、资源
-- folder_id 上提(移向树根一层),与「组织容器消失」直觉一致,无数据丢失。
-- 成环防护(moveFolder)在服务层(folders-repo)做,SQL 无递归约束。
--
-- 事务:migrator.ts 的 db.transaction 包裹,本文件不写 BEGIN/COMMIT
-- (0001_init.sql 同款约定)。ALTER TABLE ADD COLUMN 在 SQLite 中即时、
-- 无表重建,旧行 folder_id=NULL 即「根」,无需回填。
CREATE TABLE folders (
id TEXT PRIMARY KEY, -- 'fld:' + randomUUID()
kind TEXT NOT NULL CHECK (kind IN ('note', 'literature')),
name TEXT NOT NULL,
parent_id TEXT REFERENCES folders(id), -- NULL = 该 kind 树根;删除级联在服务层事务化
created_at INTEGER NOT NULL -- epoch ms(展示预留 + 同时间序稳定排序兜底)
);
CREATE INDEX folders_kind ON folders(kind);
CREATE INDEX folders_parent ON folders(parent_id);
ALTER TABLE resources ADD COLUMN folder_id TEXT;
CREATE INDEX resources_folder ON resources(folder_id);

View File

@@ -0,0 +1,35 @@
-- 0008_drop_folders.sql —— 彻底下线 DB 虚拟文件夹
--
-- 触发:r161 disk-folder refactor —— 侧栏从「DB 虚拟文件夹」切到「真实磁盘
-- 目录」(VS Code / Finder 语义),「移入文件夹」= 真实 fs.rename 而不是
-- UPDATE folder_id。两棵 kind 树(notes / literature)统一走 `<rootPath>/<kind>/`
-- 物理子目录作为组织层,favorites 引用按 byId + 新 relPath 重算后自愈。
--
-- 顺序契约(SQLite ALTER 限制):
-- 1. DROP INDEX(资源表 + folders 表各两条)—— DROP COLUMN 会自动重表,
-- 索引先删避免内部引用
-- 2. ALTER TABLE resources DROP COLUMN folder_id —— SQLite 3.35+ 支持
-- DROP COLUMN(无需表重建,即时元数据更新);better-sqlite3 自带 3.46+,
-- 满足
-- 3. DROP TABLE folders —— CASCADE 不需要(外键无显式声明;0007 迁移也
-- 没加 ON DELETE CASCADE,因为「删文件夹上提子内容」语义在服务层做
-- 不靠 SQL 级联)
-- 4. PRAGMA user_version = 8 —— migrator.ts 在同事务里设,本文件不写
--
-- 行为/数据语义:
-- * 老用户 DB 里的 folders 行 + resources.folder_id 值被一并丢弃;
-- 物理文件无动,笔记 / 文献内容 100% 保留
-- * 跨文件夹 rename 改 note id(relPath 哈希),favorites.md 引用键随之
-- 失效(但 byId 重建后 favorites 行的 relPath / id 字段同步更新);
-- 这是切到真实文件管理的天然 trade-off,UI 提示「已重算收藏」即可
-- * chokidar 不动 —— notes 本就是 depth:16 递归,本次只是把 literature
-- 从 depth:0 提升到 depth:16(独立 commit,与本迁移解耦)
--
-- 事务:migrator.ts 的 db.transaction 包裹,本文件不写 BEGIN/COMMIT
-- (0001_init.sql 同款约定)
DROP INDEX IF EXISTS resources_folder;
DROP INDEX IF EXISTS folders_kind;
DROP INDEX IF EXISTS folders_parent;
ALTER TABLE resources DROP COLUMN folder_id;
DROP TABLE IF EXISTS folders;
PRAGMA user_version = 8;

View File

@@ -0,0 +1,35 @@
-- 0009_cjk_segment.sql (r196, 2026-10-20)
--
-- 目的:给**存量** FTS 行补上 CJK 分词,否则中文全文搜索对老数据仍然
-- 0 结果(原理见 src/main/db/cjk-segment.ts 的模块头注释)。
--
-- unicode61 会把整串中文当成**一个** token ⇒ 搜「学习」「机制」「注意力」
-- 这类词内子串在老数据上永远搜不到。索引侧新写入的行由
-- resources-repo.ts 在 upsert 时分词(ZWSP 分隔),本迁移负责**存量**。
--
-- 为什么能在 SQL 里做:分词逻辑是 JS 实现(cjk-segment.ts),迁移层通过
-- migrator.ts 注册的确定性 SQL 函数 cjk_seg 暴露给 SQL。
--
-- 为什么从 resources_fts **自己**读原文:resources_fts 是普通(standalone)
-- FTS5 表,完整文本存在表内(见 0004 头注)⇒ 存量 title/body 可以直接从
-- FTS 表读出再重新分词写回,**不需要**回磁盘重读笔记(避免启动期扫全盘),
-- 也不会引入「磁盘内容 ≠ 索引内容」的新数据面。
--
-- 幂等性:
-- * 本迁移只由 user_version 驱动跑一次;
-- * cjk_seg 本身幂等(先剥 ZWSP 再切)⇒ 就算某行已被分词,重跑也只是
-- 写回同样的内容,不会累积 ZWSP。
--
-- 事务:由 migrator.ts 的 db.transaction 包裹,本文件不写 BEGIN/COMMIT
-- (0001_init.sql 同款约定)。
CREATE TEMP TABLE _cjk_seg AS
SELECT rowid, id, kind, cjk_seg(title) AS title, cjk_seg(body) AS body
FROM resources_fts;
DELETE FROM resources_fts;
INSERT INTO resources_fts (rowid, id, kind, title, body)
SELECT rowid, id, kind, title, body FROM _cjk_seg;
DROP TABLE _cjk_seg;

36
src/main/db/migrator.ts Normal file
View File

@@ -0,0 +1,36 @@
// PRAGMA user_version 驱动的有序迁移:000N_*.sql 按序、同事务原子绑 user_version,
// 幂等(>= 当前版本不重跑)。SQL 不要自带 BEGIN/COMMIT。
import Database from 'better-sqlite3';
import { readdirSync, readFileSync } from 'fs';
import { join } from 'path';
import { registerMigrationFunctions } from './cjk-segment';
/**
* 应用 dir 下所有 > 当前 user_version 的迁移。
* @throws 全部跑完仍为 0(目录错传/静默失败)
*/
export function applyMigrations(db: Database.Database, dir: string, upTo?: number): void {
// r196:迁移期需要宿主提供的 SQL 函数(cjk_seg —— 存量 FTS 行重分词用,
// 见 src/main/db/cjk-segment.ts)。必须在任何迁移跑之前注册。
// 注册是幂等的(同 db 重复调用不会抛「函数已存在」)。
registerMigrationFunctions(db);
const files = readdirSync(dir)
.filter((f) => /^\d{4}_.*\.sql$/.test(f))
.sort();
const current = db.pragma('user_version', { simple: true }) as number;
for (const f of files) {
const v = parseInt(f.slice(0, 4), 10);
if (v <= current) continue;
if (upTo !== undefined && v > upTo) continue;
const sql = readFileSync(join(dir, f), 'utf8');
db.transaction(() => {
db.exec(sql);
db.pragma(`user_version = ${v}`);
})();
}
// 无 upTo 上限且整目录跑完仍为 0 才算卡死(测试用 upTo 提前截断是预期部分应用,不抛)
if (upTo === undefined && (db.pragma('user_version', { simple: true }) as number) === 0) {
throw new Error('migration stuck at 0');
}
}

View File

@@ -0,0 +1,444 @@
// audit-r64:resources / resources_fts 的唯一读写口
// 取代 sqlite.ts 的 FilesRepo / NotesRepo / searchAll
//
// FTS 同步模型:application-managed,无触发器
// - resources_fts 是普通 FTS5 表,DELETE WHERE rowid=? 即可移除 token
// - 每个写路径单事务内做 FTS DELETE → 主表 upsert → FTS INSERT
// - 禁止对本表发 'rebuild'(普通表上 = 清空索引)
//
// 单表红利:bm25 分母统一,跨 kind 排序可比
//
// r196:CJK 分词契约(索引侧)
// 写 FTS 时,title / body 会先经 segmentCjk 切分(汉字之间插 ZWSP),
// 因为 unicode61 会把整串中文当**一个** token ⇒ 中文词内子串搜不到。
// 查询侧由 sanitizeFtsQuery 改用 FTS5 短语,片段返回前洗掉 ZWSP。
// 详见 src/main/db/cjk-segment.ts。**主表 resources 的 title/body 不分词**
// (主表是数据面,FTS 表才是索引面)。
//
// r208:中英混排查询契约(查询侧)
// 含 CJK 的 token 按文字系统切 run,每个 run 一个子句,子句间是隐式 AND。
// ⇒ 「CNN模型」与「CNN 模型」产生完全相同的查询串,带不带空格不影响结果。
import type Database from 'better-sqlite3';
import type { Statement } from 'better-sqlite3';
import { getDB } from './sqlite';
import { errMsg } from '../utils/error';
import { createLogger } from '../utils/logger';
import type {
LiteratureResource,
NoteResource,
Resource,
ResourceId,
ResourceKind,
} from '@shared/types';
// r194:高亮标记常量(值导入)—— snippet() 的标记必须与渲染侧解析用的
// 格式同源,见 shared/types.ts 的契约注释。
import { SEARCH_SNIPPET_MARK_CLOSE, SEARCH_SNIPPET_MARK_OPEN } from '@shared/types';
// r196:CJK 分词(索引侧切分 / 查询侧短语 / 展示侧清洗)
import {
hasCjk,
segmentCjk,
segmentQueryToken,
splitScriptRuns,
stripZwsp,
} from './cjk-segment';
const log = createLogger('resources-repo');
interface ResourceRow {
id: string;
kind: string;
title: string;
rel_path: string;
abs_path: string;
root_id: string | null;
size: number;
mtime: number;
indexed_at: number;
extract_status: string;
page_count: number | null;
tags: string;
}
function parseTags(json: string): string[] {
try {
const v: unknown = JSON.parse(json);
return Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : [];
} catch {
return [];
}
}
export function rowToResource(row: ResourceRow): Resource {
const base = {
id: row.id as ResourceId,
title: row.title,
mtime: row.mtime,
size: row.size,
indexedAt: row.indexed_at,
};
if (row.kind === 'note') {
const note: NoteResource = {
...base,
kind: 'note',
relPath: row.rel_path,
tags: parseTags(row.tags),
};
return note;
}
const lit: LiteratureResource = {
...base,
kind: 'literature',
absPath: row.abs_path,
rootId: row.root_id,
extractStatus: row.extract_status as LiteratureResource['extractStatus'],
pageCount: row.page_count ?? undefined,
};
return lit;
}
// statement/tx 按 db 实例身份缓存:closeDB → 换新句柄时旧缓存自动失效,
// 避免 db → repo → db 的环依赖
interface TxSet {
upsert: (id: string, kind: string, title: string, relPath: string, absPath: string,
rootId: string | null, size: number, mtime: number, indexedAt: number,
extractStatus: string, pageCount: number | null, tags: string, body: string) => void;
remove: (id: string) => void;
rename: (prevId: string, id: string, kind: string, title: string, relPath: string,
absPath: string, rootId: string | null, size: number, mtime: number, indexedAt: number,
extractStatus: string, pageCount: number | null, tags: string, body: string) => void;
}
let cache: { db: Database.Database; stmts: Map<string, Statement>; tx: TxSet } | null = null;
function prep(sql: string): Statement {
const db = getDB();
if (!cache || cache.db !== db) {
cache = { db, stmts: new Map(), tx: buildTx(db) };
}
let s = cache.stmts.get(sql);
if (!s) {
s = db.prepare(sql);
cache.stmts.set(sql, s);
}
return s;
}
function requireTx(): TxSet {
const db = getDB();
if (!cache || cache.db !== db) {
cache = { db, stmts: new Map(), tx: buildTx(db) };
}
return cache.tx;
}
const UPSERT_MAIN_SQL = `
INSERT INTO resources (id, kind, title, rel_path, abs_path, root_id,
size, mtime, indexed_at, extract_status, page_count, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
kind=excluded.kind,
title=excluded.title,
rel_path=excluded.rel_path,
abs_path=excluded.abs_path,
root_id=excluded.root_id,
size=excluded.size,
mtime=excluded.mtime,
indexed_at=excluded.indexed_at,
extract_status=excluded.extract_status,
page_count=excluded.page_count,
tags=excluded.tags
`;
const DEL_FTS_SQL = `DELETE FROM resources_fts WHERE rowid = (SELECT rowid FROM resources WHERE id = ?)`;
const INS_FTS_SQL = `INSERT INTO resources_fts (rowid, id, kind, title, body)
VALUES ((SELECT rowid FROM resources WHERE id = ?), ?, ?, ?, ?)`;
const DEL_MAIN_SQL = `DELETE FROM resources WHERE id = ?`;
function buildTx(db: Database.Database): TxSet {
const upsertMain = db.prepare(UPSERT_MAIN_SQL);
const delFts = db.prepare(DEL_FTS_SQL);
const insFts = db.prepare(INS_FTS_SQL);
const delMain = db.prepare(DEL_MAIN_SQL);
type Cols = [
id: string, kind: string, title: string, relPath: string, absPath: string,
rootId: string | null, size: number, mtime: number, indexedAt: number,
extractStatus: string, pageCount: number | null, tags: string, body: string,
];
const upsertOne = (c: Cols): void => {
// 顺序硬约束:FTS DELETE 先(按 rowid 精确移除旧 token),主表 upsert 次之,
// FTS INSERT 最后拿 upsert 后的 rowid;ON CONFLICT DO UPDATE 不改变 rowid
delFts.run(c[0]);
upsertMain.run(...c.slice(0, 12));
// r196:写进 FTS 的 title / body 先做 CJK 分词(汉字之间插 ZWSP)。
// unicode61 不分文字系统 —— 整串中文会被当成**一个** token,
// 导致「学习」「机制」这类词内子串 0 命中。主表拿到的是**原文**
// (c[2] 不分词),数据面与索引面分工明确。
insFts.run(c[0], c[0], c[1], segmentCjk(c[2]), segmentCjk(c[12]));
};
const removeOne = (id: string): void => {
delFts.run(id);
delMain.run(id);
};
return {
upsert: db.transaction((...c: Cols) => upsertOne(c)),
remove: db.transaction((id: string) => removeOne(id)),
rename: db.transaction((prevId: string, ...c: Cols) => {
// 不变量:upsert 新行 + 删旧行必须同事务,崩溃不留 orphan
// r161 disk-folder:folder_id 列已删,upsertOne 写完整新行,删旧行无
// folder 归属搬运负担(物理 relPath 即组织层,rename 后新行 relPath
// 直接是组织位置的真相)
upsertOne(c);
removeOne(prevId);
}),
};
}
function colsOf(r: Resource, absPath: string, body: string): Parameters<TxSet['upsert']> {
if (r.kind === 'note') {
return [
r.id, 'note', r.title, r.relPath, absPath, null,
r.size, r.mtime, r.indexedAt, 'ok', null, JSON.stringify(r.tags), body,
];
}
return [
r.id, 'literature', r.title, litRelPath(r), r.absPath, r.rootId,
r.size, r.mtime, r.indexedAt, r.extractStatus, r.pageCount ?? null, '[]', body,
];
}
// literature 的 rel_path 存相对所属 root;root 概念 R63 落地,当前 caller 只能给 absPath
// 这里先退化为 absPath 本身,保证 NOT NULL 约束
function litRelPath(r: LiteratureResource): string {
return r.absPath;
}
/**
* upsert 一条 resource + 同步 FTS body。单事务;FTS 抛错整体回滚。
*
* @param absPath note 的绝对路径由 caller 算(repo 不反向依赖 notes/db ← notes 单向)
*/
export function upsertResource(r: Resource, body: string, absPath: string): void {
try {
requireTx().upsert(...colsOf(r, absPath, body));
} catch (err: unknown) {
log.error('upsertResource transaction failed for', r.id, errMsg(err));
throw err;
}
}
export function deleteResource(id: ResourceId): void {
requireTx().remove(id);
}
/**
* 只更新主表的 size / mtime —— **不碰 FTS**。
*
* r211:文献文件在磁盘上被替换(重下 / 同步盘覆盖 / 备份还原)后,reload
* 需要把行上的 stat 刷成磁盘真值。走 upsertResource 是不行的:那条路
* 的语义是「FTS DELETE → 主表 upsert → FTS INSERT」,body 由 caller 给
* —— 刷 stat 的 caller 手上只有 '' ⇒ 将来抽过正文(indexed_at ≠ 0)的
* 文献正文会被**清空**,全文搜索再也搜不到。刷 stat 是「文件元数据变了」,
* 与索引面无关 ⇒ 必须有一条**只动主表**的写路径。
*
* 幂等:值没变时也照样执行一次 UPDATE(调用方自己判过差异,不重复调)。
* 行不存在时静默无操作(0 行受影响,不抛错)。
*/
export function updateResourceStat(id: ResourceId, size: number, mtime: number): void {
prep(`UPDATE resources SET size = ?, mtime = ? WHERE id = ?`).run(size, mtime, id);
}
/**
* rename(id 变化 = relPath 变化)的事务化同步:upsert 新行 + 删旧行同事务。
* body 必须随新行重喂 FTS(title/body 都可能随 rename 变)。
*/
export function renameResource(prevId: ResourceId, r: Resource, body: string, absPath: string): void {
// audit-r101:prevId === r.id(同名确认路径:rename dialog 输入带 .md 后缀的
// 原名,normalize 后 relPath 不变,renameNote 早返当前 note,IPC 仍调本
// 函数)→ 不能走 rename tx:upsertOne 之后 removeOne(prevId) 会把刚写入
// 的行整行删除(主表 + FTS 双删,笔记从列表/搜索消失,toast 却报成功;
// 磁盘文件无恙、无 watcher 事件,要等下次启动 resync 差集自愈)。
// 同 id rename 语义 = 纯 upsert(幂等覆盖,FTS 先删后插不留孤儿)。
if (prevId === r.id) {
upsertResource(r, body, absPath);
return;
}
try {
requireTx().rename(prevId, ...colsOf(r, absPath, body));
} catch (err: unknown) {
log.error('renameResource transaction failed for', r.id, errMsg(err));
throw err;
}
}
export function getResource(id: ResourceId): Resource | null {
const row = prep(`SELECT * FROM resources WHERE id = ?`).get(id) as ResourceRow | undefined;
return row ? rowToResource(row) : null;
}
export function listResources(kind?: ResourceKind): Resource[] {
const rows = (
kind
? prep(`SELECT * FROM resources WHERE kind = ? ORDER BY rel_path`).all(kind)
: prep(`SELECT * FROM resources ORDER BY kind, rel_path`).all()
) as ResourceRow[];
return rows.map(rowToResource);
}
/** 轻量 rel_path 收集,resync 差集用。 */
export function listResourceRelPaths(kind: ResourceKind): string[] {
return prep(`SELECT rel_path FROM resources WHERE kind = ?`).pluck().all(kind) as string[];
}
/**
* indexed_at = 0 = FTS body 未填;NotesService.resyncIfNeeded 捡这些重读磁盘回填。
*/
export function listStaleIndexIds(kind: ResourceKind): ResourceId[] {
return prep(`SELECT id FROM resources WHERE kind = ? AND indexed_at = 0`)
.pluck()
.all(kind) as ResourceId[];
}
export interface ResourceHit {
resource: Resource;
snippet: string; // <mark> 标记高亮
score: number;
}
// r196:snippet() 的窗口长度(单位 = token)。
// 索引侧 CJK 按**单字**分词 ⇒ 中文正文里 1 token ≈ 1 个汉字(≈2 显示列);
// 拉丁正文里 1 token ≈ 1 个词(≈6 显示列)。窗口若都用 12,中文预览只有
// ~24 显示列(拉丁的一半),上下文被砍得只剩几个字。故按查询是否含 CJK
// 分档:中文查询给 40 token(≈80 显示列),拉丁维持 12(与 r194 前一致,
// 不改动既有拉丁行为的观感)。
const SNIPPET_TOKENS_CJK = 40;
const SNIPPET_TOKENS_LATIN = 12;
/**
* 单表 MATCH + snippet + bm25。bm25 分母统一,跨 kind ORDER BY score 合法。
* snippet 优先 body,body 空回退 title(0005 迁移行 body='' 时 title 片段仍可用)。
*/
export function searchResources(
query: string,
opts: { kind?: ResourceKind; limit?: number } = {},
): ResourceHit[] {
const q = sanitizeFtsQuery(query);
if (!q) return [];
const limit = opts.limit ?? 50;
const kindFilter = opts.kind ? `AND r.kind = ?` : '';
const snippetTokens = hasCjk(query) ? SNIPPET_TOKENS_CJK : SNIPPET_TOKENS_LATIN;
// r194:高亮标记的字面量走共享常量(shared/types.ts)—— 主进程写死的
// '<mark>' 与渲染进程解析用的格式必须是同一份,否则高亮静默失效。
// 标记本身是**协议**而非展示:渲染侧按标记切分后用 React 元素渲染,
// 绝不能 dangerouslySetInnerHTML(片段原文来自用户笔记正文)。
// r196:窗口长度改成绑定参数(实测 FTS5 接受参数化的 nToken),好让
// 「中文查询给更宽的窗口」不必复制一份 SQL。
const rows = prep(
`
SELECT r.*,
snippet(resources_fts, 2, '${SEARCH_SNIPPET_MARK_OPEN}', '${SEARCH_SNIPPET_MARK_CLOSE}', '...', ?) AS t_snip,
snippet(resources_fts, 3, '${SEARCH_SNIPPET_MARK_OPEN}', '${SEARCH_SNIPPET_MARK_CLOSE}', '...', ?) AS b_snip,
bm25(resources_fts) AS score
FROM resources_fts
JOIN resources r ON r.rowid = resources_fts.rowid
WHERE resources_fts MATCH ?
${kindFilter}
ORDER BY score
LIMIT ?
`,
).all(
snippetTokens,
snippetTokens,
...(opts.kind ? [q, opts.kind, limit] : [q, limit]),
) as Array<ResourceRow & { t_snip: string; b_snip: string; score: number }>;
return rows.map((h) => ({
resource: rowToResource(h),
// 命中可能在 title(列 2)或 body(列 3);优先取**带高亮**的那段
// (COALESCE 按"非空"优先会错取 body 的开头原文,丢掉 title 的高亮)。
// r196:片段里的 ZWSP 是索引侧分词的副产品 —— 展示前洗掉,否则用户
// 会看到一串不可见字符(复制出去也是坏的)。
snippet: stripZwsp(h.t_snip.includes('<mark>') ? h.t_snip : h.b_snip),
score: h.score,
}));
}
// FTS5 query 清洗 — from sqlite.ts
// audit 2026-09-10:FTS5 保留字 AND/OR/NOT/NEAR 会让 sanitize 后的 "and*" 报 syntax error
const FTS5_RESERVED = new Set(['and', 'or', 'not', 'near']);
const FTS5_FALLBACK_TOKEN = 'a';
/**
* FTS5 bareword 安全字符:Unicode 字母/数字/下划线。
* 含其它字符的 token 必须整体加引号,否则 fts5 报 syntax error。
*/
const FTS5_BAREWORD_SAFE_RE = /^[\p{L}\p{N}_]+$/u;
/**
* CJK 串 → FTS5 短语(逐字 token,要求相邻)。
*
* r196:索引侧把汉字逐字切开,查询侧必须**对称**地切开:「学习」→ `"学 习"`,
* FTS5 短语要求两个 token 相邻 ⇒ 命中所有含「学习」的文本且保序。
* 短语内不加前缀通配(FTS5 的前缀语义只作用于短语最后一个 token,
* 对中文会产生「学习* 命中 习题」这类反直觉结果)。
*/
function cjkPhrase(text: string): string {
const parts = segmentQueryToken(text);
return parts.length ? `"${parts.join(' ')}"` : '';
}
/**
* 拉丁串 → 前缀通配(bareword)或引号短语(含特殊字符)。
*
* 沿用旧行为 —— 纯 bareword 加前缀通配(`abc*` 命中 abcd),
* 含特殊字符的加引号当短语(`"v1.2"` 精确命中,见 audit 2026-10-04)。
*/
function latinTerm(text: string): string {
return FTS5_BAREWORD_SAFE_RE.test(text) ? `${text}*` : `"${text}"`;
}
/**
* 把一个查询 token 变成 FTS5 表达式片段。
*
* r208:token 按**文字系统**切成若干 run,每个 run 一个子句,子句之间用
* 空格(= FTS5 的隐式 AND)连接。
*
* '学习' → "学 习" (单 run ⇒ 一个短语,行为同 r196)
* 'transformer' → transformer* (单 run ⇒ 前缀通配,行为同旧版)
* 'CNN模型' → CNN* "模 型" (混排 ⇒ 两个子句,AND)
*
* 为什么要改混排:旧实现把混排 token 整体当**一个短语**(「CNN 模 型」),
* 而 FTS5 短语要求 token **相邻** ⇒ 文档里必须出现「CNN模型」且中间
* 不能夹别的汉字才命中。用户写「CNN模型」(不空格)常常搜不到,写
* 「CNN 模型」(带空格)却搜得到 —— 同一篇文档,两种写法结果不同。
* 改成按 run 拆子句后,「CNN模型」与「CNN 模型」产生**完全相同**的
* 查询串(见 ARCHITECTURE §5.6),带不带空格不再影响结果。
*
* 注:混排拆分只会**放宽**命中范围(短语形式蕴含 AND 形式:短语命中
* ⇒ 两侧 token 必然相邻 ⇒ AND 必然成立),不会漏掉原本能命中的文档。
*/
function fts5Term(token: string): string {
const runs = splitScriptRuns(token);
if (!runs.length) return '';
const clauses = runs
.map((r) => (r.cjk ? cjkPhrase(r.text) : latinTerm(r.text)))
.filter(Boolean);
return clauses.join(' ');
}
export function sanitizeFtsQuery(q: string): string {
const trimmed = q.trim();
if (!trimmed) return '';
const tokens = trimmed
.replace(/"/g, ' ')
.split(/\s+/)
.filter(Boolean)
.map((t) => t.replace(/[^\p{L}\p{N}_\-.]/gu, ''))
.filter(Boolean)
.map((t) => (FTS5_RESERVED.has(t.toLowerCase()) ? FTS5_FALLBACK_TOKEN : t));
if (!tokens.length) return '';
// audit 2026-10-04:加引号判据从「含 '-'」改成「含任何非 bareword 安全字符」
// 旧实现漏 '.' '/',含点的 token 拼成 v1.2* 直接 syntax error(DOI 也中招)
// 用白名单判,新增字符默认被引号兜住,不会再漏
// 用引号而非删点:删点把 v1.2 变成 v12,索引里没有,静默 0 条;引号按短语匹配能正确命中
return tokens.map(fts5Term).join(' ');
}

118
src/main/db/sqlite.ts Normal file
View File

@@ -0,0 +1,118 @@
// SQLite 生命周期:initDB / waitForDBInit / getDB / closeDB / WAL checkpoint
// DB 放 userData(本地),不放数据路径(云盘同步会损坏 SQLite)
// 表级 CRUD / FTS 搜索见 resources-repo.ts
import Database from 'better-sqlite3';
import { app } from 'electron';
import { join } from 'path';
import { mkdirSync } from 'fs';
import { createLogger } from '../utils/logger';
import { AppError, errMsg } from '../utils/error';
import { applyMigrations } from './migrator';
const log = createLogger('db');
let db: Database.Database | null = null;
export function initDB(dataPath: string): Database.Database {
if (db) return db;
// 任何 throw 都需 reject 所有 initDbResolvers,否则 waitForDBInit 的 awaiter 永久挂住
try {
return initDBInner(dataPath);
} catch (err) {
// 粘滞失败状态,closeDB 清半初始化句柄(防下次 initDB 返无 schema 的 db)
closeDB();
dbInitError = err;
for (const { reject } of initDbResolvers.splice(0)) reject(err);
throw err;
}
}
function initDBInner(dataPath: string): Database.Database {
// DB 放 userData 下独立子目录,数据路径可能被云盘同步,SQLite 在云盘上会损坏
const dbDir = join(app.getPath('userData'), 'index');
mkdirSync(dbDir, { recursive: true });
const dbPath = join(dbDir, 'index.sqlite');
log.info('Opening database', dbPath);
log.info('(Data files in:', dataPath, '- not synced)');
db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.pragma('foreign_keys = ON');
db.pragma('temp_store = MEMORY'); // 内存 temp store,大 ORDER BY 不写盘
db.pragma('cache_size = -65536'); // 64MB page cache
db.pragma('mmap_size = 268435456'); // 256MB mmap 加速读
applyMigrations(db, join(__dirname, 'migrations'));
// ANALYZE + SqliteStat4 — 冷启动后 planner 无 stats,1.5k+ 库降 30-50% latency
db.pragma('optimize(0x10002)');
// 成功路径清粘滞失败标记,否则一次失败永久毒化后续重试
dbInitError = null;
for (const { resolve } of initDbResolvers.splice(0)) resolve();
return db;
}
/** 等待 initDB 完成,失败 reject,完成后立即 resolve */
const initDbResolvers: Array<{
resolve: () => void;
reject: (err: unknown) => void;
}> = [];
// 粘滞 initDB 失败标记:initDB fire-and-forget,waitForDBInit 后到时立即拿同一个错误,
// 否则 resolver 永久挂起,启动遮罩卡 30s
let dbInitError: unknown = null;
export function waitForDBInit(): Promise<void> {
if (db) return Promise.resolve();
if (dbInitError) return Promise.reject(dbInitError);
return new Promise((resolve, reject) => {
initDbResolvers.push({ resolve, reject });
});
}
export function getDB(): Database.Database {
if (!db) throw new AppError('INTERNAL', 'DB not initialized');
return db;
}
export function closeDB(): void {
if (db) {
db.close();
db = null;
}
// closeDB 兜底清 WAL timer,防崩溃/teardown 路径 timer 阻退出
stopWalCheckpointTimer();
}
// 30 分钟间隔 — 周期 checkpoint 防 -wal 膨胀到 4GB 上限,TRUNCATE 截 0;busy 降级 PASSIVE
const WAL_CHECKPOINT_INTERVAL_MS = 30 * 60 * 1000;
let walCheckpointTimer: ReturnType<typeof setInterval> | null = null;
export function startWalCheckpointTimer(): void {
if (walCheckpointTimer) return;
walCheckpointTimer = setInterval(() => {
if (!db) return;
try {
// audit-r80 (2026-10-17) LOW:better-sqlite3 的 wal_checkpoint pragma
// 返回**对象数组** `[{ busy, log, checkpointed }]`,旧判断 `r[0] === 1`
// 拿对象比数字恒为 false —— busy 时降级 PASSIVE 的分支是死代码,
// TRUNCATE 撞读写占用时静默不完全,-wal 在长跑会话里持续增长(实证:
// 仓库自带 better-sqlite3 跑 pragma 返回 [{busy:0,…}],r[0]===1 恒假)。
const r: unknown = db.pragma('wal_checkpoint(TRUNCATE)');
const busy = Array.isArray(r) && (r[0] as { busy?: unknown } | undefined)?.busy === 1;
if (busy) {
db.pragma('wal_checkpoint(PASSIVE)');
}
} catch (err: unknown) {
log.warn('wal_checkpoint failed', errMsg(err));
}
}, WAL_CHECKPOINT_INTERVAL_MS);
// .unref() 让 timer 不持 event loop,崩溃/SIGINT 路径正常退出
walCheckpointTimer.unref?.();
log.info('WAL checkpoint timer started', { intervalMs: WAL_CHECKPOINT_INTERVAL_MS });
}
export function stopWalCheckpointTimer(): void {
if (!walCheckpointTimer) return;
clearInterval(walCheckpointTimer);
walCheckpointTimer = null;
}

77
src/main/document-csp.ts Normal file
View File

@@ -0,0 +1,77 @@
// 应用自身文档的 CSP 响应头注入 —— `frame-ancestors` 的**唯一生效位置**。
//
// 背景(audit r169,2026-10-19):
// renderer/index.html 的 <meta> CSP 里长期挂着 `frame-ancestors 'none'`。
// 但 frame-ancestors 只在**响应头**里生效 —— 放在 <meta> 里 Chromium
// 直接忽略,并每次启动打一条 error:
// "The Content Security Policy directive 'frame-ancestors' is ignored
// when delivered via a <meta> element."
// 即该 directive 一直是**空操作**(防护从未生效),同时每次启动刷一条错。
//
// 实测(临时 Electron harness,2026-10-19,已删):
// - 注入到 **file:// 文档**响应头 → **强制执行**:
// "Refused to frame 'file:///' because an ancestor violates the
// following Content Security Policy directive: "frame-ancestors 'none'""
// + net::ERR_BLOCKED_BY_RESPONSE
// - 注入到 **rkms:// 自定义 scheme** 响应头 → **不执行**(Chromium 只对
// web 系 scheme 强制 frame-ancestors),<iframe src="rkms://…"> 照常 commit。
// → 所以 frame-ancestors 只能、也只该加在**应用自身文档**上。
//
// 为什么不在 rkms:// 资源上加兜底(见 src/main/protocol.ts 同处注释):
// 那里加了今天也不生效;而一旦 Chromium 将来开始对自定义 scheme 强制,
// PDFReader / ImageReader 的 <iframe src="rkms://…"> 会被自家 directive
// 打死 —— 收益为零、风险为正,故不加。本文件是对它的正确替代。
//
// 注入范围:仅 mainFrame(主文档)。
// - 子资源(JS/CSS/图片)带 CSP 无意义;
// - mainFrame 判定保证不误伤 rkms:// iframe 等 subFrame(它们各自有自己的
// 响应头策略)。
// - 本 session 里的 mainFrame 只有应用主窗口(dev 下是 vite dev server 的
// http://localhost:5173,prod 下是 file://),不存在第三方页面。
//
// 与 renderer/index.html 里 <meta> CSP 的关系:两者**并存生效**(多份 CSP
// 取交集)。meta 继续管 script-src / object-src / frame-src 等,本文件只
// 补 meta 表达不了的 frame-ancestors,互不替代。
import { session } from 'electron';
// 应用主文档的 CSP 响应头值。单独成常量便于测试断言与后续扩展。
export const APP_DOCUMENT_CSP = "frame-ancestors 'none'";
// 注入用的 URL 过滤:
// - file://*/* prod 下 loadFile 加载的构建产物
// - http://localhost:*/* 与 http://127.0.0.1:*/* dev server(端口随机)
const DOCUMENT_URL_FILTER = ['file://*/*', 'http://localhost:*/*', 'http://127.0.0.1:*/*'];
/**
* 在既有响应头基础上补上应用文档的 CSP。
*
* 纯函数(不碰 electron),便于单测直接断言。行为要点:
* - 大小写不敏感地清掉已有的 content-security-policy,避免与上游
* (如 vite dev server)自带的 CSP 叠成两条 —— 两条 CSP 取交集,
* 上游那条若更严会把应用直接锁死,更松则两条都松(白写)。
* - 其余响应头原样保留。
*/
export function buildDocumentHeaders(
existing: Record<string, string | string[]> | undefined
): Record<string, string | string[]> {
const out: Record<string, string | string[]> = { ...(existing ?? {}) };
for (const key of Object.keys(out)) {
if (key.toLowerCase() === 'content-security-policy') delete out[key];
}
out['Content-Security-Policy'] = APP_DOCUMENT_CSP;
return out;
}
/**
* 给应用主文档装上 frame-ancestors 响应头。必须在 createWindow 之前调用
* (首个文档请求就要带上),但可以在 app ready 之后(session 可用)。
*/
export function installDocumentCspHeaders(): void {
session.defaultSession.webRequest.onHeadersReceived({ urls: DOCUMENT_URL_FILTER }, (details, callback) => {
if (details.resourceType !== 'mainFrame') {
callback({});
return;
}
callback({ responseHeaders: buildDocumentHeaders(details.responseHeaders) });
});
}

357
src/main/i18n-dict.ts Normal file
View File

@@ -0,0 +1,357 @@
// src/main/i18n-dict.ts
//
// r165 (2026-10-19) 分层硬不变式:零上游 import 的字典叶子模块。
//
// 抽出原因:src/main/i18n.ts ↔ src/main/utils/safe-write.ts 通过
// i18n → settings/store → safe-write → i18n 形成循环依赖
// (layering.test.ts 持续红)。解法:把 T 字典与 Lang 类型从 i18n.ts
// 抽到本零依赖文件,safe-write.ts 直接从这里 import T(绕开 i18n.ts),
// 当前语言由 setI18nLangProvider 注册的 getter 解析(bin/index.ts 在
// 启动早期一次注入,setter 之前任何 tr() 调用回退 zh-CN)。
//
// 不引 main 侧任何东西(electron / fs / settings / ...),让本文件可在
// 任何地方 import 而不引发新环。
import type { AppSettings } from '../shared/types';
/** 单一真相源 —— 与 i18n.ts 的 Lang 等价,但本文件可独立 import。 */
export type Lang = NonNullable<AppSettings['language']>;
type Dict = Record<Lang, string>;
type Entry = Record<string, Dict>;
export const T: Entry = {
// App / File / Edit / View / Window / Help 菜单
'menu.preferences': { 'zh-CN': '偏好设置…', 'en-US': 'Preferences…' },
'menu.file': { 'zh-CN': '文件', 'en-US': 'File' },
'menu.file.newNote': { 'zh-CN': '新建笔记', 'en-US': 'New Note' },
'menu.file.save': { 'zh-CN': '保存', 'en-US': 'Save' },
// Edit 菜单依赖系统 role 自带本地化,这里只占位
'menu.edit': { 'zh-CN': '编辑', 'en-US': 'Edit' },
'menu.view': { 'zh-CN': '视图', 'en-US': 'View' },
// r242:左侧列表面板折叠 / 展开(与标题栏按钮、renderer ⌘B 同一命令)
'menu.view.toggleSidebar': {
'zh-CN': '折叠 / 展开左侧栏',
'en-US': 'Toggle Sidebar',
},
'menu.view.toggleStatusBar': {
'zh-CN': '折叠 / 展开 Status Bar',
'en-US': 'Toggle Status Bar',
},
'menu.window': { 'zh-CN': '窗口', 'en-US': 'Window' },
'menu.help.openSettings': { 'zh-CN': '打开设置', 'en-US': 'Open Settings' },
// Window title / 启动失败 dialog / renderer 生命周期
'window.title': { 'zh-CN': '文献管理', 'en-US': 'Literature Management' },
'error.startupFailed': { 'zh-CN': '启动失败', 'en-US': 'Startup failed' },
'error.windowCreateFailed': {
'zh-CN': '窗口创建失败',
'en-US': 'Failed to create window',
},
'error.notesServiceFailed': {
'zh-CN': '笔记服务启动失败',
'en-US': 'Notes service failed to start',
},
'error.favoritesServiceFailed': {
'zh-CN': '收藏服务启动失败',
'en-US': 'Favorites service failed to start',
},
'error.unhandledException': {
'zh-CN': '未捕获异常',
'en-US': 'Unhandled exception',
},
'error.unhandledRejection': {
'zh-CN': '未处理的 Promise 拒绝',
'en-US': 'Unhandled promise rejection',
},
'error.notesWatcherFailed': {
'zh-CN': '笔记监听失败',
'en-US': 'Notes watcher failed',
},
'error.notesReloadFailed': {
'zh-CN': '笔记重载失败',
'en-US': 'Notes reload failed',
},
// audit-r136:literature watcher 失败 / reload 失败 toast —— 与 notes 同款
// 词条,推送给 renderer 走 IPC.TOAST_PUSH(主进程 index.ts 的 watcherToast
// helper 桥接)。失败语义:连续重启超上限(同 notes MAX_NOTES_AUTO_RESTARTS
// 阈值)触发 watcher-failed,或 reload 抛错触发 reload-failed。
'error.literatureWatcherFailed': {
'zh-CN': '文献监听失败',
'en-US': 'Literature watcher failed',
},
'error.literatureReloadFailed': {
'zh-CN': '文献重载失败',
'en-US': 'Literature reload failed',
},
// 五种 Electron renderer 错误统一 toast 给用户,避免白屏无响应
'error.rendererLoadFailed': {
'zh-CN': '渲染进程加载失败,正在重试',
'en-US': 'Renderer failed to load, retrying',
},
// audit-r124:window.ts r28 重试耗尽分支一直在 t() 本 key,词表从未收录
// → 用户重试烧完后收到裸 key 字符串(「error.rendererLoadFailedFinal」)
// 或空 toast。补齐双词条。
'error.rendererLoadFailedFinal': {
'zh-CN': '渲染进程连续加载失败,请重启应用;仍失败请重新安装',
'en-US': 'Renderer keeps failing to load — restart the app; if it persists, reinstall',
},
'error.rendererCrashed': {
'zh-CN': '渲染进程崩溃,正在重启',
'en-US': 'Renderer crashed, reloading',
},
'error.rendererUnresponsive': {
'zh-CN': '渲染进程无响应',
'en-US': 'Renderer is unresponsive',
},
'error.rendererGone': {
'zh-CN': '渲染进程已退出 ({reason}),正在重启',
'en-US': 'Renderer process gone ({reason}), reloading',
},
'error.childProcessGone': {
'zh-CN': '子进程已退出 ({reason})',
'en-US': 'Child process gone ({reason})',
},
// 文件夹选择对话框(SETTINGS_PICK_DATA_PATH)
'settings.pickerData': { 'zh-CN': '选择数据文件夹', 'en-US': 'Data folder' },
// Library / Notes / Validation 错误
'library.fileMissing': { 'zh-CN': '文件路径为空', 'en-US': 'File path is empty' },
'library.fileMoved': {
'zh-CN': '文件不存在或已被移动',
'en-US': 'File no longer exists or has been moved',
},
'library.fileTooLarge': { 'zh-CN': '文件过大', 'en-US': 'File too large' },
'library.noPermission': {
'zh-CN': '无权限读取此文件',
'en-US': 'No permission to read this file',
},
'library.notAFile': {
'zh-CN': '不是文件,无法读取',
'en-US': 'Not a regular file, cannot read',
},
'notes.frontmatterParseFailed': {
'zh-CN': '笔记「{path}」 frontmatter 解析失败: {reason}',
'en-US': 'Failed to parse frontmatter of note "{path}": {reason}',
},
'notes.writeReadbackFailed': {
'zh-CN': '写入后无法立即读回笔记',
'en-US': 'Could not read back note after write',
},
'notes.createReadbackFailed': {
'zh-CN': '新建笔记后无法立即读回',
'en-US': 'Could not read back note after creation',
},
'notes.renameReadbackFailed': {
'zh-CN': '重命名后无法立即读回',
'en-US': 'Could not read back note after rename',
},
'notes.notFound': { 'zh-CN': '笔记 {id} 不存在', 'en-US': 'Note {id} does not exist' },
'notes.notFoundPath': {
'zh-CN': '笔记「{path}」不存在',
'en-US': 'Note "{path}" does not exist',
},
'notes.duplicateName': {
'zh-CN': '已存在同名笔记「{name}」',
'en-US': 'A note with the same name "{name}" already exists',
},
'notes.createFailed': {
'zh-CN': '无法创建笔记「{name}」: {reason}',
'en-US': 'Failed to create note "{name}": {reason}',
},
'notes.renameNewFailed': {
'zh-CN': '无法写入新笔记「{path}」: {reason}',
'en-US': 'Failed to write renamed note "{path}": {reason}',
},
'notes.deleteOldFailed': {
'zh-CN': '无法删除原笔记「{path}」: {reason}',
'en-US': 'Failed to delete original note "{path}": {reason}',
},
// r161+ disk-folder Phase 2b:跨文件夹移动失败
'notes.moveFailed': {
'zh-CN': '无法移动笔记「{path}」: {reason}',
'en-US': 'Failed to move note "{path}": {reason}',
},
'notes.moveReadbackFailed': {
'zh-CN': '移动后无法读取笔记「{path}」',
'en-US': 'Failed to read back note after move "{path}"',
},
// audit-r160:safeTrash 三条 throw 的本地化文案 —— 原实现把 ${resolved}
// 绝对路径拼进 message,wrap.ts 非 INTERNAL 分支原样透传 IPC wire,把
// 用户家目录 / OneDrive 路径暴露给 renderer / telemetry。改走 tr() 固定
// 双语文案,绝对路径改由主进程 log 留痕(IPC 边界外的诊断信号)。
'notes.trashRefusedSymlink': {
'zh-CN': '无法移入回收站:文件是符号链接',
'en-US': 'Cannot move to trash: file is a symbolic link',
},
'notes.trashRefusedNonRegular': {
'zh-CN': '无法移入回收站:不是常规文件',
'en-US': 'Cannot move to trash: not a regular file',
},
'notes.trashFailed': {
'zh-CN': '移入回收站失败: {reason}',
'en-US': 'Failed to move to trash: {reason}',
},
'notes.renameDbSyncFailed': {
'zh-CN': '重命名后 DB 同步失败: {reason}',
'en-US': 'Failed to sync database after rename: {reason}',
},
// audit-r155:write id-repin 路径(外部 fm.id 覆写或副本重映射)DB 旧行
// delete 失败 —— 旧实现 best-effort swallow,留下 DB + byId 双键漂移
// (搜索命中死行)。改为显式 IO_ERROR 上抛,chokidar 后续 self-heal byId。
'notes.idRepinDbSyncFailed': {
'zh-CN': 'id 重映射后旧行清理失败({oldId} → {newId}): {reason}',
'en-US': 'Failed to clean up old row after id repin ({oldId} → {newId}): {reason}',
},
// 外部 mtime 与 expectedMtime 不一致 → CONFLICT,renderer 走冲突 UI
'notes.modifiedExternally': {
'zh-CN': '笔记被外部修改(磁盘 mtime {actual} ≠ 期望 {expected})',
'en-US': 'Note was modified externally (disk mtime {actual} ≠ expected {expected})',
},
'notes.invalidExpectedMtime': {
'zh-CN': 'expectedMtime 参数非法: {value}',
'en-US': 'Invalid expectedMtime parameter: {value}',
},
// OS 拖入 .md 导入错误
'notes.importNotMd': {
'zh-CN': '「{name}」不是 .md 文件,无法作为笔记导入',
'en-US': '"{name}" is not a .md file and cannot be imported as a note',
},
'notes.importTooLarge': {
'zh-CN': '笔记文件过大 ({size} 字节),上限 5MB',
'en-US': 'Note file too large ({size} bytes, max 5MB)',
},
'notes.importFailed': {
'zh-CN': '读取源文件失败: {reason}',
'en-US': 'Failed to read source file: {reason}',
},
// 文献导入 / 删除(audit-r71,src/main/literature/import.ts)
'literature.importNotRegularFile': {
'zh-CN': '「{name}」不是普通文件,无法作为文献导入',
'en-US': '"{name}" is not a regular file and cannot be imported as literature',
},
'literature.importTooLarge': {
'zh-CN': '「{name}」过大({size} 字节),文献导入上限 500MB',
'en-US': '"{name}" is too large ({size} bytes; literature import limit is 500MB)',
},
'literature.importFailed': {
'zh-CN': '导入文献「{name}」失败: {reason}',
'en-US': 'Failed to import literature "{name}": {reason}',
},
'literature.removeFailed': {
'zh-CN': '删除文献文件「{name}」失败: {reason}',
'en-US': 'Failed to delete literature file "{name}": {reason}',
},
'literature.notFound': {
'zh-CN': '文献条目不存在: {id}',
'en-US': 'Literature entry not found: {id}',
},
// r161+ disk-folder Phase 2b:文献跨文件夹移动失败
'literature.moveFailed': {
'zh-CN': '无法移动文献「{name}」: {reason}',
'en-US': 'Failed to move literature "{name}": {reason}',
},
'literature.moveTargetExists': {
'zh-CN': '目标位置已存在文献「{name}」',
'en-US': 'A literature file already exists at the target "{name}"',
},
// r187:文献单文件重命名(与 notes 侧改名同款文案粒度)
'literature.renameInvalidName': {
'zh-CN': '文件名「{name}」不合法(不能含路径分隔符或保留字符)',
'en-US': 'Invalid file name "{name}" (no path separators or reserved characters)',
},
'literature.renameTargetExists': {
'zh-CN': '目标位置已存在文献「{name}」',
'en-US': 'A literature file already exists at the target "{name}"',
},
'literature.renameFailed': {
'zh-CN': '重命名文献「{name}」失败: {reason}',
'en-US': 'Failed to rename literature "{name}": {reason}',
},
// audit-r160:search IPC 把脏 resource id 翻译为 VALIDATION —— 之前走 INTERNAL
// 兜底,renderer 看到通用「内部错误」,实际是 DB 脏数据 / 0005 残留 / 手改
// .md 引入非法 prefix。双语文案走 tr() 与其它 notFound 同款。
'search.badResourceId': {
'zh-CN': '搜索结果中的资源 ID 无效: {id}',
'en-US': 'Invalid resource ID in search results: {id}',
},
// r161+ 真实磁盘文件夹操作(safe-write.ts safeMkdir / safeRecursiveTrash)
// + ipc/folders.ts 撞名检测。
// audit-r206 (2026-10-20):原「虚拟文件夹(0007)」域的 folder.* 文案与
// diskFolder.{notFound,moveFailed,invalidRelPath} 三条**零消费者** ——
// 虚拟文件夹已随 migration 0008 drop_folders 退役,磁盘层错误一律走
// diskFolder.*。三者的「留待未来复用」在 13 个条目上躺了数月,零引用
// ⇒ 一并删除,并加守卫 tests/unit/main/menu-label-i18n-keys.test.ts
// (主进程字典不许出现零引用 key)。
'diskFolder.mkdirRefusedSymlink': {
'zh-CN': '无法在符号链接上创建文件夹',
'en-US': 'Cannot create folder on a symbolic link',
},
'diskFolder.mkdirRefusedNonDir': {
'zh-CN': '目标路径已被占用且不是文件夹',
'en-US': 'Target path is already occupied and is not a folder',
},
'diskFolder.trashRefusedSymlink': {
'zh-CN': '无法删除符号链接(防止越界)',
'en-US': 'Cannot delete a symbolic link (refuses to follow)',
},
'diskFolder.trashRefusedNonDir': {
'zh-CN': '目标路径不是文件夹',
'en-US': 'Target path is not a folder',
},
'diskFolder.trashFailed': {
'zh-CN': '删除文件夹失败: {reason}',
'en-US': 'Failed to trash folder: {reason}',
},
'diskFolder.duplicateName': {
'zh-CN': '同级已存在同名文件夹「{name}」',
'en-US': 'A sibling folder named "{name}" already exists',
},
// 文件名校验
'validation.noteFilenameEmpty': {
'zh-CN': '新文件名不能为空',
'en-US': 'New filename cannot be empty',
},
'validation.noteFilenameEmptyOriginal': {
'zh-CN': '笔记文件名不能为空',
'en-US': 'Note filename cannot be empty',
},
'validation.noteFilenameInvalidChars': {
'zh-CN': '笔记文件名「{name}」包含非法字符',
'en-US': 'Note filename "{name}" contains invalid characters',
},
'validation.noteFilenameTooLong': {
'zh-CN': '笔记文件名过长({len} 字,上限 {max})',
'en-US': 'Note filename too long ({len} characters, max {max})',
},
'validation.noteFilenameReserved': {
'zh-CN': '笔记文件名段「{seg}」是 Windows 系统保留名',
'en-US': 'Filename segment "{seg}" is a Windows reserved device name',
},
// r195:改名只改文件名、不改位置(r175 不变式)⇒ 改名框里不许带路径分隔符。
'validation.noteFilenamePathSeparator': {
'zh-CN': '笔记文件名「{name}」不能包含路径分隔符(改名只改文件名,不改所在文件夹)',
'en-US': 'Note filename "{name}" cannot contain a path separator (rename changes the name only, not the folder)',
},
};
/** 与 i18n.ts 的 t() 等价 —— safe-write.ts 的本地 tr() 调用本函数。 */
export function resolveT(
key: string,
lang: Lang,
params?: Record<string, string | number>,
): string {
const entry = T[key];
if (!entry) return key;
let s = entry[lang] ?? entry['zh-CN'] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
s = s.replaceAll(`{${k}}`, String(v));
}
}
return s;
}

54
src/main/i18n.ts Normal file
View File

@@ -0,0 +1,54 @@
// 主进程最小化 i18n:menu/window title/dialog/error message 走 settings.language,
// 不引 main 侧 i18next(避免把 react-i18next 拉到主进程,+~80KB)。缺字段回退 zh-CN,
// 未知 key 返回 key 自身(开发期易发现),{name} 占位符 replaceAll 替换。
//
// r165 (2026-10-19):T 字典与 Lang 类型抽到 ./i18n-dict.ts(零上游 import 的叶子),
// 打破 i18n ↔ safe-write 通过 settings/store 的循环依赖(layering.test.ts
// 6. 无循环依赖持续红)。本文件保留:tr / t / safeLang 三个函数,以及
// 暴露 setI18nLangProvider 的注册表入口(safe-write.ts 启动早期调一次,之后
// 其内部 tr() 走用户当前语言)。
import { resolveT, type Lang } from './i18n-dict';
import { getSettings } from './settings/store';
import { setI18nLangProvider } from './utils/safe-write';
export type { Lang };
/** 查表;未知 key 返回 key 自身,缺字段回退 zh-CN,{name} 占位符 replaceAll 替换 */
export function t(key: string, lang: Lang, params?: Record<string, string | number>): string {
return resolveT(key, lang, params);
}
/** tr(): 自动读 settings.language;settings 未初始化时回退 zh-CN,避免把
* "Settings not initialized" 的 INTERNAL 错误盖在真正的业务错误上 */
export function tr(key: string, params?: Record<string, string | number>): string {
return resolveT(key, safeLang(), params);
}
/** 安全取当前 UI 语言。settings 未初始化(bootstrap 顶层 / startup catch /
* app.activate 早于 bootstrap)时回退 zh-CN —— 避免 getSettings() 的 INTERNAL
* 抛错把真业务错盖掉 */
export function safeLang(): Lang {
try {
return getSettings().language ?? 'zh-CN';
} catch {
return 'zh-CN';
}
}
/**
* r165 分层硬不变式补救:把 safeLang() 注册到 safe-write 的 langProvider 槽。
*
* - 必须在 settings 已能读取(bootstrap 之后)调一次;之后 safe-write.ts
* 内部抛 AppError 走的 tr() 解析就用当前用户语言。
* - 不调时回退 zh-CN(模块加载序极端情况下,例如 settings 还在 init 时
* 触发了磁盘写入)。
* - 由 main/index.ts 在 import 顶层做一次注入(无运行期分支)。
*
* 把这一步独立成 export 函数的目的:让 settings 加载 + i18n 注册这一对
* 副作用从 import 边里被「观察得到」,而不是散落在模块加载的隐式契约里。
* 测试可在 i18n 模块加载后调一次确认 safeLang() 已挂上。
*/
export function registerI18nForSafeWrite(): void {
setI18nLangProvider(safeLang);
}

335
src/main/index.ts Normal file
View File

@@ -0,0 +1,335 @@
// D2-020: 必须在 better-sqlite3 dlopen 之前执行,见 utils/path-check.ts
import './utils/path-check';
// 主进程入口
import { app, BrowserWindow, dialog } from 'electron';
import { createWindow } from './window';
import { registerRkmsSchemePrivileges, registerRkmsProtocolHandler } from './protocol';
import { installDocumentCspHeaders } from './document-csp';
import {
startup,
runBackgroundScansFireAndForget,
notesService,
literatureService,
getFavoritesServiceSafe,
} from './startup';
import { getFavoritesService } from './workspace/favorites';
import { closeDB, startWalCheckpointTimer, stopWalCheckpointTimer } from './db/sqlite';
import { IPC, type ToastPushPayload } from '../shared/types';
import { registerMenuIPC, rebuildChromeForLang } from './ipc/menu';
import { createLogger } from './utils/logger';
import { broadcastToAllWindows } from './ipc/broadcast';
// audit C3R49 (2026-08-22):统一 4 处 uncaughtException / unhandledRejection / bootstrap
// catch / createWindow catch 的 `err instanceof Error ? \`${err.message}\n${err.stack}\` : String(err)`
// 重复片段,改用 errPayloadStr 保留 stack 的同时输出单字符串。
// audit C3R52 (2026-08-22):用 errPayload 给 log.error 3-arg 形式 (msg, message, stack) 也走安全提取;
// C3R49 只修了 \`${message}\n${stack}\` template literal 形态,漏了 uncaughtException 这种
// `(err) => log.error('uncaughtException', err.message, err.stack)` 形态 —— 若 runtime
// 抛非 Error 对象(throw 'string' / null),直接访问 .message/.stack 会得到 undefined,
// 日志丢真错。改用 errPayload(err) 后 `e.message` 始终是 string,与下方 unhandledRejection
// 同款的 "reason 是 unknown" 处理一致。
import { errPayload, errPayloadStr } from './utils/error';
// i18n:启动失败 / 窗口创建失败 dialog 标题走 t(),跟随 settings.language。
// safeLang 在 settings 未初始化时降级 zh-CN 不抛 INTERNAL —— 早期错误路径
// (bootstrap catch / startup catch / macOS activate)settings 状态不可知,
// 不能让 getSettings() 二次 throw 把真错盖掉。
import { safeLang, t, registerI18nForSafeWrite } from './i18n';
// r165 (2026-10-19):分层硬不变式补救 —— 把 safeLang() 注册进 safe-write
// 的 langProvider 槽,之后 safe-write 内部抛 AppError 走 tr() 解析就用
// 当前用户语言。顶层 import 后立即调一次,确保所有 IPC handler / service
// 触达 safe-write 时槽已注入(无运行期分支)。本 import 必须在 safe-write
// 的任意调用方 import 之前 —— 我们把它放在 main/index.ts 顶部 boot 段。
registerI18nForSafeWrite();
// audit r40 (2026-10-15):老 dev userData 一次性搬迁 —— 见 utils/migrate-old-userdata.ts
// 头部注释。顶部静态 import(而非 require)让 vitest 的 vi.mock 能拦截;helper
// 自身 import electron 不会触发额外副作用,仅函数体在调用时访问 app。
import { migrateOldDevUserData } from './utils/migrate-old-userdata';
// audit 2026-08-28: rkms:// 自定义协议的 scheme privileges 必须在
// app.whenReady() resolve 之前声明(registerSchemesAsPrivileged 只在
// isReady=false 时生效)。模块顶层、所有异步之前调用,保证比 whenReady.then() 早跑。
registerRkmsSchemePrivileges();
// audit r40 (2026-10-15):dev/packaged userData 路径统一 ——
// package.json 同时含 name="research-knowledge-management-system" 与
// productName="Literature Management" 两个字段:
// - packaged 模式:electron-builder 把 productName 写进 metadata,
// app.getName() = productName,userData = .../Literature Management/
// - dev 模式:无 packaged metadata,app.getName() 回退到 name 字段,
// userData = .../research-knowledge-management-system/
// 后果:同一台机器上 dev 与 packaged 模式 userData 路径不同,SQLite 索引 +
// location.json 在两种模式下指向不同目录 → README 描述的是 packaged
// 路径,dev 用户路径与文档不符;且 dev/packaged 切换时数据漂移。修:在
// whenReady 之前 setName 到 productName ——
// - packaged 模式:同值覆盖,无副作用
// - dev 模式:userData 切到 .../Literature Management/,与文档对齐
// 老 dev 用户的旧 userData 由 utils/migrate-old-userdata.ts 一次性处理
// (location.json / settings.json / index/ 原子搬 + 删 source)。
// setName 必须在 whenReady 之前调用(否则 userData 路径已被冻结);与上方
// registerRkmsSchemePrivileges 同一窗口期。
// 老 appName **必须在 setName 之前获取** —— app.getName() 返回当前生效名,
// setName 之后再调拿到的是 'Literature Management',oldUserData === newUserData,
// helper 走 no-op 不会搬任何东西。先取再 set 是关键顺序,见 utils/migrate-old-
// userdata.ts:166-173 注释。
const _oldAppName = app.getName();
app.setName('Literature Management');
const log = createLogger('main');
// 改名史(r40 → 2026-10-21 本轮):userData 目录名历经两代 ——
// 1. research-knowledge-management-system(package.json name 字段,老 dev)
// 2. Research Knowledge Management(旧 productName,r40 后 dev+packaged)
// 本轮 productName 改为 Literature Management,两代老目录里的 location.json /
// settings.json / index/ 都要能搬进新 userData,否则老用户配置丢失、SQLite
// 索引从零重建。_oldAppName 覆盖「本次启动前生效名」(老 packaged 是
// 'Research Knowledge Management',老 dev 是 name 字段),两个常量兜底另一代。
const _legacyAppNames: readonly string[] = [
_oldAppName,
'research-knowledge-management-system',
'Research Knowledge Management',
];
// fire-and-forget:不阻塞 bootstrap。audit-r124:「helper 内部不抛错」的旧注释
// 不成立 —— migrateUserDataAppItems 对单步 fs 失败有 warnings 兜底,但
// report 组装前的同步 throw(如 computeOldUserDataPath 异常)仍让 Promise
// reject;裸 `void` 会撞上下方 unhandledRejection 全局网 → showFatalDialog
// + app.quit(),启动期一次性搬迁小故障被升级成整应用闪退。补 catch 降级。
// 多个 legacy 目录**顺序**跑(不并发):两个 source 同时往同一 newUserData
// 搬同名 item 有竞争;helper 幂等,先到的赢,后到的按「新已存在」skip
// + 清 stale source,顺序执行让该语义确定。
void (async () => {
for (const legacyName of new Set(_legacyAppNames)) {
await migrateOldDevUserData(legacyName);
}
})().catch((err: unknown) => {
log.warn('migrateOldDevUserData rejected, ignored', errPayloadStr(err));
});
// audit C3R71 (2026-08-22): 三处 error-path `setTimeout(() => app.quit(), 100)`
// verbatim 重复抽常量。100ms 给 dialog.showErrorBox / Electron 消息泵一帧时间
// flush 完后退出,而不是同步 quit 打断 dialog 渲染。
const QUIT_AFTER_ERROR_MS = 100;
// D2-019: 全局 last-line-of-defence,记录任何未捕获的同步异常/拒绝,
// 否则 IPC / chokidar / Promise 链中漏掉的错误会无声地让主进程僵死
// audit 2026-09-14:走 dialog + 退出 —— 之前只 log 不弹错,桌面应用没 telemetry
// 时用户面对"卡死的无响应窗口"完全无信号。uncaughtException 是同步路径,
// dialog 渲染不出来可能再次抛 — try/catch 包一层 + 设 quitScheduled 标记
// 防重入弹错。日志保留(telemetry / 事后定位)。
let errorDialogShown = false;
function showFatalDialog(title: string, message: string): void {
if (errorDialogShown) return;
errorDialogShown = true;
log.error(title, message);
try {
dialog.showErrorBox(title, message);
} catch (dialogErr: unknown) {
log.error('showErrorBox failed in fatal handler', errPayloadStr(dialogErr));
}
// 留 100ms 让 dialog 渲染 + Electron 消息泵 flush 一帧,然后退出
setTimeout(() => app.quit(), QUIT_AFTER_ERROR_MS);
}
process.on('uncaughtException', (err) => {
const e = errPayload(err);
showFatalDialog(t('error.unhandledException', safeLang()), `${e.message}\n\n${e.stack ?? ''}`);
});
process.on('unhandledRejection', (reason) => {
// reason 可能是 string / null / 非 Error 对象 —— errPayloadStr 统一 stringify
showFatalDialog(t('error.unhandledRejection', safeLang()), errPayloadStr(reason));
});
async function bootstrap() {
log.info('Bootstrap starting', { version: app.getVersion(), platform: process.platform });
// audit 2026-08-28: rkms:// 协议 handler 必须在第一个 BrowserWindow 加载
// URL 之前注册,否则 PDFReader / ImageReader 的 <iframe src="rkms://...">
// / <img src="rkms://..."> 会撞 ERR_UNKNOWN_URL_SCHEME。放在 bootstrap
// 顶端,startup() 之前 — createWindow 是首个 webContents 创建点。
registerRkmsProtocolHandler();
// audit r169 (2026-10-19):给**应用主文档**注入 CSP 响应头
// (frame-ancestors 'none')。meta CSP 里那条 directive 被 Chromium 忽略
// (只在响应头生效),是空操作 + 每次启动一条 error;这里补上真正生效的
// 那份。必须在 createWindow 之前 —— 首个文档请求就要带上响应头。
// 详见 src/main/document-csp.ts 头注(含实测结论)。
installDocumentCspHeaders();
try {
await startup();
} catch (err: unknown) {
const msg = errPayloadStr(err);
log.error('Startup failed:', msg);
dialog.showErrorBox(t('error.startupFailed', safeLang()), msg);
setTimeout(() => app.quit(), QUIT_AFTER_ERROR_MS);
return;
}
try {
await createWindow(safeLang()); // 窗口出
// Phase 5: 挂应用菜单 — 必须在 createWindow 之后,菜单 IPC 才有 webContents 推
// i18n:菜单 / window title 走 settings.language;切语言由 IPC.MENU_REBUILD 重建。
rebuildChromeForLang();
registerMenuIPC();
} catch (err: unknown) {
// D2-019: createWindow 之前没人接住错误,会让 whenReady 链 reject 成 unhandledRejection
log.error('Create window failed:', errPayloadStr(err));
dialog.showErrorBox(t('error.windowCreateFailed', safeLang()), errPayloadStr(err));
setTimeout(() => app.quit(), QUIT_AFTER_ERROR_MS);
return;
}
log.info('Window created');
// 启动 favorites + notes service(createWindow 之后立刻起,避免历史
// postStartup() 无人调导致 service 从不 start 的事故)。两者互不依赖,
// 并发跑缩短 dark screen;任一失败 catch 后不阻断其他。
const favoritesService = getFavoritesService();
await Promise.allSettled([
favoritesService.start().catch((err: unknown) => {
// audit 2026-09-16 r21:H8 — 收藏 service 启动失败对称 notes 走 dialog,
// 旧实现只 log 不弹,收藏分组为空 / markdown corrupt 时用户无信号。
// 与 notesService.start 同款错误处理,UX 一致。
log.error('Favorites service failed to start', errPayloadStr(err));
try {
dialog.showErrorBox(t('error.favoritesServiceFailed', safeLang()), errPayloadStr(err));
} catch (dialogErr: unknown) {
log.error('showErrorBox failed for favorites start', errPayloadStr(dialogErr));
}
}),
notesService.start({ skipInitialLoad: true }).catch((err: unknown) => {
// notesService.start 失败时除了 log,还弹 dialog 让用户知道笔记面板
// 为什么是空的(参考 r6)。不 quit —— favorites / settings 仍可用。
log.error('Notes service failed to start', errPayloadStr(err));
try {
dialog.showErrorBox(t('error.notesServiceFailed', safeLang()), errPayloadStr(err));
} catch (dialogErr: unknown) {
log.error('showErrorBox failed for notes start', errPayloadStr(dialogErr));
}
}),
// audit-r136:literature service 同 notes 形态 —— skipInitialLoad 让
// bootstrap 顶层不阻塞(同步 reload 会等 initDB + readdir),真正
// disk↔DB diff 推到 runBackgroundScansFireAndForget 的 'literature'
// phase(那里已 await waitForDBInit 拿到 DB)。失败 dialog 镜像 notes:
// 文献面板空白不阻断其它模块。
literatureService.start({ skipInitialLoad: true }).catch((err: unknown) => {
log.error('Literature service failed to start', errPayloadStr(err));
try {
dialog.showErrorBox(t('error.literatureWatcherFailed', safeLang()), errPayloadStr(err));
} catch (dialogErr: unknown) {
log.error('showErrorBox failed for literature start', errPayloadStr(dialogErr));
}
}),
]);
// r7:service 内部 emit('watcher-failed') / 'reload-failed' 时推 toast 给 renderer。
// bridge 一个 helper 把 main side event → IPC.TOAST_PUSH channel。
const watcherToast = (title: string, message: string): void => {
// audit-r92:satisfies 挂单源 payload 型(同 startup.ts 迁移 toast)
broadcastToAllWindows(IPC.TOAST_PUSH, {
type: 'error',
message: `${title}: ${message}`,
duration: 8000,
} satisfies ToastPushPayload);
};
notesService.on('watcher-failed', (err: unknown) => {
watcherToast(t('error.notesWatcherFailed', safeLang()), errPayloadStr(err));
});
notesService.on('reload-failed', (err: unknown) => {
watcherToast(t('error.notesReloadFailed', safeLang()), errPayloadStr(err));
});
// audit-r136:literature watcher / reload 失败 toast —— 复用上方 watcherToast
// helper(语义同 notes,主进程 → renderer 推 IPC.TOAST_PUSH)。
literatureService.on('watcher-failed', (err: unknown) => {
watcherToast(t('error.literatureWatcherFailed', safeLang()), errPayloadStr(err));
});
literatureService.on('reload-failed', (err: unknown) => {
watcherToast(t('error.literatureReloadFailed', safeLang()), errPayloadStr(err));
});
// audit 2026-08-26 (round 16):周期性 WAL checkpoint —— 长跑 app 期间
// -wal 文件无上限增长(默认 journal_size_limit = 0 → 4GB 上限)。
// 每 30 分钟 TRUNCATE 一次,把 commit 数据合入主 db 并把 wal 截到 0 字节;
// busy 时降级 PASSIVE。详见 sqlite.ts startWalCheckpointTimer。
startWalCheckpointTimer();
// perf(2026-08-24):走 broadcastToAllWindows 统一 helper,替代 inline BrowserWindow
// 循环 —— helper 内部已包 try/catch send,关窗口竞态不致影响其它窗口。
// audit 2026-08-28:三个 service 的 'changed' 推送载荷 {type:'changed'} 在渲染层
// 全部被忽略(preload handler 签名都是 () => void),走无参载荷即可。
// audit 2026-09-08:原注释提"trash / workspace helper 一致" —— trash 模块
// 2026-08-27 下线,workspace.ts 推 favorites/workspace 改写也并入
// favoritesService。audit-r56 后只剩 favorites / notes 两处 'changed' 监听。
// 重构 (2026-08-28):collectionsService 已删除 —— 收藏分组并入 favorites,
// favorites 的 'changed' 推送就是 renderer 端的 onCollectionsChanged 触发源。
// 注:favoritesService 已经在上方服务启动阶段拿到引用(line 124),复用即可,
// module-level 单例多次 getFavoritesService() 返同一实例。
favoritesService.on('changed', () => {
broadcastToAllWindows(IPC.FAVORITES_CHANGED);
});
notesService.on('changed', () => {
broadcastToAllWindows(IPC.NOTES_CHANGED);
});
// audit-r136:literature 'changed' 广播 —— chokidar add/change/unlink →
// reload() → byId 重建 → emit('changed') → 推 LITERATURE_CHANGED 到所有
// 未销毁 renderer。renderer useLiteratureStore._ensureInit 订阅 + 200ms
// self-write 抑制窗口(r136 M3 由 500 收紧;r221 对账:notes 域是
// 500ms,两值刻意不同,原因见 store 文件内注释),自动
// load({ force: true }) 拉新列表(防自身写入触发二次 reload)。
literatureService.on('changed', () => {
broadcastToAllWindows(IPC.LITERATURE_CHANGED);
});
// 不 await:把扫描踢到后台跑,完成后通过上方 'changed' 监听自动刷新 renderer
// (新版:同步向 startupProgress 推送 phase-start/progress/done 供 StartupOverlay 消费)
runBackgroundScansFireAndForget();
}
app.whenReady().then(() => {
// D2-019: 顶层加 .catch,防止 whenReady 回调里抛错被静默吞掉
bootstrap().catch((err) => {
const msg = errPayloadStr(err);
log.error('bootstrap rejected:', msg);
dialog.showErrorBox(t('error.startupFailed', safeLang()), msg);
setTimeout(() => app.quit(), QUIT_AFTER_ERROR_MS);
});
app.on('activate', () => {
// macOS:点击 dock 图标但所有窗口已关时,新建一个。createWindow 抛错
// (display 不可用 / renderer dir 校验失败)时不能让用户面对空白桌面
// 无声卡住 —— dialog 弹错后返回,等用户再点。
if (BrowserWindow.getAllWindows().length === 0) {
try {
createWindow(safeLang());
} catch (err) {
log.error('createWindow on activate failed', errPayloadStr(err));
dialog.showErrorBox(t('error.windowCreateFailed', safeLang()), errPayloadStr(err));
}
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
let cleanedUp = false;
app.on('before-quit', (event) => {
// audit C3R34 (2026-08-22):peer service 统一在 quit 时停 —— chokidar watcher、
// reloadTimer、AtomicFile.writeQueue 不再 leak 到 process exit。
// 顺序关键:先停 watcher(停止 emit 'change' → 停止触发 DB upsert /
// collection/notes 写入),再 await 各 service 的 .stop()(等 in-flight
// writeLock + writeQueue flush 完成),最后 closeDB() 触发 WAL checkpoint,
// 避免 checkpoint 与 pending writes 竞争。
// before-quit 默认同步 handler —— 直接 `void .stop()` + closeDB() 会让
// process 在 writeQueue flush 完成前就 exit,AtomicFile 待写数据丢失。
// 标准 Electron 模式:preventDefault → 异步清理 → cleanedUp 守卫重入 → app.quit() 二次触发。
if (cleanedUp) return;
event.preventDefault();
cleanedUp = true;
Promise.allSettled([
notesService.stop(),
// audit-r136:literature service stop —— 与 notes 同款,先停 watcher
// (停止 emit 'change' → 停止触发 DB upsert)再 await .stop()(等
// in-flight writeLock + watcher 异步 close 完成),最后 closeDB。
// literatureService.stop 内 writeLock.drain() 防止在途的 reload
// 落到内存后被 byId.clear 抹掉。
literatureService.stop(),
getFavoritesServiceSafe()?.stop(),
]).finally(() => {
// audit 2026-08-26 (round 16):先停 wal checkpoint timer 再 closeDB —
// 关闭 db 之后 timer 触发会因 !db 守卫直接 return,但停掉省一次 setter 跳变。
stopWalCheckpointTimer();
// D1-015: 显式关闭 SQLite 触发 WAL checkpoint,
// 避免 -wal / -shm 孤儿文件累积
closeDB();
app.quit();
});
});

44
src/main/ipc/bootstrap.ts Normal file
View File

@@ -0,0 +1,44 @@
// app:bootstrap IPC handler
//
// perf(2026-08-22):把启动期两次串行 IPC(SETTINGS_GET + WORKSPACE_GET_PATHS)
// 合并为单次 APP_BOOTSTRAP,返回 { settings, paths }。
//
// 为什么这样合并(audit-r124 时态校正:中间两条描述的是合并前的旧路径,
// 非现状 —— 触发链早已换代,勿当活机制读):
// - settings 与 paths 都派生自 settings,语义高度耦合
// - 旧路径(2026-08-22 前):渲染层订阅 LIBRARY_CHANGED(r56 已随库域
// 退役删除,useSyncStores 现订 favorites/startup/toast 等)→ useEffect
// 先后触发 useSettingsStore.load + usePathsStore.load,两次 IPC 间存在
// "settings 已知但 paths 未知"的中间渲染态(主区按钮 disabled)
// - 合并后两者原子到达,UI 没有中间降级态
// - 单 roundtrip 省 ~2-5ms Windows IPC overhead
//
// 不会替代以下独立 IPC:
// - SETTINGS_GET — 后续 settings 变化时仍用 getSettings 单读
// - WORKSPACE_GET_PATHS — 单独测试覆盖此路径(workspace-get-paths.test.ts)
// - settings 改后 subscribePathsReload() 仍用 getWorkspacePaths
//
// audit r50 (2026-10-15):走 handleZod<Req, Res>(...) 边界 Zod 校验 ——
// 入参是 z.void()(APP_BOOTSTRAP 不收参数),返回 { settings, paths }
// 走 ZodAppSettings + ZodWorkspacePaths,renderer 加新字段会立刻
// 「response schema mismatch」挡掉,主进程悄悄改返回结构影响不到
// renderer。**这是 r50 第一个**走新签名的 channel,作为「works」的
// end-to-end 证据;原注「后续 r52/r53 通道迁移会照此模板」是计划态,
// **并未兑现**(audit-r124 实测:全仓 handleZod 调用点至今**仅 1 个** ——
// 本 channel;其余 28 个 handle 站点仍走旧 shim;「r52/r53」实际落在
// limits 收敛等别的面),同口径见 schemas/ipc.ts 头注。
// audit-r229 更正:r124 当时记的是「2 个」(本 channel + workspace 收藏
// getState),后者的通道 FAVORITES_GET_STATE 已在 r211 整对下线 ⇒ 现在
// **仅剩 1 个**。
import { IPC } from '../../shared/types';
import { handleZod } from './wrap';
import { ZodBootstrapReq, ZodBootstrapRes } from '../../shared/schemas/ipc';
import { getSettings } from '../settings/store';
import { buildWorkspacePaths } from './paths';
export function registerBootstrapIPC(): void {
handleZod(IPC.APP_BOOTSTRAP, ZodBootstrapReq, ZodBootstrapRes, async () => ({
settings: getSettings(),
paths: buildWorkspacePaths(),
}));
}

37
src/main/ipc/broadcast.ts Normal file
View File

@@ -0,0 +1,37 @@
// 渲染层广播 helper:把 IPC 消息推到所有活跃 BrowserWindow
//
// 设计:单一入口避免 3+ 处 inline 循环漂移(`workspace.ts`、`notes.ts`、
// `library.ts` 等都做过同样 `BrowserWindow.getAllWindows() / filter
// !isDestroyed / send(channel)` 的样板)。任何"通知所有窗口刷新"场景都走这里。
//
// audit HIGH-2 (2026-08-23):3 处重复实现统一到一个 helper。
// 现状(2026-09-08 audit):原列的 trash.ts / workspace.ts 推 push* 已分别于
// 2026-08-27 (trash 整体下线) 与 2026-08-28 (workspace → favorites 重构)
// 走完迁移,目前 push* 实际由 favorites / notes / library 三处承担,
// 全部统一到本 helper。
import { BrowserWindow } from 'electron';
import { createLogger } from '../utils/logger';
import { errMsg } from '../utils/error';
const log = createLogger('broadcast');
/**
* 把 channel 推到所有未销毁 BrowserWindow 的 webContents。
* 静默忽略任何 send 抛错 —— 边界情况(窗口关闭竞态)不影响其它窗口的广播。
*/
export function broadcastToAllWindows(channel: string, payload?: unknown): void {
for (const w of BrowserWindow.getAllWindows()) {
if (w.isDestroyed()) continue;
try {
w.webContents.send(channel, payload);
} catch (err: unknown) {
// 窗口可能在 send 期间被关闭,忽略。但降级 log 记录 send 失败的具体原因
// —— 偶发 race 让 window teardown 期间 send 抛"Object has been destroyed",
// 真错误(序列化失败)被吞会很麻烦。audit 2026-09-14 改 warn。
log.warn('webContents.send failed (window may be tearing down)', {
channel,
err: errMsg(err),
});
}
}
}

356
src/main/ipc/folders.ts Normal file
View File

@@ -0,0 +1,356 @@
// src/main/ipc/folders.ts
//
// r161+ disk-folder:侧栏 VS Code 式文件管理走真实磁盘目录,不再有 DB 虚拟
// folders 表。8 通道两两对称服务 notes / literature 两棵 kind 树:
//
// list-dirs(parentRelPath|null) -> DiskFolderNode[]
// 一层直接子目录(非递归,深度无限 deep-walk 留给 caller 显式逐层 enter)
// dot 隐藏 / symlink / 非目录全部跳过;按名字自然序升序
//
// mkdir(relPath) -> { relPath }
// 在 kind 树下创建完整 relPath(可含 '/',自动递归父目录);
// 路径已存在且是目录 → no-op 幂等
//
// rename-dir(oldRelPath, newName) -> { relPath }
// 单段 rename —— 只换最后一段 basename,跨级不支持(跨级先 mkdir 目标)
// 同级同名拒绝
//
// trash-dir(relPath) -> null
// 整棵子树走 shell.trashItem(可恢复);symlink 拒收
//
// 所有路径必须落在 <rootPath>/<kind>/ 子树内,safe-write 守门越界 reject
import { join } from 'path';
import { promises as fs } from 'fs';
import { IPC, WORKSPACE_DATA_LAYOUT } from '../../shared/types';
import { handle, requireString } from './wrap';
import { MAX_IPC_PATH_CHARS } from './path-limits';
import { ValidationError, AppError, errMsg } from '../utils/error';
import { tr } from '../i18n';
import { createLogger } from '../utils/logger';
import { getRootPath, safeMkdir, safeRecursiveTrash, listSubdirs } from '../utils/safe-write';
import { validateRelPath, validateFolderName } from '@shared/rel-path';
import { remapFavoriteIds, type FavoriteIdRemap } from '../workspace/favorites';
import type { DiskFolderNode, FolderMoveResult, ResourceKind } from '@shared/types';
import type { NotesService } from '../notes/service';
import type { LiteratureService } from '../literature/literature-service';
const log = createLogger('folders-ipc');
// r186:relPath 校验收成**一份** —— shared/rel-path.ts。
// 旧版本地三份规则互相不一致(本文件的段正则拒收「首字符是空白」,
// notes/literature 两个 service 却放行),后果是「应用能往一个自己不
// 承认存在的夹里搬数据」:磁盘上名字以空格开头的夹,侧栏列得出来却
// 点不进去(本文件拒收),而笔记/文献的 move 放行 → 笔记搬得进去、
// 界面找不到。详见 shared/rel-path.ts 头注。
//
// 本文件只做「错误类型 + 消息前缀」的翻译,判定逻辑全部委托给 shared。
function requireKind(channel: string, value: unknown): ResourceKind {
if (value !== 'note' && value !== 'literature') {
throw new ValidationError(`${channel}: field "kind" must be 'note' | 'literature'`, 'kind');
}
return value;
}
/**
* 校验 IPC 传入的 relPath 是「<rootPath>/<kind>/ 下」的合法 POSIX 相对路径。
* 拒收:绝对路径、含 .. 越界、空字符串作 null 时本函数不调用(null 走专门 path)
* 接收:'foo' / 'foo/bar'(POSIX 风格,win32 也走 '/')
* 校验失败抛 VALIDATION + field='relPath'。
*/
export function requireRelPath(channel: string, value: unknown, field: string): string {
const s = requireString(channel, value, field, { maxLen: MAX_IPC_PATH_CHARS });
const problem = validateRelPath(s);
if (problem) {
throw new ValidationError(`${channel}: field "${field}" ${problem.message}`, field);
}
return s;
}
function requireNullableRelPath(channel: string, value: unknown, field: string): string | null {
if (value === null || value === undefined) return null;
return requireRelPath(channel, value, field);
}
export function requireFolderName(channel: string, value: unknown, field: string): string {
const s = requireString(channel, value, field, { maxLen: MAX_IPC_PATH_CHARS });
// 单段 name 与 relPath 里的段走**同一份**核心规则,再叠加创建策略
// (段长 ≤ 100)—— 否则会出现「能用 name 建的夹,不能用 path 进」的不变量
// I1 被破坏(旧实现:FOLDER_NAME_RE 放行 'a..b',requireRelPath 却拒收)。
const problem = validateFolderName(s);
if (problem) {
throw new ValidationError(`${channel}: field "${field}" ${problem.message}`, field);
}
return s;
}
/**
* mkdir 的 relPath —— 整条走路径规则(核心),**叶子段**额外走创建策略
* (名字 ≤ 100 字符):叶子才是这次真正新建的那个夹的名字。
*
* 父段只按路径规则(不限长度):它们多半是磁盘上**已存在**的目录
* (用户在资源管理器里建的、可能超长),不该因此拒绝「在里面建子夹」。
*/
export function requireNewFolderRelPath(
channel: string,
value: unknown,
field: string,
): string {
const s = requireRelPath(channel, value, field);
const leaf = s.slice(s.lastIndexOf('/') + 1);
const problem = validateFolderName(leaf);
if (problem) {
throw new ValidationError(`${channel}: field "${field}" ${problem.message}`, field);
}
return s;
}
/**
* 目标**父目录** relPath 校验 —— 与 requireRelPath 的区别:根目录用空串
* 表示(不是 null),且空串是合法值(不代表「段非法」)。
* null / undefined / '' → '' (根)
* 'foo' / 'foo/bar' → 原样
* '..' / 绝对路径 / 空段 / '.' 开头段 → VALIDATION
*/
export function requireParentRelPath(channel: string, value: unknown, field: string): string {
if (value === null || value === undefined || value === '') return '';
if (typeof value !== 'string') {
throw new ValidationError(`${channel}: field "${field}" must be a string`, field);
}
const problem = validateRelPath(value, { allowRoot: true });
if (problem) {
throw new ValidationError(`${channel}: field "${field}" ${problem.message}`, field);
}
return value;
}
/**
* r189:文件夹(子树)改名 / 跨目录搬移后,把子树里每个资源的收藏(★)跟着走。
*
* r182 只接了**单文件**的改名 / 搬移(见 ipc/notes.ts、ipc/literature.ts),
* 文件夹层的接线被 r182 的提交说明显式推迟(r183 的 folders.ts 合流后再补),
* 一直没补 ⇒ 用户可复现的 bug:
* 1. ★ 一个文件夹里的文献;
* 2. 右键该文件夹 → 重命名(或剪切 → 粘贴到别的目录);
* 3. 文件夹在树上改名/搬走了,但**★ 静默消失** —— favorites.md 里还留着
* 旧 fileId,而新 id 是按路径算出来的(notes: fm.id = SHA-256(relPath)
* 前 16 位;literature: 'lit:' + POSIX 绝对路径)⇒ 旧 id 再也不会命中
* 任何一行,那条收藏变成永远点不亮的孤儿。
*
* path 字段的形态**按域不同**(写错形态 ⇒ ★ 还在但面板里的路径指向不存在的文件):
* - notes = 相对 notes 根的 relPath(notes 侧 toggle 传 relPath);
* - literature = **绝对** POSIX 路径(literature 侧 toggle 传 absPath)。
*
* best-effort:收藏改写失败**不能**让已经成功的目录操作变成失败。
*/
async function remapFavoritesForSubtree(
idRemap: FolderMoveResult['idRemap'],
kind: ResourceKind,
): Promise<void> {
if (idRemap.length === 0) return;
const items: FavoriteIdRemap[] = idRemap.map((it) => ({
oldId: it.oldId,
newId: it.newId,
path: kind === 'literature' ? it.absPath : it.relPath,
}));
try {
const n = await remapFavoriteIds(items);
if (n > 0) log.info('favorites remapped after folder change', { kind, count: n });
} catch (err: unknown) {
log.warn('favorites remap failed after folder change', { kind, err: errMsg(err) });
}
}
export function registerFoldersIPC(
notesService: NotesService,
literatureService: LiteratureService,
): void {
handle<[unknown, unknown], DiskFolderNode[]>(
IPC.NOTES_FOLDERS_LIST_DIRS,
async (_e, kindRaw, parentRelRaw) => {
const kind = requireKind(IPC.NOTES_FOLDERS_LIST_DIRS, kindRaw);
const parentRel = requireNullableRelPath(
IPC.NOTES_FOLDERS_LIST_DIRS,
parentRelRaw,
'parentRelPath',
);
return listSubdirs(getRootPath(), kind, parentRel);
},
);
handle<[unknown], { relPath: string }>(
IPC.NOTES_FOLDERS_MKDIR,
async (_e, relRaw) => {
const relPath = requireNewFolderRelPath(IPC.NOTES_FOLDERS_MKDIR, relRaw, 'relPath');
// notes 域在 <rootPath>/notes/<relPath> 建 —— 显式拼绝对路径
const abs = join(getRootPath(), WORKSPACE_DATA_LAYOUT.NOTES_DIR, relPath);
await safeMkdir(abs);
return { relPath };
},
);
// r175:重命名文件夹 —— 走 service.renameDir,不再裸 fs.rename。
//
// 旧实现(本 handler 内联 fs.rename)只搬目录,**不重钉子树里每个笔记的
// frontmatter id、不更新 DB 的 rel_path**。笔记 id 与路径强绑定
// (fm.id = SHA-256(relPath) 前 16 位)⇒ 重命名目录后 id 全变而
// byId / 文件 / DB 三处指向不同 id ⇒ 打开刚改名的笔记必 NOT_FOUND,
// 且下一次写盘会重烧 id 令 renderer 手里那个 id 变死 id(之后的保存
// 全部 NOT_FOUND,用户新敲的字困在草稿里,重启即丢)。
// service.renameDir 负责「物理搬家 + 逐个重钉 id + DB rename +
// byId 换键」,并返回 idRemap 让 renderer 把选中态跟到新 id。
handle<[unknown, unknown], FolderMoveResult>(
IPC.NOTES_FOLDERS_RENAME_DIR,
async (_e, oldRelRaw, newNameRaw) => {
const oldRelPath = requireRelPath(IPC.NOTES_FOLDERS_RENAME_DIR, oldRelRaw, 'oldRelPath');
const newName = requireFolderName(IPC.NOTES_FOLDERS_RENAME_DIR, newNameRaw, 'newName');
const rootPath = getRootPath();
const oldAbs = join(rootPath, WORKSPACE_DATA_LAYOUT.NOTES_DIR, oldRelPath);
// 同级重名检测:目标 basename 在父目录下不存在
const parentAbs = join(oldAbs, '..');
try {
const entries = await fs.readdir(parentAbs, { withFileTypes: true });
if (entries.some((e) => e.isDirectory() && e.name === newName)) {
throw new AppError('DUPLICATE_NAME', tr('diskFolder.duplicateName', { name: newName }));
}
} catch (err: unknown) {
if (err instanceof AppError) throw err;
if ((err as { code?: string }).code !== 'ENOENT') throw err;
}
const newRel = oldRelPath.includes('/')
? oldRelPath.slice(0, oldRelPath.lastIndexOf('/') + 1) + newName
: newName;
const renamed = await notesService.renameDir(oldRelPath, newRel);
await remapFavoritesForSubtree(renamed.idRemap, 'note');
log.info('notes folder renamed', { oldRelPath, relPath: renamed.relPath });
return renamed;
},
);
handle<[unknown], null>(
IPC.NOTES_FOLDERS_TRASH_DIR,
async (_e, relRaw) => {
const relPath = requireRelPath(IPC.NOTES_FOLDERS_TRASH_DIR, relRaw, 'relPath');
const abs = join(getRootPath(), WORKSPACE_DATA_LAYOUT.NOTES_DIR, relPath);
await safeRecursiveTrash(abs);
log.info('notes folder trashed', { relPath });
return null;
},
);
// ---- literature 域对称 ----
handle<[unknown, unknown], DiskFolderNode[]>(
IPC.LITERATURE_FOLDERS_LIST_DIRS,
async (_e, kindRaw, parentRelRaw) => {
const kind = requireKind(IPC.LITERATURE_FOLDERS_LIST_DIRS, kindRaw);
const parentRel = requireNullableRelPath(
IPC.LITERATURE_FOLDERS_LIST_DIRS,
parentRelRaw,
'parentRelPath',
);
return listSubdirs(getRootPath(), kind, parentRel);
},
);
handle<[unknown], { relPath: string }>(
IPC.LITERATURE_FOLDERS_MKDIR,
async (_e, relRaw) => {
const relPath = requireNewFolderRelPath(IPC.LITERATURE_FOLDERS_MKDIR, relRaw, 'relPath');
const abs = join(getRootPath(), WORKSPACE_DATA_LAYOUT.LITERATURE_DIR, relPath);
await safeMkdir(abs);
return { relPath };
},
);
// r175:文献域对称 —— 走 literatureService.renameDir(见上方 notes 版注释)
// 文献 id = 'lit:'+toPosix(absPath),目录改名 ⇒ 子树里每个文献的
// absPath 变 ⇒ id 变;旧实现裸 fs.rename 后 byId / DB / 磁盘三处
// 脱节,renderer 手里选中态指向已不存在的路径。
handle<[unknown, unknown], FolderMoveResult>(
IPC.LITERATURE_FOLDERS_RENAME_DIR,
async (_e, oldRelRaw, newNameRaw) => {
const oldRelPath = requireRelPath(IPC.LITERATURE_FOLDERS_RENAME_DIR, oldRelRaw, 'oldRelPath');
const newName = requireFolderName(IPC.LITERATURE_FOLDERS_RENAME_DIR, newNameRaw, 'newName');
const rootPath = getRootPath();
const oldAbs = join(rootPath, WORKSPACE_DATA_LAYOUT.LITERATURE_DIR, oldRelPath);
const parentAbs = join(oldAbs, '..');
try {
const entries = await fs.readdir(parentAbs, { withFileTypes: true });
if (entries.some((e) => e.isDirectory() && e.name === newName)) {
throw new AppError('DUPLICATE_NAME', tr('diskFolder.duplicateName', { name: newName }));
}
} catch (err: unknown) {
if (err instanceof AppError) throw err;
if ((err as { code?: string }).code !== 'ENOENT') throw err;
}
const newRel = oldRelPath.includes('/')
? oldRelPath.slice(0, oldRelPath.lastIndexOf('/') + 1) + newName
: newName;
const renamed = await literatureService.renameDir(oldRelPath, newRel);
await remapFavoritesForSubtree(renamed.idRemap, 'literature');
log.info('literature folder renamed', { oldRelPath, relPath: renamed.relPath });
return renamed;
},
);
handle<[unknown], null>(
IPC.LITERATURE_FOLDERS_TRASH_DIR,
async (_e, relRaw) => {
const relPath = requireRelPath(IPC.LITERATURE_FOLDERS_TRASH_DIR, relRaw, 'relPath');
const abs = join(getRootPath(), WORKSPACE_DATA_LAYOUT.LITERATURE_DIR, relPath);
await safeRecursiveTrash(abs);
log.info('literature folder trashed', { relPath });
return null;
},
);
// ---- r173:目录(子树)跨父目录搬移 ----
//
// 场景:侧栏「剪切文件夹 → 粘贴到另一个目录」「把文件夹拖到别的目录」
// 「移到根目录」。此前 renderer 侧的 pasteFolderInto / moveFolderNow
// 全是 throw「not yet supported」的占位,文件夹拖拽的 dataTransfer
// payload 又因 ids: [] 被 readMovePayload 拒收 —— 三条路径全断。
//
// 与单文件 move(IPC.NOTES_MOVE / IPC.LITERATURE_MOVE)的差别:搬的是
// 整棵子树,里面每个资源 id 都变(notes id 随 relPath、lit id 随
// absPath),所以返回 idRemap 让 renderer 把选中态跟过去。
handle<[unknown, unknown], FolderMoveResult>(
IPC.NOTES_FOLDERS_MOVE_DIR,
async (_e, oldRelRaw, newParentRelRaw) => {
const oldRelPath = requireRelPath(
IPC.NOTES_FOLDERS_MOVE_DIR,
oldRelRaw,
'oldRelPath',
);
const newParentRelPath = requireParentRelPath(
IPC.NOTES_FOLDERS_MOVE_DIR,
newParentRelRaw,
'newParentRelPath',
);
const moved = await notesService.moveDir(oldRelPath, newParentRelPath);
await remapFavoritesForSubtree(moved.idRemap, 'note');
log.info('notes folder moved', { oldRelPath, relPath: moved.relPath });
return moved;
},
);
handle<[unknown, unknown], FolderMoveResult>(
IPC.LITERATURE_FOLDERS_MOVE_DIR,
async (_e, oldRelRaw, newParentRelRaw) => {
const oldRelPath = requireRelPath(
IPC.LITERATURE_FOLDERS_MOVE_DIR,
oldRelRaw,
'oldRelPath',
);
const newParentRelPath = requireParentRelPath(
IPC.LITERATURE_FOLDERS_MOVE_DIR,
newParentRelRaw,
'newParentRelPath',
);
const moved = await literatureService.moveDir(oldRelPath, newParentRelPath);
await remapFavoritesForSubtree(moved.idRemap, 'literature');
log.info('literature folder moved', { oldRelPath, relPath: moved.relPath });
return moved;
},
);
}

41
src/main/ipc/index.ts Normal file
View File

@@ -0,0 +1,41 @@
// IPC 注册汇总
import { registerSettingsIPC } from './settings';
import { registerLibraryIPC } from './library';
// audit-r71:文献功能重建 —— literature:list / import-from-file / delete 三通道。
// audit-r136:加 get-state + LiteratureService 注入(由 registerAllIPC 传单例)。
import { registerLiteratureIPC } from './literature';
import { registerNotesIPC } from './notes';
// 0007 虚拟文件夹:侧栏文件管理组织层(新建/重命名/删除/移动 + 资源改归属)
import { registerFoldersIPC } from './folders';
// 重构 (2026-09):registerAIIPC 已删除 —— AI 模块整体下线,
// 不再需要 IPC.AI_* 入口。
import { registerSearchIPC } from './search';
// 重构 (2026-08-28):registerCollectionsIPC 已删除 —— 分组并入 favorites;
// favorites IPC 在 registerWorkspaceIPC 内注册(list / toggle / get-state +
// changed push)。原「group 系列」invoke 已随 V1.4 分组只读化下线(audit-r73 注)。
import { registerWorkspaceIPC } from './workspace';
import { registerShellIPC } from './shell';
import { registerStartupIPC } from './startup';
import { registerBootstrapIPC } from './bootstrap';
import { registerWindowIPC } from './window';
import { getNotesService } from '../notes/service';
// audit-r136:literature service 注入 —— 与 notes 同款「module-level single,
// ipc 收参 + 写路径共用」。register 阶段拿单例引用(还没 start),start 在
// bootstrap 的 Promise.allSettled 阶段调。
import { getLiteratureService } from '../literature/literature-service';
export function registerAllIPC() {
registerSettingsIPC();
registerLibraryIPC();
registerLiteratureIPC(getLiteratureService());
registerNotesIPC(getNotesService());
registerFoldersIPC(getNotesService(), getLiteratureService());
registerSearchIPC();
registerWorkspaceIPC();
registerShellIPC();
registerStartupIPC();
// perf(2026-08-22):启动期合并 IPC — 见 bootstrap.ts 注释
registerBootstrapIPC();
// 自定义无边框窗口控制(2026-09):最小化/最大化/关闭按钮的 IPC 入口。
registerWindowIPC();
}

74
src/main/ipc/library.ts Normal file
View File

@@ -0,0 +1,74 @@
// src/main/ipc/library.ts
//
// IPC.LIBRARY_READER_FOR_FILE handler — 整个 library 频道表面退役后剩下的
// 唯一一条 channel,名字沿用避免 ipc 字符串散落。handler 内部走 library/reader.ts
// 的 readFileForReader(path)。
//
// r191:**读白名单** —— 本 handler 此前只查「是不是绝对路径」,不查「是不是
// 工作区内的文件」⇒ renderer 拿到任意绝对路径就能把字节读回来。
// 同威胁模型的两条兄弟路径**都有**白名单,唯独这条漏了:
// - rkms:// 协议(protocol.ts)→ assertInsideAllowedRoot → 403
// (注释原文:「防止 renderer 拿到任意路径都能读(iframe 内
// fetch().text() 可外泄 ~/.ssh/id_rsa)」)
// - shell.open-path(ipc/shell.ts)→ assertInsideAllowedRoot → EPERM
// ⇒ 读侧比 iframe 侧**更松**。protocol.ts 的 import 注释写着
// 「protocol.ts / shell.ts / reader.ts 三处独立写…」—— 以为 reader.ts
// 也在用同一份 assertInsideAllowedRoot,实际 reader.ts 根本没接;
// 这条注释漂移正是本漏洞的来源。
//
// 修法:与 shell.ts / protocol.ts 同款,在 handler 里过
// assertInsideAllowedRoot(工作区根 + symlink / realpath 链防线)。
//
// 为什么不误伤正常用户:本通道的唯一生产调用点是
// renderer/Reader.tsx 的 `api.readFileForReader(file.absPath)`,
// file 是 literature store 的一行 —— 文献导入是**拷贝**进
// <rootPath>/literature/(literature/import.ts),所以 absPath 恒在
// 工作区内。OS 文件选择器(<input type="file">)只用于**导入**,
// 不用于读;preload 里「路径来自 webUtils.getPathForFile」的旧注释
// 已不成立(见 preload/index.ts 同名注释的 r191 更正)。
import { handle, requireString } from './wrap';
import { IPC } from '../../shared/types';
import { readFileForReader } from '../library/reader';
import { MAX_IPC_PATH_CHARS, requireAbsolutePath } from './path-limits';
import { assertInsideAllowedRoot } from './paths';
import { AppError, errMsg } from '../utils/error';
import { createLogger } from '../utils/logger';
import type { ReadFileForReaderResult } from '../../shared/types';
const log = createLogger('library-ipc');
export function registerLibraryIPC(): void {
handle<[string], ReadFileForReaderResult>(
IPC.LIBRARY_READER_FOR_FILE,
async (_event, raw) => {
const absPath = requireString(IPC.LIBRARY_READER_FOR_FILE, raw, 'absPath', {
maxLen: MAX_IPC_PATH_CHARS,
});
// audit-r137:与 notes/literature/settings 同源 — 防御 renderer 注入
// 相对路径(原 bug:requireString 只守 type / 长度,fs.open 按
// process.cwd() 解析,renderer 注入 '../etc/passwd' 也能拿到 bytes)。
requireAbsolutePath(absPath, { ipc: IPC.LIBRARY_READER_FOR_FILE, field: 'absPath' });
// r191:工作区白名单(绝对路径 ≠ 工作区内)—— 挡住 renderer 读工作区
// 外的任意文件。区外 / symlink 逃逸一律 EPERM。
// 错误消息**不回显路径**:renderer 不需要知道文件系统布局,main 侧
// 日志留全路径即可(区分「路径不在工作区」与「工作区还没初始化」)。
let abs: string;
try {
abs = assertInsideAllowedRoot(absPath);
} catch (err: unknown) {
const code = err instanceof AppError ? err.code : 'INTERNAL';
log.warn('reader path rejected by workspace whitelist', { code, absPath });
// audit-r244:INTERNAL(工作区还没初始化 —— getRootPath() 未 setRootPath)
// **≠**「路径在工作区外」。旧实现把**所有**失败一律翻成 EPERM
// 「file is outside the workspace」⇒ renderer 把「工作区未初始化」
// 误报成「越界访问」,排查方向完全跑偏(用户看到的提示是假的)。
// INTERNAL 走原样上抛(wrap.ts 的 INTERNAL 分支会 scrub 路径)。
if (code === 'INTERNAL') {
throw err instanceof AppError ? err : new AppError('INTERNAL', errMsg(err), err);
}
throw new AppError('EPERM', 'file is outside the workspace');
}
return readFileForReader(abs);
},
);
}

178
src/main/ipc/literature.ts Normal file
View File

@@ -0,0 +1,178 @@
// 文献模块 IPC(audit-r71)—— resources 单表上的操作四通道:
// literature:list / literature:import-from-file / literature:delete /
// literature:get-state
//
// r187:加 literature:rename-file —— 文献**单文件**重命名(与 notes 域的
// notes:rename-file 对称)。此前 literature 域只能搬移、不能改名,两侧
// 操作集不对应(笔记能改名 / 文献不能),用户诉求「相关的文件操作也是
// 对应的」在这一条上不成立。
//
// domain 用 literature 不用 library:library:* 操作面在 r56 已整体退役,
// ipc-handlers smoke 锁死 library 域恰 1 个 handler(readFileForReader)且
// 退役通道不得复活(library:list 等 not.toContain)。readFileForReader 继续
// 归 registerLibraryIPC,文献行点开阅读的数据流 = list 拿 absPath →
// Reader 走 readFileForReader(bytes 类)或 rkms://(PDF/图片)。
//
// audit-r136:接入 LiteratureService —— list / GET_STATE 直返 svc.list()
// (byId 全量按 mtime 倒序),背后是 chokidar 监听 <rootPath>/literature/
// + reload 内 active disk↔DB diff(磁盘有 DB 无 → upsert filename-only 行,
// DB 有磁盘无 → 删 ghost 行)。push 通道 LITERATURE_CHANGED 由主进程
// index.ts 单独订阅 svc 'changed' 事件 + broadcastToAllWindows 桥接,
// 此文件不持推送逻辑(单一职责:IPC handler 落点)。GET_STATE 是 r54 I4
// pull-snapshot 配对契约(防「主进程 push 已先于 renderer listener」竞态),
// 契约由 pull-snapshot-companion.test.ts 锁住。
import type { LiteratureImportResult, LiteratureRenameResult } from '@shared/api-types';
import { IPC, type LiteratureResource, type ResourceId } from '../../shared/types';
import { handle, requireString } from './wrap';
import { MAX_IPC_PATH_CHARS, requireAbsolutePath } from './path-limits';
import type { LiteratureService } from '../literature/literature-service';
import { remapFavoriteIds, removeFavoriteIds } from '../workspace/favorites';
import { errMsg } from '../utils/error';
import { createLogger } from '../utils/logger';
const log = createLogger('ipc-literature');
/**
* r182:文献 id 变更(搬进/搬出文件夹)后,把收藏条目的 fileId 跟着改 ——
* 否则用户的 ★ 在操作成功的瞬间静默消失。
* best-effort:失败不影响已经成功的文件操作。
*/
async function remapFavoritesForLiterature(item: {
oldId: string;
newId: string;
path?: string;
}): Promise<void> {
try {
const n = await remapFavoriteIds([item]);
if (n > 0) log.info('favorites remapped after literature move', { count: n });
} catch (err: unknown) {
log.warn('favorites remap failed after literature move', { err: errMsg(err) });
}
}
/**
* r198:文献被删除(移入回收站)后,清掉它的收藏条目。
* 文献 id = 'lit:'+toPosix(absPath) ⇒ 同一个文件从原路径再导入一次会
* **复用同一个 id** ⇒ 不清掉孤儿条目的话,新行会继承旧 ★(用户没点过
* 却显示已收藏),再点一下反而是「取消收藏」。
* best-effort:收藏清理失败**不能**让已经成功的删除变成失败。
*/
async function removeFavoritesForLiterature(id: string): Promise<void> {
try {
const n = await removeFavoriteIds([id]);
if (n > 0) log.info('favorites removed after literature delete', { count: n });
} catch (err: unknown) {
log.warn('favorites cleanup failed after literature delete', { err: errMsg(err) });
}
}
export function registerLiteratureIPC(svc: LiteratureService): void {
// audit-r136:走 svc.list() 而不是 listResources —— service 的 byId 反映
// 最近一次 disk↔DB diff 状态(含 OS 拖入 / 外部删的 row);repo 读的是
// DB 持久层,两路径最终收敛同一形态,但 svc 路径省一次 SQL,且 byId 已
// 按 mtime 倒序排好,renderer 拿到不用再排。
handle<[], LiteratureResource[]>(IPC.LITERATURE_LIST, () => svc.list());
// audit-r211:配对的 pull 通道 LITERATURE_GET_STATE 整对下线 —— 它与
// LITERATURE_LIST 同源同形(都是 svc.list()),而 renderer 从来没调用过它
// (src/renderer/ 全域零调用点)。「push 先于 listener 注册」的竞态由
// 「先订阅再 load」覆盖(store 模块顶层订阅 → 组件 useEffect 里首次 load)。
handle<[string], LiteratureImportResult>(IPC.LITERATURE_IMPORT_FROM_FILE, async (_event, raw) => {
// 源路径在 rootPath 外也必须放行 —— 它本来就是 OS 拖入的外部文件,
// requireAbsolutePath 只挡相对路径注入(audit-r64 pickLibraryPath EPERM
// 回归的教训:导入边界不套 allowed-root)。
const sourcePath = requireString(IPC.LITERATURE_IMPORT_FROM_FILE, raw, 'path', {
maxLen: MAX_IPC_PATH_CHARS,
});
requireAbsolutePath(sourcePath, {
ipc: IPC.LITERATURE_IMPORT_FROM_FILE,
field: 'path',
});
// audit-r170:导入收进 service —— 旧实现在 handler 里直调
// importLiteratureFile + upsertResource,**没更新 svc.byId**,于是
// 「导入后立刻搬进文件夹」(复制粘贴 / OS 拖入两条路径)必然 NOT_FOUND,
// 文件停在 literature 根。svc.importFile 把 DB 行与 byId 在同一次
// writeLock 内落地,搬移立刻可见。
const imported = await svc.importFile(sourcePath);
return imported;
});
handle<[string], null>(IPC.LITERATURE_DELETE, async (_event, raw) => {
// 上限用 MAX_IPC_PATH_CHARS 而非 MAX_LIBRARY_ID_CHARS(64):id 的 raw 段
// 就是完整路径,64 字符会把正常深路径条目误杀。
const id = requireString(IPC.LITERATURE_DELETE, raw, 'id', {
maxLen: MAX_IPC_PATH_CHARS,
}) as ResourceId;
// audit-r160:走 svc.remove(id) 而不是裸 fs.unlink + deleteResource
// —— 旧实现绕开 LiteratureService.writeLock,文件 + DB 删了 byId
// 仍含 entry,chokidar 'unlink' 触发 scheduleReload + reload 走
// ~50ms 间,IPC.LITERATURE_LIST 走 byId 返回已删文件,UI 短暂闪老条目。
// svc.remove 内部三件(file / DB / byId)同 writeLock 内顺序,emit
// 'changed' 推 renderer 拉新状态。note id 注入由 svc.remove 内部
// byId.get 失败抛 NOT_FOUND 统一兜底(注:db rows kind 过滤不再做
// —— byId 仅含 lit,kind 检查是冗余)。
await svc.remove(id);
// r198:文件已进回收站 ⇒ 收藏条目跟着清(否则同路径再导入会继承旧 ★)
await removeFavoritesForLiterature(id);
return null;
});
// r161+ disk-folder Phase 2b:跨文件夹移动文献(id, targetRelPath) →
// { absPath, id }。targetRelPath 是「目标目录」相对路径('' = 根,
// 'foo', 'foo/bar'),不含文件名 —— svc.moveFile 内部拼 newAbsPath =
// `<rootPath>/literature/${targetRelPath}/${basename(oldAbsPath)}`。
//
// 边界由 svc.moveFile 承担(targetRelPath 段校验 + 越界 reject + 自动
// mkdir 父 + EEXIST → VALIDATION + ENOENT → NOT_FOUND),handler 只做
// 参数校验 + 透传。
handle<[string, string], { absPath: string; id: ResourceId }>(
IPC.LITERATURE_MOVE,
async (_e, idRaw, targetRelPathRaw) => {
const id = requireString(IPC.LITERATURE_MOVE, idRaw, 'id', {
maxLen: MAX_IPC_PATH_CHARS,
}) as ResourceId;
const targetRelPath = requireString(IPC.LITERATURE_MOVE, targetRelPathRaw, 'targetRelPath', {
maxLen: MAX_IPC_PATH_CHARS,
});
const moved = await svc.moveFile(id, targetRelPath);
// r182:文献 id = 'lit:'+toPosix(absPath) ⇒ 搬一次 id 就变 ⇒ 收藏条目
// 的 fileId 变孤儿(★ 静默消失)。这里把收藏跟着改;path 用新的
// absPath(literature 侧 renderer 存的就是绝对路径)。
await remapFavoritesForLiterature({
oldId: id,
newId: moved.id,
path: moved.absPath,
});
return { absPath: moved.absPath, id: moved.id };
},
);
// r187:文献单文件重命名(与 notes:rename-file 对称)——
// 只改文件名,保留所在目录。lit id = 'lit:'+toPosix(absPath) ⇒ 改名后
// id 变 ⇒ 收藏条目的 fileId 变孤儿(★ 静默消失)⇒ 同搬移路径把收藏跟过去。
handle<[string, string], LiteratureRenameResult>(
IPC.LITERATURE_RENAME_FILE,
async (_e, idRaw, newFilenameRaw) => {
const id = requireString(IPC.LITERATURE_RENAME_FILE, idRaw, 'id', {
maxLen: MAX_IPC_PATH_CHARS,
}) as ResourceId;
// 文件名不是路径 —— 长度上限给足(255 文件名 + 一点余量),但不允许
// 空串。真正的合法性(分隔符 / 保留字符 / 撞名)在 service 侧的
// planLiteratureRename 里判,错误码语义更准(VALIDATION)。
const newFilename = requireString(IPC.LITERATURE_RENAME_FILE, newFilenameRaw, 'newFilename', {
maxLen: 512,
});
const renamed = await svc.renameFile(id, newFilename);
await remapFavoritesForLiterature({
oldId: id,
newId: renamed.id,
path: renamed.absPath,
});
return {
id: renamed.id,
absPath: renamed.absPath,
oldAbsPath: renamed.oldAbsPath,
};
},
);
}

50
src/main/ipc/menu.ts Normal file
View File

@@ -0,0 +1,50 @@
// src/main/ipc/menu.ts — 主进程 chrome (菜单 / 窗口 title) 的 IPC 入口
//
// 为什么需要这个 channel:
// 切换语言时,renderer 端 react-i18next 重渲 React tree 已覆盖所有 UI 文本,
// 但主进程持有两块不在 React 树里的 chrome:
// 1. 应用菜单(Electron Menu,在 OS 顶栏/菜单栏显示)
// 2. 窗口 title(任务栏 / Dock 显示)
// 这两块只能由主进程自己重建 —— IPC.MENU_REBUILD 让 renderer 在
// i18n.changeLanguage 之后通知主进程"按当前 lang 重建"。
//
// 安全:
// IPC handler 内部只读 settings.language,无外部输入参数;
// 走 handle<[]> 的标准 wrap,不引入额外校验负担。
import { BrowserWindow } from 'electron';
import { IPC } from '../../shared/types';
import { installAppMenu } from '../menu';
import { safeLang, t } from '../i18n';
import { handle } from './wrap';
/**
* 按当前 settings.language 重建 chrome。
* 暴露给 IPC handler 调用,也可在 settings store 改动后由主进程主动调。
*
* 用 safeLang 而非 getSettings().language —— 本函数调用点有两处:
* - bootstrap 完成 startup 之后(此时 settings 已 ready,走 fast path)
* - IPC.MENU_REBUILD handler(在 createWindow 之后,settings 必然 ready)
* 但两处都依赖 settings 状态,bootstrap 内部 rebuild 紧跟 startup,理论上
* settings 一定 ready,然而拿一个 try/catch 兜底不增成本且能让函数对未来
* "先建窗口再 initSettings" 的重构天然免疫。
*/
export function rebuildChromeForLang(): void {
const lang = safeLang();
installAppMenu(() => BrowserWindow.getAllWindows()[0] ?? null, lang);
// 任务栏 / Dock 标题同步切换 —— setTitle 不影响窗口内 TitleBar 组件
// (TitleBar 显示的是 i18n 里的 titleBar.appTitle,已由 renderer i18next 处理)。
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.setTitle(t('window.title', lang));
}
}
}
/**
* 注册菜单相关 IPC。单一 IPC:renderer 切语言后调一次。
*/
export function registerMenuIPC(): void {
handle<[], void>(IPC.MENU_REBUILD, async () => {
rebuildChromeForLang();
});
}

279
src/main/ipc/notes.ts Normal file
View File

@@ -0,0 +1,279 @@
// Notes IPC handlers:NotesService 在 start() 已 chokidar 监听 + 内存索引,
// IPC 只做参数校验 + DB FTS 同步;per-file notes 已退役,source 桥接层与
// NOTES_*_FOR_SOURCE 通道全部删除。
import { IPC } from '../../shared/types';
// audit-r73:handleZod / getSettings / ZodNotesResyncProgress* import 随
// NOTES_RESCYNC_PROGRESS(_GET_STATE)死对下线移除(本文件此前唯一 handleZod
// 使用点就是那条 GET_STATE handler)。
import { handle, requireString } from './wrap';
import { getRootPath } from '../utils/safe-write';
import { assertFileExists } from '../utils/file-check';
// DB 同步走 ResourcesRepo;notes 表退役,deleteNote / renameNoteInDB / NoteRow 删除
import { deleteResource } from '../db/resources-repo';
import { noteResourceId } from '@shared/resource-id';
import { LibraryError, ValidationError, errMsg } from '../utils/error';
import { tr } from '../i18n';
import type { NotesService } from '../notes/service';
import { toNotePath } from '../notes/path-mapping';
import { createLogger } from '../utils/logger';
import {
MAX_IPC_PATH_CHARS,
MAX_NOTE_BODY_BYTES,
MAX_NOTE_FIELD_CHARS,
MAX_NOTE_TITLE_BYTES,
MAX_NOTE_TITLE_CHARS,
requireAbsolutePath,
} from './path-limits';
import { revealInOSFileManagerSafe } from '../utils/reveal';
import type { NotesImportResult } from '@shared/api-types';
import { remapFavoriteIds, removeFavoriteIds } from '../workspace/favorites';
// resources_fts 无触发器,全部写路径由 ResourcesRepo 单事务「FTS DELETE →
// 主表 upsert → FTS INSERT」承担。NOTES_WRITE / NOTES_CREATE 的 DB 上行在
// NotesService.write/create 内(修 r45 缺口:删 syncNoteToDB 后这两条不再写主表)。
const log = createLogger('ipc-notes');
/**
* r182:笔记 id 变更(改名 / 跨文件夹搬移)后,把收藏条目的 fileId 跟着改 ——
* 否则用户的 ★ 在操作成功的瞬间静默消失(favorites.md 里留着旧 id,
* UI 按 id 过滤,旧 id 再也不会命中任何一行)。
*
* best-effort:收藏改写失败**不能**让已经成功的文件操作变成失败。
*/
async function remapFavoritesForNote(item: {
oldId: string;
newId: string;
path?: string;
}): Promise<void> {
try {
const n = await remapFavoriteIds([item]);
if (n > 0) log.info('favorites remapped after note change', { count: n });
} catch (err: unknown) {
log.warn('favorites remap failed after note change', { err: errMsg(err) });
}
}
/**
* r198:笔记被移入回收站后,清掉它的收藏条目 —— 否则 favorites.md 里留着
* 一条永远命中不了的孤儿(★ 状态错位 + 死条目堆积)。
* best-effort:收藏清理失败**不能**让已经成功的删除变成失败。
*/
async function removeFavoritesForNote(id: string): Promise<void> {
try {
const n = await removeFavoriteIds([id]);
if (n > 0) log.info('favorites removed after note trash', { count: n });
} catch (err: unknown) {
log.warn('favorites cleanup failed after note trash', { err: errMsg(err) });
}
}
// parseNoteSource 已下线 —— library-file 上下文不存在后,3 个 ForSource handler
// 全部删除,parseNoteSource 失去 caller,一并清理。
export function registerNotesIPC(svc: NotesService): void {
// NOTES_LIST 走 listSummaries(不返 body),需要时按 id 走 NOTES_READ 拉单条。
// r161 disk-folder:summary 自身含 relPath,物理目录即组织层;
// renderer 端按 dirname(relPath) === currentRelPath 过滤即可,
// DB 合并层删除(folders 表已下线,folderId 概念不再存在)
handle(IPC.NOTES_LIST, async () => {
return svc.listSummaries();
});
handle(IPC.NOTES_READ, async (_e, id: string) => {
const idStr = requireString(IPC.NOTES_READ, id, 'id', { maxLen: MAX_NOTE_FIELD_CHARS });
const n = svc.findById(idStr);
if (!n) {
// NOT_FOUND 而非静默 null(NOTES_CHANGED + listener 已覆盖 rename race,
// 不需 null 兜底)
throw new LibraryError('NOT_FOUND', tr('notes.notFound', { id: idStr }));
}
return n;
});
handle(
IPC.NOTES_WRITE,
async (
_e,
id: string,
body: string,
title?: string,
// expectedMtime 透传:renderer loadFullNote 时记下 activeNoteFull.mtime,
// save 时回传,主进程比对磁盘 mtime 与期望值,不一致抛 CONFLICT(防 OneDrive /
// Vim 等外部修改被静默覆盖)。number 而非 string —— 走 Number.isFinite 校验。
// 无 expectedMtime 时保持 last-writer-wins(向后兼容老 caller)。
expectedMtime?: number,
) => {
const idStr = requireString(IPC.NOTES_WRITE, id, 'id', { maxLen: MAX_NOTE_FIELD_CHARS });
const bodyStr = requireString(IPC.NOTES_WRITE, body, 'body', {
maxBytes: MAX_NOTE_BODY_BYTES,
});
let titleStr: string | undefined;
if (title !== undefined) {
// title maxBytes 兜底 CJK / emoji UTF-8 多字节放大(200 char CJK ~600B)
titleStr = requireString(IPC.NOTES_WRITE, title, 'title', {
maxLen: MAX_NOTE_TITLE_CHARS,
maxBytes: MAX_NOTE_TITLE_BYTES,
});
}
let expectedMtimeMs: number | undefined;
if (expectedMtime !== undefined) {
// audit-r151:Number.isFinite 排除 NaN/Infinity,但放过负数 —— mtimeMs
// 永远 ≥ 0,负值与磁盘 mtime 比对恒不等 → 写操作**永远**抛 CONFLICT。
// 旧实现对负值 silently 拒,用户体验是「保存按钮按了也没反应」。
// 显式 negative 校验让 UI 看到 VALIDATION 而非 CONFLICT,定位更快。
if (typeof expectedMtime !== 'number' || !Number.isFinite(expectedMtime) || expectedMtime < 0) {
throw new LibraryError(
'VALIDATION',
tr('notes.invalidExpectedMtime', { value: String(expectedMtime) }),
);
}
expectedMtimeMs = expectedMtime;
}
const n = await svc.write(idStr, bodyStr, titleStr, expectedMtimeMs);
// r192:id 漂移 —— svc.write 在 frontmatter 的 id 与「当前 relPath 的哈希」
// 不一致时,会把 fm.id **重钉**为 computeFileId(relPath) 并返回**新 id**
// (旧 id 的 byId 键 + DB 行已被删掉)。收藏条目按 fileId 索引 ⇒ 旧 id
// 变成永远命中不了的孤儿 ⇒ ★ 在保存成功的瞬间静默消失(r189 同款,
// 只是触发点是「保存」而不是「改名 / 搬移」)。跟着改。
if (n.id !== idStr) {
await remapFavoritesForNote({ oldId: idStr, newId: n.id, path: n.relPath });
}
// FTS 失败 → 主表 INSERT 同事务回滚 → wrap.ts 冒泡 INTERNAL,三态一致
return n;
},
);
handle(IPC.NOTES_CREATE, async (_e, filename: string) => {
const filenameStr = requireString(IPC.NOTES_CREATE, filename, 'filename', {
maxLen: MAX_NOTE_FIELD_CHARS,
});
// svc.create 抛 LibraryError('DUPLICATE_NAME') 或普通 IO 错,原样上抛:
// store.create retry loop 精准识别 DUPLICATE_NAME 自动换名重试,其它错误透传
const n = await svc.create(filenameStr);
return n;
});
handle(IPC.NOTES_TRASH, async (_e, id: string) => {
const idStr = requireString(IPC.NOTES_TRASH, id, 'id', { maxLen: MAX_NOTE_FIELD_CHARS });
// 删除走系统回收站(electron shell.trashItem,经 svc.trash → safeTrash),
// 用户可恢复。audit 2026-09-10:DB delete 走 onAfterTrash:之前 onBeforeUnlink
// 顺序在删除失败(EPERM / OneDrive sync lock / AV 扫描)时留下"DB 永久 stale,
// 文件还在"鬼影 —— user 看到"看得见搜不着",只有手动重 save 才能修复。改成
// onAfterTrash 后:删除失败 → byId/DB/文件三态一致(无变化)user 重试即可。
await svc.trash(
idStr,
// onBeforeTrash 不再 DB delete,仅 hook 入口(rename race 仍由 writeLock 串行化保证)
undefined,
// DB delete 在 trash 成功后执行 —— deleteResource(ResourceId 带 note: 前缀)
() => deleteResource(noteResourceId(idStr)),
);
// r198:文件已进回收站 ⇒ 收藏条目跟着清(否则 ★ 状态错位 + 死条目堆积)
await removeFavoritesForNote(idStr);
});
handle(IPC.NOTES_RENAME_FILE, async (_e, id: string, newFilename: string) => {
const idStr = requireString(IPC.NOTES_RENAME_FILE, id, 'id', { maxLen: MAX_NOTE_FIELD_CHARS });
const newFilenameStr = requireString(IPC.NOTES_RENAME_FILE, newFilename, 'newFilename', {
maxLen: MAX_NOTE_FIELD_CHARS,
});
// audit-r151:DB tx 已经在 svc.renameFile 内 writeLock 串行化(见
// notes/service.ts:renameFile 注释)—— 旧实现 IPC handler 在
// writeLock 释放后再跑 renameResource,DB 失败时 byId 已更新,
// 出现 disk 新文件 + byId 新 id + DB 旧 row + FTS 旧行的三态漂移。
// 现在 svc 内部先 disk rename → DB tx → byId mutation,失败 throw
// 时 byId 保持原状,chokidar 'unlink' + 'add' 触发 scheduleReload
// 重建 byId 把新文件按新 relPath 入 byId。Self-healing,handler
// 只需做参数校验 + DUPLICATE_NAME → VALIDATION 翻译。
try {
const renamed = await svc.renameFile(idStr, newFilenameStr);
// r182:笔记 id = SHA-256(relPath) 前 16 位 ⇒ 改名后 id 变 ⇒ 收藏条目
// 的 fileId 变孤儿(★ 静默消失)。这里把收藏跟着改。
await remapFavoritesForNote({
oldId: idStr,
newId: renamed.id,
path: renamed.relPath,
});
return renamed;
} catch (err: unknown) {
// DUPLICATE_NAME 转 VALIDATION:create 早就翻译过,rename 一直漏 —— 用户撞名
// 时看 IO_ERROR 误导为系统问题。修复后两边 UX 一致
if (err instanceof LibraryError && err.code === 'DUPLICATE_NAME') {
throw new ValidationError(err.message, 'newFilename', err);
}
throw err;
}
});
// r161+ disk-folder Phase 2b:跨文件夹移动(id, targetRelPath) →
// { relPath, id }。targetRelPath 是「目标目录」相对路径('' = 根,
// 'foo', 'foo/bar'),不含文件名 —— svc.moveFile 内部拼 newRelPath =
// ${targetRelPath}/${basename(oldRelPath)}。
//
// 边界由 standalone.moveNoteAcrossFolders 承担(targetRelPath 段校验 +
// 越界 resolveInsideRootPath + 自动 mkdir 父 + EEXIST → DUPLICATE_NAME +
// ENOENT → NOT_FOUND),handler 只做参数校验 + 错误码翻译。
handle<[string, string], { relPath: string; id: string }>(
IPC.NOTES_MOVE,
async (_e, idRaw, targetRelPathRaw) => {
const idStr = requireString(IPC.NOTES_MOVE, idRaw, 'id', {
maxLen: MAX_NOTE_FIELD_CHARS,
});
const targetRelPathStr = requireString(IPC.NOTES_MOVE, targetRelPathRaw, 'targetRelPath', {
maxLen: MAX_IPC_PATH_CHARS,
});
const moved = await svc.moveFile(idStr, targetRelPathStr);
// r182:搬移改 relPath ⇒ 改 id ⇒ 收藏条目跟着改(★ 不该在搬完的瞬间消失)
await remapFavoritesForNote({
oldId: idStr,
newId: moved.id,
path: moved.relPath,
});
return { relPath: moved.relPath, id: moved.id };
},
);
// 拖入导入:单文件一次 IPC,renderer 批量拖入时循环发 N 次;主进程 NotesService.writeLock
// 串行化(见 service.ts:writeLock),无需额外并发控制。notes.createNote 内部已
// emit('changed') → renderer reload,无需主动 listNotes
//
// r175:走 svc.importFile 而不是裸 helper —— 旧实现导入完全绕开 service,
// 导入的笔记不进 byId / resources 表:renderer 紧接着的「移入当前文件夹」
// 必 NOT_FOUND,且全局搜索永远搜不到(见 service.importFile 注释)。
handle<[string], NotesImportResult>(IPC.NOTES_IMPORT_FROM_FILE, async (_event, rawPath) => {
const filePath = requireString(IPC.NOTES_IMPORT_FROM_FILE, rawPath, 'path', {
maxLen: MAX_IPC_PATH_CHARS,
});
requireAbsolutePath(filePath, { ipc: IPC.NOTES_IMPORT_FROM_FILE, field: 'path' });
// 安全边界由 notes/import.ts 承担(后缀白名单 + lstat symlink skip +
// 单 fd TOCTOU 防换 symlink + 5MB 上限),与 reader 通道同源同等级
return svc.importFile(filePath);
});
// NOTES_*_FOR_SOURCE 已下线 —— per-file notes 退役,3 个 handler 删除;
// AI 模块 2026-09 V1.6 下线后,IPC.AI_GET 及 ai/<hash>.md 读写层整体消失
// (audit-r78:原文误引了一个从未进过 IPC enum 的 WRITE 通道 key —— 考古
// git 历史证实;AI 内容写入本就走 fs,无独立通道。误引链已就此斩断)
// 「在文件管理器中显示」。历史上与 library:show-in-folder 对称(r56 已随
// library 域下线);notes 域是唯一存活实现。Win/Mac 走
// shell.showItemInFolder(选中),Linux 降级 openPath(父目录)。单 syscall
// fs.open 预检文件存在,防 shell 调用时文件已被外部删导致静默 no-op
handle(IPC.NOTES_SHOW_IN_FOLDER, async (_e, id: string) => {
const idStr = requireString(IPC.NOTES_SHOW_IN_FOLDER, id, 'id', {
maxLen: MAX_NOTE_FIELD_CHARS,
});
const n = svc.findById(idStr);
if (!n) {
throw new LibraryError('NOT_FOUND', tr('notes.notFound', { id: idStr }));
}
const absPath = toNotePath(getRootPath(), n.relPath);
await assertFileExists(absPath, tr('notes.notFoundPath', { path: absPath }), log);
await revealInOSFileManagerSafe(absPath);
});
// audit-r73:此处原为 NOTES_RESCYNC_PROGRESS_GET_STATE 的 handleZod ——
// 配对的 push 通道从未有 send 点,GET_STATE 从未有 renderer 调用方,整对删除。
// ghost 行清理由 NotesService.resyncIfNeeded 就地完成。
// audit-r156:settings.notesResyncPending 同链路一并删除。
}

View File

@@ -0,0 +1,68 @@
// src/main/ipc/path-limits.ts
//
// audit r52 (2026-10-15) C5:常量搬到 `@shared/limits/ipc` 后,本文件保留
// `requireAbsolutePath` helper(constants 经由 re-export 维持向后兼容,
// main/ 老 import 路径 `'../ipc/path-limits'` 继续可用)。
//
// 背景:历史上 MAX_*_CHARS / *_BYTES 散在 5+ 文件,一处改大 / 改小别处不跟,
// IPC 边界契约漂移。r9 (2026-09-15) 先集中到本文件,r52 C5 再搬到 shared。
//
// 单一来源(SSOT):constants 在 `@shared/limits/ipc` 定义,本文件 re-export。
// 未来想消掉 main → shared 的间接层,直接把 main/ 全部 imports 改成
// `'@shared/limits/ipc'` 即可,本文件可删。
//
// 调整任何上限请读:`@shared/limits/ipc` 对应常量 JSDoc。
export {
MAX_IPC_PATH_CHARS,
MAX_NOTE_BODY_BYTES,
MAX_NOTE_FIELD_CHARS,
MAX_NOTE_TITLE_CHARS,
MAX_NOTE_TITLE_BYTES,
MAX_LIBRARY_IMPORT_BYTES,
MAX_SETTINGS_BYTES,
READER_MAX_SIZE_BYTES,
MAX_LIBRARY_ID_CHARS,
MAX_SEARCH_QUERY_CHARS,
MAX_TOAST_MESSAGE_CHARS,
// audit-r73:删 MAX_EXTRA_IMPORT_ROOTS 转发(源常量随 extraImportRoots
// 字段退役而移除,无消费者)。
} from '../../shared/limits/ipc';
// ---------------------------------------------------------------------------
// requireAbsolutePath 留在 main/(依赖 main/utils/error 的 ValidationError)。
// ---------------------------------------------------------------------------
import { isAbsolute } from 'path';
import { ValidationError } from '../utils/error';
/**
* 校验 IPC 字段是绝对路径 —— 否则抛 ValidationError。
*
* audit 2026-09-16 r19:M3 — 曾 4 处 inline caller(历史行号,现已收敛,勿按图索骥:
* library.ts:113 LIBRARY_CHANGE_PATH
* newPath / library.ts:222 LIBRARY_IMPORT_FILES paths[] / notes.ts:217
* NOTES_IMPORT_FROM_FILE path / settings.ts:113 SETTINGS_MIGRATE_DATA
* newPath)各自 `if (!isAbsolute(...)) throw new ValidationError(...)`,
* 防御口径相同("防 renderer 注入相对路径,fs 在 cwd 下解析越界")。
*
* 抽到 path-limits.ts 让单点改:
* - 字段名(`field`)由 caller 注入,错误消息保留 caller 上下文。
* - 错误消息格式统一: `${IPC}: field "${field}" must be an absolute path`。
*
* 注意:caller 仍然需要先调 requireString(..., { maxLen: MAX_IPC_PATH_CHARS })
* 守 type / 长度,本函数只补 isAbsolute 这一层。多数 caller 已经在前一行
* 调了 requireString,组合无额外开销。
*
* 实现:用 path.isAbsolute 而非手写正则 —— Node 的 isAbsolute 正确处理
* Windows drive letter / UNC / Posix 三种绝对路径形态,正则易漏
* UNC '\\?\D:\long\path' 或 Linux `/foo/bar` 等边缘情况。
*/
export function requireAbsolutePath(value: string, options: { ipc: string; field: string }): void {
if (!isAbsolute(value)) {
throw new ValidationError(
`${options.ipc}: field "${options.field}" must be an absolute path`,
options.field,
);
}
}

326
src/main/ipc/paths.ts Normal file
View File

@@ -0,0 +1,326 @@
// 共享路径助手:
// - buildWorkspacePaths(): 渲染层需要的"模块 → 绝对路径"映射
// - assertInsideAllowedRoot(): shell.open-path / notes 操作的白名单校验
//
// 设计要点:
// 1. 校验用 path.relative 而不是 startsWith —— `/data` 不会误放行 `/data-evil`
// 2. 相对路径解析以 rootPath 为基准,而不是 cwd(避免 cwd 切换导致意外逃逸)
// 3. 不在这里做 fs.stat —— shell.openPath 自己处理;允许打开尚不存在的目录
// 时由上层决定(目前 SHELL_OPEN_PATH 在路径不存在时抛 ENOENT)
//
// audit round 13 (2026-09-15):H-SYM-1 — assertInsideAllowedRoot 加
// "存在即非 symlink" 二次校验。旧实现只用 path.relative 做字符串比较,
// 路径通过白名单后,protocol.ts / shell.openPath / fs.open 都会 follow
// symlink → 攻击者把 rootPath/notes/foo.md 替换为指向 /etc/passwd 的
// symlink,renderer 的 rkms:// iframe 即可读到任意文件。修法:路径
// 存在时 fs.lstat 探一次,见 symlink 抛 EPERM(等价于"出白名单")。
// 不存在时静默放过(白名单已通过,后续 fs.open ENOENT 自然抛)——
// 不阻塞 createNote 之类的"先校验再写"路径。
//
// audit-r56:删 libraryPath 相关白名单逻辑 —— 库整体下线,「库路径 +
// 库父目录 + 额外导入根 + 盘根」这套 importableRoots 也不再有调用方
// (library import / scanner / import 全部退役)。assertInsideImportableRoot /
// importableRoots / getDriveRoots / hasDotLeadingSegment /
// relativeUnderAnyRoot 整段删除。assertInsideAllowedRoot 缩成只判
// rootPath(notes + workspace 的工作目录根);fs-cache / safe-write 的
// 根判断未来如有需要可直接 reuse 这里的 isInsideAnyRoot。
import { resolve, relative, isAbsolute, posix } from 'path';
import { lstatSync, realpathSync } from 'fs';
import type { WorkspacePaths } from '../../shared/types';
import { deriveFavoritesPath, deriveNotesPath } from '../../shared/path-layout';
import { getRootPath } from '../utils/safe-write';
import { AppError, errnoCode } from '../utils/error';
import { createLogger } from '../utils/logger';
// audit round 26 (2026-09-21):case-insensitive FS dedup —— 复用 utils/platform
// 的 isCaseInsensitiveFs 谓词,与 fs-cache.ts / safe-write.ts 同口径。
import { isCaseInsensitiveFs } from '../utils/platform';
const log = createLogger('paths');
/** path.relative / path.isAbsolute 的最小接口面(便于单测注入 win32 / posix 实现) */
type PathImpl = Pick<typeof posix, 'relative' | 'isAbsolute'>;
/**
* 唯一一处「路径是否落在给定根集合之内」的判定实现。
*
* audit 2026-10-01:此前多处白名单比较各自 inline 同一段
* `rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))`
* —— assertInsideAllowedRoot(读白名单)、assertNotSymlink 的 realpath 链防御、
* assertInsideImportableRoot(拖入 source 白名单)—— 且**三处都漏了
* case-insensitive 处理**。
*
* 漏了为什么真会出事:Node 的 `path.win32.relative` 内部按小写比较,所以
* Windows 上无感;但 `path.posix.relative` 是纯词法、**大小写敏感**的。
* macOS APFS 自 10.13 起默认 case-insensitive,大小写不敏感的 realpath
* 会让「C:/Users/...」与「c:/users/...」词法上不是同一目录。
*
* 这与 safe-write.ts(audit r7)/ fs-cache.ts 是同一类缺陷 —— 那两处已按
* isCaseInsensitiveFs 修过,本文件漏了。现在三处统一收敛到本函数,口径
* 与 utils/platform.ts 同源。
*
* `impl` 默认是**宿主默认的** path.relative / path.isAbsolute(即本文件顶层
* 从 'path' 导入的那两个),与改造前的行为逐字符一致。**不要**改成按
* `process.platform` 在 posix/win32 之间切:测试里会覆写 process.platform 来
* 模拟跨平台(见 tests/integration/open-path.test.ts 的 ORIGINAL_PLATFORM ——
* r229 补齐 11 例集成用例),而路径又是用宿主 resolve 算出来的,按 platform
* 切会让两者错配、把合法路径判成越界。需要显式验证某一套语义时,由单测注入
* impl(见 tests/unit/main/ipc/paths.test.ts 的 A 组,r229 补齐 31 例)。
*/
export function isInsideAnyRoot(
roots: readonly string[],
abs: string,
opts: { caseInsensitive: boolean; impl?: PathImpl } = { caseInsensitive: false },
): boolean {
const p = opts.impl ?? { relative, isAbsolute };
return roots.some((root) => {
// 先折叠再 relative:大小写不敏感的系统上,两侧统一形态后词法比较才能
// 得出与磁盘一致的结果。
const from = opts.caseInsensitive ? root.toLowerCase() : root;
const to = opts.caseInsensitive ? abs.toLowerCase() : abs;
const rel = p.relative(from, to);
// 在内 ⇔ relative 不以 '..' 开头(否则跳出)且不是绝对路径(否则换了根)
return rel === '' || (!rel.startsWith('..') && !p.isAbsolute(rel));
});
}
/** 生产路径用:宿主默认 path 实现 + 平台 FS 的大小写语义 */
function isInsideAnyRootHere(roots: readonly string[], abs: string): boolean {
return isInsideAnyRoot(roots, abs, { caseInsensitive: isCaseInsensitiveFs });
}
/**
* 「realpath 后的落点是否在白名单内」——**允许根自身是 symlink / junction**。
*
* audit-r229(MEDIUM-HIGH):根自身是 symlink / junction 时(Windows 上数据目录
* 放在 OneDrive / Dropbox,或用户按 utils/path-check.ts 的指引用 `mklink /J`
* 绕 MAX_PATH),`realpath(target)` 解析到**物理位置**,而白名单里存的是
* **明文根**(resolve() 只做词法归一、不解 symlink)⇒ 两侧口径不一致 ⇒
* 合法的区内文件被判成「realpath 链逃逸」⇒ 抛 EPERM。
*
* 用户现象:shell:open-path 报「路径不允许」、rkms:// 协议对**所有**文件
* 返 403(文献 / PDF / 图片全打不开),而磁盘上的数据其实好好的 ——
* 极具迷惑性(与 safe-write.ts 在 2026-10-01(r208/r7 HIGH)修过的
* 是同一个 bug 类,那边已改成「两侧都 realpath 后再比」,本文件漏了
* 这次同源修复)。
*
* 修法:与 safe-write.ts 同口径 —— 落点只要落在「明文根」**或**「realpath
* 后的根」之内即放行;两侧都不满足才拒。逃逸场景(把 root/notes 换成指向
* /etc 的 symlink)两侧都不满足 ⇒ 仍然被拒,防线不放松。
*/
function isInsideRootsRealpathAware(roots: readonly string[], real: string): boolean {
if (isInsideAnyRootHere(roots, real)) return true;
for (const r of roots) {
let realRoot: string;
try {
realRoot = realpathSync(r);
} catch {
// 根解析不出来(还没建 / 权限被剥)⇒ 只能用明文根比对(与 safe-write 同口径)
continue;
}
if (isInsideAnyRootHere([realRoot], real)) return true;
}
return false;
}
/**
* 计算所有模块的绝对路径。
* 注意:这并不创建目录 —— 模块目录是懒加载(各自 service.start() 里 mkdir),
* 渲染层可能在目录尚未创建时拉到这里的数据。
*
* audit-r56:删 library 字段 —— 库整体下线,WorkspacePaths 不再有 library。
*/
export function buildWorkspacePaths(): WorkspacePaths {
const root = getRootPath();
return {
root,
// audit r44 (2026-10-15):走 shared helper 而不是 path.join,符合
// src/shared/path-layout.ts 头部「call sites should derive, not
// template-join」的 module pragma。derive*Path 内部用零依赖
// joinRootPath,行为与 path.join 在本调用点等价,但走单一真相源。
notes: deriveNotesPath(root),
favorites: deriveFavoritesPath(root),
};
}
/** 当前允许作为 shell.open-path / notes 写路径目标的根路径(已 resolve) */
export function allowedRoots(): string[] {
return [resolve(getRootPath())];
}
/**
* 校验 target 是否落在任一允许根之内。
* - 相对路径以 rootPath 为基准解析(避免 cwd 漂移)
* - 用 path.relative 判断"在不在内",避免 `/data-evil` 通过 `/data` 的前缀检查
* - 路径解析结果必须是绝对路径(已经是)
* - 路径存在时 fs.lstat 探一次:见 symlink 抛 EPERM —— 防止 protocol.ts /
* shell.openPath / fs.open follow symlink 读到 target 内容,导致白名单
* 之外的 /etc/passwd 之类被 renderer 经 rkms:// iframe 拉到
*
* 返回解析后的绝对路径;不在白名单内 / 路径是 symlink 抛 EPERM。
*
* audit-r56:白名单从「rootPath + libraryPath」缩到「rootPath」单元素
* —— 库已下线,libraryPath 字段不再存在。notes 写 / show-in-folder 仍
* 走这条防御(notes 是工作目录根的子目录),语义不变。
*/
export function assertInsideAllowedRoot(target: string): string {
if (typeof target !== 'string' || !target) {
throw new AppError('EINVAL', 'path required');
}
const abs = isAbsolute(target) ? resolve(target) : resolve(getRootPath(), target);
const roots = allowedRoots();
if (!isInsideAnyRootHere(roots, abs)) {
// audit-r244:消息**不回显路径** —— 与 ipc/shell.ts / ipc/library.ts 同源口径
// (r229 / r191)。非 INTERNAL 的 code 在 ipc/wrap.ts 里是**原样透传**的
// (scrubInternal 只处理 INTERNAL 分支,且它只脱敏 rootPath **之内**的
// 路径 —— 本条恰恰是 rootPath **之外**的路径,scrub 不到)⇒ 消息里
// 带绝对路径 ⇒ 家目录 / OneDrive 目录名进 renderer 与 telemetry。
// 完整路径只留在 main 侧日志。
log.warn('path outside allowed roots', { abs, roots });
throw new AppError('EPERM', 'path is outside allowed roots');
}
// audit round 13 H-SYM-1:symlink 兜底 —— 路径字符串在白名单内 ≠ 文件就是
// 白名单内。fs.lstat 不 follow,见 symlink 直接拒。ENOENT / 其它错误
// 静默放过 —— 不存在路径后续 fs.open 会自己抛 ENOENT,这里多 stat 一次
// 无意义;权限翻转等"真错误"留给上层 fs.open 报告,免得 assertInsideAllowedRoot
// 把 "EACCES" 翻译成 "EPERM" 误导用户。
// 显式传 roots:realpath 链防御必须对照**本调用方**的白名单(allowedRoots),
// 而不是函数默认值 —— 见 assertNotSymlink 头注里 2026-10-06 那条 HIGH。
assertNotSymlink(abs, roots);
return abs;
}
/**
* 路径若存在且是 symlink 则抛 EPERM。不存在或非 symlink 静默放过。
* 单一 syscall(fs.lstat 不 follow),与 notes/standalone.ts 共用同一套 fs.lstat 防御。
*
* 用 lstatSync 而非 async fs.lstat:assertInsideAllowedRoot 是 shell /
* protocol 两个 IPC handler 的同步入口,改 async 会拉断所有 caller
* (包括 shell-ipc.test.ts / protocol.test.ts 等 mock 都要改 await)。lstatSync
* 在正常 hot path 上 ~0.1ms,同步 vs async 差异不可观察;但保留同步契约让
* 现有调用方零修改。
*
* audit r28 (2026-09-21):realpath 链防御。单纯 lstat(target) 只验末端;
* 中间任意目录段被换成 symlink 后,target lstat 是「不是 symlink」
* 通过,但实际 open / read 时跟随 foo/ → /etc。修法:realpathSync 解析
* 整个链路,比较 root 是否落在任一 allowed root 内 —— 路径不存在
* (ENOENT)/ 链上某段不存在(rfc3168 ENOTDIR)/权限翻转(EACCES)静默放过,
* 后续 fs.open 自己抛真错误,这里不阻断 create 之类的"先校验再写"。
* realpath 自身会 follow 末段 symlink,所以末尾 symlink 直接由
* lstat 兜底(已存在时 realpath !== resolve(target) 等价于 symlink)。
*
* audit 2026-10-06(HIGH):`roots` 参数 —— realpath 链防御必须对照**调用方自己
* 的白名单**校验,不能把 allowedRoots() 写死在函数体内。
* 旧实现让 realpath 复查硬编码走 allowedRoots(),而本函数被
* assertInsideAllowedRoot 之外的潜在调用方使用不同的根集合时会误杀。
* 修法:把调用方的根集合传进来(默认值保持 allowedRoots(),读白名单调用点
* 行为不变),**两道防线都不放松**:lstat 仍拒末段 symlink,realpath 仍拒
* 「中间段被换成 symlink 导致逃逸出同一套白名单」。
*/
// audit r52 (2026-10-16):realpath 链防御上限 —— 把 realpath 循环跑**多次**
// 直到非 symlink / 落点在白名单内 / 撞深度上限,任一停止条件命中即返。
//
// 取 8:Windows / Linux / macOS 上「合法 + 真实场景」几乎不可能超过 4-5 层
// (典型就是 rootPath/notes → workspace → 用户自定义重定向,4 层足矣)。
// 8 是 generous 上限 —— 防恶意构造 a → b → c → ... 无限循环把 realpath
// 栈打爆。realpathSync 自身在循环链上抛 ELOOP,所以「链太长」防御
// 实际命中的是 FS 边界 (NTFS junction 嵌套 / 重解析点嵌套) ——
// 这种场景用户即使不是攻击者,也大概率是配置错误,拒掉更安全。
const MAX_CHAIN_DEPTH = 8;
export function assertNotSymlink(abs: string, roots: readonly string[] = allowedRoots()): void {
// 第一道防线:input 自身的 lstat。symlink 立即拒,这是 r13 H-SYM-1
// 引入的契约 —— 现有测试 tests/unit/main/ipc/paths.test.ts 锁这条
// 行为("白名单内但 lstat 是 symlink → EPERM"),保留向后兼容
// (r229 补齐 31 例单测)。
let lst;
try {
lst = lstatSync(abs);
} catch (err: unknown) {
// audit 2026-09-21 audit-r24:区分 ENOENT(静默放过,符合原契约)与其他错误
// (EACCES / EIO / ELOOP / EMFILE) → warn 一行,便于事后排查安全关键
// 路径上的 syscall 异常。安全检查路径上 swallow 所有错误无 trace 是
// defense-in-depth 反模式(见 audit-r24 finding)。
// audit 2026-09-21 batch-migration C3R50:走 errnoCode() 共享 helper,
// 不再 inline `(err as NodeJS.ErrnoException)?.code`(类型不安全 +
// 漏掉非 errno 例外如 LibraryError)。errnoCode 返回 string|undefined,
// 行为等价。
const code = errnoCode(err);
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
log.warn('[assertNotSymlink] lstat failed', { abs, code });
}
return;
}
if (lst.isSymbolicLink()) {
throw new AppError('EPERM', `path is a symlink (refusing to follow): ${abs}`);
}
// 第二道防线:realpath 链检查(单次 realpath 调用,resolve 整条链路)。
// 这是 r28 / r52 引入的「中间目录被换成 symlink → 跳到 root 外」防御
// —— lstat 看末端不是 symlink,realpath 解析链路却发现落点在 root 外。
// realpathSync 内部会跟随整条链,所以 a → b → c → outside 一次性就被
// 解析到 outside,在 single realpath 调用里就被 catch。
let realAbs: string;
try {
realAbs = realpathSync(abs);
} catch (err: unknown) {
// audit r28:ENOENT / ENOTDIR 静默放过 —— 不存在路径不在白名单防御范围,
// 后续 fs.open 自己抛。ELOOP(circular symlink chain)同处理 —— 与
// 旧行为对齐,避免回归。
const code = errnoCode(err);
if (code !== 'ENOENT' && code !== 'ENOTDIR' && code !== 'ELOOP') {
log.warn('[assertNotSymlink] realpath failed', { abs, code });
}
return;
}
// 第二道防线的关键判定:realpath 解析后的落点必须在白名单内。
// audit-r229:用 realpath-aware 口径 —— 根自身是 symlink/junction 时,
// 落点落在「realpath 后的根」内也是**合法**(见 isInsideRootsRealpathAware
// 头注的 bug 复现与同源修法)。
if (!isInsideRootsRealpathAware(roots, realAbs)) {
// audit-r244:同 assertInsideAllowedRoot —— 消息不含路径(EPERM 原样透传),
// 完整链路只进 main 侧日志。
log.warn('symlink chain resolves outside allowed roots', { abs, realAbs, roots });
throw new AppError('EPERM', 'path resolves outside allowed roots (symlink chain)');
}
// 第三道防线(防御性深度,r52 引入):realpath 返回的路径本身仍可能是 symlink
// (罕见 FS 边界场景 —— 部分 NTFS junction 嵌套 / 重解析点嵌套会让 realpath
// 一次性解析后返回一个相对路径 / 二次 symlink)。这里用 readlink-style
// 单跳循环跟随,直到非 symlink / 撞深度上限 / 出白名单。MAX_CHAIN_DEPTH=8
// 是 paranoid bound,绝大多数真实场景 1-2 跳就停。
let cur = realAbs;
for (let i = 0; i < MAX_CHAIN_DEPTH; i++) {
let hopLst;
try {
hopLst = lstatSync(cur);
} catch (err: unknown) {
const code = errnoCode(err);
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
log.warn('[assertNotSymlink] chain lstat failed', { abs: cur, code });
}
return;
}
if (!hopLst.isSymbolicLink()) return;
let next: string;
try {
next = realpathSync(cur);
} catch (err: unknown) {
const code = errnoCode(err);
if (code !== 'ENOENT' && code !== 'ENOTDIR' && code !== 'ELOOP') {
log.warn('[assertNotSymlink] chain realpath failed', { abs: cur, code });
}
return;
}
if (next === cur) return;
if (!isInsideRootsRealpathAware(roots, next)) {
// audit-r244:同 assertInsideAllowedRoot —— 消息不含路径。
log.warn('symlink hop resolves outside allowed roots', { cur, next, roots });
throw new AppError('EPERM', 'path resolves outside allowed roots (symlink chain)');
}
cur = next;
}
// 走完 MAX_CHAIN_DEPTH 还没收敛 —— 极少见,但仍按 brief 抛 EPERM。
// 真实场景下,realpath 已把所有 hop 折叠到 cur,这里的循环大多 0 次
// 迭代;这条防御只在 FS 边界嵌套场景(罕见)下生效。
// audit-r244:同 assertInsideAllowedRoot —— 消息不含路径。
log.warn('symlink chain depth exceeded', { abs, depth: MAX_CHAIN_DEPTH });
throw new AppError('EPERM', `symlink chain depth > ${MAX_CHAIN_DEPTH}`);
}

53
src/main/ipc/search.ts Normal file
View File

@@ -0,0 +1,53 @@
// Search IPC:searchResources 单 resources_fts(wire 形状不变,kind literature→'file'
// 未来统一改 ResourceHit)。query 走 requireString 合并"非字符串"和"超长"
// 进 VALIDATION。
import { IPC, SearchHit } from '../../shared/types';
import { searchResources } from '../db/resources-repo';
import { parseResourceId, INVALID_RESOURCE_ID } from '@shared/resource-id';
import { handle, requireString } from './wrap';
import { MAX_SEARCH_QUERY_CHARS } from './path-limits';
import { LibraryError, errnoCode } from '../utils/error';
import { tr } from '../i18n';
export function registerSearchIPC() {
handle<[string], SearchHit[]>(IPC.SEARCH_QUERY, async (_, query) => {
const q = requireString(IPC.SEARCH_QUERY, query, 'query', { maxLen: MAX_SEARCH_QUERY_CHARS });
if (!q.trim()) return [];
// searchResources 是同步,但 IPC handler 是 async;await 不影响 sync 返回,
// 但让 caller 拿到真值(避免 promise.map 链式错)
const hits = await searchResources(q, { limit: 50 });
return hits.map((h) => {
// audit-r160:parseResourceId 失败(DB 脏 id / 0005 迁移残留 / 手改 .md
// 引入非法 prefix)不再裸抛 —— shared 层禁 import main/utils/error,
// 原实现抛 Error+`code:'INVALID_RESOURCE_ID'` tag 后被 wrap.ts 兜底
// 包成 INTERNAL,renderer 看到通用「内部错误」,实际是数据完整性问题。
// 在主进程识别 tag 后重包装成 LibraryError(VALIDATION),走 wrap.ts
// 已知的 AppError 通道,renderer 拿到的就是准确的 VALIDATION。
let parsed;
try {
parsed = parseResourceId(h.resource.id);
} catch (err: unknown) {
if (
err instanceof Error &&
errnoCode(err) === INVALID_RESOURCE_ID
) {
throw new LibraryError(
'VALIDATION',
tr('search.badResourceId', { id: h.resource.id }),
);
}
throw err;
}
const { kind, raw } = parsed;
return {
// id 是裸 id(note uuid / lit relPath),ResourceId 前缀不进 wire
kind: kind === 'note' ? ('note' as const) : ('file' as const),
id: raw,
relPath: h.resource.kind === 'note' ? h.resource.relPath : h.resource.absPath,
title: h.resource.title,
snippet: h.snippet,
score: h.score,
};
});
});
}

166
src/main/ipc/settings.ts Normal file
View File

@@ -0,0 +1,166 @@
// Settings IPC handlers
// audit-r56:删 SETTINGS_PICK_LIBRARY_PATH / SETTINGS_PICK_IMPORT_ROOT /
// LIBRARY_MIGRATION_MOVE / LIBRARY_MIGRATION_KEEP / LIBRARY_MIGRATION_GET_STATE
// handler —— library 整体下线,库路径选择、额外导入根、库归位这三条通道
// 都没有意义了。SETTINGS_PICK_DATA_PATH 保留(选 notes / workspace 工作目录根用)。
// settings 内部 SETTINGS_UPDATE 的 libraryPath / extraImportRoots 路由
// (changeLibraryPath / stopWatching / refreshLibraryMigrationFlag)也一并删,
// 因为 settings.store 不再含这些字段。
import { dialog, BrowserWindow, OpenDialogOptions } from 'electron';
import { IPC, AppSettings } from '../../shared/types';
import { getSettings, updateSettings } from '../settings/store';
import { switchDataPath, restoreDefaultDataPath } from '../settings/migration';
import { sanitizeSettingsPatch } from '../settings/validate';
import { AppError, errMsg } from '../utils/error';
import { createLogger } from '../utils/logger';
import { handle, requireObject, requireString } from './wrap';
import { tr } from '../i18n';
import { MAX_IPC_PATH_CHARS, requireAbsolutePath } from './path-limits';
const log = createLogger('ipc-settings');
// audit 2026-08-28:dialog.showOpenDialog 的 title 走主进程 i18n 表(settings.pickerData),
// 跟随 settings.language 选标题。
// - 不引入 main 侧 i18next 依赖(避免把 renderer 的 react-i18next 拉到 main)
// - 不让 renderer 把 title 通过 IPC 传过来(契约污染,且 locale 由 main 拿更近)
// 弹一个文件夹选择对话框,统一两个 SETTINGS_PICK_* 的模板代码(原 settings.ts
// 里 8 行 × 2 处复制)。Options 只在 properties / title 上有差异,封装为 helper 后
// 调用方只关心"我要什么标题 + 哪些 properties",不再关心 BrowserWindow 拿取
// / canceled 判断 / 空数组兜底这些 boilerplate。
//
// audit C2R9 (2026-08-22):2 个 IPC handler 几乎一字不差,settings dialog 的 4 个
// 步骤(从 event 拿 win → 调 showOpenDialog → 判 canceled → 返回 string|null)
// 完全可抽;抽后调用方 1 行,且未来加第 3 个 picker 时零成本。
async function pickFolder(
event: Electron.IpcMainInvokeEvent,
options: OpenDialogOptions,
): Promise<string | null> {
// audit 2026-08-25:显式 null check —— renderer 在 dialog 弹出前 reload / 关闭
// (e.g. devtools hot reload) → BrowserWindow.fromWebContents 返回 null,
// win! 解引用会 throw TypeError → wrap.ts 转成 INTERNAL 但用户看到的是
// unhandled "Cannot read properties of null"。改为显式 null check 直接返 null,
// 与下面 canceled 路径同语义 —— 拿不到 win 就当用户取消了。
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return null;
const res = await dialog.showOpenDialog(win, options);
if (res.canceled || !res.filePaths[0]) return null;
return res.filePaths[0];
}
// audit-r244:SETTINGS_UPDATE 的 patch **只允许「UI 偏好」字段** ——
//
// 背景:rootPath / dataPath 是**加载路径**字段(由 initSettings 从
// location.json 推导 + reconcileRootPath 校正),renderer 没有理由改它们。
// 旧实现里 sanitizeSettingsPatch 就是 sanitizeAppSettings 的别名(同一个
// **加载侧**净化器),它接受任何「绝对 + ≤4096 字符」的 rootPath ⇒
// 被劫持 / XSS 的 renderer 调 api.updateSettings({rootPath:'C:\\Windows'})
// 就能把 rootPath **落盘**(store.updateSettings 直接 `{...base, ...patch}`
// + saveSettings)⇒
// - 设置页显示错的根(renderer 的 deriveNotesPath(settings.rootPath)
// 指向工作区外的目录);
// - 「打开数据目录」按钮走 SHELL_OPEN_PATH → assertInsideAllowedRoot
// 对**真实**根校验 → EPERM ⇒ 按钮变死;
// - 落盘的脏值直到下次启动 reconcileRootPath 才自愈,期间 UI 全错。
// 不是数据丢失(没有文件 IO 跟着伪造的 rootPath 走),但「renderer 能改
// 磁盘位置」本身是 IPC 边界该堵的口子。
//
// 修法:在 IPC 边界按**白名单**挑字段 —— 只有 UI 偏好字段可更新。
// 刻意**不改** settings/validate.ts 的 sanitizeAppSettings:加载路径必须
// 继续接受 rootPath / dataPath(否则 initSettings 拿不到旧值推导 rootPath,
// 见 validate.ts:39-42 的注释),两条路径的口径本来就不同 —— 这正是
// validate.ts 里 sanitizeLoadedSettings / sanitizeSettingsPatch 要**分开
// 命名**的原因。
const UPDATABLE_SETTING_KEYS = ['theme', 'language', 'version', 'workspaceLayout'] as const;
function pickUpdatableSettings(patch: Partial<AppSettings>): Partial<AppSettings> {
const src = patch as Record<string, unknown>;
const out: Record<string, unknown> = {};
const dropped: string[] = [];
for (const key of Object.keys(src)) {
if ((UPDATABLE_SETTING_KEYS as readonly string[]).includes(key)) {
out[key] = src[key];
} else {
dropped.push(key);
}
}
if (dropped.length > 0) {
// 留痕:被剥掉的字段说明有调用方(或被劫持的 renderer)在试着改不该改的东西。
log.warn('settings update dropped non-updatable fields', { dropped });
}
return out as Partial<AppSettings>;
}
export function registerSettingsIPC() {
handle<[], AppSettings>(IPC.SETTINGS_GET, async () => getSettings());
// updateSettings 内部会被 updateSettings 的 patch merge 透传到 settings.json。
// 坏形状(数组 / 字符串 / null)应当被拒在 IPC 边界,避免 service 把它当
// object 写盘。下游 store.updateSettings 没有 typeof 检查,这里兜底。
//
// audit round 3 (2026-08-22):在 IPC 层 sanitize patch — 把"坏字段静默丢弃"
// 收紧到边界,避免 settings 注入脏字段扩散到 store。
// sanitizeLoadedSettings 同样的策略(load 路径已有,update 路径缺)。
// audit-r56:删 libraryPath / extraImportRoots 路由分支 —— 字段已下线。
// SETTINGS_UPDATE 现在只剩 theme / language / version / workspaceLayout
// 这几个 UI 偏好字段,无任何「副作用」,handler 简化为 sanitize + merge。
handle<[Partial<AppSettings>], AppSettings>(IPC.SETTINGS_UPDATE, async (_, patch) => {
const patchObj = requireObject(IPC.SETTINGS_UPDATE, patch);
const cleanPatch = pickUpdatableSettings(sanitizeSettingsPatch(patchObj));
return updateSettings(cleanPatch);
});
// audit-r56:删 SETTINGS_PICK_LIBRARY_PATH / SETTINGS_PICK_IMPORT_ROOT —— 库
// 路径与受信任导入根都不再存在。SETTINGS_PICK_DATA_PATH 仍走(用户改
// notes 工作目录根的入口)。
handle<[], string | null>(IPC.SETTINGS_PICK_DATA_PATH, async (event) =>
pickFolder(event, {
title: tr('settings.pickerData'),
properties: ['openDirectory', 'createDirectory'],
}),
);
handle<[string], AppSettings>(IPC.SETTINGS_MIGRATE_DATA, async (_event, rawPath) => {
// 防 string 类型被传成 number / object / null,避免 fs.mkdir 拿到非字符串炸掉
// audit round 9 (2026-09-15):maxLen 走 path-limits.ts 共享常量(MAX_IPC_PATH_CHARS),
// 与 shell.ts / notes.ts 同款。Windows MAX_PATH = 260,UNC \\?\
// 前缀路径可达 ~32k,但实践中没人用那么长,4096 给未来网络路径留余量。
const newPath = requireString(IPC.SETTINGS_MIGRATE_DATA, rawPath, 'newPath', {
maxLen: MAX_IPC_PATH_CHARS,
});
// audit round 18 (2026-08-27):要求绝对路径 —— 旧版 switchDataPath 内部
// 已经 resolve,但相对路径会基于 process.cwd() 解析,用户从 cwd 调起时正常,
// 但把 app 打成 daemon 跑 / 跨进程调起时 cwd 可能不是预期目录。把绝对路径
// 要求上提到 IPC 边界,renderer 只能从 dialog pickFolder 拿到绝对路径
// 传进来,无法塞 `../../etc/passwd` 之类基于 cwd 的攻击向量。
// audit 2026-09-16 r19:M3 — 抽 requireAbsolutePath 共享校验。
requireAbsolutePath(newPath, { ipc: IPC.SETTINGS_MIGRATE_DATA, field: 'newPath' });
log.info('Migrating data to', newPath);
await switchDataPath(newPath);
return getSettings();
});
// 恢复默认数据路径 —— 走 location.defaultDataPath() → switchDataPath 全流程。
// 与 SETTINGS_MIGRATE_DATA 同源(都走 switchDataPath),唯一差异是路径由主进程
// 派生而非用户 pick —— IPC 不需要输入校验,renderer 注入不了。
//
// audit-r205:这里**不再**直接 switchDataPath(default) —— 那条路要求目标目录
// 为空,而默认位置里躺着上一次工作区的副本(changeDataPath 是复制语义)
// ⇒ 用户一旦搬离默认位置,这个按钮就永久报「target directory must be empty」。
// 改走 restoreDefaultDataPath:先把默认目录**改名让开**(不删任何字节),
// 再切换。详见 settings/migration.ts 的 r205 头注。
handle<[], AppSettings>(IPC.SETTINGS_RESTORE_DEFAULT_DATA, async () => {
try {
await restoreDefaultDataPath();
} catch (err: unknown) {
// restoreDefaultDataPath 内部已包 rollback,若仍走到这里说明 rollback 也炸了,
// 把原始错误透传,UI 用 toast.dataRestoreFailed 显示。
log.error('restoreDefaultDataPath failed', errMsg(err));
throw err instanceof AppError ? err : new AppError('MIGRATION_FAILED', errMsg(err), err);
}
return getSettings();
});
// audit-r56:删 LIBRARY_MIGRATION_MOVE / KEEP / GET_STATE handler —— 库归位
// 整套通道下线。
}

88
src/main/ipc/shell.ts Normal file
View File

@@ -0,0 +1,88 @@
// 通用 shell IPC:渲染层在文件管理器中打开模块数据目录。安全三关——
// 路径必须在白名单根内(rootPath),否则 EPERM;路径必须存在,否则 ENOENT
// (shell.openPath 静默 no-op 让用户困惑);shell.openPath 返非空字符串翻译 EOPEN。
// 不复用 NOTES_SHOW_IN_FOLDER:那个接受 note id 走 revealInOSFileManager
// (Win/Mac showItemInFolder select 语义),这里是"打开目录",语义不同。
// (audit-r77 更正:原注释写 LIBRARY_SHOW_IN_FOLDER —— 该通道已随 r56 库退役
// 删除,现存的同款通道是 notes 域。)
import { promises as fs } from 'fs';
import { shell } from 'electron';
import { IPC } from '../../shared/types';
import { AppError, errMsg, errnoCode } from '../utils/error';
import { handle, requireString } from './wrap';
import { MAX_IPC_PATH_CHARS } from './path-limits';
import { createLogger } from '../utils/logger';
import { assertInsideAllowedRoot } from './paths';
const log = createLogger('shell');
export function registerShellIPC(): void {
handle<[string], { opened: string }>(IPC.SHELL_OPEN_PATH, async (_e, raw) => {
// 4096 走 path-limits.ts 共享常量(原本 inline 与其它 4 处分散声明)
const path = requireString(IPC.SHELL_OPEN_PATH, raw, 'raw', { maxLen: MAX_IPC_PATH_CHARS });
// 1. 白名单 + 绝对路径解析
//
// audit-r229:错误消息**不回显路径** —— 与 ipc/library.ts 同源口径
// (r137/r160)。非 INTERNAL 的 code 在 ipc/wrap.ts 里是**原样透传**的
// (scrubInternal 只处理 INTERNAL 分支),所以消息里带绝对路径 ⇒
// 家目录 / OneDrive 目录名进 renderer 与 telemetry。renderer 不需要
// 知道文件系统布局,main 侧日志留全路径即可(区分「路径不在工作区」
// 与「路径不可访问」)。
let abs: string;
try {
abs = assertInsideAllowedRoot(path);
} catch (err: unknown) {
// EINVAL('path required')不含路径 ⇒ 原样透传;其余(含 EPERM 越界)
// 一律换成不含路径的文案。
if (err instanceof AppError && err.code === 'EPERM') {
log.warn('open-path rejected by workspace whitelist', { path });
throw new AppError('EPERM', 'path is outside the workspace');
}
throw err;
}
// 2. 存在性校验:shell.openPath 路径不存在静默失败,提前抛 ENOENT
// audit-r77 (2026-10-17):探针由 fs.open(abs,'r')+close 改 fs.stat(abs)。
// 语义修正而非平台 bug(曾疑「POSIX open 目录抛 EISDIR 判死打开目录」,
// 实证驳回:open(2) 只在写/读写标志抛 EISDIR,O_RDONLY 打开目录三平台
// 均成功;本机 Windows 含 System32 实测 OK)。换 stat 的真实理由:
// - 探针职责是「存在性」,stat 是正语义;open 还额外要求**可读**,权限位
// 拒绝会被旧探针报成 EACCES/ENOENT 类失败,而 shell.openPath 对同一
// 路径大概率能正常工作 —— OS 的答案才是权威。
// - 单 syscall,去掉 close EBUSY 尾钩(旧代码专为它加了 warn)。
// - r33 时代的 EISDIR 分流分支:open(目录,'r') 根本不产 EISDIR、close
// 也不产 —— 本就是死分支,随 stat 一并删。
try {
await fs.stat(abs);
} catch (err: unknown) {
// errnoCode 分流到正确码:ENOENT/EACCES/EPERM/ENOTDIR 各归各位,
// 兜底 IO_ERROR —— 旧实现一律 ENOENT 让权限拒绝用户永远 retry 不好
//
// audit-r229:文案不含路径 —— Node 的 fs 错误 message 本身带绝对路径
// ("ENOENT: no such file or directory, stat 'C:\Users\…'"),旧的
// `${abs} (${errMsg(err)})` 双重回显 ⇒ 家目录进 renderer / telemetry。
// 完整错误(含路径)只留在 main 侧日志。
const code = errnoCode(err);
log.warn('open-path stat failed', { code, abs, detail: errMsg(err) });
if (code === 'ENOENT') {
throw new AppError('ENOENT', 'path does not exist');
}
if (code === 'EACCES' || code === 'EPERM') {
throw new AppError('EACCES', 'permission denied');
}
if (code === 'ENOTDIR') {
throw new AppError('EOPEN', 'path component is not a directory');
}
throw new AppError('IO_ERROR', 'path not accessible');
}
// 3. 系统文件管理器,返非空字符串 = 平台报错
const err = await shell.openPath(abs);
if (err) {
throw new AppError('EOPEN', err);
}
return { opened: abs };
});
}

11
src/main/ipc/startup.ts Normal file
View File

@@ -0,0 +1,11 @@
// Startup progress IPC handlers
// 当前只暴露 STARTUP_GET_STATE(invoke),STARTUP_PROGRESS 是 webContents.send 推送,
// 不需要 handler。renderer 在 mount StartupOverlay 时 invoke 一次拿 snapshot,
// 用来填补"主进程 push 已先于 renderer listener 注册"的竞态。
import { IPC, StartupProgressSnapshot } from '../../shared/types';
import { handle } from './wrap';
import { startupProgress } from '../startup-progress';
export function registerStartupIPC(): void {
handle<[], StartupProgressSnapshot>(IPC.STARTUP_GET_STATE, () => startupProgress.snapshot());
}

68
src/main/ipc/window.ts Normal file
View File

@@ -0,0 +1,68 @@
// 自定义窗口控制 IPC (2026-09)
//
// 动机:
// 主进程 BrowserWindow 用 frame:false + titleBarStyle:hidden 完全去掉原生 chrome,
// 渲染层 TitleBar 接管最小化/最大化/关闭按钮 + 拖拽区。这些操作必须在主进程做
// (渲染层无 BrowserWindow 实例),所以通过 IPC 暴露。
//
// IPC 设计要点:
// - minimize / maximize-toggle / close 走 handle<[], void> 常规包络
// (audit-r124 校正,原注「单向 invoke 无返回值/失败主进程吞掉」不实:
// wrap.ts ipcMain.handle 恒回 {ok,data};失败回 {ok:false} 且 r55 起
// catch 兜底必有 log.error,并非吞掉)。void = data 无内容,渲染层
// TitleBar.fireWindowControl 不消费返回值,失败对用户静默 —— 静默的
// 是 caller 侧语义,不是主进程吞异常
// - isMaximized 是启动期一次性拉快照 — 渲染层 mount TitleBar 时拿初值,后续
// 由 maximize-changed 推送保持同步。和 STARTUP_GET_STATE 一个套路,避免
// "主进程 push 已先于 renderer listener 注册"竞态。
// - maximize-changed 是带布尔载荷的推送事件 — 渲染层仅当 changed=true 才换
// maximize 图标,没变化时不响。
import { BrowserWindow } from 'electron';
import { IPC } from '../../shared/types';
import { handle } from './wrap';
export function registerWindowIPC(): void {
handle<[], void>(IPC.WINDOW_MINIMIZE, (e) => {
const win = BrowserWindow.fromWebContents(e.sender);
// isDestroyed 守卫:race 下 webContents 还指向已被 close 的 BrowserWindow
// (Renderer 关窗 + 主进程 close 几乎同时,IPC 仍能 invoke 进来)—— minimize
// 在 destroyed window 上抛 "Object has been destroyed" 异常,wrap 把它
// 归类为 INTERNAL,renderer 弹无意义 toast。改成静默 no-op。
if (!win || win.isDestroyed()) return;
win.minimize();
});
handle<[], void>(IPC.WINDOW_MAXIMIZE_TOGGLE, (e) => {
const win = BrowserWindow.fromWebContents(e.sender);
if (!win || win.isDestroyed()) return;
if (win.isMaximized()) win.unmaximize();
else win.maximize();
});
handle<[], void>(IPC.WINDOW_CLOSE, (e) => {
const win = BrowserWindow.fromWebContents(e.sender);
if (!win || win.isDestroyed()) return;
win.close();
});
handle<[], boolean>(IPC.WINDOW_IS_MAXIMIZED, (e) => {
const win = BrowserWindow.fromWebContents(e.sender);
if (!win || win.isDestroyed()) return false;
return win.isMaximized();
});
}
/**
* 在窗口创建后挂上 maximize-changed 推送。
*
* 不要在这里写 ipcMain.on — 调用方(createWindow)要在 webContents 加载之前
* 注册 'maximize' / 'unmaximize' 监听,否则首次 maximize 事件可能错过。
* createWindow 内部已经做 win.on('maximize', ...) / win.on('unmaximize', ...)。
*
* helper 抽出来是为了让 createWindow 集中放"窗口自身事件"的注册,
* IPC 注册集中放 ipc/index.ts — 边界清晰。
*/
export function broadcastMaximizeChanged(win: BrowserWindow, maximized: boolean): void {
if (win.isDestroyed()) return;
win.webContents.send(IPC.WINDOW_MAXIMIZE_CHANGED, maximized);
}

70
src/main/ipc/workspace.ts Normal file
View File

@@ -0,0 +1,70 @@
// 工作台扩展 IPC:独立收藏。接收写入 → emit 'changed' → IPC.FAVORITES_CHANGED
// 推送渲染端,业务逻辑收敛主进程。group CRUD(V1.4 下线,group 是 read-only
// metadata 无 IPC consumer)后续 V2.0 需要管理时再 wire。
import { IPC, type WorkspacePaths } from '../../shared/types';
import { handle, requireObject, requireString } from './wrap';
import { getFavoritesService } from '../workspace/favorites';
import { buildWorkspacePaths } from './paths';
import {
MAX_IPC_PATH_CHARS,
MAX_IPC_PATH_CHARS as MAX_FAVORITES_FILE_ID_CHARS,
MAX_NOTE_TITLE_CHARS,
} from './path-limits';
export function registerWorkspaceIPC(): void {
const favorites = getFavoritesService();
// ===== 独立收藏 =====
// FAVORITES_LIST 过滤 group —— renderer 只消费 file entry(★ filter /
// favoritedFileIds Set),group 是 read-only metadata,filter 后返 FavoritesFileEntry[]
handle<[], Awaited<ReturnType<typeof favorites.listEntries>>>(IPC.FAVORITES_LIST, async () =>
favorites.listEntries().filter((e) => e.kind === 'file'),
);
handle(IPC.FAVORITES_TOGGLE, async (_e, fileId: unknown, info?: unknown) => {
// ValidationError 让 wrap.ts 归类 VALIDATION,UI 端可拿 field 做字段级高亮
// audit-r124:fileId / relPath 上限从 MAX_NOTE_FIELD_CHARS(256)改为
// MAX_IPC_PATH_CHARS(4096)。256 是「id=16 字符 hex」时代的产物,而
// literature 收藏的 fileId 实为 'lit:'+绝对路径(LiteratureList.tsx 传
// r.id),深层 Windows 路径 >256 字符完全常规 → 点 ★ 恒 VALIDATION
// 「收藏失败」,功能对长路径用户不可用。relPath 同理由收藏 toggle 的
// schema 早已声明 4096,运行时与 schema 对齐。
const fileIdStr = requireString(IPC.FAVORITES_TOGGLE, fileId, 'fileId', {
maxLen: MAX_FAVORITES_FILE_ID_CHARS,
});
// info 存在时必须是 object(否则 VALIDATION);内部字段若存在必须是 string
// (否则 VALIDATION 透传 field);undefined 走 '' fallback 兼容既有行为。
// 旧实现 `typeof o.title === 'string' ? o.title : ''` 是 silent coercion,
// renderer 注入 `{ title: 42 }` 静默存成 '' 落盘
let infoClean: { title: string; relPath: string } | undefined;
if (info !== undefined && info !== null) {
const o = requireObject(IPC.FAVORITES_TOGGLE, info);
// title/relPath 加 maxLen 防止 renderer 注入 1MB 字符串撑爆 favorites.md
infoClean = {
title:
o.title === undefined
? ''
: requireString(IPC.FAVORITES_TOGGLE, o.title, 'title', {
maxLen: MAX_NOTE_TITLE_CHARS,
}),
relPath:
o.relPath === undefined
? ''
: requireString(IPC.FAVORITES_TOGGLE, o.relPath, 'relPath', {
maxLen: MAX_IPC_PATH_CHARS,
}),
};
}
const favorited = await favorites.toggle(fileIdStr, infoClean);
return { favorited, fileId: fileIdStr };
});
// ===== 模块数据路径 =====
// 渲染层「在文件管理器中打开」按钮:派生自 getRootPath() + WORKSPACE_DATA_LAYOUT,
// 一次拉取,模块切换时无 IPC
handle<[], WorkspacePaths>(IPC.WORKSPACE_GET_PATHS, async () => buildWorkspacePaths());
// audit-r211:配对的 pull 通道 FAVORITES_GET_STATE 整对下线 —— 它与
// FAVORITES_LIST 同源同形(都是 listEntries() 过滤 file),而 renderer
// 从来没调用过它(src/renderer/ 全域零调用点)。「push 先于 listener
// 注册」的竞态由 useSyncStores 的「先订阅再 load」覆盖(2026-09-10 的修法)。
}

210
src/main/ipc/wrap.ts Normal file
View File

@@ -0,0 +1,210 @@
// src/main/ipc/wrap.ts
import { ipcMain, IpcMainInvokeEvent } from 'electron';
import { ZodType } from 'zod';
import { AppError, LibraryError, ValidationError, errMsg } from '../utils/error';
import { scrubInternal } from '../utils/scrub-internal';
import { createLogger } from '../utils/logger';
import { IpcResult, isIpcErrorCode } from '../../shared/types';
const log = createLogger('ipc');
// 错误响应包络 — 直接复用 shared/types 的 IpcResult,字段镜像一致:
// ok=false 时带 code/message + 透传 status/body/size/field(可选)。
// 这样 preload / renderer / tests 拿的是同一个 discriminated union,
// 不会出现"主进程声明 unknown / shared 声明 string"这种类型谎言。
type IpcErrorPayload = Extract<IpcResult<unknown>, { ok: false }>;
/**
* 异常 → IpcErrorPayload 映射(旧 handle 与新 handleZod 共用)。
*
* 集中点 —— 任何 IpcErrorCode union 扩码 / LibraryError.size / ValidationError.field
* 透传变更,只在这里改一处。直接 inline 两份会让 union widening 漂移
* (r51 BACK_PRESSURE 加进来时,旧 handle 漏,新 handleZod 加,两个不一致,
* 行为上 renderer 收到一个 INTERNAL 一个 BACK_PRESSURE,debug 极难)。
*/
function mapErrorToEnvelope(err: unknown, channel: string): IpcErrorPayload {
const e: AppError = err instanceof AppError ? err : new AppError('INTERNAL', errMsg(err), err);
log.warn('IPC failed', channel, e.code, e.message);
// audit r55 (2026-10-16):wrap.ts catch 兜底 log.error —— 任何非 AppError
// 的 throw(Error / 类型 throw)走到这里,只有 log.warn 配 message 时**不会有
// stack**。开发者排查 IPC handler 内部失败时,console 只看到 'IPC failed X INTERNAL msg',
// 完全不知道哪一行 throw。补一条 log.error 让 stack 留底,排查时间从 30min 降到 5min。
// AppError 已经有结构化语义(code/message/cause),warn 一条足够;这条 error 只
// 针对「裸 Error 滑进来」场景,warn + error 双写不冗余(前者给业务码、后者给位置)。
if (!(err instanceof AppError)) {
log.error('IPC handler error (non-AppError): see stack below', channel, err);
}
// audit v2.0 Phase 0c (2026-09-17):wire boundary 校验 e.code 是否落在
// IpcErrorCode union 内。e.code 来自 AppError 任意 string(API 设计保留
// 灵活性),但 IpcResult.code 已经收窄,需要 cast + 兜底。漏加到 union 的
// 新码会被映射到 INTERNAL,用户看到通用错误 + main 进程日志能定位。
const wireCode: IpcErrorPayload['code'] = isIpcErrorCode(e.code)
? e.code
: (() => {
log.warn('IPC code not in union, falling back to INTERNAL', channel, e.code);
return 'INTERNAL' as const;
})();
const result: IpcErrorPayload = {
ok: false,
code: wireCode,
// audit r52 (2026-10-15) C3:INTERNAL 分支走 scrubInternal 替换用户路径
// 为 <rootPath> 占位符,防止 IPC wire 把家目录透传到 renderer / telemetry。
// 已知 code(DUPLICATE_NAME / VALIDATION / NOT_FOUND / ...)的消息
// 一般不包含绝对路径,直接透传保留上下文。
message: wireCode === 'INTERNAL' ? scrubInternal(e.message) : e.message,
};
// 透传 LibraryError.size(TOO_LARGE 时需要),让 Reader 能提示文件实际大小
if (err instanceof LibraryError && err.size !== undefined) {
result.size = err.size;
}
// 透传 ValidationError.field(VALIDATION 时需要),前端可定位到具体输入项
if (err instanceof ValidationError && err.field !== undefined) {
result.field = err.field;
}
return result;
}
/**
* 在 IPC 入口验证"对象必须是 object、字段必须是 T 类型"。
*
* 解决的核心问题:handle<TArgs> 的 `as TArgs` 跳过了运行时检查,renderer
* 注入坏 payload(手改、第三方扩展、DevTools 注入)就会让 main 进程的
* service 层把非 object 当 object / 把 string 当 array 写到磁盘,炸在
* 下游的 `.map` / `new Set()` / `JSON.stringify` 调用上。
*
* 约定:校验函数返回 true 即放行,false 抛 ValidationError。channelName
* 用于错误信息,field 透传到 UI 便于定位。
*/
export function requireObject(channelName: string, value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new ValidationError(`${channelName}: payload must be an object`, 'payload');
}
return value as Record<string, unknown>;
}
export function requireString(
channelName: string,
value: unknown,
field: string,
options?: { maxLen?: number; maxBytes?: number },
): string {
if (typeof value !== 'string') {
throw new ValidationError(`${channelName}: field "${field}" must be a string`, field);
}
// D2-006: 长度上限 — 防止 renderer 注入 10MB 字符串进 IPC channel(单 channel
// 默认上限 ~100MB,但我们在到达 electron 边界之前先拒,避免大块 alloc)。
// 多数业务字段(id / filename / fileId)≤ 256 字符够用,需要更高上限的地方
// 单独传 options(如 notes body 走 library/metadata.ts MAX_NOTE_BODY_BYTES
// = 5MB 单独检查 —— 现存唯一 5MB 上限 caller)。
if (options?.maxLen !== undefined && value.length > options.maxLen) {
throw new ValidationError(
`${channelName}: field "${field}" exceeds ${options.maxLen} chars`,
field,
);
}
// audit round 10 (2026-08-22):maxBytes 是 maxLen 的 UTF-8 字节维度补充 ——
// CJK / emoji 字符每个占 3-4 字节,光看 char count 会让 5MB 限制被绕过成
// 20MB 实际字节。notes body 等可写字段走 maxBytes 精确控制。
// maxLen 和 maxBytes 是 orthogonal 选项 — 调用方按字段特性选其一或两者都用。
if (options?.maxBytes !== undefined && Buffer.byteLength(value, 'utf8') > options.maxBytes) {
throw new ValidationError(
`${channelName}: field "${field}" exceeds ${options.maxBytes} bytes`,
field,
);
}
return value;
}
// TArgs extends any[] 是 TS 官方文档推荐的"任意 IPC handler 参数"约束模式
// (替代方案 unknown[] 会强制所有调用方显式标 IpcArgs tuple,改动面过大)。
//
// audit r50 (2026-10-15):保留**旧签名 handle<TArgs, TRet>** 兼容未迁移
// 的 30+ 个 handler —— 一次性全迁移会让 round diff 巨大、回归面广。
// 新签名 handleZod<Req, Res>(channel, schemaReq, schemaRes, fn) 走 Zod
// 边界校验;两者并存,由调用方按迁移进度选用。新签名稳定后旧 handle
// 会逐步退出(预计 r52 / r53 删干净)。
//
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function handle<TArgs extends any[], TRet>(
channel: string,
fn: (event: IpcMainInvokeEvent, ...args: TArgs) => Promise<TRet> | TRet,
): void {
ipcMain.handle(channel, async (event, ...args) => {
try {
const data = await fn(event, ...(args as TArgs));
return { ok: true, data };
} catch (err: unknown) {
return mapErrorToEnvelope(err, channel);
}
});
}
/**
* 新签名 handleZod<Req, Res>(channel, schemaReq, schemaRes, fn) —— Zod 边界。
*
* 关键合约:
* 1. **入口校验**:fn 调用前 schemaReq.safeParse(raw)。失败返
* `{ ok: false, code: 'INVALID_INPUT', message, field }`,**不**调 fn。
* 这是「renderer 加新字段 / 注入坏 payload」零成本挡在最外圈。
* 2. **出口校验**:fn 返回值 schemaRes.safeParse(verified)。失败返
* `{ ok: false, code: 'INTERNAL', message: 'response schema mismatch' }`,
* 主进程日志含 verified.error,便于排查主进程 return 结构漂移。
* 3. **错误透传契约**(与旧 handle 同款):AppError / LibraryError /
* ValidationError 错误码 / size / field 全部透传。
*
* 与旧 handle 的兼容性:
* - 旧 handle 没有 Req/Res 校验,fn 收到 `unknown` 数组 → fn 内部用
* requireObject / requireString 逐字段守;
* - 新 handleZod 在 fn 前一步做了 Req schema,fn 收到的 req 是 Zod 解析后的
* 类型(若 schema 用 strict(),多余字段已被 reject);
* - 同一 IPC channel 只能调一次 handle 或 handleZod,不能两次都注册
* (ipcMain.handle 会覆盖前一个)。
*
* @param schemaReq 入参 Zod schema(必填,即使是 z.void() 也得显式声明)
* @param schemaRes 出参 Zod schema(必填,保护 renderer 不被主进程静默改
* 返回结构影响)
*/
export function handleZod<Req, Res>(
channel: string,
schemaReq: ZodType<Req>,
schemaRes: ZodType<Res>,
fn: (event: IpcMainInvokeEvent, req: Req) => Promise<Res> | Res,
): void {
ipcMain.handle(channel, async (event, raw: unknown) => {
const parsedReq = schemaReq.safeParse(raw);
if (!parsedReq.success) {
const issue = parsedReq.error.issues[0];
const field = issue.path.length > 0 ? issue.path.join('.') : undefined;
const message = issue.message;
log.warn('IPC Req schema mismatch', channel, field, message);
const failResult: IpcErrorPayload = {
ok: false,
code: 'INVALID_INPUT',
message,
};
if (field !== undefined) failResult.field = field;
return failResult;
}
try {
const data = await fn(event, parsedReq.data);
const parsedRes = schemaRes.safeParse(data);
if (!parsedRes.success) {
log.error('IPC Res schema mismatch', channel, parsedRes.error);
return {
ok: false,
code: 'INTERNAL' as const,
message: 'response schema mismatch',
};
}
return { ok: true, data: parsedRes.data };
} catch (err: unknown) {
return mapErrorToEnvelope(err, channel);
}
});
}
// redactSensitive 已下线(2026-09 V1.6 audit)—— AI 模块删除后 0 生产 caller,
// 原注释列的"provider debug 端点 echo header 到 response body"威胁源随之消失。
// 通用正则可以套到未来任何 outbound HTTP 场景,但本项目 ssrf.ts 例外的判定标准
// 是"明确的未来使用场景列表"—— redact 没有,留下 18 行 dead code。
// 未来真要做导出 / RSS / Web Clipper 时再从 git history 找回即可。

View File

@@ -0,0 +1,47 @@
// 按 path 读 bytes + size + mtime 给 renderer(reader 走 rkms:// iframe 单文件流)。
// r191 更正:旧注释说「Renderer 走 <input type="file"> + webUtils.getPathForFile()
// 拿 OS picker 绝对路径」—— **已不成立**:OS 选择器只用于导入(拷贝进工作区),
// 本模块的唯一调用方是 ipc/library.ts 的 handler,而它自 r191 起先把路径过
// 工作区白名单(assertInsideAllowedRoot)再进这里 ⇒ 到达本函数的 path 必在
// <rootPath> 内。
// 失败抛 LibraryError 让 wrap.ts 收口为 {ok:false, code, message, size?}。
import { promises as fs } from 'fs';
import { errnoCode, errMsg, LibraryError } from '../utils/error';
import { tr } from '../i18n';
import { createLogger } from '../utils/logger';
import { READER_MAX_SIZE_BYTES } from '../ipc/path-limits';
import { safeCloseFh } from '../utils/safe-write';
import type { ReadFileForReaderResult } from '../../shared/types';
const log = createLogger('library-reader');
export async function readFileForReader(absPath: string): Promise<ReadFileForReaderResult> {
if (typeof absPath !== 'string' || absPath.length === 0) {
throw new LibraryError('VALIDATION', tr('library.fileMissing'));
}
let fh: Awaited<ReturnType<typeof fs.open>> | null = null;
try {
fh = await fs.open(absPath, 'r');
const st = await fh.stat();
if (!st.isFile()) {
throw new LibraryError('IO_ERROR', tr('library.notAFile'));
}
if (st.size > READER_MAX_SIZE_BYTES) {
throw new LibraryError('TOO_LARGE', tr('library.fileTooLarge'), undefined, st.size);
}
const data = await fh.readFile();
return { data, size: st.size, mtime: st.mtimeMs };
} catch (e: unknown) {
if (e instanceof LibraryError) throw e;
const code = errnoCode(e);
if (code === 'ENOENT') {
throw new LibraryError('NOT_FOUND', tr('library.fileMoved'));
}
if (code === 'EACCES') {
throw new LibraryError('EACCES', tr('library.noPermission'));
}
throw new LibraryError('IO_ERROR', errMsg(e));
} finally {
await safeCloseFh(fh, 'fh.close failed in reader finally', log, { path: absPath });
}
}

View File

@@ -0,0 +1,236 @@
// 文献拖入导入(audit-r71)—— OS 拖入任意文件(PDF / docx / txt / md …)拷贝进
// <rootPath>/literature/ 并生成 resources 表 lit 行所需的信息。
//
// 与 notes/import.ts 的关系:复用同一套安全骨架(lstat 不 follow symlink →
// symlink/非普通文件拒绝 → fd-bind stat 拿可信 size → size cap),但**拷贝是
// 二进制安全的 fs.copyFile(COPYFILE_EXCL)**,不是 notes 的 readFile('utf-8')
// + 重写 —— 后者对 PDF 等二进制天然走不通(见 importNoteFromFile:80)。
// 后缀白名单在这里**刻意不存在**:「任意文件入库为文献」就是本模块的语义,
// UI 层也不过滤。
//
// id 体系注记(与 0005 迁移行并存):新行 id = litResourceId(absPath),
// repo colsOf 的 lit 分支把 rel_path 也写成 absPath(resources-repo.ts:204-214,
// root 概念 R63 未落地的退化)—— search 命中回推 id 时两代行都满足
// `id === litResourceId(rel_path)`,自洽。fs 调用一律用 absPath 原始分隔符,
// **不要**用 id(id 里的 `\` 已被 litResourceId 归一为 `/`)。
import { constants, promises as fs, realpathSync } from 'fs';
import { lstat } from 'fs/promises';
import { basename, extname, join, resolve } from 'path';
import type { LiteratureImportResult } from '@shared/api-types';
import { WORKSPACE_DATA_LAYOUT } from '../../shared/types';
import { litResourceId } from '../../shared/resource-id';
import { createLogger } from '../utils/logger';
import { errMsg, errnoCode, LibraryError } from '../utils/error';
import { isCaseInsensitiveFs } from '../utils/platform';
import { safeCloseFh, safeTrash } from '../utils/safe-write';
import { isInsideAnyRoot } from '../ipc/paths';
import { MAX_LIBRARY_IMPORT_BYTES } from '../ipc/path-limits';
import { tr } from '../i18n';
const log = createLogger('literature-import');
/** 文献文件的物理落点目录(拷入式导入的目标根)。 */
export function literatureDir(rootPath: string): string {
return join(rootPath, WORKSPACE_DATA_LAYOUT.LITERATURE_DIR);
}
/**
* 把 OS 拖入的文件拷贝进 <rootPath>/literature/,返回 lit 行的全部字段来源。
*
* 撞名策略:COPYFILE_EXCL 原子探占,O_EXCL 报 EEXIST 时自动 `name (1).ext`
* 递增重试(上限 1000 —— pathological 场景直接报错,不无限循环)。
*
* @param sourcePath OS 绝对路径(renderer 经 webUtils.getPathForFile 取得)
* @param rootPath 当前工作目录根(getRootPath())
*/
export async function importLiteratureFile(
sourcePath: string,
rootPath: string,
): Promise<LiteratureImportResult> {
const name = basename(sourcePath);
// lstat 不 follow:防 symlink 指向 root 外大文件 / 特殊文件被拷进来。
// 与 notes/import.ts 不同,这里失败直接 throw —— notes 是批量循环可以
// skipped++,文献导入单文件 IPC,失败必须 surface 给用户(toast)。
let st: Awaited<ReturnType<typeof lstat>>;
try {
st = await lstat(sourcePath);
} catch (err: unknown) {
throw new LibraryError(
'IO_ERROR',
tr('literature.importFailed', { name, reason: errMsg(err) }),
err,
);
}
if (st.isSymbolicLink() || !st.isFile()) {
throw new LibraryError('VALIDATION', tr('literature.importNotRegularFile', { name }));
}
// audit-r151:TOCTOU 防 symlink-swap —— 与 notes/import.ts 同款。
// lstat 后 attacker 在 lstat/open 之间把普通文件换成 symlink → 任意路径,
// fs.open follow 后会把 symlink target 拷进 <rootPath>/literature/。
//
// audit-r175 (2026-10-19) —— 旧实现用 `realpathSync(sourcePath) !== sourcePath`
// 判「文件被 symlink 重定向」,这在 Windows 上**恒为真**:
// realpathSync 永远返**反斜杠**形态,而 sourcePath 可能是**正斜杠**
// 形态(LiteratureService.moveFile 把行的 absPath 存成 toPosix(...))→
// 只要文献被搬动过一次,再复制/粘贴它就报「不是普通文件,无法作为
// 文献导入」。实锤(本机 Node):realpathSync('C:/x/native.txt') →
// 'C:\\x\\native.txt' ≠ 输入。从没搬过的根目录文献正常,所以现象是
// 「搬过之后就废了」。
//
// 换成**inode 比对**(lstat 拿到的 st vs 打开后 fd 绑定的 fhStat):
// 跨分隔符形态 / 跨大小写都稳定,又真的能识别「文件被换成 symlink」
// (ino/dev 变了)。realpath 只在 inode 不可用(ino 与 dev 均为 0,
// 少数网络 FS)时作为兜底,且比的是**归一化后**的路径。
// 失败语义与 notes/import.ts 对齐 —— 同样失败走 IO_ERROR(单文件 IPC,
// 与 lstat 失败的 throw 路径同形态)。
// size 绑到 fh.stat(TOCTOU-004 同 notes/import.ts:71-73):lstat 的 size 在
// lstat→open 之间可能已被换;fd 绑 inode 后的 stat 才是可信数字。
let fh: Awaited<ReturnType<typeof fs.open>> | null = null;
let srcSize = 0;
try {
fh = await fs.open(sourcePath, 'r');
const fhStat = await fh.stat();
if (!fhStat.isFile()) {
throw new LibraryError('VALIDATION', tr('literature.importNotRegularFile', { name }));
}
// r175:文件在 lstat→open 之间被换成 symlink / 别的文件 → 拒收。
// ino/dev 有一方为 0(少数 FS 不提供)→ 退回归一化 realpath 比对。
const inodeUsable = st.ino !== 0 || st.dev !== 0;
if (inodeUsable) {
if (fhStat.ino !== st.ino || fhStat.dev !== st.dev) {
throw new LibraryError('VALIDATION', tr('literature.importNotRegularFile', { name }));
}
} else {
const real = realpathSync(sourcePath);
const norm = (p: string): string => {
const posix = p.split('\\').join('/');
return isCaseInsensitiveFs ? posix.toLowerCase() : posix;
};
if (norm(real) !== norm(resolve(sourcePath))) {
throw new LibraryError('VALIDATION', tr('literature.importNotRegularFile', { name }));
}
}
if (fhStat.size > MAX_LIBRARY_IMPORT_BYTES) {
throw new LibraryError(
'TOO_LARGE',
tr('literature.importTooLarge', { name, size: fhStat.size }),
undefined,
fhStat.size,
);
}
srcSize = fhStat.size;
} catch (err: unknown) {
if (err instanceof LibraryError) throw err;
throw new LibraryError(
'IO_ERROR',
tr('literature.importFailed', { name, reason: errMsg(err) }),
err,
);
} finally {
await safeCloseFh(fh, 'fh.close failed during literature import', log, { path: sourcePath });
}
const dir = literatureDir(rootPath);
await fs.mkdir(dir, { recursive: true });
const ext = extname(sourcePath);
const base = basename(sourcePath, ext);
let dst = join(dir, name);
let renamed = false;
let copied = false;
for (let n = 1; n <= 1000; n++) {
try {
await fs.copyFile(sourcePath, dst, constants.COPYFILE_EXCL);
copied = true;
break;
} catch (err: unknown) {
if (errnoCode(err) !== 'EEXIST') {
// audit-r243(2026-10-21):**删掉拷贝失败留下的半份文件**。
// fs.copyFile 先创建/截断 dst 再拷内容,中途失败(ENOSPC / EIO /
// 断电)会留下一个**截断的** dst。旧实现直接抛错,那份半截文件
// 留在 literature/ 里 ⇒ chokidar 'add' ⇒ reload 把当正常文献
// 收进 byId + DB ⇒ 用户列表里出现一个打不开的「文献」,还占着
// 磁盘空间(下方的 size 校验分支会 rm dst,但 copy 失败这条路径
// 根本走不到那里)。
// 只在「非 EEXIST」分支删 —— EEXIST 说明 dst 是**别人已有的文件**
// (下面要换名重试),删了就是毁用户数据。
await fs.rm(dst, { force: true }).catch(() => {
/* 残留清不掉:reload 会按 size/mtime 收进索引,用户手动删即可 */
});
throw new LibraryError(
'IO_ERROR',
tr('literature.importFailed', { name, reason: errMsg(err) }),
err,
);
}
dst = join(dir, `${base} (${n})${ext}`);
renamed = true;
}
}
if (!copied) {
throw new LibraryError(
'CONFLICT',
tr('literature.importFailed', { name, reason: `name collision over ${1000} tries` }),
);
}
// 拷贝后校验:copyFile 按路径重开源(fd 闸到 copy 之间仍有 TOCTOU 窗口),
// dst 尺寸 ≠ srcSize 说明源在窗口内被换 → 废弃 dst 报错,不落半吊子文件。
const dstStat = await fs.stat(dst);
if (dstStat.size !== srcSize) {
await fs.rm(dst, { force: true });
throw new LibraryError(
'IO_ERROR',
tr('literature.importFailed', { name, reason: 'size changed during copy' }),
);
}
return {
id: litResourceId(dst),
absPath: dst,
filename: basename(dst),
size: dstStat.size,
mtime: dstStat.mtimeMs,
renamed,
};
}
/**
* 删文献行时按需物理删文件——只有位于 <rootPath>/literature/ 内(拖入导入
* 拷出来的行)才动文件;0005 迁移行指向用户旧库路径,只删行不动文件。
*
* audit-r175 (2026-10-19) —— 删除走**系统回收站**(electron shell.trashItem),
* 与笔记域完全一致(notes 侧 r155+ 已改 safeTrash)。旧实现是 fs.unlink
* 硬删:文献多为 PDF / docx(用户手里往往只有这一份原文),误删即永久丢失,
* 没有任何找回途径;而同一栏里的笔记删除却能进回收站 —— 两侧行为不一致,
* 既违反用户「文献和笔记的操作要对应」的诉求,也是一条真实的数据丢失路径。
*
* 错误语义:
* - ENOENT 容忍(文件已经没了 = 目标已达成)—— safeTrash 内部已吞
* - 其它失败(典型:PDF 被阅读器锁着 / 回收站不可用)→ throw,调用方
* **保留行**并报错给用户;顺序契约是 removeLiteratureFile 先、
* deleteResource 后,宁可留 orphan 行也不静默吞文件
* - root 外路径直接返回 false(未动文件),不算错误
*/
export async function removeLiteratureFile(absPath: string, rootPath: string): Promise<boolean> {
const dir = literatureDir(rootPath);
if (!isInsideAnyRoot([dir], absPath, { caseInsensitive: isCaseInsensitiveFs })) {
return false;
}
try {
// safeTrash:走 electron shell.trashItem(Win 回收站 / macOS Trash /
// Linux XDG trash),用户可从系统回收站恢复;ENOENT 内部吞掉。
// 越界 / symlink / 非普通文件由 safeTrash 拒收(AppError),这里统一
// 包成 LibraryError(IO_ERROR) —— 文献域文案保持在这一域内。
await safeTrash(absPath);
} catch (err: unknown) {
throw new LibraryError(
'IO_ERROR',
tr('literature.removeFailed', { name: basename(absPath), reason: errMsg(err) }),
err,
);
}
return true;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,211 @@
// src/main/literature/rename-file.ts
//
// r187:文献**单文件**重命名的纯逻辑层(计划 + 落盘两步)。
//
// 为什么单独成文件:重命名有三件容易出错、且**互相独立**的判定 ——
// 1. 新文件名的合法性(不能带路径分隔符 / 盘符 / 保留字符);
// 2. 「不改扩展名」的补全规则(用户只敲词干时按原扩展名补齐);
// 3. **不能覆盖已存在的文件**(数据丢失红线)。
// 塞进 service 类里会和 moveFile / moveDir 的搬移逻辑糊在一起,
// 复用与单测都吃亏;这里做成两个纯函数,service 只负责编排。
//
// 关键不变式:
// - 目标已存在 ⇒ 抛 VALIDATION,**绝不**覆盖(Windows fs.rename 走
// MoveFileExW(MOVEFILE_REPLACE_EXISTING),静默替换目标文件 —— 用户
// 的另一份 PDF 直接没了,这是数据丢失)。
// - 仅大小写重命名('a.pdf' → 'A.pdf')在 Windows 上是合法操作,目标
// 「看起来存在」其实是同一个文件 ⇒ 用 dev/ino 判定同一文件后放行。
// - 目录归属不变:只在**原目录**内改文件名(与 notes 侧 r175 的
// 「重命名只改文件名,保留所在目录」同约定)。
import { promises as fs } from 'fs';
import { basename, dirname, extname, join } from 'path';
import { LibraryError } from '../utils/error';
import { errMsg } from '../utils/error';
import { retryOnWindowsTransient } from '../utils/retry-transient';
import { tr } from '../i18n';
// audit 2026-10-21:Win32 命名规则收成**一份**(shared/win32-name.ts)——
// 本文件原先手抄了一份保留设备名 Set + 内联的字符正则,notes 域抄了
// 另一份,文件夹名那处没抄(⇒ 死夹,P1-1)。现在三处同源。
import {
hasControlChars,
hasTrailingDotOrSpace,
hasWin32ForbiddenChars,
isReservedDeviceName,
} from '@shared/win32-name';
/** 单个文件名长度上限(与 ext4 的 255 字节 NAME_MAX 对齐)。 */
const MAX_FILENAME_LEN = 255;
export interface LiteratureRenamePlan {
/** 改名前的绝对路径。 */
oldAbs: string;
/** 改名后的绝对路径(同目录,只换文件名)。 */
newAbs: string;
/** 实际落地的文件名(含扩展名)。 */
newFilename: string;
/** 是否只改了大小写(目标路径与旧路径在大小写不敏感比较下相同)。 */
caseOnly: boolean;
}
/**
* 校验 + 拼装重命名计划(纯函数,不碰磁盘)。
*
* @param oldAbs 改名前的绝对路径(决定目录与原扩展名)
* @param rawInput 用户输入(完整文件名,或只给词干)
*/
export function planLiteratureRename(oldAbs: string, rawInput: string): LiteratureRenamePlan {
const input = rawInput.trim();
if (input === '') {
throw new LibraryError('VALIDATION', tr('literature.renameInvalidName', { name: input }));
}
if (input.length > MAX_FILENAME_LEN) {
throw new LibraryError(
'VALIDATION',
tr('literature.renameInvalidName', { name: input.slice(0, 40) }),
);
}
// 路径分隔符 / 盘符 / 相对段 —— 一律拒收:重命名**只改文件名**,不改位置。
if (/[\\/]/.test(input) || /^[A-Za-z]:/.test(input)) {
throw new LibraryError('VALIDATION', tr('literature.renameInvalidName', { name: input }));
}
// 控制字符 + Win32 保留字符 <>:"|?*(路径分隔符上面已单独拒)
if (hasWin32ForbiddenChars(input) || hasControlChars(input)) {
throw new LibraryError('VALIDATION', tr('literature.renameInvalidName', { name: input }));
}
// '.' / '..' 与「结尾带点/空格」在 Win32 上非法(会被静默截断)。
if (input === '.' || input === '..' || hasTrailingDotOrSpace(input)) {
throw new LibraryError('VALIDATION', tr('literature.renameInvalidName', { name: input }));
}
// Windows 保留设备名(按**词干**判 —— 'CON.txt' 在 Win32 一样非法)。
if (isReservedDeviceName(input)) {
throw new LibraryError('VALIDATION', tr('literature.renameInvalidName', { name: input }));
}
// 扩展名补全:用户只敲词干('paper')时按**原**扩展名补齐('.pdf');
// 用户显式给了扩展名('paper.docx')就照用 —— 文献可以是 pdf/epub/...
// 多种格式,不能像 notes 那样一律 .md。
const oldExt = extname(oldAbs);
let newFilename = input;
if (extname(input) === '') {
newFilename = oldExt ? `${input}${oldExt}` : input;
}
// 保留**原路径的分隔符风格**:库里 absPath 有两种形态(导入写的是 OS
// 原生路径,搬移写的是 POSIX 化形态 —— 见 service 的 importFile /
// moveFile)。改名不该顺手把风格换掉(否则同一目录里的行一半 'C:\'、
// 一半 'C:/',肉眼看不出来但搜索/去重会挑花眼)。
// 做法:切掉旧 basename,原样接上新文件名。
const base = basename(oldAbs);
const prefix = oldAbs.slice(0, oldAbs.length - base.length);
const newAbs = prefix + newFilename;
const caseOnly =
newAbs !== oldAbs && newAbs.toLowerCase() === oldAbs.toLowerCase();
return { oldAbs, newAbs, newFilename, caseOnly };
}
/**
* 两个路径是否指向**同一个**文件(用于「仅大小写重命名」判定)。
* 先比路径(Windows 大小写不敏感),再比 dev/ino(同文件的两条硬链接)。
*/
async function isSameFile(a: string, b: string): Promise<boolean> {
if (a === b) return true;
const norm = (p: string): string => p.replace(/[\\/]+$/, '').toLowerCase();
if (norm(a) === norm(b)) return true;
try {
const [sa, sb] = await Promise.all([fs.lstat(a), fs.lstat(b)]);
return sa.dev === sb.dev && sa.ino === sb.ino && sa.ino !== 0;
} catch {
return false;
}
}
/**
* 落盘:预检目标不存在 → fs.rename(带 Windows 瞬时占用退避重试)。
*
* 预检存在的原因:fs.rename 在目标已存在时**从不**抛 EEXIST(Windows 走
* MoveFileExW(MOVEFILE_REPLACE_EXISTING),POSIX 是 rename(2)),两者都
* 静默替换目标文件 —— 直接 rename 等于「把用户另一份同名 PDF 覆盖掉」。
* 所以先 lstat 拦下,真撞名时抛 VALIDATION,交给 UI 提示用户改名。
*/
export async function performLiteratureRename(plan: LiteratureRenamePlan): Promise<void> {
const { oldAbs, newAbs, newFilename } = plan;
if (newAbs === oldAbs) return; // 完全同名 → no-op
const targetExists = await fs.lstat(newAbs).then(
() => true,
() => false,
);
if (targetExists) {
// 仅大小写重命名:目标就是源文件本身(Win32 大小写不敏感)→ 放行。
if (!plan.caseOnly || !(await isSameFile(oldAbs, newAbs))) {
throw new LibraryError(
'VALIDATION',
tr('literature.renameTargetExists', { name: newFilename }),
);
}
await renameCaseOnly(oldAbs, newAbs, newFilename);
return;
}
await renameOnDisk(oldAbs, newAbs, newFilename);
}
/**
* 仅大小写重命名 —— 两步走:先改成临时名,再改成目标名。
*
* 为什么不能一步到位:Win32 的 MoveFileExW(MOVEFILE_REPLACE_EXISTING)对
* 「同一文件、仅大小写不同」的目标会当成「目标已存在」而失败(报
* EEXIST / EPERM),用户在资源管理器里能做的改名,在 fs.rename 这层
* 反而做不了。两步走把「撞名」这一中间态消掉,两个 rename 各自的目标
* 都不存在。
*
* 回滚:第二步失败时把临时名改回原名(源文件不丢);第一步失败直接抛。
*/
async function renameCaseOnly(
oldAbs: string,
newAbs: string,
newFilename: string,
): Promise<void> {
const dir = dirname(oldAbs);
const tmp = join(dir, `.rkms-rename-${process.pid}-${Date.now()}`);
await renameOnDisk(oldAbs, tmp, basename(tmp));
try {
await renameOnDisk(tmp, newAbs, newFilename);
} catch (err: unknown) {
// 回滚:把临时名改回原名(尽力而为 —— 回滚也失败就只能靠 chokidar
// 的 reload 自愈,但那要等磁盘状态变化)。
try {
await fs.rename(tmp, oldAbs);
} catch {
// 吞掉回滚异常:原始错误更有信息量,回滚失败由 reload 自愈。
}
throw err;
}
}
async function renameOnDisk(
oldAbs: string,
newAbs: string,
newFilename: string,
): Promise<void> {
try {
await retryOnWindowsTransient(
() => fs.rename(oldAbs, newAbs),
() => Promise.resolve(),
);
} catch (err: unknown) {
const code = (err as { code?: string }).code;
if (code === 'ENOENT') {
throw new LibraryError('NOT_FOUND', tr('literature.notFound', { id: basename(oldAbs) }));
}
if (code === 'EEXIST' || code === 'ENOTEMPTY') {
throw new LibraryError(
'VALIDATION',
tr('literature.renameTargetExists', { name: newFilename }),
);
}
throw new LibraryError(
'IO_ERROR',
tr('literature.renameFailed', { name: newFilename, reason: errMsg(err) }),
);
}
}

378
src/main/location.ts Normal file
View File

@@ -0,0 +1,378 @@
// 数据路径定位:用 userData/location.json 作为规范指针
// 解决问题:
// 1. bootstrap 需要在不读取 settings 的情况下知道 dataPath
// 2. 用户切换 dataPath 时,新位置立即被记录
// 3. 旧版本的 settings 在 userData/settings.json (已废弃) 也能被自动迁移
//
// audit-r56:删 defaultLibraryPath —— 库整体下线,没有「库默认路径」概念;
// location.ts 也不再有「find settings with libraryPath」这种优先序;
// resolveDataPath 的所有分支都按 rootPath / dataPath 解析。location.json
// schema 不变(只含 dataPath),向下兼容老格式。
import { app } from 'electron';
import { promises as fs, lstatSync } from 'fs';
import { isAbsolute, join } from 'path';
import { createLogger } from './utils/logger';
import { errMsg, errnoCode } from './utils/error';
import { WORKSPACE_DATA_LAYOUT } from '@shared/types';
import { atomicWriteFileInternal } from './utils/safe-write';
// audit r29 (2026-09-21):location.json mkdir 路径走 lstat 同款 symlink
// 防御。不能用 ipc/paths.assertNotSymlink:那个 helper 同时校验
// allowedRoots(),而 location.ts 是 bootstrap 最早阶段,setDataPath()
// 还没调用 → allowedRoots() 空 → 任何路径都 EPERM → 永远 fallback 到
// 默认。这里只做末端 lstat 是 symlink 检查,不动 allowedRoots 校验
// (留给后续 ipc handler 在 setDataPath 之后做)。
const log = createLogger('location');
// audit round 10 (2026-09-15):L7 — 'ResearchHub' 之前在 defaultParentPath()
// 里字面写,改父目录名要 grep 全仓。抽成 BRAND_DIR_NAME 顶层常量,未来
// 白标 / OEM 改目录名改一处即可。注意:location.json / settings.json /
// 数据迁移都依赖这个目录名,改值需要走数据迁移流程,本轮只抽常量不动值。
const BRAND_DIR_NAME = 'ResearchHub';
interface LocationData {
dataPath: string;
version: number;
}
const LOCATION_VERSION = 1;
function locationPath(): string {
return join(app.getPath('userData'), 'location.json');
}
// 重构 (2026-09-07):数据默认路径统一到 <Documents>/ResearchHub/。
// 之前库在 <Documents>/ResearchHub/library,data 在 D:\ResearchHub\<app>
// (Win 有 D 盘)或 appData/<appName>(其他)。用户拷机器 / 备份目录时要在
// 两个完全不同的位置找;在设置页看到的两个路径也互不挨着,直观上没法
// 一眼看出"这两个都是这 app 的东西"。现在统一到 <Documents>/ResearchHub/:
// - data: <Documents>/ResearchHub/data (settings / index / notes 等)
// 跨平台一致 —— app.getPath('documents') 内部按 OS 选对应目录
// (Win %USERPROFILE%\Documents,macOS ~/Documents,Linux $XDG_DOCUMENTS_DIR
// 或 ~/Documents),无需 win32 的 existsSync('D:\\') 探测,默认就是用户
// 最熟悉的"我的文档"位置。Documents 不可写(Linux XDG 损坏等)走
// initSettings 里的「dataPath/library」双层 fallback,与之前一致。
//
// audit (2026-09-07):data 子目录用 'data' 不用 app.getName() —— 后者是
// productName "Literature Management",路径会变成 ".../ResearchHub/
// Literature Management/",目录名又臭又长还带空格。'data' 与
// 'library' 同长同结构,对称好认。
function defaultParentPath(): string {
return join(app.getPath('documents'), BRAND_DIR_NAME);
}
// audit 2026-10-02(workspace-unification 落地):defaultRootPath 是**权威的**
// 默认工作目录根。defaultDataPath 保留为转发别名 —— 它是「旧名」,但对外
// 仍有 IPC / 设置页等调用方,直接删会是一次纯改名 churn;等下一次大版本
// 随 AppSettings.dataPath 一起删。
export function defaultDataPath(): string {
return defaultRootPath();
}
// audit 2026-10-02(workspace-unification 落地):defaultRootPath 现在是**权威的
// 默认工作目录根**,defaultDataPath 转发到它。
//
// 取值是 `defaultParentPath()/data` 而不是"更干净"的 `defaultParentPath()`,
// 是刻意的:本项目吃过「同一件事有两种形状」的亏 —— r32 的 data/data/notes
// 事故就是「新装用户」与「升级用户」落在不同布局上引发的。既有用户的
// rootPath 直接由旧 dataPath 提升而来(零搬迁),所以默认布局必须与之一致,
// 否则新装用户又会拿到第三种形状。丑一点,换布局唯一。
export function defaultRootPath(): string {
return join(defaultParentPath(), 'data');
}
// 读取 location.json,失败时尝试从旧 userData/settings.json 迁移,再尝试默认路径,最后用全新默认
export async function resolveDataPath(): Promise<{ path: string; fromLegacy: boolean }> {
const def = defaultDataPath();
// perf(2026-08-24):3 个 readFile 并发跑(原版是串行 await),互不依赖。
// HDD / AV-on-Windows 下串行 3 次读 5-30ms,Promise.allSettled 一次拿到全部。
// 哪个文件不存在就让对应分支走 null,不阻断其它两个。
const [locationResult, defaultResult, userDataResult] = await Promise.allSettled([
fs.readFile(locationPath(), 'utf-8'),
fs.readFile(join(def, WORKSPACE_DATA_LAYOUT.SETTINGS_FILE), 'utf-8'),
fs.readFile(join(app.getPath('userData'), WORKSPACE_DATA_LAYOUT.SETTINGS_FILE), 'utf-8'),
]);
// 1. 优先读 location.json
let locationDataPath: string | null = null;
if (locationResult.status === 'fulfilled') {
try {
const parsed: LocationData = JSON.parse(locationResult.value);
if (parsed.dataPath) {
// audit round 10 (2026-09-15):location.json 是 userData 内的可信文件,
// 但同一进程 / 同用户共享 box / 第三方 app 都有写权限。恶意构造
// {dataPath: "../../etc"} 让 fs.mkdir 成功后,getDataPath() 解析出
// 相对 cwd 的非预期路径,后续所有 fs.writeFile 都漂到攻击者选的
// 位置。加 isAbsolute 兜底,与 settings/migration.ts:24 /
// settings/validate.ts 同一防御口径,非绝对路径 → 视为 null 走
// 默认 fallback,不静默落进 mkdir。
if (!isAbsolute(parsed.dataPath)) {
log.warn('location.json dataPath not absolute, falling back to default', parsed.dataPath);
locationDataPath = null;
} else {
locationDataPath = parsed.dataPath;
// audit 2026-09-07:把 mkdir 从 fire-and-forget 改成 await ——
// 旧实现把失败吞到 .catch 里,后续步骤(line 127 readFile / 各
// fs.writeFile)却假定目录已就绪,Documents 不可写 / 路径被卸载
// 时会让 settings / index 写到不存在的目录,UI 立即崩。
// 失败时落到默认路径走"全新默认"分支,行为更可预测。
try {
// audit r28 (2026-09-21):recursive mkdir 在 NTFS / ext4 上
// 会 follow 中间 symlink,允许攻击者先在父目录里布一个
// 「指向 /etc」 的伪目录 → location.json 让 app mkdir 沿 →
// 后续 settings.json 写到攻击者控制的目录。先 lstat 路径
// 末端确认不是 symlink(同 assertNotSymlink 末端检查口径);
// 父目录链由递归 mkdir 自身不 follow 保证(mkdir {recursive:true}
// 见 EEXIST 但不静默吞解析结果)。
// audit r29 (2026-09-21):location.ts 是 bootstrap 最早阶段,
// setDataPath() 还没调用,不能用 ipc/paths.assertNotSymlink
// (它同时校验 allowedRoots(),此时根列表是空)。这里只做末端
// lstat 是 symlink 检查;父链中间 symlink 防御留给
// ipc/paths.assertInsideAllowedRoot(setDataPath 之后)。
await fs.mkdir(parsed.dataPath, { recursive: true });
try {
const lst = lstatSync(parsed.dataPath);
if (lst.isSymbolicLink()) {
log.warn(
'location.json dataPath is a symlink, falling back to default',
parsed.dataPath,
);
locationDataPath = null;
}
} catch (lstatErr: unknown) {
// lstat 失败(权限翻转 / ENOENT 等)不阻断 — mkdir 已成功,
// 后续 readFile/writeFile 会抛真错误,这里多一次 syscall
// 不替上层掩盖。
const code = errnoCode(lstatErr);
if (code !== 'ENOENT') {
log.warn('location.json dataPath lstat failed', {
path: parsed.dataPath,
code,
});
}
}
} catch (err: unknown) {
log.warn('mkdir location.dataPath failed, falling back to default', errMsg(err));
locationDataPath = null;
}
if (locationDataPath) log.info('Resolved data path from location.json', parsed.dataPath);
}
}
} catch (err: unknown) {
log.warn('location.json parse failed', errMsg(err));
}
} else {
const code = errnoCode(locationResult.reason);
if (code !== 'ENOENT') {
log.warn('Failed to read location.json', errMsg(locationResult.reason));
}
}
// 2. 默认路径的 settings.json(可能包含更完整的旧设置,如 rootPath)
let defaultSettingsRaw: string | null = null;
let defaultSettingsParsed: Record<string, unknown> | null = null;
if (defaultResult.status === 'fulfilled') {
try {
defaultSettingsRaw = defaultResult.value;
defaultSettingsParsed = JSON.parse(defaultSettingsRaw);
} catch (err: unknown) {
// audit round 17 (2026-09-02):损坏 JSON 不再静默 —— 之前 0 字节 / 权限翻转 /
// 截断的 settings.json 会被默认 silently skip,bootstrap 退回新默认路径,
// 老用户数据看似"凭空消失"。至少留一行 warn 给 telemetry。
log.warn('default settings.json parse failed', errMsg(err));
}
}
// 3. userData 的 settings.json (legacy)
let userDataSettingsParsed: Record<string, unknown> | null = null;
if (userDataResult.status === 'fulfilled') {
try {
userDataSettingsParsed = JSON.parse(userDataResult.value);
} catch (err: unknown) {
log.warn('userData settings.json parse failed', errMsg(err));
}
}
// 4. 智能合并: 找出所有候选设置中 rootPath / dataPath 任意一个不为空的
// audit-r56:删「libraryPath 不为空」的优先序 —— library 整体下线。
// 现在按 rootPath → dataPath → 默认 的取值顺序,优先级仍是 new > userData
// > default(同 r32 修法),不再有「带库 settings 优先于无库 settings」的
// 隐式排序。早期版本如果 settings.json 有 libraryPath 而没有 rootPath,
// 这里仍能取到 dataPath(老字段),不会被孤立。
const candidates: { source: string; parsed: Record<string, unknown> }[] = [];
if (locationDataPath) {
try {
const newSettingsRaw = await fs.readFile(
join(locationDataPath, WORKSPACE_DATA_LAYOUT.SETTINGS_FILE),
'utf-8',
);
candidates.push({ source: 'new', parsed: JSON.parse(newSettingsRaw) });
} catch (err: unknown) {
// readFile 失败(权限 / 路径不存在):warn 但不阻断 bootstrap。
log.warn('new settings.json read/parse failed', errMsg(err));
}
}
if (userDataSettingsParsed)
candidates.push({ source: 'userData', parsed: userDataSettingsParsed });
if (defaultSettingsParsed) candidates.push({ source: 'default', parsed: defaultSettingsParsed });
// 5. 优先用有 rootPath / dataPath 的设置(任一字段非空都算「有设置」)
// audit-r56:把 libraryPath 判定换成 rootPath / dataPath 的 union 判定。
// 行为上「new 路径 + 默认路径都同时有 settings.json」时仍由 new 获胜
// (candidates 数组顺序保证),不重蹈 r32 「默认获胜导致 location.json
// 被改写到默认」的覆辙。
const withPath = candidates.find((c) => {
const rp = c.parsed['rootPath'];
const dp = c.parsed['dataPath'];
return (
(typeof rp === 'string' && rp) ||
(typeof dp === 'string' && dp)
);
});
if (withPath) {
log.info('Found settings with rootPath/dataPath in', withPath.source);
// audit 2026-10-03:MEDIUM — `rootPath` 必须与 `dataPath` 同等参与取值。
// 旧实现只看 `dataPath`,缺字段就退回默认目录。但 ADR-0003 之后
// `rootPath` 才是工作目录根的**单一真相**(`dataPath` 只是为降级到旧版本
// 而镜像写入的 @deprecated 字段)。于是「有 rootPath 但没有 dataPath」的
// settings.json —— 手改过、旧版本写的、或将来某个版本真的不再写镜像 ——
// 会被判成「没有工作目录」而 fallback 到 `def`,紧接着 writeLocation
// **把 location.json 改写成默认目录**。
// 用户可见后果:应用在一个全新的空工作区里启动,真笔记被彻底孤立
// (不是丢在磁盘上,只是再也看不到),而 location.json 已经被改写,
// 下次启动连线索都没了。这正是 r32「首启必丢笔记」那一类事故的
// 同构形态 —— 同样是「同一件事有两种布局来源」。
// 取值顺序 rootPath → dataPath → 默认,与 initSettings 的优先级一致。
const rawDataPath =
typeof withPath.parsed['rootPath'] === 'string'
? withPath.parsed['rootPath']
: typeof withPath.parsed['dataPath'] === 'string'
? withPath.parsed['dataPath']
: def;
// audit 2026-10-01:MEDIUM — 补齐与 location.json 同一口径的绝对路径校验。
// 读 location.json 的分支(location.ts:108 附近)有 `isAbsolute` 防御,
// 注释也明写「与 settings/migration.ts / settings/validate.ts 同一防御
// 口径」;但**这份 settings.json 的 dataPath 却没有**:
// - 相对路径会被 fs.mkdir 按 process.cwd() 解析(dev 下是项目根,
// 打包后是 asar 侧目录)→ 整个工作区落到一个完全意外的位置;
// - 更糟的是紧接着 writeLocation(realDataPath) 会把这个相对路径
// **持久化**进 userData/location.json —— 下次启动 location.ts:108
// 的 isAbsolute 才会 reject,但那时目录已经在 cwd 下建好了。
// 来源是 userData / 默认路径下的**旧版本** settings.json(不是本 app
// 写出的可信格式),所以必须自己校验。
const realDataPath = isAbsolute(rawDataPath) ? rawDataPath : def;
if (!isAbsolute(rawDataPath)) {
log.warn('settings.json dataPath is not absolute, falling back to default', {
source: withPath.source,
dataPath: rawDataPath,
fallback: def,
});
}
// audit 2026-10-03:fallback 到默认目录会把真工作区孤立掉,而
// writeLocation(realDataPath) 紧接着就把这个选择**持久化**进
// location.json —— 之后既看不到原工作区,也没有任何提示。
// 这里**故意只 log 不弹 toast**:`resolveDataPath()` 是在 bootstrap 的
// 第一步(index.ts:startup)跑的,那时 createWindow() 还没执行,
// BrowserWindow.getAllWindows() 是空的 —— 推出去的 toast 不会有任何人
// 收到。一个「看起来在通知、实际静默失效」的信号比没有更糟。
// 而且由于上一段已经让 rootPath 参与取值,能走到这里的只剩
// 「记录值不是绝对路径」这种真正的坏数据,频率极低。
if (realDataPath === def && rawDataPath !== def) {
log.warn('Falling back to default data path; previous workspace may be orphaned', {
source: withPath.source,
claimed: rawDataPath,
fallback: def,
});
}
await fs.mkdir(realDataPath, { recursive: true });
if (realDataPath !== locationDataPath) {
await writeLocation(realDataPath);
}
// 关键: 如果目标 settings 不存在,或者目标 settings 缺 rootPath / dataPath
// (审计-r56:不再判 libraryPath),都覆盖。
const targetSettingsPath = join(realDataPath, WORKSPACE_DATA_LAYOUT.SETTINGS_FILE);
let needWrite = true;
try {
const existing = JSON.parse(await fs.readFile(targetSettingsPath, 'utf-8')) as {
rootPath?: unknown;
dataPath?: unknown;
};
if (existing.rootPath || existing.dataPath) {
needWrite = false; // 已有完整设置,不覆盖
log.info('Target settings already has rootPath/dataPath, keeping it');
} else {
log.info('Target settings missing rootPath/dataPath, will overwrite');
}
} catch (err: unknown) {
// audit 2026-09-14:之前裸 catch 把 EACCES / EBUSY 当成"不存在",会覆盖
// 用户磁盘上的合法 settings.json。仅 ENOENT 走 needWrite=true,其他报错。
if (errnoCode(err) !== 'ENOENT') {
log.warn('settings.json read failed unexpectedly', {
path: targetSettingsPath,
err: errMsg(err),
});
}
}
if (needWrite) {
await atomicWriteFileInternal(
targetSettingsPath,
JSON.stringify(withPath.parsed, null, 2),
);
log.info('Copied settings (with path) to', realDataPath);
}
return { path: realDataPath, fromLegacy: true };
}
// 6. 没有 rootPath / dataPath, 用 location.json 或默认
if (locationDataPath) {
return { path: locationDataPath, fromLegacy: false };
}
// 7. userData settings 有 dataPath
let legacyDataPath =
typeof userDataSettingsParsed?.['dataPath'] === 'string'
? userDataSettingsParsed['dataPath']
: null;
// r197:绝对路径守卫(defense-in-depth)——
// **先说清可达性**:本分支在当前流程里其实**到不了**。第 5 步的
// withPath 分支已经把「userData settings 里带 rootPath/dataPath」的
// 情形全部截住(那里已有 isAbsolute 校验,见 :264),能走到第 7 步时
// userData settings 要么没有 dataPath、要么 dataPath 是空串 ⇒
// legacyDataPath 恒为 null。
// 但守卫仍然留着:本分支的形态是「拿候选路径 → fs.mkdir → writeLocation
// 持久化」,一旦将来流程调整(withPath 的候选顺序变了 / 某个分支提前
// return),漏掉绝对路径校验的后果是工作区被建在 process.cwd() 下并
// 写进 location.json 长期固化 —— 与 :255-263 描述的危害完全一致。
// 一行守卫换一条「将来不会静默改回去」的保险,划算。
if (legacyDataPath && !isAbsolute(legacyDataPath)) {
log.warn('userData settings dataPath is not absolute — ignoring it', legacyDataPath);
legacyDataPath = null;
}
if (legacyDataPath) {
await fs.mkdir(legacyDataPath, { recursive: true });
await writeLocation(legacyDataPath);
return { path: legacyDataPath, fromLegacy: true };
}
// 8. 默认 settings 没有 dataPath 但存在
if (defaultSettingsRaw) {
await fs.mkdir(def, { recursive: true });
await writeLocation(def);
return { path: def, fromLegacy: true };
}
// 9. 全新默认
await fs.mkdir(def, { recursive: true });
log.info('Using fresh default data path', def);
return { path: def, fromLegacy: false };
}
export async function writeLocation(dataPath: string): Promise<void> {
const data: LocationData = { dataPath, version: LOCATION_VERSION };
const target = locationPath();
await atomicWriteFileInternal(target, JSON.stringify(data, null, 2));
log.info('Location written', dataPath);
}

167
src/main/menu.ts Normal file
View File

@@ -0,0 +1,167 @@
// 工作台应用菜单构建。菜单点击 ≠ 直接执行 action:经 IPC.MENU_INVOKE 把 {id}
// 推给渲染层,CommandDispatcher.invoke(id) 走单一调度入口(命令面板/快捷键共享),
// 新增菜单项 = 改 registry + menu 各一处。
// macOS 与 Win/linux 菜单结构差异:macOS 顶部有 App 菜单(about/services/hide/quit),
// Win/linux 走 File.quit;Edit 内 cut/copy/paste 是 Electron role 自带本地化,不手动翻译。
// label 走 src/main/i18n.ts t();切语言由 renderer 调 IPC.MENU_REBUILD 重建。
import { app, BrowserWindow, Menu, type MenuItemConstructorOptions } from 'electron';
import { IPC } from '../shared/types';
import { t, type Lang } from './i18n';
type Sender = (id: string) => void;
/** 把菜单点击转换为 IPC 推送到当前窗口 */
function makeSender(getWindow: () => BrowserWindow | null): Sender {
return (id: string) => {
const w = getWindow();
if (!w || w.isDestroyed()) return;
w.webContents.send(IPC.MENU_INVOKE, { id });
};
}
/** 构建工作台应用菜单(getWindow 支持返回 null,lang 切换时重建) */
export function buildAppMenu(getWindow: () => BrowserWindow | null, lang: Lang): Menu {
const isMac = process.platform === 'darwin';
const send = makeSender(getWindow);
// macOS 顶栏第一项必须是 app.name(系统约定),只有它出现在应用菜单区
const macAppMenu: MenuItemConstructorOptions[] = isMac
? [
{
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{
label: t('menu.preferences', lang),
accelerator: 'CmdOrCtrl+,',
click: () => send('settings.open'),
},
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
},
]
: [];
// File 菜单:Win/linux 文件菜单第一;macOS 紧跟 App 菜单
const fileMenu: MenuItemConstructorOptions = {
label: t('menu.file', lang),
submenu: [
{
label: t('menu.file.newNote', lang),
accelerator: 'CmdOrCtrl+N',
click: () => send('file.newNote'),
},
// 「新建收藏夹」(⌘⇧N)2026-09 下线 — collections 并入 favorites
// audit-r72:「重新扫描」(⌘R → library.rescan)死项删除 —— library 扫描
// 面随 r56 退役,renderer command registry 里 'library.rescan' 已在
// r63 移除(menu:test 曾锁它),且 i18n.ts 无 'menu.file.rescan' 键 →
// t() 回退漏出 raw key。新表键-菜单一致性守卫见
// tests/unit/main/menu-label-i18n-keys.test.ts。
{ type: 'separator' },
{
label: t('menu.file.save', lang),
accelerator: 'CmdOrCtrl+S',
click: () => send('file.save'),
},
{ type: 'separator' },
isMac ? { role: 'close' } : { role: 'quit' },
],
};
// Edit 菜单:Electron role 提供原生 cut/copy/paste/undo/redo,自带系统本地化,
// 不在 main i18n 里翻译。文本撤销走 macOS role:undo,由 textarea/input 自动处理,
// 与命令系统互不干扰。
const editMenu: MenuItemConstructorOptions = {
label: t('menu.edit', lang),
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
...(isMac
? ([
{ role: 'pasteAndMatchStyle' },
{ role: 'delete' },
{ role: 'selectAll' },
] as MenuItemConstructorOptions[])
: ([
{ role: 'delete' },
{ type: 'separator' },
{ role: 'selectAll' },
] as MenuItemConstructorOptions[])),
],
};
// View 菜单:⌘B(左栏折叠)于 r242 回归;⌘J / ⌘P / Zen 模式仍下线
const viewMenu: MenuItemConstructorOptions = {
label: t('menu.view', lang),
submenu: [
{
// r242:左侧列表面板折叠 / 展开(与标题栏按钮、renderer ⌘B 同一个命令 id)
label: t('menu.view.toggleSidebar', lang),
accelerator: 'CmdOrCtrl+B',
click: () => send('view.toggleSidebar'),
},
{
label: t('menu.view.toggleStatusBar', lang),
accelerator: 'CmdOrCtrl+Shift+S',
click: () => send('view.toggleStatusBar'),
},
{ type: 'separator' },
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
],
};
// Window 菜单
const windowMenu: MenuItemConstructorOptions = {
label: t('menu.window', lang),
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
...(isMac
? ([{ type: 'separator' }, { role: 'front' }] as MenuItemConstructorOptions[])
: ([{ role: 'close' }] as MenuItemConstructorOptions[])),
],
};
// Help 菜单:「访问文档」旧菜单项指 404 仓库已删;⌘? ShortcutsSheet 同步下线
const helpMenu: MenuItemConstructorOptions = {
role: 'help',
submenu: [
{
label: t('menu.help.openSettings', lang),
accelerator: 'CmdOrCtrl+,',
click: () => send('settings.open'),
},
],
};
// navigateMenu(⌘[/⌘]/⌘1..8)整体删除 — useTabsStore + TabsBar 下线,顶栏模块切换走快捷键
const template: MenuItemConstructorOptions[] = [
...macAppMenu,
fileMenu,
editMenu,
viewMenu,
windowMenu,
helpMenu,
];
return Menu.buildFromTemplate(template);
}
/** 挂到当前应用,启动时调用一次。切语言时由 renderer 通过 IPC.MENU_REBUILD 重新调用 */
export function installAppMenu(getWindow: () => BrowserWindow | null, lang: Lang): void {
Menu.setApplicationMenu(buildAppMenu(getWindow, lang));
}

View File

@@ -0,0 +1,140 @@
// createNote / renameNote 共用同一份校验(防 silent drift):抛 ValidationError +
// field 让 IPC map 表单字段;不放 shared 是因为 ValidationError 是 main-only。
import { ValidationError } from '../utils/error';
import { tr } from '../i18n';
// audit-r160:接受 .md / .markdown 两后缀 —— r155-L1 扩 scanNotes / import.ts
// 端接受 .markdown 后,用户从 Obsidian / Joplin / Bear 导入或手动丢入 .markdown
// 文件,chokidar 'add' 走 scanNotes 能收,但 New Note / Rename 走本 validator
// 旧 regex 只认 .md → 主动改名到 .markdown 被 INVALID_CHARS 拒。一致性破洞用
// `(?:d|arkdown)` 非捕获组 + case-insensitive 与 scanNotes / import.ts 对齐。
//
// audit-r170:接受圆括号 —— 「复制 → 粘贴」的默认副本名是「X (copy).md」/
// 「X (copy 2).md」(Windows 资源管理器同款约定),旧 regex 不收 '(' ')' →
// createNote 直接抛 ValidationError('validation.noteFilenameInvalidChars')
// → **每一次 notes 复制粘贴都失败**(用户报「粘贴功能存在问题」的主因)。
//
// ── r195:字符集改成「与文献域同一把尺子」─────────────────────────────────
//
// 旧实现是一条**白名单 regex**:
// /^[A-Za-z0-9_\-(一-龥][A-Za-z0-9_\- 一-龥()/]*\.m(?:d|arkdown)$/i
// 即:只收字母 / 数字 / 下划线 / 连字符 / 空格 / 圆括号 / 中文。**其余一律拒**。
//
// 后果(用户可复现,且与「列表 == 文件夹」的诉求直接冲突):
// 1. 侧栏**列得出**这些文件 —— collectNotePaths 只按后缀收(.md/.markdown),
// 不做字符过滤 ⇒ 「A&B.md」「C++ 学习.md」「v1.2 草稿.md」「100%完成.md」
// 「结果=最终.md」「O'Brien 访谈.md」全都**照常显示在列表里**;
// 2. 但**同样的文件名**,应用自己却拒绝管理:
// - 拖入导入 → importNoteFromFile → createNote → 本 validator
// → ValidationError ⇒ **文件根本进不来**(用户从 Obsidian /
// 网盘同步来的笔记大量命中);
// - 改名到这种名字 → 同样被拒。
// ⇒ **「看得见、管不了」** —— 与 r186 在文件夹层修掉的 I2 不变量
// (「列得出来的,一定能进得去」)是同一个洞的字符集版本。
// 3. 两侧不对齐:文献域的改名校验(`literature/rename-file.ts`
// `planLiteratureRename`)只拒 Win32 真正不能用的 `< > : " | ? *`
// + 控制字符,「C++ 笔记.pdf」改名**通过**;同名 .md **被拒**。
// 用户诉求「两个列表的操作要对齐」⇒ 同一个字符,一边行一边不行。
//
// 修法:白名单 → **黑名单**。只拒「在 Windows / macOS / Linux 上真的建不出来」
// 的字符与形态,其余(点、& + = % ! @ $ # , ; ' ~ [] {} 等)一律放行 ——
// 它们不进路径语法(不与 '/' '\' 冲突),不削弱 resolveInsideRootPath 的
// 越界防护(越界靠路径解析 + 根目录校验 + 本文件下面的段规则,不靠字符集)。
//
// 段规则(逐段,与 shared/rel-path.ts 的核心段规则 + 文献域改名规则对齐):
// - 段非空('a//b.md' 拒)
// - 段不恰为 '.' / '..'(路径穿越;r186 收窄后的口径)
// - 段不以 '.' 开头(隐藏项 —— collectNotePaths 也跳过 dot 项,
// 应用不管理隐藏文件 ⇒ 列不出也管不了,两侧一致)
// - 段不含 '\' 与 Win32 保留字符 <>:"|?* 与控制字符
// - 段不以 '.' 或空格结尾(Win32 会**静默截断**,存下去的名字与用户
// 输入的不一样 ⇒ 用户下次找不到自己的文件)
// - 段的「词干」(第一个 '.' 之前)不是 Windows 保留设备名
// (con / prn / aux / nul / com1-9 / lpt1-9,含 'con.txt' 这种形态)
//
// 另加一条**域内**规则:改名(newFilename)**不得含 '/'** —— r175 起
// 「重命名只改文件名,保留所在目录」,而旧 regex 收 '/' ⇒ 用户在改名框里
// 写 'sub/x.md' 会把笔记**搬进子目录**(违反 r175 的不变式);文献域同样
// 拒 '/'。create 路径(filename)仍然允许 '/' —— 新建到子目录靠它。
// https://learn.microsoft.com/windows/win32/fileio/naming-a-file
//
// audit 2026-10-21:Win32 命名规则收成**一份** —— shared/win32-name.ts。
// 本文件原先自己抄了一份(WIN32_FORBIDDEN_CHARS / CONTROL_CHARS /
// RESERVED_DEVICE_NAME),文献域(renamed-file.ts)抄了第二份,而
// **文件夹名**那一处压根没抄 ⇒ 抄漏处正是死夹的来源(P1-1)。
// 现在三处都引用 shared/win32-name.ts,规则改一处即三处同步。
import {
hasControlChars,
hasWin32ForbiddenChars,
isReservedDeviceName,
} from '@shared/win32-name';
// 视觉长度(grapheme 计数),防 MAX_PATH 260 越界
export const NOTE_FILENAME_MAX_LENGTH = 120;
/**
* Trim → 补 .md → 校验字符集 / 段规则 / 长度 / 保留名。
* @throws ValidationError(EMPTY / INVALID_CHARS / RESERVED_NAME / TOO_LONG)
*/
export function normalizeAndValidateNoteFilename(
input: string,
field: 'filename' | 'newFilename',
): string {
const trimmed = (input ?? '').trim();
if (!trimmed) {
throw new ValidationError(
field === 'newFilename'
? tr('validation.noteFilenameEmpty')
: tr('validation.noteFilenameEmptyOriginal'),
field,
);
}
let fn = trimmed;
// audit-r160:补后缀逻辑同步接受 .markdown —— 与后缀白名单一致。
// 旧实现只 endsWith('.md'),用户输入 'foo.markdown' 走 validator 后再补
// '.md' → 'foo.markdown.md' → INVALID_CHARS。改成 endsWith 两个后缀。
if (!/\.m(?:d|arkdown)$/i.test(fn)) fn += '.md';
// r195:段规则取代白名单 regex —— 先按 '/' 切段(改名路径 field='newFilename'
// 一律拒 '/' 见下)。
const rawSegments = fn.split('/');
if (field === 'newFilename' && rawSegments.length > 1) {
// r195:改名只改文件名,不改位置(r175 不变式;文献域同款)。
throw new ValidationError(tr('validation.noteFilenamePathSeparator', { name: fn }), field);
}
for (const seg of rawSegments) {
if (seg === '') {
throw new ValidationError(tr('validation.noteFilenameInvalidChars', { name: fn }), field);
}
// 隐藏项:collectNotePaths 跳过 dot 项 ⇒ 应用不列也不管,两侧一致。
if (seg.startsWith('.')) {
throw new ValidationError(tr('validation.noteFilenameInvalidChars', { name: fn }), field);
}
// 路径穿越:段恰为 '..'(r186 收窄后的口径 —— 'a..b' 是合法名字)。
if (seg === '..') {
throw new ValidationError(tr('validation.noteFilenameInvalidChars', { name: fn }), field);
}
if (hasWin32ForbiddenChars(seg) || seg.includes('\\') || hasControlChars(seg)) {
throw new ValidationError(tr('validation.noteFilenameInvalidChars', { name: fn }), field);
}
// Win32 静默截断:结尾的点 / 空格会被 OS 悄悄去掉 ⇒ 存下来的名字与
// 用户输入的不一样(用户下次找不到自己的文件)。文献域同名规则同款。
if (/[. ]$/.test(seg)) {
throw new ValidationError(tr('validation.noteFilenameInvalidChars', { name: fn }), field);
}
// Windows 保留设备名:按**词干**判(第一个 '.' 之前)—— 'con.txt.md'
// 的词干是 'con' ⇒ 拒(与文献域 planLiteratureRename 同款)。
if (isReservedDeviceName(seg)) {
throw new ValidationError(tr('validation.noteFilenameReserved', { seg }), field);
}
}
const visualLen = [...fn].length;
if (visualLen > NOTE_FILENAME_MAX_LENGTH) {
throw new ValidationError(
tr('validation.noteFilenameTooLong', { len: visualLen, max: NOTE_FILENAME_MAX_LENGTH }),
field,
);
}
return fn;
}

190
src/main/notes/import.ts Normal file
View File

@@ -0,0 +1,190 @@
// OS 拖入 .md → 解析 frontmatter → 创建笔记 + 写 body。单文件导入
// (renderer 循环发 IPC,主进程由 writeLock 串行化);复用 standalone.createNote +
// writeNote。5MB 上限走 MAX_NOTE_BODY_BYTES 共享常量。
import { promises as fs, realpathSync } from 'fs';
import { lstat } from 'fs/promises';
import { basename, extname, resolve } from 'path';
import matter from 'gray-matter';
import type { NotesImportResult } from '@shared/api-types';
import { createLogger } from '../utils/logger';
import { LibraryError, errMsg } from '../utils/error';
import { tr } from '../i18n';
import { safeCloseFh } from '../utils/safe-write';
import { isCaseInsensitiveFs } from '../utils/platform';
import { createNote, writeNote } from './standalone';
import { cleanTags, cleanTitle } from '../utils/frontmatter';
import { MAX_NOTE_BODY_BYTES as MAX_IMPORT_BYTES } from '../ipc/path-limits';
const log = createLogger('notes-import');
/** 从 OS 拖入的 .md 文件创建新笔记。
*
* @param sourcePath OS 文件绝对路径(renderer 通过 File.path 拿到,Electron 30 可用)
* @param dataPath 当前设置的数据目录(createNote 的目标)
* @returns 导入统计(成功时 copied=1,失败时 errors[0] 携带 reason)
*/
/**
* 从 OS 拖入的 .md 文件创建新笔记。
*
* @param sourcePath OS 绝对路径(renderer 通过 File.path / webUtils 拿)
* @param dataPath 当前数据目录(createNote 的目标)
* @returns 导入统计(copied/skipped/errors + createdIds 列表)
*/
export async function importNoteFromFile(
sourcePath: string,
dataPath: string,
): Promise<NotesImportResult> {
// 异步 lstat:同步 lstat 阻塞事件循环 ~5-30ms,改 fs.promises.lstat 让 libuv worker
// 处理;用 lstat 不 stat 正确识别 symlink(防 follow 越界)。lstat 失败走 skipped
let stat: Awaited<ReturnType<typeof lstat>>;
try {
stat = await lstat(sourcePath);
} catch (err: unknown) {
log.warn('lstat failed, skipping', sourcePath, errMsg(err));
return { copied: 0, skipped: 1, errors: [{ source: sourcePath, reason: errMsg(err) }] };
}
if (stat.isSymbolicLink()) {
return { copied: 0, skipped: 1, errors: [] };
}
if (!stat.isFile()) {
return { copied: 0, skipped: 1, errors: [] };
}
// audit-r151:TOCTOU 防 symlink-swap —— lstat 后 attacker 可在 lstat/open 之间把
// 普通文件换成 symlink → /etc/passwd。Node 的 fs.constants.O_NOFOLLOW 不存在,
// 用「打开后比对 inode」收紧(见下方 open 块内的 r197 段)。
//
// audit-r197:旧实现用 `realpathSync(sourcePath) !== sourcePath` 判「被 symlink
// 重定向」,这在 Windows 上**恒为真**:realpathSync 永远返**反斜杠**形态,而
// sourcePath 可能是**正斜杠**形态(笔记被搬动过一次后 relPath 就是 POSIX
// 形态)⇒ 只要路径形态对不上就静默 skip,用户看到「拖进来没反应」。
// 文献侧在 r175 已查清并改成 inode/dev 比对,笔记侧这次跟上(两域同款)。
// 真正的判定挪到 open 之后(拿到 fd 绑定的 fhStat 才可信)——
// 见下面 open 块里的同名注释。
// 后缀白名单(.md / .markdown),大小写不敏感 — 主进程最后一道防线,renderer 可能
// 因 bug / 注入绕过 UI 层 endsWith(.md) 预判
// rawExt 保留原始大小写:basename(path, ext) 大小写敏感,后续 validator 要求
// lowercase '.md',传 '.md' 不会剥掉 '.MD' 后缀
const rawExt = extname(sourcePath);
const ext = rawExt.toLowerCase();
if (ext !== '.md' && ext !== '.markdown') {
throw new LibraryError('IO_ERROR', tr('notes.importNotMd', { name: basename(sourcePath) }));
}
// size 绑到 fh.stat(TOCTOU-004):lstat 拿到的 size 在 lstat 与 readFile 之间可能被替换,
// fd 解析 symlink 后绑到目标 inode,后续 stat/read 从同一 fd 走,size 校验才可信
// gray-matter 解析失败时降级:raw 当 body,filename 当 title —— 不抛,让用户内容能导入。
// 极端损坏(non-UTF-8)的 .md 抛真错误向上冒泡
let raw: string;
let fh: Awaited<ReturnType<typeof fs.open>> | null = null;
try {
fh = await fs.open(sourcePath, 'r');
const fhStat = await fh.stat();
if (!fhStat.isFile()) {
log.warn('import: source not regular file, skipping', sourcePath);
return { copied: 0, skipped: 1, errors: [] };
}
// r197:TOCTOU 防 symlink-swap —— inode/dev 比对(与 literature/import.ts 同款)。
// lstat 拿到的 st 与「打开后 fd 绑定的」fhStat 是同一个文件 ⇒ ino/dev 相同。
// 文件在 lstat→open 之间被换成 symlink / 别的文件 ⇒ ino/dev 变了 ⇒ 拒收。
// inode 不可用(ino 与 dev 均为 0,少数网络 FS)时退回归一化 realpath 比对。
const inodeUsable = stat.ino !== 0 || stat.dev !== 0;
if (inodeUsable) {
if (fhStat.ino !== stat.ino || fhStat.dev !== stat.dev) {
log.warn('import: source replaced between lstat and open, skipping', sourcePath);
return { copied: 0, skipped: 1, errors: [] };
}
} else {
const real = realpathSync(sourcePath);
const norm = (p: string): string => {
const posix = p.split('\\').join('/');
return isCaseInsensitiveFs ? posix.toLowerCase() : posix;
};
if (norm(real) !== norm(resolve(sourcePath))) {
log.warn('import: source is a symlink (realpath divergence), skipping', sourcePath);
return { copied: 0, skipped: 1, errors: [] };
}
}
if (fhStat.size > MAX_IMPORT_BYTES) {
throw new LibraryError('IO_ERROR', tr('notes.importTooLarge', { size: fhStat.size }));
}
raw = await fh.readFile('utf-8');
} catch (err: unknown) {
throw new LibraryError('IO_ERROR', tr('notes.importFailed', { reason: errMsg(err) }));
} finally {
await safeCloseFh(fh, 'fh.close failed during notes import', log, { path: sourcePath });
}
let body = raw;
let titleFromFm: string | undefined;
let tagsFromFm: string[] | undefined;
// r197:源 frontmatter 的**其余**字段原样带过去(doi / year / author /
// citekey / status / …)。旧实现只挑 title + tags 两个键,其余全丢 ——
// 用户从 Obsidian / Zotero / Obsidian 插件导出的笔记,那些研究元数据
// 进了应用就消失(正文里没有、frontmatter 里也没有,等于**永久丢失**)。
// 与「保存时保留用户手写 frontmatter」是同一条契约(2026-10-04 起
// writeNote 用 existingFm 垫底),导入是这条契约的入口缺口。
const extraFm: Record<string, unknown> = {};
// audit 2026-09-16 r21:M15 — frontmatter 解析失败时既 log 又 surface 给 UI。
// 之前只 log.warn,用户拖入一个 .md 没出现笔记也不知道为什么。
// errors[] 让 renderer toast「导入成功但 frontmatter 解析失败」,内容
// 仍按"全文当 body"降级导入(语义不变)。
const parseWarnings: string[] = [];
try {
const parsed = matter(raw);
body = parsed.content;
const fm = parsed.data as Record<string, unknown>;
// cleanTitle 200-char 截断:与 readNote/scanNotes 同源,防源 .md 里 1MB title 无 cap 进入
const cleanedTitle = cleanTitle(fm);
if (cleanedTitle !== null) titleFromFm = cleanedTitle;
tagsFromFm = cleanTags(fm) ?? undefined;
// 其余字段:title / tags 走各自的清洗路径,id / created / updated 由
// writeNote 统一管(见 standalone.ts writeNote 的 fm 组装段)——
// 这五个键不在此处搬运,避免「源文件的 id 覆盖掉按 relPath 重算的 id」。
for (const [k, v] of Object.entries(fm)) {
if (k === 'title' || k === 'tags' || k === 'id' || k === 'created' || k === 'updated') continue;
extraFm[k] = v;
}
} catch (err: unknown) {
const reason = errMsg(err);
parseWarnings.push(`frontmatter: ${reason}`);
log.warn('frontmatter parse failed, importing as plain body', sourcePath, reason);
}
// basename(去 ext)传 validator,内部会再补一次 .md 后缀(createNote 需要它)
// 用 rawExt 不 lowercase:basename 大小写敏感,传 '.md' 不会剥掉 '.MD'
const sourceBase = basename(sourcePath, rawExt);
const note = await createNote(dataPath, sourceBase);
// preserveExisting 保留 createNote 写的 created 字段,updated 由 writeNote 自动刷新
// tags 仅在 frontmatter 显式给时才传(undefined → preserveExisting 兜底,避免空 tags 写成 null)
const fm: Record<string, unknown> = { ...extraFm };
if (titleFromFm !== undefined) fm['title'] = titleFromFm;
if (tagsFromFm !== undefined) fm['tags'] = tagsFromFm;
await writeNote(dataPath, note.relPath, body, fm, { preserveExisting: true });
// copied=1(导入本身成功);createdIds 让 NotesList 对最后一条自动 select + focus + scroll
// r175:createdRelPaths —— 主进程用它把导入的笔记登记进 NotesService.byId
// + resources 表(旧实现导入完全绕开 service,导入的笔记在本次会话内
// 永远不进 byId:renderer 紧接着的「移入当前文件夹」必 NOT_FOUND,
// 而 resources 表缺行 → 全局搜索永远搜不到这条笔记)。
if (parseWarnings.length > 0) {
return {
copied: 1,
skipped: 0,
errors: [],
warnings: parseWarnings,
createdIds: [note.id],
createdRelPaths: [note.relPath],
};
}
return {
copied: 1,
skipped: 0,
errors: [],
createdIds: [note.id],
createdRelPaths: [note.relPath],
};
}

40
src/main/notes/note-id.ts Normal file
View File

@@ -0,0 +1,40 @@
// src/main/notes/note-id.ts
//
// audit C3R77 (2026-08-22):scanNotes + readNote 各有一份字面
// `typeof fm['id'] === 'string' && /^[a-f0-9]{16}$/.test(fm['id']) ? fm['id'] : computeFileId(relPath)`
// 同文件内 ~50 行重复。改一边漏另一边 = "scan 时拿的是 frontmatter id,read 时拿的是
// relPath hash" — 同一个笔记 2 个 ID,silent 数据不一致(收藏夹引用旧 ID,
// 主表 list 跳到新 ID)。
//
// 抽到独立模块后,NOTE_ID_RE + extractNoteId 单一 source-of-truth,改动一次两边生效。
//
// 设计取舍:
// - 独立模块而不是放进 filename-validator.ts:filename 和 id 是两套独立 schema,
// 文件名允许 CJK/空格/斜杠,ID 只允许 16-char 十六进制,混合放会模糊语义
// - `fm` 参数用 `Record<string, unknown>`(两个 caller 都在 gray-matter 解析后立即
// 取 `fm['id']`,此时类型还是 unknown,cast 边界保留在 caller)
// - 不放 path-mapping.ts(computeFileId 所在):那模块是纯路径/ID 计算,无 frontmatter 概念
// audit-r56:library/path-mapping.ts 整体下线 —— 搬到 ./path-mapping(notes 同目录)。
import { computeFileId } from './path-mapping';
/**
* 笔记 ID 允许形态:16-char 小写十六进制(SHA-256 截前 16 位)。
* 历史:旧版笔记文件 frontmatter 写有 id 字段(手写或导出兼容),新代码统一由
* relPath SHA-256 派生。两条路径必须共用同一 regex 才不会产生 2 个 ID 视图。
*/
export const NOTE_ID_RE = /^[a-f0-9]{16}$/;
/**
* 从 gray-matter frontmatter + relPath 派生稳定 ID。
* 优先 frontmatter.id(旧兼容),否则由 relPath SHA-256 派生。
*
* @param fm gray-matter 解析后的 frontmatter(`fm['id']` 是 unknown,需 runtime 验证)
* @param relPath POSIX 风格相对路径(库根目录下)
*/
export function extractNoteId(fm: Record<string, unknown>, relPath: string): string {
const candidate = fm['id'];
return typeof candidate === 'string' && NOTE_ID_RE.test(candidate)
? candidate
: computeFileId(relPath);
}

View File

@@ -0,0 +1,71 @@
// 笔记专用路径 / ID 助手 —— audit-r56 从 src/main/library/path-mapping.ts 搬过来:
//
// library/path-mapping.ts 随 library 整套(scan / import / watcher)下线,
// 但 toPosix / computeFileId / filenameOf / toNotePath 这四个 helper 被
// notes 子树继续使用:
//
// - service.ts: toPosix, computeFileId
// - standalone.ts: toPosix, computeFileId, filenameOf, toNotePath
// - note-id.ts: computeFileId
//
// 原模块还包含 library-only 工具(deriveLibraryPath / isInsideLibrary /
// isInsideData / toSidecarPath / toAIPath 等)随 library 整体退役,本文件
// 不再保留这些。命名前缀不再加 'library' 概念 —— notes 的 helper 放在
// notes/ 下,call site 不再跨模块读 library/*。
//
// 设计约束:
// - toPosix / computeFileId 零 fs 依赖,理论上可下沉到 @shared/path-utils,
// 但目前 3 个 caller 全是 main,放 shared 会让 shared 模块增长边角;
// 留 main 侧就近调用,等真有 renderer 需求再搬。
// - 行为契约与原 path-mapping.ts 完全一致 —— 见 CHANGELOG / tests
// 对 toPosix / computeFileId 的字面比对。
import { createHash } from 'crypto';
import { join, sep } from 'path';
import { WORKSPACE_DATA_LAYOUT } from '@shared/types';
/**
* 把 path 模块产生的 native 分隔符统一折叠为 posix `/`。
*
* 之所以统一:notes 在 settings.json / DB 落盘的 relPath 必须与 OS 无关,
* Windows 上 `path.relative('a\\b', 'a\\b\\c')` 返 `'c'`,`path.relative('a/b','a/b/c')`
* 才返 `'c'`,但 SQL FTS / favorites 跨设备同步都按字面字符串匹配,字面 `'a\\b\\c'`
* 与 `'a/b/c'` 算两个文件。统一为 `/` 是已落地的口径。
*/
export function toPosix(p: string): string {
return p.split(sep).join('/');
}
/**
* 由 relPath 派生稳定 16-char 十六进制 ID —— SHA-256 截前 16 位。
*
* **大小写敏感**:`computeFileId('A.md') !== computeFileId('a.md')`。
* 上层不归一化大小写是故意的 —— 文件系统语义本身在 macOS(APFS default
* case-insensitive) / Windows(NTFS) / Linux(ext4) 之间漂移,笔记
* relPath 一旦落盘再以另一种大小写去重 hash,会出现「同文件两 id」
* 的不可见问题,而用户机器上**只有一个文件**。解决思路是上游 relPath
* 已经按磁盘真实形态记录,这层 hash 保持对字面串敏感即可。
*/
export function computeFileId(relPath: string): string {
return createHash('sha256').update(relPath).digest('hex').slice(0, 16);
}
/**
* 把 relPath 收成 `<dataPath>/notes/<relPath>` 绝对路径。
* 仅做 join + sep 兼容 —— relPath 已由 caller 保证是 posix 形态(经过
* toPosix),这里直接 join 到 dataPath,sep 由运行时 path 模块决定。
*/
export function toNotePath(dataPath: string, relPath: string): string {
return join(dataPath, WORKSPACE_DATA_LAYOUT.NOTES_DIR, relPath);
}
/**
* 取 relPath 末段作为 filename。语义与 Node `path.basename` 一致
* (按 posix 分隔符切,取末段),不依赖 `path.basename` 防止 Windows 下
* `\\` 与 `/` 混用导致结果漂移。
*/
export function filenameOf(relPath: string): string {
// 末段分隔符统一按 posix 切
const parts = relPath.split('/');
return parts[parts.length - 1] ?? relPath;
}

1276
src/main/notes/service.ts Normal file

File diff suppressed because it is too large Load Diff

1249
src/main/notes/standalone.ts Normal file

File diff suppressed because it is too large Load Diff

226
src/main/protocol.ts Normal file
View File

@@ -0,0 +1,226 @@
// 自定义协议 rkms:// — 让 <iframe> / <img> 在任何父窗 origin 下稳定加载磁盘文件。
//
// 背景:Chromium 把 file:// 当 opaque origin(Chrome 89+),父窗是 file:// /
// http://localhost:5173 都跨域,iframe 被拦 → PDF / 图片空白。rkms:// 是
// privileged scheme(同 http/https),走 scheme 自己的 origin,不参与 file://
// 跨域判断。主进程用 pathToFileURL + net.fetch 把 rkms://xxx 翻成 file://xxx
// 交给 Chromium file fetcher 读盘,不需要 webSecurity:false。
//
// 调用顺序(Electron 强约束):
// 1. registerRkmsSchemePrivileges() 必须在 app.whenReady() resolve 之前
// 2. registerRkmsProtocolHandler() 必须在 app ready 之后、首窗 URL 之前
// 接入点见 src/main/index.ts。
import { protocol, net } from 'electron';
import { pathToFileURL } from 'node:url';
import { createLogger } from './utils/logger';
import { errMsg, errnoCode } from './utils/error';
import { RKMS_SCHEME } from '../shared/protocol';
// 共享 ipc/paths.ts:assertInsideAllowedRoot 替代内联 predicate,杜绝
// protocol.ts / shell.ts / reader.ts 三处独立写「rel === '' || (!rel.startsWith('..')
// && !isAbsolute(rel))」的语义漂移
// r191 更正:上面这行注释里点名的 reader.ts **当时并没有**接这份 helper
// (只有本文件与 shell.ts 接了)⇒ library:readFileForReader 这条 IPC 比
// iframe 侧更松,renderer 能读工作区外的任意文件。r191 已把 reader 的
// handler(ipc/library.ts)补上白名单 —— 三处现在口径一致。
import { assertInsideAllowedRoot } from './ipc/paths';
import { contentDispositionInline } from './utils/content-disposition';
const log = createLogger('protocol');
// 必须在 app.whenReady 之前调用。privileges 决定 scheme 能不能像 http 一样走
// fetch / fetch API / CORS / stream,这里全部按 http 同级开
export function registerRkmsSchemePrivileges(): void {
protocol.registerSchemesAsPrivileged([
{
scheme: RKMS_SCHEME,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true,
// 不开 bypassCSP:renderer 没设 CSP,开了反而留攻击面;PDF / 图片也不需要
// 绕过 CSP 跑脚本,Chromium PDFium 走内部 renderer
bypassCSP: false,
},
},
]);
}
// 必须在 app ready 之后、createWindow 之前调用。
// macOS app.on('activate') 重入 createWindow() 时不需要重注册 — protocol.handle
// 第二次调用会替换 handler 但闭包内 log/状态不变,等价浪费一次 setter 跳变
export function registerRkmsProtocolHandler(): void {
protocol.handle(RKMS_SCHEME, async (request) => {
try {
const url = new URL(request.url);
// pathname 三种形态:
// Windows drive: rkms:///D:/My%20Papers/p.pdf → "/D:/My%20Papers/p.pdf"
// Windows UNC: rkms:////server/share/p.pdf → "//server/share/p.pdf"
// POSIX: rkms:///home/user/p.pdf → "/home/user/p.pdf"
let decoded: string;
try {
decoded = decodeURIComponent(url.pathname);
} catch {
// 不完整 % 序列会让 decodeURIComponent 抛 URIError,冒到下方 catch 走
// 500 + errMsg body 变成「URI malformed」探测 oracle;本地吞,返 400
log.warn('rkms: malformed uri encoding', request.url);
return new Response('rkms: malformed uri encoding', { status: 400 });
}
// Windows drive 要去前导 '/',否则 pathToFileURL 把它当 root 下的字面量
// "D:" 而非盘符;POSIX 路径前导 '/' 必须保留;UNC 要把正斜杠翻反斜杠
let absPath = decoded;
if (process.platform === 'win32') {
if (/^\/[A-Z]:\//i.test(decoded)) {
absPath = decoded.slice(1);
} else if (decoded.startsWith('//')) {
absPath = decoded.replace(/\//g, '\\');
}
}
// 白名单校验 — 防止 renderer 拿到任意路径都能读(iframe 内 fetch().text()
// 可外泄 ~/.ssh/id_rsa)。settings 未初始化同样拒绝(403),不降级放行。
// audit-r124 分支语义校正:EINVAL 的真实来源是 paths.ts:132
// 'path required'(空串/非字符串路径);「settings 未初始化」实际由
// getRootPath 抛 INTERNAL 发出,落进下方通用 deny 分支。两分支结果
// 都是 403,仅 body/log 归因不同 —— 原注把 EINVAL 当成
// 「settings not ready」的专属信号是误标,protocol.test.ts:219 用
// 合成 {code:'EINVAL'} 锁的正是本分支的 deny 行为(不变)。
try {
assertInsideAllowedRoot(absPath);
} catch (err: unknown) {
if (errnoCode(err) === 'EINVAL') {
log.warn('rkms: invalid/empty path, denying request', request.url, errMsg(err));
return new Response('rkms: settings not initialized', { status: 403 });
}
log.warn('rkms: path outside allowed roots, denying', absPath);
return new Response('rkms: path not allowed', { status: 403 });
}
// .html / .htm / .svg / .xml 一律拒 — 即使没 frame-ancestors,Chromium
// 也会按这些类型执行内联脚本 / SVG animation。library 不收录 HTML / SVG
// 笔记(走 MarkdownReader / CodeReader),这里 deny 是「零误伤」
const lowerName = absPath.toLowerCase();
if (
lowerName.endsWith('.html') ||
lowerName.endsWith('.htm') ||
lowerName.endsWith('.svg') ||
lowerName.endsWith('.xml')
) {
log.warn('rkms: refusing to serve scriptable content type', absPath);
return new Response('rkms: content type not served', { status: 403 });
}
const upstream = await net.fetch(pathToFileURL(absPath).toString());
// 克隆 headers 后再 mutate,避免污染 upstream 缓存
const headers = new Headers(upstream.headers);
// nosniff 防 Chromium 把 .svg 按内容嗅探成 text/html 跑内联脚本 ——
// assertInsideAllowedRoot 不防 mime sniffing
headers.set('X-Content-Type-Options', 'nosniff');
// 这里**不**下发 Content-Security-Policy: frame-ancestors。
// 实测(2026-10-19,Electron 30 / Chromium 124):Chromium 只对 web 系
// scheme 强制 frame-ancestors,对 rkms:// 这类自定义 scheme **不强制**
// —— 加了也不生效(实测 <iframe src="rkms://…"> 照常 commit)。
// 反过来,若哪天 Chromium 改为强制,PDFReader / ImageReader 的
// <iframe src="rkms://…"> 会被自家 directive 打死 —— 收益为零、
// 风险为正,故不加。
// 点击劫持防护改由**应用自身文档**的响应头承担(那才是 directive 唯一
// 生效且被强制执行的位置),见 src/main/document-csp.ts。
const baseName = absPath.split(/[\\/]/).pop() ?? 'file';
// 不能裸拼文件名:header value 必须是 ByteString(≤U+00FF),
// U+2019(’)/ U+2013(–)/ 中文 等字符会让 Headers.set 当场 throw
// TypeError「Cannot convert argument to a ByteString」⇒ 冒到外层
// catch 返 500 ⇒ Reader 探测 fetch 拿 500 ⇒ 「打开失败」错误页
// (2026-10-20 实证,见 utils/content-disposition.ts 头注)。
headers.set('Content-Disposition', contentDispositionInline(baseName));
// 显式补 Content-Type:file fetcher 在精简 OS(Windows / Linux 缺 mime-db)
// 会回退 application/octet-stream,nosniff 下浏览器拒渲染(PDF 看不到内容)
const explicitType = contentTypeFor(absPath);
if (explicitType) {
headers.set('Content-Type', explicitType);
}
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers,
});
} catch (err: unknown) {
// 不抛 — handler 抛错让 Electron 把请求视为失败,iframe/img 看到
// src.onerror 而非更具体的 500。返 Response(500) 让上层能读 status / body
const msg = errMsg(err);
// audit-r119:「文件缺失」是 iframe/img navigate 最高频的失败形态
// (0005 迁移行 PDF 实体丢失),原实现把裸错误串 text/plain 直接当页面
// 渲染 = 用户全屏英文 debug。识别缺失态返回 404 + 自包含 HTML 错误页;
// Reader 层已有探测主修(404 → ReaderError),这里是纵深兜底:任何没被
// 路由到的 surface(外链 / 未来新 reader)也只看到排版过的双语页面。
// 固定文案不回显路径/错误串 —— 404 语义已完备,避免探测 oracle。
if (msg.includes('ERR_FILE_NOT_FOUND') || errnoCode(err) === 'ENOENT') {
log.warn('rkms: file not found', request.url);
return new Response(FILE_NOT_FOUND_HTML, {
status: 404,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}
log.warn('rkms fetch failed', request.url, msg);
return new Response(`Failed to load rkms resource: ${msg}`, {
status: 500,
});
}
});
}
// audit-r119:文件缺失时 iframe / img navigate 到这里的兜底页面。
// 自包含(无外链资源,协议 response body 一次性)、双语文案、跟随系统
// 深浅色;不放文件名 —— navigate 场景用户本来就知道「打开的是哪个」,
// 回显路径只扩大探测面。
const FILE_NOT_FOUND_HTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>文件未找到 · File not found</title>
<style>
body { margin:0; height:100vh; display:flex; align-items:center; justify-content:center;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background:#fafafa; color:#333; }
.card { text-align:center; padding:32px; max-width:420px; }
.icon { font-size:40px; line-height:1; margin-bottom:16px; }
h1 { font-size:17px; font-weight:600; margin:0 0 8px; }
p { font-size:13px; color:#666; margin:0; line-height:1.6; }
@media (prefers-color-scheme: dark) {
body { background:#111827; color:#e5e7eb; }
p { color:#9ca3af; }
}
</style>
</head>
<body>
<div class="card">
<div class="icon" aria-hidden="true">&#128196;</div>
<h1>文件未找到</h1>
<p>文件可能已被移动、删除或重命名。<br />
File not found &mdash; it may have been moved, deleted, or renamed.</p>
</div>
</body>
</html>`;
// 扩展名 → Content-Type 映射。覆盖 library / 笔记场景核心类型;
// 命中不到返 null(由 net.fetch upstream 自带 Content-Type 顶上)
// 内联而非引 mime-types:6 行表不值得 +30kB 依赖
const CONTENT_TYPE_BY_EXT: Readonly<Record<string, string>> = {
'.pdf': 'application/pdf',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml', // 上面已 deny,这里只是兜底
'.bmp': 'image/bmp',
'.txt': 'text/plain; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
'.markdown': 'text/markdown; charset=utf-8',
};
function contentTypeFor(absPath: string): string | null {
const lower = absPath.toLowerCase();
for (const ext of Object.keys(CONTENT_TYPE_BY_EXT)) {
if (lower.endsWith(ext)) return CONTENT_TYPE_BY_EXT[ext];
}
return null;
}

View File

@@ -0,0 +1,148 @@
// Layout 迁移 v2:分层布局(dataPath/data/...)→ 扁平布局(dataPath/...)。
// v3 分层曾导致 data/data/ 双层 ENOENT,回滚到扁平。
// 幂等:老存在 & 新不存在才搬;失败只 warn 不抛(数据安全 > layout 一致)。
// 原子 rename(Windows 不跨盘符无问题)。
import { promises as fs } from 'fs';
import { join } from 'path';
import { safeRename } from '../utils/safe-write';
import { createLogger } from '../utils/logger';
import { errnoCode, errMsg } from '../utils/error';
const log = createLogger('migration-layout');
interface MigrationResult {
moved: string[];
warnings: string[];
}
/** 老布局 → 新布局。dataPath 相对路径(POSIX) */
const OLD_LAYOUT_DIRS: { oldRel: string; newRel: string }[] = [
// 显式写字符串不用 layout 常量:常量改名后重跑迁移会把已迁好的目录再搬一遍
// workspace 排最后:含 favorites/ 子目录,原子 rename 内部子结构整体跟走
{ oldRel: 'data/notes', newRel: 'notes' },
{ oldRel: 'data/workspace', newRel: 'workspace' },
];
/** 主入口:启动期调用一次 */
export async function migrateDataLayoutV2(dataPath: string): Promise<MigrationResult> {
const result: MigrationResult = { moved: [], warnings: [] };
// 互不依赖,并发跑节省 1 次 stat 延迟(典型 10-30ms HDD / AV-on-Windows)
try {
await Promise.all(
OLD_LAYOUT_DIRS.map(async ({ oldRel, newRel }) => {
try {
await migrateDirectory(dataPath, oldRel, newRel, result);
} catch (err: unknown) {
log.warn('Layout v2: migrateDirectory failed', { oldRel, err: errMsg(err) });
result.warnings.push(`${oldRel} failed: ${errMsg(err)}`);
}
}),
);
} catch (err: unknown) {
log.error('Layout migration v2 failed (continuing with original)', errMsg(err));
}
if (result.moved.length > 0 || result.warnings.length > 0) {
log.info('Layout migration v2 summary', JSON.stringify(result, null, 2));
}
return result;
}
/** 把 dataPath/<oldRel> 整个搬到 dataPath/<newRel>。幂等:目标存在则 skip(rename 不覆盖) */
async function migrateDirectory(
dataPath: string,
oldRel: string,
newRel: string,
result: MigrationResult,
): Promise<void> {
const oldPath = join(dataPath, oldRel);
const newPath = join(dataPath, newRel);
let oldStat;
try {
// lstat 不 follow symlink — 升级期 stat 与 safeRename 之间可被另一进程
// 替换为 symlink(云盘冲突合并 / 用户误操作),rename 会搬走 swap 后的
// symlink target,跨出 dataPath 边界
oldStat = await fs.lstat(oldPath);
} catch (err: unknown) {
if (errnoCode(err) === 'ENOENT') return; // 老位置不存在 → 无需迁移
log.warn('Layout v2: stat old dir failed', { oldPath, err: errMsg(err) });
result.warnings.push(`stat ${oldRel} failed`);
return;
}
if (oldStat.isSymbolicLink()) {
// symlink 不 follow,跳过(可能是误迁移留下的)
log.warn('Layout v2: old path is symlink, skipping', { oldPath });
result.warnings.push(`${oldRel} is a symlink`);
return;
}
if (!oldStat.isDirectory()) {
// 老位置存在但不是目录(用户误放同名文件)→ 跳过,留给运维
log.warn('Layout v2: old path is not a directory, skipping', { oldPath });
result.warnings.push(`${oldRel} is not a directory`);
return;
}
// 目标检查
try {
const newStat = await fs.stat(newPath);
if (!newStat.isDirectory()) {
log.warn('Layout v2: new path is not a directory, skipping', { newPath });
result.warnings.push(`${newRel} exists and is not a directory`);
return;
}
// 保守三分支,永不猜测哪份是权威:
// a) 目标非空 → 两份都有内容 → 一律不删,警告让用户决定
// b) 目标为空 → 必然是迁移残留空壳 → 删壳再 rename
// c) 目标不存在 → 直接 rename
// r32 旧实现 stat(newPath) 成功就无条件 rm -rf oldPath,曾造成「首启必丢笔记」
// (ensureWorkspaceDirs 先跑留空 notes/ → 命中分支删 v3 真数据);删逻辑原样保留
// 的话任何调换启动顺序都会重犯
const newEntries = await fs.readdir(newPath);
if (newEntries.length > 0) {
log.warn('Layout v2: both old and new are non-empty, keeping both (no auto-delete)', {
oldPath,
newPath,
newCount: newEntries.length,
});
result.warnings.push(
`${oldRel}/ and ${newRel}/ both contain files — kept both, merge or delete one manually`,
);
return;
}
// 删空壳后落到下方 rename
log.info('Layout v2: new path is an empty dir, removing it so old content can move in', {
oldPath,
newPath,
});
try {
await fs.rmdir(newPath);
} catch (rmErr: unknown) {
log.warn('Layout v2: failed to remove empty new dir', { newPath, err: errMsg(rmErr) });
result.warnings.push(`cannot remove empty ${newRel}/ (${errMsg(rmErr)}); old kept`);
return;
}
} catch (err: unknown) {
if (errnoCode(err) !== 'ENOENT') {
log.warn('Layout v2: stat new dir failed', { newPath, err: errMsg(err) });
result.warnings.push(`stat ${newRel} failed`);
return;
}
// newPath 不存在 → 直接搬
}
// newRel 是 'notes' / 'workspace',直接落在 dataPath 根下,无需先 mkdir 中间层
try {
// safeRename 内部 assertInsideAllowedRoot,源和目标都在 dataPath 内
await safeRename(oldPath, newPath);
log.info('Layout v2: moved', oldRel, '→', newRel);
result.moved.push(`${oldRel}/ → ${newRel}/`);
} catch (err: unknown) {
log.error('Layout v2: rename failed', { oldPath, newPath, err: errMsg(err) });
result.warnings.push(`rename ${oldRel} → ${newRel} failed`);
// 失败不动源,下次启动重试(老目录仍在)
}
}

View File

@@ -0,0 +1,334 @@
// src/main/settings/migration.ts
//
// audit-r56:library service / library-migration 整体下线 —— switchDataPath
// 不再 restart library watcher / scanAndIndex。这里只保留「切换数据目录 +
// reinit DB + rollback」的核心路径,被 `settings:migrate-data` 与
// `settings:restore-default-data` 两条 IPC 复用,供用户把工作目录迁到新盘。
//
// 不变式:
// 1. teardown 顺序:closeDB → 写新 location → initSettings(newPath) →
// initDB(newPath);任意中间抛错必须把 DB 回滚到 currentPath,避免
// 「location 指向 A、运行时却是 B」的分裂态
// 2. 同 newPath 直接 no-op,不写 location,不动 DB
// 3. drive-root (例如 `C:\`) 拒绝,不够区分父目录
import { promises as fs, type Dirent } from 'fs';
import path from 'path';
import { changeDataPath, resetSettings, captureSettingsSnapshot } from './store';
import { setRootPath, getRootPath } from '../utils/safe-write';
import { initSettings } from './store';
import { closeDB, initDB } from '../db/sqlite';
import { defaultDataPath, writeLocation } from '../location';
import { createLogger } from '../utils/logger';
import { AppError, errnoCode, errMsg } from '../utils/error';
import { isSameDir } from '../utils/path-compare';
import { getNotesService } from '../notes/service';
import { getFavoritesService } from '../workspace/favorites';
// audit-r204:换根后文献 id 必须重算 —— 文献 id = 'lit:'+toPosix(absPath),
// 强绑定绝对路径;而 changeDataPath 是**复制**(旧目录还在)+ SQLite 库在
// userData(全局,不在数据根下)⇒ 切换后旧行全部存活 ⇒ 文献列表里每个 PDF
// 出现两次、搜索结果重复、★ 挂在旧副本上。详见 root-remap.ts 头注。
import { remapLiteratureIdsForRootSwitch, relUnderRoot } from './root-remap';
// audit-r160:literature service 也需 rootPath 重绑 —— 旧实现漏掉导致切库后
// literature watcher 仍听旧目录 + byId 仍是旧目录扫描结果。
import { getLiteratureService } from '../literature/literature-service';
const log = createLogger('settings-migration');
export async function switchDataPath(newPath: string): Promise<void> {
const currentPath = getRootPath();
log.info('Switching data path', currentPath, '->', newPath);
// ---- D2-001: Validate newPath before any mutation ----
if (!newPath || typeof newPath !== 'string') {
throw new AppError('INVALID_INPUT', 'newPath must be a non-empty string');
}
if (!path.isAbsolute(newPath)) {
throw new AppError('INVALID_INPUT', 'newPath must be an absolute path');
}
if (newPath === currentPath) {
log.info('newPath equals currentPath, no-op');
return;
}
if (path.parse(newPath).root === newPath) {
throw new AppError('INVALID_INPUT', 'newPath cannot be a drive root');
}
// audit-r56:不再 stopWatching —— library service 已下线。
// audit-r80 更正:原注释「notes + favorites 无 chokidar watcher,无需停」
// 是错误前提 —— notes 有 watcher,favorites 无 watcher 但有 start() 时
// 一次性绑定的 AtomicFile 写路径。两者的重绑在下方 initDB 成功后做
// (stop/start 幂等全卸全装),这里 teardown 阶段不动它们:此刻还没
// 提交新路径,服务必须继续服务旧目录直到切换不可逆。
await changeDataPath(newPath, currentPath);
// audit 2026-10-08 (LOW):closeDB() 同步,无需 await —— 仅加注释防后续
// contributor 误以为漏 await。closeDB 内部 db.close() / stmtCache.clear()
// / txHolder.current = null / stopWalCheckpointTimer() 全部同步,
// 不返回 Promise。原 audit 标注 "not awaited" 是误读(把 fire-and-forget
// 习惯性套在这里),实际不构成 race。
closeDB();
// audit 2026-10-01:MEDIUM — 补齐 teardown 之后的回滚缺口。
//
// 到这里为止已经发生了两件**不可逆**的事:DB 关了。下面原先的
// `await writeLocation(newPath)` 位于 try 之外(try 从 initSettings 那行
// 才开始),它一旦抛错(atomic 写重试耗尽 / userData 只读 / OneDrive
// 占用),异常直接上抛给 IPC 层,于是本会话进入这个状态:
// - DB 永远是关闭的 → 所有 requireTx() 抛 "DB tx not initialized"
// - location.json 可能已指向 newPath,而 settings 仍指向 currentPath
// 用户表现:「切换数据目录失败」之后整个应用半死不活,必须重启。
//
// 修法与下面 initDB 失败路径对称:teardown 之后、进入可回滚区之前的任何
// 失败,都要把 DB 恢复回去再抛。
const restoreRuntime = (): void => {
try {
initDB(currentPath);
} catch (reopenErr: unknown) {
log.error('restore initDB(currentPath) failed after teardown', errMsg(reopenErr));
}
};
// ---- V1.9 (audit round 21): writeLocation BEFORE initDB ----
// 注:changeDataPath 内已写一次 location.json(newPath);这里再写一次是
// 幂等保险 —— 即便 changeDataPath 未来重构去掉写 location 这一步,
// switchDataPath 仍然能在 initDB 失败时把 location.json 滚回 currentPath。
try {
await writeLocation(newPath);
} catch (locErr: unknown) {
log.error('writeLocation(newPath) failed after teardown, restoring runtime', errMsg(locErr));
// audit 2026-10-04:把 location.json 一起滚回 currentPath。
//
// 这里原本只 restoreRuntime()(重开 DB)就抛。但 changeDataPath 在
// **调用本函数之前**已经成功写过一次 location.json = newPath(store.ts 的
// writeLocation(newPath));本行那次是幂等保险,它失败说明 userData 此刻
// 不可写 —— 很可能**第一次也只写了一部分或根本没生效**,而我们无从确认。
//
// 不回滚的后果是「location 指向 newPath、运行时却是 currentPath」的
// 分裂状态:本次会话之后对 currentPath 的所有写入成为孤儿(下次启动从
// newPath 读,拿到的是切换那一刻的旧副本),会话期的编辑静默消失。
//
// 下面 initDB 失败分支(:107)本来就有 writeLocation(currentPath),这里
// 是同一个不变量的另一半 —— 之前只覆盖了主路径,漏了这一行相邻的同类
// 失败点。回滚本身失败只 log,不掩盖原始的 locErr(与 :107-109 同款处置)。
try {
await writeLocation(currentPath);
} catch (rollbackLocErr: unknown) {
log.error('Rollback writeLocation(currentPath) failed', errMsg(rollbackLocErr));
}
restoreRuntime();
throw locErr;
}
// ---- D1-011: Rollback DB state if init fails ----
// audit round 18 (2026-08-27):initDB(currentPath) 自己抛错(损坏的旧 DB /
// 权限被剥)会掩盖原始的 initSettings(newPath) 错,用户无从知道迁移失败是因为
// 新路径问题还是回滚本身。把 rollback 也包到 try/catch,失败时把两条错误都
// 包到一个 AppError 里抛出去,UI 能完整看到"为什么迁移失败 + 为什么回滚也失败"。
// audit 2026-09-10:rollback 还要 reset settings —— initSettings(newPath) 会把
// module-level currentSettings / settingsPath 指向 newPath;若 initSettings
// 抛错前已经把 currentSettings 覆盖了,rollback 只滚 location + DB 仍会让
// 后续 getSettings() 返回 newPath 数据,saveSettings() 写到新路径,UI
// 与磁盘出现"location 指向 A 但 settings 实指 B" 的不可观测漂移。
// capturePrevSettings 在 initSettings(newPath) 之前抓快照;resetSettings
// 在 catch 里恢复(只在 initSettings 真的改了 currentSettings 时安全)。
const prevSettings = captureSettingsSnapshot();
setRootPath(newPath);
try {
await initSettings(newPath);
initDB(newPath);
} catch (err) {
log.error('Failed to init at new path, rolling back DB', err);
setRootPath(currentPath);
resetSettings(prevSettings);
try {
await writeLocation(currentPath);
} catch (rollbackLocErr) {
log.error('Rollback writeLocation(currentPath) failed', rollbackLocErr);
// 继续尝试回滚 DB —— location.json 写失败不阻塞 DB 回滚路径
}
try {
initDB(currentPath); // reopen old DB
} catch (rollbackErr) {
log.error('Rollback initDB(currentPath) also failed', rollbackErr);
throw new AppError(
'MIGRATION_ROLLBACK_FAILED',
`Migration failed AND rollback to previous data path failed. ` +
`Original error: ${errMsg(err)}. ` +
`Rollback error: ${errMsg(rollbackErr)}. ` +
`App may be in an inconsistent state — restart recommended.`,
err,
);
}
// audit 2026-09-10:rollback 之后清理 newPath —— changeDataPath 已经把整
// 个旧 data 目录复制到 newPath,initDB(newPath) 失败时虽然 location /
// settings / DB 都滚回 currentPath,但 newPath 这个孤儿目录还在磁盘上
// 占用空间(典型 50GB research library 场景下变成永久空间泄漏)。
// rm 失败不阻塞 throw err —— 失败也只是留个孤儿目录,不影响功能。
await fs.rm(newPath, { recursive: true, force: true }).catch((rmErr: unknown) => {
log.warn('Rollback fs.rm(newPath) failed (orphan directory may remain)', errMsg(rmErr));
});
throw err;
}
// audit-r204:切换 = **整库搬移** ⇒ 文献 id 必须重算成新根下的形态。
//
// 放在 initDB 之后(要写库)、服务重绑之前(重绑后的 reload 会重扫新根;
// 若此刻还没改 id,重扫会把新根的文件当成 disk-only 再插一行 ⇒ 重复)。
// 单行失败由 root-remap 内部逐行 catch + log(不阻断切换本身:location /
// settings / DB 已经提交,此时抛错只会让「切换失败」的提示与实际状态矛盾)。
const idRemap = remapLiteratureIdsForRootSwitch(currentPath, newPath);
// audit-r56:不再 startWatching + scanAndIndex —— library 没了,这两个调用
// 是把 library watcher 接到新 libraryPath,并扫描索引。这条路径在 V1.7
// 之前负责「切换数据目录后让用户立即看到新数据」,现在列表数据
// (notes / literature)直接走 SQLite,FTS 走 startup 一次性 build,
// 切换时无需主动 scan —— 启动期已经一次性 build 完。
// (audit-r73:原注释写「LibraryList 的数据」,该组件 r71 已删,措辞更正)
// audit-r80 (2026-10-17) HIGH:重绑随 rootPath 一次性绑定的两个服务。
// 旧实现切换成功后什么都不做,于是:
// - FavoritesService 的 AtomicFile 仍写**旧目录**的 favorites.md ——
// 本会话的收藏变更全部落到旧副本,重启后(从新目录加载)静默丢失;
// - NotesService 的 chokidar watcher 仍听旧目录 —— 新目录的外部增删
// 改不触发 reload(UI 不刷新),旧目录改动反而触发无意义全量重扫;
// - NotesService.byId 内存索引仍是旧目录扫描结果。
// stop() 对两者都是幂等全卸(排写队列/关 watcher/清索引),start()
// 重新 join(getRootPath(), …) 绑定新目录。放在 initDB 成功之后:
// 此前的 rollback 路径(currentPath 恢复)不需要也不许动服务。
// 重绑失败不回滚切换 —— location/settings/DB 已提交 newPath,服务
// 降级(收藏暂空/监听暂缺)比分裂态更可取,记 error 让排查有迹。
try {
const notes = getNotesService();
await notes.stop();
await notes.start();
const favorites = getFavoritesService();
await favorites.stop();
await favorites.start();
// audit-r204:★ 的 fileId 跟着文献 id 一起改 —— 否则收藏条目指向的旧 id
// 再也不会命中任何一行 ⇒ 用户的 ★ 静默消失(收藏面板里那条变成点不亮
// 的孤儿)。relPath 同步改成新根下的绝对路径(literature 侧存绝对路径)。
// 必须在 favorites.start() **之后**:start() 才把 AtomicFile 绑到新根的
// favorites.md 上,否则改写会落回旧目录的副本。
// 独立 try/catch:收藏改写失败不该连累后面的 literature 重绑。
if (idRemap.length > 0) {
try {
const idMap = new Map(idRemap.map((m) => [m.oldId, m.newId]));
const pathMap = new Map(idRemap.map((m) => [m.oldId, m.path]));
const changed = await favorites.remapIds(idMap, pathMap);
log.info('Favorites ids remapped for new data path', { changed });
} catch (favErr: unknown) {
log.error('Favorites id remap after data path switch failed', errMsg(favErr));
}
}
// audit-r160:literature service 同款单例 + 同样走 rootPath 重绑 ──
// 旧实现只 rebind notes + favorites,切库后 LiteratureService 的
// chokidar watcher 仍听旧目录(新目录 OS 拖入 / 外部删不触发 reload),
// byId 内存索引仍是旧目录扫描结果,多次切换还会累积 watcher 句柄。
// stop()/start() 都是幂等的(参见 literature-service.ts 头注的契约),
// 与 notes / favorites 顺序插入,保持三服务对称。
const literature = getLiteratureService();
await literature.stop();
await literature.start();
} catch (rebindErr: unknown) {
log.error('Service rebind after data path switch failed', errMsg(rebindErr));
}
log.info('Data path switched successfully');
}
// ─────────────────────────────────────────────────────────────────────
// audit-r205:「恢复默认数据目录」—— 这个按钮以前是**死的**
// ─────────────────────────────────────────────────────────────────────
//
// 现象(用户可见):用户只要**曾经**把工作区搬离默认位置
// (<Documents>/ResearchHub/data),设置页的「恢复默认数据目录」就**永久失效**,
// 而且报的是一句用户看不懂的话:
//
// target directory must be empty: C:\Users\x\Documents\ResearchHub\data
// (contains 37 entries)
//
// 根因 = 两条事实叠加(缺一条都不成立):
// ① changeDataPath **要求目标目录为空**(r56 的 HIGH 修复:绝不对用户自有的
// 非空目录 rm -rf —— 用户可能选了 D:\MyBackup 作新数据目录,那里有照片);
// ② changeDataPath 是**复制**(旧目录原样保留,见 r204 头注)⇒ 默认位置里
// 恰恰躺着**上一次工作区的副本**。
// ⇒ 目标永远非空 ⇒ 按钮永远报「目录必须为空」⇒ 用户永远回不去默认位置。
//
// 修法:**让开**,不是**清空**。
// 默认目录非空时,先把它**改名**到同级的 `<default>.replaced-<ts>`
// (同卷 ⇒ rename 原子、瞬时、不复制字节),再把当前工作区复制进去。
// 旧副本**一个字节都没删** —— 它只是挪了位置,随时可以挪回去;
// 恢复成功后它作为「上一份工作区的备份」留在旁边(日志给出路径),
// 由用户自己决定何时清理。
// 与「直接 rm -rf 掉默认目录」的本质区别:后者销毁的是**用户数据**
// (默认目录里可能有用户手工放进去的东西),前者只是换个位置。
//
// 两条「绝不能把工作区自己让开」的防线(下面 (1)(2)):
// 「让开」的副作用是:如果当前工作区**就在**默认目录里(或者在它内部),
// 那一步会把工作区本身搬走 —— 灾难性数据丢失。
// ⇒ 已经在默认位置 → 整个流程 no-op;
// 工作区在默认目录之内 → 直接拒绝并说清楚。
//
// 不变式:「目标目录必须为空」这条防线**只对用户自选的目标**成立
// (MIGRATE 流程保持原样,见测试 A7);本流程走的是「让开」而不是
// 「放宽为空检查」—— 后者会让用户自选的非空目录被覆盖。
export async function restoreDefaultDataPath(): Promise<void> {
const def = defaultDataPath();
const current = getRootPath();
// (1) 已经在默认位置 → 什么都不用做。
// 这一条不是「优化」而是**安全前提**:下面的「让开」会把默认目录改名,
// 而当前工作区**就是**默认目录时,那一步会把工作区自己搬走。
if (isSameDir(def, current)) {
log.info('Already at the default data path; nothing to restore');
return;
}
// (2) 当前工作区在默认目录**之内** → 同样不能「让开」(会把工作区带走)。
if (relUnderRoot(def, current) !== null) {
throw new AppError(
'VALIDATION',
`当前工作目录位于默认数据目录(${def})之内,无法恢复默认数据目录:${current}`,
);
}
const aside = await moveDefaultDirAside(def);
try {
await switchDataPath(def);
} catch (err: unknown) {
// 失败时:旧副本还在 aside 位置(一个字节都没丢);
// changeDataPath 内部会把半成品拷贝回滚掉(def 是我们 mkdir 出来的)。
// 错误消息里带出 aside 路径 —— 用户据此能找回自己的数据,而不是只看到
// 一句「失败了」。
const detail = aside
? `恢复默认数据目录失败;原默认工作区已保留在 ${aside}(未删除)。${errMsg(err)}`
: `恢复默认数据目录失败:${errMsg(err)}`;
log.error('Restore to default data path failed', detail);
throw new AppError('MIGRATION_FAILED', detail, err);
}
if (aside) {
log.info('Previous default workspace moved aside (not deleted)', aside);
}
}
/**
* 把默认目录「让开」:存在且非空时,改名到同级的 `<def>.replaced-<ts>`。
*
* - 不存在 / 已空 → 返回 null(无需让开,后续流程与旧行为一致)。
* - 改名而不是删除:同卷 rename 是原子的、瞬时的、不复制字节;
* 旧副本完整留在磁盘上,用户随时能拿回来。
* - 名字带随机后缀:同一毫秒内重试两次也不会撞名(rename 到已存在的目录会
* 报 EEXIST/ENOTEMPTY,那会让恢复白白失败一次)。
*/
async function moveDefaultDirAside(def: string): Promise<string | null> {
let entries: Dirent[];
try {
entries = await fs.readdir(def, { withFileTypes: true });
} catch (err: unknown) {
if (errnoCode(err) === 'ENOENT') return null;
throw err;
}
if (entries.length === 0) return null;
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const aside = `${def}.replaced-${stamp}`;
await fs.rename(def, aside);
log.info('Default data dir moved aside', { from: def, to: aside });
return aside;
}

View File

@@ -0,0 +1,120 @@
// src/main/settings/root-remap.ts
//
// audit-r204:切换数据目录是「整库搬移」,不是「换个扫描根」。
//
// ── 背景(真实 bug,用户可见)──────────────────────────────────────────
// 文献 id = 'lit:' + toPosix(absPath) —— **id 与绝对路径强绑定**。
// 切数据目录时:
// 1. changeDataPath 是**复制**(旧目录原样保留,不是移动);
// 2. SQLite 库住在 userData/index/index.sqlite(全局,**不在**数据根
// 下)⇒ 切换**不会**产生新库,DB 里旧根下的文献行全部存活。
// 随后 literature service 重绑 → reload() 重扫新根:
// - 新根下的文件算出**新 id** → 落进 disk-only 分支 → upsert 出新行;
// - 旧行不在新根的 diskIdSet 里 → 落进 db-only 分支,而旧目录的文件
// **还在**(复制不是移动)⇒ existsSync(旧 absPath) 为真 ⇒ 「保留」。
// ⇒ 同一个 PDF 在 DB 里留下**两行**(旧 id + 新 id):
// - 文献列表里**每个 PDF 出现两次**;
// - 全文搜索返回**重复结果**;
// - ★ 挂在旧 id 那条上 ⇒ 用户在新目录里看到的是「没收藏」的那条。
//
// ── 修法 ───────────────────────────────────────────────────────────
// 把切换当成一次**整库搬移**:在服务重扫**之前**,把 DB 里旧根下的每一行
// 文献的 id 重算成新根下的 id(单事务 renameResource,见 r201)。
// 于是重扫时新根的 diskIdSet **命中既有行** ⇒ 不再产生 disk-only 新行
// ⇒ 不重复;★ 的 fileId / relPath 同步改写(favorites 落在新根的
// favorites.md 里,必须在 favorites 服务重绑到新根**之后**改)。
//
// 笔记侧不需要改 id:note id = frontmatter id 或 relPath 的哈希,而
// relPath 是**相对 notes/ 目录**的 ⇒ 换根不动。DB 里 note 行的 abs_path
// 会过期,但那一列对 note 从不被读回(见 resources-repo.rowToResource:
// note 分支不取 abs_path)⇒ 无害,不在本轮范围内。
import { isAbsolute, join, relative, sep } from 'path';
import { listResources, renameResource } from '../db/resources-repo';
import { litResourceId } from '@shared/resource-id';
import { toPosix } from '../notes/path-mapping';
import { WORKSPACE_DATA_LAYOUT } from '@shared/types';
import type { LiteratureResource } from '@shared/types';
import { createLogger } from '../utils/logger';
import { errMsg } from '../utils/error';
const log = createLogger('root-remap');
/** 一条「id 因换根而变」的记录 —— 供收藏(favorites)同步改写。 */
export interface RootSwitchIdRemap {
/** 变更前的 id(= 旧根下的绝对路径形态) */
oldId: string;
/** 变更后的 id(= 新根下的绝对路径形态) */
newId: string;
/** 新根下的绝对路径(同时写进 favorites entry 的 relPath) */
path: string;
}
/**
* absPath 是否位于 root 之下;是则返回相对路径,否则 null。
*
* 边界:
* - root 本身(相对路径为空串)不算「在根之下」;
* - 兄弟目录(`..` / `..\foo`)不算 —— 注意**不能**只判 startsWith('..'):
* 名为 `..foo` 的兄弟目录会被误判成越界;
* - 跨盘符(Windows 上 relative 返回绝对路径)⇒ null。
*/
export function relUnderRoot(root: string, absPath: string): string | null {
if (!root || !absPath) return null;
const rel = relative(root, absPath);
if (rel === '' || isAbsolute(rel)) return null;
if (rel === '..' || rel.startsWith(`..${sep}`)) return null;
return rel;
}
/**
* 换根重算文献 id —— 把 DB 里「旧根下」的文献行改写成「新根下」的形态。
*
* 单行失败(DB 忙 / FTS 报错)不阻断其余行:失败的那行保持旧 id ⇒ 该文献在
* 列表里会**多出一份**(旧 id 的保留行 + 新 id 的新行),但不会连累别的文献
* 也重复。逐行记 error,便于事后从日志定位。
*
* @returns 成功改写的 id 映射表(供 favorites 同步改写 fileId / relPath)
*/
export function remapLiteratureIdsForRootSwitch(
oldRoot: string,
newRoot: string,
): RootSwitchIdRemap[] {
const remaps: RootSwitchIdRemap[] = [];
if (!oldRoot || !newRoot || oldRoot === newRoot) return remaps;
const newLitDir = join(newRoot, WORKSPACE_DATA_LAYOUT.LITERATURE_DIR);
// listResources('literature') 已按 kind 过滤,返回类型仍是 Resource 联合
// (note | literature)—— TS 看不到运行时过滤,需 narrow。
const rows = listResources('literature');
for (const r of rows) {
if (r.kind !== 'literature') continue;
const rel = relUnderRoot(oldRoot, r.absPath);
if (rel === null) continue; // 不在旧根下(别的库 / 手工导入的外部文件)
const newAbs = join(newRoot, rel);
const newId = litResourceId(toPosix(newAbs));
if (newId === r.id) continue; // 已经是新根形态(幂等)
const next: LiteratureResource = {
...r,
id: newId,
absPath: newAbs,
// 文献的「文件夹归属」是相对 literature 根的 POSIX 路径 —— 换根后
// 归属本身不变(目录结构照搬),但基准目录换了,重算一次保持自洽。
relPath: toPosix(relative(newLitDir, newAbs)),
};
try {
// 单事务「upsert 新行 + 删旧行」(r201)—— 中途失败不会留下两行。
// body 传 '':文献的 FTS 正文一直是空的(正文抽取流水线尚未落地),
// 传 '' 不会丢任何已索引内容。
renameResource(r.id, next, '', newAbs);
} catch (err: unknown) {
log.error('Literature id remap failed', r.id, '->', newId, errMsg(err));
continue;
}
remaps.push({ oldId: r.id, newId, path: newAbs });
}
if (remaps.length > 0) {
log.info('Literature ids remapped for new data path', { count: remaps.length });
}
return remaps;
}

532
src/main/settings/store.ts Normal file
View File

@@ -0,0 +1,532 @@
// 设置存储:在数据路径下读写 settings.json
import { app } from 'electron';
import { promises as fs } from 'fs';
import { join, resolve } from 'path';
import { AppSettings, DEFAULT_SETTINGS, WORKSPACE_DATA_LAYOUT } from '@shared/types';
import { safeWriteFileAtomic, atomicWriteFileInternal, safeCloseFh } from '../utils/safe-write';
import { createLogger } from '../utils/logger';
import { writeLocation } from '../location';
import { AppError, errnoCode, errMsg } from '../utils/error';
import { isDeepStrictEqual } from 'node:util';
// 并发 updateSettings 走 updateLock 串行化(防两 patch 互相覆盖)—— 与
// LibraryService / NotesService 共用 utils/write-lock.ts
import { WriteLock } from '../utils/write-lock';
import { sanitizeLoadedSettings } from './validate';
import { isSameDir } from '../utils/path-compare';
import { MAX_SETTINGS_BYTES } from '../ipc/path-limits';
// audit r200:读 settings.json 也走 transient 退避 —— Windows 上同步盘 /
// 杀毒软件短暂持句柄会让 open 报 EPERM/EBUSY,那是「重试一下就好」,
// 不该被当成「文件损坏」(与 atomic-file 的读路径同款处理)
import { retryOnWindowsTransient } from '../utils/retry-transient';
const log = createLogger('settings');
// 单 fd 拿 size check + content — 替代 fs.stat → fs.readFile 两步走法(后者
// stat 与 read 之间存在 TOCTOU 窗口,cap 校验可能跑在错 inode 上)
async function readFileCapped(path: string, cap: number): Promise<string> {
let fh: Awaited<ReturnType<typeof fs.open>> | null = null;
try {
fh = await fs.open(path, 'r');
const st = await fh.stat();
if (st.size > cap) {
throw new AppError(
'IO_ERROR',
`${path} exceeds ${cap} bytes (got ${st.size}); refusing to load oversized config`,
);
}
return await fh.readFile('utf-8');
} finally {
// safeCloseFh 统一 close 失败日志形态,Windows stale handle 偶发 EBUSY
// 时 fd 泄漏信号不再消失
await safeCloseFh(fh, 'fh.close failed in readFileCapped finally', log, { path });
}
}
let currentSettings: AppSettings | null = null;
let settingsPath: string | null = null;
// 串行化锁:两个 IPC 同时调,A: 读 → B: 读 → A: 写 patch1 → B: 写 patch2,
// B 的快照不含 patch1 → patch1 静默丢失
const updateLock = new WriteLock();
/** 消解「settings.json 自述路径」与「文件实际所在目录」的分叉。
* 自述与实际位置一致才可信;不一致说明文件从别处搬来 / 被手改过,采信会让
* getSettings().rootPath 与 getRootPath() 分叉,下游读写落在不同磁盘位置。
* 用 isSameDir(尾部分隔符 / 分隔符风格 / 大小写统一)避免
* C:/Users/foo/data 与 C:\Users\foo\data 误判分叉 */
function reconcileRootPath(claimed: string | undefined, actualDataPath: string): string {
if (!claimed) return actualDataPath;
if (isSameDir(claimed, actualDataPath)) return actualDataPath;
log.warn('settings.json rootPath disagrees with its own location; using actual directory', {
claimed,
actual: actualDataPath,
});
return actualDataPath;
}
export async function initSettings(dataPath: string): Promise<AppSettings> {
settingsPath = join(dataPath, WORKSPACE_DATA_LAYOUT.SETTINGS_FILE);
// 本地副本:module-level 的 settingsPath 是 `string | null`,TS 在闭包
// (retryOnWindowsTransient 的 action 回调)里丢失窄化 ⇒ 读盘调用点拿到
// `string | null`。这里抓一份确定是 string 的局部引用给读 / 写路径用。
const settingsFile = settingsPath;
await fs.mkdir(dataPath, { recursive: true });
// 本函数读的就是 dataPath 下的 settings.json,「工作目录根」在定义上 = dataPath
const resolvedDataPath = resolve(dataPath);
// symlink 守卫:dataPath 在用户机器上,若被恶意 mklink settings.json → 别的
// 文件,readFile 会 follow 读到对方内容,sanitizeLoadedSettings 把错误配置
// 加载到 currentSettings。本地 minimal lstat(不能调 assertNotSymlink ——
// 其 allowedRoots 依赖 getSettings(),而 initSettings 还没设 currentSettings,
// 会自抛 INTERNAL 把 fail-fast 误退成 fail-closed)
try {
const lst = await fs.lstat(settingsPath);
if (lst.isSymbolicLink()) {
throw new AppError(
'VALIDATION',
`settings.json is a symbolic link — refusing to follow (${settingsPath})`,
);
}
} catch (err: unknown) {
if (err instanceof AppError) throw err;
const code = errnoCode(err);
if (code !== 'ENOENT') {
// security 关键路径不静默放过,lstat 失败(EACCES / EIO / ELOOP) 显式 warn
log.warn('settings.json lstat failed (continuing with defaults)', {
path: settingsPath,
err: errMsg(err),
});
}
}
let loaded: Partial<AppSettings> = {};
//
// audit r200 (2026-10-20):**读失败 ≠ 文件损坏** —— r199(atomic-file 同一
// bug 类)的上层版本。旧实现把 readFileCapped 与 JSON.parse 裹在**同一个
// try** 里 ⇒ 除 ENOENT / IO_ERROR 外的**任何读错误**(EACCES 权限 /
// EPERM / EBUSY 被占用 / EIO / EISDIR 路径其实是目录)都落进「corrupt」
// 分支 ⇒
// 1. **rename 走用户的 settings.json**(内容可能完好,只是此刻读不到);
// 2. loaded 保持 {} ⇒ currentSettings = DEFAULT_SETTINGS;
// 3. 函数末尾 saveSettings() 用**默认值**写出一个**新的** settings.json
// ⇒ 主题 / 语言 / 布局 / extraImportRoots 等用户设置**全丢**
// (原文件只剩 settings.json.corrupt-<ts>.json 一份,而它并不是坏文件)。
// 一句话:一次瞬时读失败 = 用户设置被重置 —— 与本函数自己对 size-cap 的
// 策略注释「让首启崩比『静默接受 defaults 覆盖用户设置』更安全」自相矛盾。
//
// 新契约(**读 / 解析两阶段独立处置**):
// 读阶段 —— ENOENT → 正常首启走默认值;IO_ERROR(size cap)→ 照旧抛;
// **其它读错误 → fail-closed 抛错**:不 rename、不写默认值,文件原封
// 不动留在盘上,让用户自己处理(关掉占用它的同步盘 / 编辑器 / 杀毒,
// 或修权限),然后重启;
// 解析阶段 —— JSON.parse 抛错(SyntaxError,**无 errno code**)才说明
// 「内容坏了」→ 走原来的 quarantine 阶梯(保留手编辑内容)。
let raw: string | null = null;
try {
// 读也走 transient 退避:Windows 上 AV / OneDrive 短暂持句柄会让 open
// 报 EPERM/EBUSY —— 那是「重试一下就好」,不该被当成损坏。
raw = await retryOnWindowsTransient(
() => readFileCapped(settingsFile, MAX_SETTINGS_BYTES),
() => Promise.resolve(), // 读失败没有 tmp 残留要清
);
} catch (err: unknown) {
const code = errnoCode(err);
if (code === 'ENOENT') {
// 正常首启,settings.json 还不存在 → 走默认值
} else if (code === 'IO_ERROR') {
// size cap 主动抛,有意 fail-closed —— 超大说明磁盘被异常写入或 IPC 边界
// 被绕过,让首启崩比「静默接受 defaults 覆盖用户设置」更安全
// 不 quarantine:rename 走会让用户失去原文件,应自己看到错误决定
throw err;
} else {
// **读失败** —— 不是「文件损坏」。fail-closed(与上面的 size-cap 同款
// 理由):不 rename(那是破坏用户数据)、不写默认值(那会覆盖用户设置)。
// 错误文案要能指导用户自救:关掉占用它的程序 / 修权限,而不是「删掉
// settings.json」(那才是真正的数据销毁)。
throw new AppError(
'SETTINGS_UNREADABLE',
`settings.json at ${settingsPath} could not be read (${errMsg(err)}). ` +
`It was left untouched on disk — close whatever is holding it (sync client / ` +
`antivirus / editor) or fix its permissions, then restart. Refusing to start ` +
`with default settings, because that would overwrite your existing settings.`,
err,
);
}
}
if (raw !== null) {
try {
// 走 sanitizeLoadedSettings 与 IPC update 路径同源 —— 手工编辑 / 老版本
// 残留写非法 theme='rainbow' / workspaceLayout.statusBarHidden='yes'
// 会被丢弃,DEFAULT_SETTINGS 接管,防下游静默失效或 TypeError
loaded = sanitizeLoadedSettings(JSON.parse(raw));
log.info('Settings loaded from', settingsPath);
} catch (err: unknown) {
// 真·corrupt JSON(JSON.parse 抛 SyntaxError,无 errno code):quarantine 到
// .corrupt-<unix-ms>.json,用户可在 dataPath 找回手编辑内容,而不是被
// 默认值无声覆盖后找不到
try {
const quarantineName = `settings.json.corrupt-${Date.now()}.json`;
const quarantinePath = join(dataPath, quarantineName);
await fs.rename(settingsPath, quarantinePath);
log.warn('settings.json is corrupt, quarantined to', {
from: settingsPath,
to: quarantinePath,
err: errMsg(err),
});
} catch (renameErr: unknown) {
// quarantine 失败必须 fail-closed:旧实现只 log 继续,代价是本函数末尾
// 的 saveSettings() 拿 DEFAULT_SETTINGS 覆盖掉损坏原文件 —— quarantine
// 唯一作用(保住用户手编辑内容)在它失败时不仅没生效,反而毁了唯一可
// 恢复副本
log.error('settings.json is corrupt AND could not be quarantined', {
path: settingsPath,
readErr: errMsg(err),
renameErr: errMsg(renameErr),
});
throw new AppError(
'SETTINGS_CORRUPTED',
`settings.json at ${settingsPath} is unreadable (${errMsg(err)}) and could not be ` +
`preserved (${errMsg(renameErr)}). Refusing to start, because continuing would ` +
`overwrite the only copy of your settings. Move or delete that file manually, ` +
`then restart.`,
err,
);
}
}
}
currentSettings = {
...DEFAULT_SETTINGS,
...loaded,
// rootPath 取值优先级 ① loaded.rootPath ② loaded.dataPath ③ resolvedDataPath
// ①② 都要先与「本文件实际所在目录」对齐 —— 旧实现无条件信任文件里的
// 自述,但自述可能指向别的目录(changeDataPath 旧版逐字复制 / 手工编辑 /
// 备份恢复),一旦采信,getSettings().rootPath 与 getRootPath()(safe-write
// 单例,由 location.json 解析)就永久分叉,下游读写落在不同位置
rootPath: reconcileRootPath(loaded.rootPath ?? loaded.dataPath, resolvedDataPath),
// dataPath 镜像写入:降级到旧版本时仍能启动(见 AppSettings @deprecated)
dataPath: reconcileRootPath(loaded.rootPath ?? loaded.dataPath, resolvedDataPath),
version: app.getVersion(),
} as AppSettings;
// audit-r163 N1:首启场景(loaded === 空对象 / 全 DEFAULT)跳过 saveSettings —
// 旧实现末尾无条件 await saveSettings(),用户每开 app 一次首启无意义写盘
// + 触发 mtime 变化让下游 chokidar / fsevents 误以为 settings.json 改了
// 唤醒 inotify(浪费 IO)。比对 next 是否等于「DEFAULT + 实际 dataPath +
// 当前 version」(首启形态),若等则跳过 saveSettings 直接 return。
// 防误伤:loaded 含任意用户字段(theme / language / workspaceLayout 等)
// → next 必不等默认 → 走 saveSettings(用户编辑仍落盘)。仅完全首启
// 才走 skip,无回归风险。
const freshDefault: AppSettings = {
...DEFAULT_SETTINGS,
rootPath: resolvedDataPath,
dataPath: resolvedDataPath,
version: app.getVersion(),
};
if (isDeepStrictEqual(currentSettings, freshDefault)) {
return currentSettings;
}
await saveSettings();
return currentSettings;
}
export function getSettings(): AppSettings {
if (!currentSettings) throw new AppError('INTERNAL', 'Settings not initialized');
return currentSettings;
}
/** 回滚 module-level settings —— migration 失败时还原到旧快照。
* initSettings(newPath) 会把 currentSettings / settingsPath 指向 newPath,
* 但 location.json / setDataPath / initDB rollback 后只还原磁盘状态;
* currentSettings 仍指向 newPath → 后续 updateSettings 写到新路径。
* 返回旧快照供 caller(罕见)复用 */
export function resetSettings(prev: { current: AppSettings; path: string }): {
current: AppSettings;
path: string;
} {
currentSettings = prev.current;
settingsPath = prev.path;
return prev;
}
/** 抓 module-level settings 当前快照 —— switchDataPath 在 initSettings(newPath)
* 之前捕获 currentPath 状态,失败时给 resetSettings 还原。
* 必须在 initSettings(newPath) 之前调用 —— 一旦改了 currentSettings,快照
* 就丢失 currentPath 数据,resetSettings 还原成「新路径数据」无意义 */
export function captureSettingsSnapshot(): { current: AppSettings; path: string } {
if (!currentSettings || !settingsPath) {
// 理论上不可达(switchDataPath 只在 initSettings(currentPath) 成功后进入),
// 仍抛清晰错误避免 reset 成 null
throw new AppError('INTERNAL', 'Settings not initialized before switchDataPath');
}
return { current: currentSettings, path: settingsPath };
}
export async function saveSettings(value?: AppSettings): Promise<void> {
if (!currentSettings || !settingsPath) throw new AppError('INTERNAL', 'Settings not initialized');
// 可选参数 — updateSettings 需要「先落盘、成功后才改内存」
const source = value ?? currentSettings;
const next = JSON.stringify(source, null, 2);
// 写盘侧体积闸门(系统性,不是逐字段补丁):
// 写侧原本无 cap,读侧 cap = 1MB → 任意字段撑大序列化结果就能永久砖化启动
// (r56 enabledExtensions 缺 cap 一次 IPC 写出 6MB;新加字段不变式仍在,系统
// 性闸门一视同仁,与读侧同源不再漂移)。fail-closed:宁可这次写入失败也
// 不能写出一个下次启动读不回来的文件;旧 settings.json 抛错前完好
const nextBytes = Buffer.byteLength(next, 'utf8');
if (nextBytes > MAX_SETTINGS_BYTES) {
throw new AppError(
'VALIDATION',
`Refusing to write settings.json of ${nextBytes} bytes — it would exceed the ` +
`${MAX_SETTINGS_BYTES}-byte read cap and make the app fail to start on next ` +
`launch. Check for an oversized field in the settings patch.`,
);
}
// 跳过无变化写入:保护用户手工编辑的 settings.json 不被默认值合并覆盖,
// 也避免每次启动都触发一次磁盘 IO + fsync
//
// KNOWN LIMITATION:read→compare→write 存在 TOCTOU 窗口(读 prev → 比对
// 通过 early return → 期间用户手工改 / OneDrive 同步合并 → currentSettings
// 仍旧值,等下次 saveSettings 才盖回正确内容)。在我们的应用模型下可接受:
// 唯一 writer 是主进程 updateSettings,外部手工编辑时应用处于 idle;
// 撞上也只是「应用最后一次写的版本覆盖用户外部修改」,不是数据丢失
// 完整 CAS(proper-lockfile / atomic rename 检测 mtime)留 follow-up;
// 现行 workaround:saveSettings 只由 updateSettings 调,updateSettings
// 走 updateLock 串行化不会并发竞争
try {
const prev = await readFileCapped(settingsPath, MAX_SETTINGS_BYTES);
if (prev === next) return;
} catch (err: unknown) {
const code = errnoCode(err);
if (code === 'ENOENT') {
// 文件不存在(首启 / 被用户删了)→ 正常写
} else if (code === 'IO_ERROR') {
// size cap fail-closed(与读侧同款)
throw err;
} else {
// audit r200:读失败 —— **不能**继续写。写下去就是把一份「此刻读不到
// 但内容可能完好」的 settings.json 直接盖掉(与 initSettings 读阶段
// 同一个 bug 类,只是这里发生在写路径上)。fail-closed + 可行动文案。
throw new AppError(
'SETTINGS_UNREADABLE',
`settings.json at ${settingsPath} could not be read (${errMsg(err)}), so it was ` +
`left untouched — refusing to overwrite a file we could not read. Close whatever ` +
`is holding it (sync client / antivirus / editor) or fix its permissions, then retry.`,
err,
);
}
}
await safeWriteFileAtomic(settingsPath, next);
}
export async function updateSettings(patch: Partial<AppSettings>): Promise<AppSettings> {
if (!currentSettings) throw new AppError('INTERNAL', 'Settings not initialized');
// 并发 IPC(SETTINGS_UPDATE 连发)走 updateLock 串行化,修 lost-update race:
// 旧实现 base = currentSettings 在锁外捕获,A/B 拿到同一 base,A 写完释放
// 锁后 B 用旧 base 算 next,A 的字段被覆盖丢失。修:base 挪进 updateLock.run
// 闭包,每次进入锁都读最新 currentSettings
//
// 内存赋值挪到落盘成功之后:旧顺序 currentSettings = next; await saveSettings();
// 写盘失败时异常上抛但内存已污染,UI 乐观更新 → 重启后静默回退;getSettings()
// 返回从没落过盘的值,saveSettings 「无变化跳过」比对都建立在错前提
return updateLock.run(async () => {
if (!currentSettings) throw new AppError('INTERNAL', 'Settings not initialized');
const base = currentSettings;
const next: AppSettings = { ...base, ...patch };
// 先落盘(失败即抛,内存不动)→ 再更新内存态
await saveSettings(next);
currentSettings = next;
return next;
});
}
/**
* 读源工作区的 settings.json —— **「没有」与「读不到」是两件事**(r207)。
*
* - **不存在**(ENOENT)→ 返回 null:全新工作区就是没有这个文件(r163 起
* 首启不写盘),迁移**不该**因此失败。
* - **读不到**(EACCES / EPERM / EBUSY / EISDIR …)→ fail-closed 抛错:宁可不
* 迁移,也不能把一份读不出来的设置当成「没有设置」搬过去 —— 那等于悄悄
* 把用户的主题 / 语言 / 布局换成默认值。读先走 retryOnWindowsTransient:
* Windows 上同步盘 / 杀毒软件短暂持句柄会让 open 报 EPERM/EBUSY,那是
* 「重试一下就好」。
* - **超过 size cap** → 照旧抛(IO_ERROR,有意 fail-closed)。
* - **内容不是合法 JSON** → 不抛:逐字复制(「迁移不改变内容」优先)。
*/
async function readSourceSettings(
currentDataPath: string,
): Promise<{ raw: string; parsed: Record<string, unknown> | null } | null> {
const sourcePath = join(currentDataPath, WORKSPACE_DATA_LAYOUT.SETTINGS_FILE);
let raw: string;
try {
raw = await retryOnWindowsTransient(
() => readFileCapped(sourcePath, MAX_SETTINGS_BYTES),
() => Promise.resolve(), // 读失败没有 tmp 残留要清
);
} catch (err: unknown) {
if (errnoCode(err) === 'ENOENT') {
// 源工作区没有 settings.json(全新工作区,或它已被 quarantine 改名)
return null;
}
throw new AppError(
'IO_ERROR',
`无法读取源工作区的 settings.json(${sourcePath}):${errMsg(err)}。` +
`迁移已中止 —— 目标目录未被写入任何内容。请关掉占用它的程序(同步盘 / ` +
`杀毒软件 / 编辑器)或修复权限后重试。`,
err,
);
}
let parsed: Record<string, unknown> | null = null;
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch (parseErr: unknown) {
// 源 settings.json 本身非合法 JSON — 逐字复制,新目录继承同样损坏
// (下次启动走 quarantine,与留在旧目录一致)。「迁移不改变内容」优先
log.warn('Source settings.json is not valid JSON; copying verbatim', errMsg(parseErr));
}
return { raw, parsed };
}
// 切换数据路径:复制所有数据到新位置
export async function changeDataPath(
newPath: string,
currentDataPath: string,
): Promise<{ oldPath: string; newPath: string }> {
log.info('Changing data path', currentDataPath, '->', newPath);
// HIGH:旧实现 mkdir + fs.cp({force:false}) 写入任意目标,失败 catch 又
// fs.rm({recursive,force}) 强制清空 → 若用户选 D:\MyBackup(已有照片等)
// 作新数据目录,复制中途 EEXIST/throw → catch 触发 → 整个 D:\MyBackup
// 被 rm -rf 永久删。
// 修法两段:
// (1) mkdir 前 readdir 预检:ENOENT/已存在且空 → safe(targetWasEmpty=true);
// 已存在且非空 → 直接抛 VALIDATION 让用户重选,根本不写任何字节
// (2) rollback fs.rm 仅当 targetWasEmpty —— 我们 mkdir/复制出去的才 rm,
// 用户原有非空目录永不 rm
let targetWasEmpty = false;
try {
const existing = await fs.readdir(newPath, { withFileTypes: true });
if (existing.length > 0) {
throw new AppError(
'VALIDATION',
`target directory must be empty: ${newPath} (contains ${existing.length} entries)`,
);
}
targetWasEmpty = true;
} catch (err: unknown) {
if (err instanceof AppError) throw err;
const code = errnoCode(err);
if (code === 'ENOENT') {
targetWasEmpty = true;
} else {
throw err;
}
}
// audit-r207:源 settings.json **先读**,且「没有」与「读不到」是两件事。
//
// 旧实现把 readFileCapped 的 ENOENT 当成致命错 ⇒ **迁移整个失败**。
// 而「全新工作区根本没有 settings.json」是**正常状态**:r163 的
// 「首启跳过写盘」优化(当前设置 == 全默认 ⇒ 不写盘)让空工作区的根下
// 一直没有这个文件。⇒ 「更改数据目录」按钮对**全新安装**的用户永远是
// 坏的,报的还是一句裸的
// ENOENT: no such file or directory, open '...\settings.json'
// (settings.json 被 quarantine 成 .corrupt-<ts>.json 的工作区同样中招
// —— 那条分支也不写回 defaults)。
//
// 新的处置口径:
// - **不存在** → 没有可搬的设置,新根也不写(与源保持一致;新根的
// initSettings 会给出同一套默认值)⇒ 迁移正常进行;
// - **读不到**(EACCES / EBUSY / EISDIR …)→ fail-closed 抛错:宁可不
// 迁移,也不能把一份读不出来的设置当成「没有设置」搬过去(那等于
// 悄悄把用户的主题/语言/布局换成默认值)。读先走
// retryOnWindowsTransient —— Windows 上同步盘/杀毒软件短暂持句柄
// 会让 open 报 EPERM/EBUSY,那是「重试一下就好」;
// - **内容不是合法 JSON** → 不抛:逐字复制(「迁移不改变内容」优先),
// 由新根的 initSettings 走它自己的 quarantine 阶梯。
//
// 位置也重要:这一段在 mkdir / 拷贝**之前** ⇒ 读失败时目标目录**一个
// 字节都没被写**,不必先复制几 GB 再回滚。
const sourceSettings = await readSourceSettings(currentDataPath);
await fs.mkdir(newPath, { recursive: true });
try {
// 复制整个数据目录(除了 index/settings.json/location.json,后面会重建)
// 库目录已下线,rootPath 下任何子目录都照搬过去
const entries = await fs.readdir(currentDataPath, { withFileTypes: true });
for (const entry of entries) {
if (
entry.name === 'index' ||
entry.name === WORKSPACE_DATA_LAYOUT.SETTINGS_FILE ||
entry.name === 'location.json'
)
continue;
const src = join(currentDataPath, entry.name);
const dst = join(newPath, entry.name);
// 遇 EEXIST 直接 abort:目标已有同名条目(用户建了同名子目录 / 上次
// 迁移残骸)会让 fs.cp({force:false}) 跳过,新路径缺文件但无报错信号
try {
await fs.cp(src, dst, { recursive: true, force: false });
log.info('Copied', entry.name);
} catch (err: unknown) {
const code = errnoCode(err);
log.warn('Failed to copy', entry.name, code ?? errMsg(err));
throw err;
}
}
// 写新的 settings.json —— 用 atomic 写入避免半截文件被下个启动读到
// 不能走 safeWriteFileAtomic:迁移时 dataPath 还指向旧目录,写新路径会被
// safe-write 的 dataPath 校验拒掉
//
// HIGH:不能**逐字**复制。旧 settings.json 带 rootPath = <旧目录>,逐字
// 搬过去后新目录里的 settings.json 说「我的根在别处」。随后 switchDataPath
// 调 setRootPath(newPath)(safe-write 单例 = 新目录)再 initSettings(newPath),
// 取值优先级是 loaded.rootPath ?? …,getSettings().rootPath = 旧目录,与
// getRootPath() = 新目录永久分叉。后果不是抽象「两个真相」:library-migration
// 用 deriveLibraryPath(getSettings().rootPath) 算「规范库位置」,分叉后
// 指向已被用户放弃的旧目录 —— 用户点「搬」的话,库会被复制到那个即将
// 被删的目录里,新目录下的真库反而不被索引
// 修法:复制时把 rootPath / dataPath 重写成新目录(数据已在复制,这里只是
// 让「我在哪」与实际位置一致),其余字段逐字保留
//
// audit-r207:源没有 settings.json(全新工作区)⇒ **不写**(与源保持一致)。
// 源有但内容损坏 ⇒ 逐字复制(「迁移不改变内容」优先,新根的 initSettings
// 会走它自己的 quarantine 阶梯)。
if (sourceSettings) {
const newSettingsPath = join(newPath, WORKSPACE_DATA_LAYOUT.SETTINGS_FILE);
const parsed = sourceSettings.parsed;
if (parsed) {
parsed['rootPath'] = newPath;
parsed['dataPath'] = newPath;
}
await atomicWriteFileInternal(
newSettingsPath,
parsed ? JSON.stringify(parsed, null, 2) : sourceSettings.raw,
);
} else {
log.info('Source workspace has no settings.json; leaving the new root without one');
}
// 更新 location.json
await writeLocation(newPath);
} catch (err) {
// 清理部分复制的目录,回滚到旧路径
log.error('Failed to copy data, rolling back new directory', err);
if (targetWasEmpty) {
// targetWasEmpty=true newPath 是我们 mkdir 出来的(原不存在或原空),
// rm 合法清理;用户原有非空目录永不 rm,半成品 entry 让用户手动清
await fs.rm(newPath, { recursive: true, force: true }).catch((rmErr: unknown) =>
log.warn('Failed to remove partially-copied data dir during rollback', {
newPath,
err: errMsg(rmErr),
}),
);
} else {
log.warn('Rollback skipped: targetWasEmpty=false, refusing to rm user-owned dir', {
newPath,
});
}
throw err;
}
return { oldPath: currentDataPath, newPath };
}

View File

@@ -0,0 +1,100 @@
// settings.json 字段级校验:把 JSON.parse 出的 unknown 收窄成 Partial<AppSettings>。
// 坏字段被剥掉(等价用户从未写过,DEFAULT_SETTINGS 兜底),不抛错 — 走 saveSettings
// 「无变化跳过」语义,下次落盘自然洗掉。下游 SettingsPage 的 .length / .map /
// 类型 union 触发 "Cannot read properties of undefined" 类崩溃的根因被堵死。
//
// 与 ZodAppSettings 两层并存:strict 边界(IPC)用 Zod,本文件保「剥脏不抛」+ 内联
// 长度上限/绝对路径兜底,不替换为 ZodAppSettings.partial().safeParse(后者的
// 整片拒绝 + ZodError.issues 数组语义无法 1:1 翻成本文件的「只剥坏字段」)
import { isAbsolute } from 'path';
import type { AppSettings, WorkspaceLayout } from '@shared/types';
import { FONT_FAMILIES, THEMES, THEME_PRESETS } from '@shared/types';
import { MAX_IPC_PATH_CHARS } from '../ipc/path-limits';
// audit-r125:theme 三档收 THEMES 单源(与下行 FONT_FAMILIES/THEME_PRESETS 同族补全)
const VALID_THEMES: ReadonlySet<string> = new Set(THEMES);
// version 是自由字符串但会写进 settings.json。读侧 1MB cap、写侧无 cap ——
// 超长 version 就能撑爆配置(2026-10 enabledExtensions 6MB 事件同模式)
// 64 字符宽松到不可能误伤真实值
const MAX_SETTINGS_FIELD_CHARS = 64;
const VALID_LANGUAGES: ReadonlySet<string> = new Set(['zh-CN', 'en-US']);
// 从 @shared/types 导入,与 renderer/src/store/workspace.ts 共用单一真相源
const VALID_FONT_FAMILY: ReadonlySet<string> = new Set(FONT_FAMILIES);
const VALID_THEME_PRESET: ReadonlySet<string> = new Set(THEME_PRESETS);
/** 字段值不符合预期则丢弃该字段(走 DEFAULT_SETTINGS 兜底)。
* 顶层非 object 返空对象,让外层 spread 拿全 DEFAULT_SETTINGS */
export function sanitizeAppSettings(raw: unknown): Partial<AppSettings> {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
const o = raw as Record<string, unknown>;
const out: Partial<AppSettings> = {};
if (typeof o.rootPath === 'string' && isAbsolute(o.rootPath)) {
// rootPath 必须绝对(相对路径会基于 process.cwd() 解析)
if (o.rootPath.length <= MAX_IPC_PATH_CHARS) {
out.rootPath = o.rootPath;
}
}
if (typeof o.dataPath === 'string') {
// dataPath 也必须绝对:SETTINGS_PICK_DATA_PATH dialog 必返绝对、SETTINGS_MIGRATE_DATA
// 已 isAbsolute 守卫,这里是兜底 IPC SETTINGS_UPDATE 整批 patch 注入
// 已 @deprecated 但继续接受 — 丢弃会导致 initSettings 拿不到旧值做 rootPath 推导
if (isAbsolute(o.dataPath) && o.dataPath.length <= MAX_IPC_PATH_CHARS) {
out.dataPath = o.dataPath;
}
}
if (typeof o.theme === 'string' && VALID_THEMES.has(o.theme)) {
out.theme = o.theme as AppSettings['theme'];
}
if (typeof o.language === 'string' && VALID_LANGUAGES.has(o.language)) {
out.language = o.language as AppSettings['language'];
}
if (typeof o.version === 'string' && o.version.length <= MAX_SETTINGS_FIELD_CHARS) {
out.version = o.version;
}
if (
o.workspaceLayout &&
typeof o.workspaceLayout === 'object' &&
!Array.isArray(o.workspaceLayout)
) {
const wl = sanitizeWorkspaceLayout(o.workspaceLayout);
if (wl) out.workspaceLayout = wl;
}
return out;
}
/** 启动加载 settings.json。thin wrapper 保留独立名字便于 grep 「加载路径」vs「更新路径」 */
export function sanitizeLoadedSettings(raw: unknown): Partial<AppSettings> {
return sanitizeAppSettings(raw);
}
/** IPC SETTINGS_UPDATE patch。共享 sanitizeAppSettings 自动保证启动 / 更新同源 */
export function sanitizeSettingsPatch(raw: unknown): Partial<AppSettings> {
return sanitizeAppSettings(raw);
}
function sanitizeWorkspaceLayout(raw: unknown): WorkspaceLayout | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const o = raw as Record<string, unknown>;
const out: WorkspaceLayout = {};
// `modified` 替代保留关键字 `any`,审计期发现 `any` shadowing
let modified = false;
// r242:sidebarCollapsed 复活 —— 只保留「左栏列表折叠」这一条(标题栏按钮 +
// ⌘B 切换)。detailCollapsed / activityBarHidden / zenMode 仍下线
// (右栏常驻无折叠,ActivityBar / zenMode 整体删除)。
for (const key of ['statusBarHidden', 'sidebarCollapsed'] as const) {
if (typeof o[key] === 'boolean') {
out[key] = o[key];
modified = true;
}
}
if (typeof o.fontFamily === 'string' && VALID_FONT_FAMILY.has(o.fontFamily)) {
out.fontFamily = o.fontFamily as WorkspaceLayout['fontFamily'];
modified = true;
}
if (typeof o.themePreset === 'string' && VALID_THEME_PRESET.has(o.themePreset)) {
out.themePreset = o.themePreset as WorkspaceLayout['themePreset'];
modified = true;
}
return modified ? out : null;
}

View File

@@ -0,0 +1,176 @@
// 启动期进度状态机 — 单一权威源,所有 startup-phase 事件都从这里出。
//
// 职责:
// - 维护 StartupProgressSnapshot(主进程唯一一份真相)
// - 通过 BrowserWindow.webContents.send 推送给所有未销毁窗口
// - 提供 snapshot() 给 STARTUP_GET_STATE IPC(渲染层 mount 时兜底拉)
//
// 设计要点:
// - 状态机简单:每个 phase 单独 pending/running/done,允许独立失败互不影响
// - snapshot 与最后一次推送语义自洽(send 内先更新 snap,再发事件)
// - 主进程崩溃 / 推送异常都不能让该单例崩出(所有方法 try/catch 兜底)
//
// 不做的事:
// - 不在这里 await 任何 service 的 Promise — 调用方在 startup.ts 包 then/catch
// - 不持久化 — 进程内内存状态,关窗即丢
// - 不做 IPC 序列化类型校验 — 渲染层用 StartupProgressEvent discriminated union 自检
// perf(2026-08-24):走 broadcastToAllWindows helper 替代 inline BrowserWindow 循环
import { broadcastToAllWindows } from './ipc/broadcast';
import {
IPC,
StartupPhase,
StartupPhaseInfo,
StartupProgressEvent,
StartupProgressSnapshot,
} from '../shared/types';
import { createLogger } from './utils/logger';
// audit C3R47 (2026-08-22):errMsg 替代 5 处内联 `errMsg(err)`
import { errMsg } from './utils/error';
const log = createLogger('startup-progress');
/** 三个 phase 的初值 — 都是 pending,total 未知,error 为空 */
function freshPhases(): StartupProgressSnapshot['phases'] {
return {
notes: { state: 'pending' },
// audit r51 (2026-10-15):补 'notes-resync' 占位 — r50 M6 引入新 phase 但
// runtime initializer 漏跟进,types.ts 已声明其为 StartupProgressSnapshot.phases
// 必填键。r51 当时仅作 type/runtime 一致性兜底(原注「phase 永不被
// phase-start 激活」描述的是那一轮状态;audit-r124 校正:后续轮已
// 接入 —— startup.ts:211 trackPhase('notes-resync', resyncIfNeeded)
// 在真实启动路径激活本 phase)。
'notes-resync': { state: 'pending' },
// audit-r136:literature phase 占位 —— 与 StartupPhase 同步扩。
// startup.ts:runBackgroundScansFireAndForget 在 notes-resync 后、
// finalizing 前调 trackPhase('literature', () => literatureService.load())。
// 任何一处漏加 → renderer 端 phases.literature === undefined,
// StartupOverlay 直接卡死。snapshot 浅拷贝见下方同款注释。
literature: { state: 'pending' },
finalizing: { state: 'pending' },
};
}
function freshSnapshot(): StartupProgressSnapshot {
return {
done: false,
startedAt: null,
currentPhase: null,
phases: freshPhases(),
lastError: null,
};
}
/** 把 StartupPhaseInfo 通用 patch 进对应槽位 — 仅覆盖给定字段,不动其他 */
function patchPhase(
snap: StartupProgressSnapshot,
phase: StartupPhase,
patch: Partial<StartupPhaseInfo>,
): void {
const cur = snap.phases[phase];
snap.phases[phase] = { ...cur, ...patch };
}
class StartupProgressBroadcaster {
private snap: StartupProgressSnapshot = freshSnapshot();
/**
* 标记某 phase 开始。允许在同一 phase 被 start 多次(幂等:running 后再 start 不变)。
* 首次调用时同时 stamp startedAt + currentPhase。
* 多个 phase 并发时,last-started wins(overlay 显示最新启动阶段的 label)。
*/
startPhase(phase: StartupPhase, total?: number): void {
try {
// perf+correctness:done 后不再接受新 phase(防止误调用污染 snapshot)
if (this.snap.done) return;
if (this.snap.startedAt === null) this.snap.startedAt = Date.now();
this.snap.currentPhase = phase;
const patch: Partial<StartupPhaseInfo> = { state: 'running' };
if (typeof total === 'number') patch.total = total;
patchPhase(this.snap, phase, patch);
this.send({ type: 'phase-start', phase, total });
log.debug('phase-start', phase, total ?? '');
} catch (err) {
log.warn('startPhase failed', phase, errMsg(err));
}
}
/**
* 标记某 phase 结束。ok=false 时 error 字段填入,lastError 也更新。
* 同一 phase 可多次 endPhase(用最新一次覆盖,方便 finalizing 阶段覆盖之前的失败)。
*/
endPhase(phase: StartupPhase, ok: boolean, error?: string): void {
try {
if (this.snap.done) return;
const patch: Partial<StartupPhaseInfo> = { state: 'done' };
if (!ok && error) {
patch.error = error;
this.snap.lastError = error;
}
patchPhase(this.snap, phase, patch);
this.send({ type: 'phase-done', phase, ok, error });
log.debug('phase-done', phase, ok, error ?? '');
} catch (err) {
log.warn('endPhase failed', phase, errMsg(err));
}
}
/**
* 全部完成 — 推 all-done 并标记 snapshot.done=true。
* 任何后续 startPhase 都不再发送事件(snapshot 已 done)。
*/
finish(): void {
try {
this.snap.done = true;
this.send({ type: 'all-done' });
log.info('all startup phases done');
} catch (err) {
log.warn('finish failed', errMsg(err));
}
}
/**
* 测试用 — 把内部 snap 重置为初始状态。
* 生产代码不应调用(会丢失 done 状态导致 overlay 重新挂载);
* 暴露此方法是为了让 vitest beforeEach 不必走 private 字段黑魔法。
*/
reset(): void {
this.snap = freshSnapshot();
}
/**
* 给 STARTUP_GET_STATE IPC handler 用 — 返回当前 snapshot 的浅拷贝,
* 防止 renderer 端意外篡改主进程内部状态。
*/
snapshot(): StartupProgressSnapshot {
return {
done: this.snap.done,
startedAt: this.snap.startedAt,
currentPhase: this.snap.currentPhase,
phases: {
notes: { ...this.snap.phases.notes },
// audit r51:补 'notes-resync' 浅拷贝,与 freshPhases 对齐。
'notes-resync': { ...this.snap.phases['notes-resync'] },
// audit-r136:literature 浅拷贝 — 与 freshPhases 对齐。漏掉此处
// 会让 renderer 收到 phases.literature === undefined(浅拷贝
// 只覆盖显式列出的键),StartupOverlay 加载阶段卡死。
literature: { ...this.snap.phases.literature },
finalizing: { ...this.snap.phases.finalizing },
},
lastError: this.snap.lastError,
};
}
/**
* 推送给所有未销毁的 renderer。
* - snapshot.done 后不再发事件(防止 reload 后又看见 overlay 跳动)
* - 每个窗口单独 try/catch,某个 send 抛错不影响其他窗口
*/
private send(ev: StartupProgressEvent): void {
if (this.snap.done && ev.type !== 'all-done') return;
// perf(2026-08-24):走 broadcastToAllWindows 统一 helper
broadcastToAllWindows(IPC.STARTUP_PROGRESS, ev);
}
}
export const startupProgress = new StartupProgressBroadcaster();

261
src/main/startup.ts Normal file
View File

@@ -0,0 +1,261 @@
// src/main/startup.ts
// 顶层启动编排:解析 dataPath、initSettings、layout 迁移、workspace 子目录 mkdir、
// fire-and-forget initDB(让 IPC 注册不被同步 30-80ms 阻塞)、注册 IPC。
// service.start(favorites / notes)由 index.ts 的 inline-bootstrap 接管。
import { promises as fs } from 'fs';
import { join } from 'path';
import { resolveDataPath } from './location';
import { setRootPath } from './utils/safe-write';
import { initSettings } from './settings/store';
import { initDB } from './db/sqlite';
import { registerAllIPC } from './ipc';
import { getNotesService, NotesService } from './notes/service';
import { waitForDBInit } from './db/sqlite';
import { getFavoritesService, FavoritesService } from './workspace/favorites';
// audit-r136:literature service 单例 —— 与 notes 同款 module-level single,
// ipc/index.ts 注册 + index.ts 启动 + before-quit 停止 三处共用同一引用。
// 放在 startup.ts 顶层而不是另起一个 singleton barrel,是为了与 notes
// 路径对称(notesService / literatureService 在同一行 import 群),index.ts
// 已有 `import { notesService } from './startup'` 形态,加同款 import 0 开销。
import { getLiteratureService, LiteratureService } from './literature/literature-service';
import { migrateDataLayoutV2 } from './settings/migration-layout';
import { createLogger } from './utils/logger';
import { errMsg, errPayload } from './utils/error';
import { startupProgress } from './startup-progress';
import { broadcastToAllWindows } from './ipc/broadcast';
import { IPC, WORKSPACE_DATA_LAYOUT } from '../shared/types';
import type { StartupPhase, ToastPushPayload } from '../shared/types';
const log = createLogger('startup');
// 模块级单例:供 ipc/index.ts 在注册 handler 时使用,以及 index.ts 监听 'changed' 事件。
export const notesService: NotesService = getNotesService();
// audit-r136:literature service 单例 —— 同 notes 形态(懒加载 + module-level memoize)。
// index.ts 的 inline-bootstrap 走 `literatureService.start({ skipInitialLoad: true })`,
// notes-resync phase 内 `literatureService.load()` 主动触发 disk↔DB diff(因为
// start 时跳了 initial load,这里补回)。
export const literatureService: LiteratureService = getLiteratureService();
// Safe getter — 直接走 workspace/favorites.ts 的 memoize 单例,与 index.ts 启动时
// 拿到的引用一致,before-quit 的 `?.stop()` 能真正排空启动时建立的写队列。
// 返回 `| null` 是 Promise.allSettled 的链式契约占位(getFavoritesService() 自身
// lazy memoize,首调即填,不会真返 null)。
export function getFavoritesServiceSafe(): FavoritesService | null {
return getFavoritesService();
}
// audit 2026-09-14 r7:async mkdir —— 原来 sync mkdirSync 阻塞事件循环 5-30ms;
// 改成 fs.promises.mkdir 后 syscall 走 libuv 线程池,主进程不 stall。
async function ensureDirAsync(p: string): Promise<void> {
try {
await fs.mkdir(p, { recursive: true });
} catch (err: unknown) {
log.error('Failed to create dir', p, errPayload(err).message);
throw err;
}
}
// perf(2026-09-14):预创建 workspace 子目录 —— NotesService.start / FavoritesService
// .start 都会 mkdir,但全部塞进 service 启动阶段会变成串行 IO(尤其是首次启动
// 全新 dataPath 时,每个 mkdir 都触发 syscall)。这里在 startup() 顶层与
// initSettings 并发跑一次,后续 service 启动路径走 cache (mkdir recursive 见到
// 已存在目录立即 return)。也能让全新用户首次创建笔记时 parent 一定存在。
// audit 2026-09-14 r7:并发跑两个 mkdir —— 之前 for 循环串行,每个 5-30ms,
// 改成 Promise.all 并发可省一份 mkdir 延迟。
async function ensureWorkspaceDirs(dataPath: string): Promise<void> {
// audit:FAVORITES_DIR = 'workspace/favorites'(带子路径),直接 join 避免再嵌一层 workspace/
// audit-r136:literature 目录预创建 —— 与 notes 对齐;LiteratureService.start
// 内部也有 mkdir 兜底(防 service 跑在 startup 之前),此处是 startup 顶部
// 主动建好让后续 import / chokidar watch / reload 走 cache(idempotent)。
// 全新 dataPath + 用户首次启动场景:这里 mkdir 后,startup 完成的
// literature 目录就绪,chokidar 监听 + reload 都不再 5-30ms 同步阻塞。
const dirs = [
join(dataPath, WORKSPACE_DATA_LAYOUT.NOTES_DIR),
join(dataPath, WORKSPACE_DATA_LAYOUT.FAVORITES_DIR),
join(dataPath, WORKSPACE_DATA_LAYOUT.LITERATURE_DIR),
];
await Promise.all(dirs.map((d) => ensureDirAsync(d)));
}
/**
* 把一段异步工作包成 "startPhase -> fn -> endPhase(ok|err)"。
* 让 renderer 端的 StartupOverlay 真正能反映主进程扫描进度,
* 而不是依赖 30s 的 forceFinish 兜底超时。
*
* perf:startPhase/endPhase 内部已经 try/catch,这里只关心 fn 自身的成败。
*/
async function trackPhase<T>(phase: StartupPhase, fn: () => Promise<T>): Promise<T> {
startupProgress.startPhase(phase);
try {
const r = await fn();
startupProgress.endPhase(phase, true);
return r;
} catch (err: unknown) {
startupProgress.endPhase(phase, false, errPayload(err).message);
throw err;
}
}
export async function startup(): Promise<void> {
const { path: dataPath } = await resolveDataPath();
log.info('Data path resolved', dataPath);
setRootPath(dataPath);
// perf(2026-09-14):resolveDataPath() 已经在所有 5 处分支成功 mkdir 真实
// dataPath(location.ts:86/155/205/212/218),dataPath 此时已就绪。
// 原 ensureDir(dataPath) 是 sync mkdirSync,阻塞事件循环 5-30ms(Win + AV),
// 且 syscall 永远命中已存在目录。删掉这条 sync IO 让 startup() 顶层
// 不再阻塞主进程。
// audit r33 (2026-09-23):HIGH (r32 回归数据丢失)—— 旧 r32 fix 把
// `ensureWorkspaceDirs` 与 `migrateDataLayoutV2` 串行化,但序向错了:
// ensureWorkspaceDirs 先跑会创建 `<dataPath>/notes/` 与 `<dataPath>/workspace/`
// 空的两个目录,migration 紧接着看到 `new path exists` 直接走
// `migration-layout.ts:141-160` 的「目标已存在 → rm 老」分支,把 v3 升级用户的
// `<dataPath>/data/notes/`(真实笔记位置)整棵 rm -rf,首启必丢笔记。
// 修法:**order 反转** —— initSettings + migrateDataLayoutV2 先跑完(让
// migration 的「newPath 不存在 → rename 老→新」分支正常走),再
// ensureWorkspaceDirs 给新位置 idempotent mkdir `notes/` + `workspace/favorites/`
// 兜底。三种用户都安全:
// - v3 升级:newPath 不存在 → rename 老目录进新位置(真 move,数据保留);
// - 已迁移用户:migration 在 `oldPath` ENOENT 处早 return,ensure 后
// mkdir 命中已存在(no-op);
// - 全新用户:oldPath 不存在,newPath 不存在 → migration 跳过,ensure
// mkdir 两个目录。
await (async () => {
await initSettings(dataPath);
// Layout v2 迁移:把 dataPath/{notes, workspace} 顶层目录
// 整体搬到 dataPath/data/ 下(原子 rename)。幂等:已迁移用户无副作用。
// audit 2026-09 (V1.6):头注原写 "notes, sidecars, ai, collections, workspace"
// 整体搬 —— 但 sidecars / ai / collections 三个目录 2026-08-28 整体下线
// (collections 合并进 favorites,sidecars 合并进 notes-lit,ai 合并进
// notes-ai),物理搬走在 V3 migration 里做(已下线)。当前 V2 只搬
// notes / workspace 两个仍在使用的顶层目录(见
// settings/migration-layout.ts OLD_LAYOUT_DIRS)。
// 重构 (2026-09):V3 内容合并 + JSON→MD 迁移已下线 —— recent/trash 模块
// 删除后这两个迁移失去意义(migration.ts 整体移除)。
// audit 2026-09-14 r7:migration 失败也 swallow —— 失败是 warn 级,用户
// 看不到 layout 迁移不会丢笔记(目标位置已可用);但要给 renderer 推 toast
// 让用户知道 data layout 可能不完整。改:把 warnings 装入 result 返回。
try {
const migrationResult = await migrateDataLayoutV2(dataPath);
if (migrationResult.warnings.length > 0) {
// 给 renderer 推 toast 让用户知道部分迁移失败
for (const w of migrationResult.warnings) {
// audit-r92:satisfies 挂 ToastPushPayload 单源 —— 发送字面与 preload
// 白名单同源 tuple 漂移时 TS 编译期报红(旧 payload 是裸对象无质料)
broadcastToAllWindows(IPC.TOAST_PUSH, {
type: 'warning',
message: `Layout migration: ${w}`,
duration: 8000,
} satisfies ToastPushPayload);
}
}
} catch (err: unknown) {
log.warn('migrateDataLayoutV2 threw (continuing)', errMsg(err));
}
})();
// audit r33 (2026-09-23):HIGH —— 见上方 schema 注释,migrateDataLayoutV2
// 之后跑确保新位置(`notes/` + `workspace/favorites/`)一定就位。
// 老 v3 用户的 `data/notes` 已经重命名为顶层 `notes`,这条 mkdir 命中已
// 存在目录(no-op);已迁移用户也无操作。失败 swallow:service.start 内
// 部还有 mkdir 兜底,启动期全部 fail 不应让 app 死。
await ensureWorkspaceDirs(dataPath).catch((err: unknown) => {
log.warn('ensureWorkspaceDirs failed (service.start will retry)', errMsg(err));
});
// perf(2026-08-24):DB init 内部全同步(better-sqlite3 new Database + pragmas +
// quick_check + migrations)—— 大库(5000+ 文件)首次打开 + quick_check 可吃掉
// 30-80ms 事件循环。包一层 Promise.resolve().then 把同步执行推到下一个 microtask,
// 让 ensureDir + settings + migration 的 IO 完成回调能优先 flush。
// 与 registerAllIPC 并发:任一先完成都行,IPC handler 在 createWindow 前注册即可,
// 而 createWindow 在 index.ts bootstrap 链路后才发生,有充足时间窗口。
// audit 2026-08-25:原 `void Promise.resolve().then(...)` 把 initDB 的 throw 推出
// startup() 的 await 边界,变成 unhandledRejection —— bootstrap() 抓不到错误,
// 用户看不到 dialog.showErrorBox,而是 broken DB + silent startup failure。
// 修:fire-and-forget + .catch 调 bootstrap 路径(通过 dialog.showErrorBox),
// 同时保留 microtask defer 让 IPC 注册不被 initDB 的 30-80ms 阻塞。
// audit 2026-08-28:删 `initDBPromise` 变量 + `waitForDBInit` export —— 全
// src + tests 无任何 caller,只用来 throw 给 unhandledRejection 监听。Promise
// 表达式直接丢弃也没问题,.catch 自身就会让 throw 走到 unhandledRejection。
// audit 2026-09-07:弹 dialog 让用户看到 —— 之前 unhandledRejection 仅写
// renderer/main log,桌面应用里用户看不见。dialog.showErrorBox 是 Electron
// 同步 API,启动期可用(主窗口还没创建)。失败信息走 tr() 走用户语种。
Promise.resolve()
.then(() => initDB(dataPath))
.catch((err: unknown) => {
const e = errPayload(err);
log.error('DB init failed in background', e.message, e.stack);
// dialog 在 BrowserWindow 创建前可调 —— 直接弹原生对话框。
// 静态 import 在主进程同步路径下 OK,不像 renderer 受 bundle 影响。
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { dialog } = require('electron');
dialog.showErrorBox(
'Database initialization failed',
`${e.message}\n\nThe app cannot start without a working database. Please check disk permissions / disk space and restart.`,
);
} catch (dialogErr: unknown) {
log.error('dialog.showErrorBox failed', errMsg(dialogErr));
}
});
registerAllIPC();
}
/**
* 后台执行所有重型磁盘扫描。**不**被调用方 await —— 调用者应当在
* 窗口创建之后才调用,以避免黑屏等待。失败互不影响,trackPhase 包裹每个
* phase 让 StartupOverlay 实时反映。
*/
export function runBackgroundScansFireAndForget(): void {
void (async () => {
log.info('Background scans started');
// audit 2026-09-10:等 initDB 完成 —— startup() 内部把 initDB 包到
// Promise.resolve().then(...) 后 fire-and-forget 启动(让 IPC 注册不被
// 30-80ms 同步执行阻塞),但 runBackgroundScansFireAndForget 紧接着就
// 调 notesService.load —— requireTx() / getDB() 在 initDB 没完成时直接
// 抛 "DB tx not initialized",整个 background scan 崩。waitForDBInit()
// 在 db 就绪前 await,完成后立即 resolve(已是 done 状态下等于一次
// microtask,几乎 0 开销)。
await waitForDBInit();
await trackPhase('notes', () => notesService.load()).then(
() => log.info('Notes scan complete'),
(err) => log.warn('Notes scan failed', errPayload(err).message),
);
// audit r50 (2026-10-15) M6:notes load 后跑 disk ↔ DB 差集(独立 phase,
// 失败不让 startup 退场卡住,log warn 留 trace 即可)。
// audit-r124:resyncIfNeeded 改为就地删除 db-only 行,本处 dbOnlyIds 只是
// 「本轮已清理行」的观测快照。
// audit-r156:旧「写 settings.notesResyncPending 让 renderer modal 决定」
// 链路一并删除,见 schema/types 删除注释。
await trackPhase('notes-resync', () => notesService.resyncIfNeeded()).then(
(r) => {
if (r.dbOnlyIds.length > 0) {
log.info('Notes resync: db-only ghost rows dropped', r.dbOnlyIds.length);
}
if (r.inserted > 0) log.info('Notes resync: disk-only inserted', r.inserted);
},
(err) => log.warn('Notes resync failed', errPayload(err).message),
);
// audit-r136:literature phase —— 在 notes-resync 之后、finalizing 之前
// 排一个独立 phase。LiteratureService.start 阶段用了 skipInitialLoad: true
// (与 notes 同款:让 bootstrap 的 allSettled 不被同步 reload 阻塞),这里
// 显式 load() 触发 disk↔DB diff(读 litDir → upsert disk-only filename
// rows + 删 ghost rows),byId 完成首次快照填充。失败 warn,不让 startup
// 退场卡住(同 notes-resync 兜底语义)。
await trackPhase('literature', () => literatureService.load()).then(
() => log.info('Literature scan complete'),
(err) => log.warn('Literature scan failed', errPayload(err).message),
);
// finalizing 阶段:scan 完成到 overlay 退场之间的小尾巴 ——
// 把这段时间声明成 explicit phase,让 UI 显示"正在整理索引"的最后过渡
// 而不是直接闪现主界面(突兀)。空操作即可,只是占个 phase 信号位。
startupProgress.startPhase('finalizing');
startupProgress.endPhase('finalizing', true);
// 关键:推送 all-done 让 renderer 端 StartupOverlay 立刻收尾
startupProgress.finish();
})().catch((err: unknown) => {
// D2-019: 外层 IIFE 自身的同步抛错或顶链 reject(emit 'changed' 等),不能让进程僵死
const e = errPayload(err);
log.error('runBackgroundScans outer failure', e.message, e.stack);
// 即便中间有意外,也保证 overlay 能收尾,不能让用户卡 30s
startupProgress.finish();
});
}

View File

@@ -0,0 +1,389 @@
// 通用原子化文件抽象
// 适用场景:
// - 单一文件 + 并发写
// - 内存镜像(读零 IO)
// - 启动时从磁盘加载,运行时由 update()/write() 持久化
//
// 特性:
// - 原子写:open(tmp, 'wx') → writeFile → fsync → rename(tmp → dest)
// 断电时不会让 dest 变成半截文件,也不会留下孤儿 .tmp
// 'wx' 防止预置 symlink 把数据写出 dataPath 外。
// - 并发队列:所有 update()/write() 串行化,避免丢失更新
// - 损坏恢复:start() 时 **parse** 抛错 → quarantine 坏文件(保留内容)
// + 走 defaultValue 兜底。**读失败不等于文件损坏** —— 读不到时
// 原文件原封不动留在盘上,只锁写并抛错(audit r199)
//
// 不适用场景:
// - 多文件协调(如 trash.record 要 rename + 写 index.md) → 由调用方串行调
// - 大文件流式写 → 走 fs.createWriteStream,而不是这个抽象
// - chokidar 监听的目录 → 用 watcher + reload 模式
import { EventEmitter } from 'events';
import { promises as fs } from 'fs';
import { randomBytes } from 'crypto';
import { dirname } from 'path';
import { createLogger } from './logger';
// RKMS_TMP_MARKER 与 safe-write 共用同一份 —— 两条写路径 + 残留清扫器
// 必须对「什么算本应用的 tmp」有同一个定义,否则清扫器会扫不到自己写的,
// 或(更糟)扫到别人的。见 safe-write.ts 的 ATOMIC_TMP_PATTERN 头注。
import { UnsafeWriteError, safeCloseFh, RKMS_TMP_MARKER } from './safe-write';
import { errMsg, errnoCode, AppError } from './error';
import { retryOnWindowsTransient } from './retry-transient';
import { needsDirFsyncForRename } from './platform';
interface AtomicFileOptions<T> {
/** 绝对路径(已校验在 dataPath 内) */
filePath: string;
/** 文件不存在/解析失败时的兜底值 */
defaultValue: T;
/** T → 字符串 */
serialize: (value: T) => string;
/** 字符串 → T;抛错会被 start() 兜底为 defaultValue */
parse: (raw: string) => T;
/** 写完成后触发(用于上层 emit 'changed') */
onChange?: (value: T) => void;
/** 日志 scope */
logScope?: string;
}
export class AtomicFile<T> extends EventEmitter {
private current: T;
private writeQueue: Promise<unknown> = Promise.resolve();
private filePath: string;
private defaultValue: T;
private serialize: (value: T) => string;
private parse: (raw: string) => T;
private onChangeCb?: (value: T) => void;
private scope: string;
private loaded = false;
/**
* 写锁:一旦置位,此后所有 persist 一律拒绝 —— 宁可功能暂不可用,
* 也不静默覆盖用户数据。
*
* 两个来源(audit r199 统一成一条闸):
* 1. 坏文件没能被 quarantine 出去(rename + copy 都失败)—— 磁盘上
* 仍是一份「内容坏了但必须留着」的文件,不能被空态覆盖;
* 2. **读失败**(EACCES/EPERM/EBUSY/EIO/EISDIR)—— 这一刻读不到,
* 但文件内容可能完好(权限 / 被占用 / 路径指向目录)。旧实现
* 把它当「文件损坏」处理,quarantine 会把用户数据改名走 ——
* 那不是保护,是二次伤害(详见 start() 注释)。
*/
private writeBlockedReason?: string;
constructor(opts: AtomicFileOptions<T>) {
super();
this.filePath = opts.filePath;
this.defaultValue = opts.defaultValue;
this.serialize = opts.serialize;
this.parse = opts.parse;
this.onChangeCb = opts.onChange;
this.scope = opts.logScope || 'atomic-file';
this.current = opts.defaultValue;
}
/**
* 启动时调用一次:从磁盘加载到内存。
* - 文件不存在(ENOENT) → 写一份 defaultValue,内存态 = defaultValue;
* - **读失败**(EACCES/EPERM/EBUSY/EIO/EISDIR)→ 抛错 + 锁写,
* 文件**原封不动**留在盘上(不 quarantine、不写默认值);
* - **解析失败** → quarantine 坏文件 + 走 defaultValue。
*
* audit r28 (2026-09-21):quarantine 坏文件 —— 旧实现仅 warn 一行就
* 用 defaultValue 接管,坏文件留盘上原位。favorites 这类「用户亲手
* 维护」的文件(OneDrive 同步中断 / 手动 edit 破坏 frontmatter)如果
* 直接被 defaultValue 覆盖,用户连抢救的机会都没有 —— 第一次 toggle
* 就把 corrupt file 用空 in-memory state 持久化,原 200 条收藏永久
* 丢失。修法:parse 失败 → rename 到 `<file>.corrupt-<timestamp>`,
* 再用 defaultValue 接管。rename 失败不阻断启动(目标已不可读,
* 至少保住内存态),但 warn 升级到 error 让 log 监控可见。
*
* audit r199 (2026-10-20):读失败与解析失败**分开处置** —— 旧实现
* 两者共用一个 catch,一次瞬时读失败就把用户的 favorites.md 改名走
* 并回落空态。详见下面阶段 1 / 阶段 2 的注释。
*/
async start(): Promise<void> {
const localLog = createLogger(this.scope);
//
// audit r199 (2026-10-20):**读失败 ≠ 文件损坏** —— 把「读」和「解析」
// 拆成两个阶段,各自独立处置。
//
// 旧实现只有一个 try,把 fs.readFile 和 this.parse 一起裹住:
// catch → 非 ENOENT / 非 UnsafeWriteError ⇒ 一律当成「文件坏了」
// → rename 成 `<file>.corrupt-<ts>` → 内存态回落 defaultValue。
// 于是**任何读错误**(EACCES 权限 / EPERM / EBUSY 被占用 / EIO / EISDIR
// 路径其实是目录)都会:
// 1. 把用户的 favorites.md 改名走(文件内容可能完好无损!);
// 2. 内存态变成**空收藏列表**,而启动路径不抛错 ⇒ 界面上 ★ 全部消失,
// 用户看不到任何错误提示;
// 3. 用户下一次点 ★ → persist 把「空列表 + 1 条」写成一个**全新**的
// favorites.md —— 原来的 200 条只剩 .corrupt-* 那一份。
// 换句话说:一次瞬时读失败 = 收藏全丢。这跟本抽象自己写的原则
// 「宁可让这个功能暂时不可用,也不能静默销毁用户数据」是反的。
//
// 新契约:
// 读阶段 —— ENOENT → 写 defaultValue(首次运行);UnsafeWriteError →
// 抛(路径校验失败由调用方处理);**其它读错误 → 抛 + 锁写,
// 既不 rename 也不写默认值**(文件原封不动留在盘上)。
// 解析阶段 —— parse 抛错才说明「内容坏了」→ 走 r28 的 quarantine
// 链(rename → copy 退化 → 拒绝写),语义与 r196 的 favorites
// 「entries 形态非法 → 抛错 → quarantine」完全不变。
let raw: string;
try {
// 读也走 transient 退避:Windows 上 AV / OneDrive 短暂持句柄会让
// readFile 报 EPERM/EBUSY,那是「重试一下就好」,不该被当成损坏。
raw = await retryOnWindowsTransient(
() => fs.readFile(this.filePath, 'utf8'),
() => Promise.resolve(), // 读失败没有 tmp 残留要清
);
} catch (err: unknown) {
if (errnoCode(err) === 'ENOENT') {
localLog.info('No existing file, using default', this.filePath);
// 启动时若文件不存在,主动写一份 defaultValue,让目录里立刻能看到
await this.write(this.defaultValue);
this.loaded = true;
return;
}
if (err instanceof UnsafeWriteError) {
// 路径校验失败:这种错误不能静默吞掉 — 由调用方处理
throw err;
}
// 读失败 —— 处置:① 不 quarantine(改名走是破坏,不是保护);
// ② 不写 defaultValue(那会把用户数据覆盖成空态);
// ③ 锁死后续写入(★ toggle 不能把读不到的文件盖掉);
// ④ 抛给调用方 —— favorites service 启动期抛错会被 index.ts 弹错误框,
// 用户至少知道「收藏读不出来」,而不是「收藏凭空没了」。
this.writeBlockedReason =
`the file could not be read (${errMsg(err)}) and was left untouched on disk. ` +
`Fix its permissions / close whatever is holding it, then restart.`;
localLog.error(
'Read failed — file left untouched (NOT treated as corruption), further writes blocked',
this.filePath,
'| err:',
errMsg(err),
);
throw err;
}
// ---- 阶段 2:解析(只有这里失败才说明「内容坏了」)----
try {
this.current = this.parse(raw);
localLog.info('Loaded', this.filePath);
} catch (err: unknown) {
// audit r28:parse 失败 quarantine 坏文件 ——
// 先尝试 rename,再走 defaultValue。
//
// audit 2026-10-04:quarantine 失败时**不能就这么算了**。
// 原实现在 rename 失败时只 log.error 然后继续:坏文件留在 filePath
// 原位,内存已切到 defaultValue。用户下次点任何一颗星标 → update() →
// persist() → 原子写直接覆盖坏文件 —— **用户最后的抢救机会归零**。
// 注释 69-76 声称的修法「rename 到 quarantine 再用 defaultValue」,
// 在 rename 失败时正好退化回它本要修掉的旧行为。
//
// 三层兜底,每一层都比上一层弱,任何一层成功就停:
// 1. rename + transient 退避重试 —— rename 失败绝大多数是 Windows 上
// AV / OneDrive 短暂持句柄(EBUSY/EPERM),项目里已有现成的
// retryOnWindowsTransient(atomic-file 自己就在用它做写入)。
// 2. 退化成 copy —— copy 只需要读权限 + 写一个新文件,不需要对
// 原文件做 rename。锁住原文件不影响 copy,能保住内容。
// 3. 连 copy 都不行 → 置写锁,**拒绝**后续 persist
// 覆盖原文件,抛可读错误。宁可让这个功能暂时不可用,
// 也不能静默销毁用户数据(本抽象的唯一生产实例是用户的收藏夹)。
const quarantinePath = `${this.filePath}.corrupt-${Date.now()}`;
let quarantined = false;
let lastErr: unknown = err;
try {
await retryOnWindowsTransient(
() => fs.rename(this.filePath, quarantinePath),
() => Promise.resolve(), // 绝不能删源 —— 那是唯一的用户数据
);
quarantined = true;
} catch (renameErr: unknown) {
lastErr = renameErr;
// 第 2 层:退化成 copy(内容已解析失败但字节还在,必须留住)
try {
await fs.copyFile(this.filePath, quarantinePath);
quarantined = true;
} catch (copyErr: unknown) {
this.writeBlockedReason =
`the existing file failed to parse and could not be quarantined ` +
`(${errMsg(lastErr)} / ${errMsg(copyErr)}). ` +
`Move or delete that file manually, then restart.`;
localLog.error(
'Parse failed; quarantine rename AND copy both failed — ' +
'further writes to this file are blocked to avoid destroying the corrupt data',
this.filePath,
'| parse error:',
errMsg(err),
'| rename error:',
errMsg(renameErr),
'| copy error:',
errMsg(copyErr),
);
}
}
if (quarantined) {
localLog.error(
'Parse failed, quarantined corrupt file before using default',
this.filePath,
'→',
quarantinePath,
errMsg(err),
);
}
// quarantined === false ⇒ rename 与 copy 都失败 ⇒ 写锁已在上面置位。
}
this.loaded = true;
}
/** 同步读内存值(零 IO) */
get(): T {
return this.current;
}
/** 文件是否已加载(防止外部在 start() 之前读) */
isLoaded(): boolean {
return this.loaded;
}
/**
* 用函数变换当前值后原子写入。
* 串行化:所有 update() 并发调用都会被此机制排成 FIFO。
*/
async update(fn: (curr: T) => T | Promise<T>): Promise<T> {
const prev = this.writeQueue;
let release: () => void = () => {};
this.writeQueue = new Promise<void>((r) => {
release = r;
});
await prev;
try {
const next = await fn(this.current);
return await this.persist(next);
} finally {
release();
}
}
/** 直接覆盖整个值 */
async write(value: T): Promise<T> {
const prev = this.writeQueue;
let release: () => void = () => {};
this.writeQueue = new Promise<void>((r) => {
release = r;
});
await prev;
try {
return await this.persist(value);
} finally {
release();
}
}
/** 停止前调用:等待所有 in-flight 写完成 */
async stop(): Promise<void> {
await this.writeQueue;
}
/**
* 内部:把值原子写到磁盘。已假定 writeQueue 已获取。
* 1. 确保父目录存在
* 2. 写到 .tmp + fsync
* 3. rename(.tmp → dest) — POSIX 原子,Windows 上 fs.rename 是 replace 语义
* 4. 更新内存 + 触发 onChange
*
* audit round 18 (2026-08-27):tmp 加 8 字符 base64url 随机后缀(防并发
* 同 target 互相 truncate)+ 'wx' 模式(防 symlink write-through)。
*/
private async persist(value: T): Promise<T> {
const localLog = createLogger(this.scope);
// audit 2026-10-04:quarantine 全失败时的最后一道闸 —— 拒绝写,
// 而不是把仍存在于磁盘的坏文件覆盖掉。错误信息要能让用户知道
// 「去把那个文件改名/删掉,应用就能继续写」。
// audit r199:同一道闸也拦住「读失败」(文件还在、但读不到)—— 那种情况
// 下写默认值 = 把用户数据覆盖成空态,正是本抽象最该避免的事。
if (this.writeBlockedReason) {
throw new AppError('IO_ERROR', `Refusing to overwrite ${this.filePath}: ${this.writeBlockedReason}`);
}
const payload = this.serialize(value);
const target = this.filePath;
await fs.mkdir(dirname(target), { recursive: true });
const tmp = `${target}.${RKMS_TMP_MARKER}${randomBytes(6).toString('base64url')}.tmp`;
let fh;
try {
fh = await fs.open(tmp, 'wx');
await fh.writeFile(payload, 'utf8');
await fh.sync();
await fh.close();
fh = null;
} catch (err) {
// audit 2026-09-14:close 改 warn —— localLog 已存在(同方法 line 150 声明),
// Windows stale handle 让 close EBUSY 时不再 swallow。
// audit round 8 (2026-09-14):走 safeCloseFh helper,统一 close 失败日志形态。
// `let fh` 推断为 FileHandle | undefined,safeCloseFh 签名收窄为 | null,
// ?? null 强制窄化,避免调用点类型噪音。
await safeCloseFh(fh ?? null, 'fh.close failed during atomic-write error path', localLog, {
tmp,
});
// audit 2026-08-25:unlink 只吞 ENOENT —— 其他错误(权限 / Windows 锁文件)
// 之前 swallow 会留下孤儿 .tmp,占盘且下次启动 load 时干扰 .tmp-rescue 逻辑。
await fs.unlink(tmp).catch((err: unknown) => {
if (errnoCode(err) !== 'ENOENT') {
localLog.warn('Failed to clean tmp on write error', { tmp, err: errMsg(err) });
}
});
throw err;
}
// Windows 在快速 back-to-back rename 时偶发 EPERM/EBUSY(AV 扫描/文件
// watcher 短时持 handle 导致 MoveFileEx 失败)。重试 5 次,指数退避。
// 这是 Windows 文件系统的已知 quirk,POSIX 上不会触发。
//
// audit C3R66 (2026-08-22):抽到 utils/retry-transient.ts,safe-write.ts 共享。
await retryOnWindowsTransient(
() => fs.rename(tmp, target),
() => fs.unlink(tmp),
);
// audit 2026-10-04:rename 后 fsync 父目录。atomic(不会留半截文件)与
// durability(rename 本身已提交)是两件事;只 fsync tmp 的数据块不够。
// 与 safe-write.ts 的 atomicWriteFileInternal 同款,共用 platform.ts
// 的 needsDirFsyncForRename 判定(Windows 上跳过 —— 那里没有这个契约)。
if (needsDirFsyncForRename) {
let dirFh: Awaited<ReturnType<typeof fs.open>> | null = null;
try {
dirFh = await fs.open(dirname(target), 'r');
await dirFh.sync();
} catch (dirErr: unknown) {
// 某些文件系统不支持目录 fsync(部分 macOS 卷 / 网络盘)—— 只 warn,
// 不让一次已成功的保存整体失败。
localLog.warn('fsync parent dir failed (rename durability not guaranteed)', {
dir: dirname(target),
err: errMsg(dirErr),
});
} finally {
await safeCloseFh(dirFh, 'fsync parent dir close failed', localLog, {
path: dirname(target),
});
}
}
this.current = value;
if (this.onChangeCb) {
try {
this.onChangeCb(value);
} catch (err) {
localLog.warn('onChange callback threw', errMsg(err));
}
}
// audit C3R5 (2026-08-22):emit('changed') 必须每个 listener 单独 try/catch。
// 原实现只用一层 try/catch 包整个 emit —— EventEmitter 在内部对 listeners
// 同步迭代,第一个抛错的 listener 让 emit() 整体 reject,后续 listener 不会跑。
// 这与设计意图("listener 抛错只影响那一个 listener")不符。
// listener A throw → B,C,D 全不跑(应该只 A 不跑,B,C,D 继续)。
// 修复:手动迭代 listeners('changed'),每个单独包 try/catch。
const listeners = this.listeners('changed');
for (const l of listeners) {
try {
(l as (v: T) => void)(value);
} catch (err: unknown) {
localLog.error('changed listener threw (caught, persisting finished)', errMsg(err));
}
}
return value;
}
}

View File

@@ -0,0 +1,51 @@
// 笔记 / Markdown body 读统一入口 — audit r47 (2026-10-15) M5。
//
// 契约:任何在 src/main/** 读 .md / 笔记 body 的代码必须走本文件(或
// notes/standalone.ts:parseNoteFile 的块读 + 早 abort 路径,
// 后者是 scan hot path 的特化,见 standalone.ts:140-148 注释)。
// grep 守卫:tests/unit/main/lint/no-direct-body-read.test.ts
// 扫 `fs.readFile(` + `.md` 路径,除本文件与 standalone.ts 之外
// 全部 fail。
//
// 为什么有 TOCTOU 问题(规范问题):fs.stat + fs.readFile 两步走法,
// stat / read 间存在时间窗口,AV 隔离 / OneDrive 重水合 / 用户
// atomic-rename 都可能把文件换成 1GB+ 大文件,5MB 上限失效,主进程 OOM。
// 本实现用单 fd 拿 stat + read,缩小窗口;更严的 scan hot path
// 在 standalone.ts:parseNoteFile(块读 + 字节累计 + 早 abort)。
//
// cap 默认值 = MAX_NOTE_BODY_BYTES(5MB),调用方可按需调低(例如搜索 snippet
// 只需 16KB)。
import { open } from 'node:fs/promises';
import { AppError } from './error';
import { MAX_NOTE_BODY_BYTES } from '../ipc/path-limits';
import { createLogger } from './logger';
import { safeCloseFh } from './safe-write';
const log = createLogger('body-cap');
/**
* 读文件 body,size 超过 cap 抛 AppError('TOO_LARGE')。
*
* @param abs 绝对路径
* @param cap 上限,默认 MAX_NOTE_BODY_BYTES(5MB)
* @returns utf-8 字符串
* @throws AppError(TOO_LARGE) 超过上限
* @throws Error 其他 fs 错误(open / read 失败)
* audit-r124:close 失败不再 throw —— 旧实现裸 `await fh.close()` 在
* finally 里,EBADF 等 close 错误会把已成功的 read 结果整体替换掉(真数据
* 丢失假象),更会把 TOO_LARGE 的准确错因掩盖成 close 错。改走 safe-write
* 的 safeCloseFh:warn 落日志,fd 泄漏由进程退出兜底(单 fd 生命周期内
* close 失败极罕见,宁可丢一个 fd 不可错报内容)。
*/
export async function readBodyWithCap(abs: string, cap = MAX_NOTE_BODY_BYTES): Promise<string> {
const fh = await open(abs, 'r');
try {
const stat = await fh.stat();
if (stat.size > cap) {
throw new AppError('TOO_LARGE', `${abs}: ${stat.size} > ${cap}`);
}
return await fh.readFile({ encoding: 'utf8' });
} finally {
await safeCloseFh(fh, 'body-cap: fh close failed', log, { path: abs });
}
}

View File

@@ -0,0 +1,38 @@
// Content-Disposition 头构造(rkms:// protocol handler 用)。
//
// 为什么必须独立成纯函数(2026-10-20 报障实证):
// Fetch 规范要求 header value 是 ByteString —— 每字符 ≤ U+00FF,
// Headers.set 遇到越界字符当场 throw TypeError
// 「Cannot convert argument to a ByteString」。
// 文件名带 U+2019(’ 排版右单引号)/ U+2013(–)/ 中文 时,
// 旧实现 `inline; filename="${baseName}"` 直接炸,handler 外层 catch
// 返 500 ⇒ Reader 探测 fetch 拿 500 ⇒ 「打开失败」错误页。
// (实证:node REPL 里 Headers().set('Content-Disposition', '... 1T’ ...')
// ⇒ TypeError: value of 8217 which is greater than 255。)
//
// 修法(RFC 6266 + RFC 5987):
// - filename="..." 兜底段:只放 ASCII 可打印字符,引号/反斜杠/控制符/
// 非 ASCII 一律折成 '_' —— 这段只是给老旧 client 的降级展示;
// - filename*=UTF-8''<percent-encoded>:真实文件名的无损载体,
// 现代 client(Chromium)优先取它。
//
// 行为契约由 tests/unit/main/protocol-content-disposition.test.ts 锁定。
// RFC 5987 attr-char 以外的字符都要百分号编码。
// encodeURIComponent 不编码 ~!*()' —— 其中 ' ( ) * 不在 attr-char 集合,
// 必须补编;! 合法,保留。
function encodeRFC5987Value(str: string): string {
return encodeURIComponent(str).replace(
/['()*]/g,
(c) => '%' + c.charCodeAt(0).toString(16).toUpperCase(),
);
}
/** 构造 `inline` 型 Content-Disposition,对任意文件名字符 ByteString 安全。 */
export function contentDispositionInline(filename: string): string {
// 兜底段清洗:quoted-string 里 `"` `\` 非法(截断/转义歧义),
// 控制符与非 ASCII 超出 ByteString / 可打印范围,统一折 '_'
// eslint-disable-next-line no-control-regex -- 刻意匹配控制符:它们正是要清洗的对象
const fallback = filename.replace(/["\\\r\n\x00-\x1f\x7f-\u{10FFFF}]/gu, '_') || 'file';
return `inline; filename="${fallback}"; filename*=UTF-8''${encodeRFC5987Value(filename)}`;
}

241
src/main/utils/error.ts Normal file
View File

@@ -0,0 +1,241 @@
// src/main/utils/error.ts
//
// 集中以下五类 helper,供 main/ 全局 catch 块使用:
// - toDisplayString(v) : unknown → string,永不 throw(errMsg 系共用底座)
// - errMsg(err) : unknown → string(人类可读消息)
// - errPayload(err) : unknown → { message, stack? }(结构化日志)
// - errPayloadStr(err) : unknown → string(message + "\n" + stack 或 String(err))
// - errnoCode(err) : unknown → string | undefined(err.code 收窄)
//
// 这些 helper 在 audit C3R27 / C3R46 / C3R49 / C3R50 / C3R52 / C3R61 等
// 多个 round 集中提取,替换散落在 50+ catch 块里的 inline 重复:
//
// - `err instanceof Error ? err.message : String(err)` → errMsg(err)
// - `(err as { code?: unknown }).code` → errnoCode(err)
// - `err.message, err.stack` 直接访问(uncaughtException) → errPayload(err)
// - 4 处 `err instanceof Error ? \`${m}\n${s}\` : String(err)` → errPayloadStr(err)
//
// 关键 invariant:所有 helper 都必须**永不 throw** —— 它们是 catch 块里的"翻译官",
// 自身抛错会让用户看不到真错,反而暴露 helper bug。测试见:
// tests/unit/utils/error.test.ts (AppError + toDisplayString + errMsg + errPayload)
// tests/unit/main/utils/errno-code.test.ts (errnoCode)
// tests/unit/main/utils/error-payload-str.test.ts (errPayloadStr)
// audit-r217:toDisplayString 下沉到 @shared/display-string —— renderer
// (lib/error.ts)此前镜像的是 r124 修复前的旧口径(String(null-proto) 会
// throw),孪生实现修一边漏一边;现在两侧共用同一份实现。本文件保留
// re-export,既有 import 路径(main/ 全域 + tests)不动。
import { toDisplayString } from '@shared/display-string';
export { toDisplayString };
/**
* unknown err → string 的人类可读消息。
* - Error 实例 → err.message
* - 字符串 → 原样返回
* - 其它 → toDisplayString(err)(null→'null', undefined→'undefined',
* 对象→'[object Object]';null-proto / throwing toString 走降级链)
*
* 关键:永不 throw。
* 测试见 tests/unit/utils/error.test.ts:errMsg describe block。
*
* audit-r229:实现已下沉到 @shared/display-string(renderer 侧此前是逐字
* 孪生副本,改一边漏一边);本文件只保留 re-export,既有 import 路径不动。
*/
export { errMsg } from '@shared/display-string';
/**
* errPayload 返回值类型:cause 链递归,每层都有 message 字段(永远 string)。
* 用 type-only 定义让 errPayload / errPayloadImpl 共享同一份契约,
* 测试侧 p.cause?.message / p.cause?.cause?.message 直接通过类型检查,
* 不用每处 cast。
*/
export type ErrPayload = {
message: string;
stack?: string;
cause?: ErrPayload;
};
/**
* unknown err → { message: string, stack?: string },给结构化日志 / IPC 序列化用。
*
* - Error 实例 → { message, stack: err.stack } (stack 可能 undefined,接受)
* - 非 Error → { message: String(err) } (永远不返回 stack 字段)
*
* 与 errMsg 的区别:errPayload 返回结构化对象,便于 telemetry / Sentry 区分
* message vs stack。errMsg 是一行字符串,适合 inline 到 toast。
*
* 关键 invariant:返回的 message **永远**是 string,从不 undefined(回归见
* tests/unit/utils/error.test.ts:REGRESSION (C3R52) describe block)。
*/
export function errPayload(err: unknown): ErrPayload {
return errPayloadImpl(err, new WeakSet());
}
// audit 2026-09-11:实现抽出 + WeakSet 循环检测 —— 同一个 Error 实例在 cause 链
// 中若被重复引用(典型场景:外部代码错把自身挂到 cause),递归会无限循环直到
// stack overflow。helper 是 catch 块里的"翻译官",自己崩了用户看不到真错。
// WeakSet 自动随外部引用消失,无清理负担。
function errPayloadImpl(err: unknown, seen: WeakSet<object>): ErrPayload {
if (err instanceof Error) {
const out: ErrPayload = err.stack
? { message: err.message, stack: err.stack }
: { message: err.message };
// audit 2026-09-07:带上 cause —— 标准 Error.prototype.cause(我们 AppError
// / ValidationError 都用 { cause } 传给 super())在 IPC 序列化
// (structured-clone)里被吞掉,renderer 侧只能看到 message + stack,
// 链式失败根因不可见。手动序列化保证链路向下游透传。
// audit 2026-09-10:递归展开整条 cause 链 —— 原实现只 unwrap 一层,深层
// Error C → cause B → cause A 在 telemetry / Sentry 上丢失中间节点;
// errPayloadStr 是递归的,errPayload 不递归会让两个 helper 对同一错
// 给出形状不一致的结果。复用作 errPayload 自己,自然得到完整链路。
// audit 2026-09-11:`!= null`(loose eq)同时跳过 null + undefined —— 原 `!==
// undefined` 让 cause=null 走到 String(null)='null',渲染成 "Caused by: null"
// 噪音。
if (err.cause != null && !seen.has(err)) {
seen.add(err);
out.cause = errPayloadImpl(err.cause, seen);
}
return out;
}
return { message: toDisplayString(err) };
}
/**
* unknown err → 拼接好的单行字符串(message + "\n" + stack 或 String(err))。
*
* - Error 实例有 stack → `${message}\n${stack}`
* - Error 实例无 stack → `${message}`
* - 字符串 → 原样
* - 其它 → toDisplayString(err)(object → '[object Object]', null → 'null';
* null-proto / throwing toString 走降级链,永不 throw)
*
* 与 errPayload 的区别:errPayloadStr 是日志/上报一行字符串版,直接喂 log.error;
* errPayload 是结构化版,适合 telemetry / Sentry。
*
* 关键 invariant:永不返回 undefined,永不变 undefined(toast / dialog 渲染稳定)。
* 测试见 tests/unit/main/utils/error-payload-str.test.ts。
*/
export function errPayloadStr(err: unknown): string {
return errPayloadStrImpl(err, new WeakSet());
}
// audit 2026-09-11:实现抽出 + WeakSet 循环检测 —— 同 errPayload,防止 cause 链
// 互引时 stack overflow。已见过的 Error 实例在递归时返回截断占位 '[Circular cause]',
// 既保链路信息又不让 helper 自己崩。
function errPayloadStrImpl(err: unknown, seen: WeakSet<object>): string {
if (err instanceof Error) {
// 循环:同一 Error 实例再次出现在 cause 链上 → 截断,不再展开
if (seen.has(err)) {
const head = err.stack ? `${err.message}\n${err.stack}` : err.message;
return `${head}\nCaused by: [Circular cause]`;
}
seen.add(err);
const head = err.stack ? `${err.message}\n${err.stack}` : err.message;
// audit 2026-09-07:拼上 cause —— AppError / ValidationError / 原生 Error({cause})
// 都可能带根因,不暴露 → 链式失败调试只能看到最外层 message,根因不可见。
// audit 2026-09-11:`!= null` 跳 null + undefined,避免 "Caused by: null" 噪音。
if (err.cause != null) {
return `${head}\nCaused by: ${errPayloadStrImpl(err.cause, seen)}`;
}
return head;
}
return toDisplayString(err);
}
/**
* unknown err → string | undefined(err.code 收窄到 string)。
*
* - 包含 string `code` 字段的对象 → code
* - number / null / undefined / Symbol / 缺 code 字段 → undefined
* - non-object (string / number / null / undefined) → undefined
*
* 与 inline `(err as { code?: unknown }).code` 的关键区别:
* 原 inline 让 `code` 是 `unknown`,直接 `code === 'ENOENT'` 比较时:
* - number / Symbol / null 严格不等于 'ENOENT',fallback 行为是"误判为非 ENOENT rethrow"
* - helper 显式收窄到 string,number 等落 undefined,行为**完全等价**但契约显式
*
* audit 2026-09-11:`'code' in err` 走的是原型链 —— Object.prototype 上的 code
* 也会命中(几乎不可能,但 contract 文档写的是 own property)。
* 改用 `Object.prototype.hasOwnProperty.call(err, 'code')` 收紧到 own property。
*
* 测试见 tests/unit/main/utils/errno-code.test.ts。
*/
export function errnoCode(err: unknown): string | undefined {
if (err && typeof err === 'object' && Object.prototype.hasOwnProperty.call(err, 'code')) {
const code = (err as { code: unknown }).code;
return typeof code === 'string' ? code : undefined;
}
return undefined;
}
export class AppError extends Error {
constructor(
public code: string,
message: string,
public cause?: unknown,
) {
// audit 2026-09-07:把 cause 传给 super —— 标准 Error({cause}) 会把 cause
// 挂在 Error.prototype.cause 上;不传则 prototype getter 永远是 undefined,
// IPC structured-clone / JSON.stringify(err) 都不带 cause,链式失败根因
// 在 telemetry / toast 里彻底丢失。透传是单点修复。
super(message, cause !== undefined ? { cause } : undefined);
this.name = 'AppError';
}
}
export class LibraryError extends AppError {
// audit 2026-09 (V1.9):删 SCAN_FAILED / WATCHER_ERROR / VALIDATION 三项 —
// 0 production caller(SCAN_FAILED / WATCHER_ERROR 之前为扫描失败的占位
// code,实际实现早走 AppError;VALIDATION 用 ValidationError 替代,
// LibraryError 不应再承担 validation 职责)。NOT_FOUND / FILE_MISSING /
// IO_ERROR / TOO_LARGE / EACCES / DUPLICATE_NAME 是现存 6 个 caller 实际
// 使用的 code,保留。
//
// audit 2026-10-08 (HIGH):加 CONFLICT —— writeNote expectedMtime 不一致
// 时(防外部修改被静默覆盖,OneDrive sync / Vim 等),NotesService.write
// 抛 LibraryError('CONFLICT', ...) 让 IPC wrap.ts 透传到 renderer,后者
// 走 store/notes.ts:save 的 catch + toast 路径。
// 加 VALIDATION —— ipc/notes.ts 的 expectedMtime 校验复用 LibraryError
// 的 VALIDATION code(与其它 IPC handler 对齐,wrap.ts 走统一翻译)。
constructor(
code:
| 'NOT_FOUND'
| 'FILE_MISSING'
| 'IO_ERROR'
| 'TOO_LARGE'
| 'EACCES'
| 'DUPLICATE_NAME'
| 'CONFLICT'
| 'VALIDATION'
// audit-r137:加 'INTERNAL' —— 调用方构造 LibraryError 时若 message
// 含用户路径(典型 fs 抛 ENOENT/EACCES 的 errnoMessage),应使用
// 'INTERNAL' 让 wrap.ts 走 scrubInternal 把家目录替成 <rootPath>
// 占位符,避免 IPC wire 把全路径透传到 renderer / telemetry。
// 已知 code(NOT_FOUND / VALIDATION / DUPLICATE_NAME / ...)的消息
// 不含绝对路径,保持原 code 由 caller 决定。
| 'INTERNAL',
msg: string,
cause?: unknown,
public size?: number,
) {
super(code, msg, cause);
this.name = 'LibraryError';
}
}
export class ValidationError extends AppError {
constructor(
msg: string,
public field?: string,
cause?: unknown,
) {
super('VALIDATION', msg, cause);
this.name = 'ValidationError';
}
}
// audit round 17 (2026-09-02):
// - SettingsError 已下线 —— 全仓无 caller(grep 仅此声明本身)。任何残留设置流
// 错误走 AppError + 显式 code 字符串即可。
// - CollectionError 早已下线(模块整体删除后),仅留注释。

View File

@@ -0,0 +1,94 @@
// 共享文件存在性检查:fs.open → close 单 syscall,无 TOCTOU。
//
// audit 2026-09-21 audit-r24:library.ts:assertFileExists 与 notes.ts:
// NOTES_SHOW_IN_FOLDER inline 重复 ~20 行(都走 fs.open → safeCloseFh →
// 抛 LibraryError FILE_MISSING)。抽到 utils 共享层,改 API/改错误形态
// 只改一处。
//
// 设计要点:
// - absPath 由 caller 校验(走 requireAbsolutePath + 业务白名单),
// 本 helper 不再二次防御
// - 错误码统一 FILE_MISSING(与 NOTES_READ 通道保持一致;audit-r78 删
// 「LIBRARY_GET」并列 —— 该通道 r56 已退役,不能作为现行为锚点)
// - safeCloseFh 走 Windows stale handle EBUSY warn(老实现 swallow 让
// fd 泄漏信号消失,见 safe-write.ts 注释)
// - i18n 文案由 caller 在 catch 边界翻译,helper 不依赖任何具体 key,
// 也不重复占位符。典型 caller 模式:tr(key, params) → helper 拼 errno
import { promises as fs } from 'fs';
import { LibraryError, errMsg, errnoCode } from './error';
import { safeCloseFh } from './safe-write';
import type { createLogger } from './logger';
type LoggerLike = ReturnType<typeof createLogger>;
/**
* 用 fs.open('r') 单 syscall 校验文件存在,不存在抛 LibraryError(FILE_MISSING)。
*
* @param absPath 绝对路径(由 caller 校验)
* @param notFoundMessage 不存在时抛错的 i18n message —— caller 已填好所有占位符,
* helper 在 syscall 失败时附加 `(errMsg(err))` 便于排查
* @param log 模块 logger,用于 safeCloseFh 内部 warn
*/
export async function assertFileExists(
absPath: string,
notFoundMessage: string,
log: LoggerLike,
): Promise<void> {
if (!absPath) {
throw new LibraryError('FILE_MISSING', notFoundMessage);
}
let fh: Awaited<ReturnType<typeof fs.open>> | null = null;
try {
fh = await fs.open(absPath, 'r');
} catch (err: unknown) {
// audit r33 (2026-09-23):旧实现一律 FILE_MISSING —— EACCES / EPERM
// / EISDIR 等都被错报成「文件不存在」,UI 端的 toast 让用户以为是临时
// 状况不停 retry,真实根因(权限、类型错配)被埋没。修法:照 errnoCode()
// 精确分类 —— ENOENT 才报 FILE_MISSING,EACCES/EPERM 报 IO_ERROR 让
// 上层 store 能区分并加权限提示。
// audit-r96:分类三支(ENOENT / !code → FILE_MISSING,其余 → IO_ERROR)
// 首补行为锁 tests/unit/main/utils/file-check.test.ts,摘 !code 分支必红。
const code = errnoCode(err);
const msg = `${notFoundMessage} (${errMsg(err)})`;
if (code === 'ENOENT' || !code) {
throw new LibraryError('FILE_MISSING', msg);
}
throw new LibraryError('IO_ERROR', msg);
} finally {
if (fh) {
// audit 2026-09-14:finally close 改 warn —— Windows stale handle 偶发 EBUSY,
// 旧 swallow 让 fd 泄漏信号消失。
// audit round 8 (2026-09-14):走 safeCloseFh helper。
await safeCloseFh(fh, 'fh.close failed in assertFileExists', log, { path: absPath });
}
}
}
/**
* 文件是不是**真的**不在磁盘上了。
*
* `existsSync` 把「文件不存在」与「文件读不到」都压成 `false` ⇒ 分不开这两
* 种情况(而且它**永不 throw**,try/catch 包住它 = 死代码)。本项目对「读
* 不到」的处置是 fail-safe(r200 settings / r199 收藏 / r210 笔记索引同款
* 口径):**判不出来 ⇒ 当作还在**。理由是两条路的代价不对等:
* - 误判「还在」⇒ 留一个 ghost 行(列表里有一条点进去 NOT_FOUND 的
* 条目)—— 轻微、可自愈(下次 resync 再清);
* - 误判「没了」⇒ 把用户**还在磁盘上**的条目从索引里删掉(列表 / 搜索里
* 消失,且本轮不会补回)—— 数据可见性损失,用户会以为文件被删了。
* 所以只有 **ENOENT**(确定的不存在)才算「没了」,其余错误(EACCES /
* EPERM / EBUSY —— Defender 扫描、OneDrive placeholder、同步盘占用)一律
* 当作「还在」。
*
* 本函数**永不 throw**(stat 的任何错误都被分类掉),caller 不需要也不应该
* 再包 try/catch。单一真相源:notes(r210 两处)与 literature(r219 两处)
* 的「删索引行之前再确认一次」全部走这里 —— 凡是「判文件在不在 → 决定
* 删不删索引行」的场景不许再出现裸 `existsSync`。
*/
export async function isReallyGone(absPath: string): Promise<boolean> {
try {
await fs.stat(absPath);
return false;
} catch (err: unknown) {
return errnoCode(err) === 'ENOENT';
}
}

View File

@@ -0,0 +1,132 @@
// src/main/utils/frontmatter.ts — gray-matter 类型适配 helper
//
// audit C3R51 (2026-08-22):散落在多个文件的 `matter.stringify(body, fm)` 调用
// 都各自写 `fm as unknown as Record<string, unknown>` / `fm as Record<string,
// unknown>` / `fm as Parameters<typeof matter.stringify>[1]` / `as unknown as
// Parameters<typeof matter.stringify>[1]` 的类型断言。
// audit 2026-09 (V1.9):原注释提 "5 个文件的 8 处" + "collections/storage.ts:133
// 的 inline cast" —— collections 模块 2026-08-29 整体并入 favorites,
// collections/storage.ts 文件已删除,具体行号失去锚点;实际 inline cast 散落
// 数取决于 V1.6 时的代码快照,不再给出精确数字。保留 cast 类型列表(仍是
// 完整的 4 种变体),具体排查交给后续 caller。
//
// 根因:gray-matter 的 `data` 参数类型是 `Record<string, any> | null | undefined`,
// 我们 typed 的 frontmatter 结构(struct)不能直接赋给 `Record<string, any>`
// —— TS 严格模式下 `unknown` 不流到 `any`(any 是 bivariant,unknown 是 top,
// 两者在 v4.0+ 分离)。所以需要 cast,但 cast 写一遍就够,集中在一个 helper 里
// 让 caller 看到的是"我要把 typed fm 喂给 gray-matter"的语义,而不是一行长类型
// 表达式干扰阅读。
//
// 用法:
// import { asFrontmatter } from '../utils/frontmatter';
// return matter.stringify(body, asFrontmatter(fm));
import matter from 'gray-matter';
// audit round 10 (2026-09-15):L2 — MAX_NOTE_TITLE_CHARS 共享自 path-limits.ts,
// 单一来源 (写路径 IPC 入口 + 读路径 frontmatter.cleanTitle 共用同一份)。
// 本文件此前声明 MAX_TITLE_CHARS = 200 与 IPC 入口 MAX_NOTE_TITLE_CHARS = 200
// 平行写,值漂移风险。这里不 import-as 别名,直接引用 path-limits 名字,
// 与 IPC 入口命名一致。
import { MAX_NOTE_TITLE_CHARS } from '../ipc/path-limits';
/**
* Adapt a typed frontmatter struct to gray-matter's loose Record<string, any> contract.
*
* gray-matter's `stringify(content, data?)` accepts `Record<string, any> | null | undefined`.
* Our typed frontmatter interfaces (e.g. `CollectionFrontmatter`, `RecentFm`,
* `TrashFm`, `AIFrontmatter`, `NoteFrontmatter`) are closed-shape structs that
* TypeScript can't structurally assign to `Record<string, any>` because `unknown`
* is the top type but doesn't flow into `any` under strict checks.
*
* This helper centralizes the `as Parameters<typeof matter.stringify>[1]` cast
* (the most precise form, matching the actual signature) so call sites read as:
* matter.stringify(body, asFrontmatter(fm))
* instead of:
* matter.stringify(body, fm as unknown as Record<string, unknown>)
*
* Pure cast — no runtime transformation. `matter.stringify` then walks the
* object normally via its own reflection.
*/
export function asFrontmatter<T extends object>(fm: T): Parameters<typeof matter.stringify>[1] {
return fm as Parameters<typeof matter.stringify>[1];
}
/**
* 把 frontmatter.tags(raw unknown)清洗成 string[] / null。
*
* audit round 8 (2026-09-14):3 处 verbatim `(fm['tags'] as unknown[]).filter(
* (x): x is string => typeof x === 'string')` 模板散落在 standalone.ts:
* 199-201 + 246-248 和 notes/import.ts:99-101。每个都自己 cast + filter
* + type guard,任何一处漂移(忘了 typeof 守卫 / 把 filter 改成 map)
* 只能 grep 兜底。
*
* 集中后:callsite 只关心 "我想要 string[]",helper 内部把 Array.isArray
* 守卫 + filter + typeof 守卫统一包好。返回 null 让 callsite 自由决定
* "无 tag" vs "空 tag"的语义(standalone 把它当作空数组传下去,
* import.ts 把它当作 undefined 跳过)。
*
* audit round 9 (2026-09-15):MAX_TAGS_PER_NOTE 上限 —— 攻击者 / 误操作放
* 一个 100k tag 数组的 .md 进 workspace,scan + list 时 array.map → React
* render 会让 UI 卡顿 / 主进程堆吃紧。50 是经验值(普通笔记 tag 数 < 20),
* 超长截断,多出来的 tag 静默丢弃 —— frontmatter 是用户数据,不能 throw
* 让一条笔记全不可用。
*/
const MAX_TAGS_PER_NOTE = 50;
// 单 tag 字符串上限 —— 防止单 tag 几百 KB 把 UI 撑爆(reason: tag 通常作为
// chip 渲染,极长字符串会让布局破裂 + 滚动卡顿)。
const MAX_TAG_CHARS = 64;
export function cleanTags(fm: Record<string, unknown>): string[] | null {
if (!Array.isArray(fm['tags'])) return null;
const raw = fm['tags'] as unknown[];
const out: string[] = [];
for (let i = 0; i < raw.length && out.length < MAX_TAGS_PER_NOTE; i++) {
const t = raw[i];
if (typeof t !== 'string') continue;
// 截断单 tag,并**丢掉 trailing 半字符**(surrogate pair 边界)。
//
// audit 2026-10-01:此前注释描述的「回退到完整字符」根本没实现,代码是纯
// `slice(0, MAX_TAG_CHARS)`。JS 字符串按 UTF-16 code unit 计数,一个
// emoji / 增补平面字符占 2 个 unit,切在中间会留下孤立的高位 surrogate
// —— 写进 markdown 时 UTF-8 编码 lone surrogate 变成 U+FFFD(乱码),
// 经 JSON.stringify 则是 "\ud83d"。tag 恰好跨过第 64 字符边界就会命中。
out.push(dropLoneSurrogateTail(t, MAX_TAG_CHARS));
}
return out;
}
/**
* 按 maxChars 截断字符串,并保证不以半个 surrogate pair 结尾。
*
* 判据:截断后最后一个 code unit 若落在高位 surrogate 区(0xD800–0xDBFF),
* 说明它需要一个低位 surrogate(0xDC00–0xDFFF)配对才完整,而那个低位部分
* 已被切掉 —— 丢掉它,字符串末尾就是一个孤立高位 surrogate。
*/
function dropLoneSurrogateTail(s: string, maxChars: number): string {
if (s.length <= maxChars) return s;
const cut = s.slice(0, maxChars);
const last = cut.charCodeAt(cut.length - 1);
const isHighSurrogate = last >= 0xd800 && last <= 0xdbff;
return isHighSurrogate ? cut.slice(0, -1) : cut;
}
/**
* frontmatter.title 字符串上限 —— DoS 防御 + UI 布局保护。
*
* audit round 10 (2026-09-15):L2 — 本函数使用 path-limits.ts 的共享常量
* MAX_NOTE_TITLE_CHARS(写路径 IPC 入口 + 读路径 frontmatter.cleanTitle 共用)。
* 历史注释里 "200 字符与 NOTES_WRITE 入口 MAX_NOTE_TITLE_CHARS = 200 对齐"
* 隐含的两份平行常量已合并为一份。
*
* 返回 string | null:null = 无标题。audit-r222:空串 / 纯空白也归一为
* null —— 写路径(writeNote `title.trim() || undefined`)本就写不出空
* title,只有外部手改 frontmatter 能造出来;返回 '' 会让所有 `?? 兜底`
* 失效('' 不是 null):列表空标题、DB 空 title、搜索命中空标题。
*/
export function cleanTitle(fm: Record<string, unknown>): string | null {
const t = fm['title'];
if (typeof t !== 'string') return null;
if (t.trim() === '') return null;
// audit 2026-10-01:与 cleanTags 同款 surrogate 截断修复 —— 中文标题里混
// emoji(「🔥 实验记录」类)在 200 字符边界被切开同样会产生乱码。
return dropLoneSurrogateTail(t, MAX_NOTE_TITLE_CHARS);
}

176
src/main/utils/fs-cache.ts Normal file
View File

@@ -0,0 +1,176 @@
// perf(2026-08-24):mtime-keyed parse cache for file reads.
//
// 用途:同一进程内,同一文件多次读 + parse(frontmatter / markdown body / AI JSON)
// 的场景下,缓存 parse 结果,mtime 变了才重 parse。
// - readStandaloneNote(notes 编辑时高频读)
//
// 实现:Map<absPath, { mtimeMs, size, ino, value }>。读 cache 时若 mtime/size/ino
// 都一致直接返回,不一致或文件不存在则调底层 reader,结果回填。
// 写操作必须 invalidate(否则读到 stale 数据)。
//
// 注意:缓存是 per-process;主进程单实例所以天然安全。
// 估算收益:gray-matter parse 一次 ~5-30ms,缓存命中 <0.1ms,差距 50-300x。
//
// 设计取舍:不做容量上限(bounded LRU)。LibraryService 处理文件 ≤ 10k,
// 缓存键 ≤ 10k,平均每条 ~200B → ~2MB,可控。后期需要 cap 时再加。
//
// audit 2026-09-14:cache 入口(2026-08-24)引入时 close 走 swallow 没补日志,
// Windows stale handle 让 fd 泄漏信号丢失。补 log.warn 走 createLogger。
import { createLogger } from './logger';
const log = createLogger('fs-cache');
//
// audit round 18 (2026-08-27):
// 1. 缓存键 absPath 在 win32 下统一 lowercase — 否则 C:\x\A.md 与
// C:\x\a.md 视为两个文件,symlink 链接 / Git checkout 在大小写不敏感
// 的 Windows 上会让 cache hit 但内容是另一文件。
// 2. cache key 加 size + ino — 旧版仅 mtimeMs 时,backup 还原 / git checkout
// 把 mtime 设回原值,fs.open 拿到的内容是新版本,但 cache 命中 stale。
// size/ino 不同即代表"是另一份内容",安全 reparse。
import { promises as fs } from 'fs';
import { errnoCode } from './error';
import { safeCloseFh } from './safe-write';
import { isCaseInsensitiveFs } from './platform';
interface CacheEntry<T> {
mtimeMs: number;
/** 文件 size:加进 cache key 防止 mtime 巧合撞上。 */
size: number;
/** inode 号:加进 cache key 防止替换文件。 */
ino: number;
/** parser 的返回值(parser 可以主动返 null 作为业务值)。 */
value: T;
}
// perf(2026-08-28):bounded FIFO —— 旧注释说"LibraryService ≤ 10k,缓存键 ≤ 10k,
// 平均每条 ~200B → ~2MB,可控",但 watcher 推送 + scan 期间 hot reload + 笔记
// 编辑 readNote 都共用同一张 Map,极端场景(chokidar add 风暴 + 用户编辑大
// 笔记)能拉到 30k+ 条 ≈ 6MB,内存里挂着。
// 上限设 20k:典型项目命中大半,异常情况只丢冷条目。FIFO(insertion order
// 由 Map 自身保证)替代 LRU —— 简单 O(1),没有 hit/miss 双向链表维护成本;
// 冷条目重新 read 一次只需一次 stat(命中 fs-cache 缓存逻辑照样生效)。
const FS_CACHE_MAX_ENTRIES = 20_000;
function setWithCap(key: string, entry: CacheEntry<unknown>): void {
// 删旧值(如已是 cached),避免 set 完 size 异常膨胀
if (caches.has(key)) caches.delete(key);
caches.set(key, entry);
// Map 保留 insertion order,keys() 第一个 = 最老;超 cap 时弹掉一个
if (caches.size > FS_CACHE_MAX_ENTRIES) {
const oldest = caches.keys().next().value;
if (oldest !== undefined) caches.delete(oldest);
}
}
const caches = new Map<string, CacheEntry<unknown>>();
/** win32 + darwin 下把路径标准化成小写,其他 POSIX (Linux 等) 原样返回。
* `\\?\` 前缀长路径保留大小写,但本项目所有 caller 都用 resolve() 出来的
* 普通路径,不走那个前缀;统一 lowercase 即可。macOS APFS 默认 case-insensitive
* (见 safe-write.ts:resolveInsideDataPath 同款 r7 注释)。
*
* audit r33 (2026-09-23):从 module-private 升级到 export —— watcher.ts
* dedupe map / settings 等所有需要 case-insensitive 路径 key 的 caller
* 都走这一份,避免与 fs-cache 内部 map key 不一致导致「fs-cache 命中
* 但 watcher dedupe miss」 之类的 race。 */
export function caseInsensitiveFsKey(absPath: string): string {
// audit round 10 (2026-09-15):H5 — 共享 isCaseInsensitiveFs 替代内联谓词。
return isCaseInsensitiveFs ? absPath.toLowerCase() : absPath;
}
// 内部仍以短名 cacheKey 引用(无外部调用,避免大改内部 helpers)
function cacheKey(absPath: string): string {
return caseInsensitiveFsKey(absPath);
}
/**
* 读文件并解析,结果按 (absPath, mtimeMs) 缓存。
* 解析器 fn 接收原始文件内容,返回任意结构化的解析结果(允许返回 null)。
*
* 返回:{ value, mtimeMs } | null
* - 文件不存在(ENOENT)→ null(直接返回,**不进缓存** —— 见下方 r197 段)
* - 文件存在 + 解析成功 → { value, mtimeMs }
* - 其它 IO 错误(EACCES / ELOOP / EIO 等)→ 直接 throw,让 caller 区分
*
* 注(2026-08-25 → r197 修订):"missing" 与 "value === null" 曾用两个独立
* 状态表达,理由是 `() => null` 这种 parser 的 null 不该被当成"文件不存在"。
* 现在**不需要**两个状态了 —— 文件不存在的情况**根本不进缓存**(返回
* null 时不写缓存),于是缓存里只可能有「parser 主动返回的 null」。
* 旧的负缓存(missing=true)是**死代码**:写进去的条目 mtimeMs/size/ino 全是
* -1,而读路径要先 open 成功才查缓存 ⇒ 负缓存条目永远命中不了(且
* `cached.missing` 分支恒为 false)⇒ 它只是白占一个缓存槽位、把真正
* 的热条目挤掉(FIFO cap 20k)。r197 删掉它,并把「负缓存」的实现意图
* 记录在此:文件可能在下一瞬被创建(用户新建笔记 / 外部工具落盘),真要
* 缓存「缺失」必须带 TTL,否则新建的文件会被缓存的 missing 吞掉。
*
* 注:返回 mtimeMs 让 caller 不必再二次 stat —— 之前 standalone.ts readNote
* 走 cachedReadParse 后又 fs.stat 一次拿 mtime,在 cachedReadParse 与 stat 之间
* 存在微弱 TOCTOU 窗口(stat 后文件被替换会让 StandaloneNote.mtime 与缓存里的
* 旧 value 不一致)。caller 直接用本次 open 拿到的 mtime,杜绝这个 race。
*
* audit 2026-08-26:用 fs.open + fh.stat + fh.readFile 单 fd 替代之前的
* fs.stat + fs.readFile 两步 —— 后者在 stat 与 read 之间存在 TOCTOU 窗口
* (扫描 / 编辑过程中文件被替换 / 截断),mtime 与内容可能来自不同 inode。
* 单 fd 把 size check + content 拿到绑到同一 inode。
*/
export async function cachedReadParse<T>(
absPath: string,
parser: (raw: string) => T,
): Promise<{ value: T; mtimeMs: number } | null> {
const key = cacheKey(absPath);
let fh: Awaited<ReturnType<typeof fs.open>> | null = null;
try {
try {
fh = await fs.open(absPath, 'r');
} catch (err: unknown) {
// 仅 ENOENT 视为"文件不存在" → 直接返 null;EACCES / ELOOP / EIO 等
// 是真实错误,透出让 caller 决定(典型:library/service.ts indexOneFile
// 把 EACCES 转 LibraryError 弹给 UI;旧实现所有 stat 失败都吞 → EACCES
// 永久缓存成 missing,UI 永远看不到权限错)。
// r197:不再写「负缓存」条目 —— 它永远命中不了(读路径要先 open 成功
// 才查缓存),只是白占一个 FIFO 槽位把热条目挤掉。详见上方头注。
if (errnoCode(err) === 'ENOENT') {
return null;
}
throw err;
}
const st = await fh.stat();
const cached = caches.get(key);
// audit round 18:cache hit 条件加 size + ino — 仅 mtime 时,git checkout
// / backup restore 会让 mtime 巧合撞上 → 返回 stale parse。
if (
cached &&
cached.mtimeMs === st.mtimeMs &&
cached.size === st.size &&
cached.ino === st.ino
) {
return { value: cached.value as T, mtimeMs: st.mtimeMs };
}
// miss:读 + parse + 写回缓存
const raw = await fh.readFile('utf-8');
const value = parser(raw);
setWithCap(key, {
mtimeMs: st.mtimeMs,
size: st.size,
ino: st.ino,
value,
});
return { value, mtimeMs: st.mtimeMs };
} finally {
// audit 2026-09-14:finally close 改 warn —— Windows stale handle 偶发 EBUSY,
// 旧 swallow 让 fd 泄漏信号消失。
// audit round 8 (2026-09-14):走 safeCloseFh helper,统一 close 失败日志形态。
await safeCloseFh(fh, 'fh.close failed in fs-cache finally', log, { path: absPath });
}
}
/**
* 文件被改写或删除后必须 invalidate,否则下次读拿到 stale mtime。
* 写 writeNote 时调用。
*
* audit 2026-08-28:删 invalidateMissing(byte 级重复 invalidateFile) +
* clearFsCache(声称测试用但无 caller)。单一真相在 invalidateFile。
*/
export function invalidateFile(absPath: string): void {
caches.delete(cacheKey(absPath));
}

97
src/main/utils/logger.ts Normal file
View File

@@ -0,0 +1,97 @@
// 简单日志器:写到 stderr (开发期可在 Electron 控制台看到)
import process from 'node:process';
import { errMsg, toDisplayString } from './error';
// Windows UTF-8 兜底:start.cjs 已经 chcp 65001 切到 UTF-8 codepage,但用户绕过 npm
// 直接跑 electron 时 codepage 仍是 cp936。Node 默认按 process.env 的编码把 string
// 写到 tty stream,在 cp936 终端上中文路径会变成 "æç å·¥ä½" 这种 mojibake,把真正
// 的根因(例如 NODE_MODULE_VERSION mismatch)掩盖成"看起来像路径问题"。
// 显式 setDefaultEncoding('utf8') 让后续 write 都按 UTF-8 编码字节流,配合
// 终端 UTF-8 codepage 就能正确显示中文。setDefaultEncoding 失败时 (例如打包后
// stdout 不是 TTY) 静默 fallback,不影响功能。
if (process.platform === 'win32') {
try {
process.stdout.setDefaultEncoding('utf8');
} catch {
// ignore — 非 TTY 或其他 stream 不支持 setEncoding
}
try {
process.stderr.setDefaultEncoding('utf8');
} catch {
// ignore
}
}
type Level = 'debug' | 'info' | 'warn' | 'error';
const LEVELS: Record<Level, number> = { debug: 0, info: 1, warn: 2, error: 3 };
// audit 2026-09-11:验证 LOG_LEVEL 必须在 LEVELS enum 内 —— 旧实现直接 cast
// `as Level`,LOG_LEVEL=foo 时 LEVELS[currentLevel] 是 undefined,所有 level
// 比较都变 NaN <= N = false,**所有** log 静默 no-op。用户配错环境变量
// 会让整个项目看起来"没崩"但实际什么 trace 都看不到。
// 修:不在 LEVELS 内 → fallback 'info' + console.warn 一次提醒。
const VALID_LEVELS = new Set<string>(Object.keys(LEVELS));
const rawLevel = process.env.LOG_LEVEL;
const currentLevel: Level = rawLevel && VALID_LEVELS.has(rawLevel) ? (rawLevel as Level) : 'info';
if (rawLevel && !VALID_LEVELS.has(rawLevel)) {
// 不能走 logger(自己就是被 logger 引用的);直写 stderr。
process.stderr.write(`[logger] invalid LOG_LEVEL=${rawLevel}; falling back to 'info'\n`);
}
// audit C3R22 (2026-08-22):删 setLogLevel / getLogLevel — 零调用方。
// 运行时调 log level 当前没有 UI 面板(log level 只在启动期从 LOG_LEVEL env
// 读取),两个导出是 over-engineering;未来真要做 debug panel 时再加。
function format(level: Level, scope: string, msg: string, ...args: unknown[]): string {
const ts = new Date().toISOString();
// audit-r124:非 error 档此前裸 JSON.stringify —— 循环引用 / BigInt 直接
// throw,把 log.warn(常规调用)炸成调用方异常(error() 分支早有 try/catch
// + [unserializable] 标记,口径对齐)。
const tail = args.length ? ' ' + args.map((a) => stringifyArg(a)).join(' ') : '';
return `[${ts}] [${level.toUpperCase()}] [${scope}] ${msg}${tail}`;
}
function stringifyArg(a: unknown): string {
if (typeof a === 'string') return a;
try {
return JSON.stringify(a);
} catch (err: unknown) {
return `[unserializable: ${errMsg(err)}] ${toDisplayString(a)}`;
}
}
export function createLogger(scope: string) {
return {
debug(msg: string, ...args: unknown[]) {
if (LEVELS[currentLevel] <= LEVELS.debug) console.error(format('debug', scope, msg, ...args));
},
info(msg: string, ...args: unknown[]) {
if (LEVELS[currentLevel] <= LEVELS.info) console.error(format('info', scope, msg, ...args));
},
warn(msg: string, ...args: unknown[]) {
if (LEVELS[currentLevel] <= LEVELS.warn) console.error(format('warn', scope, msg, ...args));
},
error(msg: string, ...args: unknown[]) {
if (LEVELS[currentLevel] <= LEVELS.error) {
const formatted = args.map((a) => {
if (a instanceof Error) return `${a.message}\n${a.stack}`;
if (typeof a === 'object') {
try {
return JSON.stringify(a, null, 2);
} catch (err: unknown) {
// audit round 17 (2026-09-02):JSON.stringify 失败不再静默
// —— 循环引用 / BigInt 在生产路径偶尔出现,fallback 到 String(a)
// 会让日志看起来正常但实际丢了字段。附加 stringify-failed
// 标记便于 telemetry 区分"故意 fall through" vs "真崩了"。
// 不递归调 log.* —— logger 自身异常不能再走 logger(防死循环)。
return `[unserializable: ${errMsg(err)}] ${toDisplayString(a)}`;
}
}
return String(a);
});
console.error(format('error', scope, msg, ...formatted));
}
},
};
}

View File

@@ -0,0 +1,305 @@
// src/main/utils/migrate-old-userdata.ts
//
// audit r40 (2026-10-15):dev userData 一次性搬迁 helper
//
// 背景:src/main/index.ts 在 whenReady 之前调 app.setName(...),把 dev 模式的
// userData 路径从 package.json name 字段(research-knowledge-management-system)
// 切到 productName。2026-10-21 productName 又从 'Research Knowledge
// Management' 改为 'Literature Management',于是 legacy userData 目录有两代
// (name 字段的老 dev 目录 + 旧 productName 目录),调用方逐代传入。老用户
// 的 location.json / settings.json / index/ 仍留在旧 userData 内,新 userData
// 看不到 → 用户配置丢失,SQLite 索引从零重建。
//
// 本 helper 把老 userData 内的 3 类应用文件搬到新 userData,搬完后**删 source
// 端的 3 个文件**;Chromium 自管目录(Cache / GPUCache / Local Storage 等)
// **保留**(由 Chromium 自己回收 / 用户手动清理)。
//
// 行为契约:
// - 严格幂等:新位置已有 → 跳过该文件(不 merge)
// - 老不存在 → no-op
// - 老是 symlink → skip + warn(symlink 替换攻击防御,同 migration-layout)
// - 0 warning + 每个 item 都被处理过(moved / skipped / absent-ENOENT)→
// 删 source 端对应应用文件(stale cleanup:skipped 场景下新端已有更新
// 版本,删 source 是清理 stale;moved 场景已被 fs.rename 原子搬走,
// deleteItem 走 ENOENT 兜底,deleted.length 不计已搬走的;absent 场景
// 本无 source 副本,删除阶段 ENOENT no-op。audit-r96:缺失项原不计入
// 「被处理过」,「1 skipped + 2 缺失」会让 stale 副本永久残留)
// - 任一 warn → 整体保留 source(不删任一应用文件,等下次启动再试)
//
// 调用方:src/main/index.ts app.setName 之后,fire-and-forget。
// 不会抛错:helper 内 catch 全部 swallow + log;启动失败不该由迁移触发。
import { app } from 'electron';
import { promises as fs } from 'fs';
import { join } from 'path';
import { createLogger } from './logger';
import { errnoCode, errMsg } from './error';
const log = createLogger('migrate-old-userdata');
// 3 个应用文件 / 目录清单(老 → 新相对路径)。Chromium 自管的 Cache /
// GPUCache / Local Storage / Session Storage / Code Cache / Network /
// Preferences / Dictionaries / blob_storage / SharedDictionary /
// SharedStorage / Local State / DawnGraphiteCache / DawnWebGPUCache /
// README.md 不在清单 —— 由 Chromium 自己管理,迁移它会拖走我们需要权限。
const APP_ITEMS: readonly string[] = ['location.json', 'settings.json', 'index'] as const;
interface MigrateReport {
moved: string[];
skipped: string[];
deleted: string[];
warnings: string[];
}
/**
* 计算老 userData 绝对路径 = `<appData>/<oldAppName>`。
* 不依赖 app.getPath('userData')(其依赖 app 启动时 name 字段,setName 后已被
* 切走),也不依赖 app.getPath('appData')(ready 之前不可调)。
* 直接拼 platform 各自的标准 appData 位置 + 传入的 oldAppName。
* 公开以让单测在 mock platform 时直接拿路径(不依赖 process.env)。
*/
export function computeOldUserDataPath(oldAppName: string): string {
if (process.platform === 'win32') {
const appData =
process.env.APPDATA || join(process.env.USERPROFILE || '', 'AppData', 'Roaming');
return join(appData, oldAppName);
}
if (process.platform === 'darwin') {
// macOS:~/Library/Application Support/<name>。homedir() 在 Node 启动时即可用,
// 不依赖 electron ready。
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
const { homedir } = require('os') as typeof import('os');
return join(homedir(), 'Library', 'Application Support', oldAppName);
}
// linux + 其他
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
const { homedir } = require('os') as typeof import('os');
const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
return join(xdg, oldAppName);
}
/**
* 把单个 item 从 oldUserData 搬到 newUserData。
* - 老不存在 → 跳过(返回 ok=true, skipped=null)
* - 新已存在 → 跳过(返回 ok=true, skipped=newPath;不覆盖,保留 source)
* - 老是 symlink → 跳过 + warn(返回 ok=false)
* - 搬失败 → 抛错(让 caller 决定不删 source)
*/
async function moveItem(
oldBase: string,
newBase: string,
item: string,
): Promise<{
ok: boolean;
movedTo?: string;
skipped?: string;
/** audit-r96:source 不存在(ENOENT)= 已核验「无 source 可丢」,
* 计入「每个 item 都被处理过」闸门 —— 否则「1 项 skipped + 2 项缺失」
* 会让 skipped 的 stale 副本永久保留(每次启动重复 partial-state warn)。 */
absent?: boolean;
warning?: string;
}> {
const oldPath = join(oldBase, item);
const newPath = join(newBase, item);
// lstat:不 follow symlink,见 migration-layout.ts:141 同款防御。
let oldStat;
try {
oldStat = await fs.lstat(oldPath);
} catch (err: unknown) {
if (errnoCode(err) === 'ENOENT') return { ok: true, absent: true };
return { ok: false, warning: `lstat ${item}: ${errMsg(err)}` };
}
if (oldStat.isSymbolicLink()) {
return { ok: false, warning: `${item} is a symlink; skipping` };
}
// 新位置已存在 → 跳过;保留 source 不动。
try {
await fs.lstat(newPath);
return { ok: true, skipped: newPath };
} catch (err: unknown) {
if (errnoCode(err) !== 'ENOENT') {
return { ok: false, warning: `lstat new ${item}: ${errMsg(err)}` };
}
// 新不存在 → 继续搬
}
// 跨盘符 EXDEV 时 fs.rename 抛 → 走 copy + rm fallback。userData 一般在
// %APPDATA% 同盘,跨盘场景少见(OneDrive 重定向 / 用户把 appData 挪到
// D 盘);但代码防御一下。
try {
if (oldStat.isDirectory()) {
// 目录:试 rename,跨盘走 copy + rm。
try {
await fs.rename(oldPath, newPath);
} catch (err: unknown) {
if (errnoCode(err) !== 'EXDEV') throw err;
// audit-r243(2026-10-21):copy 失败 ⇒ 清掉目标端半份残留。
// 跨盘 fallback 的 copy 中途失败(ENOSPC / EIO / 断电)会在
// newPath 留下**半份**拷贝(部分子树)。旧实现只记 warning 就走人,
// 后果是下一次启动:
// moveItem 看到 newPath **已存在** ⇒ 返回 {ok:true, skipped}
// ⇒ 没有 warning ⇒ hasFailure 仍为 false ⇒ itemsHandled 满 ⇒
// 启动流程判定「新端已有对应副本」⇒ **删掉 source 端原件**。
// ⇒ 用户原始 settings.json / location.json 只剩那份截断副本 ——
// 永久丢数据。
// 修法:copy 失败时把目标端残留删掉,让下次启动「目标端不存在」
// ⇒ 重新从 source 搬 ⇒ source 始终是权威副本。
// 只在 copy 失败时删 —— rename 成功路径不碰 newPath;
// copy 成功后的「删 source 失败」也**不**删(那时新端是好的,
// 删了反而逼用户重搬一遍)。
try {
await fs.cp(oldPath, newPath, { recursive: true, errorOnExist: true, force: false });
} catch (cpErr: unknown) {
await fs.rm(newPath, { recursive: true, force: true }).catch(() => {
/* 残留清不掉:下次启动重试,source 仍在,不算丢数据 */
});
throw cpErr;
}
await fs.rm(oldPath, { recursive: true, force: true });
}
} else {
// 文件:同样 rename → cp + rm fallback。
try {
await fs.rename(oldPath, newPath);
} catch (err: unknown) {
if (errnoCode(err) !== 'EXDEV') throw err;
// 同上:copy 失败 ⇒ 删掉截断的目标副本,别让下次启动把它当「已搬完」。
try {
await fs.copyFile(oldPath, newPath);
} catch (cpErr: unknown) {
await fs.rm(newPath, { force: true }).catch(() => {
/* 同上:残留清不掉也无所谓,source 仍在 */
});
throw cpErr;
}
await fs.unlink(oldPath);
}
}
return { ok: true, movedTo: newPath };
} catch (err: unknown) {
return { ok: false, warning: `move ${item}: ${errMsg(err)}` };
}
}
/**
* 删 source 端的 item。任一失败 swallow + warn(本步失败不应阻塞其它删;
* 也不抛,启动期容错)。
*/
async function deleteItem(
oldBase: string,
item: string,
): Promise<{ deleted?: string; warning?: string }> {
const oldPath = join(oldBase, item);
try {
await fs.lstat(oldPath);
} catch (err: unknown) {
if (errnoCode(err) === 'ENOENT') return {};
return { warning: `lstat ${item} for delete: ${errMsg(err)}` };
}
try {
await fs.rm(oldPath, { recursive: true, force: true });
return { deleted: oldPath };
} catch (err: unknown) {
return { warning: `delete ${item}: ${errMsg(err)}` };
}
}
/**
* 主入口。调用方传 oldAppName(setName 之前的 app.getName());
* helper 内部走 *全部失败的处理*——3 个全搬成功才删 source 端对应文件,
* 任一失败保留 source 全部不动,等下次启动再试。
*/
export async function migrateOldDevUserData(oldAppName: string): Promise<MigrateReport> {
// 防御:packaged 模式 appName 已是 productName,与 setName 后一致 →
// oldUserData === newUserData,下面 moveUserDataAppItems 直接 no-op,
// 不影响 packaged 用户。
const oldUserData = computeOldUserDataPath(oldAppName);
const newUserData = app.getPath('userData');
return migrateUserDataAppItems(oldUserData, newUserData);
}
/**
* 核心搬迁逻辑 —— 老 userData → 新 userData 内 3 个应用文件。
* 暴露成独立函数以便测试(测试不依赖 platform / electron app,直接传 sandbox
* 路径)。
*/
export async function migrateUserDataAppItems(
oldUserData: string,
newUserData: string,
): Promise<MigrateReport> {
const report: MigrateReport = { moved: [], skipped: [], deleted: [], warnings: [] };
if (oldUserData === newUserData) {
// 已在新位置(packaged / 第二次启动)。no-op。
return report;
}
// 老 userData 整体不存在 → 全新用户,no-op。
try {
await fs.lstat(oldUserData);
} catch (err: unknown) {
if (errnoCode(err) === 'ENOENT') return report;
report.warnings.push(`lstat oldUserData: ${errMsg(err)}`);
return report;
}
log.info('App begin', { oldUserData, newUserData, items: APP_ITEMS });
// 第一阶段:逐个搬迁。任一失败 → warnings + 整体放弃删 source。
let hasFailure = false;
// audit-r96:ENOENT 缺失项单独计数 —— 缺失 = 已核验「无 source 可丢」,
// 与 moved/skipped 一样算「被处理过」。旧口径只数 moved+skipped,
// 「1 skipped + 2 缺失」永远进不了第二阶段,stale 副本永久残留。
let absentCount = 0;
for (const item of APP_ITEMS) {
const r = await moveItem(oldUserData, newUserData, item);
if (r.warning) {
log.warn('App move warning', { item, warning: r.warning });
report.warnings.push(r.warning);
hasFailure = true;
// 不 break:继续检查其它 item,拿到全部警告一并入档。
}
if (r.movedTo) report.moved.push(r.movedTo);
if (r.skipped) report.skipped.push(r.skipped);
if (r.absent) absentCount++;
}
// 第二阶段:删 source 端 3 个应用文件。条件:
// - 0 warning(没有搬迁失败)
// - 每个 item 都至少被处理过(moved 或 skipped):证明新端有对应文件副本,
// 删 source 不会丢数据
// skipped 场景下新端已有更新版本(如 location.ts 本轮启动写过新端
// location.json),此时 source 是 stale 数据,删它是清理而非丢数据;
// moved 场景下 source 已被 fs.rename 原子搬走,deleteItem 走 ENOENT
// 兜底(deleted.length 不计已搬走的项)。
// 任一失败 / warning / item 未处理 → 保留全部 source,等下次启动再试
// (同 migration-layout partial state 不删 source 的口径)。
// audit-r96:缺失(ENOENT)项计入 handled —— 闸门语义是「证明新端有
// 对应副本或 source 本无副本,删 source 不丢数据」。
const itemsHandled = report.moved.length + report.skipped.length + absentCount;
if (!hasFailure && itemsHandled === APP_ITEMS.length) {
for (const item of APP_ITEMS) {
const d = await deleteItem(oldUserData, item);
if (d.deleted) report.deleted.push(d.deleted);
if (d.warning) {
log.warn('App delete warning', { item, warning: d.warning });
report.warnings.push(d.warning);
}
}
} else if (itemsHandled > 0) {
log.warn('App partial state: source kept (will re-try on next launch)', {
moved: report.moved,
warnings: report.warnings,
});
}
log.info('App summary', {
moved: report.moved.length,
skipped: report.skipped.length,
deleted: report.deleted.length,
warnings: report.warnings.length,
});
return report;
}

View File

@@ -0,0 +1,35 @@
// src/main/utils/optimistic-concurrency.ts
//
// 乐观并发控制 helper —— 共享给所有「主进程写之前比对 mtime」场景,避免
// 三个 handler (NOTES_WRITE / SETTINGS_UPDATE / LIBRARY_IMPORT_PATHS) 各自
// inline 一段相同 mtime 比对逻辑导致漂移。
//
// 背景:之前的 writeNote 已经引入 expectedMtime(2026-10-08),用于防 OneDrive /
// Vim / 外部 git pull 等场景下「用户保存时文件已经被外部改过」被静默覆盖。
// r53 (2026-10-16) 把它从 notes 专属扩到 settings + library import 两条写路径。
//
// 设计要点:
// - assertNoConflict(actual, expected?) → expected 缺省时**直接放行**(向后兼容
// 老 caller:旧 renderer / 早期 IPC 不带 expectedMtime 的请求正常过),不等
// 于「关闭并发检查」—— 仅表示 caller 主动放弃比对。
// - 不一致时抛 AppError('CONFLICT', ...) —— 已有 IPC_ERROR_CODES tuple 收
// CONFLICT(r50 加入),wrap.ts 透传到 renderer,UI 走专用冲突路径。
// - 不在此处理 mtime 解析(读文件 stat / 读 DB mtime 列)—— 那是 caller 的
// 责任,本 helper 只做「两个数是否一致」的纯函数判定,便于单测(无 fs 依赖)。
import { AppError } from './error';
/**
* 乐观并发比对:actual 是当前主进程读到的资源 mtime(milliseconds,整数),
* expected 是 renderer 写请求里带回的「我读时的 mtime」快照。两者一致即放行,
* 不一致抛 CONFLICT。
*
* @param actual 当前主进程读到的 mtime(ms)。NaN / Infinity 由 caller 自行校验。
* @param expected renderer 回带的快照。undefined 表示 caller 主动跳过比对
* (向后兼容老 IPC —— 不带 expectedMtime 的请求正常过)。
*/
export function assertNoConflict(actualMtime: number, expected?: number): void {
if (expected === undefined) return;
if (actualMtime !== expected) {
throw new AppError('CONFLICT', `mtime mismatch: expected=${expected} actual=${actualMtime}`);
}
}

View File

@@ -0,0 +1,167 @@
// D2-020: 主进程启动路径安全检查,作为 scripts/start.cjs 的兜底防御。
//
// 必须在 main/index.ts 的最顶部 import,以便在 better-sqlite3 dlopen 之前
// 截断路径确实不安全的进程。
//
// 策略(2026-08-28 修订):
// - cwd 超长 (>240) → 致命,直接退出。真实的 Windows MAX_PATH 限制。
// - cwd 含 CJK → 仅提示。实测 Electron 30 + Win11 能正常 dlopen CJK 路径,
// 原来的致命判定反而逼出了 ASCII junction,把 vite dev server 搞崩。
// 详见 scripts/start.cjs 顶部的分析。
//
// 另:作为第二道兜底,如果 .node 文件的 ABI 已经过期(mtime 早于 electron.exe),
// 直接报清晰错误而不是让 dlopen 抛乱码堆栈。start.cjs 在正常 npm run dev 下
// 会先一步自动 rebuild,这里只处理"用户绕过 npm"或"自动 rebuild 失败"的场景。
import process from 'node:process';
import { existsSync, statSync } from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
// 必须用 createRequire 而不是 ESM `import` —— ESM import 会被 hoist 到模块顶部,
// 导致 `import('electron')` 在本检查函数之前就执行,破坏"先检查再 dlopen"的时序保证。
// 实际上这里只读 .node 路径不真正 dlopen,但用 require 形式是为了显式表达意图,
// 避免未来重构时被 hoist 规则坑到。
const requireFromHere = createRequire(import.meta.url);
function checkWindowsPath(): void {
if (process.platform !== 'win32') return;
// 仅在 dev/源码运行模式下生效:此时 better-sqlite3 从 cwd/node_modules 加载,
// cwd 的 CJK 会让 dlopen 失败。打包模式下 .node 从 app.asar.unpacked 加载
// (位于 %LOCALAPPDATA%),cwd 是否含 CJK 与加载路径无关,跳过。
const cwd = process.cwd();
const devNativeMarker = path.join(cwd, 'node_modules', 'better-sqlite3');
if (!existsSync(devNativeMarker)) return;
// 匹配非 ASCII(含 CJK)字符。使用 \x00-\x7F 边界是检测非 ASCII 的
// 标准写法。\x00 控制字符是有意的,排除注释 eslint。
// eslint-disable-next-line no-control-regex
const hasCJK = /[^\x00-\x7F]/.test(cwd);
const tooLong = cwd.length > 240;
if (!hasCJK && !tooLong) return;
// CJK 不再是致命错误(2026-08-28 修订)。
//
// 原假设:CJK cwd → Windows 给 .node 加 \\?\ 前缀时 Node 按 CP936 解码 UTF-8 → dlopen 失败。
// 实测在 Electron 30 + Win11 下不成立:scripts/start.cjs 的 verify-abi 探针就是在真实 CJK
// cwd 下 spawn electron.exe 跑 require('better-sqlite3') + new Database(':memory:') 成功的。
//
// 而把 CJK 判成致命错误的代价很实在:它逼着 start.cjs 去建 ASCII junction,而 junction 会让
// process.cwd() 与 vite 的 cacheDir 不同源,直接崩掉 dev server(见 start.cjs 顶部注释)。
// 所以 CJK 降级为一次性提示,真正 dlopen 失败时下面的 ABI 检查和 Node 自己的报错会接管。
if (hasCJK && !tooLong) {
process.stderr.write(
`[path-check] 注意: 项目路径含非 ASCII 字符 (${cwd.length} chars): ${cwd}\n` +
`[path-check] 已知在 Electron 30 + Win11 下可正常加载 native module。\n` +
`[path-check] 若 better-sqlite3 dlopen 失败,试 RKMS_FORCE_JUNCTION=1 npm run dev。\n`,
);
return;
}
// 超长路径仍然致命 —— 这是真实的 Windows MAX_PATH 限制,与字符集无关。
const reason = `path too long (${cwd.length} chars)`;
const lines = [
'',
'=====================================================',
'[FATAL] Project path is not safe for native module loading.',
` cwd: ${cwd}`,
` reason: ${reason}`,
'',
' Windows 的 MAX_PATH 会让 node_modules 深层文件路径被截断。',
'',
' Manual fix: move the project to a shorter path, e.g.',
' D:\\dev\\research-knowledge-management-system',
'',
' Or create a junction and launch from it:',
` mklink /J C:\\rkms "${cwd}"`,
' cd C:\\rkms && npm install && npm run dev',
'=====================================================',
'',
];
// logger 尚未初始化,直接写 stderr
process.stderr.write(lines.join('\n'));
process.exit(1);
}
/**
* 检查 better_sqlite3.node 的 mtime 是否晚于 electron.exe。
* mtime 早于 electron → 升级 Electron 后没重跑 install,ABI 漂移。
* 此时直接给清晰错误并退出 —— 比让 Node dlopen 抛
* "The module '...\\?\D:\data\我的工作...' was compiled against a different
* Node.js version using NODE_MODULE_VERSION XXX" 这种带乱码路径的错友好得多。
*
* 这一层是 scripts/start.cjs 自动 rebuild 的**兜底**:如果用户绕过 npm 直接
* 跑 electron(IDE / 双击 / 手敲),start.cjs 的检测和自动修复都不会发生,
* 这里就接管报错。
*/
function checkBetterSqliteAbi(): void {
if (process.platform !== 'win32') return;
const cwd = process.cwd();
const sqlitePath = path.join(
cwd,
'node_modules',
'better-sqlite3',
'build',
'Release',
'better_sqlite3.node',
);
if (!existsSync(sqlitePath)) return; // 第一次 install,交给 postinstall / 下次 npm run dev 的 start.cjs
let electronPath: unknown;
try {
electronPath = requireFromHere('electron');
} catch {
return; // electron 模块找不到(非 dev 模式或打包后),跳过
}
if (typeof electronPath !== 'string' || !existsSync(electronPath)) return;
let sqliteStat, electronStat;
try {
sqliteStat = statSync(sqlitePath);
electronStat = statSync(electronPath);
} catch {
return;
}
// .node 比 electron 新 → 已经对齐,无事可做
if (sqliteStat.mtimeMs >= electronStat.mtimeMs) return;
// 用 writeSync 直写 fd=2 (stderr) 而不是 console.error:
// console.error 会把 string 通过 tty stream 写出,Windows PowerShell cp936 终端
// 会按 GBK 解码,把 UTF-8 字节再次变成乱码 —— 反而掩盖真正的根因。
// writeSync 直接发字节流,配合终端 UTF-8 codepage (start.cjs 已 chcp 65001)
// 一定能正确显示中文路径。
try {
process.stderr.write(`\r\n`);
process.stderr.write(`=====================================================\r\n`);
process.stderr.write(`[FATAL] better-sqlite3 ABI mismatch.\r\n`);
process.stderr.write(` better_sqlite3.node mtime: ${sqliteStat.mtime.toISOString()}\r\n`);
process.stderr.write(` electron.exe mtime: ${electronStat.mtime.toISOString()}\r\n`);
process.stderr.write(`\r\n`);
process.stderr.write(
` 说明: 升级 Electron 后没重跑 npm install,导致 .node 是用旧的 ABI 编译的。\r\n`,
);
process.stderr.write(` 启动主进程时 dlopen 会抛 'NODE_MODULE_VERSION XXX' 错误。\r\n`);
process.stderr.write(`\r\n`);
process.stderr.write(` 修复(在项目根目录执行任一命令):\r\n`);
process.stderr.write(` npm run rebuild:native\r\n`);
process.stderr.write(` npm rebuild better-sqlite3\r\n`);
process.stderr.write(` npx electron-rebuild -f -w better-sqlite3\r\n`);
process.stderr.write(`\r\n`);
process.stderr.write(
` 之后正常 npm run dev 即可。如果是通过 IDE 启动,请改用 npm run dev 让 start.cjs 自动处理。\r\n`,
);
process.stderr.write(`=====================================================\r\n`);
} catch {
// 写不出来就退,反正 process.exit(1) 也会被 IDE 看到
}
process.exit(1);
}
checkWindowsPath();
checkBetterSqliteAbi();
export {};

View File

@@ -0,0 +1,53 @@
// 跨平台路径等价比较 —— 「这两个字符串是不是同一个目录」
//
// audit 2026-10-03:起因是 settings/library-migration.ts 用裸 `!==` 比较
// `settings.libraryPath` 与 `deriveLibraryPath(settings.rootPath)`。
// 那是**字符串**比较,却被用来回答「是不是同一个目录」这个**文件系统**
// 问题,于是三种完全合法的写法都被判成「库不在规范位置」:
//
// 1. 尾部分隔符 `...\library\` vs `...\library`
// 2. 分隔符风格 `C:/Users/foo` vs `C:\Users\foo`
// 3. 大小写 `C:\Users\Foo` vs `c:\users\foo` (NTFS / APFS)
//
// 后果不是「多弹一次窗」:迁移动作会因为「目标已存在」而报错(目标其实就是
// 源本身),UI 于是叫用户**删掉自己的库目录**;而用户点「保持现状」之后,派生
// 量会在下一轮启动把它重算成待迁移,弹窗反复出现。
//
// 放在 utils/ 而不是 shared/:大小写折叠要用 `isCaseInsensitiveFs`
// (utils/platform.ts),那是**主进程**的平台真相;shared/ 三端共用且拿不到
// process.platform。
import { isCaseInsensitiveFs } from './platform';
/**
* 归一化路径,使其可与另一条路径做同一目录比较。
*
* - 折叠分隔符风格:仅当路径呈 win32 形态(盘符 / UNC / 含反斜杠)才把 `/`
* 转成 `\`。POSIX 路径的 `/` 是语义的一部分,不能碰。
* **判定只看字符串形态,不看宿主平台** —— 这是刻意的:同一份判定要与
* src/shared/path-layout.ts 的 detectSeparator 同源,而后者是给三端共用的。
* 因此 caller 若持有的是**平台原生**路径(`path.join` / `path.resolve` 的产物),
* 要先自己过一遍 `resolve()` 再传进来 —— 见 settings/store.ts 的
* changeDataPath 跳过库目录那一处。
* - 剥尾部多余分隔符,但保住 `C:\` 与 `/` 这类根本身(剥空了就丢了根)。
* - case-insensitive FS 上 lowercase(单一真相来自 utils/platform)。
*/
export function normalizePathForCompare(p: string): string {
// win32 形态判定与 src/shared/path-layout.ts 的 detectSeparator **同规则**:
// 盘符 / UNC 两种前缀都按「两个前导分隔符」来认(不区分 `\\` 与 `//`,因为
// `//server/share` 是 Windows 上完全合法的 UNC 写法)。
//
// 这里曾经只认 `\\` 开头的 UNC,于是 `//server/share/lib` 被当成 POSIX 路径
// 原样保留,而 `\\server\share\lib` 走 win32 分支 —— 同一个 UNC 服务器的
// 两种写法被判成两个目录。两处判定必须同源,否则「单一真相」只是口号。
const looksWin32 = /^[a-zA-Z]:[\\/]/.test(p) || /^[\\/]{2}[^\\/]/.test(p) || p.includes('\\');
let s = looksWin32 ? p.replace(/\//g, '\\') : p;
s = s.replace(/[\\/]+$/, '');
if (s === '' || /^[a-zA-Z]:$/.test(s)) s = looksWin32 ? `${s}\\` : `${s}/`;
return isCaseInsensitiveFs ? s.toLowerCase() : s;
}
/** 两条路径是否指向同一个目录(而非同一个字符串)。 */
export function isSameDir(a: string, b: string): boolean {
return normalizePathForCompare(a) === normalizePathForCompare(b);
}

View File

@@ -0,0 +1,36 @@
// 跨平台分支谓词集中点
//
// audit round 10 (2026-09-15):H5 — `process.platform === 'win32' ||
// process.platform === 'darwin'` case-insensitive FS 谓词三处独立写
// (safe-write.ts / fs-cache.ts / window.ts)。一处改规则(比如未来
// Electron 加 CI 文件系统平台)要 grep 三处,容易漏。
//
// 集中后,新增 callsite 只 import 一个名字;若未来 Android / iOS 等其他
// case-insensitive 平台出现,只改这一个文件。
//
// 为什么是 win32 + darwin:
// - NTFS / FAT32 (Windows) 默认 case-insensitive,即使是 case-preserving
// 也能设置 case-sensitive flag 但 99%+ 默认装机是 insensitive
// - APFS (macOS 10.13+) 默认 case-insensitive(可设 flag,但 99%+
// 默认是 insensitive)
// - ext4 / btrfs (Linux) 默认 case-sensitive
// - Electron 支持的平台就是 darwin / win32 / linux,这里列两个 case-insensitive
/** 文件系统是否默认 case-insensitive(WIN + Mac,Linux 否) */
export const isCaseInsensitiveFs: boolean =
process.platform === 'win32' || process.platform === 'darwin';
/**
* 是否需要「fsync 父目录」来保证 rename 的持久性。
*
* audit 2026-10-04:只对 POSIX 为 true。POSIX 的 fsync 语义要求 **目录项**
* 也落盘才算 rename 提交;`fh.sync()` 只保证 tmp 文件的数据块落盘,
* 两者是两件事 —— 不 fsync 目录,断电后 rename 可能丢失(目标文件回退到
* 旧版本,表现为「这次保存静默没了」)。
*
* Windows 上不存在这个问题:NTFS 的元数据日志让 rename 的持久性与
* MoveFileEx 的返回同步,而且 Node 在 Windows 上无法对目录句柄做有意义的
* fsync(打开目录句柄成功但 sync 无效)。故此处直接跳过,不是「尽力而为」的
* 猜测,而是「该平台上没有这个契约」。
*/
export const needsDirFsyncForRename: boolean = process.platform !== 'win32';

View File

@@ -0,0 +1,86 @@
// src/main/utils/retry-transient.ts
//
// audit C3R66 (2026-08-22):抽 Windows EPERM/EBUSY transient retry loop —
// 之前在 src/main/utils/safe-write.ts:118-142 和 src/main/utils/atomic-file.ts:164-191
// verbatim 重复 25 行(只有 return vs break 的差异)。两份代码独立演化风险高
// (C2R20 off-by-one fix 时只修了一处,另一处保留原状)。合并到本 helper 后:
// 1. EPERM/EBUSY 退避策略单一真相源(maxAttempts / backoff 公式 / jitter 上限)
// 2. C2R20 off-by-one fix 一处生效,全覆盖
// 3. 失败清理抽到 caller(caller 已经持有 tmp 路径,语义上更清晰)
//
// 退避策略(Windows 验证过):
// - maxAttempts: 5
// - backoff: 50 * 2^(attempt-1) ms —— 但**只有 4 次 sleep**:
// 50/100/200/400(基础合计 750ms)。第 5 次失败时 attempt=5 不再
// < MAX_ATTEMPTS,直接 throw,800ms 档不存在。
// audit-r95 修正:旧注释宣称「50/100/200/400/800,总窗口 ~1.5s」,
// 与代码差一倍(r88 PromptDialog 注释撒谎先例)。行为面由
// retry-transient.test.ts 的 fake-timer 序列锁钉死,改公式必红。
// - jitter: [0, 25) ms random,避免多 writer 齐步重试
// - 退避期间命中 transient 之外的其他错误 → 立即 throw(不重试)
//
// audit 2026-09-14:cleanup callback 的 .catch(() => {}) 之前无日志。cleanup 是 caller 传的
// (safeUnlink / fh.close 等),失败通常是 Windows AV 持锁 / 权限丢失 —— 留下孤儿 tmp /
// fd 泄漏信号消失。补 log 让退避循环外的清理失败可观察。
import { createLogger } from './logger';
import { errMsg } from './error';
const log = createLogger('retry-transient');
//
// 为什么 caller 传 cleanup 而不是 helper 内部 fs.unlink:
// - helper 不知道 caller 的 tmp 路径(caller 可能自定义 .tmp suffix / 不同的目录)
// - 某些 caller 已经在 outer catch 里清理 tmp,inline 重复清理有 race
// - 让 helper 保持单一职责(只 retry),副作用归 caller
import { errnoCode } from './error';
const MAX_ATTEMPTS = 5;
const BACKOFF_BASE_MS = 50;
const JITTER_MAX_MS = 25;
/**
* Windows 在快速 back-to-back rename 时偶发 EPERM/EBUSY(AV 扫描 / Defender
* 全盘扫描持句柄导致 MoveFileEx 失败)。POSIX 上不会触发。本 helper 把 retry
* 退避策略封装一次,所有原子写路径(safe-write + atomic-file + 未来新增的
* rename-based 写)共享同一份行为。
*
* @param action 要执行的 rename-style 操作。返回 = 成功;抛错 = 失败。
* @param cleanup exhaust 时调用,通常清理 .tmp 文件;失败也无害(caller 通常
* 会 catch 住,真正的 tmp 清理已经做过)。
*/
export async function retryOnWindowsTransient<T>(
action: () => Promise<T>,
cleanup: () => Promise<void>,
): Promise<T> {
let attempt = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
try {
return await action();
} catch (err: unknown) {
// C2R20: 先 ++ attempt 再比较,避免 off-by-one 多试一次。
// maxAttempts=5 时实际跑 5 次(不是 6 次)。
attempt++;
const code = errnoCode(err);
// audit 2026-10-01:补 ENOTEMPTY。目录 rename / recursive rm 在 Windows 上
// 撞上 Defender / Search / 云盘同步的瞬时占用时,报的正是 ENOTEMPTY
// (EPERM/EBUSY 只覆盖文件级)。缺它意味着「清空目标目录再 rename」
// 这类迁移步骤会一次失败就放弃 —— 而调用方把失败降级成 warn 继续启动,
// 用户看到的就是笔记凭空消失。
const transient = code === 'EPERM' || code === 'EBUSY' || code === 'ENOTEMPTY';
if (transient && attempt < MAX_ATTEMPTS) {
const backoff = BACKOFF_BASE_MS * 2 ** (attempt - 1);
await new Promise((r) =>
setTimeout(r, backoff + Math.floor(Math.random() * JITTER_MAX_MS)),
);
continue;
}
// cleanup 可能同步返回 undefined(vi.fn 或裸 () => {})— 用 Promise.resolve 包一层
// 才能安全链 .catch。否则 vi.fn()(undefined).catch → TypeError 盖住原 err。
await Promise.resolve(cleanup()).catch((cleanupErr: unknown) =>
log.warn('Transient retry cleanup callback failed', { err: errMsg(cleanupErr) }),
);
throw err;
}
}
}

49
src/main/utils/reveal.ts Normal file
View File

@@ -0,0 +1,49 @@
// src/main/utils/reveal.ts — 跨平台"在文件管理器中显示文件"helper
//
// 集中到共享层是因为同一份 3-way 分支(Win / Mac / Linux)之前在 library.ts
// 与 notes.ts 各写一份,任何漂移(改 API、改日志、改错误形态)只能 grep 兜底,
// 且 Linux 失败路径只有 library.ts 接住 reject,notes.ts 偶发仍是静默 no-op
// (audit 2026-08-26 收口时未对齐)。共享 helper 把行为绑死:
//
// - Windows / macOS: shell.showItemInFolder (Win32 SHOpenFolderAndSelectItems /
// NSWorkspace selectFile,最可靠)。Win 上 child_process.spawn('explorer.exe',
// ['/select,"..."'], ...) 在不少环境下被静默忽略(Node 命令行 quoting + explorer
// 解析在某些路径/会话下不兼容),改用 Electron 官方 API 是推荐做法。
// - Linux: 无标准 reveal 语义,降级为打开父目录。shell.openPath 在 Linux 上
// 经常返回非空错误字符串(xdg-open 缺失 / dbus 不可用 / 无默认文件管理器),
// 不接住会让用户点了「在文件夹中显示」毫无反应 —— 这里把非空 err 翻译为
// LibraryError 让 caller 决定走 IPC 错误流。
//
// 注:helper 不做文件存在预检 —— 预检归 caller(notes.ts:204 先过
// utils/file-check.ts 的 assertFileExists,r24 起两域已收敛到该共享
// helper,「library.ts:assertFileExists / notes.ts 内联 fs.open」的旧
// 分工已不存在;audit-r124 校正)。共享层只关心"文件存在就怎么 reveal"。
import { dirname } from 'path';
import { shell } from 'electron';
import { LibraryError, errMsg } from './error';
export async function revealInOSFileManager(absPath: string): Promise<void> {
if (process.platform === 'linux') {
// Linux: 无标准 reveal 语义,降级为打开父目录
const openErr = await shell.openPath(dirname(absPath));
if (openErr) throw new LibraryError('IO_ERROR', `openPath failed: ${openErr}`);
return;
}
// Windows / macOS - 走官方 API;caller 的 assertFileExists 已经保证文件存在
shell.showItemInFolder(absPath);
}
/**
* Wrap revealInOSFileManager 抛错 → IPC 错误流。
* notes.ts 旧实现把非 Linux 路径吞掉,Mac/Linux 上次抛错静默,这里统一 catch
* 后转 IO_ERROR,行为与 library.ts 收口对齐。
*/
export async function revealInOSFileManagerSafe(absPath: string): Promise<void> {
try {
await revealInOSFileManager(absPath);
} catch (err: unknown) {
if (err instanceof LibraryError) throw err;
throw new LibraryError('IO_ERROR', errMsg(err));
}
}

View File

@@ -0,0 +1,47 @@
// perf(2026-08-23):N-way 并发池 —— 从 library/scanner.ts 抽出共享。
// 原本只有 scanner 用,notes/standalone.ts 的 walk 也需要同款限流(串行 readFile
// 在几百篇笔记时拖到几百 ms),与 WriteLock / errPayload 同样的抽取动机:
// 避免第二份 verbatim 实现。
//
// 为什么用池而不是裸 Promise.all:大库一次性 spawn 数千 Promise 会同时打开数千
// fd(Windows 默认 handle 上限虽高,但 EMFILE 在 macOS/Linux 上很容易撞),
// 且没有背压 —— 池把并发钉在 N。
import { createLogger } from './logger';
import { errMsg } from './error';
const log = createLogger('run-pool');
/**
* 把 items 分到最多 concurrency 个并发 worker 上跑 fn。
*
* 错误策略:单个 item 抛错被记录为 warn(包含 index + 错误信息),继续下一个 item。
* 这样即使调用方忘记在 fn 内 try/catch,也能在 devtools 看到失败 ——
* 原实现 empty catch 让 6 个 callsite 中任何一个忘记 log 都会 silent 丢失败,
* 索引 / 扫描悄悄跳过损坏文件但 UI 没感知。warn 信息只到 run-pool scope,
* 具体失败的 item 语义仍由调用方在 fn 内部 try/catch 自行 log。
*/
export async function runPool<T>(
items: readonly T[],
concurrency: number,
fn: (item: T) => Promise<void>,
): Promise<void> {
if (items.length === 0) return;
let cursor = 0;
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
let i = cursor++;
while (i < items.length) {
const item = items[i];
try {
await fn(item);
} catch (err: unknown) {
log.warn('runPool task failed (caller should log specifics)', {
i,
item,
err: errMsg(err),
});
}
i = cursor++;
}
});
await Promise.all(workers);
}

View File

@@ -0,0 +1,817 @@
// 安全写操作:确保所有写操作目标在数据路径内
// 任何试图写入库路径或外部路径的操作都会抛错
import { promises as fs, realpathSync } from 'fs';
import { randomBytes } from 'crypto';
import { dirname, isAbsolute, join, relative, resolve, sep } from 'path';
import { shell } from 'electron';
import { AppError, errnoCode, errMsg } from './error';
// r165 (2026-10-19):不再 `import { tr } from '../i18n'` —— 那条边通过
// settings/store 形成循环依赖。改为从零依赖的 i18n-dict 拿字典,当前
// 语言由 setI18nLangProvider 注册的 getter 解析(未注册前回退 zh-CN,
// main/index.ts 在 import 顶层做一次注入)。
import { resolveT, type Lang } from '../i18n-dict';
import { createLogger } from './logger';
// audit round 10 (2026-09-15):H5 — 共享 isCaseInsensitiveFs 替代内联
// `process.platform === 'win32' || process.platform === 'darwin'` 谓词,
// 与 fs-cache.ts / window.ts 同源,新增 CI 平台只改 utils/platform.ts 一处。
import { isCaseInsensitiveFs, needsDirFsyncForRename } from './platform';
const log = createLogger('safe-write');
import { retryOnWindowsTransient } from './retry-transient';
// r161+ disk-folder:共享 DiskFolderNode 类型
// r174:kind → 物理目录名映射的唯一真相 —— WORKSPACE_DATA_LAYOUT。
// kind 是**逻辑**域('note' / 'literature'),磁盘目录名却是
// 'notes' / 'literature'(note 与 notes 差一个 s)。以前 listSubdirs
// 直接 `join(rootPath, kind, ...)`,notes 域于是去读 <root>/note ——
// 不存在 → readdir ENOENT → 静默返 [] → 笔记侧栏一个文件夹都不显示
// (文献域侥幸:kind 'literature' 恰好等于目录名)。
// 磁盘目录名一律走本常量,不再散落字面量。
import { WORKSPACE_DATA_LAYOUT, type DiskFolderNode } from '../../shared/types';
// r186:目录名过滤与路径校验**共用同一份**核心规则(shared/rel-path.ts)——
// 不变量 I2「列得出来的,一定能进得去」。判据另写一份就会分叉,分叉
// 的后果就是用户报的「点进去报错」的夹。
import { isValidRelPathSegment } from '../../shared/rel-path';
// audit C3R50 (2026-08-22):safeUnlink 内 inline errno 提取已被本文件上方 import 的
// errnoCode helper 覆盖 — 本 helper 内部仍然用 `(err as { code?: unknown }).code`
// (utils/error.ts 的 helper 自身),不重复迁移。下方 safeUnlink 同步迁移。
// r165 (2026-10-19):langProvider 注册表 —— i18n.ts 通过 setI18nLangProvider
// 注入 safeLang 引用(主进程启动早期调一次),safe-write 的本地 tr() 在抛
// AppError 走 key 解析时取该 getter 的返回值。未注册前任何调用回退 zh-CN
// (settings 还没初始化等极端情况下,只发生在 app 还没起来时的 very-early
// 阶段,主进程 index.ts 顶层 import 链已保证注入在所有 IPC handler 之前
// 完成 —— 即 r165 wiring 不变量)。
let langProvider: () => Lang = () => 'zh-CN';
export function setI18nLangProvider(p: () => Lang): void {
langProvider = p;
}
/** safe-write.ts 本地 tr():与 i18n.ts 的 tr() 等价,但读 langProvider 槽而非
* 直接调 safeLang()(避免回引 i18n.ts 形成循环)。占位符替换语义同 i18n.ts。 */
function tr(key: string, params?: Record<string, string | number>): string {
return resolveT(key, langProvider(), params);
}
// audit round 18 (2026-08-27):原子写 tmp 文件名加 8 字符 base64url 随机后缀 + 用
// 'wx'(exclusive create)而不是 'w',两个目的:
// 1. 'w' 模式会 truncate 已存在的同名文件 + 跟随符号链接;预置的
// <target>.tmp 符号链接能把数据写出 dataPath 外,绕过 resolveInsideDataPath。
// 'wx' 在目标已存在(包括 symlink)时抛 EEXIST,写入被拒。
// 2. 随机后缀让并发写(同一 target 的不同 caller)各自拿独立的 tmp,
// 即使 WriteLock 漏掉的边角场景(sidecar / providers),也不会互相 truncate。
// base64url 8 字符 ≈ 48 bit 熵 — 单用户应用写冲突幂等概率 ~0。
// audit C2R2 (2026-08-22):继承 AppError 而不是裸 Error。
// 之前 UnsafeWriteError extends Error,在 IPC wrap.ts handle() 里
// 只走 `else { new AppError('INTERNAL', ...) }` 兜底,失去"安全拒绝"
// 的语义 — renderer 端无法区分"安全策略阻止" vs "通用内部错误"。
// 继承 AppError 后,wrap.ts 的 `err instanceof AppError` 分支会透传
// code='UNSAFE_WRITE',renderer 可以针对性提示(例如"路径不允许"
// 而非"内部错误")。atomic-file.ts 内的 `err instanceof UnsafeWriteError`
// 检测仍可用(继承链未变)。
export class UnsafeWriteError extends AppError {
constructor(target: string, dataPath: string) {
super(
'UNSAFE_WRITE',
`Refusing to write outside data path.\n target: ${target}\n dataPath: ${dataPath}`,
);
this.name = 'UnsafeWriteError';
}
}
let currentDataPath: string | null = null;
// workspace-unification(2026-09-23 起,2026-10-02 接线完成)
//
// 这对函数的原名是 setDataPath / getDataPath —— 它们把「数据目录」当成一个
// 模块级单例,但 AppSettings 历史上 libraryPath(也是路径、却不是「数据目录」)
// 与它并列。统一到 rootPath 后,notes/ workspace/ library/ settings.json
// 全都归 rootPath 管,`dataPath` 这个名字就偏窄了。
//
// 演进是分两步走的,第二步(2026-10-02)刚完成:
// 1. 2026-09-23 加 setRootPath/getRootPath 作为 @deprecated 转发别名
// 2. 2026-10-02 把**全部 call site** 切到新名字,别名即退休
// —— 这正是 ADR-0002 说的「接上 caller 之后即可删掉别名」。留着别名会让
// 下一个 contributor 以为两套名字都有语义,又是一次影子实现。
export function setRootPath(p: string): void {
currentDataPath = resolve(p);
}
export function getRootPath(): string {
if (!currentDataPath) {
throw new AppError('INTERNAL', 'Root path not initialized. Call setRootPath() first.');
}
return currentDataPath;
}
/**
* 把 target 解析为绝对路径,并校验其在 rootPath 内
* 关键: 始终以 rootPath 为基准,避免相对路径被解析到 cwd
* 返回已解析的绝对路径(供后续 fs.* 直接使用)
*
* audit 2026-09-11:Windows 大小写不敏感 —— 之前 `resolved.startsWith(dpWithSep)`
* 是字面比较,`C:\Users\Foo` 与 `c:\users\foo` 不相等。`app.getPath('userData')`
* 返回 canonical case (e.g. `C:\Users\…`),而 IPC 注入的 target 可能是 IPC 层
* 任意来源,大小写不一定一致 → 误判 UnsafeWriteError。
* 修:Windows 上两侧都 toLowerCase 比对。POSIX 文件系统大小写敏感,不动。
* audit 2026-09-14 r7:macOS (darwin) 大小写不敏感 —— APFS 自 10.13 默认格式
* 是 case-insensitive(用户主动格式化时才能选 case-sensitive,99%+ 用户用默认),
* 与 HFS+ 一致。`safe-write` / `fs-cache` / `window.ts` 之前只 win32 走
* lowercase,导致 macOS 上 `~/Documents/ResearchHub/data` 与 IPC 传过来的
* `~/documents/researchhub/data` 不匹配 → 误抛 UnsafeWriteError / 缓存
* miss 反复 stat。扩到 win32 + darwin。Linux 不动(ext4 走 case-sensitive)。
*/
function resolveInsideDataPath(target: string): string {
const dp = getRootPath();
const resolved = isAbsolute(target) ? resolve(target) : resolve(dp, target);
const dpWithSep = dp.endsWith(sep) ? dp : dp + sep;
// audit round 10 (2026-09-15):H5 — 走共享 isCaseInsensitiveFs 替代内联
// `process.platform === 'win32' || process.platform === 'darwin'` 谓词,
// 与 fs-cache.ts / window.ts 同一来源。
const ci = isCaseInsensitiveFs;
// audit 2026-10-08 (HIGH):fold helper 提到函数顶部 —— 之前定义在 outer try
// 内,inner catch (ENOENT 父目录 realpath 兜底)看不到。提到顶部,父目录
// realpath 比对时也走同一份 case-insensitive 折叠。
const fold = (p: string): string => (ci ? p.toLowerCase() : p);
const matchSame = ci ? fold(resolved) === fold(dp) : resolved === dp;
const matchInside = ci
? fold(resolved).startsWith(fold(dpWithSep))
: resolved.startsWith(dpWithSep);
if (!matchSame && !matchInside) {
throw new UnsafeWriteError(resolved, dp);
}
// audit r28 (2026-09-21):realpath 防御中间路径 symlink —— path.relative
// / startsWith 只比字符串,target 字符串合规 ≠ 实际写到 dataPath 内。
// 攻击者把 dataPath/notes/foo 替换成指向 /etc 的 symlink,后续
// safeWriteFileAtomic 的 tmp.open('wx') 仍写进「dataPath 内」的字符串
// 路径,但内容落到了 symlink target。把 safeWriteFileAtomic 的 resolved
// 路径也做一次 realpath 检查 —— 已存在的路径解析真实位置必须仍在
// dataPath 内(允许跨大小写),否则走 UNSAFE_WRITE 抛错。ENOENT 等
// "路径不存在" 静默放过(create 路径后续 mkdir 兜底 + atomic 写新建)。
//
// audit 2026-10-01:HIGH — 修正比较基准,两侧必须同口径 realpath。
// 旧实现拿 `realpath(target)` 去比**未 realpath 的** dp。Node 的
// realpathSync 会把整条链路解析到物理位置,于是只要 data 根路径自身
// 位于 symlink / junction 之下,real(target) 就与 dp 不在同一棵树上,
// relative() 必然产出 '..' 前缀 → 抛 UNSAFE_WRITE。
// 实测复现(Windows junction,无符号链接逃逸、纯合法写入):
// dataPath = C:\...\junction-data (→ C:\...\real-data)
// target = C:\...\junction-data\notes\a.md
// real = C:\...\real-data\notes\a.md
// relative(dp, real) = '..\real-data\notes\a.md' → REJECT
// 影响面对这个应用特别大:README 明确建议把数据目录丢 OneDrive /
// Dropbox / iCloud,而这些云盘文件夹在 Windows 上**常常是 reparse
// point**;`utils/path-check.ts` 更是直接教用户 `mklink /J` 建 junction
// 绕 MAX_PATH。于是这类用户会遇到「所有保存都报路径不允许」,而磁盘上
// 的旧数据仍能读(读路径不过本函数),现象极具迷惑性。
//
// 修法:两侧都 realpath 后再比。realDp 解析不出来(根还没建 / 权限被剥)
// 时退回原有的「只比明文 dp」,与下方 target ENOENT 的既有契约一致 ——
// 「无法验证就不阻断,让上层 fs 报真错误」,而不是把用户锁在门外。
try {
const real = realpathSync(resolved);
let realDp: string | null = null;
try {
realDp = realpathSync(dp);
} catch {
// 根不可解析 —— 只能退回明文比较(见上方说明)
realDp = null;
}
// 用 path.relative 比 startsWith 更稳:跨平台路径分隔符(Win \ vs POSIX /)
// 在 memfs / 测试环境下 real 与 dp 可能形态不同(Win32 resolve 把
// '/data' 变成 'C:\data',real 仍 '/data/notes/a.md'),但 relative
// 会把两侧 normalize 成同一形态后再算偏移。case-insensitive FS 上再
// 两侧 lowercase 折叠 —— 与 startsWith 逻辑同口径。
const relPlain = relative(fold(dp), fold(real));
const insidePlain = relPlain === '' || (!relPlain.startsWith('..') && !isAbsolute(relPlain));
// 目标「同时」在明文根或 realpath 根之内即放行。逃逸场景两侧都不满足
// (real 落在两棵树之外),仍然被拒 —— 修正确认不削弱 symlink 防护。
const insideReal =
realDp !== null &&
(() => {
const relReal = relative(fold(realDp), fold(real));
return relReal === '' || (!relReal.startsWith('..') && !isAbsolute(relReal));
})();
if (!insidePlain && !insideReal) {
throw new UnsafeWriteError(real, dp);
}
} catch (err: unknown) {
if (err instanceof UnsafeWriteError) throw err;
// audit 2026-10-08 (HIGH):ENOENT/ENOTDIR 分支必须校验父目录 —— 旧实现
// 静默放过(target 不存在 → mkdir 兜底 + atomic 写新建)。但攻击者把
// `dataPath/notes` 替换为指向 /etc 的 symlink,target 不存在(realpath
// 抛 ENOENT),旧 catch 直接 return resolved,后续 atomic 写跟着 symlink
// 把内容写到 dataPath 外 → 绕过所有字符串 + 明文根防御。
// 修法:ENOENT/ENOTDIR 时单独 realpath 父目录,父目录解析到 dataPath 外
// → UnsafeWriteError(走 same 双重根比对)。其它 errno 分桶处理:
// ELOOP — symlink 循环,永远不是合法场景,直接拒(不该 warn-and-allow)
// EACCES/EPERM/EIO/EMFILE 等 — 仍 warn-and-allow(Defender 瞬态锁、
// 一次性权限翻转,让上层 fs 抛真错;若硬拒会导致用户被锁在外面)。
const code = errnoCode(err);
if (code === 'ENOENT' || code === 'ENOTDIR') {
try {
const realParent = realpathSync(dirname(resolved));
// 父目录走明文根 + realpath 根双重比对(同上,只是对象换成父目录)。
// 明文比对防「父目录本身在 dataPath 内,但父目录 → 软链外部」场景;
// realpath 比对防「父目录就是 dataPath 内某个软链解析后的位置」场景。
const relPlain = relative(fold(dp), fold(realParent));
const insidePlain =
relPlain === '' || (!relPlain.startsWith('..') && !isAbsolute(relPlain));
let realDp: string | null = null;
try {
realDp = realpathSync(dp);
} catch {
realDp = null;
}
const insideReal =
realDp !== null &&
(() => {
const relReal = relative(fold(realDp), fold(realParent));
return relReal === '' || (!relReal.startsWith('..') && !isAbsolute(relReal));
})();
if (!insidePlain && !insideReal) {
throw new UnsafeWriteError(realParent, dp);
}
} catch (e: unknown) {
if (e instanceof UnsafeWriteError) throw e;
// 父目录 realpath 抛错(权限 / 根不可达) —— 与 target realpath 抛错
// 同口径,放过让上层 fs 报真错,只 log warn(不淹没真正的 IO_ERROR)。
const parentCode = errnoCode(e);
// audit-r151:ELOOP 父目录分支必须显式拒,与 target-ELOOP 同口径
// (safe-write.ts:211-214)。原实现 parent-ELOOP 走默认 warn-and-allow,
// atomicWriteFileInternal 紧随其后的 mkdir recursive 在 symlink 环上
// 会反复 mkdir 同一个路径直到 ELOOP 触发(撞上 OS hop limit,通常 40)
// → 用户得到的是一个迷惑的 fs.mkdir ELOOP 而不是清晰的 UNSAFE_WRITE。
// 攻击者可构造 `dataPath/notes/sub/loop/.../loop` 让单次写入触发
// symlink 环,绕过字符串校验把数据写出 dataPath 外。
if (parentCode === 'ELOOP') {
throw new UnsafeWriteError(resolved, dp);
}
// audit-r164 N4:ENAMETOOLONG = 路径设计错误(单段 >255 字符 or 总长
// >PATH_MAX),不是 Defender 瞬态 / 一次性权限翻转,旧实现走
// warn-and-allow 让上层 fs 抛迷惑 ENAMETOOLONG。修:显式 reject
// VALIDATION(IPC union 内 code,renderer 走「路径过长」toast 区分
// 「瞬态 IO」与「用户输入错误」),与 ELOOP 同款「path 形状错误」语义。
if (parentCode === 'ENAMETOOLONG') {
throw new AppError(
'VALIDATION',
`parent path exceeds OS limit: ${resolved}`,
);
}
if (parentCode !== 'ENOENT' && parentCode !== 'ENOTDIR') {
log.warn('resolveInsideDataPath: parent realpath failed', {
resolved,
code: parentCode,
});
}
}
} else if (code === 'ELOOP') {
// ELOOP = symlink 循环。这是攻击/配置错误,不是 Defender 瞬态,直接拒。
// 不 warn-and-allow,否则攻击者构造 symlink 循环就能绕过。
throw new UnsafeWriteError(resolved, dp);
} else if (code !== undefined) {
// EACCES / EPERM / EIO / EMFILE / ... 仍 warn-and-allow(可能是 Defender /
// 一次性权限翻转)。但 ELOOP 已在上方分支独立处理,这里不再讨论。
log.warn('resolveInsideDataPath: realpath failed', { resolved, code });
}
}
return resolved;
}
// audit C3R54 (2026-08-22):删 `safeWriteFile` 死代码 export。
// 安全非原子写这个 API 自从 codebase 全切到 atomic write 后就 0 caller
// (grep 确认 src/ 0 调用,test 文件 7 处全是测试该函数本身)。
// 需要 dataPath 内写入统一走 safeWriteFileAtomic(自带 fsync + EPERM retry + 清理)。
// 需要跨 dataPath / userData / 迁移路径写入走 atomicWriteFileInternal。
// 没有"普通写"需求 —— atomic 写本来就涵盖非 atomic 写的所有场景,
// 且更安全(避免断电/崩溃留半截文件)。
//
// 替代映射:
// safeWriteFile(target, content) → safeWriteFileAtomic(target, content) // dataPath 内
// safeWriteFile(target, content) → atomicWriteFileInternal(target, content) // 跨 dataPath
// audit 2026-10-08 (HIGH):导出 resolveInsideDataPath —— 原是 private,仅
// safeWriteFileAtomic 内部用。notes/standalone.ts 的 createNote / renameNote
// 用裸 `fs.open('wx')`(line 333 / 449)实现「atomic exclusive create」语义,
// 不能换成 safeWriteFileAtomic(后者 tmp+rename 替换语义,丢 DUPLICATE_NAME
// 保证)。但这俩路径之前完全绕过 dataPath / symlink 校验 —— 把
// resolveInsideDataPath 导出,在 fs.open('wx') 之前调一次,把字符串 +
// 父目录 realpath 防御接上,保留 `wx` 原子独占语义。
export { resolveInsideDataPath as resolveInsideRootPath };
export async function safeWriteFileAtomic(target: string, content: string | Buffer): Promise<void> {
const resolved = resolveInsideDataPath(target);
await atomicWriteFileInternal(resolved, content);
}
/**
* 原子写文件 — 不做 dataPath 校验。
*
* audit C3R53 (2026-08-22):抽出此 helper 让 safeWriteFileAtomic 复用,同时也让
* 之前因为 dataPath 校验拒掉而无法走 safeWriteFileAtomic 的 3 个 callsite
* 也能拿到 fsync + EPERM retry + 失败清理的全套保护:
* - main/location.ts:121(legacy settings.json 复制,直接 fs.writeFile 无原子)
* - main/location.ts:159(writeLocation → userData/location.json,userData 在 dataPath 外)
* - main/settings/store.ts:135(migrateData → 新路径的 settings.json,旧 dataPath 校验拒掉)
*
* 这 3 个 callsite 都明确知道自己在写 userData / 新迁移路径,**必须**绕过 dataPath 校验
* —— 但仍然需要 fsync + EPERM retry + 清理 tmp 的原子保证。本 helper 提供这两层:
* 1. fsync tmp before rename → 断电/崩溃不会让 rename 成功但内容为 0 字节
* 2. EPERM/EBUSY 退避 5 次 + 抖动 → Windows AV/Defender 持句柄导致 MoveFileEx 失败
* 3. 任意阶段失败清理 .tmp,避免历史失败累积的孤儿文件
*
* 调用方责任:确保 `target` 是绝对路径或相对于当前合法的写入位置。
* 此 helper 不做 safe-write 的 dataPath 边界校验。
*/
export async function atomicWriteFileInternal(
target: string,
content: string | Buffer,
): Promise<void> {
await fs.mkdir(dirname(target), { recursive: true });
// audit round 18:tmp 文件名加 marker + 8 字符 base64url 随机后缀,见 ATOMIC_TMP_PATTERN。
const tmp = `${target}.${RKMS_TMP_MARKER}${randomBytes(6).toString('base64url')}.tmp`;
// open + write + fsync + close:确保 tmp 内容已落盘后再 rename,
// 否则断电/崩溃可能让 rename 成功但内容为 0 字节,目标文件无声损坏
// 'wx' 防止 symlink write-through 绕过 resolveInsideDataPath。
// 任何阶段失败都清理 .tmp,避免历史失败累积的孤儿文件
let fh;
try {
fh = await fs.open(tmp, 'wx');
await fh.writeFile(content);
await fh.sync();
} catch (err) {
if (fh) {
// audit 2026-09-14:write error 路径 close 改 warn,保留 throw 上抛 —— 上层
// 已经 throw 了原 err,close 失败只是诊断信号,不再 swallow。
// audit round 8 (2026-09-14):走 safeCloseFh helper,统一 close 失败日志形态。
await safeCloseFh(fh, 'fh.close failed during safeWrite error path', log, { tmp });
}
// audit 2026-08-25:unlink 只吞 ENOENT —— 其他错误(权限 / Windows 锁文件)
// 之前 swallow 会留下孤儿 .tmp,累积占盘。日志保留失败记录供 debug。
await fs.unlink(tmp).catch((err: unknown) => {
if (errnoCode(err) !== 'ENOENT') {
log.warn('Failed to clean tmp on write error', { tmp, err: errMsg(err) });
}
});
throw err;
}
// audit round 18:close() 也包到独立 try 里 — 如果 close 自己抛错(Windows
// 上的 stale handle 等),旧实现 catch 不会触发,tmp 文件会一直留在磁盘上。
try {
await fh.close();
} catch (closeErr) {
// audit 2026-09-14:close 失败时 unlink 改 warn —— Windows stale handle 时 tmp
// 残留信号丢失,无法后续清理。closeErr 上抛不变(写路径已经失败)。
// audit round 8 (2026-09-14):close 失败这里保留 inline throw,不走 helper —
// helper 是"吞 close 失败,只 warn",但这里需要 rethrow closeErr。
await fs
.unlink(tmp)
.catch((unlinkErr: unknown) =>
log.warn('Failed to clean tmp on close error', { tmp, err: errMsg(unlinkErr) }),
);
throw closeErr;
}
// audit C3R66 (2026-08-22):EPERM/EBUSY transient retry 逻辑抽到
// utils/retry-transient.ts,与 atomic-file.ts persist() 共享同一份退避策略。
// C2R1 的 Windows EPERM 修复 + C2R20 的 off-by-one fix 现在由 helper 单一负责。
await retryOnWindowsTransient(
() => fs.rename(tmp, target),
() => fs.unlink(tmp),
);
// audit 2026-10-04:rename 之后 fsync 父目录,让 rename 本身也持久化。
// 见 fsyncParentDir 头注 —— atomic ≠ durability。
await fsyncParentDir(target);
}
/**
* 原子写的 tmp 命名约定 —— **两条写路径(safe-write / atomic-file)与本文件
* 的残留清扫器共用同一份**,否则清扫器会扫不到自己该该扫的东西。
*
* 形态:`<target>.<RKMS_TMP_MARKER><8 字符 base64url>.tmp`
* (`randomBytes(6)` → base64url 恰好 8 字符)。
*
* audit 2026-10-05:MEDIUM —— 中间那个 marker 是**必需**的,不是装饰。
* 旧形态 `<target>.<8 字符>.tmp` 只靠 `[A-Za-z0-9_-]{8}` 匹配随机段,而这个
* 字符类**没有任何用户文件不会命中**的形态:`实验记录.20250101.tmp`(8 位日期)
* 完全落在类内 → 清扫器在下次启动把它 unlink 掉,**无 quarantine、无备份、
* 无提示**。原注释写的「不会误伤用户自己命名的 .tmp 文件」是事实错误。
* 随机段与用户命名段在字符层面无法区分,只能靠一个显式标记把两类文件名分开。
*
* marker 用 `rkms-a` 而不是更长的词:它只出现在本应用写出的 tmp 里,而用户
* 自己命名时几乎不可能正好落在这个形态;同时保持文件名可读(崩溃后用户
* 在资源管理器里一眼能看出这是本应用的残留)。
*
* 兼容性:旧约定(`<target>.<8 字符>.tmp`)留下的历史残留**不再被清扫**。
* 这是有意的方向选择 —— 那个形态与用户命名不可区分,宁可不删,也不误删
* 用户的数据;这类残留只占空间,不会影响功能(见 sweepOrphanTmpFiles 头注)。
*/
export const RKMS_TMP_MARKER = 'rkms-a';
export const ATOMIC_TMP_PATTERN = new RegExp(`\\.${RKMS_TMP_MARKER}[A-Za-z0-9_-]{8}\\.tmp$`);
/**
* audit 2026-10-04:fsync 父目录 —— rename 本身的持久性。
*
* 原有实现只 `fh.sync()`(tmp 的数据块落盘)就 rename。**atomic 性与 durability
* 是两件事**:POSIX 上 rename 提交要靠目录项落盘,目录不 fsync,断电后
* rename 可能丢失 —— 现象是「保存时 UI 显示成功,重启后回到上一次的内容」,
* 比报错更难查。本项目 README 明确建议把数据目录放 OneDrive / Dropbox /
* iCloud,这些场景下用户对「保存没生效」尤其敏感。
*
* 只在 POSIX 生效(见 platform.ts 的 needsDirFsyncForRename 注释:Windows 上
* 没有这个契约,而不是「尽力而为」)。任何失败都只 warn —— 目录 fsync 失败
* 不代表内容没落盘,不该让一次成功的保存整体失败。
*/
async function fsyncParentDir(target: string): Promise<void> {
if (!needsDirFsyncForRename) return;
const dir = dirname(target);
let dirFh: Awaited<ReturnType<typeof fs.open>> | null = null;
try {
dirFh = await fs.open(dir, 'r');
await dirFh.sync();
} catch (err: unknown) {
// macOS 上对目录 fs.open('r') 可能直接 EISDIR/EPERM(某些文件系统不支持
// 目录 fsync)—— 这类平台的 rename 持久性由 FS 自身保证,不阻断写入。
log.warn('fsync parent dir failed (rename durability not guaranteed)', {
dir,
err: errMsg(err),
});
} finally {
await safeCloseFh(dirFh, 'fsync parent dir close failed', log, { path: dir });
}
}
/**
* audit 2026-10-04:清扫崩溃残留的原子写 tmp 文件。
*
* 背景:`atomicWriteFileInternal` 的流程是 open(wx) → write → fsync → close
* → rename。进程在 close 与 rename 之间被杀(系统更新 / OOM / 用户强退)时,
* `<name>.md.<rand>.tmp` 就留在用户的云盘目录里。全仓此前**没有任何清扫器**
* —— 且这些文件对用户完全不可见:`collectNotePaths` 只收 `.md` 结尾的条目,
* `.tmp` 既不会被扫描也不会被展示,永远不会自愈,反复断电会持续占盘。
*
* 安全性:只删符合 `ATOMIC_TMP_PATTERN` 的**普通文件**(symlink/目录不碰),
* 文件名形如 `<target>.<8 字符 base64url>.tmp`。刻意**不**按 mtime 加时间窗 —— 那样会
* 漏掉「刚崩完就启动」的情形,而那正是最需要清的一次。并发写期间的 tmp 呢?
* 也不会误删:另一个进程正在写的 tmp 在 rename 之前被删,那次写会失败并
* 走它自己的错误路径(重试/报错),不会产生半截目标文件 —— 目标文件本身
* 从未被这个清扫器触碰。
*
* 返回清扫数量,供调用方记日志。
*/
export async function sweepOrphanTmpFiles(dir: string): Promise<number> {
// audit-r80 (2026-10-17) LOW:递归清扫 —— 旧实现只 readdir 一层,而
// filename-validator 允许 `sub/note.md` 子目录笔记,子目录里的原子写
// 残留(崩溃时 <name>.md.<rand>.tmp)永远扫不到,反复断电持续占盘。
// 与 collectNotePaths 同约定:跳过 dot 目录、不跟 symlink(防递归环
// 与越界),只 unlink 普通文件,目录形态不匹配也绝不 rmdir。
return sweepDir(dir, 0);
}
// 与 notes watcher 同深度上限思路:子目录笔记实际深度有限,8 层兜底防
// 「非 symlink 的深嵌套」病态目录树拖慢启动。
const SWEEP_MAX_DEPTH = 8;
async function sweepDir(dir: string, depth: number): Promise<number> {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (err: unknown) {
if (errnoCode(err) !== 'ENOENT' && errnoCode(err) !== 'ENOTDIR') {
log.warn('sweepOrphanTmpFiles: readdir failed', { dir, code: errnoCode(err) });
}
return 0;
}
let removed = 0;
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const p = join(dir, entry.name);
if (entry.isDirectory()) {
if (depth < SWEEP_MAX_DEPTH) removed += await sweepDir(p, depth + 1);
continue;
}
if (!ATOMIC_TMP_PATTERN.test(entry.name)) continue;
try {
// Dirent.isFile 之外再 lstat 确认:只删普通文件,symlink/目录形态不碰。
const st = await fs.lstat(p);
if (!st.isFile()) continue;
await fs.unlink(p);
removed += 1;
} catch (err: unknown) {
if (errnoCode(err) !== 'ENOENT') {
log.warn('sweepOrphanTmpFiles: unlink failed', { p, code: errnoCode(err) });
}
}
}
if (removed > 0) log.info('Swept orphan atomic-write tmp files', { dir, removed });
return removed;
}
/**
* 2026-10-16 file-manager ops:安全回收站删除 —— 与 safeUnlink 对位,
* 走 electron shell.trashItem(Win 回收站 / macOS Trash / Linux XDG trash),
* 用户可从系统回收站恢复。
*
* 契约与 safeUnlink 对齐:
* - 路径必须解析到 rootPath 内(resolveInsideDataPath,外逃 → UNSAFE_WRITE)
* - 文件已不存在(ENOENT)当作成功吞掉 —— 外部 watcher / 用户 / 其他窗口
* 已删时 IPC 不应失败
* - shell.trashItem 自身失败(平台回收站不可用 / 权限)包成 AppError
* IO_ERROR 冒泡,不静默
*/
export async function safeTrash(target: string): Promise<void> {
const resolved = resolveInsideDataPath(target);
let lst: Awaited<ReturnType<typeof fs.lstat>>;
try {
lst = await fs.lstat(resolved);
} catch (err: unknown) {
if (errnoCode(err) === 'ENOENT') return;
throw err;
}
// audit-r151:拒收 symlink —— shell.trashItem 在 Windows 上 follow symlink,
// resolveInsideDataPath 只校验字符串 + 父目录 realpath,遇到 planted
// symlink(dataPath/notes/foo → C:\Users\<user>\.ssh\id_rsa)会让用户
// 点删除时把 dataPath 外的文件移进回收站。lstat 不 follow,这里拒绝
// symlink + 目录(目录走独立删除路径,不属于本 helper 契约)。
// audit-r155:拆错误码 —— symlink 与目录语义不同,合并抛 UNSAFE_WRITE
// 会让 UI 看到「unsafe write」误导用户(实际是「目录不能走 trash 入口」)。
// symlink 才是越界风险,UNSAFE_WRITE 合适;目录仅是 helper 契约不接,
// VALIDATION(可被 wrap.ts 透传 field=null)更准。
// audit-r160:消息不再含 ${resolved} —— UNSAFE_WRITE / VALIDATION 与 IO_ERROR
// 一样会被 wrap.ts 原样透传到 IPC wire,绝对路径会把用户家目录 / OneDrive
// 路径暴露给 renderer / telemetry。改走 tr() 双语固定文案,errMsg(err) 留作
// 主进程 log(IPC 边界外的诊断信号,不进 wire)。
if (lst.isSymbolicLink()) {
throw new AppError('UNSAFE_WRITE', tr('notes.trashRefusedSymlink'));
}
if (!lst.isFile()) {
throw new AppError('VALIDATION', tr('notes.trashRefusedNonRegular'));
}
try {
await shell.trashItem(resolved);
} catch (err: unknown) {
// audit-r137:不把 resolved 绝对路径拼到 message —— wrap.ts 非 INTERNAL 分支
// 原样透传 IPC wire。r160 进一步走 tr() 双语,避免裸英文 `trash failed:` 前缀。
throw new AppError('IO_ERROR', tr('notes.trashFailed', { reason: errMsg(err) }));
}
}
export async function safeUnlink(target: string): Promise<void> {
// 必须用 resolveInsideDataPath 已经解析的绝对路径删除 —— 不再次 resolve,
// 否则相对路径会基于 process.cwd() 解析,绕过 dataPath 校验
const resolved = resolveInsideDataPath(target);
try {
await fs.unlink(resolved);
} catch (err: unknown) {
if (errnoCode(err) !== 'ENOENT') throw err;
}
}
// r161+ disk-folder —— 真实磁盘文件夹操作,fs 路径必须在 rootPath/<kind>/ 子树内
// (走 resolveInsideDataPath 二次 realpath 越界 reject)。symlink 一律不 follow
// (lstat 拒收 + safeTrash/safeRecursiveTrash 不 follow 走 trash),与 r155 safeTrash
// 范式一致,防止 planted symlink 把回收站外文件挪走。
/**
* 安全创建目录(磁盘文件夹)——「新建文件夹」语义。
* target 是绝对路径或相对 rootPath 的路径,自动 resolveInsideDataPath 守门。
* 行为:
* - 路径不存在 → fs.mkdir(..., { recursive: true }) 一次建父 + 自身
* - 路径存在且是目录 → no-op 幂等
* - 路径存在但不是目录 / 是 symlink → 抛 VALIDATION(与 caller 预期不一致)
*/
export async function safeMkdir(target: string): Promise<void> {
const resolved = resolveInsideDataPath(target);
let lst: Awaited<ReturnType<typeof fs.lstat>>;
try {
lst = await fs.lstat(resolved);
} catch (err: unknown) {
if (errnoCode(err) === 'ENOENT') {
await fs.mkdir(resolved, { recursive: true });
return;
}
throw err;
}
if (lst.isSymbolicLink()) {
// 与 safeTrash 范式一致:不 follow symlink,防止 planted symlink 越界
throw new AppError('UNSAFE_WRITE', tr('diskFolder.mkdirRefusedSymlink'));
}
if (!lst.isDirectory()) {
throw new AppError('VALIDATION', tr('diskFolder.mkdirRefusedNonDir'));
}
// 已存在的目录:幂等 no-op
}
/**
* 安全递归删除目录走系统回收站 ——「删除文件夹」语义。
* target 是绝对路径或相对 rootPath 的路径,自动 resolveInsideDataPath 守门。
* 行为:
* - 路径不存在(ENOENT) → no-op 成功(并发 watcher / 用户已删时 IPC 不该失败)
* - symlink → UNSAFE_WRITE(越界风险,单点拒绝)
* - 非目录文件 → VALIDATION(caller 错把它当文件夹丢)
* - 目录 → DFS 走深:每个文件走 safeTrash(走 lstat 拒收 symlink),每个
* 子目录递归 trashDirContents + shell.trashItem 兜底(避免 shell.trashItem
* 跨平台差异吞子项失败)
*/
export async function safeRecursiveTrash(target: string): Promise<void> {
const resolved = resolveInsideDataPath(target);
let lst: Awaited<ReturnType<typeof fs.lstat>>;
try {
lst = await fs.lstat(resolved);
} catch (err: unknown) {
if (errnoCode(err) === 'ENOENT') return;
throw err;
}
if (lst.isSymbolicLink()) {
throw new AppError('UNSAFE_WRITE', tr('diskFolder.trashRefusedSymlink'));
}
if (!lst.isDirectory()) {
throw new AppError('VALIDATION', tr('diskFolder.trashRefusedNonDir'));
}
await trashDirContents(resolved);
try {
await shell.trashItem(resolved);
} catch (err: unknown) {
throw new AppError('IO_ERROR', tr('diskFolder.trashFailed', { reason: errMsg(err) }));
}
}
async function trashDirContents(dir: string): Promise<void> {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (err: unknown) {
if (errnoCode(err) !== 'ENOENT') throw err;
return;
}
for (const entry of entries) {
const child = join(dir, entry.name);
if (entry.isSymbolicLink()) {
// audit-r243(2026-10-21):**不能**对 symlink 调 shell.trashItem ——
// shell.trashItem 在 Windows 上 **follow** symlink(见 safeTrash 头注
// r151:planted symlink 会把 dataPath 外的文件移进回收站)。旧实现
// 这里的注释「lstat 已经挡过 planted 越界」是**错的**:上面 lstat
// 的是**父目录**,子项 symlink 从没被 lstat 过,越界风险原样存在。
// 实证后果:用户在 literature/links/ 放一个指向家目录某个 PDF 的
// symlink,再「删除文件夹 links/」⇒ **外部那个 PDF 被移进回收站**。
// 正确做法:**删链接本身**而不是删它的目标 ——
// fs.unlink / fs.rm 对 symlink 路径都只摘链接,不 follow(unlink
// 语义上就是「删目录项」)。
// 链接指向的目标留在原处(用户没要求删它,也不该被顺手删)。
// 用 fs.rm 而不是 fs.unlink:Windows 上「指向目录的 symlink」用
// unlink 会 EPERM(得用 rmdir),fs.rm 内部按 lstat 判定 ⇒ 两种
// symlink 都只摘链接本身。
try {
await fs.rm(child, { force: true, recursive: true });
} catch (err: unknown) {
if (errnoCode(err) === 'ENOENT') continue;
throw new AppError('IO_ERROR', tr('diskFolder.trashFailed', { reason: errMsg(err) }));
}
continue;
}
if (entry.isDirectory()) {
await trashDirContents(child);
try {
await shell.trashItem(child);
} catch (err: unknown) {
throw new AppError('IO_ERROR', tr('diskFolder.trashFailed', { reason: errMsg(err) }));
}
} else {
// 单文件走 safeTrash(走 lstat + symlink reject + 回收站)
await safeTrash(child);
}
}
}
/**
* kind(逻辑域)→ 磁盘目录名(物理布局)。
* 'note' → 'notes'
* 'literature' → 'literature'
* r174:这是「笔记侧栏不显示文件夹」的唯一根因 —— 以前 listSubdirs 拿 kind
* 当目录名直接 join,notes 域读的是 <root>/note(不存在),readdir ENOENT
* 被当「空目录」静默吞掉,UI 侧一个文件夹行都没有,且没有任何报错。
*/
const KIND_DIR_NAME: Record<'note' | 'literature', string> = {
note: WORKSPACE_DATA_LAYOUT.NOTES_DIR,
literature: WORKSPACE_DATA_LAYOUT.LITERATURE_DIR,
};
/**
* 列某 parentRelPath 的直接子目录(一层,非递归)。dot 隐藏 + symlink +
* 非目录全部跳过;按名字自然序升序。返回 relPath(相对 kind 根)而非
* basename,renderer 端直接用于 enter() / 拖入目标 key。
* parentRelPath = '' 或 null = kind 树根。
* 路径必须在 dataPath 内(主进程 callers 传的是绝对根 + 相对子路径,
* 这里用 join,不做 resolveInsideDataPath 二次 realpath —— 子目录都
* 是 chokidar / IPC 入口拿到的合法磁盘路径,中间层不需再越界检查)。
*/
export async function listSubdirs(
rootPath: string,
kind: 'note' | 'literature',
parentRelPath: string | null,
): Promise<DiskFolderNode[]> {
// r174:目录名走 KIND_DIR_NAME —— 逻辑 kind('note')≠ 物理目录('notes')。
const dirName = KIND_DIR_NAME[kind];
const target = parentRelPath
? join(rootPath, dirName, parentRelPath)
: join(rootPath, dirName);
let entries;
try {
entries = await fs.readdir(target, { withFileTypes: true });
} catch (err: unknown) {
if (errnoCode(err) === 'ENOENT') {
// r174:目录不存在 → 空列表(合法:kind 根可能还没建)。
// 但「目录名写错」也是 ENOENT —— r174 之前 notes 域去读 <root>/note
// 就是这样静默返 [],UI 一个文件夹都不显示且零报错。留一条 debug
// 日志,下次再出现目录名漂移时能从 main.log 看出来(生产 LOG_LEVEL
// 默认 info,不会打扰用户)。
log.debug('listSubdirs: directory not found', { kind, target, parentRelPath });
return [];
}
throw err;
}
const out: DiskFolderNode[] = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
if (entry.isSymbolicLink()) continue;
if (!entry.isDirectory()) continue;
// r186 不变量 I2「列得出来的,一定能进得去」:目录名必须满足
// shared/rel-path 的核心段规则。否则 list-dirs 的 requireRelPath 会
// 拒收它 → 用户点进去只得到一个 VALIDATION 报错,而里面的笔记在
// 夹视图里看不到(列得出来、进不去)。判据与路径校验共用同一份
// 规则,两边永远不会分叉。
// Windows 上除 dot 目录外不存在这种名字(不能含 '\'、控制字符),
// 对正常用户零影响;Linux 上的极端目录名从「点进去报错」变成
// 「不列」—— 与 dot 目录同一处理方式。
if (!isValidRelPathSegment(entry.name)) continue;
const childRel = parentRelPath ? `${parentRelPath}/${entry.name}` : entry.name;
out.push({
relPath: childRel,
name: entry.name,
parentRelPath,
});
}
// 名字自然序升序(numeric + base sensitivity 与 folder-view 同款,
// 共享语义:VS Code 文件管理列表排序)
out.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
return out;
}
/**
* 安全 rename:源和目标都必须在 dataPath 内。
* 用途:settings/migration-layout.ts 在启动迁移期把 dataPath 内的老布局
* (notes/ workspace/) 一次性搬到 dataPath/data/ 下。
* audit 2026-09 (V1.6):头注原写 "notes/ sidecars/ ai/ collections/
* workspace/" —— sidecars / ai / collections 三个目录 2026-08-28
* 整体下线,不在迁移清单中。trash module 2026-08-26 下线,只剩
* migration 一处 caller。
* EXDEV 等跨设备错误向上抛,fs.rename 跨平台行为一致。
*/
export async function safeRename(source: string, target: string): Promise<void> {
const src = resolveInsideDataPath(source);
const dst = resolveInsideDataPath(target);
// audit r35 (2026-10-08):MEDIUM — src === dst 早返。旧实现无脑 fs.rename,
// 看似无害(同路径 rename 在 POSIX 下 no-op),实则在 Windows 上若中途路径
// 被替换为 symlink(典型场景:夹在 resolveInsideDataPath 与 fs.rename 之间的
// 几个 await 给了 attacker / 恶意插件 TOCTOU 窗口),rename 会真的动到
// symlink 目标文件,且部分 Windows 版本会报 ERROR_ALREADY_EXISTS 并被
// retryOnWindowsTransient 误判为瞬时占用、反复重试。早返后这段 TOCTOU
// 路径整段消失,且与「重命名到自身」的语义意图一致(就是 no-op)。
if (src === dst) return;
await fs.mkdir(dirname(dst), { recursive: true });
// audit 2026-10-01:MEDIUM — 补退避重试。两条原子写路径
// (atomicWriteFileInternal / atomic-file.persist)都走 retryOnWindowsTransient,
// 唯独这里曾是裸 fs.rename。启动期目录 rename 撞上 Defender / Windows
// Search / 云盘同步客户端的瞬时占用(EBUSY/EPERM)是高频场景,而调用方
// (settings/migration-layout.ts)把失败降级成 warn + 继续启动 —— 结果是
// 「源目录没搬过去,应用却已按新布局读空目录」,用户看起来就是笔记凭空消失。
// cleanup 传 noop:目录 rename 失败时**绝不能**删源(那是唯一的用户数据)。
await retryOnWindowsTransient(
() => fs.rename(src, dst),
() => Promise.resolve(),
);
}
/**
* 兜底 close 文件句柄 + 失败时 warn 一行日志。
*
* audit round 8 (2026-09-14):替换全仓 11 处 verbatim 的 `fh?.close().catch((err) =>
* log.warn('fh.close failed in <label>', { path, err: errMsg(err) }))` 模板 ———
* 11 处散落在 notes / ipc / settings / utils,每个 label + meta 字段都不一样,
* 任何一处漂移(忘了加 tmp / 改了 label 拼写)只能 grep 兜底。集中后:
* - close 失败的统一日志形态:{ label, path?, tmp?, err }
* - caller 只关心 label,不再复制 errMsg + meta boilerplate
* - safe-write.ts 自身 2 处内部 close 也走这个 helper,行为与外部一致
*
* 不做 fail-fast:close 失败在 finally / 错误回滚路径里通常是次要信号
* (主 err 已经 throw 或要 throw),只 warn 不 throw,避免 swallow 主 err。
*/
export async function safeCloseFh(
fh: Awaited<ReturnType<typeof fs.open>> | null,
label: string,
localLog: { warn: (msg: string, meta?: Record<string, unknown>) => void },
extra?: { path?: string; tmp?: string },
): Promise<void> {
if (!fh) return;
await fh
.close()
.catch((closeErr: unknown) => localLog.warn(label, { ...extra, err: errMsg(closeErr) }));
}

View File

@@ -0,0 +1,86 @@
// src/main/utils/scrub-internal.ts
//
// audit r52 (2026-10-15) C3:把 INTERNAL 错误消息里的用户路径替换为占位符,
// 防止 IPC wire 把家目录暴露到 renderer(以及任何 telemetry / Sentry 抓取
// IPC 错误日志的渠道)。
//
// 用法:仅在 `IpcErrorCode.code === 'INTERNAL'` 分支调用 —— AppError 已知
// code(DUPLICATE_NAME / VALIDATION / NOT_FOUND 等)的消息一般不包含路径,
// 直接透传保留上下文。INTERNAL 分支的消息来源是 `errMsg(err)`(原始 Error
// .message 或 String(err)),可能含「failed at C:\Users\foo\ResearchHub\...」
// 这类全路径,进 IPC 后被结构化日志全量记录,家目录泄露。
//
// 设计:本文件独立成 module(不放在 utils/error.ts)以避开循环依赖 ——
// utils/error.ts 是被 safe-write 依赖的基础层,反过来 error.ts 不能 import
// safe-write(否则 cycle)。把 scrubInternal 拆到独立文件,正常
// `import { getRootPath } from './safe-write'`,import 链 error →
// scrub-internal → safe-write → error 不成环(vitest mock 也能正常拦到
// safe-write 替换 getRootPath)。
//
// 性能:split+join 走 V8 内部 O(n) 字符串扫描,500 字符以内实测 < 10µs,
// IPC 错误路径非热路径,无 benchmark 意义。
//
// 测试:tests/unit/main/utils/scrub-internal.test.ts。
import { getRootPath } from './safe-write';
import { isCaseInsensitiveFs } from './platform';
/**
* 把 INTERNAL 错误消息里的用户路径替换为 `<rootPath>`,并截断到 500 字符。
*
* - 替换 getRootPath() 全路径为占位符。getLibraryPath() 在 ADR-0003 后已下线,
* library 路径都从 getRootPath() 派,所以单点替换 root 即覆盖所有用户路径。
* - 启动期 getRootPath() 未 init 时抛 —— try/catch 吞,scrub 不阻断。
* - 截 500 字符:防止长 stack trace 全进 IPC(单条消息 5MB 炸 IPC buffer)。
* - rootPath 路径前缀不重叠:`C:\Users\foo\ResearchHubX` 不能被误判为
* `C:\Users\foo\ResearchHub`。要求匹配后**必须是** path separator
* (Windows `\\` 或 POSIX `/`)或字符串末尾 —— 否则不动。子目录
* `ResearchHub\library\x.pdf` 命中(后跟 `\`),同前缀
* `ResearchHubX\y` 不命中(后跟 `X`,非 separator)。
* - audit-r87:case-insensitive FS(win32/darwin)上大小写不敏感匹配。
* fs 错误消息原样回显**调用参数**,IPC 参数大小写由外部决定(用户粘贴
* 路径 / 旧配置迁移 / shell 返回),与 settings 存的 root 字符串可不一致;
* 同目录的 2^n 种大小写表示都要脱敏。与 path-compare「大小写差异 =
* 同一目录」不变式对齐。POSIX 保持区分大小写(变体即不同路径)。
*/
export function scrubInternal(message: string): string {
let root: string | null = null;
try {
root = getRootPath();
} catch {
root = null;
}
let out = message;
// audit-r124:root 以分隔符结尾时(实测盘根配置下 getRootPath 可返回
// 'C:\' 形态;任何 root+尾分隔符同理),旧 pattern
// `root(?=[\\/]|$)` 把 root 尾分隔符消费后再要求 lookahead 命中
// separator —— 'C:\Users\…' 里 'C:\' 之后的 'U' 不满足,整条消息
// 一个字节都不脱敏,且静默。规约尾部分隔符后拼 lookahead 即正确:
// 'C:' + (?=[\\/]) 对 'C:\Users\…' 命中,盘内全部路径归入 <rootPath>。
// 规约后长度 < 2 仅剩 POSIX '/' 根(整盘皆内部,替换会吞所有绝对路径
// 前缀)→ 保守跳过,记档于此。
const rootCmp = root ? root.replace(/[\\/]+$/, '') : '';
if (rootCmp.length >= 2) {
// 全局替换 root 后必须是 separator 或 end-of-string:
// ResearchHub\library\x.pdf → <rootPath>\library\x.pdf (✅ \ 跟后面)
// ResearchHubX\y → ResearchHubX\y (✅ X 非 separator)
// ...ResearchHub... → ...<rootPath>... (✅ end-of-string 在前)
// 用 lookahead 而不是 consumed 字符,避免误吞 separator。
// audit-r87:case-insensitive FS 上同目录有 2^n 种大小写表示,且 INTERNAL
// 消息里的路径 = fs 原样回显**调用参数**(IPC 参数大小写由外部决定:
// 用户粘贴路径 / 旧配置 / shell 返回),与 settings 存的 root 字符串
// 大小写可不一致 → 区分大小写的 regex 会整条漏脱敏。'i' 只在
// isCaseInsensitiveFs(win32/darwin)追加,POSIX 保持区分(变体=不同路径);
// 与 path-compare「大小写差异 = 同一目录」的跨模块不变式对齐。
const flags = isCaseInsensitiveFs ? 'gi' : 'g';
out = out.replace(new RegExp(escapeRegExp(rootCmp) + '(?=[\\\\/]|$)', flags), '<rootPath>');
}
return out.length > 500 ? out.slice(0, 500) + '…' : out;
}
/**
* 转义 RegExp 元字符,让 root 字符串(可能含 `\`,`.`,`$` 等)能直接做
* 匹配 pattern 源。
*/
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View File

@@ -0,0 +1,121 @@
// 自写回声(self-write echo)抑制 —— 主进程侧。
//
// perf r239(2026-10-21):notes 域的 autosave 每 1.5s 落一次盘
// (WORKSPACE_LIMITS.NOTES_AUTOSAVE_DEBOUNCE_MS),落盘 → chokidar
// 'change' → scheduleReload(100ms 去抖)→ **全量重扫**(scanNotes 把每个
// .md 都 open+read+gray-matter 解析一遍)。
//
// 实测(本机,8KB/篇):
// 500 篇 → 全量重扫 124ms;2000 篇 → 410ms。
// 而触发条件只是「用户停下来打字 1.5 秒」。主进程被占住 ⇒ 整个应用
// 卡顿(不只是列表)。
//
// 而这次重扫是**纯浪费**:svc.write() 自己已经做完三件事 ——
// 1. byId.set(updated) —— 内存索引已是最新
// 2. syncResource(updated) —— resources 表 + FTS 已是最新
// 3. emit('changed') —— renderer 已收到广播
// watcher 的回声只是把同样的状态**重新从磁盘读一遍**。
//
// 判据 = **文件指纹**(mtime + size + ino),不是「时间窗口」。
//
// 为什么不用时间窗口(曾经的实现):窗口内发生的**外部**改动会被一起
// 吞掉 —— 用户在 app 里 autosave 之后 1 秒内又用 Obsidian / 同步盘改了
// 同一个文件,那次改动永远不可见(直到下一次外部改动或重启)。
// 指纹判据没有这个洞:回声事件到达时现 stat 一次,与「我们写完时记下的
// 指纹」比对 —— 相同 ⇒ 文件还是我们写的那份 ⇒ 纯回声,抑制;不同 ⇒
// 文件被别人动过 ⇒ 照常重扫。
//
// 已知盲区(与全项目其它双因子判据同源,见 notes 的 DB 同步 r220 /
// utils/fs-cache 的 readNote 缓存):「内容变了但 mtime 与 size 都一样」
// (备份还原 / cp -p / 同步盘按内容去重,且长度相同)判成「没变」。
// 要兜只能全文 hash,代价是每次事件都把文件读回来 —— 正是本模块要
// 消掉的成本。
//
// 契约:
// - mark(absPath, fp):「本进程刚写过这个文件,写完时它的指纹是 fp」。
// - isSelfEcho(absPath, fp):「现在这个文件的指纹是 fp,是不是我自己
// 写出来的回声?」—— 命中即抑制。
// - 记录**不被消费**:同一份指纹要能反复命中(应用连续 autosave 时
// 指纹会被新的写入覆盖,不会互相干扰)。TTL 只用于不让 map 无限增长。
//
// 为什么放在 utils/ 而不是各 service 内联:notes 与 literature 两个域是
// 同一款机制的两次实例化(两边都有 watcher → 去抖 → 全量 reload 的骨架),
// 判据「是不是我自己写的」必须同源,否则两边漂移出一个「只有一边生效」
// 的窗口,排查时极难看出。
//
// 为什么不用 renderer 那套 SELF_WRITE_SUPPRESS_WINDOW_MS:那是**渲染进程**
// 的「别因为自己写的广播去重拉列表」,管的是 IPC 往返;这里是**主进程**
// 的「别因为自己写的回声去重扫磁盘」,管的是磁盘 IO。两处窗口各自
// 独立取值,不要互相「统一」。
import { normalizePathForCompare } from './path-compare';
/** 文件指纹 —— 判定「还是不是同一份文件内容」。 */
export interface FileFingerprint {
/** 修改时间(ms,含亚毫秒小数)。 */
mtimeMs: number;
/** 字节数。 */
size: number;
/** inode 号(Windows = file index;拿不到时为 0,退化成 mtime+size)。 */
ino: number;
}
export function fileFingerprint(st: {
mtimeMs: number;
size: number;
ino: number;
}): FileFingerprint {
return { mtimeMs: st.mtimeMs, size: st.size, ino: st.ino };
}
/** 两个指纹是否指同一份内容。 */
export function sameFingerprint(a: FileFingerprint, b: FileFingerprint): boolean {
return a.mtimeMs === b.mtimeMs && a.size === b.size && a.ino === b.ino;
}
export class SelfWriteEchoSuppressor {
/** 归一化后的路径 → { 写完时的指纹, 打点时刻 } */
private marks = new Map<string, { fp: FileFingerprint; at: number }>();
/**
* @param ttlMs 打点有效期。回声事件的延迟上界(平台事件延迟 + 机器繁忙)
* 通常在几百 ms 内,取 30s 是留足余量又不让 map 长期攥着旧记录。
*/
constructor(private readonly ttlMs: number) {}
/** 打点:本进程刚写过这个文件,写完时它的指纹是 fp。 */
mark(absPath: string, fp: FileFingerprint, now: number = Date.now()): void {
this.prune(now);
this.marks.set(normalizePathForCompare(absPath), { fp, at: now });
}
/**
* 这个 watcher 事件是不是我自己写出来的回声?
* 命中 ⇒ true(该抑制重扫);未命中 / 指纹不同 / 记录过期 ⇒ false。
*/
isSelfEcho(absPath: string, fp: FileFingerprint, now: number = Date.now()): boolean {
const key = normalizePathForCompare(absPath);
const rec = this.marks.get(key);
if (rec === undefined) return false;
if (now - rec.at > this.ttlMs) {
// 记录过期:平台丢事件 / 磁盘卡顿时回声迟到太久 ⇒ 宁可多重扫,
// 不可漏扫(外部改动必须可见)。
this.marks.delete(key);
return false;
}
return sameFingerprint(rec.fp, fp);
}
/** 当前有多少条未过期的打点(诊断用)。 */
get size(): number {
return this.marks.size;
}
/** 清掉过期打点 —— map 天然很小(只有「刚写过」的文件在里面),
* 但防「某个路径的事件永远不来 → 打点永远留着」的慢性泄漏。 */
private prune(now: number): void {
if (this.marks.size === 0) return;
for (const [key, rec] of this.marks) {
if (now - rec.at > this.ttlMs) this.marks.delete(key);
}
}
}

View File

@@ -0,0 +1,17 @@
// 短随机 ID 生成器 — workspace 模块 id 来源
//
// 用 base64url 编码 6 字节随机数(48 bit),得到 8 字符 URL-safe 字符串。
// 碰撞概率:6 字节生日攻击 50% 概率 ≈ 2^24 ≈ 16M 个 id
// 对 favorites 等"单用户、几千条"场景绰绰有余(现存唯一 caller:
// src/main/workspace/favorites.ts 给 group / file entry 派生 id)。
//
// audit 2026-09-08:头注原写 "trash / tasks / recent" —— 三个模块整体
// 下线,实际只剩 favorites。
//
// 不要再用 hex(碰撞概率翻倍)、uuid(太长、太显眼)、Math.random()(非 CSPRNG)
import { randomBytes } from 'crypto';
/** 生成 8 字符 [A-Za-z0-9_-]+ 短 ID */
export function newShortId(): string {
return randomBytes(6).toString('base64url');
}

239
src/main/utils/ssrf.ts Normal file
View File

@@ -0,0 +1,239 @@
// SSRF 防护工具:拒绝指向私有/loopback/链路本地/ULA/CGNAT/multicast 地址的出站连接
//
// audit 2026-09 (V1.9):AI 模块 2026-09 V1.6 整体下线后,
// isPrivateIpLiteral / isPrivateUrl 在 src/main/ 内仅 **1 个生产 caller**
// (src/main/window.ts:isPrivateHost 用于 setWindowOpenHandler 的同源 SSRF
// 防御,sync 路径只查 hostname 字面 IP 不解析,async DNS 留给未来 outbound
// HTTP 场景)—— `isPrivateUrl`(async,DNS 解析)当前 0 caller。
// 不删除 —— 本文件是防御性 SSRF 模块,删除后未来需要 outbound HTTP
// 时(导出 / 抓取 / RSS / Web Clipper / URL preview)还要重新发明
// 一次,且容易漏 IPv6/IDN/rebinding 边界条件。代价:本模块源码 +
// 1 个 dns mock 的测试启动开销(audit-r103:原写「195 行源」实测已 237
// 行 —— 行数随边界补丁增长必漂,数字删除,同 r101 行号锚口径)。
// 若决定彻底删除:同步删 src/main/utils/ssrf.ts + tests/unit/main/utils/ssrf.test.ts
// (含其自带的 `vi.mock('dns')` 块 —— dns mock 在**测试文件内**,不在
// tests/setup.ts;audit-r103 写的"tests/setup.ts 的 vi.mock('dns') 块"
// 已随 setup.ts 精简(只留 `export {}`)而消失,行号锚一并作废)。
//
// 设计要点:
// 1. 同时拒绝 IPv4 字面量、IPv6 字面量、IPv4-mapped IPv6(::ffff:0:0/96)
// — audit-r85:mapped 按数值形态识别(高 96 位 === 0xffff),不依赖
// `::ffff:` 文本前缀;full-form `0:0:0:0:0:ffff:x:y` 同样还原成 IPv4 判。
// URL 解析器对 caller 传入 hostname 的规范化不变式由 ssrf.test.ts 锁。
// 2. hostname 通过 dns.lookup 解析后,任一条 A/AAAA 落在禁止范围就拒
// (防 hostname 字面合法但解析到内部 IP)
// 3. 同步入口返回 boolean,异步入口 resolve host 后返回 boolean
//
// 与 isPrivateHost(ai.ts,已下线)的区别:
// - 老实现只查字面字符串,绕过 IPv6/IDN
// - 本文件覆盖 RFC1918、loopback、link-local(169.254/fe80::/10)、
// ULA(fc00::/7)、CGNAT(100.64/10)、multicast、reserved(::/128)
// - 对 IDN:不阻止 IDN 本身(用户可能需要),但要求 hostname 不含非 ASCII
// (Punycode 形式 xn-- 是 ASCII,可放行)
//
// 已知残余风险(audit round 18 文档化):
// - DNS rebinding 窗口:dns.lookup 返回的 IP 与 fetch() 实际连接的 IP 之间
// 有时间窗口,0-TTL 攻击者控制的 DNS 可在第二次解析时返回私有 IP。本文件
// 提供的 URL-层校验不能完全闭合这个窗口。
// - 历史缓解(AI 模块 V1.6 已下线,以下条目仅作"曾经的防御深度"记录):
// (a) ai.ts 每请求 validateProvider(被 SSRF filter 包含)后立刻发起
// fetch,窗口极小;
// (b) client.ts 用 `redirect: 'error'` 关闭 redirect-via-302-bypass;
// (c) 用户场景是 AI baseURL(用户自配),不是从外部输入读 URL,攻击面
// 较窄。
// - 完全防御需在 undici Agent 层 `connect.lookup` 重新校验 connect 时刻
// 实际地址,工作量大,留作 follow-up。
//
// 与上层协作:
// - 本文件是"URL 字符串层"防御。任何未来 outbound HTTP caller(导云 /
// 抓取 / RSS / Web Clipper)都应在 fetch 之前先调 isPrivateUrl。
import { lookup } from 'dns';
import { isIP } from 'net';
// IPv4 私有/保留段(位掩码)
const IPV4_PRIVATE_RANGES: Array<[number, number]> = [
[0x00000000, 0xff000000], // 0.0.0.0/8 — "this network"
[0x0a000000, 0xff000000], // 10.0.0.0/8 — RFC1918 private
[0x7f000000, 0xff000000], // 127.0.0.0/8 — loopback (整个 /8 都是 RFC1122 loopback)
[0x64400000, 0xffc00000], // 100.64.0.0/10 — CGNAT
[0xa9fe0000, 0xffff0000], // 169.254.0.0/16 — link-local
[0xac100000, 0xfff00000], // 172.16.0.0/12 — RFC1918 private
[0xc0a80000, 0xffff0000], // 192.168.0.0/16 — RFC1918 private
[0xe0000000, 0xf0000000], // 224.0.0.0/4 — multicast
[0xf0000000, 0xf0000000], // 240.0.0.0/4 — reserved
];
// IPv6 私有/保留段(位掩码,BigInt)
//
// audit round 18 (2026-08-27):每个 mask 字面量必须是 128 位(32 hex digits),
// 因为 ipv6ToBigInt 返回 128-bit BigInt,旧版本用 64-bit mask 只掩低 64 位,
// 与 128-bit base 比较 → fe80::abcd / fc00::dead:beef / ff02::abcd 等
// "最后 64-bit ≠ 1" 的私有地址全部漏判。同时 2606:4700:4700::0 这种公开地址
// 低 64 位是 0,会和某些 range 误匹配 — 现在所有 literal 都按 128-bit 写,
// 一致地按掩码截取后比较。
const IPV6_PRIVATE_RANGES: Array<[bigint, bigint]> = [
[0n, (1n << 128n) - 1n], // ::/128 — unspecified
[1n, (1n << 128n) - 1n], // ::1/128 — loopback
[0xfe800000000000000000000000000000n, 0xffc00000000000000000000000000000n], // fe80::/10 — link-local
[0xfc000000000000000000000000000000n, 0xfe000000000000000000000000000000n], // fc00::/7 — ULA
[0xff000000000000000000000000000000n, 0xff000000000000000000000000000000n], // ff00::/8 — multicast
// 2001:db8::/32 (RFC3849 documentation) 不在拦截名单 —— 测试
// window-edge.test.ts:177 期望它通过,作为"不会被任何拦截规则命中"的
// 对照组。该前缀保留作公网示例,实际不会出现在用户输入里。
// audit round 9 (2026-09-15):补 6to4 (2002::/16) 和 NAT64 (64:ff9b::/96)。
// 原 window.ts 内联 isPrivateHost 走 startsWith 字符串前缀挡了这两个
// 范围,refactor 到 ssrf.ts 共享位掩码时漏掉 — window-edge.test.ts 3 条
// 用例立刻 fail。加回后,本模块成为 SSRF 唯一来源,window.ts 直接复用。
// - 6to4 (2002::/16):RFC3056 transition,实际映射到 IPv4 公网,但客户端
// 浏览器/Node 一般不会直接打这种地址,且攻击者可构造 2002::a.b.c.d
// 指向内网 IPv4。沙箱式拦截减少攻击面。
// - NAT64 (64:ff9b::/96):RFC6052,用于 IPv6-only 网络访问 IPv4,常见于
// 运营商/企业内部。明确属于"内部网络",必须拦截。
[0x20020000000000000000000000000000n, 0xffff0000000000000000000000000000n], // 2002::/16 — 6to4
// NAT64 /96:mask 是 96 位 = 24 hex digits of 1。
// 注:不能用 0xffffffff000000000000000000000000n(32 位 mask),那样会
// 把整个 64:0000::/4 都匹配上,误伤公开 IPv6。
[0x0064ff9b000000000000000000000000n, 0xffffffffffffffffffffffff00000000n], // 64:ff9b::/96 — NAT64
];
function ipv4ToInt(s: string): number | null {
const parts = s.split('.');
if (parts.length !== 4) return null;
let n = 0;
for (const p of parts) {
if (!/^\d+$/.test(p)) return null;
const v = Number(p);
if (v < 0 || v > 255) return null;
// 拒绝前导零(如 "01"),防止一些解析器的歧义
if (p.length > 1 && p.startsWith('0')) return null;
n = n * 256 + v;
}
return n >>> 0;
}
function ipv6ToBigInt(s: string): bigint | null {
// 支持 :: 简写、双冒号只能出现一次、各段 0-ffff
const lower = s.toLowerCase();
if (lower.includes(':::')) return null;
const dblColon = lower.split('::');
if (dblColon.length > 2) return null;
const head = dblColon[0] ? dblColon[0].split(':') : [];
let tail = dblColon[1] !== undefined ? (dblColon[1] ? dblColon[1].split(':') : []) : null;
if (dblColon.length === 1) tail = null; // 没有 ::,head 应该是完整 8 段
if (head.length + (tail?.length ?? 0) > 8) return null;
const fill = 8 - head.length - (tail?.length ?? 0);
const middle = Array(fill).fill(0);
const segments = tail === null ? [...head] : [...head, ...middle, ...tail];
if (segments.length !== 8) return null;
let n = 0n;
for (const seg of segments) {
if (!/^[0-9a-f]{1,4}$/.test(seg)) return null;
n = (n << 16n) | BigInt(parseInt(seg, 16));
}
return n;
}
/** 把 IPv4-mapped IPv6(::ffff:a.b.c.d) 还原成 IPv4 字面量做检查
* 支持两种格式:
* - ::ffff:127.0.0.1 (十进制 IPv4 段,某些解析器输出)
* - ::ffff:7f00:1 (Node URL 解析器会把 IPv4-mapped 压缩成 hex 段)
*/
function stripIpv4Mapped(host: string): string {
const lower = host.toLowerCase();
const dec = lower.match(/^::ffff:([0-9.]+)$/);
if (dec) return dec[1];
const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (hex) {
const high = parseInt(hex[1], 16);
const low = parseInt(hex[2], 16);
const a = (high >> 8) & 0xff;
const b = high & 0xff;
const c = (low >> 8) & 0xff;
const d = low & 0xff;
return `${a}.${b}.${c}.${d}`;
}
return host;
}
/** 同步判断一个 IP 字面量是否私有/保留 */
export function isPrivateIpLiteral(host: string): boolean {
const stripped = stripIpv4Mapped(host);
if (isIP(stripped) === 4) {
const n = ipv4ToInt(stripped);
if (n === null) return true; // 无法解析,按危险处理
for (const [base, mask] of IPV4_PRIVATE_RANGES) {
if ((n & mask) === (base & mask)) return true;
}
return false;
}
if (isIP(stripped) === 6) {
const n = ipv6ToBigInt(stripped);
if (n === null) return true;
// audit-r85:IPv4-mapped 按**数值**识别(n 的高 96 位恰为 0xffff ⇔ ::ffff:a.b.c.d),
// 不再依赖 `::ffff:` 文本前缀。旧实现下 `0:0:0:0:0:ffff:7f00:1`(即 127.0.0.1)
// 不匹配 stripIpv4Mapped 的文本正则,又落不进任何 v6 range → 误判公开。
// 当前 caller 经 URL 解析器会先规范化,所以此条封的是**直接喂字面量**的
// 契约面(语义修正,同 r77 stat 先例:不修不存在的 bug,修错的语义)。
if (n >> 32n === 0xffffn) {
const low = Number(n & 0xffffffffn);
const v4 = `${(low >>> 24) & 0xff}.${(low >>> 16) & 0xff}.${(low >>> 8) & 0xff}.${low & 0xff}`;
return isPrivateIpLiteral(v4);
}
for (const [base, mask] of IPV6_PRIVATE_RANGES) {
if ((n & mask) === (base & mask)) return true;
}
return false;
}
// 既不是 IPv4 也不是 IPv6 字面量(可能是 hostname) — 调用方应进一步解析
return false;
}
/**
* 异步判断一个 URL 字符串是否指向私有地址
* - 拒绝非 http(s)
* - 拒绝 localhost / 任何非 ASCII 字符(IDN homograph 防御)
* - 同步先看 hostname 字面是否为 IP;若是,直接走 isPrivateIpLiteral
* - 否则 dns.lookup,任何一条 A/AAAA 落在禁止范围都拒
*/
export function isPrivateUrl(urlString: string): Promise<boolean> {
let url: URL;
try {
url = new URL(urlString);
} catch {
return Promise.resolve(true);
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return Promise.resolve(true);
}
// 去 IPv6 字面量的方括号
const host = url.hostname.replace(/^\[|\]$/g, '');
// 非 ASCII(hostname 含 Unicode)→ 拒绝,防止 IDN homograph
// Punycode 形式 xn--... 是纯 ASCII,可放行
if (/[^\x20-\x7e]/.test(host)) return Promise.resolve(true);
// 字面 IP 直接走快路径
if (isIP(host)) {
return Promise.resolve(isPrivateIpLiteral(host));
}
// hostname → DNS 解析
return new Promise<boolean>((resolve) => {
lookup(host, { all: true }, (err, addresses) => {
if (err || addresses.length === 0) {
// 解析失败:保守拒绝,但不抛(测试时常常 mock 网络)
resolve(true);
return;
}
for (const a of addresses) {
if (isPrivateIpLiteral(a.address)) {
resolve(true);
return;
}
}
resolve(false);
});
});
}

View File

@@ -0,0 +1,113 @@
// src/main/utils/watcher-release.ts
//
// r188:Windows 上「重命名 / 搬移一个**含子目录**的文件夹」必然 EPERM。
//
// 根因(本机实证,chokidar 4 + Windows 11):
// chokidar 用 fs.watch(ReadDirectoryChangesW)递归监听目录树,对**每个**
// 被监听的目录持有一个 OS 句柄。Windows 拒绝重命名一个**内部存在打开
// 句柄**的目录 ⇒ 只要待搬动的目录里还有子目录(chokidar 正监听着它),
// fs.rename 一定 EPERM。
//
// 四格对照实验(见本轮提交说明):
// 无 watcher + 扁平子结构 → rename OK
// 无 watcher + 含子目录 → rename OK
// 有 watcher + 扁平子结构 → rename OK
// 有 watcher + 含子目录 → rename **EPERM**
//
// 既有退避重试(retryOnWindowsTransient,5 次 / ~750ms)救不了:句柄是
// **长期**的(chokidar 一直在监听那个子目录),不是 Defender 那种瞬时占用。
// ⇒ 用户侧表现:侧栏右键「重命名」/「剪切→粘贴」一个含子文件夹的目录,
// 永远失败(notes 与 literature 两侧同病)。
//
// 修法:物理搬目录之前**关掉 watcher**(close 掉所有 FSWatcher ⇒ 释放
// 全部目录句柄),搬完再重建。
// 实测:close → rename → 重新 watch ⇒ rename 成功,且重建后的 watcher
// 仍能收到新位置里的文件变更(add 事件)⇒ 监听能力不丢。
//
// 为什么是「关整个 watcher」而不是 chokidar.unwatch(子树):
// chokidar v4 的 unwatch 只 close **该路径自身**的 FSWatcher
// (_closePath),**不**递归关子目录的 ⇒ 子目录句柄仍在 ⇒ 仍 EPERM。
// (实测:unwatch(sub) 之后 rename 依旧 EPERM。)
import type { FSWatcher } from 'chokidar';
import { createLogger } from './logger';
import { errMsg } from './error';
const log = createLogger('watcher-release');
/** 服务侧钩子 —— 各 service 的 watcher 生命周期不同,由调用方给。 */
export interface WatcherReleaseHooks {
/** 取当前 watcher;返回 null ⇒ 没有监听要释放,直接跑 fn。 */
get: () => FSWatcher | null;
/** 关掉之后把引用摘掉(避免 stop() 二次 close、身份判断误伤)。 */
clear: () => void;
/** 搬完之后重建 watcher(服务已 stop 时应自行跳过)。 */
restart: () => void;
}
/**
* r216:关掉 chokidar watcher,且不让「晚到的 awaitWriteFinish 轮询错误」
* 炸主进程。
*
* 根因(chokidar 4.0.3 源码实证,node_modules/chokidar/index.js):
* - awaitWriteFinish 的轮询是 setTimeout 链(_awaitWriteFinish),挂在
* _pendingWrites 上,**不在** close() 的清理范围内(_closers 里只有
* FSWatcher 句柄,定时器没人 cancel);
* - close() 第一行就 removeAllListeners();
* - 轮询晚到时文件已被删/搬走 ⇒ fs.stat 报错;Windows 对 pending-delete
* 路径报 **EPERM**(不是 ENOENT),而 chokidar 只容忍 ENOENT,其余错误
* 走 awfEmit(err) ⇒ emit('error', err);
* - EventEmitter 对**无监听者**的 'error' 是同步 throw
* ⇒ 主进程 uncaught exception。
* 三条现实路径都能撞上:应用退出 stop()、watcher 报错自动重启、
* withWatcherReleased 搬目录 —— 全都是「close 的正当时,被监听树里的
* 文件正被删/被搬」。测试 teardown 删临时目录实测炸出 2 例。
*
* 修法:close **之后**在实例上补挂一个 noop 'error' 监听。实例已弃,监听
* 无副作用,只给迟到事件一个去处。为什么挂在 close 之后而不是之前:
* close() 自己会 removeAllListeners(),先挂会被它一起摘掉。await 的
* continuation 在微任务里跑,fs.stat 回调是 macrotask,插不进 close()
* 返回与补挂之间的窗口 ⇒ 无竞态。
*
* close 本身的失败**不吞**(finally 保证 noop 仍挂上),由调用方按各自
* 语义 warn / 透传。
*/
// 行为锁:tests/unit/main/watcher-close-quiet.test.ts,摘掉 finally 里的 noop 补挂必红。
export async function closeWatcherQuietly(watcher: FSWatcher): Promise<void> {
try {
await watcher.close();
} finally {
watcher.on('error', () => {});
}
}
/**
* 在 fn 执行期间临时释放 chokidar 的目录句柄。
*
* 语义:
* - 没有 watcher → 直接跑 fn(测试环境 / watcher 未起);
* - fn 抛错 → 仍然重建 watcher(finally),不让监听永久消失;
* - close 失败 → 只 warn,不阻断搬家(句柄没释放也只是搬不动,
* 错误会由 fn 自己抛出,语义更准)。
*/
export async function withWatcherReleased<T>(
hooks: WatcherReleaseHooks,
fn: () => Promise<T>,
): Promise<T> {
const watcher = hooks.get();
if (!watcher) return fn();
hooks.clear();
// 摘监听 → close 掉所有 FSWatcher ⇒ 释放 chokidar 持有的目录句柄。
// closeWatcherQuietly(r216):搬目录正是「被监听树里的文件全部消失」的时刻,
// 晚到的 awaitWriteFinish 轮询 stat 必报错 ⇒ 必须给迟到 error 留去处
watcher.removeAllListeners();
try {
await closeWatcherQuietly(watcher);
} catch (err: unknown) {
log.warn('watcher close failed before directory move', { err: errMsg(err) });
}
try {
return await fn();
} finally {
hooks.restart();
}
}

View File

@@ -0,0 +1,124 @@
// 写操作串行化锁:FIFO promise chain 实现
//
// audit C3R45 (2026-08-22):从 NotesService / CollectionsService / LibraryService
// 三处 verbatim 重复代码抽出。这三个 service 的 mutation 入口都需要把所有写操作
// 串行化,避免 read-modify-write 的 lost-update 与 scan 期间用户改 metadata
// 被 worker 覆盖。原先每个 service 都自带:
// private writeLock: Promise<unknown> = Promise.resolve();
// private async withWriteLock<T>(fn: () => Promise<T>): Promise<T> { ... 12 行 }
// 共 36 行 100% 重复,且任何 service 改了锁实现,其他两个不会跟着改 —— 容易漂移。
//
// 抽到 util 后的好处:
// 1. 单点改动,所有 service 行为一致(FIFO + finally release)
// 2. 测试覆盖一次,所有 service 受益
// 3. 新加 mutation service 时不用再抄一遍
//
// 实现要点(必须保留的 invariant):
// - **FIFO**:const prev = this.chain 捕获当前链尾,await prev 后才执行 fn
// - **finally 必调 release**:一次抛错如果不调 release,后续所有 run() 永远等不到
// —— 锁永久死锁,这种 bug 比丢更新还严重(不会自然恢复,需重启)
// - **try { await fn() } 不重试**:锁只负责串行化,fn 内部抛错让 caller 处理
// (业务层可能需要 retry / idempotent 等不同策略,锁不应该替你决定)
//
// audit r51 (2026-10-15):加 `maxQueue` 上限 —— 默认 16 是经验值(远高于任何
// 真实场景的瞬时并发,典型 1-3);超过时同步抛 AppError('BACK_PRESSURE') 让
// caller 立即拒绝,**不**阻塞返回。深度检查放在 `run()` 入口处(同步 throw),
// 而**不是**放进 promise chain —— 否则在 async caller 里会被吞成 rejected
// promise,IPC 层 wrap.ts 把它转 INTERNAL 走 INTERNAL 兜底,用户看到通用
// 「internal error」而非「系统繁忙,稍后重试」。同步 throw 让 catch + IPC
// 透传链全保。
import { AppError } from './error';
// 默认队列上限 = 16:远超真实瞬时并发(典型 1-3),给慢 mutation / 锁等待留余量。
// 调小会让正常压力测试红,调大会让「锁积压」的诊断信号消失。
export const WRITE_LOCK_DEFAULT_MAX_QUEUE = 16;
export class WriteLock {
private chain: Promise<unknown> = Promise.resolve();
// 当前 in-flight + pending 任务数。`run()` 入口先 ++,`finally` 里 --。
// 0 = 空闲,> 0 = 有任务跑着或排队中。
private depth = 0;
/**
* @param maxQueue 队列上限(含 in-flight + pending)。超过时 `run()` 同步抛
* AppError('BACK_PRESSURE')。默认 16(见上方常量)。
*/
constructor(private readonly maxQueue: number = WRITE_LOCK_DEFAULT_MAX_QUEUE) {}
/**
* 把 fn 排队到当前锁链尾执行。返回 fn 的 resolved value;
* 若 fn 抛错,错误透传给 caller,锁链照常推进(后续 run 不会被死锁)。
*
* 用法:
* private writeLock = new WriteLock();
* async create(x: T): Promise<T> {
* return this.writeLock.run(async () => { ... mutation body ... });
* }
*
* 抛出条件:`depth >= maxQueue` 时**同步**抛 AppError('BACK_PRESSURE')。
* 同步(而非 queue 成 rejected)是因为 async caller 中同步 throw 会立即冒泡
* 到 wrap.ts catch → 转成 { ok: false, code: 'BACK_PRESSURE', ... };
* 若改成 `queue 一个 rejected promise` 会被 caller 的 await 吞掉 → IPC
* 层 wrap.ts 看见的是 fn 返回的 promise → 进 chain.then() 失败 → 转
* INTERNAL(user 看到「internal error」而非「系统繁忙」)。
*
* **实现注意**:本函数**不是** async,只返回 Promise。原因是 JS 规则:
* async 函数体里所有 throw(即便是第一个 await 之前的 sync throw)都会被
* 包成 rejected promise 返回,caller 的 try/catch 接不住 —— BACK_PRESSURE
* 信号就这样被「async 化」消失,IPC handler catch 里只能看到
* `Promise<unknown> rejection`,wrap.ts 走 INTERNAL 兜底分支。
* 所以本签名是 sync 函数 + 内部用 IIFE async 包装锁链逻辑,实现「入口同步
* throw,后续异步等待」的混合行为。
*/
run<T>(fn: () => Promise<T>): Promise<T> {
// 深度上限检查必须**在**任何状态变更前 —— 否则 ++ 之后再抛,depth 永远
// 比真实排队多 1,「队列已满」信号被自己吃掉。
if (this.depth >= this.maxQueue) {
throw new AppError(
'BACK_PRESSURE',
`writeLock overflow: depth=${this.depth} >= maxQueue=${this.maxQueue}`,
);
}
this.depth++;
const prev = this.chain;
let release: (v: unknown) => void = () => {};
this.chain = new Promise<unknown>((r) => {
release = r;
});
// IIFE async:把「await prev + try fn + finally release」包成内层 async,
// 让 lock 链调度与 fn 错误处理走 try/finally 语义;外层 run() 是普通函数
// → 入口 throw 同 warp 立即冒泡。
return (async () => {
try {
await prev;
return await fn();
} finally {
this.depth--;
release(undefined);
}
})();
}
/**
* 等待当前锁链彻底排空(所有已排队的 run() 执行完毕),但**不**新增自己的执行。
* 用于 service 关闭 / reset 路径 —— 必须保证 in-flight mutation 全部完成才能
* 释放内存索引,否则可能丢数据(写入完成后 byId.clear() 把新条目吞掉)。
//
// audit 2026-09-11:notes/service.ts:107 原写 `await this.writeLock` —— WriteLock
// 不是 thenable,await 直接 resolve 成实例本身,**不**等待链尾。修复:queue 一个
// no-op 到链尾,await 它就拿到"前面所有 run() 都完成"的语义。
//
// audit r162 M1:原写 `await this.run(async () => {})`,run() 入口 `depth >= maxQueue`
// 同步抛 BACK_PRESSURE。某 service 改 `new WriteLock(1)`,stop() 调 drain
// → 同步抛,主进程 shutdown 路径崩。修:走「直接 await this.chain」绕过
// depth 闸门 —— drain 语义是「等现有任务完成」,不应被 maxQueue 拒(等于
// 「不允许等」自相矛盾)。maxQueue 仍是 run() 入口闸门防"积压"。
*/
async drain(): Promise<void> {
// 直接 await 当前链尾 —— 不 ++depth,不走 run() 闸门,纯等待。
// chain 是 thenable(Promise<unknown>),await 等链尾 resolve 即"所有已
// 排队 run() 都完成"。chain 自身不会被本调用改写,后续 run() 仍按
// FIFO 排在本调用之后。
await this.chain;
}
}

View File

@@ -0,0 +1,24 @@
// src/main/window-defaults.ts
//
// 窗口初始尺寸的唯一来源(抽出来的原因:src/main/window.ts 顶层 import
// 'electron' → 单元测试里 import 它会拉起原生 electron 模块,没法直接断言
// 数值;把这几个纯数字挪进独立文件后,tests/unit/main/window-defaults.test.ts
// 可以无 electron 依赖地锁住契约)。
//
// 语义:
// - DEFAULT_WINDOW_WIDTH / HEIGHT 是**冷启动**时的初始尺寸。
// 本应用不持久化窗口 bounds(无 getBounds/setBounds 落盘),所以每次
// 启动都用这里的默认值 —— 用户拖过/最大化过也不影响下次启动。
// - MIN_WINDOW_WIDTH / HEIGHT 是用户可手动缩到多小(下界)。
// - MIN 必须 ≤ DEFAULT,否则窗口一创建就比允许的最小尺寸还小,用户的
// 「缩到最小」操作会被 Chromium 静默顶回,手感像 bug。
//
// audit r176 (2026-10-19):默认宽度 1280 → 1440(用户报「默认界面宽度可以
// 再稍微宽一些」)。1440 落在 1366×768 笔记本之外的屏上时,Electron 会
// 自行把窗口收进可视区域(Windows 上按 workArea 裁剪),不会把标题栏/
// 关闭按钮挤出屏幕;但 MIN_WINDOW_WIDTH 仍必须 ≤ 常见小屏宽度 1366,
// 否则 1366 屏用户根本没法把窗口拖到最窄。
export const DEFAULT_WINDOW_WIDTH = 1440;
export const DEFAULT_WINDOW_HEIGHT = 800;
export const MIN_WINDOW_WIDTH = 900;
export const MIN_WINDOW_HEIGHT = 600;

343
src/main/window.ts Normal file
View File

@@ -0,0 +1,343 @@
import { BrowserWindow, shell } from 'electron';
import { existsSync } from 'fs';
import { join, resolve } from 'path';
import { fileURLToPath } from 'url';
import { createLogger } from './utils/logger';
// audit C3R47 (2026-08-22):errMsg 替代 verbose 错误格式化
import { errMsg } from './utils/error';
import { broadcastMaximizeChanged } from './ipc/window';
// audit 2026-09-16 r21:lifecycle 监听通过 IPC.TOAST_PUSH 推给 renderer,
// 引入 broadcastToAllWindows + IPC channel(避免 inline require 兜底的脆弱性)。
import { broadcastToAllWindows } from './ipc/broadcast';
import {
DEFAULT_WINDOW_WIDTH,
DEFAULT_WINDOW_HEIGHT,
MIN_WINDOW_WIDTH,
MIN_WINDOW_HEIGHT,
} from './window-defaults';
import { IPC, BOOT_CANVAS_HEX_DARK, type ToastPushPayload } from '../shared/types';
import { t, type Lang } from './i18n';
// audit round 9 (2026-09-15):整合 isPrivateHost → isPrivateIpLiteral(ssrf.ts):
// 原实现 40+ 行 IPv4/IPv6 字面判断,但漏掉 CGNAT 100.64.0.0/10(IPv4
// parts[0]===100 没判断),IPv6 NAT64 / 6to4 也只是 startsWith 字符串前缀。
// ssrf.ts:isPrivateIpLiteral 用 IPV4_PRIVATE_RANGES / IPV6_PRIVATE_RANGES
// 位掩码 + 128-bit BigInt,完整覆盖 RFC1918 / loopback / link-local / ULA /
// CGNAT / multicast / NAT64 / 6to4 / IPv4-mapped。复用避免漂移。
import { isPrivateIpLiteral } from './utils/ssrf';
// audit round 10 (2026-09-15):H5 — 共享 isCaseInsensitiveFs 替代内联
// `process.platform === 'win32' || process.platform === 'darwin'` 谓词,
// 与 safe-write.ts / fs-cache.ts 同源。
import { isCaseInsensitiveFs } from './utils/platform';
const log = createLogger('window');
// 允许交给系统浏览器打开的协议白名单
// 拒绝 file://, smb:, tel: 等可被渲染内容触发的危险协议
const ALLOWED_EXTERNAL_PROTOCOLS = new Set(['http:', 'https:']);
// D2-018: shell.openExternal 的同源 SSRF 防御
// audit round 9 (2026-09-15):委托给 ssrf.ts —— 单一来源,不再维护第二份 IP 表。
// 注意:同步路径只检查 hostname 字面 IP,不解析 hostname(防 DNS rebinding
// 需要异步路径,留作 future;当前所有 setWindowOpenHandler 调用都是用户
// 点链接瞬间,fetch/connect 那一刻的 IP 不归此处管)。
function isPrivateHost(hostname: string): boolean {
const host = hostname.replace(/^\[|\]$/g, '');
// 字面 IP 走 ssrf.ts 统一位掩码判断(覆盖 RFC1918 / CGNAT / IPv4-mapped /
// NAT64 / 6to4 / link-local / ULA / multicast / loopback)
if (isPrivateIpLiteral(host)) return true;
// hostname 字面对常见本地名硬拦截
if (host === 'localhost' || host.endsWith('.localhost')) return true;
return false;
}
export function createWindow(lang: Lang): BrowserWindow {
// 自定义无边框窗口 (2026-09):
// - Win/Linux:frame:false + titleBarStyle:'hidden' — 完全去掉原生 chrome,
// 渲染层 TitleBar 组件接管所有窗口控件 + 拖拽区。
// - Mac:titleBarStyle:'hiddenInset' — 隐藏标题但保留左上角红绿灯(系统约定),
// 我们的 TitleBar 在 Mac 上不渲染窗口控制按钮,只渲染品牌 + 拖拽区,
// 左上角让出 80px 给红绿灯避免遮挡。
// - 菜单栏隐藏:autoHideMenuBar:Mac/Win 都生效,但 Linux(GNOME/KDE)行为不一
// —— 既然全平台都走自定义标题栏,直接 false 让原生菜单彻底消失。
const isMac = process.platform === 'darwin';
// 窗口 / 任务栏图标 (2026-10-18,audit-r135 加固,audit-r136 修订):
// 统一用项目根 icon.ico,Windows 平台限定。
// - dev:__dirname = out/main/,'../../icon.ico' 指向项目根 → 任务栏不再
// 是 Electron 默认图标;
// - packaged (audit-r136 改):根 icon.ico 已由 package.json build.files
// 数组显式收录(__dirname=resources/app/out/main/,
// '../../icon.ico' → resources/app/icon.ico)→ existsSync 命中 →
// 传 window icon。dev + packaged 视觉一致。
// - 若有人改 package.json 把 'icon.ico' 从 files 数组里删了:existsSync
// false → 不传 icon,Windows 回退到 build.win.icon 嵌进 EXE 的
// 同一张图,任务栏仍正确 —— 这是「packaged 形态两路回退」保障。
// audit-r135:加 isWin 平台守卫 —— Electron 在 macOS 需 .icns / Linux
// 需 .png,旧实现裸 existsSync(winIcon) 即 spread `icon: winIcon`,
// .ico 路径在 Mac/Linux 被 Electron 静默忽略,守卫给虚假安全感;
// 现仅 Windows 平台尝试注入。Mac/Linux dev 模式任务栏 / Dock 用
// Electron 默认图标直到 build.mac.icon / build.linux.icon 资产就位
// (后续 round 补 .icns / .png)。
// M5 (audit-r136):__dirname 相对路径深度(2 级)与 electron-vite 默认
// main bundle 落点 out/main/index.js 锁死;若未来落点改(分包 / 拆
// 进程)需同步调深。包成 ICON_RELATIVE_FROM_MAIN 常量便于集中维护。
const ICON_RELATIVE_FROM_MAIN = '../../icon.ico';
const isWin = process.platform === 'win32';
const winIcon = isWin ? join(__dirname, ICON_RELATIVE_FROM_MAIN) : '';
const win = new BrowserWindow({
// 初始尺寸来自 window-defaults.ts(纯常量模块,可被单元测试直接断言)
width: DEFAULT_WINDOW_WIDTH,
height: DEFAULT_WINDOW_HEIGHT,
minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT,
show: false,
...(isWin && existsSync(winIcon) ? { icon: winIcon } : {}),
title: t('window.title', lang),
autoHideMenuBar: true,
frame: false,
titleBarStyle: isMac ? 'hiddenInset' : 'hidden',
// perf:首屏前 window 已显示纯黑底,等 React 挂上 globals.css 后才换主题底色,
// 200-400ms 的"黑闪"被消除。色值与 globals.css :root.dark --bg-canvas-rgb
// (7 8 10) + index.html #07080A 三处对齐,共享 @shared/types BOOT_CANVAS_HEX_DARK。
backgroundColor: BOOT_CANVAS_HEX_DARK,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
// D2-008: 启用 Chromium 渲染进程沙箱,作为 contextIsolation 之外的第二道防线;
// preload 只用 contextBridge + ipcRenderer,不需要 Node 访问,沙箱兼容
sandbox: true,
},
});
// maximize-changed 推送(2026-09):自定义标题栏里的"最大化 / 还原"图标要跟随
// 系统状态。系统级 maximize/unmaximize 由 Electron 触发,这里转发到渲染层。
// 必须在 loadURL/loadFile 之前注册,避免 webContents 还没准备好就丢了首批事件。
win.on('maximize', () => broadcastMaximizeChanged(win, true));
win.on('unmaximize', () => broadcastMaximizeChanged(win, false));
win.on('ready-to-show', () => win.show());
// audit 2026-09-07:显式拒绝所有未列入 ALLOWED_PERMISSIONS 的 web permission 请求 —
// 默认 Chromium 行为在不同版本差异很大(部分自动 grant / 部分 prompt),被注入
// 内容(或上文的 file:// 导航漏洞被利用)后可能拿到 geolocation / notifications /
// media / midi / push 等敏感权限。限定到白名单,其余一律 deny。
const ALLOWED_PERMISSIONS = new Set<string>([]);
win.webContents.session.setPermissionRequestHandler((_wc, _permission, callback) => {
if (ALLOWED_PERMISSIONS.has(_permission)) {
callback(true);
} else {
log.warn('Denied web permission request', _permission);
callback(false);
}
});
win.webContents.setWindowOpenHandler(({ url }) => {
// D2-009: 仅放行 http(s); 其它协议 (file:, tel:, 等) 拒绝交给系统
try {
const parsed = new URL(url);
if (ALLOWED_EXTERNAL_PROTOCOLS.has(parsed.protocol)) {
// D2-018: 同源检查也防 SSRF-amplification — 不允许指向 localhost / 私有 IP
if (isPrivateHost(parsed.hostname)) {
log.warn('Blocked external open to private host', parsed.hostname, url);
} else {
// openExternal 返回 Promise:xdg-open 缺失 / 无默认浏览器时会 reject。
// 不接住的话用户点了链接毫无反应,日志里也查不到原因。
shell.openExternal(url).catch((err: unknown) => {
log.warn('openExternal failed', url, errMsg(err));
});
}
} else {
log.warn('Blocked external open for non-allowlisted protocol', parsed.protocol, url);
}
} catch {
log.warn('Blocked external open for unparseable url', url);
}
return { action: 'deny' };
});
// D2-014: 阻止 renderer 顶帧导航到非应用 URL,避免被注入内容劫持整窗
win.webContents.on('will-navigate', (e, url) => {
// D2-018: 不再用 startsWith 字符串前缀 — `http://localhost:5173.attacker.tld/x`
// 会骗过 startsWith。改成基于 origin 的精确比较
const allowedOrigin = process.env.ELECTRON_RENDERER_URL ?? null;
try {
const parsed = new URL(url);
if (allowedOrigin) {
const allowed = new URL(allowedOrigin);
// 仅允许相同 protocol + host + port
if (
parsed.protocol !== allowed.protocol ||
parsed.hostname !== allowed.hostname ||
parsed.port !== allowed.port
) {
log.warn('Blocked in-window navigation to', url);
e.preventDefault();
}
} else {
// 生产 (audit 2026-09-07 HIGH):仅放行指向 renderer bundle 目录内的 file:// —
// 原实现"protocol === 'file:'"放过了 file:///c:/users/secrets.txt 这类
// 任意本地文件,被注入的 renderer / 笔记正文 / 受污染依赖
// `window.location = 'file:///'` 即可读盘。规范化为绝对路径,
// 路径必须在 rendererIndexDir 前缀之下(同目录或子目录),否则拦。
if (parsed.protocol !== 'file:') {
log.warn('Blocked in-window navigation to non-file protocol', url);
e.preventDefault();
return;
}
// Windows 上 pathname 是 '/C:/...';Linux 是 '/home/...'.统一前缀比较
// 需要 normalize — 用 fileURLToPath 而不是 path.resolve(parsed.pathname):
// path.resolve('/C:/foo') 在 Windows 上返回 'C:\C:\foo'(把 '/C:' 误读成
// 不带盘符的绝对 POSIX 路径,再被前面解析成 cwd + 'C:/foo'),导致合法路径
// 被误判成跨界。fileURLToPath 走 URL spec 解析,跨平台行为一致。
const rendererDir = join(__dirname, '../renderer').replace(/\\/g, '/');
const target = fileURLToPath(parsed).replace(/\\/g, '/');
const rendererDirNorm = resolve(rendererDir).replace(/\\/g, '/');
// audit 2026-09-14 r7:macOS (darwin) APFS 默认 case-insensitive,见
// safe-write.ts:resolveInsideDataPath 同款注释。Linux 不动。
// audit round 10 (2026-09-15):H5 — 走共享 isCaseInsensitiveFs,
// 与 safe-write.ts / fs-cache.ts 同源。
const targetCmp = isCaseInsensitiveFs ? target.toLowerCase() : target;
const dirCmp = isCaseInsensitiveFs ? rendererDirNorm.toLowerCase() : rendererDirNorm;
// audit-r76 (2026-10-17):等值判定旧写作 `target !== dirCmp` —— raw 与
// lowercase 后的值比,大小写不敏感 FS 上恒 true(除非 target 恰好全
// 小写),等值放行成死分支(裸目录 URL 误拦,fail-closed 仅可用性
// 影响;安全方向不会 false-allow)。统一走 targetCmp 比较。
if (targetCmp !== dirCmp && !targetCmp.startsWith(dirCmp + '/')) {
log.warn('Blocked in-window navigation outside renderer dir', url, '→', targetCmp);
e.preventDefault();
}
}
} catch {
log.warn('Blocked in-window navigation to unparseable url', url);
e.preventDefault();
}
});
// audit-r124:loadURL/loadFile 返回 Promise,旧实现裸调丢弃 → dev server
// down / 产物缺失时 rejection 撞上 index.ts 的 unhandledRejection 全局网
// = showFatalDialog + app.quit(),一次本可自愈的加载失败被升级成闪退。
// catch 只降级为日志;用户可见的重试/兜底仍由 did-fail-load 驱动。
const load = process.env.ELECTRON_RENDERER_URL
? win.loadURL(process.env.ELECTRON_RENDERER_URL)
: win.loadFile(join(__dirname, '../renderer/index.html'));
void load.catch((err: unknown) => {
log.error('renderer load rejected', errMsg(err));
});
// audit 2026-09-16 r21:H1+H2 — 补齐 renderer 进程生命周期监听。
// 旧实现没 did-fail-load / render-process-gone / child-process-gone /
// crashed / unresponsive 任何一个 → dev server down / 构建产物丢失 /
// GPU 进程 OOM / 渲染进程被 kill 时只看到白屏 + show:false,无信号。
// 修复:统一推到 IPC.TOAST_PUSH 让用户看到 + 必要时 win.reload() 自愈。
// broadcastToAllWindows 已包 try/catch send,关窗竞态不会反过来炸监听器。
const lifecycleToast = (key: string, params?: Record<string, string | number>): void => {
try {
// audit-r92:satisfies 挂单源 payload 型(同 startup.ts 迁移 toast)
broadcastToAllWindows(IPC.TOAST_PUSH, {
type: 'error',
message: t(key, lang, params),
duration: 8000,
} satisfies ToastPushPayload);
} catch (notifyErr) {
log.warn('renderer lifecycle toast dispatch failed', errMsg(notifyErr));
}
};
// H1:did-fail-load — URL 加载失败(协议错误 / 404 / 渲染层 SPA 自身 throw)。
// win.show() 已在 ready-to-show 触发,即使失败也会 show 一次 → 兜底 reload()。
// audit r28 (2026-09-21):retry cap —— 旧实现每次 fail 都 setTimeout reload,
// 永久坏 bundle(asar hash 不匹配 / dev server 永久 down)会让 renderer
// 在 did-fail-load → reload → did-fail-load 死循环,每轮 ~1s + 一条
// rendererLoadFailed toast,内存累积到 OOM-kill。修法:per-window
// attempts 闭包计数器,> 3 次 → 停掉 reload + 最后一条 toast 引导
// 用户「重启 / 重装」。窗口重建(createMainWindow 再次调用)重置。
let failLoadAttempts = 0;
win.webContents.on('did-fail-load', (_e, code, desc, url, isMainFrame) => {
// audit-r124:did-fail-load 对**任意框架**触发 —— PDF Reader / 图片预览
// 这些 iframe 子资源 404 同样进本事件。旧实现不区分主框架,子资源失败
// 会烧光主窗口重试预算并 win.reload() 把用户整个工作区掀掉(r119/r120
// 后 rkms:// iframe 404 是常规态,杀伤面真实存在)。只处理主框架。
if (!isMainFrame) return;
log.warn('renderer did-fail-load', { code, desc, url });
// audit-r124:ERR_ABORTED(-3)不 toast、不计数、不 reload —— reload()
// 本身会 abort 在途导航,旧实现连 -3 都进重试环 → abort→reload→abort,
// 预算被自家 reload 烧光后弹 rendererLoadFailedFinal 误导用户重启。
// r28 原注释「避开 obvious 开发期噪声(Abort / -3)以外都 reload」
// 描述的是本意但从未落地,现在代码与注释对齐。
if (code === -3) {
log.info('did-fail-load ERR_ABORTED, skip (likely self-inflicted by reload)', { desc, url });
return;
}
const MAX_FAIL_LOAD_ATTEMPTS = 3;
if (failLoadAttempts >= MAX_FAIL_LOAD_ATTEMPTS) {
log.error('renderer fail-load attempts exhausted, giving up', { code, desc, url });
lifecycleToast('error.rendererLoadFailedFinal');
return;
}
lifecycleToast('error.rendererLoadFailed');
// 到这里只剩非 abort 的真失败(ERR_FAILED / ERR_FILE_NOT_FOUND / 网络类),
// 统一 reload 兜底;abort 分支已在上方 return。
if (!win.isDestroyed()) {
failLoadAttempts += 1;
setTimeout(() => {
if (win.isDestroyed()) return;
try {
win.reload();
} catch (reloadErr) {
log.warn('renderer reload after did-fail-load threw', errMsg(reloadErr));
}
}, 1000);
}
});
// H2/M2:render-process-gone — GPU 进程 OOM / killed → 用户看到黑/白窗。
// reason: 'crashed' | 'killed' | 'oom' 等。killed 在 Mac 上是用户 quit 后正常态,
// 不需要 toast。crashed / oom 自动 reload + 提示。
win.webContents.on('render-process-gone', (_e, details) => {
log.error('render-process-gone', details);
if (details.reason === 'killed') return;
lifecycleToast('error.rendererGone', { reason: details.reason });
if (!win.isDestroyed() && details.reason !== 'abnormal-exit') {
setTimeout(() => {
if (win.isDestroyed()) return;
try {
win.webContents.reload();
} catch (reloadErr) {
log.warn('reload after render-process-gone threw', errMsg(reloadErr));
}
}, 1000);
}
});
// M2:crashed — render-process-gone 上面的低层版本,部分 Electron 版本同时触发。
// Electron 类型 def 未暴露 'crashed' 事件,走 (webContents as ...).on 兜底。
(
win.webContents as unknown as {
on: (e: 'crashed', cb: (_e: unknown, killed: boolean) => void) => void;
}
).on('crashed', (_e, killed) => {
log.error('renderer crashed', { killed });
if (killed) return;
lifecycleToast('error.rendererCrashed');
});
// M2:unresponsive — 主线程卡顿超过阈值。BrowserWindow 默认不自动重启,
// 给用户信号即可。
win.on('unresponsive', () => {
log.warn('window unresponsive');
lifecycleToast('error.rendererUnresponsive');
});
// H2:child-process-gone — utility 进程 / sandbox 辅助进程意外退出。
// 最常见是 sandbox renderer / GPU helper。提示用户即可,不强制 reload。
(
win.webContents as unknown as {
on: (e: 'child-process-gone', cb: (_e: unknown, details: { reason: string }) => void) => void;
}
).on('child-process-gone', (_e, details) => {
log.warn('child-process-gone', details);
lifecycleToast('error.childProcessGone', { reason: details.reason });
});
return win;
}

View File

@@ -0,0 +1,529 @@
// 独立收藏 — 单文件收藏 ★ 文献列表(显示分组但不提供 CRUD)
// 重构 (2026-08-28):原 collection 模块已删除,「收藏分组」功能并入 favorites —
// 每个 entry 可以是 kind: 'file'(★ 单文件)或 kind: 'group'(多文件分组 + 描述)。
// 老 schema(无 kind 字段)= 视为 kind: 'file',与历史数据兼容。
// 重构 (2026-09 V1.4):group CRUD(createGroup/deleteGroup/addItemToGroup/...
// removeItemFromGroup/setGroupDescription)整体下线 —— favorites 分组只读,
// ★ toggle 与 chip 过滤由 LibraryList 提供(audit-r71 删 LibraryList,
// r72 起由 LiteratureList 提供)。IPC + preload + api + 服务方法
// 全删。分组数据仍由 normalizeEntries 解析,V2.0 需要管理 UI 时再 wire 起来。
// 数据落在 <rootPath>/workspace/favorites/favorites.md(SSOT =
// WORKSPACE_DATA_LAYOUT.FAVORITES_FILE,运行时 join(getRootPath(), …) 拼出;
// audit-r124:原文「data/workspace/favorites/…」是相对默认父目录的写法,
// 恰在默认配置下成立(location.ts:rootPath 默认 = <Documents>/ResearchHub/data),
// 但用户改库位置后 data/ 一级并不存在 —— 权威锚应为 rootPath)
// - frontmatter.entries: 权威源(应用读这个)
// - body: 人类可读的星标清单 + 分组列表
// 路径走 WORKSPACE_DATA_LAYOUT 单一真相源
import { EventEmitter } from 'events';
import { join } from 'path';
import matter from 'gray-matter';
import type { FavoritesEntry, FavoritesFileEntry, FavoritesGroupEntry } from '@shared/types';
import { WORKSPACE_DATA_LAYOUT, SCHEMA_VERSION } from '@shared/types';
import { getRootPath } from '../utils/safe-write';
import { AppError, ValidationError } from '../utils/error';
import { AtomicFile } from '../utils/atomic-file';
import { newShortId } from '../utils/short-id';
import { asFrontmatter } from '../utils/frontmatter';
import { createLogger } from '../utils/logger';
const log = createLogger('favorites');
interface FavoritesFileShape {
schemaVersion: number;
entries: FavoritesEntry[];
updatedAt: string;
}
// 重构 (2026-09 V1.4 + audit round 17):FavoritesEntry 直接用 @shared/types 的版本,
// 本地不再 re-export — 之前 `export type FavoritesEntry = ...` 与 shared 重名,
// 迁移模块从 ./favorites import 这个别名是循环依赖风险。删后类型在文件内部仍可
// 用(隐式从 shared import 拿),无需重新声明。
export class FavoritesService extends EventEmitter {
private file: AtomicFile<FavoritesFileShape> | null = null;
async start(): Promise<void> {
// audit 2026-10-01:幂等守卫 —— 与 NotesService.start() 的
// `if (this.started) return` 对齐。旧实现没有守卫,二次 start() 会
// `new AtomicFile` 覆盖 this.file,**上一个实例的 in-flight writeQueue
// 失去引用**(它上面的 'changed' 订阅也一并丢掉)。本项目当前只在启动
// 路径调一次,所以没暴露;但只要将来出现第二条启动路径(迁移后重启 /
// 手动 retry),就会静默丢事件 + 丢待写数据。
if (this.file) return;
const filePath = join(getRootPath(), WORKSPACE_DATA_LAYOUT.FAVORITES_FILE);
this.file = new AtomicFile<FavoritesFileShape>({
filePath,
defaultValue: {
schemaVersion: SCHEMA_VERSION.FAVORITES,
entries: [],
updatedAt: new Date().toISOString(),
},
// audit round 14 (2026-09-15):CQ-007 — 用 asFrontmatter helper 替代
// inline `v as unknown as Record<string, unknown>` cast。frontmatter.ts
// 集中维护 gray-matter 类型适配,改 helper 一处即可,本文件不再读一行
// 强类型断言。
serialize: (v) => matter.stringify(buildBody(v), asFrontmatter(v)),
parse: (raw) => {
const parsed = matter(raw);
// gray-matter + js-yaml 可能把 ISO 字符串解析成 Date;这里用 unknown 兜底
const fm = parsed.data as {
updatedAt?: unknown;
schemaVersion?: unknown;
entries?: unknown;
};
// r196:entries 形态非法 → **抛错**,让 AtomicFile.start() 走 quarantine
// 分支(把原文件 rename 成 `<file>.corrupt-<ts>` 保留内容)。
//
// 旧实现 `Array.isArray(fm.entries) ? fm.entries : []` 把「手编时把
// entries 写成映射 / 留空 / 写成字符串」一律降级成**空列表**,
// 而解析**不抛错** ⇒ quarantine 不触发 ⇒ 下一次 ★ toggle 就拿空
// 内存态 persist 覆盖整个文件 ⇒ 用户的收藏**永久消失**,磁盘上
// 没有任何 .corrupt 备份、没有任何日志痕迹(只有一条 warn)。
//
// favorites.md 是设计成**支持手编**的(文件头注),形态非法是
// 「用户手滑」而不是「应用没写好」⇒ 宁可让收藏功能本次不可用
// (quarantine 后内存态回落空列表),也不能静默销毁用户数据 ——
// 与 AtomicFile 那句「宁可让这个功能暂时不可用,也不能静默销毁
// 用户数据」同一条原则。
if (fm.entries !== undefined && !Array.isArray(fm.entries)) {
throw new Error(
'favorites.md frontmatter.entries must be an array — refusing to load ' +
'(the file will be quarantined as .corrupt-*)',
);
}
const entries = Array.isArray(fm.entries) ? fm.entries : [];
const updatedAt = asIsoString(fm.updatedAt);
// 重构 (2026-08-28):用 normalizeEntries 替代 inline isValidEntry —— 现在
// 是 union 类型,需要给老 schema(无 kind 字段)打 'file' 标,并把 group.items
// 数组内每条都规整为完整 FavoritesFileEntry。
const normalized = normalizeEntries(entries);
return {
schemaVersion:
typeof fm.schemaVersion === 'number' ? fm.schemaVersion : SCHEMA_VERSION.FAVORITES,
entries: normalized,
updatedAt: updatedAt ?? new Date().toISOString(),
};
},
onChange: (v) => this.emit('changed', v),
logScope: 'favorites',
});
await this.file.start();
log.info('Favorites loaded', this.file.get().entries.length, 'entries');
}
/** 返回完整 entry 列表(供 UI 渲染标题/路径)。
* 重构 (2026-09 round 21):删原 list() 返回 string[] 的 API —— production 全部走
* listEntries()(renderer 在 store 内 filter kind === 'file' 派生 favoritedFileIds),
* 无 caller 直接调 list()。测试侧通过 listEntries().filter(...).map(...) 一行替换。 */
listEntries(): FavoritesEntry[] {
if (!this.file) return [];
return this.file.get().entries;
}
/** 切换收藏态:已收藏则移除,未收藏则加入。返回新的收藏态。
* 重构 (2026-08-28):只动 file kind —— group 是用户创建的分组容器,
* 不该被单文件 ★ 操作影响。
* audit 2026-09-10:start / validation 失败抛 AppError / ValidationError —— 旧
* 实现裸 Error 走 wrap.ts → INTERNAL,用户看到通用内部错误。favorites 启
* 动期是"应用刚 boot 还没就绪"语义,跟真正的内部错误不能混。 */
async toggle(fileId: string, info?: { title?: string; relPath?: string }): Promise<boolean> {
if (!this.file) throw new AppError('NOT_READY', 'favorites service not started');
if (!fileId) throw new ValidationError('fileId is required', 'fileId');
let nowFavorited = false;
await this.file.update((curr) => {
const existing = curr.entries.find((e) => e.kind === 'file' && e.fileId === fileId);
if (existing) {
nowFavorited = false;
return {
...curr,
entries: curr.entries.filter((e) => !(e.kind === 'file' && e.fileId === fileId)),
updatedAt: new Date().toISOString(),
};
}
// audit 2026-09-10:加新条目时要求 info 至少有一个非空字段 ——
// 之前任何 fileId 都能成功加入,即便 title / relPath 都是空。
// 真实事件:renderer stale UI(用户点过 ★ 但文件已 chokidar 删除)
// → toggle(fileId) 写入 favorites.md 一条空 title/relPath 记录,
// UI 用 favoritedFileIds Set 显示 ★ 但 favorites 列表因为 title 空
// 显示为空,孤儿记录永远占着 disk。下次清理逻辑也没有,只能手动
// 编辑 .md 文件。
// 修法:title 和 relPath 同时为空 → throw ValidationError,让 UI 显式
// 不显示 ★ 给无法填充元数据的文件,或允许「仅 fileId」时把现有 entry
// 当孤儿(下一步)。
// audit-r151:trim 后判断 —— 原实现 `' '.length > 0` 视为非空,纯空白
// title 写进 favorites.md 渲染成「· 」不可读条目;trim 后既识别纯空白
// 又与 buildBody 渲染时 trim 行为对齐,避免「输入空格通过 + 显示被截」
// 的语义不一致。
const titleRaw = info?.title ?? '';
const relPathRaw = info?.relPath ?? '';
const title = titleRaw.trim();
const relPath = relPathRaw.trim();
if (!title && !relPath) {
throw new ValidationError(
'Cannot add favorite without title or relPath — caller must provide real file metadata',
'fileId',
);
}
nowFavorited = true;
const entry: FavoritesFileEntry = {
kind: 'file',
id: newShortId(),
fileId,
title,
relPath,
addedAt: new Date().toISOString(),
};
return {
...curr,
entries: [entry, ...curr.entries],
updatedAt: new Date().toISOString(),
};
});
return nowFavorited;
}
/**
* r198:资源被删除(移入回收站)后,清掉它的收藏条目。
*
* 背景:删除路径一直**只**动文件 + DB 行,不动 favorites.md ⇒ 文件没了,
* 收藏条目还留着(孤儿)。三个后果:
* 1. favorites.md 无限增长(每个删掉的 ★ 文献留一条);
* 2. **★ 状态错位**:文献 id = 'lit:'+toPosix(absPath) —— 把同一个
* 文件从原路径再导入一次 ⇒ id 相同 ⇒ 新行**继承**旧 ★,用户看到
* 一个自己没点过的星;再点一下是「取消收藏」(toggle 语义),用户
* 反而更困惑;
* 3. notes 域同样有孤儿(虽然还没有笔记 ★ 的 UI,但 favorites.md 是
* 设计上可手工编辑的文件,老版本 / 手改都可能留下笔记条目)。
*
* 幂等:不存在的 id 直接跳过;集合里没有命中项时**不写盘**(避免每次
* 删除都无谓地重写 favorites.md)。只动 kind==='file' 的条目 —— group
* 是用户自建的容器,不该被单文件删除牵连。
*/
async removeIds(ids: string[]): Promise<number> {
if (!this.file) return 0;
const set = new Set(ids.filter((id) => typeof id === 'string' && id.length > 0));
if (set.size === 0) return 0;
// 先看有没有命中 —— 没有就不进 update(update 无条件 persist)。
const hasHit = this.file
.get()
.entries.some((e) => e.kind === 'file' && set.has(e.fileId));
if (!hasHit) return 0;
let changed = 0;
await this.file.update((curr) => {
const kept = curr.entries.filter((e) => {
if (e.kind !== 'file' || !set.has(e.fileId)) return true;
changed++;
return false;
});
if (changed === 0) return curr;
return { ...curr, entries: kept, updatedAt: new Date().toISOString() };
});
log.info('Favorites entries removed for deleted resources', { changed });
return changed;
}
async stop(): Promise<void> {
// audit 2026-10-01:排空写队列后**清掉 this.file**。
// 旧实现只 `await this.file.stop()`(那只是等 AtomicFile 的 writeQueue
// flush,不是「卸载」),this.file 仍非 null → toggle() 开头的
// `if (!this.file) throw NOT_READY` 守卫在「已停止」之后依然不生效:
// 退出流程中 stop() 之后抵达的 IPC toggle 仍会照常写盘。
// 与 NotesService 的 started=false 语义对齐(它 stop 后 byId.clear() 且
// started=false,caller 侧立刻进入明确的「已停」态)。
if (this.file) {
await this.file.stop();
this.file = null;
}
}
/**
* r182:资源 id 变更后,把收藏条目里的 fileId 跟着改 —— 否则用户的 ★ 静默
* 消失(favorites.md 里还留着旧 id,但 UI 按 id 过滤,旧 id 再也不会命中
* 任何一行 ⇒ 收藏列表里那条记录变成永远点不亮的孤儿)。
*
* 触发场景(全是「id 与路径强绑定」的操作):
* - 文献搬进/搬出文件夹:lit id = 'lit:'+toPosix(absPath),路径变 id 变;
* - 文件夹改名 / 跨目录搬移:子树里每个文献的 id 全变;
* - 笔记改名 / 跨文件夹搬移:fm.id = SHA-256(relPath) 前 16 位,路径变 id 变。
*
* pathMap 可选:同时改写 entry.relPath(收藏面板显示的路径)。各域存的东西
* 不一样 —— literature 侧 renderer 存的是**绝对路径**(LiteratureList
* handleToggleFavorite 传 r.absPath),notes 侧存的是相对 notes/ 的
* relPath;调用方按域给对即可。缺省只改 fileId(★ 的显示只认 fileId)。
*
* 返回改写条数(0 = 没有收藏指向这些 id,或服务未启动)。
*/
async remapIds(map: Map<string, string>, pathMap?: Map<string, string>): Promise<number> {
if (!this.file) return 0;
if (map.size === 0) return 0;
// 先干读一遍:AtomicFile.update 每次都会 persist,没命中还写一次盘
// (mtime 抖动 + 无谓的 'changed' 广播 → renderer 白刷一次)
let hit = 0;
for (const e of this.file.get().entries) {
if (e.kind === 'file' && map.has(e.fileId)) hit++;
}
if (hit === 0) return 0;
let changed = 0;
await this.file.update((curr) => {
let dirty = false;
const entries = curr.entries.map((e) => {
if (e.kind !== 'file') return e;
const newId = map.get(e.fileId);
if (newId === undefined) return e;
const newPath = pathMap?.get(e.fileId);
if (newId === e.fileId && (newPath === undefined || newPath === e.relPath)) return e;
dirty = true;
changed++;
return { ...e, fileId: newId, relPath: newPath ?? e.relPath };
});
if (!dirty) return curr;
return { ...curr, entries, updatedAt: new Date().toISOString() };
});
log.info('Favorites ids remapped', { changed });
return changed;
}
}
/** 一条「资源 id 变更」记录 —— 供 remapFavoriteIds 消费。 */
export interface FavoriteIdRemap {
/** 变更前的 id(与 favorites.md 里 entry.fileId 同形) */
oldId: string;
/** 变更后的 id */
newId: string;
/** 新的展示路径(写进 entry.relPath);缺省则保留原值 */
path?: string;
}
/**
* r182:便捷入口 —— 把 id 变更表喂给 favorites 服务。
* 幂等:oldId === newId 的条目直接跳过;服务未启动(测试环境 / 早于启动
* 序)时静默返回 0(此时也还没有任何收藏数据可改)。
*/
export async function remapFavoriteIds(items: FavoriteIdRemap[]): Promise<number> {
if (items.length === 0) return 0;
const map = new Map<string, string>();
const pathMap = new Map<string, string>();
for (const it of items) {
if (!it.oldId || !it.newId || it.oldId === it.newId) continue;
map.set(it.oldId, it.newId);
if (it.path !== undefined) pathMap.set(it.oldId, it.path);
}
if (map.size === 0) return 0;
return getFavoritesService().remapIds(map, pathMap);
}
/**
* r198:便捷入口 —— 资源被删除后清掉它的收藏条目。
* 与 remapFavoriteIds 同款语义:服务未启动(测试环境 / 早于启动序)时
* 静默返回 0(此时也还没有任何收藏数据可清)。
*/
export async function removeFavoriteIds(ids: string[]): Promise<number> {
if (ids.length === 0) return 0;
return getFavoritesService().removeIds(ids);
}
function asIsoString(v: unknown): string | null {
// audit 2026-10-01:MEDIUM — string 分支原先**原样返回、不做合法性校验**。
// favorites.md 在这个项目里是设计上就支持手工编辑的(见文件头注),而
// YAML 里的 `addedAt: 2024年1月1日` 会被解析成 string,V8 的 Date 解析不了
// 它 → 后面 buildBody 的 `new Date(e.addedAt).toISOString()` 抛 RangeError。
// 而 serialize 是 AtomicFile.persist 的**第一条语句**、在 writeQueue 临界
// 区里,抛错时 this.current 不会被替换 → 内存态持续带毒,此后**每一次**
// toggle 都失败,且没有任何自愈路径(只能手改文件)。用户看到的还是通用
// 内部错误,完全指不到「你手写的那行日期格式不对」。
//
// 修:这里就校验掉。校验不过返回 null,交给调用方 `?? new Date().toISOString()`
// 回落 —— 与 Date 分支的既有语义一致(无效就用现在)。
if (typeof v === 'string') {
const d = new Date(v);
return isNaN(d.getTime()) ? null : v;
}
if (v instanceof Date && !isNaN(v.getTime())) return v.toISOString();
return null;
}
/**
* 渲染 addedAt 为 `YYYY-MM-DD HH:mm`,非法值回落到原字符串。
*
* audit 2026-10-01:与 asIsoString 同源的兜底。即便 asIsoString 已经在 parse
* 阶段挡住了非法值,buildBody 仍直接 `new Date(...).toISOString()` —— 任何
* 绕过 normalize 的路径(或将来新增的 entry 来源)都会让它抛 RangeError,
* 而那会把整个收藏文件写成"毒状态"。这里不让它抛。
*/
function formatAddedAt(s: string): string {
const d = new Date(s);
if (isNaN(d.getTime())) return String(s);
return d.toISOString().slice(0, 16).replace('T', ' ');
}
// 重构 (2026-08-28):parse-time normalize —— 给老 schema entry 补 kind 字段 + group
// items 内部归一化。老 favorites 文件可能没有 `kind` 字段;parse 时把每条 entry
// 都打上 'file' 标。group.items 数组内每条都需要 file 五字段,缺字段的直接丢掉,
// 不静默保留半残数据。
//
// audit 2026-10-04:MEDIUM —— 「缺字段就丢」在这里是**数据丢失**,不是无害的清理。
// favorites.md 是设计成**支持手编**的(文件头注明「人类可读优先」),而 YAML 会
// 把看起来像字符串的值解析成别的类型:`title: 2024` → number,`id: 12` → number。
// 旧实现对四个字段全部要求 `typeof === 'string'`,于是这类条目被 `continue`
// 静默丢弃;`parse` 又不抛错,`AtomicFile.start()` 的 quarantine 也不会触发。
// 真正的杀伤在**下一次 toggle**:persist → buildBody 用内存态重写整个文件,
// 被丢的条目从磁盘上永久消失,没有 quarantine、没有 toast、没有任何痕迹。
//
// 修法(分级,尽量不丢):
// - `title` 接受任意原始类型并 String() 化(数字年份是最常见的误写);
// - `id` 缺失/非字符串时用 `fileId` 顶上(两者都来自同一条 entry,不是编造);
// - 只有 `fileId` / `relPath` 这类**无法重建的身份字段**缺失时才丢,且必须
// 留下 warn 日志(原来连日志都没有)。
function coerceText(v: unknown): string | null {
if (typeof v === 'string') return v;
// YAML 会把 `2024` / `true` 解析成 number / boolean;手编场景下它们就是文本
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
return null;
}
function normalizeEntries(entries: unknown): FavoritesEntry[] {
if (!Array.isArray(entries)) return [];
const out: FavoritesEntry[] = [];
for (const e of entries) {
if (!e || typeof e !== 'object') continue;
const o = e as Record<string, unknown>;
const kind = o['kind'] === 'group' ? 'group' : 'file';
if (kind === 'file') {
const addedAt = asIsoString(o.addedAt) ?? new Date().toISOString();
const fileId = coerceText(o.fileId);
const title = coerceText(o.title);
const relPath = coerceText(o.relPath);
// id 是内部标识(serialize 时原样写出),缺失时用 fileId 顶上
const id = coerceText(o.id) ?? fileId;
if (fileId && title !== null && relPath !== null && id) {
out.push({ kind: 'file', id, fileId, title, relPath, addedAt });
} else {
log.warn('Dropping malformed favorites file entry (hand-edited?)', {
hasId: typeof o.id === 'string',
fileId: typeof o.fileId,
title: typeof o.title,
relPath: typeof o.relPath,
});
}
} else {
// group
// group
//
// audit 2026-10-04:group 的标量字段也放宽。旧实现要求 id/name/description/
// createdAt/updatedAt **全部**是 string,任何一项不合规就把**整组连同它
// 里面所有条目**一起丢掉 —— 而「手编时忘了写 description」远比「title 写成
// 数字」常见(它看起来就是可选字段)。丢一组的损失远大于丢一条。
const gId = coerceText(o.id) ?? coerceText(o.name);
const gName = coerceText(o.name);
if (gId && gName) {
const rawItems = Array.isArray(o.items) ? o.items : [];
const items: FavoritesFileEntry[] = [];
for (const it of rawItems) {
if (!it || typeof it !== 'object') continue;
const r = it as Record<string, unknown>;
const itemAddedAt = asIsoString(r.addedAt) ?? new Date().toISOString();
// 与顶层 file entry 同款处理(见 normalizeEntries 头注)
const itemFileId = coerceText(r.fileId);
const itemTitle = coerceText(r.title);
const itemRelPath = coerceText(r.relPath);
const itemId = coerceText(r.id) ?? itemFileId;
if (itemFileId && itemTitle !== null && itemRelPath !== null && itemId) {
items.push({
kind: 'file',
id: itemId,
fileId: itemFileId,
title: itemTitle,
relPath: itemRelPath,
addedAt: itemAddedAt,
});
} else {
log.warn('Dropping malformed favorites group item (hand-edited?)', {
hasId: typeof r.id === 'string',
fileId: typeof r.fileId,
title: typeof r.title,
relPath: typeof r.relPath,
});
}
}
out.push({
kind: 'group',
id: gId,
name: gName,
// 缺失/非字符串时兜底,不因此丢掉整组
description: coerceText(o.description) ?? '',
items,
createdAt: asIsoString(o.createdAt) ?? new Date().toISOString(),
updatedAt: asIsoString(o.updatedAt) ?? new Date().toISOString(),
});
} else {
log.warn('Dropping malformed favorites group (hand-edited?)', {
hasId: typeof o.id === 'string',
name: typeof o.name,
});
}
}
}
return out;
}
// 重构 (2026-09):buildBody / normalizeEntries 改回 module-private —— 原 export
// 是给 migration.ts V3 把老 data/collections/ 合并进 favorites.md 复用。
// migration.ts 2026-09 V1.4 整体下线后,这两个 export 无外部 caller,内部
// caller(serialize / parse 走同一份实现)始终存在,export 失去意义。
// audit 2026-09 (V1.9):补一笔 —— 之前导出是为了 V3 migration 共享实现;
// V3 下线后只 favorites.ts 内部用,export 是 dead export。改回 function
// 关键字,所有现有 caller(serialize / parseEntries 内部)继续走同一份实现。
function buildBody(state: FavoritesFileShape): string {
const lines: string[] = [];
lines.push('# 收藏');
lines.push('');
if (state.entries.length === 0) {
lines.push('> 尚未收藏任何文献');
lines.push('');
return lines.join('\n');
}
// 重构 (2026-08-28):group 单独一段,file 走原表格
const files = state.entries.filter((e): e is FavoritesFileEntry => e.kind === 'file');
const groups = state.entries.filter((e): e is FavoritesGroupEntry => e.kind === 'group');
if (files.length > 0) {
lines.push(`## 星标 (${files.length})`);
lines.push('');
lines.push('| 收藏时间 | 标题 | 路径 |');
lines.push('|---|---|---|');
for (const e of files) {
const t = formatAddedAt(e.addedAt);
const title = e.title.replace(/\|/g, '\\|').replace(/\n/g, ' ');
const rel = e.relPath.replace(/\|/g, '\\|');
lines.push(`| ${t} | ${title} | \`${rel}\` |`);
}
lines.push('');
}
for (const g of groups) {
lines.push(`## 分组 · ${g.name} (${g.items.length})`);
if (g.description) lines.push(`> ${g.description}`);
lines.push('');
if (g.items.length === 0) {
lines.push('_(空)_');
lines.push('');
continue;
}
lines.push('| 收藏时间 | 标题 | 路径 |');
lines.push('|---|---|---|');
for (const e of g.items) {
const t = formatAddedAt(e.addedAt);
const title = e.title.replace(/\|/g, '\\|').replace(/\n/g, ' ');
const rel = e.relPath.replace(/\|/g, '\\|');
lines.push(`| ${t} | ${title} | \`${rel}\` |`);
}
lines.push('');
}
return lines.join('\n');
}
let _instance: FavoritesService | null = null;
export function getFavoritesService(): FavoritesService {
if (!_instance) _instance = new FavoritesService();
return _instance;
}

509
src/preload/index.ts Normal file
View File

@@ -0,0 +1,509 @@
import { contextBridge, ipcRenderer, webUtils } from 'electron';
import {
IPC,
AppSettings,
LiteratureResource,
ReadFileForReaderResult,
SearchHit,
StandaloneNote,
StandaloneNoteSummary,
DiskFolderNode,
FolderMoveResult,
ResourceKind,
WorkspacePaths,
BootstrapPayload,
STARTUP_PROGRESS_EVENT_TYPES,
StartupProgressEvent,
StartupProgressEventType,
StartupProgressSnapshot,
FavoritesFileEntry,
IpcResult,
ResourceId,
ToastPushPayload,
TOAST_EVENT_TYPES,
} from '../shared/types';
// audit v2.0 Phase 0a (2026-09-17):Api interface 从 renderer 手抄迁移到
// src/shared/api-types.ts 单一真相源。preload 用 `const api: Api = {...}`
// 强制对齐 contract —— preload 增删 method 时 TS 编译期会因 assignability
// 失败报红。`typeof api` 在 generic invokeGuard<T> 上会被泛到 unknown,
// 所以不能继续靠 typeof 拿 Api type。
//
// 注意:下方 invokeGuard<T> 调用需要解析 generic 实参,所以 BootstrapPayload
// / AppSettings 等 type 仍要 import —— 即使 Api 已经声明过返回类型,
// TS 不会从 Api 反向回填 generic 上下文。
import type {
Api,
LiteratureImportResult,
LiteratureRenameResult,
NotesImportResult,
} from '../shared/api-types';
// audit r29 (2026-09-21):cq H2 — invokeGuard reject 分支走 errMsg 共享 helper,
// preload 跑在 main 进程(与 main/utils/error 同侧),不引 renderer/lib/error;
// inline `err instanceof Error ? err.message : String(err)` 是第 3 处重复。
// audit-r229:改直连 @shared/display-string —— errMsg 本体已下沉到 shared
// (此前与 main/utils/error.ts 的实现逐字孪生,r217 只下沉了底座)。这同时
// 修掉 preload 的**运行时**引 main:main/utils/error 会把 main 的模块图
// 拖进 renderer 进程的启动路径,与「preload 只做桥、不含业务」冲突;
// 与 r140 把 MAX_TOAST_MESSAGE_CHARS 搬去 @shared/limits/ipc 同款修法。
import { errMsg } from '@shared/display-string';
// audit 2026-09-10:contextBridge 边界 runtime 校验 —— 原实现 4 个 on* listener
// 把 contextBridge 擦到 unknown 的第二参数直接 `as T` 强转,主进程一旦
// 发送异常载荷(regression / 版本错位)会走 `undefined.something` 抛错把
// renderer 整死。统一走 assertPayload 入口,类型不对就 warn + 跳过,
// 不会让 handler 拿到 undefined 触发 NPE。
// 故意不抛:contextBridge 内抛错会冒泡到 electron 内部状态,不如静默
// 跳过 + log.warn 留 trace,行为降级为"本次事件丢失",下次正常事件照常
// 走通。
function assertPayload<T>(
channel: string,
payload: unknown,
// 验证函数:返回 true 表示形状匹配,false 表示跳过 handler。
validate: (p: unknown) => p is T,
): T | null {
if (validate(payload)) return payload;
// 故意不 throw —— 渲染层 IPC 事件丢失比整死主进程友好;log 留 trace。
console.warn(`[preload] ${channel} payload shape mismatch, dropping event`, payload);
return null;
}
// audit 2026-09-16 r21:H13 — ipcRenderer.invoke 的 runtime 形状守卫。
// 原 35 处 `as Promise<IpcResult<T>>` 都是类型撒谎:ipcRenderer.invoke
// 在主进程 handler throw / channel 缺失 / 序列化失败时直接 reject,
// 渲染端 await 永远 pending,无 toast / 无日志,体验上是"按钮没反应"。
// 修复:包一层 invokeGuard,reject 时自动 reject 一个带 {ok:false,...}
// 的 IpcResult,渲染端按统一路径处理错误。
function isIpcResult(value: unknown): value is IpcResult<unknown> {
if (!value || typeof value !== 'object') return false;
const v = value as Record<string, unknown>;
if (v.ok === true) return 'data' in v;
if (v.ok === false) return typeof v.code === 'string' && typeof v.message === 'string';
return false;
}
/**
* IPC 调用护栏 —— 把 wire 层的不确定形状收敛成 `IpcResult`。
*
* audit 2026-10-01 语义说明(改过,别改回去):
* - **形状不对**(主进程正常回了但 envelope 被 regress / channel 错位 / 序列化
* 异常)→ resolve 成 `{ok:false, code:'INTERNAL'}`。这是可预期的业务错误,
* 渲染层 `unwrap()` 统一按 `!ok` 处理。
* - **调用被拒**(handler throw / channel 不存在 / contextBridge 断连)→
* **reject**。旧实现把它 resolve 成 `{ok:false}` 是错的,详见下方注释。
*/
function invokeGuard<T>(channel: string, ...args: unknown[]): Promise<IpcResult<T>> {
return ipcRenderer.invoke(channel, ...args).then(
(raw: unknown): IpcResult<T> => {
if (isIpcResult(raw)) return raw as IpcResult<T>;
// 形状不对 —— 主进程 regress / channel 错位 / 序列化异常。降级为
// 错误响应,让渲染端走统一错误路径(toast 等)。
console.warn(`[preload] ${channel} returned non-IpcResult envelope`, raw);
return {
ok: false,
code: 'INTERNAL',
message: `[preload] ${channel} returned non-IpcResult envelope`,
};
},
(err: unknown): never => {
// 主进程 handler throw / channel 缺失 / 序列化失败 → ipcRenderer.invoke reject。
//
// audit 2026-10-01:HIGH — 这里是 `.then(onFulfilled, onRejected)` 的**第二个
// 参数**,旧实现 `return { ok:false, code:'IPC_REJECTED', message }` 把
// rejection 转成了 **fulfillment**。后果是全仓所有
// `api.xxx().catch(...)` 永远不可能执行(实测 17 处死代码),而其中
// settings.ts 的 `.then(() => localStorage.removeItem(...))` 更会在
// `{ok:false}` 时照样删掉兜底 key,让 pending layout 永久丢失 ——
// 典型的「错误被静默吞掉」。
// 另:形状不对的 envelope(上一个 handler 分支)仍然 return,那是
// **主进程正常回了但内容坏了**,属于可预期的业务错误,保持 resolve。
const message = errMsg(err);
console.warn(`[preload] ${channel} invoke rejected`, message);
// 抛出的对象在结构上与 renderer 的 IpcError 对齐(name/code/message),
// 于是 catch 块里读 err.code / errMsg(err) 的既有写法照常工作。
const wrapped = new Error(`[preload] ${channel} invoke rejected: ${message}`) as Error & {
code?: string;
field?: string;
size?: number;
};
wrapped.name = 'IpcError';
// audit-r161:透传主进程 AppError 跨 structured clone 保留的 code/field/size
const errObj =
err && typeof err === 'object' ? (err as Record<string, unknown>) : null;
if (errObj) {
if (typeof errObj['code'] === 'string') wrapped.code = errObj['code'];
if (typeof errObj['field'] === 'string') wrapped.field = errObj['field'];
if (typeof errObj['size'] === 'number') wrapped.size = errObj['size'];
}
if (!wrapped.code) wrapped.code = 'IPC_REJECTED';
throw wrapped;
},
);
}
// audit r52 (2026-10-15) C5:toast message 字符上限 —— 主进程一次塞 100MB
// 字符串让 renderer 撑爆是 DoS。集中到 `@shared/limits/ipc`:
// MAX_TOAST_MESSAGE_CHARS 后,main + preload 共享单一来源(r52 之前在
// main/ipc/path-limits,preload 跨进程 import main 间接,搬到 shared 直连)。
import { MAX_TOAST_MESSAGE_CHARS } from '@shared/limits/ipc';
// audit-r92:toast type 边界白名单由 @shared/types TOAST_EVENT_TYPES 单源 tuple
// 派生(对齐 STARTUP_PROGRESS 守卫的 Set 派生模式)。放模块作用域:每事件
// 重建 Set 是纯浪费(STARTUP 侧在 listener 体内构造,属同款小 perf 噪声,
// 此处不复制该写法)。
const ALLOWED_TOAST_TYPES = new Set<ToastPushPayload['type']>(TOAST_EVENT_TYPES);
// audit v2.0 Phase 0a:`const api: Api` 强制实现 contract,新增/删 method
// 时 TS 编译期报红。
// audit-r56:删 pickLibraryPath / pickImportRoot / libraryMigrationMove / Keep /
// libraryMigrationGetState / onLibraryMigrationProgress / listLibrary /
// rescanLibrary / changeLibraryPath / restoreDefaultLibraryPath / openFile /
// importFiles / importPaths / onLibraryChanged / onLibraryScanProgress /
// onLibraryScanDone / libraryScanProgressGetState —— library 整体下线。
// 留下的文献管理 surface 唯一一条:readFileForReader,入参从 string(id) 改成
// string(absPath),与 src/main/library/reader.ts 同步。importNoteFromFile
// 返回类型由 ImportFilesResult 改成 NotesImportResult(语义同源但避免
// 让人误以为与 library 导入共用)。
const api: Api = {
version: '0.1.0',
platform: process.platform,
// perf(2026-08-22):启动期一次性拉取 settings + workspace paths。
// 替代启动期 useSettingsStore.load() + usePathsStore.load() 的两次串行 IPC,
// 合并为单次 roundtrip,消除中间渲染态。
// audit 2026-08-26 (round 16):cast 改成 IpcResult<BootstrapPayload> —— 之前
// `as Promise<BootstrapPayload>` 是类型撒谎(wrap.ts 实际返回 { ok, data })。
bootstrap: () => invokeGuard<BootstrapPayload>(IPC.APP_BOOTSTRAP),
// 设置
getSettings: () => invokeGuard<AppSettings>(IPC.SETTINGS_GET),
updateSettings: (patch: Partial<AppSettings>) =>
invokeGuard<AppSettings>(IPC.SETTINGS_UPDATE, patch),
pickDataPath: () => invokeGuard<string | null>(IPC.SETTINGS_PICK_DATA_PATH),
migrateData: (newPath: string) => invokeGuard<AppSettings>(IPC.SETTINGS_MIGRATE_DATA, newPath),
// 恢复默认数据路径 —— 走 location.defaultDataPath()。返回最新 settings。
restoreDefaultDataPath: () => invokeGuard<AppSettings>(IPC.SETTINGS_RESTORE_DEFAULT_DATA),
// 文献管理 —— audit-r56 后唯一 surface
// r191 更正:旧注释说「入参 absPath 由 renderer 从 <input type="file"> +
// webUtils.getPathForFile() 派生」—— **已不成立**。OS 文件选择器只用于
// **导入**(拷贝进工作区),不用于读;本通道的唯一生产调用点是
// renderer/Reader.tsx 的 `api.readFileForReader(file.absPath)`,file 是
// literature store 的一行 ⇒ absPath 恒在 <rootPath>/literature/ 内。
// 主进程自 r191 起对这层入参加**工作区白名单**(区外 / symlink 逃逸 → EPERM),
// 与 rkms:// 协议、shell.open-path 同款。
// 主进程 src/main/library/reader.ts:readFileForReader 不再做 id 查表,
// 直接按 path 读 bytes + size + mtime。
readFileForReader: (absPath: string) =>
invokeGuard<ReadFileForReaderResult>(IPC.LIBRARY_READER_FOR_FILE, absPath),
// 文献操作面(audit-r71)—— resources 单表 list / 拷入式导入 / 删除。
// v1 无 push 通道:import / delete 成功后 renderer(store/literature.ts)
// 主动 listLiterature() 重拉。
listLiterature: () => invokeGuard<LiteratureResource[]>(IPC.LITERATURE_LIST),
importLiteratureFromFile: (sourceAbsPath: string) =>
invokeGuard<LiteratureImportResult>(IPC.LITERATURE_IMPORT_FROM_FILE, sourceAbsPath),
deleteLiterature: (id: string) => invokeGuard<null>(IPC.LITERATURE_DELETE, id),
// audit-r136:literature push 订阅 —— LiteratureService.chokidar add/change/
// unlink → reload() → byId 重建 → emit('changed') → 主进程 broadcastToAllWindows
// (IPC.LITERATURE_CHANGED) → 这里 listener 转发到 renderer useLiteratureStore。
// 镜像 onNotesChanged 形态(handler 无参,disposer 返 removeListener)。
// 契约锁 subscription-disposer-contract.test.ts。
onLiteratureChanged: (handler: () => void) => {
const listener = () => handler();
ipcRenderer.on(IPC.LITERATURE_CHANGED, listener);
return () => ipcRenderer.removeListener(IPC.LITERATURE_CHANGED, listener);
},
// audit-r211:配对的 pull 通道 literatureGetState 整对下线 —— 它与
// listLiterature 同源同形(都是 svc.list()),而 renderer 从来没调用过它。
// 「push 先于 listener 注册」的竞态由「先订阅再 load」覆盖。
// 笔记
// perf (2026-09-06):listNotes 返回精简版 summary,无 body。详见 shared/types
// StandaloneNoteSummary。body 走 NOTES_READ 按 id 按需拉。
listNotes: () => invokeGuard<StandaloneNoteSummary[]>(IPC.NOTES_LIST),
// audit 2026-09-21 audit-r24 — readNote 找不到 id 抛 NOT_FOUND,不再返 null。
// 泛型与 shared/api-types.ts 的契约一致,去掉 `| null` 防止 renderer
// 误走防御性 null 路径(实际不可能命中)。
readNote: (id: string) => invokeGuard<StandaloneNote>(IPC.NOTES_READ, id),
// audit 2026-10-08 (HIGH):expectedMtime 透传 —— 主进程比对当前 mtime 与
// renderer 期望值,不一致抛 CONFLICT,防止外部修改被静默覆盖(详见
// shared/api-types.ts 的 writeNote 注释)。无 expectedMtime 走 last-writer-wins。
writeNote: (id: string, body: string, title?: string, expectedMtime?: number) =>
invokeGuard<StandaloneNote>(IPC.NOTES_WRITE, id, body, title, expectedMtime),
createNote: (filename: string) => invokeGuard<StandaloneNote>(IPC.NOTES_CREATE, filename),
trashNote: (id: string) => invokeGuard<void>(IPC.NOTES_TRASH, id),
// audit-r73 (2026-10-17) CRITICAL:此前只透传 newFilename,**丢掉了 id** ——
// 主进程 handler requireStrings(id, newFilename) 两参都必传,于是运行时
// 重命名笔记文件永远 VALIDATION 失败。契约锁 tests/unit/preload/
// invoke-arg-passthrough.test.ts(全 invoke 方法逐参透传行为级守卫)。
renameNoteFile: (id: string, newFilename: string) =>
invokeGuard<StandaloneNote>(IPC.NOTES_RENAME_FILE, id, newFilename),
// 笔记「在文件管理器中显示」。方法名加 Note 前缀以区分 id 空间
// (历史上曾与 library:show-in-folder 对称,library 通道 r56 已下线)。
showNoteInFolder: (id: string) => invokeGuard<void>(IPC.NOTES_SHOW_IN_FOLDER, id),
// 拖入导入:从 OS 拖入的 .md 文件 → 主进程解析 + createNote + writeNote。
// 走 createNote/writeNote 会自动 emit NOTES_CHANGED,renderer 不需主动 reload。
// audit-r56:返回类型由 ImportFilesResult 改为 NotesImportResult —— 形态
// 相同(都是 copied / skipped / errors / warnings / createdIds),但语义上
// 只服务笔记导入,避免与「library 导入」残留概念混淆。
importNoteFromFile: (path: string) =>
invokeGuard<NotesImportResult>(IPC.NOTES_IMPORT_FROM_FILE, path),
// r161+ disk-folder:侧栏文件管理走真实磁盘目录,2 域 × 4 操作 = 8 wrapper
// (notes/literature 各管各的 kind 树,不走旧的 folders 域)
notesListDirs: (kind: ResourceKind, parentRelPath: string | null) =>
invokeGuard<DiskFolderNode[]>(IPC.NOTES_FOLDERS_LIST_DIRS, kind, parentRelPath),
notesMkdir: (relPath: string) =>
invokeGuard<{ relPath: string }>(IPC.NOTES_FOLDERS_MKDIR, relPath),
notesRenameDir: (oldRelPath: string, newName: string) =>
invokeGuard<FolderMoveResult>(IPC.NOTES_FOLDERS_RENAME_DIR, oldRelPath, newName),
notesTrashDir: (relPath: string) =>
invokeGuard<null>(IPC.NOTES_FOLDERS_TRASH_DIR, relPath),
litListDirs: (kind: ResourceKind, parentRelPath: string | null) =>
invokeGuard<DiskFolderNode[]>(IPC.LITERATURE_FOLDERS_LIST_DIRS, kind, parentRelPath),
litMkdir: (relPath: string) =>
invokeGuard<{ relPath: string }>(IPC.LITERATURE_FOLDERS_MKDIR, relPath),
litRenameDir: (oldRelPath: string, newName: string) =>
invokeGuard<FolderMoveResult>(IPC.LITERATURE_FOLDERS_RENAME_DIR, oldRelPath, newName),
litTrashDir: (relPath: string) =>
invokeGuard<null>(IPC.LITERATURE_FOLDERS_TRASH_DIR, relPath),
// r161+ disk-folder Phase 2c:跨文件夹移动资源。targetRelPath null = 根
// ('' 段)。notes 返 { relPath, id },literature 返 { absPath, id }。
notesMove: (id: string, targetRelPath: string | null) =>
invokeGuard<{ relPath: string; id: string }>(IPC.NOTES_MOVE, id, targetRelPath ?? ''),
litMove: (id: string, targetRelPath: string | null) =>
invokeGuard<{ absPath: string; id: ResourceId }>(IPC.LITERATURE_MOVE, id, targetRelPath ?? ''),
// r187:文献单文件重命名(与 notesRenameFile 对称)。lit id = 'lit:'+absPath
// ⇒ 改名后 id 变,返回新 id + 新旧 absPath 让 renderer 把选中态与
// 收藏条目跟到新 id。
litRenameFile: (id: string, newFilename: string) =>
invokeGuard<LiteratureRenameResult>(
IPC.LITERATURE_RENAME_FILE,
id,
newFilename,
),
// audit-r173:目录(子树)跨父目录搬移。targetParentRelPath null = 根('' 段)。
notesMoveDir: (oldRelPath: string, newParentRelPath: string | null) =>
invokeGuard<FolderMoveResult>(
IPC.NOTES_FOLDERS_MOVE_DIR,
oldRelPath,
newParentRelPath ?? '',
),
litMoveDir: (oldRelPath: string, newParentRelPath: string | null) =>
invokeGuard<FolderMoveResult>(
IPC.LITERATURE_FOLDERS_MOVE_DIR,
oldRelPath,
newParentRelPath ?? '',
),
// audit-r73:删 notesResyncProgressGetState —— NOTES_RESCYNC_PROGRESS(push)
// 从未有任何 send 点、GET_STATE 从未有 renderer 调用方,整对下线。
// ghost 行清理自 audit-r124 起由 NotesService.resyncIfNeeded 就地完成。
// audit-r156:settings.notesResyncPending 字段同链路一并删除。
// 收藏 — audit-r211:favoritesGetState 已整对下线(空壳 API),
// favorites 的 push 订阅见下方 onFavoritesChanged。
// 搜索
search: (query: string) => invokeGuard<SearchHit[]>(IPC.SEARCH_QUERY, query),
// 收藏夹 — 读 list / 写 toggle 是仅剩的 2 个公开 API。
// 重构 (2026-09 V1.4):createGroup / deleteGroup / addItemToGroup / removeItemFromGroup /
// setGroupDescription 全部删除 —— favorites 分组 read-only;V2.0 需要管理 UI 时再 wire。
// audit-r56:LibraryList 退役。audit-r72:★ toggle 与「全部/仅收藏」chip 过滤
// 现由 LiteratureList 承担(旧组件删除,r71/r72 重建时接线恢复)。Api 表面不动。
// 事件订阅
// audit-r56:删 onLibraryChanged / onLibraryScanProgress / onLibraryScanDone
// —— library 推送通道整体下线。ScanErrorSummary / ScanSummary type
// guard helper 也一并删除(无 consumer)。
onNotesChanged: (handler: () => void) => {
const listener = () => handler();
ipcRenderer.on(IPC.NOTES_CHANGED, listener);
return () => ipcRenderer.removeListener(IPC.NOTES_CHANGED, listener);
},
// audit 2026-09-14 r7:主进程 → renderer 推 toast 通道。payload 形态 =
// @shared/types ToastPushPayload(type 派生自单源 tuple,见下守卫)。
// renderer 端 useToast.show() 包装消费。
// audit round 9 (2026-09-15):补 assertPayload 守卫 —— 其它 on* listener
// (MENU_INVOKE / STARTUP_PROGRESS / WINDOW_MAXIMIZE_CHANGED)
// 都有 shape 校验,TOAST_PUSH 是唯一裸 `handler(payload as never)` 的 listener。
// 现状 message 是 React text 渲染,XSS 走不通,但未来若有 consumer 走
// dangerouslySetInnerHTML,主进程一次 type 漏发 / duration=NaN 就会静默 RCE。
// 与其它 listener 对齐守卫:type 在白名单 + message 是非空 string + duration 数字。
// audit-r92 (2026-10-17):type 白名单改由 TOAST_EVENT_TYPES 派生 Set(CQ-002
// 同款修法,对齐 STARTUP_PROGRESS 守卫)—— 原 `t !== 'info' && ...` 4 条件
// 手抄是跨进程 5 份拷贝里唯一有运行时强制力的一份:ToastType 加成员时
// TS 不会碰它,新 type 事件在边界被静默丢弃(console.warn 不进 renderer UI)。
onToastPush: (handler: (payload: ToastPushPayload) => void) => {
const listener = (_: unknown, payload: unknown) => {
const ok = assertPayload(
IPC.TOAST_PUSH,
payload,
(p): p is ToastPushPayload => {
if (typeof p !== 'object' || p === null) return false;
const o = p as Record<string, unknown>;
const t = o['type'];
if (!ALLOWED_TOAST_TYPES.has(t as ToastPushPayload['type'])) return false;
const m = o['message'];
if (typeof m !== 'string' || m.length === 0 || m.length > MAX_TOAST_MESSAGE_CHARS) {
return false;
}
const d = o['duration'];
// audit round 14 (2026-09-15):CQ-006 — duration NaN / Infinity
// 守卫。setTimeout(NaN) 永不触发,toast 永久不消失;Infinity
// 等同极长 timeout(几亿年),setTimeout 内部 clamp 到 1ms,
// 行为虽不崩但语义错。
if (d !== undefined && (typeof d !== 'number' || !Number.isFinite(d))) return false;
return true;
},
);
if (ok !== null) handler(ok);
};
ipcRenderer.on(IPC.TOAST_PUSH, listener);
return () => ipcRenderer.removeListener(IPC.TOAST_PUSH, listener);
},
// 独立收藏 — 重构 (2026-09 V1.4):group CRUD 5 个方法(createFavoriteGroup / delete
// / addItem / removeItem / setGroupDescription)删除 —— ★ toggle 与 chip
// 过滤曾由 LibraryList 提供。
// audit r54 (2026-10-16) I6:从 FavoritesEntry[] 收紧到 FavoritesFileEntry[],
// 与 IPC handler filter 对齐(group 无 renderer consumer)。
// audit-r56:LibraryList 退役。audit-r72:chip 过滤现由 LiteratureList 承担。
listFavorites: () => invokeGuard<FavoritesFileEntry[]>(IPC.FAVORITES_LIST),
toggleFavorite: (fileId: string, info?: { title?: string; relPath?: string }) =>
invokeGuard<{ favorited: boolean; fileId: string }>(IPC.FAVORITES_TOGGLE, fileId, info),
onFavoritesChanged: (handler: () => void) => {
const listener = () => handler();
ipcRenderer.on(IPC.FAVORITES_CHANGED, listener);
return () => ipcRenderer.removeListener(IPC.FAVORITES_CHANGED, listener);
},
// 通用 shell + 模块数据路径
// 路径校验由主进程负责(白名单 + fs.access),渲染层不能传任意路径
openPath: (p: string) => invokeGuard<{ opened: string }>(IPC.SHELL_OPEN_PATH, p),
// fix drag-drop (2026-10-13):从拖入 / DnD 事件里的 File 派生 OS 绝对路径。
// 背景:旧 LibraryList / NotesList 走 `f.path`(Electron 在 File 对象上的
// 非标准扩展)。Electron 30 已 deprecate,Electron 32 彻底移除 —— 升级时
// 拖入会静默变成 `paths.length===0`,UI 显示"拖入内容不含可识别路径"
// toast,用户无法判断是 bug 还是没选文件。
// webUtils.getPathForFile 是 Electron 30+ 官方推荐路径(沙箱兼容、renderer
// 侧模块),接受 File 实例返回 OS 绝对路径,folder drop 也走同一接口
// (folder 的 File.size=0 但 entry.fullPath = folder 绝对路径)。
// 兜底:不是所有 File 都有 OS 路径(blob: URL / 剪贴板虚拟文件 / webkitGetAsEntry
// 路径),返回 null 让 renderer 走 f.path 旧路径 —— 渐进降级不破坏老 Electron。
// try/catch 防御 webUtils 内部抛(实测极少,但 File 是 `new DataTransfer()` 的
// 虚拟条目时偶发)。
getPathForFile: (file: File): string | null => {
try {
// webUtils 是 renderer-side 模块,只在 preload 注入,不走 wire 协议。
return webUtils.getPathForFile(file);
} catch {
return null;
}
},
// audit 2026-08-26 (round 16):cast 改成 IpcResult<WorkspacePaths> —— 之前
// `as Promise<WorkspacePaths>` 是类型撒谎。
getWorkspacePaths: () => invokeGuard<WorkspacePaths>(IPC.WORKSPACE_GET_PATHS),
// Phase 5: 应用菜单点击桥
// 主进程 send(IPC.MENU_INVOKE, { id }) → 这里转发到渲染层
// App.tsx 订阅 → dispatcher.invoke(id) → 走统一调度入口
// i18n (2026-09):renderer 切语言后调 rebuildMenu,主进程按当前 settings.language
// 重建菜单 + 更新窗口 title(都不归 react-i18next 管)。
rebuildMenu: () => invokeGuard<void>(IPC.MENU_REBUILD),
onMenuInvoke: (handler: (data: { id: string }) => void) => {
const listener = (_: unknown, data: unknown) => {
// audit 2026-09-10:{id: string} 守卫 —— id 缺失 / 非 string 会让
// dispatcher.invoke 抛 "id is not a string" 同步错,菜单响应静默死掉。
const ok = assertPayload(
IPC.MENU_INVOKE,
data,
(p): p is { id: string } =>
typeof p === 'object' && p !== null && typeof (p as { id?: unknown }).id === 'string',
);
if (ok !== null) handler(ok);
};
ipcRenderer.on(IPC.MENU_INVOKE, listener);
return () => ipcRenderer.removeListener(IPC.MENU_INVOKE, listener);
},
// 启动期进度 — invoke 拉快照(兜底"主进程 push 已先于 renderer listener"竞态)
// + 订阅增量事件。contextBridge 边界把 listener 参数擦除到 unknown,
// 这里显式 cast / shape-guard 到 discriminated union 类型。
getStartupState: () => invokeGuard<StartupProgressSnapshot>(IPC.STARTUP_GET_STATE),
onStartupProgress: (handler: (event: StartupProgressEvent) => void) => {
const listener = (_: unknown, event: unknown) => {
// audit 2026-09-10:StartupProgressEvent discriminated union 守卫 ——
// 这是 listener 里 shape 最复杂的(type + 多字段),漏 type 字段
// 会让 useStartupStore.applyEvent 走到 default 分支直接吞事件,UI
// 卡在"启动中" 永远不结束。守卫要满足"对象 + type 是字面量之一"。
// audit round 15 (2026-09-15):CQ-002 — 之前守卫手抄 4 个字面量,与
// StartupProgressEvent union 漂移(union 实际有 phase-start /
// progress / phase-done / all-done 四个,守卫误写为 phase-start /
// phase-done / error / log —— 后两个早已不在 union 中,前两个
// progress + all-done 会被守卫拒掉)。改用 Set<StartupProgressEventType>
// 派生自动同步 union 增减。
// audit-r56:删 'progress' 事件 —— library phase 用它报扫描计数,library
// 下线后没有 phase 需要 progress 增量事件。STARTUP_PROGRESS_EVENT_TYPES
// 缩到 phase-start / phase-done / all-done。
const ALLOWED_STARTUP_EVENT_TYPES = new Set<StartupProgressEventType>(
STARTUP_PROGRESS_EVENT_TYPES,
);
const ok = assertPayload(
IPC.STARTUP_PROGRESS,
event,
(p): p is StartupProgressEvent =>
typeof p === 'object' &&
p !== null &&
typeof (p as { type?: unknown }).type === 'string' &&
ALLOWED_STARTUP_EVENT_TYPES.has((p as { type: string }).type as StartupProgressEventType),
);
if (ok !== null) handler(ok);
};
ipcRenderer.on(IPC.STARTUP_PROGRESS, listener);
return () => ipcRenderer.removeListener(IPC.STARTUP_PROGRESS, listener);
},
// 自定义无边框窗口控制 (2026-09):渲染层 TitleBar 调这些去操作原生窗口。
// Mac 上 TitleBar 不渲染这三个按钮(系统红绿灯接管),但 API 仍可用(供菜单或
// 快捷键调 minimize/close 等场景)。
windowMinimize: () => invokeGuard<void>(IPC.WINDOW_MINIMIZE),
windowMaximizeToggle: () => invokeGuard<void>(IPC.WINDOW_MAXIMIZE_TOGGLE),
windowClose: () => invokeGuard<void>(IPC.WINDOW_CLOSE),
windowIsMaximized: () => invokeGuard<boolean>(IPC.WINDOW_IS_MAXIMIZED),
onWindowMaximizeChanged: (handler: (maximized: boolean) => void) => {
const listener = (_: unknown, payload: unknown) => {
// audit 2026-09-10:boolean 守卫 —— 与 SCAN_PROGRESS 同款,非 boolean
// 时 TitleBar 最大化图标状态卡在"普通" 不再切换。
const ok = assertPayload(
IPC.WINDOW_MAXIMIZE_CHANGED,
payload,
(p): p is boolean => typeof p === 'boolean',
);
if (ok !== null) handler(ok);
};
ipcRenderer.on(IPC.WINDOW_MAXIMIZE_CHANGED, listener);
return () => ipcRenderer.removeListener(IPC.WINDOW_MAXIMIZE_CHANGED, listener);
},
// ===== Pull-snapshot (r54 I4) =====
//
// 每个 GET_STATE 对应一个 push channel —— push 在 renderer 启动期 / 重新
// mount 时容易错过(主进程 send 时 renderer listener 还没注册),GET_STATE
// 让 renderer 主动拉一次当前状态作为 baseline,返回值与对应 push 同形。
// audit-r56:删 libraryMigrationGetState / libraryScanProgressGetState ——
// library 通道下线,这两个 GET_STATE 也无消费方。
// audit-r73:删 notesResyncProgressGetState(整对无发送点/无调用方)。
// audit-r211:删 favoritesGetState / literatureGetState —— 两条都是**空壳**:
// renderer 全域零调用点,且各自与同域的 LIST 通道同源同形。竞态由
// 「先订阅再 load」覆盖,不需要额外一次 pull。**退役通道不得复活** ——
// 守卫:tests/unit/main/ipc/pull-snapshot-companion.test.ts
//
// 现存 pull-snapshot 配对只有一条(启动进度):
// STARTUP_PROGRESS → STARTUP_GET_STATE(见上方 getStartupState)
};
contextBridge.exposeInMainWorld('api', api);
// audit v2.0 Phase 0a:旧 `export type Api = typeof api` 删除 ——
// Api type 现在从 ../shared/api-types import。`typeof api` 在 generic
// invokeGuard<T> 上会被泛到 unknown,不精确。

Some files were not shown because too many files have changed in this diff Show More