Skip to content

07 · 质量基线 ​

1. 测试分层 ​

层工具覆盖什么现状
单元Vitest解析器、序列化、装饰规则、路径解析、冲突判定、Patch 映射✅ 823 条,每次提交
保真闭环Vitest + CommonMark 官方语料(652 例)打开→保存字节不变;装饰不吞内容✅ 每次提交
两个解析器的一致性Vitest + CommonMark/GFM 官方用例Lezer 与 remark 的块级结构比对✅ 每次提交,见 §1.1
往返属性测试Vitest + fast-checkparse(serialize(parse(x))) == parse(x)⏸ 只有 open→save == 原字节,没上 fast-check
编辑器交互 + 端到端Playwright + 真 Electron光标穿越隐藏标记、IME、冲突、崩溃恢复、导出✅ 286 条,每次 PR,三平台
无障碍Playwright + axe-core见 §3✅ 8 条,每次 PR
性能基准自建 + 基线对比见 §2✅ 每次 PR(只有体积卡门槛)
视觉回归Playwright 截图对比各内置主题 × 各元素类型❌ 没有
AI(假 runtime)Vitest + PlaywrightPatch 基线映射、撤销粒度、事件乱序与取消、工具的权限边界🟡 协议层 69 条已有;撤销粒度与工具边界要等实现,见 10 §9
AI(真 Provider)手动Pi 适配器能不能跑通⬜ 刻意不进 CI,见下

AI 那两行的分界是这份文档里最该守住的一条。 CI 里不打真 Provider,理由不是 花钱,是不确定的输出配不上确定的断言 —— 一条会随模型版本变绿变红的测试, 比没有测试更糟。所以 AgentRuntime 是接口,CI 注入脚本化的假 runtime;真跑 Pi 的用例是手动的。直说这意味着适配器那一层的回归靠人,不假装有覆盖; 缩小它的办法是把适配器做薄(10 §9.1)。

@mosu/agent-core 落地时这条已经兑现了一半:69 条单测,一条 Provider 请求都不发。 其中最值钱的一组是基线映射,而它值钱的原因正是「替身顶在系统边界上」—— 那一组用的是真的 ChangeSet,不是手搓的改动清单。它当场抓出了一个真 bug (区间两端的 assoc 用了同一个,导致「AI 改写一句话、用户紧接着在后面打字」时 用户刚打的字被吃掉)。手搓替身的话多半会照着实现的样子搓,然后一路绿到线上。

1.1 两个解析器的一致性:欠了三档,补上了 ​

ADR-0003 选双解析器时把「一致性测试进 CI 必跑」写成决策的一部分,不是可选项。 这条从 M3 一直欠到 M4.5 之后才补上(packages/export/test/parser-consistency.test.ts)。

结果:652 例中 2 例不一致,占 0.31%。 两条都是 Lezer 与规范不符、remark 正确 —— 也就是导出的结果是对的,编辑器里看到的是错的。逐条分析在 known-divergences。

做法:先规范化,再比对 ​

难点从来不是跑用例,是两边的节点词汇不一样:Lezer 说 ATXHeading2,mdast 说 heading{depth:2};Lezer 把围栏代码与缩进代码分成 FencedCode / CodeBlock, mdast 一律叫 code;GFM 任务项在 Lezer 里是 ListItem > Task,在 mdast 里仍是 listItem > paragraph。所以先把两棵树各自映射成同一套块级骨架,再逐字比对。

只比块级、不比行内,也不比文本内容:行内的分隔符规则本身就有大量边角,而那些差异 对用户的影响远小于「这一段到底是不是引用块」。ADR 里写的也是「比对块级结构树」。

一个必须记下的中间结论:先出 29 条,其中 27 条是映射的锅 ​

第一版跑出 29 条差异。逐条看下来,其中 27 条都长这样:

源码: <del>*foo*</del>
Lezer: doc / p
mdast: doc / p / html

原因是 mdast 用同一个 html 类型表示块级 HTML 与行内 HTML,而 Lezer 把后者 叫 HTMLTag(不在块表里)。修法是在「叶子块」(段落、标题、单元格)上停止下钻 —— 它们的子节点本来就全是行内元素。

这件事值得单独写下来,因为它是这类比对最容易掉进去的坑:一个规范化映射写得不 够仔细,会把噪声伪装成信号。29 条里有 27 条是假的,如果当时直接把 29 条抄进 「已知差异」清单,这份清单从第一天起就是废的。

清单两个方向都卡 ​

KNOWN_DIVERGENCES 表登记那 2 条。测试同时断言:出现表外的新差异要失败, 表内的某条不再有差异也要失败。后者是这张表不腐烂的唯一保证 —— 只卡「不许变多」的允许清单,几年后会变成一份谁也不敢动的化石: 里面一半的条目早已不成立,但没人知道是哪一半。

替身要顶在系统边界上,不是顶在自己的代码上 ​

E2E 里凡是碰系统 UI 的地方(原生对话框、原生菜单)都得替身,否则无头环境里 根本跑不动。选在哪一层替身很要紧:顶掉系统那一层,产品代码一行都不绕开。

现成的三个:dialog.showOpenDialog、dialog.showSaveDialog、 Menu.prototype.popup。以最后一个为例,它记下 menu.items 再直接触发某一项的 click —— 于是「渲染进程算出的上下文 → IPC → main 建的模板 → 哪些项该灰掉」 整条链都被真跑了一遍,没被覆盖的只剩「Electron 真的画了一个菜单」。

反例(试过,不成立):想在页面里改 window.mosu.menu.context 来记请求。 contextBridge 暴露出去的对象是只读的 —— 那正是它的安全性所在 —— 赋值静默失败,替身根本没被调用过,用例挂在一个跟被测行为毫无关系的地方。

顺带一条:右键菜单这类东西分两层测更划算。「有哪几项、哪些灰掉、点了回什么 id」是纯函数,抽成不 import electron 的模板模块之后直接单测(快、断言精确); E2E 只负责验「点下去之后真的发生了那件事」。

只在一个平台上跑的测试,失败方式是假阳性 ​

单元测试的 check job 只跑 Linux。跟真实文件系统打交道的用例,在别的平台上 不是「会红」,而是「恒绿而且验错了东西」 —— 后者危险得多,因为它还占着 「这里已经测过了」的位置。

