Others

不购买每年 99 美元的 Apple 开发者证书,如何实现 macOS 软件自动更新

2125 字约 8 分钟

摘要Windows 端已经可以在应用内自动更新,macOS 上却仍需要用户手动从 GitHub 下载几百 MB 的 DMG 再拖拽覆盖。在没有 Apple 开发者证书和公证的前提下,本文介绍如何用 Sparkle 搭建一条自主可控的更新信任链。

Auto Email Sender 是一款基于 Electron 与 Python 混合架构的开源桌面客户端。

在 Windows 上,自动更新的体验很顺畅:系统检测到新版本后,在后台下载增量补丁,弹出一个轻量提示,用户点击重启即可完成升级。

但到了 macOS 上,流程完全不同:每次点击“检查更新”,只会弹出一个窗口,引导用户前往 GitHub Releases 页面。用户需要下载几百 MB 的 DMG,挂载磁盘映像,再把应用图标拖进 Applications 文件夹覆盖旧版本。

用户在群里抱怨:“都 2026 年了,苹果电脑更新个软件还像在用 Windows 98?”

我们当然希望实现应用内一键更新,但有一个很大的限制:项目是非盈利的开源项目,没有每年支付 99 美元购买 Apple Developer Program,也就没有 Developer ID 证书和官方公证(Notarization)。

苹果的生态相对封闭。没有苹果的证书,macOS 应用是否只能处于二等地位,无法享受应用内的静默更新?

什么是 Apple Developer ID 和公证(Notarization)?

从 macOS Catalina 开始,苹果要求从 App Store 之外分发的应用,必须使用年费 99 美元的开发者证书签名,并上传到苹果服务器进行自动化恶意软件扫描(即公证)。通过之后,应用才会被 Gatekeeper 信任。否则,用户双击时会看到“无法验证开发者,已移到废纸篓”的提示。

为什么直接沿用 Windows 的方案会失败

在 Windows 上,我们使用的是业界常用的 electron-updater。那么,直接去掉 macOS 上的拦截判断,让它使用同样的逻辑,可以吗?

不可以。代码放开之后,在 macOS 上不仅无法完成更新,还可能直接导致旧应用崩溃。

原因在于,electron-updater 在 macOS 上依赖 Squirrel.Mac,而 Squirrel.Mac 的设计前提是:把苹果系统的代码签名(Code Signing)作为唯一的身份凭证。

在替换新应用之前,它会检查新旧应用的 Apple Team ID 和 Bundle 签名是否一致。

我们在 macOS 打包时使用的是 Ad-hoc 签名,即本地自签名,命令是 codesign -s -:

  • 它可以让应用具备基本的完整性结构,防止可执行文件被篡改;
  • 但它没有 Team ID,不被系统级的信任链认可;
  • Squirrel.Mac 发现签名缺少身份信息,安全检查会直接报错,并丢弃已经下载的更新包。
通俗比喻:正式护照与双方约定的私人印章

为什么不花钱买证书,就不能使用常规的更新器? 可以把两种信任方式这样理解:

  • 官方公证体系(Apple 开发者证书):相当于国家颁发的正式护照。每次过关都要联网核验防伪信息,并且必须每年向苹果缴费续期。没有护照,就会被视为非法入境;
  • Sparkle 自建体系(Ed25519 公私钥):相当于你和老朋友之间约定的专用印章。用户在第一次安装时手动信任你,之后每个新版本只要盖着开发者的专属印章,软件就可以自行验证,确认是正版。

只依靠密码学原理,就可以在零成本的情况下,做到足够可靠的防篡改。

问题的核心

问题并不在于 Electron 不支持更新,而在于操作系统层面的信任链断了。如果不购买证书,就需要在应用层寻找另一套不依赖苹果 CA 根证书的第二信任体系。

Sparkle 与 EdDSA 签名

了解 macOS 桌面开发生态之后,我发现了一个历史比 macOS 还要长的更新框架:Sparkle。

许多 Mac 独占软件,例如 IINA、Keka 和 Sublime Text,都在使用 Sparkle。Sparkle 的设计比 Squirrel.Mac 更灵活:

它不仅支持 Apple 官方签名,还原生支持基于 Ed25519(EdDSA)的非对称签名。

