07 · 质量基线
1. 测试分层
| 层 | 工具 | 覆盖什么 | 现状 |
|---|---|---|---|
| 单元 | Vitest | 解析器、序列化、装饰规则、路径解析、冲突判定 | ✅ 526 条,每次提交 |
| 保真闭环 | Vitest + CommonMark 官方语料(652 例) | 打开→保存字节不变;装饰不吞内容 | ✅ 每次提交 |
| 两个解析器的一致性 | Vitest + CommonMark/GFM 官方用例 | Lezer 与 remark 的块级结构比对 | ✅ 每次提交,见 §1.1 |
| 往返属性测试 | Vitest + fast-check | parse(serialize(parse(x))) == parse(x) | ⏸ 只有 open→save == 原字节,没上 fast-check |
| 编辑器交互 + 端到端 | Playwright + 真 Electron | 光标穿越隐藏标记、IME、冲突、崩溃恢复、导出 | ✅ 200 条,每次 PR,三平台 |
| 无障碍 | Playwright + axe-core | 见 §3 | ✅ 8 条,每次 PR |
| 性能基准 | 自建 + 基线对比 | 见 §2 | ✅ 每次 PR(只有体积卡门槛) |
| 视觉回归 | Playwright 截图对比 | 各内置主题 × 各元素类型 | ❌ 没有 |
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 条。测试同时断言:出现表外的新差异要失败, 表内的某条不再有差异也要失败。后者是这张表不腐烂的唯一保证 —— 只卡「不许变多」的允许清单,几年后会变成一份谁也不敢动的化石: 里面一半的条目早已不成立,但没人知道是哪一半。
必须有的几组「防灾」测试
这些直接对应最可能出事故的地方:
- 保真闭环:拿一批真实世界的
.md(CommonMark 用例 + 若干开源项目 README + 刻意构造的畸形文档),逐个「打开 → 保存」,断言字节完全一致。 - IME 三语用例:中文拼音、日文假名转换、韩文组字,各在「装饰区域内」 「表格单元格内」「块公式旁」三种位置输入,断言无丢字、无错位。
- 崩溃恢复:进程被
SIGKILL后重启,断言草稿可恢复且内容正确。 - 并发写:编辑器保存的同时外部程序改文件,断言不产生静默覆盖。
- 未知语法保留:喂入包含自定义语法、原始 HTML、异常嵌套的文档,断言原样保留。
2. 性能预算与守护
pnpm bench(scripts/bench/)。基线在 benchmarks/baseline.json, pnpm bench --update 写回。
| 指标 | 预算 | 实测(开发容器) | 门槛 |
|---|---|---|---|
| 按键处理 p95(1k 行,含强制布局) | 16ms | 0.1ms | 只报告 |
| 打开 10k 行文档到可编辑 | 300ms | 36ms | 只报告 |
| 冷启动到可输入 | 1.5s | 807ms | 只报告 |
| 空闲内存(全进程) | 300MB | 550MB | 只报告 |
| 主 bundle 体积(不含懒加载) | 3MB | 1.32MB | 硬门槛 |
| 安装包体积 | 180MB | Linux 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-fg | 3.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>。少一条、多一条都编译不过。
运行期仍然有一套逐级降级(目标语言 → 英文 → 键名本身),但那不是给内置这三份准备的 —— 它是给将来的用户 / 插件语言包留的口子,那时类型检查够不着。
4.3 两处「看起来完全不像文案」的地方
i18n 最容易漏的不是对话框,是这两类:
- 常量表顺手带的中文名。
THEMES原来是{ id: 'dark', label: '深色' }, 切语言时主题下拉框会留在旧语言里。现在THEMES只有 id,名字在文案表里。 - 模块级常量。
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 插件也要贡献界面文案时,这里会换成一个 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)。
dependency-cruiser强制 01 §1 的分层依赖方向。- Conventional Commits;语义化版本;CHANGELOG 自动生成。
- 每个 PR 必须说明:改了什么、怎么测的、有没有影响格式保真。
- 新依赖需要在 PR 里说明必要性与许可(必须 MIT 兼容)。
6. CI 流水线
实际的 ci.yml(✅ 已有,❌ 还没有):
push / PR
├─ ✅ check:依赖方向 + typecheck + lint + 格式 + 单元测试(526 条)
├─ ✅ e2e:三平台各构建一次,跑 200 条 Playwright(含无障碍与 IME)
├─ ✅ bench:性能基准 + bundle 体积(只有体积卡门槛,见 §2.1)
├─ ✅ artifacts:三平台各一个安装包,供下载试用
├─ ❌ 两个解析器的一致性比对(见 §1.1)
└─ ❌ 视觉回归
docs/** 变动
└─ ✅ pages:构建 VitePress 并发布到 mosu.ohgiantai.com
tag v*
├─ ✅ 三平台全量打包(release.yml,串行,见 §6.6)
├─ ⏸ 代码签名(配置已就位,等证书,见 §6.1 / §6.2)
├─ ❌ 生成 CHANGELOG
└─ ❌ 发布更新源(自动更新还没做,M6)代码签名证书通过仓库 Secret 管理,只在 tag 触发的流水线里可用, fork 的 PR 拿不到(必须在 workflow 里显式限制)。
6.1 macOS 签名与公证:Gatekeeper 那一关
先说清楚这一关不能靠代码绕过。 当前 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) |
| 签名策略(有证书就签、没有就跳过) | electron-builder.yml 的 mac 段刻意不写 identity |
| 公证开关 | release.yml 检测到 APPLE_TEAM_ID 时加 -c.mac.notarize=true |
| 签名状态可见 | release.yml 的「记录签名状态」步骤写进 job summary |
拿到账号之后要做的三件事:
- 在 Apple Developer 后台建一张 Developer ID Application 证书 (不是 Mac App Distribution —— 那张是 App Store 用的,装到这里公证会被拒), 导出成
.p12并设一个密码; base64 -i cert.p12 | pbcopy,把结果存成仓库 SecretCSC_LINK, 密码存成CSC_KEY_PASSWORD;- 建一个 App-Specific Password(appleid.apple.com → 登录与安全), 连同 Apple ID 与 Team ID 存成
APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID。
三个 secret 齐了,下一次 tag 推上去就是签名 + 公证 + 装订(staple)的包。
几个容易踩的点:
- 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 那边排队几分钟到几十分钟都有可能,发布作业会一直等。 第一次跑记得把作业超时放宽。
- 公证失败最常见的原因是 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 是同一类问题, 但解除拦截的条件完全不同,而这个差别决定了投入产出:
| macOS | Windows | |
|---|---|---|
| 签名之后 | 还要公证,公证完立刻双击即开 | 签了名不代表不弹窗 |
| 生效方式 | 确定性的 | 靠声誉积累 —— 安装量够多了才不弹 |
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>/时要改回/open-typo-md/,否则所有资源 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 为什么发布作业是串行的
三个平台都带 --publish always,而草稿 release 的语义是「没有就建一个」。 并发时三个作业会同时发现「没有」,于是各建一个 —— 产物散落在两三个草稿里, 或者其中两个直接报「已存在」失败。所以 release.yml 里写了 max-parallel: 1。 代价是墙钟时间三倍,而发布一年也没几次。
7. 发布与更新
- 渠道:stable / beta。beta 用户可在设置里切换。
- 自动更新:下载后提示重启安装,绝不静默替换正在运行的程序。
- 更新包签名校验失败一律拒绝安装并上报。
- 提供便携版(免安装),配置写在程序目录旁而不是用户目录。