路径守卫那一组撞过两次(完整经过见 §6.7)。留下的规矩有两条:

  • 每一个被拒绝的目标都必须真实存在。 守卫分两种拒绝:路径解析不出来报 「不可访问」,解析出来但不在白名单里才报「未获授权」。拿不存在的路径去验 白名单,走的是前一条分支,白名单整个失效也照样绿。
  • 想验「.. 被规范化后拦下」,就不能用 path.join 拼路径 —— 它自己就把 .. 抹平了,守卫压根没见过 ..。

CI 那一侧的补法是 verify-cross:macOS 与 Windows 上跑跟 release.yml 一模一样的 pnpm verify,跟 e2e 并行(e2e 长得多,墙钟上白拿)。

本地复现 macOS 的符号链接行为不必等 CI:

bash
mkdir -p /tmp/realtmp && ln -sfn /tmp/realtmp /tmp/linktmp
TMPDIR=/tmp/linktmp pnpm vitest run apps/desktop/test/path-guard.test.ts

必须有的几组「防灾」测试 ​

这些直接对应最可能出事故的地方:

  1. 保真闭环:拿一批真实世界的 .md(CommonMark 用例 + 若干开源项目 README + 刻意构造的畸形文档),逐个「打开 → 保存」,断言字节完全一致。
  2. IME 三语用例:中文拼音、日文假名转换、韩文组字,各在「装饰区域内」 「表格单元格内」「块公式旁」三种位置输入,断言无丢字、无错位。
  3. 崩溃恢复:进程被 SIGKILL 后重启,断言草稿可恢复且内容正确。
  4. 并发写:编辑器保存的同时外部程序改文件,断言不产生静默覆盖。
  5. 未知语法保留:喂入包含自定义语法、原始 HTML、异常嵌套的文档,断言原样保留。

2. 性能预算与守护 ​

pnpm bench(scripts/bench/)。基线在 benchmarks/baseline.json, pnpm bench --update 写回。

指标预算实测(开发容器)门槛
按键处理 p95(1k 行,含强制布局)16ms0.1ms只报告
打开 10k 行文档到可编辑300ms36ms只报告
冷启动到可输入1.5s807ms只报告
空闲内存(全进程)300MB550MB只报告
主 bundle 体积(不含懒加载)3MB1.32MB硬门槛
安装包体积180MBLinux AppImage 113.6MB发布时人工看

2.1 「硬门槛」这条要改:只有体积卡死,时间不卡 ​

这一节原来写的是「在 CI 里是硬门槛,不是参考值」。真去做的时候这条站不住。

GitHub 的共享 runner 上,同一份代码连着跑三次能差出两三倍 —— 邻居在编译, 或者你抽到了一台老 CPU。把时间设成硬门槛,结果不是「守住了性能」,是 「一半的 PR 随机变红,于是所有人学会了点 re-run」。那比没有门槛更糟: 它训练所有人忽略这个信号。

所以分两档:

  • 体积是确定性的 —— 同一份代码在任何机器上打出来都是同样的字节数,卡死。 它守的正是「Mermaid、KaTeX、语言高亮包一律懒加载,禁止进主 bundle」这条: 谁不小心把它们静态 import 了,主 bundle 会立刻从 1.3MB 蹦到 5MB 以上。 现在懒加载有 174 个 chunk、共 4.2MB,全在主 bundle 之外。
  • 时间与内存跑、记录、写进 CI 的 job summary,跟基线并排显示。人来判断。 基线里存的是开发容器上的数字,不是「合格线」;它的用处是发现「这次比 上次慢了一倍」这种量级的变化 —— 那种变化再吵的机器也盖不住。

2.2 「按键 → 渲染」要拆成两个数 ​

第一版只量到「下一帧绘制完成」,结果中位数 16.9ms、p95 17.3ms —— 看着刚好 擦着 16ms 的预算过不去,很像一个真实的性能问题。

不是。60Hz 下一帧就是 16.7ms,那个数的下限由帧率决定,跟代码快慢基本无关。 拿它去比 16ms 的预算,等于设了一个构造上就达不到的门槛。

拆开之后:

  • 按键处理(派发 beforeinput → 强制刷完样式与布局):0.1ms。 这是「一次按键吃掉多少帧预算」,是真正可以优化、也真正该跟 16ms 比的数。 必须读一次 getBoundingClientRect() 把布局逼出来 —— 不逼的话浏览器会把布局 推迟到帧末,测出来是个漂亮的假数。
  • 按键 → 下一次绘制:17.2ms。它没有预算,只在明显超过一帧时才有意义 (那说明掉帧了)。

教训跟 06 §3.3 那个 PDF 是同一个:一个数字擦着门槛过不去,先怀疑这个数字 量的是不是你以为的那件事。

2.3 空闲内存超预算 550MB / 300MB —— 还没解决 ​

数字是 app.getAppMetrics() 里所有进程 workingSetSize 的和:main、渲染、 GPU、网络服务、utility 全算上。只报 main 会得到一个好看很多的数,但那不是 用户在活动监视器里看到的东西。

Electron 的进程基座本身就要三四百 MB,300MB 这个预算当初定得偏乐观。真要压 下来得动结构(比如把 GPU 进程关掉、或者合并 utility 进程),那是一批单独的 工作,不是调个参数的事。先记着,不假装达标了。

3. 无障碍 ​

  • 编辑区必须能被屏幕阅读器正确朗读。隐藏 Markdown 标记时用 aria-hidden + 恰当的语义标签,让读屏软件读到「标题 二级:安装」而不是「井号 井号 安装」。
  • 块 widget(表格、公式)提供 aria-label 描述;公式带 LaTeX 源码作为替代文本。
  • 全键盘可达:所有命令有可绑定的快捷键,命令面板覆盖全部命令, 焦点顺序合理,widget 可用键盘进入与退出(Enter 进 / Esc 出)。
  • 对比度满足 WCAG AA;内置一个高对比度主题。
  • 尊重 prefers-reduced-motion:关闭动画。

e2e/a11y.spec.ts 用 axe-core 扫主要界面。这里比原计划的「违规项进看板」更严: 扫出来的违规直接让用例失败。因为现在违规数是 0 —— 从 0 守住 0 很容易, 而一旦允许「进看板」,那个看板就会一直有东西,然后没人看。

