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
2
3
4
5
6
xcrun notarytool submit 应用.zip \
--keychain-profile "$NOTARY_PROFILE" \
--output-format json | jq -r '.id'
xcrun notarytool wait "$SUBMISSION_ID" \
--keychain-profile "$NOTARY_PROFILE" \
--output-format json | jq -r '.status' # Accepted / Invalid / Rejected

把 submit 和 wait 拆成两步还有一个好处:提交 ID 先落盘,网络中断也不丢,之后可以续等。

坑二:Accepted 不代表“没问题”

公证日志里,即使状态是 Accepted,也可能有需要修复的警告项——比如某个二进制缺了安全时间戳。这些不会阻止这次发布,但可能成为下一次的坑。

无论成功失败,都拉一次日志存下来:

1
2
3
xcrun notarytool log "$SUBMISSION_ID" \
--keychain-profile "$NOTARY_PROFILE" \
日志路径/notary-log.json

被拒时日志里是具体原因;通过时日志里是值得修一修的警告。

顺带:网络中断不代表提交失败

--wait 因为网络断掉而退出,Apple 端的提交可能还在正常处理。这时不要重新提交(会白白多交一份),用已保存的提交 ID 续等:

1
2
xcrun notarytool wait "$SUBMISSION_ID" \
--keychain-profile "$NOTARY_PROFILE"

这也是为什么要把提交 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
2
3
if detail.contains("not authorized") || detail.contains("apple events") {
// 显示“请到 系统设置 → 隐私与安全性 → 自动化 中允许”
}

英文系统下没问题。但报错文案会随系统语言翻译,中文系统下变成:

1
2
英文:Not authorized to send Apple events to Terminal. (-1743)
中文:未获得授权将Apple事件发送给"终端"。 (-1743)

英文关键词在中文报错里一个都匹配不上。结果是:中文系统的用户拒绝授权后,看到的是一行原始报错,而不是引导他去“自动化”设置里打开的中文提示。

修复方式是认错误码,不认报错文本。 -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),只做读取、不改任何东西:

  1. 从公开地址下载 ZIP 和 HTML,HTTP 状态、Content-Type 正常
  2. 下载后的 SHA256 和本地逐字节一致
  3. 解压下载的 ZIP,对里面的 .app 跑 codesign --verify、stapler validate、spctl -a -vv
  4. 线上 appcast 里本版本的地址、长度、EdDSA 签名和本地一致
  5. 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
2
3
4
5
6
7
8
# 1. 准备下一版:递增构建号,生成更新说明草稿
bash Scripts/release_local.sh --build 5

# 2. 检查说明、删除草稿标记、提交后先预览(不构建不联网)
bash Scripts/release_local.sh --dry-run

# 3. 正式运行
bash Scripts/release_local.sh

正式运行的顺序是:

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,还是下载页,还在权衡。
  • 发布节奏的稳定性:现在是手动上传 + 手动验收。跑得次数多了,哪些是每次必忘的步骤,会决定下一个要自动化的点在哪里。

第一篇解决的是“知道该做什么”,这一篇解决的是“实际做一遍会发现什么”。写代码和发产品之间,隔的大多是这种“不跑一遍不知道”的细节。

如果这篇里有一个点值得记住,那就是:所有只看“命令返回成功”就往下走的流程,都值得再查一层。 公证的退出码、下载的可达性、权限报错的语言——每一个都是“看起来对了”和“实际对了”之间的缝隙。


macOS 应用写完之后,怎么交到用户手里(二):从手动流程到一条命令
https://iyuluo.com/2026/10/13/macOS-应用写完之后,怎么交到用户手里(二):从手动流程到一条命令/
作者
yuluo
发布于
2026年10月13日
许可协议