ADR-0010:macOS 热修基于 Sparkle¶
日期:2026-07-23 状态:已接受
修订关系:这是 macOS 侧的交付/安装/回退方案,与 ADR-0007(Windows 复用 LiveUpdate)平级;并把"跨平台共享决策引擎"重新定义为"共享契约"(第 3 节,回改 US21 与 tech-spec 的决策引擎表述)。
背景:macOS 一无所有¶
Windows 的热修安全网整个压在 liveupdate.exe + Copy.exe 上——外部、现成、永不热修、能提权换文件、能盯首启。macOS 这些一个都没有(源码与 CONTEXT 均证实):没有客户端内更新器、没有外部替换器、没有特权助手,用户今天是自己去浏览器下 dmg 重装。所以 macOS 的交付/替换/回退,是整套方案里最难、且完全净新建的部分。
参考 ChatGPT.app 的做法,macOS 采用 Sparkle 框架(开源、MIT、EdDSA 签名)来吃掉这最难的一块。
决策¶
1. 前进路径用 Sparkle¶
下载、EdDSA 验签、提权安装(沙盒下走 XPC)、让 CEF 干净退出、替换 bundle、重启——全部交给 Sparkle。三处和我们已定的设计天然对齐:
- 签名:Sparkle 用 EdDSA(= Ed25519),公钥进
Info.plist(SUPublicEDKey)、稳定不变——正是 ADR-0003 的应用层签名模型。 - 动态 feed(ChatGPT 那招):用
feedURLStringForUpdater:在运行时把热修 appcast 交给 Sparkle,由我们的决策驱动它去装哪条,而不是用 Sparkle 自带的分阶段灰度。 - 群体回滚:Sparkle 只接受更高版本,回滚办法是"发一个内容回退、版本号前进的修复版"——这正是 ADR-0004 的服务端强制回滚,免费对齐。
红利:macOS 借此顺带补上了"客户端内更新"这个北极星痛点。但 v1 只把 Sparkle 用于热修,不扩成完整版本更新的大工程。
2. 本地回退:Sparkle 不做,我们补一个小看门狗¶
Sparkle 只往前装、不盯首启、不自动回退。如果一个 mac 热修启动就崩,app 起不来 → Sparkle 的检查在启动之后才跑、根本没机会执行 → 拉不到修复 → 砖。这就是 Windows"死在 main() 之前"盲区的 mac 版。
所以 macOS 的本地自愈 = 启动标记(跨平台共享的 main() 代码,mac 白拿)+ 一个小的、永不热修的看门狗(LaunchDaemon):它在登录/定期触发时查"标记是否连续 N 次没清",是就把预先备份的 safe bundle 换回去。
- 让 Sparkle 装之前,先把当前
.app备份成 safe bundle(Sparkle 不留旧 bundle)——这是 Windows"safe exe 备份"的 mac 对应。代价是多存一份 app 副本。 - StreamFab 装在
/Applications(系统目录,与现状及行业标准一致),所以看门狗是 LaunchDaemon(系统级、带权限);换回 bundle 时与 Sparkle 的提权安装助手共用同一次授权——用户仅在首次安装/更新时输一次管理员密码,之后热修与回退全静默。
至此 mac 也拿到本地自愈,和 Windows 对称。
3. 一致性靠共享契约,不靠共享二进制(回改 US21)¶
原 US21 要求"验签/版本/分桶/回退判定必须是一份跨平台共享代码"。本轮从第一性原理修正:目标是决策行为跨平台一致,"一份共享二进制"是被说过头的手段,而且会和 Sparkle 抢活(Sparkle 自己就做 EdDSA 验签)。
一致性改由契约保证,而不是同一个二进制:
- 签名对"原始字节"签、验"原始字节"(Sparkle 就这么干)——两边验同一串字节对同一个签名,没有"重新规范化"这一步,字节级漂移的风险从根上消除。
- 分桶哈希在规格里写死到字节(如
SHA-256(UTF-8("种子:热修ID"))取前 4 字节大端% 100)。 - 一份共享测试向量的契约测试同时驱动两端——两个实现都通过同一套向量,就在要紧处等价。
于是:Sparkle 管 mac 的验签/版本/安装;我们的策略层(分桶/exclude/隔离/urgent/基线)在交给 Sparkle 之前当一道闸,靠共享向量与 Windows 保持一致。这个小策略层可以做成一个两端都链的小共享 C++ 库(省事),但那是工程便利、不是正确性要求,且绝不重复 Sparkle 已经做的验签/安装。
4. 热修版本号:基线 4 段 + 第 5 段序号¶
热修不是版本升级,却要让 Sparkle 认它"更高、但低于下个完整版本、热修之间单调"。StreamFab 的版本是十进制里程表(7.0.4.1 → 7.0.4.2 → … → 7.0.4.9 → 7.0.5.0),相邻发布之间没有缝。方案:给热修加第 5 段序号。
- 清单规范版本(跨平台真源):
7.0.4.1.<seq>,逐段比较。它同时是防降级号、热修隔离 ID,并编码基线(前 4 段)。 - mac:
CFBundleVersion = 7.0.4.1.<seq>(Sparkle 逐段比,认它 > 基线、< 下个完整版);CFBundleShortVersionString = 7.0.4.1(用户看到的仍是基线,热修隐形)。 - Windows:exe 版本资源保持 4 段
7.0.4.1;热修决策用清单的 5 段号,由新引擎逐段比较。 - 强制回滚 = 序号 +1、装旧内容(号更高、内容更旧)。
- 纪律:完整发布只推进前 4 段(里程表)、第 5 段每换基线归零;下个完整版本的前 4 段必然盖过本基线所有热修——这是 ADR-0002"先并主线再发版"的数字保证。
Windows 侧铁律(code-review 检查项):第 5 段绝不能漏进 Windows 的 exe 版本资源或 getVersion()。否则 getVersion().replace(".","") 会把 7.0.4.1.5 压成 "70415",撞坏 main.cpp 里 < 5000、== "6000" 这类"去点转整数"的老比较(main.cpp:995/2161/2214)。新热修引擎一律用正规逐段比较,不复用那套压缩。
这一并关闭开放问题 Q5(热修版本命名语义)。
后果¶
- macOS 的最难三块(外部安装器、提权换 bundle、公证/Gatekeeper)由 Sparkle 承担,6 周窗口内可行性大幅提升;本地自愈由小看门狗补齐,与 Windows 对称。
- Sparkle 是 Objective-C API,Qt/CEF 需一层 Obj-C++ 桥(
updater_sparkle.mm,-fobjc-arc);CEF 多进程必须能干净退出好让 Sparkle 安装(见 Sparkle 集成指南 §7)。 - "跨平台共享引擎"在 mac 上不再是一个和 Sparkle 抢活的单体,而是共享契约 + Sparkle 交付。
- 安装位置 =
/Applications(与现状一致);看门狗定为 LaunchDaemon,与 Sparkle 提权助手共用一次性授权。用户全程仅"首次安装/更新输一次管理员密码",之后热修与回退全静默。