3.1 专注模式的变暗达不到 AA,这是一个明知故犯的取舍 ​

--mosu-focus-dim: 0.35 下,变暗的正文只有 2.1:1(sepia 主题 1.8:1), 而 AA 要 4.5:1。算下来要达标得把不透明度调到 0.64(sepia 要 0.76)—— 那时基本看不出变暗了,功能等于没有。

取舍:默认保留 0.35(这个模式默认关着,且是用户主动开的), 但尊重系统的 prefers-contrast: more —— 用户在操作系统层面说过「我需要对比度」, 就不该再让应用里的某个效果把它压下去。高对比主题另外把它设成 0.7。

这一条不能靠 axe 验。第一版就是那么写的,而且「通过」了 —— 后来去看才发现 axe 把变暗的行全归进了 incomplete(「背景被别的元素盖住,判定不了」), violations 自然是空的。一条永远通过的用例比没有用例更糟。 现在改成直接读 --mosu-focus-dim 的实际取值,并用 page.emulateMedia({ contrast }) 验那条 media query。

3.2 一次调色把六套主题全过了一遍 ​

scripts/contrast.mjs 直接读 themes.css,把该配对的组合全算一遍 (前景 × 背景 × 六套主题 = 60 组)。第一次跑出来 9 组不达标:

变量问题处理
marker-fg浅色 2.04、sepia 2.01、github 2.07、深色 4.12全部压到 ≥4.5
fg-muted on 状态栏浅色 4.27(差一点)4.64
sepia 的 fg-muted / string-fg / number-fg3.3–4.5全部压到 ≥4.6

marker-fg 那条值得单独说:它是显形中的 Markdown 标记的颜色,淡是故意的 —— 标记本该比正文轻。但它是可选中、可编辑的正文字符,WCAG 就该按正文算,2.04 是 明确的违规。压到 4.5 之后,它跟正文(14.65:1)之间仍有巨大落差,「轻」还在, 只是不再轻到低视力用户看不见。

为什么需要这么个脚本而不是全靠 axe:axe 一次只看当前那套主题、当前那个页面上 真实出现过的组合。六套主题 × 十来个前景色靠 E2E 去覆盖不现实,而调色时最需要 知道的恰恰是「我把这个值改成这样,还够不够」。

3.3 axe 查不到的那些 ​

axe 能查的是机器可判定的那一类:对比度、缺 label、ARIA 用错、标题层级跳跃。

它查不到的,恰恰是上面列表里最重要的几条:读屏软件把二级标题读成「标题 二级: 安装」而不是「井号 井号 安装」、widget 能不能用键盘进出、焦点顺序合不合理。 a11y.spec.ts 全绿 ≠ 无障碍做好了,它只是把机器能发现的错清零。

3.4 顺手修掉的两处 ​

  • 命令面板的列表没有可访问名字,而且是个「只能用鼠标滚」的区域。 现在补齐了完整的 combobox 模式:role=combobox + aria-activedescendant (少了它,上下键在读屏软件那边完全没有反馈 —— 视觉上有高亮,听觉上什么都 没发生)、列表有 aria-label、tabindex="-1"。
  • index.html 里预填的中文(「未命名.md」「0 字」「实时预览」)与 lang="zh-CN"。JS 会在首帧改掉,但英文 / 日文用户启动瞬间会看见一闪而过的 中文 —— 那正是「翻了一半」最刺眼的样子。现在留空,lang 取兜底语言。

4. 国际化 ​

已落地(M6 提前):简体中文、English、日本語三种界面语言,可在设置里切换, 无需重启。快捷键按平台差异化(macOS ⌘ vs 其他 Ctrl)且完全可自定义。

还没做:RTL(界面镜像 + 编辑区 dir 检测;Markdown 内容与 UI 方向独立)、 拼写检查(接系统词典:macOS/Windows 原生 API,Linux 用 hunspell;代码块、行内代码、 公式、URL 内不检查)。

4.1 为什么手写 ICU 子集而不是 intl-messageformat ​

原计划是「UI 文案全部走 ICU MessageFormat」。真去接的时候发现,整份文案里实际用到的 语法只有两种:变量替换和复数。而 intl-messageformat 带着一个完整的表达式解析器, 压缩后二十多 KB,还要跟 CLDR 数据一起走。

所以 @mosu/i18n 手写了这两种(约 150 行),并且把「不支持」写成显式规则: 遇到不认识的语法就把原文吐出来,不猜、不抛错。翻译里写错一个花括号,代价是那一句 显示成原样(一眼看得见),而不是整个面板白屏 —— 这条有 4 个专门的单测。

复数的类别选择走平台自带的 Intl.PluralRules。那是 CLDR 本体,比任何我们自己维护的 表都准,而且不要钱。

4.2 漏译是编译错误,不是运行时惊喜 ​

shared/messages/zh-CN.ts 是源语言,MessageKey 从它导出;en.ts 与 ja.ts 的类型是 Record<MessageKey, string>。少一条、多一条都编译不过。

运行期仍然有一套逐级降级(目标语言 → 英文 → 键名本身),但那不是给内置这三份准备的 —— 它是给将来的用户自带语言包留的口子,那时类型检查够不着。(原来这里还写着 「插件语言包」,插件系统推后了,见 ADR-0006;用户语言包这条口子仍然留着, 它是一份 JSON,不需要执行任何代码。)

4.3 两处「看起来完全不像文案」的地方 ​

i18n 最容易漏的不是对话框,是这两类:

  1. 常量表顺手带的中文名。 THEMES 原来是 { id: 'dark', label: '深色' }, 切语言时主题下拉框会留在旧语言里。现在 THEMES 只有 id,名字在文案表里。
  2. 模块级常量。 const UNTITLED = t('doc.untitled') 在模块求值时就定死了, 而那时语言还没从磁盘读回来。改成函数才对。

4.4 换语言不重启,代价是一张必须维护的清单 ​

最省事的做法是「切语言要重启」(VS Code 就这样),但那把一次「我想看看英文长什么样」 变成一次重启。这个应用的面板本来就是每次打开重画的,真正需要补的只有那些只在构造时 写过一次的文案(面板标题、输入框占位符、aria-label)。

