Files
lofyer d406a0a508
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
docs: 记录 CI 与 Linux 平台差异踩坑
2026-08-05 20:28:26 +08:00

21 KiB
Raw Permalink Blame History

AGENTS

面向在本仓库工作的 AI 代理。约定之外的部分按代码里的既有写法照做。

项目性质

Electron 桌面应用:多源文献检索 + 本地书库 + 内置阅读器(PDF/EPUB/MOBI/AZW+ 批注笔记 + AI 助手。 Windows x64 免安装版,用户数据在程序同级 data/

语言与风格

  • 界面文案、提交信息、注释一律中文;标识符英文。
  • 默认不写注释。只在原因不显然时写:隐藏约束、易踩的坑、绕过某个具体 bug 的原因。不要复述代码在做什么。
  • 不用 emoji。散文里避免破折号。
  • 回复简洁,不主动扩展用户没要求的事。

硬性约束

安全边界不可绕过

  • 渲染层永远不能传任意路径读盘。所有文件访问必须过 main.jsresolveReadable(),它只放行书库中真实登记的条目。
  • 分段读取会话(src/reader/range-sessions.js)与 sender 绑定,句柄不透明,webContents 销毁即回收。新增 IPC 时必须校验 sender。
  • 所有 ipcMain.handlewrap(),同步抛出也要变成 { ok:false },否则渲染层的 await 无 catch,界面会永远卡在加载中。
  • 不要把密钥、token 写进日志或错误信息。AI Key 经 safeStorage 加密后落盘。

依赖

  • 前端第三方库全部 vendored 在 src/ui/vendor/,版本在 package.json 中锁死(不用 ^)。不要引入新的运行时依赖,除非用户明确要求。
  • src/ui/vendor/pdf.worker.range.mjs手工 patch 过的 PDF.js worker,不要用上游文件覆盖。见下文。

PDF 分段读取(易踩坑)

超大 PDF 不整文件读入内存,走 reader:rangeOpen/rangeRead/rangeClosereader:bytes 直接拒绝 PDF。

worker 选择策略在 pdf-adapter.mjs

  • ≤256 MB:用官方 workerpdf.worker.min.mjs),即使是 range 模式。
  • 256 MB:用稀疏 workerpdf.worker.range.mjs)。

稀疏 worker 的基础缓冲区是空的(new Uint8Array(0)),因此任何对 stream.bytes.buffer 直接建视图的代码都会抛 RangeError。已知踩过的坑:preEvaluateFont 里的 ToUnicode 哈希,错误表现是字体静默降级成不可见的 ErrorFont(文本项数量正常,但画布上没有墨迹)。必须用 stream.getByteRange(start, end)

改动这个 worker 后,用真实 PDF 对比三条路径的渲染结果(文本项数 + 非白像素数):官方 worker 全量数据 / 官方 worker range / 稀疏 worker range。三者应一致。

PDF 渲染画质

  • 底栏「画质」档位(1/2/3)经 renderOpts 下发为 opts.renderQuality,持久化在 reader.pdfRenderQuality
  • 生效倍率是 max(devicePixelRatio, renderQuality)HiDPI 屏上不叠乘。所以断言时不能写「backing 翻倍」,只能断言 backing / CSS 宽度 == 期望倍率;开发机 dpr 常见 1.75,写死 2 会假失败。
  • 画布尺寸必须过 clampCanvasSize()Chromium 单边上限 16384px、面积上限 268435456px。超限时浏览器静默给出不可用画布,整页空白且不报错。大幅面图纸(2000pt 以上)在 scale 5 下必然踩中。
  • 钳制后 backing 与 CSS 盒子的比例不再等于名义倍率,pdfPage.rendertransform 必须用 fit.scaleX / fit.scaleY(两个方向分别算,Math.floor 的损失不同),否则画面错位或只画出一角。
  • 倍率变化必须走 epoch++ 整篇重建,只改新渲染的页会让同屏出现清晰度不一致。
  • 集成测试里量画质前要先把视口滚回目标页:离屏页会被回收成空白占位,量到的是占位画布。画布尺寸在重建时立即变大,墨迹要等重绘完才落上去,断言墨迹必须轮询而不是只等比例。

