macOS 应用写完之后,怎么交到用户手里(二):从手动流程到一条命令
本文最后更新于 2026年10月13日 下午
上一篇《macOS 应用写完之后,怎么交到用户手里?》画了一张地图:写好代码、编译测试、签名、公证、附加票据、打包、上传下载渠道,最后让用户安装。
地图有了,这一次真的跑了一遍。SSHGuard 的 1.0.0(构建 4)从代码提交一路走到了 Apple 公证通过、上传到 R2、更新清单上线。
结论先说:“能跑”的部分几乎不会卡住你,卡住的是“检查和验证”的部分。 公证被拒时命令返回成功、下载的包能被系统静默挪走、权限报错会跟着系统语言变化……这些都不是文档首页会写的事,是跑过一次才会知道的事。
这篇就按实际踩过的顺序讲。
一、这次真的跑通了什么
上一篇结尾时,SSHGuard 停在“签名完成、公证还没提交”。这次把整条链路走完了:
flowchart TD
A["Git 提交 1168e19"] --> B["构建号 4<br/>回归 + XCTest"]
B --> C["Developer ID 签名归档"]
C --> D["Apple 公证:Accepted"]
D --> E["附加票据、重新打包"]
E --> F["生成更新包、HTML 说明、清单"]
F --> G["上传 R2"]
G --> H["更新清单 appcast.xml 上线"]
style A fill:#dcfce7,stroke:#16a34a,color:#14532d
style D fill:#dcfce7,stroke:#16a34a,color:#14532d
style H fill:#dcfce7,stroke:#16a34a,color:#14532d
每一步都留了日志,提交 ID、公证状态、Apple 返回的公证日志都保存在本次发布目录里。剩下的只有两件事:用已安装的旧版完成一次真实自动升级验收,以及想好新用户从哪里下载。
下面按“这次新学到的东西”展开。
二、其实是三把钥匙
上一篇说签名私钥要妥善保管。实际跑下来,参与发布的不是一个私钥,而是三套不同的凭据,它们的保管方式、丢失后果都不一样:
| 凭据 | 用途 | 丢了怎么办 | 备份方式 |
|---|---|---|---|
| Developer ID 私钥 | 代码签名 | 同一团队可重发新证书,指定要求(DR)不变 | 钥匙串导出 .p12,存到受保护的位置 |
| notarytool 凭据(App 专用密码) | 命令行提交公证 | 重新配置即可 | 不需要备份,App 专用密码可撤销重建 |
| Sparkle EdDSA 私钥 | 给更新包签名 | 最麻烦:已安装的旧客户端无法验证新签名 | generate_keys -x 导出并异地备份 |
三个细节值得单独说:
公证凭据不是 Xcode 登录。 Xcode 里登录 Apple ID 不会自动创建命令行可用的公证凭据。要走 xcrun notarytool store-credentials,输入的是 Apple 账户页面生成的 App 专用密码,不是 Apple ID 登录密码。写成独立入口(脚本里的 --setup-notary)是为了让这一步只配一次、且密码不经过脚本和 shell 历史。
最危险的是 Sparkle 私钥。 它存在用户机器上的每个已安装应用里(公钥嵌在 app 里)。丢了它,现有用户就再也收不到可信的自动更新——不是换个证书那么简单。导出命令是 generate_keys -x,恢复用 -f,注意都必须带 --account 指向你的钥匙串账户。
两把钥匙不要同时换。 更新链路的可信度由“代码签名 + EdDSA 签名”共同保证。如果某个版本同时换了 Apple 签名身份和更新密钥,旧客户端可能无法验证这次更新。分开做,中间至少隔一个正常发布的版本。
三、公证的两个坑
notarytool 的 --wait 很方便:提交后阻塞到出结果。但用起来有两个坑,第一个坑还藏得很深。
坑一:被拒时退出码可能仍是 0
直觉上“命令返回 0 = 成功”,但实际不是。公证返回 Invalid 时,submit --wait 的退出码仍可能是 0。
如果脚本只看退出码,后果是:公证已经被拒了,流程还继续往下走,到 stapler staple 时才报错——报的还是 stapler 的错误,看不出真正原因。
正确做法是按返回 JSON 里的 status 判定:
1 | |
把 submit 和 wait 拆成两步还有一个好处:提交 ID 先落盘,网络中断也不丢,之后可以续等。
坑二:Accepted 不代表“没问题”
公证日志里,即使状态是 Accepted,也可能有需要修复的警告项——比如某个二进制缺了安全时间戳。这些不会阻止这次发布,但可能成为下一次的坑。
无论成功失败,都拉一次日志存下来:
1 | |
被拒时日志里是具体原因;通过时日志里是值得修一修的警告。
顺带:网络中断不代表提交失败
--wait 因为网络断掉而退出,Apple 端的提交可能还在正常处理。这时不要重新提交(会白白多交一份),用已保存的提交 ID 续等:
1 | |
这也是为什么要把提交 ID 第一时间写进日志文件。
四、“上传到下载渠道”其实是三次上传
上一篇画的时序图是两次上传:上传 Apple 检查、上传下载渠道交付。
接入自动更新之后,实际上是三次,而且顺序有讲究:
sequenceDiagram
participant D as 开发者
participant A as Apple 公证
participant R as R2 更新源
participant U as 已安装用户
participant N as 新用户
D->>A: 上传应用申请公证(检查)
A-->>D: Accepted,返回票据
D->>R: 上传 ZIP 和 HTML 说明(交付)
N->>R: 通过下载链接获取安装包
D->>R: 最后上传 appcast.xml(通知)
U->>R: 定期检查清单,发现新版本
为什么清单要最后传: appcast.xml 一上线,所有装了旧版的用户立刻会收到“有新版本”的推送。如果更新包还没就位,用户点进去会下载到 404。反过来,先传包再传清单,用户拿到的永远是已就位的版本。
这三次上传的验证内容也不同:
- 检查阶段:Apple 返回
status: Accepted、票据能 staple、Gatekeeper 通过 - 交付阶段:下载地址能访问、下载下来的 SHA256 和本地逐字节一致
- 通知阶段:清单里新版本的下载地址、文件长度、EdDSA 签名和本地生成的一致
五、ZIP 分发的隐藏坑:App Translocation
用户下载 ZIP,Safari 自动解压到「下载」文件夹。如果他直接双击打开 SSHGuard.app——macOS 会把这个 app 静默放到一个只读的隔离位置运行(App Translocation),他不会察觉任何异常。
但后果很严重:Sparkle 的自动更新在这种状态下完全静默失效。文档原话是:
By default Sparkle will not notify your user if an update cannot be performed, like if the app is running from a read-only mount or being impacted by app translocation.
也就是说,从「下载」直接打开的用户,会永远收不到新版本,而且他自己不知道。
三种补救方式,可以按成本选:
- 说明文档里写清楚:解压后把应用拖进「应用程序」文件夹再打开。最低成本,挡不住不看文档的用户。
- 应用自己检测:bundle 路径包含
/AppTranslocation/或以/Volumes/开头时,启动弹一次提示。SSHGuard 现在就这么做,同时在设置 → 关于里常驻一个橙色警告。 - 改用 DMG:里面放一个指向「应用程序」的符号链接,引导用户拖过去。这是 Sparkle 官方推荐的做法,需要多一次公证(给 DMG 本身)。
另外,如果你的应用支持“登录时启动”之类的功能,被隔离的随机路径注册出去的行为可能也不符合预期——这又是一个“本机能跑”和“用户机器上能跑”的差异点。
六、“测试拒绝权限”要用用户的系统语言测
上一篇的测试清单里有“用户拒绝权限时,应用是否能正确处理”。实际做的时候才发现这句话缺了一个限定:用什么语言的操作系统测。
SSHGuard 用 osascript 调起 Terminal/iTerm 执行 SSH 命令。用户拒绝“自动化”授权后,osascript 返回错误码 -1743。一开始的代码靠匹配报错文本判断:
1 | |
英文系统下没问题。但报错文案会随系统语言翻译,中文系统下变成:
1 | |
英文关键词在中文报错里一个都匹配不上。结果是:中文系统的用户拒绝授权后,看到的是一行原始报错,而不是引导他去“自动化”设置里打开的中文提示。
修复方式是认错误码,不认报错文本。 -1743 是 errAEEventNotPermitted,在任何语言的系统下都一样。
这个教训推广开来就是:凡是靠“系统文案”判断的逻辑,都要在目标用户实际使用的语言下各测一遍。 发布验收里现在专门有一条:Apple Events 拒绝路径至少在简体中文系统下测一次。
七、测试矩阵要补齐的不只是“能跑”
发布的是 universal 二进制(arm64 + x86_64),但测试只跑 arm64,Intel 切片从来没被执行过。最低支持版本写的是 macOS 14,验收却只在 macOS 27 上做。
这两件事补上之后,测试矩阵才算完整:
| 维度 | 方法 | 成本 |
|---|---|---|
| Intel 切片 | Rosetta 下测试目标改为 x86_64 |
一条命令 |
| 最低系统版本 | macOS 14 虚拟机跑一次冒烟 | 每次发布几小时 |
| 破坏性操作 | 专用测试账户或可回滚快照 | 一次性环境 |
Rosetta 那一项成本最低收益最直接:装了 Rosetta 的 Apple Silicon Mac 上,arch -x86_64 就能把测试跑在 Intel 切片下,发现的是“只在 x86_64 路径上才出现”的问题。这次跑下来 201 个测试在 Rosetta 下全过,说明目前没有这类问题——但前提是去跑了。
八、发完之后:验证和留档
验证的是“用户拿到的那份”,不是“本地构建的那份”
打包完、上传完、能下载,只说明传输没坏。真正该验证的是从最终下载链接拿到的应用本身。
可以写成一个小脚本(verify_remote.sh),只做读取、不改任何东西:
- 从公开地址下载 ZIP 和 HTML,HTTP 状态、Content-Type 正常
- 下载后的 SHA256 和本地逐字节一致
- 解压下载的 ZIP,对里面的
.app跑codesign --verify、stapler validate、spctl -a -vv - 线上 appcast 里本版本的地址、长度、EdDSA 签名和本地一致
appcast.xml的响应头确认没有被 CDN 缓存
第 3 步是最关键的一环:本地 notarized/SSHGuard.app 能过验证,只说明本地的包没问题;从线上地址解压出来的那份,才是用户真正会拿到的版本。
还有一个容易漏的点:curl 下载不带 com.apple.quarantine 属性,真实用户的“浏览器下载→首次打开”流程在 curl 下载里根本走不到。要覆盖这一步,得用浏览器从公开地址下载一遍,再首次打开。
留档:Git 标签和 dSYM 不要只留在这台 Mac 上
每次发布建议做两件“将来会感谢现在”的事:
- 为发布的源码提交打 Git 标签,比如
v1.0.0-build4。source-commit.txt记提交号已经够了,但标签让仓库自己就能回答“线上这个版本对应哪份源码”。 - 把
SSHGuard.xcarchive备份到这台 Mac 以外的地方。 归档里的 dSYM 是将来把崩溃日志还原成可读调用栈的唯一依据。它现在躺在被 Git 忽略的Build/目录里,这台 Mac 一出问题就什么都没了。注意别放进公开的更新桶——它是给开发者用的,不是给用户下载的。
九、把这些串成一条命令
上面的每一步单独看都不复杂,手动做却容易漏一步或做反顺序。所以实际做法是把这些全部串进一个入口脚本,让“下次发版”变成三件事:
1 | |
正式运行的顺序是:
flowchart LR
A["工作区干净检查<br/>公证凭据预检"] --> B["生成工程<br/>检查漂移"]
B --> C["签名证书、Rosetta<br/>更新密钥预检"]
C --> D["回归 + XCTest<br/>arm64 + x86_64"]
D --> E["签名归档"]
E --> F["公证并按 status 判定"]
F --> G["票据验证"]
G --> H["更新包、HTML、清单"]
设计上几个值得说的取舍:
预检放在最贵的操作之前。 证书、公证凭据、更新密钥、Rosetta 都在测试和归档之前检查,避免出现“公证做完了才发现缺更新密钥”的情况。
按失败阶段给不同的恢复指引。 没签名的失败可以直接重跑;归档后没提交公证可以删目录重跑;已提交公证的按 status 区分——被拒要修代码重提交,结果未知要用提交 ID 续等,已通过但后续失败不要重提公证。
“上传”始终手动。 脚本只负责本地全部可验证的产物,R2 上传和清单发布是人工步骤,顺序和注意事项写在脚本最后输出的提示里。
十、还没做完的
发布流程跑通了,但“交到用户手里”还有几件没收尾:
- 真实自动升级验收:构建 3 → 4 的完整检查、下载、安装、重启还没实际做过。本地编译通过 ≠ 用户能升级成功,这一步必须真跑。
- 新用户的下载入口:现在只有带版本号的 R2 链接,还没有固定的“下载最新版”地址。是做一个简短跳转链接、GitHub Releases,还是下载页,还在权衡。
- 发布节奏的稳定性:现在是手动上传 + 手动验收。跑得次数多了,哪些是每次必忘的步骤,会决定下一个要自动化的点在哪里。
第一篇解决的是“知道该做什么”,这一篇解决的是“实际做一遍会发现什么”。写代码和发产品之间,隔的大多是这种“不跑一遍不知道”的细节。
如果这篇里有一个点值得记住,那就是:所有只看“命令返回成功”就往下走的流程,都值得再查一层。 公证的退出码、下载的可达性、权限报错的语言——每一个都是“看起来对了”和“实际对了”之间的缝隙。