信任体系苹果官方体系(Developer ID + 公证)Sparkle 自建体系(EdDSA 密钥对)
解决的核心问题用户首次获得 App 时,系统是否信任它用户安装旧版本后,新版本是否由原作者发布
每年开销99 美元/年,停止付费后证书失效0 元,依靠数学和密码学保障
防篡改能力依赖苹果的在线公证比对依赖 Ed25519 数字签名,难以伪造
适用范围首次双击打开时绕过 Gatekeeper 的拦截后续所有自动更新
信任的接力

我们接受苹果的基本要求:用户首次安装时,必须在 Finder 中右键打开一次 DMG,允许这个未公证的应用运行。 只要用户在第一次信任了我们,之后 Sparkle 就会使用应用内置的 EdDSA 公钥处理所有后续更新。只要新的更新包带有有效的私钥签名,就可以确认它是开发者本人发布的,然后在应用内安全地完成替换。

在 Electron 中接入原生模块

Sparkle 是用 Objective-C 和 Swift 编写的原生 macOS 动态库,而我们的前端使用的是 TypeScript 和 React。

我没有选择臃肿的第三方封装库,而是自己编写了一层很薄的 Objective-C++ 原生 Node 扩展(Native Bridge):

[ React 前端界面 ]
        │ (IPC: "check-for-updates")
        ▼
[ Electron 主进程 ]
        │ (调用原生动态库)
        ▼
[ Sparkle Bridge (Obj-C++) ]
        │ (直接调用)
        ▼
[ Sparkle.framework ] ──> 解析 appcast.xml ──> EdDSA 验签 ──> 显示原生更新窗口 ──> 替换并重启

这种设计的好处是:下载断点续传、文件解压、权限提升(Privilege Escalation)、进程退出与重启等功能,都交给了经过多年验证的 Sparkle 处理。主进程的代码只有几十行胶水代码,整体很稳定。

全量 DMG 与差分包(Delta Update)

在分发文件方面,我原本计划为自动更新单独打包一份 ZIP,但很快发现这样会增加额外的维护工作。

Sparkle 可以直接从 DMG 镜像中提取应用并完成替换。

因此,我们不需要为“首次安装”和“自动更新”分别维护两套产物。每次 CI/CD 发布的流程都是统一的:

  1. 构建完整的未公证 DMG,它既作为 GitHub 下载文件,也作为全量更新包;
  2. 使用 CI 中的私钥生成 Ed25519 签名,并更新 appcast.xml 索引;
  3. 如果上一个版本的二进制文件可用,CI 会运行一条命令,自动生成几 MB 的差分包(.delta)。
什么是差分更新(Delta Updates)?

假设 Electron 应用的完整包是 120 MB,而新版本只修改了几张图片和前端 JS 代码。Sparkle 的 generate_appcast 工具会对比新旧两个版本的二进制文件,只把差异部分打包成一个约 5 MB 的 delta 文件。用户更新时,几秒钟就能下载完成。

更新元数据必须最后发布

在完善 GitHub Actions 自动发布流程时,我们遇到过一个问题。

有一段时间,用户频繁反馈:“提示有新版本,但点击下载时总是报 404 错误。”

排查后发现,CI 是并发上传文件的:appcast.xml 体积较小,先上传到了 GitHub Releases;而几百 MB 的 DMG 和差分包仍在上传中。

结果是,客户端中的 Sparkle 先看到了新的 appcast.xml,于是去请求其中的下载链接,却正好遇到尚未就绪的文件,返回了 404。

我们因此调整了发布顺序,并定下一条规则:

必须等所有平台的安装包和差分补丁全部上传完成,才能把作为“总开关”的 appcast.xml 发布到公网。

总结

不购买 Apple 的付费证书,并不意味着只能接受手动覆盖的更新方式。

  1. 分清信任的边界: Gatekeeper 防范的是来源不明的恶意软件,而自动更新防范的是传输过程中的篡改和冒充。只有弄清每一层防御针对的问题,才能选择合适的工具;
  2. 数字签名的原理是通用的: 苹果的证书归根结底也是数字签名。使用成熟的开源密码学框架(Sparkle 加 Ed25519),就可以在零成本的情况下搭建一条可靠的自主更新通道;
  3. 原生功能交给原生框架处理: 在跨平台桌面开发中,不必用 Node.js 从头实现安装器的状态机。使用 macOS 上成熟的原生基础设施,通常能带来更好的交互体验。

评论