TXT 与 Markdown

  • text-adapter.mjs 把 txt/md 转成内存 EPUB 再交给 epub-adapter 渲染,定位器 kindtxt / md,结构与 epub 同源(chapter + offset)。新增重排格式时记得同步 REFLOW_KINDS
  • 格式白名单有六处,改一处不够:main.jsREADABLE_EXTlibrary/store.jslibrary/local-import.jsBOOK_EXTmain.js 里选文件对话框的 extensions,以及渲染层的两份 READABLE_REui/views/library.jsui/reader/shell.mjs)。单测有正则锁死并逐项比对渲染层与 READABLE_EXT 是否一致。
  • 渲染层那两份漏掉格式不会报错:IPC 照样能打开,但卡片上的「阅读」按钮和封面点击入口静默消失,阅读器的「从书库打开」列表里也少掉这些书。用户看到的现象是"内置阅读器打不开 txt"。集成测试里直接调 reader.open 是查不出来的,必须从书库界面点「阅读」。
  • 书库卡片的「阅读」取的是第一个可阅读文件。同一条书目既有 txt 又有 pdf 时,加进白名单后打开的文件会从 pdf 变成 txt,断言里不要写死 PDF 画布。
  • Markdown 走 markdown-ithtml: false+ DOMPurify 双保险。裸 HTML 会被转义成文本,所以净化断言必须按 DOM 查querySelectorAll('script')on* 属性、a[href] 协议),用 innerHTML 匹配 onerror 会把转义后的字面量误判成漏网。
  • 编码探测支持 UTF-8 / UTF-16LE / UTF-16BE(含 BOM)与 GB18030。带 BOM 的 UTF-16LE 是记事本另存的默认之一,探测错会整本乱码且用户无从修正。

AI 助手

  • 上下文范围:selection | page | document | page-image | region-image
  • 只有 selection 需要选中文本;pagedocument 不依赖选区。
  • document 无论多短都强制弹确认框,并提示可能超出模型上下文限制。
  • 正文完整发送,不做本地截断。模型窗口够不够由接口自己判断,超限时把报错转成中文提示(isContextOverflow / overflowHint)。曾经按 MAX_CHARS = 12000 挖空中间,实测 40 页文档只发出 8 页,而界面仍显示全文字数,属于静默数据丢失,已移除。clipContext 保留给有明确预算上限的场合显式调用。
  • 界面展示的字数必须等于真正外发的字数。任何"先按上限裁剪再发送"的改动都要同步改 aiCost 与确认框文案,否则用户会把答非所问归因于模型。
  • 图像只用 JPEG 单一编码路径,1600px / 目标 400 KB。不要加格式回退(PNG 等),维护成本高于收益;视觉模型按像素图块计费,格式不影响费用。
  • 三种协议:Anthropic /messages、OpenAI Responses /responses、兼容 Chat /chat/completions。图像负载格式各不相同,改动时三条都要验。

AI 多轮会话

  • 会话存在 reader-ai-sessions/,一个会话一个 JSON,外加可重建的 index.json。不进 store.js,避免整本阅读进度被聊天记录带着反复重写。
  • ai:run 里顺序是硬约束:historyFor()appendUser()。顺序反了,当前这轮提问会出现在自己的历史里,模型收到两遍同样的问题。
  • 同一会话禁止并发生成(ai:run 里按 sessionIdaiRuns)。两轮同时写一个文件,后完成的那轮会把前一轮的消息覆盖掉。
  • 消息正文存的是用户看见的那句提问aiTurnTitle),整篇正文只在 contextRef 里留 scope / 字数 / 哈希。把正文当消息存会让重开后的气泡变成十几万字原文,还会被 LIMITS.question 截成一段无意义的残句。
  • 失败和取消都要落盘,并且要把已经流出来的残片一起存(streamed)。只写空串的话,界面上明明显示着半截回答,一重开就消失。
  • 历史只发文本:stream() 只取 roletext,图像一律不重发。历史里内联 base64 会让每轮费用随轮数线性上涨。
  • 落盘失败不能把已经拿到的回答变成请求失败,settleAiAssistant 吞掉异常。
  • 书籍删除、孤立对账都要带上会话(aiSessions.forgetMany / orphanReport),删完调 collectAiImages()。内容寻址的图片没有引用者就永远不会被回收。
  • 集成测试里断言历史时不能按固定下标取消息:本轮提问固定在末尾,中间是历史。原来写死 messages[1] 的断言在多轮上线后会取到上一轮,表现为"图像尺寸无效"这种完全无关的报错。

