update
This commit is contained in:
31
.eslintrc.cjs
Normal file
31
.eslintrc.cjs
Normal 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
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
out/
|
||||
dist/
|
||||
release/
|
||||
5
.lintstagedrc.json
Normal file
5
.lintstagedrc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
|
||||
"*.{md,json,css}": ["prettier --write"],
|
||||
"*.cjs": ["prettier --write"]
|
||||
}
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
93
README.md
Normal 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
33
commitlint.config.cjs
Normal 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
182
electron.vite.config.ts
Normal 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()],
|
||||
},
|
||||
});
|
||||
13400
package-lock.json
generated
Normal file
13400
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
133
package.json
Normal file
133
package.json
Normal 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
3
postcss.config.cjs
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||
};
|
||||
160
scripts/commit-msg-preprocess.cjs
Normal file
160
scripts/commit-msg-preprocess.cjs
Normal 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
203
scripts/rebuild-native.cjs
Normal 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
109
scripts/run-tests.cjs
Normal 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
72
scripts/shutdown.cjs
Normal 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
529
scripts/start.cjs
Normal 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;
|
||||
}
|
||||
21
scripts/verify-abi-node.cjs
Normal file
21
scripts/verify-abi-node.cjs
Normal 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
196
src/main/db/cjk-segment.ts
Normal 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);
|
||||
}
|
||||
76
src/main/db/migrations/0001_init.sql
Normal file
76
src/main/db/migrations/0001_init.sql
Normal 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'
|
||||
);
|
||||
46
src/main/db/migrations/0002_drop_dead_cols.sql
Normal file
46
src/main/db/migrations/0002_drop_dead_cols.sql
Normal 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;
|
||||
52
src/main/db/migrations/0003_fts_external_content.sql
Normal file
52
src/main/db/migrations/0003_fts_external_content.sql
Normal 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;
|
||||
58
src/main/db/migrations/0004_resources.sql
Normal file
58
src/main/db/migrations/0004_resources.sql
Normal 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'
|
||||
);
|
||||
@@ -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;
|
||||
44
src/main/db/migrations/0006_repair_doubled_lit_abs.sql
Normal file
44
src/main/db/migrations/0006_repair_doubled_lit_abs.sql
Normal 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, '/', '\')
|
||||
);
|
||||
31
src/main/db/migrations/0007_folders.sql
Normal file
31
src/main/db/migrations/0007_folders.sql
Normal 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);
|
||||
35
src/main/db/migrations/0008_drop_folders.sql
Normal file
35
src/main/db/migrations/0008_drop_folders.sql
Normal 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;
|
||||
35
src/main/db/migrations/0009_cjk_segment.sql
Normal file
35
src/main/db/migrations/0009_cjk_segment.sql
Normal 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
36
src/main/db/migrator.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
444
src/main/db/resources-repo.ts
Normal file
444
src/main/db/resources-repo.ts
Normal 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
118
src/main/db/sqlite.ts
Normal 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
77
src/main/document-csp.ts
Normal 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
357
src/main/i18n-dict.ts
Normal 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
54
src/main/i18n.ts
Normal 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
335
src/main/index.ts
Normal 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
44
src/main/ipc/bootstrap.ts
Normal 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
37
src/main/ipc/broadcast.ts
Normal 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
356
src/main/ipc/folders.ts
Normal 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
41
src/main/ipc/index.ts
Normal 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
74
src/main/ipc/library.ts
Normal 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
178
src/main/ipc/literature.ts
Normal 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
50
src/main/ipc/menu.ts
Normal 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
279
src/main/ipc/notes.ts
Normal 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 同链路一并删除。
|
||||
}
|
||||
68
src/main/ipc/path-limits.ts
Normal file
68
src/main/ipc/path-limits.ts
Normal 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
326
src/main/ipc/paths.ts
Normal 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
53
src/main/ipc/search.ts
Normal 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
166
src/main/ipc/settings.ts
Normal 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
88
src/main/ipc/shell.ts
Normal 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
11
src/main/ipc/startup.ts
Normal 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
68
src/main/ipc/window.ts
Normal 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
70
src/main/ipc/workspace.ts
Normal 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
210
src/main/ipc/wrap.ts
Normal 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 找回即可。
|
||||
47
src/main/library/reader.ts
Normal file
47
src/main/library/reader.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
236
src/main/literature/import.ts
Normal file
236
src/main/literature/import.ts
Normal 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;
|
||||
}
|
||||
1581
src/main/literature/literature-service.ts
Normal file
1581
src/main/literature/literature-service.ts
Normal file
File diff suppressed because it is too large
Load Diff
211
src/main/literature/rename-file.ts
Normal file
211
src/main/literature/rename-file.ts
Normal 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
378
src/main/location.ts
Normal 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
167
src/main/menu.ts
Normal 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));
|
||||
}
|
||||
140
src/main/notes/filename-validator.ts
Normal file
140
src/main/notes/filename-validator.ts
Normal 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
190
src/main/notes/import.ts
Normal 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
40
src/main/notes/note-id.ts
Normal 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);
|
||||
}
|
||||
71
src/main/notes/path-mapping.ts
Normal file
71
src/main/notes/path-mapping.ts
Normal 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
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
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
226
src/main/protocol.ts
Normal 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">📄</div>
|
||||
<h1>文件未找到</h1>
|
||||
<p>文件可能已被移动、删除或重命名。<br />
|
||||
File not found — 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;
|
||||
}
|
||||
148
src/main/settings/migration-layout.ts
Normal file
148
src/main/settings/migration-layout.ts
Normal 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`);
|
||||
// 失败不动源,下次启动重试(老目录仍在)
|
||||
}
|
||||
}
|
||||
334
src/main/settings/migration.ts
Normal file
334
src/main/settings/migration.ts
Normal 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;
|
||||
}
|
||||
120
src/main/settings/root-remap.ts
Normal file
120
src/main/settings/root-remap.ts
Normal 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
532
src/main/settings/store.ts
Normal 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 };
|
||||
}
|
||||
100
src/main/settings/validate.ts
Normal file
100
src/main/settings/validate.ts
Normal 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;
|
||||
}
|
||||
176
src/main/startup-progress.ts
Normal file
176
src/main/startup-progress.ts
Normal 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
261
src/main/startup.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
389
src/main/utils/atomic-file.ts
Normal file
389
src/main/utils/atomic-file.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
51
src/main/utils/body-cap.ts
Normal file
51
src/main/utils/body-cap.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
38
src/main/utils/content-disposition.ts
Normal file
38
src/main/utils/content-disposition.ts
Normal 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
241
src/main/utils/error.ts
Normal 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 早已下线(模块整体删除后),仅留注释。
|
||||
94
src/main/utils/file-check.ts
Normal file
94
src/main/utils/file-check.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
132
src/main/utils/frontmatter.ts
Normal file
132
src/main/utils/frontmatter.ts
Normal 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
176
src/main/utils/fs-cache.ts
Normal 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
97
src/main/utils/logger.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
305
src/main/utils/migrate-old-userdata.ts
Normal file
305
src/main/utils/migrate-old-userdata.ts
Normal 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;
|
||||
}
|
||||
35
src/main/utils/optimistic-concurrency.ts
Normal file
35
src/main/utils/optimistic-concurrency.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
167
src/main/utils/path-check.ts
Normal file
167
src/main/utils/path-check.ts
Normal 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 {};
|
||||
53
src/main/utils/path-compare.ts
Normal file
53
src/main/utils/path-compare.ts
Normal 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);
|
||||
}
|
||||
36
src/main/utils/platform.ts
Normal file
36
src/main/utils/platform.ts
Normal 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';
|
||||
86
src/main/utils/retry-transient.ts
Normal file
86
src/main/utils/retry-transient.ts
Normal 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
49
src/main/utils/reveal.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
47
src/main/utils/run-pool.ts
Normal file
47
src/main/utils/run-pool.ts
Normal 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);
|
||||
}
|
||||
817
src/main/utils/safe-write.ts
Normal file
817
src/main/utils/safe-write.ts
Normal 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) }));
|
||||
}
|
||||
86
src/main/utils/scrub-internal.ts
Normal file
86
src/main/utils/scrub-internal.ts
Normal 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, '\\$&');
|
||||
}
|
||||
121
src/main/utils/self-write-echo.ts
Normal file
121
src/main/utils/self-write-echo.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/main/utils/short-id.ts
Normal file
17
src/main/utils/short-id.ts
Normal 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
239
src/main/utils/ssrf.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
113
src/main/utils/watcher-release.ts
Normal file
113
src/main/utils/watcher-release.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
124
src/main/utils/write-lock.ts
Normal file
124
src/main/utils/write-lock.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
24
src/main/window-defaults.ts
Normal file
24
src/main/window-defaults.ts
Normal 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
343
src/main/window.ts
Normal 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;
|
||||
}
|
||||
529
src/main/workspace/favorites.ts
Normal file
529
src/main/workspace/favorites.ts
Normal 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
509
src/preload/index.ts
Normal 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
Reference in New Issue
Block a user