// 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 的