笔记独立窗口

  • src/reader/note-window.js单窗口多标签:一个笔记窗口,一条笔记一个标签,已开则切到该标签。同一条笔记两处编辑时 reader:updateNote 是整条覆盖、无版本校验,后保存者会把画布内容整块吃掉,所以「一条笔记只能有一个编辑器」是数据安全约束,不是体验优化。openTab 必须先 tabOf() 查重。
  • 阅读器内的笔记模态保留,因此「模态 + 独立窗口」仍可能撞车。靠列表按钮避开:已开窗时「编辑」变成「在窗口中编辑」并转为聚焦窗口,不再开模态。
  • 笔记消失时必须关掉对应标签(不是销毁整个窗口):reader:removeNotecloseForlibrary:removeManyreader:purgeOrphanscloseForEntries。留着标签,它下一次保存会把已删的笔记整条写回去。最后一个标签关掉后窗口才自行退场。
  • 三处窗口广播(notifyNotesChangedapplyWindowIconsnotifyUiThemeChanged)都要带 noteWindow.all(),漏一处笔记窗口就收不到笔记变更或主题切换。单测有正则锁死。
  • notes:getOne 要校验 noteWindow.ownsNote(event.sender, noteId):笔记窗口只能读自己已开标签的那几条,否则这个通道就是遍历全部笔记的后门。开窗目标也必须过 findNote()listNotes() 重新对账。
  • notes:tabsChangedsetTabs)只能收窄标签集,即只允许 openNotes.delete,绝不能 set。允许渲染层往里加 ID 等于让它自己扩权:谎报持有某条笔记后 notes:getOne 立刻放行,授权集合就形同虚设。新增标签只能走 open(),那条路径过 findNote() 对账。
  • 关窗拦截三件套必须配套:closepreventDefault() + closePending + 看门狗,渲染层处理完调 notes:shutdownReady用户取消时必须调 notes:cancelClose。少了取消回报,closePending 一直为真会让之后每次点关闭都被当成「正在处理」静默忽略,而看门狗仍会在十秒后把带未保存内容的窗口直接销毁。
  • 笔记没有自动保存。切换标签只留在内存,LRU 回收前要把未保存内容序列化进 pendingContent,否则回收即丢改动。
  • 脏判定必须比对序列化后的内容contentKey 与挂载后取的 baselineKey),不能用 pointerdown/keydown 之类的交互事件:只点选不改字也会被判脏,每个标签关闭时都弹一次无谓的确认。基线要在编辑器 ready() 之后取(画布有 version 1→2 归一化,拿磁盘原值当基线会让刚打开就显示已修改),保存成功后基线要跟着前移。
  • 笔记窗口纳入 isReaderSender,不获得 AI 会话等阅读器权限。
  • PDF 底版草稿按 sender 隔离(resolveDrafts / readDraft),草稿不能跨窗口交接,笔记窗口必须自己 stage。
  • 编辑区吃满整窗要逐层 min-height: 0,缺一层 flex 子项就被内容顶高、画布溢出窗口。
  • 笔记本下拉框要 max-width + min-width: 0。只给 max-width 不够:select 的 min-content 以最长选项为准,长书名/长笔记本名照样把整行顶宽(实测取消限宽后从 260px 涨到 553px)。
  • 标题栏只放品牌名「笔记」,不要副标题。note.html 用的是 style.css,那里的 .titlebar-left 不是 flex(只有 reader.css 才是),把 brand-sub 放成 .brand 的同级会掉到下一行,把左侧块顶成 46px 而标题栏只有 44px。要加副标题只能像 index.html 那样塞进 .brand 内部。
  • note.html 不加载 reader.css,所以标签条样式必须在 note-window.css自带一份,也不能用只在 reader.css 里定义的变量(如 --hover-bg-soft),否则静默失效。
  • 集成测试四个坑:窗口刚建好时 getURL() 还是空串,找窗口必须轮询;断言窗口数量只能数笔记窗口,用总窗口数当基线会被阅读器窗口的开关搅乱;笔记页同时有多张卡片,找按钮必须限定在 .note-card[data-note-id=...] 内,全局找「编辑」会命中别的卡片;多标签后表单是每标签一份,查询要限定在当前激活的 .note-tab-view 内,或直接按 [data-note-id] 定位,否则量到的是别的标签。
  • 删除笔记要走真实 IPCreader:removeNote),它内部已经调了 closeFor。测试里再手工补一次 closeFor 等于在验证自己造的假路径,还会因为重复处理而看到「标签没关掉」的假失败。