所以各面板有一个 retranslate(),调用点集中在 renderer/main.ts 的 retranslateAll() —— 集中在一处,才有可能不漏。

这个函数启动时也必须调一次:面板都在模块顶层构造,那时语言还没读回来,它们身上 带的是兜底语言的文案。这个 bug 真的写出来过,是被 E2E 抓到的 —— 症状是「系统是中文、 菜单也是中文,唯独面板标题是英文」,而且只在启动那一次出现,用户切一下语言再切回来 就好了,正是最难复现的那种。

4.5 编辑器内核不认识文案表 ​

@mosu/editor 里有 6 条给人看的文字(编辑区 aria-label、代码语言选择器、任务复选框、 图片加载失败)。它们走注入(EditorLabels),默认值是英文 —— 原则 P3 说内核不依赖 Electron,同样也不该依赖某个特定的 i18n 方案。一个直接拿这个包去嵌网页的人, 拿到英文比拿到中文更可能看得懂。

只有 6 条,用不着上一整套消息机制。这句话原来接的是「等 M5 插件也要贡献界面 文案时」 —— 插件不来了(ADR-0006),但触发条件只是换了个:M5 的 AI 面板会往 这一层加一批文案(流式状态、失败原因、Patch 预览的按钮)。到那时这里会换成一个 translate 函数。结论没变,前提换了,记一笔免得下次有人照着一个不存在的理由改。

4.6 哪些字符串不翻 ​

协议处理器的「越界访问」、路径守卫的「路径未获授权」、草稿写盘失败的 console 输出 —— 这些是诊断信息,出现即意味着有 bug 或有人在试探,用户拿它没办法。翻译它们只会让 日志在不同机器上长得不一样,反而更难对。

fs-service 是个例外中的例外:它的「文件过大」「不支持的图片类型」会进对话框,所以要翻, 但它不去问当前语言 —— 问就要认识 electron 和设置存储,于是那批「在临时目录上跑真实 fs」的防灾测试得先把整个 Electron 桩起来。翻译器由 IPC 那一侧传进来,默认值是英文。

4.7 E2E 必须钉死界面语言 ​

菜单标签一旦跟着 app.getLocale() 走,那五十来处 clickMenu(app, ['视图', …]) 就依赖 跑测试的那台机器的系统区域了 —— 三个平台的 runner 各给各的。所以 fixture 在启动前 往 userData 里写一份 {"ui.language": "zh-CN"}。换语言本身由 e2e/i18n.spec.ts 单独覆盖。

5. 工程规范 ​

  • TypeScript strict + noUncheckedIndexedAccess;不允许 any 逃逸(@typescript-eslint 强制)。
  • ESLint + Prettier。pre-commit 钩子还没装 —— 这一条原来写着「lint-staged」, 但 devDependencies 里没有 husky 也没有 lint-staged,是句空话。目前 pnpm format:check 只有 CI 那一道关口,这决定了 §6 那两份触发路径清单 必须互补(哪条路径两边都不跑,格式就没人管了)。
  • dependency-cruiser 强制 01 §1 的分层依赖方向。
  • Conventional Commits;语义化版本;CHANGELOG 自动生成。
  • 每个 PR 必须说明:改了什么、怎么测的、有没有影响格式保真。
  • 新依赖需要在 PR 里说明必要性与许可(必须 MIT 兼容)。

6. CI 流水线 ​

实际的 ci.yml(✅ 已有,❌ 还没有):

哪条流水线跑,取决于改了什么。 改一行文档要等三平台 e2e 跑完,等的是一件 跟它毫无关系的事。所以 ci.yml 用 paths-ignore 把文档、Markdown、其它工作流 摘出去,由 docs.yml 接住 —— 重点是接住,不是不跑(见 §6.8)。

push / PR(代码、ci.yml 自己)
 ├─ ✅ check:依赖方向 + typecheck + lint + 格式 + 单元测试(669 条,仅 Linux)
 ├─ ✅ verify-cross:macOS / Windows 上跑同一条 pnpm verify(见 §6.7)
 ├─ ✅ e2e:三平台各构建一次,跑 221 条 Playwright(含无障碍与 IME)
 ├─ ✅ bench:性能基准 + bundle 体积(只有体积卡门槛,见 §2.1)
 ├─ ✅ artifacts:三平台各一个安装包,供下载试用
 ├─ ❌ 两个解析器的一致性比对(见 §1.1)
 └─ ❌ 视觉回归

