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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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