书库卡片封面

  • 封面比例来源不一(内置生成 400x500,书源常见 0.65~0.75,还有方图和横图)。卡片盒子固定 aspect-ratio: 3/4,用 background-size: cover 会按各自比例裁掉不同的边,观感就是「预览大小不一致」,但量盒子是量不出问题的(每张都一样宽高),必须看截图或比对可见的封面内容。
  • 现在用 contain 完整显示封面,留白由 ::before 里同一张图放大模糊后垫底。注意 ::before 会盖在父元素自己的背景之上,所以清晰的那层必须单独画在 ::after 里,只调 z-index 是压不住父元素背景的;照这个顺序:::before(模糊,z-index 0)→ ::after(清晰,z-index 1)→ .card-cover > *(角标文字,z-index 2)。两个伪元素都要 pointer-events: none,否则挡掉封面的阅读点击。
  • 多选复选框不要加 padding + 背景色块。13px 的原生复选框套一圈衬底后看起来像加粗了边框,浅色封面上的可见性用 filter: drop-shadow(...) 解决。

笔记表单的选择器边界

.note-edit-form 里嵌着画布工具栏(.canvas-note-root)与富文本工具栏(.ql-toolbar),两者都有 <select>。因此 .note-edit-form select { ... } 这类后代选择器会一路灌进工具栏,必须显式 :not(.canvas-note-root select):not(.ql-toolbar select)

踩过的坑:表单控件的 margin-top: 5px 落到工具栏的粗细/纸张下拉上,分组高度变成 28 与 33 两种,工具栏从一行变两行(42px → 78px)。表现像是「工具栏没横排、需要 flex-wrap 调整」,改 .canvas-note-tool-group 的 wrap/shrink 只能把 78px 压到 46px,剩下的 4px 差和分组错位依然在,因为根因不在布局属性而在这条越界的样式。

诊断方法:量到子项全是 28px 而父分组是 33px 时,不要继续猜 flex 属性,直接遍历 document.styleSheets 找出 el.matches(rule.selectorText) 的全部规则,越界的那条会立刻现形。

测试

npm test                                          # Node 单测
npx electron src/_test/electron/<name>.integration.js   # Electron 集成

集成套件:ai-scopereader-featureslibrary-notesannotationdownloadcoverstartup

要求:

  • 完成任务前跑单测 + 相关集成套件。
  • 单测里有对源码的正则断言(锁死关键约定)。改了被断言的代码要同步更新断言,不要为了让测试过而弱化断言。
  • 集成测试断言"真正离开进程的内容"(真实本地 HTTP 服务收到的 body、真实渲染出的像素),不要 stub 渲染层。
  • 不要为了测试往生产代码里加 window.__test 之类的全局钩子,通过真实 UI 断言。
  • library-notes 偶发失败,重跑确认再判断。

构建

npm run build       # Windows,输出 dist/PeopleLib-windows-x64/
npm run build:mac   # macOS arm64,输出 .app 与 .dmg,只能在 macOS 上跑
npm run build:linux -- --arch x64   # Linux,输出 tar.gz,任意平台可构建
npm run release -- --platform linux --arch x64   # 发布件 + 校验和

Windows

  • 输出目录固定,不带版本号。改名会导致 data/ 被遗留在旧目录。
  • 构建保留 data/,但会清空其余内容。构建前必须退出该目录下运行中的 PeopleLib.exe,否则清理到一半失败,目录处于不完整状态。
  • 涉及 data/ 的操作前后做逐文件哈希比对,确认书库、笔记、批注未被改动。

macOSbuild-mac.js,易踩坑):

  • 不能交叉构建。DMG 需要 hdiutil,且 Apple Silicon 内核直接拒绝执行未签名二进制;改名与改 plist 会让 Electron 原始签名失效,必须用 macOS 的 codesign 重签(ad-hoc --sign - 即可)。
  • 解压官方 zip 必须用 dittoElectron Framework.framework 内有符号链接,用 Node 或 unzip 解压会展开成副本,签名随即失效。
  • 重打包后 Info.plist 里的 ElectronAsarIntegrity 必须删除,否则启动即报完整性错误。
  • helper 的 plist 没有 CFBundleExecutable,靠 bundle 名推断可执行文件名。重命名 helper 后必须显式补上该字段,否则渲染进程起不来,界面一片空白。
  • 签名严格由内向外:嵌套可执行文件 → helper → framework → 外层 .app。带版本的 framework 签 Versions/ASquirrel.frameworkResources/ShipIt 是独立可执行文件,要单独签。
  • 数据目录走 ~/Library/Application Support/PeopleLib不要沿用 Windows 的便携布局:.app 在 DMG 里只读,且升级覆盖会删掉用户书库。
  • .icnsnpm run icons:icns 生成,纯 Node 实现(icns 自 10.7 起内嵌 PNG),不依赖 macOS 的 iconutil。窗口图标在非 Windows 平台用 PNG.ico 只有 Windows 认。