push / PR(docs/**、*.md、LICENSE、release.yml / pages.yml / docs.yml)
 └─ ✅ docs:格式检查 + 构建文档站 + 工作流清单互补检查(~1 分钟)

docs/** 变动且在 main 上
 └─ ✅ pages:构建 VitePress 并发布到 mosu.ohgiantai.com

tag v* / 手动触发(tag 留空 = 试跑,见 §6.7)
 ├─ ✅ 三平台全量打包(release.yml)
 ├─ ✅ macOS 代码签名 + 公证 + 装订(见 §6.1)
 ├─ ⏸ Windows 代码签名(刻意先不做,见 §6.2)
 ├─ ❌ 生成 CHANGELOG
 └─ ❌ 发布更新源(自动更新还没做,M6)

代码签名证书通过仓库 Secret 管理,只在 tag 触发的流水线里可用, fork 的 PR 拿不到(必须在 workflow 里显式限制)。

6.1 macOS 签名与公证:Gatekeeper 那一关 ​

状态:已跑通。 配齐五个 secret 之后,release.yml 打出来的 macOS 包是 签名 + 公证 + 装订(staple)过的。下面这一段仍然保留,因为它解释了为什么必须 走到这一步,以及换一个账号重新配时该做什么。

这一关不能靠代码绕过。 没有证书时 CI 打出来的包带的是 ad-hoc 签名 (scripts/after-pack.mjs),它只解决「Apple Silicon 上能不能执行」—— 所以「已损坏,无法打开」那句误导性提示消失了,但每次安装仍会被 Gatekeeper 拦下,用户要右键 → 打开,或者去「系统设置 → 隐私与安全性」点「仍要打开」。

解除拦截只有一条路:Developer ID 签名 + 公证(notarization)。 公证过的包双击即开,一句提示都没有。这需要一个 Apple Developer Program 账号 (99 美元/年),没有免费替代品。

证书之外的一切都在仓库里:

已就位位置
Hardened Runtime 豁免项apps/desktop/build/entitlements.mac.plist(+ .inherit.plist,说明见同目录 README)
签名策略(有证书就签、没有就跳过)electron-builder.yml 的 mac 段刻意不写 identity
公证开关release.yml 检测到 APPLE_TEAM_ID 时加 -c.mac.notarize=true
签名状态可见release.yml 的「记录签名状态」步骤写进 job summary
证书本身能不能打开release.yml 的「核对 macOS 证书能被打开」,在打包之前

拿到账号之后要做的三件事:

  1. 在 Apple Developer 后台建一张 Developer ID Application 证书 (不是 Mac App Distribution —— 那张是 App Store 用的,装到这里公证会被拒; 也不是 Developer ID Installer,那张签的是 .pkg)。生成 CSR 用「钥匙串 访问 → 证书助理 → 从证书颁发机构请求证书」,装回钥匙串后从「我的证书」 导出成 .p12 并设一个密码 —— 只有「我的证书」那一栏里的条目才带私钥;
  2. base64 -i cert.p12 | tr -d '\n' | pbcopy,把结果存成仓库 Secret CSC_LINK,密码存成 CSC_KEY_PASSWORD;
  3. 建一个 App-Specific Password(appleid.apple.com → 登录与安全), 连同 Apple ID 与 Team ID 存成 APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID。

只有账号持有人(Account Holder)能创建 Developer ID 证书,团队里的 Admin 不行。

五个 secret 齐了,下一次 tag 推上去就是签名 + 公证 + 装订的包。产物到手之后 还要在本机独立验一遍 —— 流水线绿只说明工具链没报错:

bash
spctl -a -vvv -t install /Applications/Mosu.app   # 期望 accepted / Notarized Developer ID
xcrun stapler validate /Applications/Mosu.app     # 期望 The validate action worked!

stapler validate 过了才说明公证票据装订进包里了 —— 那是「用户断网也能 双击即开」的条件。

先别急着打 tag —— 有一条试跑的路。 release.yml 的 workflow_dispatch 把 tag 这一栏留空,就拿当前分支跑一遍:签名、公证一样不少,但不建 Release, 产物落在本次运行的 artifact(mosu-dryrun-*)里。

留这条路是因为「验证签名配好没有」不该以造一个 tag 为代价 —— tag 是发布语义的 东西,推错了要删、删了本地还留着,而这里要回答的问题只是「那五个值对不对」。 判断依据是 PUBLISH_MODE:有 tag 才 --publish always,跟怎么触发的无关。

顺带一提,填一个不存在的 tag 时,checkout 的原生报错是 The process '/usr/bin/git' failed with exit code 1,还会重试三次各等 11 秒 —— 看上去像网络抖动。所以 checkout 之前加了一步 gh api 核对,把它换成一句人话。

几个容易踩的点:

  • Apple 更推荐用 App Store Connect API Key(APPLE_API_KEY / APPLE_API_KEY_ID / APPLE_API_ISSUER)而不是 App-Specific Password —— 前者可单独吊销、不绑定个人 Apple ID。代价是 APPLE_API_KEY 是个 .p8文件路径,CI 里要多一步把 secret 写成临时文件。想换的话改 release.yml 那一处 env 即可。
  • 公证是异步的,Apple 那边排队几分钟到几十分钟都有可能,发布作业会一直等。 第一次跑记得把作业超时放宽。(实测第一次约 5 分半,含签名与装订。)
  • MAC verification failed during PKCS12 import (wrong password?) 这句里的 问号,底下压着至少三件事:base64 存坏了、密码不一致、或者 .p12 是 OpenSSL 3 导出的(默认 AES-256 + SHA-256 MAC,security(1) 读不懂, 密码完全正确也报同一句话)。怎么分辨见 §6.7。
  • entitlements 的文件形式本身就是一道坎。codesign --entitlements 那一头 解析 plist 的不是 CFPropertyList,而是 AMFI 自带的解析器(内核 OSUnserializeXML 的变体),比标准 XML 严得多:注释、<true /> 里的空格、 缺 DOCTYPE 都可能让它报 AMFIUnserializeXML: syntax error near line N, 而 xmllint 判定完全良构。更麻烦的是这只在真配了证书之后才会发生 —— 没证书时 electron-builder 跳过签名,这两个文件根本不会被读到,坏了也没人 知道。现在由 apps/desktop/test/entitlements.test.ts 钉住形式, 说明写在 apps/desktop/build/README.md。
  • 公证失败最常见的原因是 entitlements 或 hardened runtime 没开。 这两样已经配好了,但如果将来引入了原生模块(.node),它必须单独签名, 否则公证日志里会报 “The binary is not signed with a valid Developer ID certificate”。目前三个进程的产物都是自包含 JS,没有这个问题。

只讲流水线怎么搭。走哪条分发路线、代价是什么,在 09 分发 —— 包括 Mac App Store 那条路上的架构冲突(App Sandbox 跟会话恢复、附件写盘、文件 监听三处正面撞车),以及为什么现在还不该收费。

6.2 Windows 签名:为什么现在先不做 ​

SmartScreen(「Windows 已保护你的电脑」)跟 Gatekeeper 是同一类问题, 但解除拦截的条件完全不同,而这个差别决定了投入产出:

macOSWindows
签名之后还要公证,公证完立刻双击即开签了名不代表不弹窗
生效方式确定性的靠声誉积累 —— 安装量够多了才不弹

OV 证书签完,SmartScreen 照样弹,要等签名积累够安装量;只有 EV 证书 立刻获得声誉。对一个还没有用户的项目,OV 等于花了钱还是弹窗 —— 而「等安装量」的前提是有人愿意穿过弹窗去装。

而且 macOS 那套「p12 塞进 secret」的路子在 Windows 上已经不成立。 CA/浏览器论坛从 2023 年 6 月起要求所有代码签名私钥存在硬件里(HSM / USB 令牌), OV、EV 都一样 —— GitHub 托管的 runner 插不了 U 盘。现在可行的只有两类:

  • 云签名服务(Azure Trusted Signing、DigiCert KeyLocker、SSL.com eSigner): 私钥在服务商的 HSM 里,给一个能从 CI 调的签名接口。其中 Azure Trusted Signing 便宜得多,但资格条件要按当下的官方条款确认(对组织成立年限有过要求, 个人开发者的政策变过)。
  • Certum 的开源证书:对开源项目价格很低,但发的是实体卡, 意味着只能在本机签,或者搭一台自托管 runner。

结论:先不买。 macOS 那 99 美元/年换来的是确定的「双击即开」,值; Windows 这边花更多钱,OV 换不来立刻不弹窗,EV 又贵还得配云签名 —— 而现在没有用户,声誉积累无从谈起。用户点两下「更多信息 → 仍要运行」能装, README 里写清楚了。等真有下载量、或者开始收费,再回头算这笔账。

release.yml 里给 Windows 留的 WIN_CSC_LINK / WIN_CSC_KEY_PASSWORD 是经典 p12 那条路的接口。走云签名的话这两个变量用不上, 要换成各家的签名钩子(electron-builder 支持自定义 sign 脚本)—— 等真选了服务商再改,现在写死反而是负担。

6.3 官网 / 文档站 ​

docs/ 目录本身就是站点源码(VitePress),由 .github/workflows/pages.yml 发到 GitHub Pages。不另建一个 site/ 再把文档复制过去 —— 那会多出一份必然 漂移的副本,而 README 里指向 docs/… 的链接在 GitHub 上也就不再有效。

只在 docs/**、README.md 或站点配置变动时触发:每次改代码都重新部署一遍站点 纯属浪费,也会让部署历史里全是与站点无关的记录。

自定义域名 mosu.ohgiantai.com(跟 appId 的 com.ohgiantai.mosu 同源)。 两处必须一致,改一处就得改另一处:

  • docs/public/CNAME —— VitePress 把 public/ 原样拷进产物根目录, GitHub Pages 读它来配置域名;
  • docs/.vitepress/config.ts 里的 SITE 与 base —— 自定义域名下站点挂在 域名根,base 必须是 /。退回 <user>.github.io/<repo>/ 时要改回 /mosu/,否则所有资源 404,而表现是「页面出来了但一片空白」, 不容易一眼看出是 base 的问题。

站点定位是设计文档,不是用户手册。 这是刻意的:现在最值得读的就是那些取舍 与被推翻的结论,而功能清单 README 里已经有了。

多语言:落地页翻,设计文档不翻。 /en/ 与 /ja/ 各有一份落地页,README 也是 三份。设计文档九篇加五份 ADR,而且还在随开发变动 —— 翻译一份会漂移的长文档, 代价不是翻一次,是此后每次改动都要翻三次。落地页不一样:它变得慢,而且是决定 「要不要继续看下去」的那一页。两份外语落地页都明说了设计文档是中文的, 这是取舍不是遗漏。

加一种语言 = 一个 LOCALES 条目 + 一份落地页 + 一份样例文档(截图用)+ 一份 README。

顺带一提,接 VitePress 的第一次构建就抓出了一个真问题:02-editor-core.md 里有个没闭合的代码围栏,导致从 §6.3 到文件末尾整段被当成代码块 —— GitHub 上一直也是这么渲染的,没人发现。多一个渲染器就多一双眼睛。

6.4 截图是脚本拍的,不是手工截的 ​

pnpm screenshots(Linux 上 xvfb-run -a pnpm screenshots)跑一遍真应用, 把官网与 README 用的图拍进 docs/public/shots/。

理由只有一条:手工截的图会过期,而且没人会注意到它过期了。 界面改了、 主题调了、某个功能重做了,图还停在半年前 —— 读者据此形成的第一印象是错的。 脚本化之后重拍的成本是一条命令,过期就没有借口。

顺带还有一个好处:图里那张表格的列宽、那段公式的排版、那张流程图, 都是产品自己算的,不是设计稿。看图等于看真东西。

几条实现上的选择:

  • --force-device-scale-factor=2:CI 机器的 DPR 是 1,不强制拉高的话 Retina 屏上看是糊的;强制之后任何机器拍出来的尺寸都一致,diff 才有意义。

  • 冻住插入符的闪烁动画(而不是隐藏光标):不冻的话同一条命令跑两次得到 两张不同的图;而光标本身正是「显形」那两张对比图要说明的东西。

  • 按元素定位,不按滚动距离:scrollIntoView 到某个标题,而不是滚 N 像素。 猜距离的写法一改文档就拍歪,而且拍歪了 CI 也不会报错 —— 图还是能出。

  • 图提交进仓库:让 Pages 的构建去跑一遍 Electron 太重,而图的更新频率 远低于代码。

  • 每种界面语言各拍一套(docs/public/shots/<语言>/):一个英文落地页配一张 中文截图,读者第一眼看到的是「这个项目没做完」。样例文档按语言各写一份, 而且不是机器翻译的 —— 「光标进入即显源码」那段说明是要被截进对比图里的, 译得别扭那张图就白拍了。

    ⚠️ 目前应用 UI 本身还没 i18n,所以英日截图的状态栏仍是中文。 已解决:i18n 落地后重跑了一次脚本,英文那套的状态栏现在是 「215 words · 3:1 / Focus / Typewriter / Live preview」。

    脚本自己也钉住界面语言(往临时 userData 里写 ui.language),不看开发机的 系统区域 —— 否则同一条命令在两台机器上会产出不同的图,而这种漂移只有对比 新旧图时才看得出来。

    顺带把切主题从「点菜单标签」改成了发命令名:菜单标签现在跟着语言变, 按标签找的话英文那一轮会直接挂在「菜单里找不到『视图』」上。

6.5 CI 产物是 zip,Release 产物不是 ​

Actions 页面上下载到的永远是 zip —— 那是 actions/upload-artifact 的固有行为 (GitHub 一律把产物打包),跟我们的配置无关,也改不掉。所以 CI 里那几个 mosu-macos-arm64-dmg 之类的名字指的是产物包,解开才是安装包。

Release 走的是 electron-builder --publish always,它把 .dmg / .exe / .AppImage / .deb 原样传成 release asset,用户点了直接就是安装包。

会多出两样东西,都不是包装:

  • macOS 的 .zip —— Squirrel.Mac 自动更新只认 zip 不认 dmg。自动更新还没做 (M6),现在确实多余,但留着省一次改配置;
  • latest*.yml —— electron-updater 的元数据,同理。

一个只有全量矩阵才会踩的坑:nsis 与 portable 都产出 .exe, 套同一个全局 artifactName 会解析成同一个文件名,后打的覆盖先打的。 CI 只打 nsis,所以它一直藏着。现在 portable 有自己的命名模板。

6.6 打包与发布拆成两个 job ​

曾经是串行的,而串行只是在绕开竞态。

原来一个 job 干两件事:三个平台各自 --publish always,各自往 Release 里塞 产物。草稿 release 的语义是「没有就建一个」,于是并发时三个作业会同时发现 「没有」,各建一个 —— 产物散落在两三个草稿里,或者其中两个直接报「已存在」 失败。当时的处理是 max-parallel: 1,墙钟从 ~6 分钟变成 ~18 分钟。

竞的是「谁来建这个 Release」。那就别让打包作业去建:

build(三平台并行,一律 --publish never)
  └─ 产物 → artifact
       └─ release(唯一一个,needs: build)
            下载 → gh release create --draft

换来三件事,第三件才是最值钱的:

  1. 三个平台真正并行,墙钟回到「最慢的那个平台」;
  2. 要么全发,要么不发。 原来 Windows 挂了的话,Release 里已经躺着 macOS 的产物了 —— 一个只发了一半的版本。现在发布只在三个都绿之后发生一次;
  3. 试跑和真发布的打包路径完全一样了。 差别只剩「后面那个 job 跑不跑」。 以前 --publish 的取值两条路不同,意味着试跑验不到真发布那条 —— 而这个 仓库刚刚才因为「从没跑过的路径」连挂四轮(见 §6.7)。现在每一次试跑都在 演练真发布的全部打包步骤。

代价是产物多走一趟 artifact(上传一次、下载一次)。这笔开销买到的是第 2、3 条, 划算。

「要不要发布」的判断在整个文件里只出现一处 —— release job 的 if:。job 的 if 读不到 env(可用上下文里没有它),所以只能写在那儿;好在也只需要那一处。

gh release create 建的是草稿,写好正文后在 Release 页面点 Publish 才对外 可见。

正文由两段拼起来,散文在上、变更清单在下:

来源内容
release-notes/<tag>.md(可选)这个版本要跟用户说的话:这是什么、为什么值得下、怎么装
自动生成上一个 tag 以来的 feat / fix / perf / refactor,按类型分组

分开是因为这两段的性质不同。清单是机械的,机器做得比人好也比人齐全;而 「为什么值得下」需要判断,所以它写在仓库里、跟着提交走,不是发布当天在 网页上现敲 —— 那样它既不过 review,也留不下历史。

两段都是可选的:没有散文文件就只有清单;首个版本没有上一个 tag,就只有散文。 v0.1.0 正是后者 —— 全历史 83 个提交里绝大多数是写给我们自己的施工记录 (「两条路径守卫用例在 macOS 上是错的」),列出来对下载的人零价值。

没用 GitHub 自带的 --generate-notes:那个列的是合并的 PR,而这个仓库直接 推 main,git log --merges 是空的,生成出来只剩一行链接。

两个实现细节都是本地拿构造的提交历史跑出来才发现的:分隔符用制表符而不是 |(提交标题里出现 | 完全可能,会让 awk 从中间截断);行首加数字前缀定序, 否则 sort 按字母来,Fixed 会排到 New 前面。

6.7 第一次真发布:七个洞,只有一个跟证书有关 ​

配好证书之后第一次点发布,连着挂了四轮。七处失败里只有一处出在证书上, 其余六处是流水线和测试自己的问题 —— 它们都躺在那儿很久了,只是没有任何一条 既有路径会执行到。

#现象真正的原因
1checkout 报 failed with exit code 1,重试三次各等 11 秒手动触发时填了一个不存在的 tag。报错长得像网络抖动
2macOS 上两条路径守卫用例失败断言拿 mkdtemp 的返回值当期望值,而 macOS 的 /var 是符号链接
3Windows 上 format:check 报全仓 178 个文件格式不对没有 .gitattributes,runner 的 core.autocrlf 把检出换成了 CRLF
4打包时 asar 里没有入口文件release.yml 里从来没有 pnpm build
5Windows 上「未授权路径一律拒绝」失败靶子写的是 /etc/passwd,Windows 上没这个文件
6.p12 导不进钥匙串存进 secret 的值不对(唯一一处真的跟证书有关)
7AMFIUnserializeXML: syntax error near line 15entitlements 里有注释、<true /> 带空格、缺 DOCTYPE

从没跑过的流水线等于没有 ​

第 4 条和第 7 条有个共同点:它们只在「真发布」这条路径上才会被执行到。

  • release.yml 只在 tag v* 上触发。第一次点它的时候,run_number 是 1 —— 也就是说这个文件写下来之后从来没有真正跑过一次。少一句 pnpm build 这种程度的错误,就那么放着。
  • entitlements 更隐蔽:没有证书时 electron-builder 会跳过签名,那两个 plist 根本不会被读。文件坏了多久都不会有人知道,直到第一次拿真证书发布。

结论不是「以后小心点」,而是让这条路径变得随时可以走一遍。所以 release.yml 的手动触发把 tag 这一栏改成可留空:留空就拿当前分支跑一遍, 签名公证一样不少,只是不建 Release、产物走 artifact(见 §6.1)。验证签名配置 本来就不该以「先造一个 tag」为代价 —— tag 是发布语义的东西,推错了要删, 删了本地还留着。

只在一个平台上跑的测试,失败方式是假阳性 ​

第 2 和第 5 条不是「在别的平台上会红」,而是在 Linux 上恒绿而且验错了东西:

  • 「目录穿越被规范化后拦下」写的是 ../../etc/passwd。Linux 的 /tmp 只有 一层,正好落到真的 /etc/passwd;macOS 的临时目录深在 /var/folders/… 里,同样的写法落在一个不存在的路径上 —— 于是守卫走的是「路径不可访问」 那条分支,白名单即使整个失效,用例照样绿。
  • 而且 path.join 自己就把 .. 抹平了,守卫压根没见过 .., 「被规范化后拦下」这句话从头到尾没测到规范化。

所以路径守卫的测试里立了一条规矩,写在 fs-service.test.ts 的 describe 头上: 每一个被拒绝的目标都必须真实存在。守卫分两种拒绝,解析不出来报「不可 访问」、解析出来但不在白名单里才报「未获授权」;拿不存在的路径去验白名单, 验的是前一条分支。

CI 那一侧的补法是 verify-cross:macOS 与 Windows 上跑跟 release.yml 一模一样的 pnpm verify。刻意不是只跑 pnpm test —— 七条里有一条(CRLF) 根本不是测试,按「我猜哪些地方会有平台差异」织网这次已经证明猜不准。 它跟 e2e 并行,而 e2e 比它长得多,墙钟上是白拿的。

本地想复现 macOS 的符号链接行为不必等 CI,给 TMPDIR 套一层就行:

bash
mkdir -p /tmp/realtmp && ln -sfn /tmp/realtmp /tmp/linktmp
TMPDIR=/tmp/linktmp pnpm vitest run apps/desktop/test/path-guard.test.ts

报错里的问号,底下往往压着好几件事 ​

第 6 和第 7 条的原始报错都属于「一句话把几种毛病混在一起」:

security: SecKeychainItemImport: MAC verification failed during PKCS12 import (wrong password?)

那个问号底下至少三件事:base64 存坏了、密码不一致、或者 .p12 是 OpenSSL 3 导出的(默认 AES-256 + SHA-256 MAC,macOS 的 security(1) 读不懂, 密码完全正确也报同一句话)。

有一件事这句报错本身就排除掉了:能走到「校验 MAC」,就说明解出来的确实是一张 结构完整的 PKCS#12 —— base64 存坏的话连解析都到不了那一步。这类推理值得写进 流水线而不是留在脑子里,所以打包之前加了一步「核对 macOS 证书能被打开」: 失败时先打印能跟本机对照的凭据(CI 那边拿到的 .p12 的 sha256、 CSC_KEY_PASSWORD 里有没有空白字符),再列可能性。secret 存进去就读不回来, 「我本机这一对是好的」跟「CI 拿到的是同一对」之间一直隔着一层,这两条把它捅破。

顺序是刻意的:先给可对照的事实,再谈猜测。

不逐个试 ​

第 7 条的三个可疑点(注释、<true /> 的空格、缺 DOCTYPE)没有逐个验证 —— 每试一轮十几分钟,而三个都不是我们非要不可的写法。直接换成 Electron 官方文档 里那份逐字规范的形式,一次排除干净,代价是不知道究竟是哪一个。

注释里的内容有价值(每条豁免项为什么必须、为什么要两份),搬去了 apps/desktop/build/README.md,一个字没丢。形式则由 apps/desktop/test/entitlements.test.ts 钉住:有 DOCTYPE、没有 <!--、 <true/> 不带空格、两份文件的键完全一致。

那个测试是按惯例验过「对着旧文件是红的」才留下的 —— 一个不会失败的测试比 没有测试更糟,它还占着「这里已经测过了」的位置。

6.8 按改动路径分流:关键是「接住」,不是「不跑」 ​

改一行文档等三平台 e2e,等的是一件跟它无关的事。所以 ci.yml 的 push 和 pull_request 都带了 paths-ignore:docs/**、**/*.md、LICENSE、 .github/ISSUE_TEMPLATE/**,以及 release.yml / pages.yml / docs.yml。

ISSUE_TEMPLATE 那条是漏了之后补的:清单第一版只写了 **/*.md,而 issue 模板 是 .yml,于是一次「只改了社区文件」的推送照样拖出了完整 CI。分流的清单是 按后缀想出来的,而它该按「会不会影响产品」来划。

.github/workflows/ci.yml 刻意不在那份清单里 —— 改 CI 自己当然要跑 CI。

摘出去的那些交给 docs.yml:pnpm format:check + pnpm docs:build,约一分钟。 这一步不能省。 仓库里没有 pre-commit 钩子(§5 那句「lint-staged」是空话), format:check 只有 CI 这一道;哪条路径两边都不跑,那条路径的格式就没人管了, 而它的报应是下一次改代码时 CI 莫名其妙地红,且跟那次改动毫无关系。

于是两份清单必须互补。这本来是一句写在两个文件注释里的「改一边记得改另 一边」—— 而这个仓库当天已经因为「靠记性的约定」栽过好几次,所以做成了检查: test/workflows.test.ts 断言 ci.yml 的 paths-ignore 与 docs.yml 的 paths 逐条相等。

三个实现细节,都是踩出来的:

  • GitHub Actions 不支持 YAML 锚点(& / *)。同一份清单在每个文件里得写 两遍(push 一遍、pull_request 一遍),想用锚点合并会直接报解析错误。 这也正是那条一致性测试存在的理由。
  • 那条测试要在 docs.yml 里单独跑一次(pnpm vitest run test/workflows.test.ts)。指望 ci.yml 去验是不成立的:改 docs.yml 的清单 只会触发 docs.yml,而清单写歪恰恰就发生在改它的时候。
  • 测试里不引 YAML 解析器。 js-yaml 只是某个包的传递依赖,直接 import 是在 用幽灵依赖;为一条断言加一个直接依赖又太重。验的本来就是文本约定,所以用了个 够小的提取器 —— 它坏掉时的表现是「列表为空」,所以第一条断言就是「不许为空」, 免得悄悄变成永远通过。

7. 发布与更新 ​

现状(v0.1.0):手动发布,无自动更新。tag 推上去 → 三平台并行打包 → 建草稿 Release → 人看过再点 Publish。下面这些是 M6 的目标,除了最后一条都还没做。

有一件已经在做了但还没有人用:latest*.yml(electron-updater 的更新源元数据) 随每个 release 一起发出去了。客户端那半没写,所以目前没有任何东西会去读它 —— 带上它只是为了做自动更新那天不必回头补一个 release。

  • 渠道:stable / beta。beta 用户可在设置里切换。
  • 自动更新:下载后提示重启安装,绝不静默替换正在运行的程序。
  • 更新包签名校验失败一律拒绝安装并上报。
  • 提供便携版(免安装),配置写在程序目录旁而不是用户目录。

MIT 许可发布。与 Typora 无关联,是一个独立实现。