Linuxbuild-linux.js):

  • 直接从官方 zip 的条目转写进 tar,不落地中间目录。可执行位存在 zip 的 external attributes 里,先解到 NTFS 再打包会全部丢掉,产物解压后主程序和 chrome-sandbox 都不可执行。因此这个脚本在 Windows 上也能构建。
  • tar 头是手写的。路径超过 100 字节要走 PAX 扩展头:ustar 的 prefix 只能在斜杠处切分,undici 与 vendor 里的深层路径切不出合法组合。

发布件(build-release.js):

  • 发布件的唯一出口,不要手工压缩构建目录上传。Windows 便携版的 data/ 就在程序同级,手工压缩会把用户书库连同笔记打进公开发布件;脚本按前缀排除并在打包后回读压缩包确认。
  • 打完包一定回读产物再签校验和:Linux 要确认可执行位还在,Windows 要确认没有 data/_test。只算哈希不看内容,等于把「构建脚本改坏了」这类问题一路放到用户手上。

持续集成

  • .github/workflows/build.ymlvalidate(单测 + 全部集成套件,xvfb-run 起 X server)→ package(四目标矩阵)→ release(仅 v* 标签)。
  • 仓库 .npmrc 指向 npmmirrorGitHub runner 在境外拉不动,工作流用 npm_config_registryELECTRON_MIRROR 覆盖回官方源。新增构建步骤时别把这两个环境变量漏掉。
  • 新增集成套件后要同步加进工作流的套件列表,单测里有断言按 src/_test/electron/ 的实际文件逐个核对,漏加会直接失败。
  • 标签名必须等于 v + package.jsonversion,且标签要指向被构建的那个提交。
  • 下载完整日志要仓库管理员权限,所以失败详情都转成 ::error 注解。改这几步时别把注解去掉,否则没有管理员权限的人只能看到「exit code 1」。
  • runner 上 chrome-sandbox 拿不到 root:root 4755,集成测试必须带 --no-sandbox。这个开关只属于 CI,不要带进构建产物。
  • runner 自带字体极少,工作流装了 Liberation 与 Noto CJK。界面文案是中文,缺 CJK 字体会渲染成豆腐块,版式测量也跟着偏。

Linux 上首次跑测试暴露过三类只在该平台成立的问题,新写代码时留意:

  • mtimeMs 在 ext4/APFS 带亚毫秒小数,Date.now() 只到整毫秒。直接相减,刚落盘的文件年龄是负数,「超过宽限期就回收」的逻辑永远不触发。比较前先 Math.floor
  • 集成夹具要走网络时,代理只能读 HTTPS_PROXY,不能写死本机端口,否则 CI 上直接 ECONNREFUSED。
  • 版式类断言的容差不要写死字符数,按实际渲染出的每行字数算。字体集不同,同一行的字数就不同,写死的数字换个平台就假失败。

仓库

两个远端,用途不同:

  • origin(私有):完整源码,日常开发推这里。
  • github(公开):源码与 CI。GitHub Actions 必须 checkout 到源码才能构建,所以公开仓不再只放 README。历史与本地无共同祖先,推送前先核对两边的差异范围。

推公开仓前必须确认:没有本地配置、账号凭据、data/ 内容或诊断产物混进去。公开仓一旦推出去,删提交也留在别人的克隆里。

其他:

  • 根目录 dist/ 已 gitignore,规则写作 /dist/必须保留前导斜杠:不加会连 icons/dist/ 一起忽略,新克隆的仓库缺图标,构建直接失败。诊断产物(*-diagnostic.pngprobe*.json)也已忽略,不要提交。
  • 未跟踪文件视为用户资产,不要删除或覆盖。清理前先看 git status --porcelain
  • 提交前 git diff --cached 检查是否混入密钥。