给 macOS App 加自动更新:Sparkle 入门
最近给 Notchy 加了自动更新。原先的”检查更新”功能很糙:调一下 GitHub Releases API 看有没有新版本,发现有就开浏览器跳到 release 页面,让用户自己下 dmg、自己拖进 Applications。
这显然算不上”自动”。理想体验是:发现新版本 → 自动下载 → 校验签名 → 替换 App → 重启。这一套自己写也可以,但 macOS 生态二十年前就有现成的轮子 —— Sparkle。
接进去之后回头看,整个事情比想象中简单,但中间踩了几个不算直觉的坑,整理一下。

Sparkle 是什么
Sparkle 是 macOS 上事实标准的开源自动更新框架,从 2006 年用到现在。Transmission、VLC、Handbrake、Sequel Pro、Tower、Hammerspoon、iStat Menus …… 基本上你在 App Store 之外用过的、有自动更新的 Mac App,十有八九背后都是它。
工作流大致这样:
- App 启动或定时,Sparkle 去服务器拉一份
appcast.xml - xml 里列着最新版本号、下载地址、文件大小、加密签名
- 客户端对比版本,发现有更新就弹框问用户
- 用户点”安装”,Sparkle 后台下载 zip、校验签名是不是你这个开发者签的、没问题就解压、替换
.app、关掉旧进程、启动新的
对用户来说就是一个对话框加一次重启。对开发者来说就是发版时多产出一份 appcast.xml。
谁适合用
两种情况不太适合:上架 Mac App Store 的 App(商店本身有更新机制,且 App Store 也不允许 App 自己拉远端代码下来执行),以及强沙盒 App(Sparkle 2 技术上支持沙盒,但配置麻烦得多,要走 XPC service 隔离)。
比较适合的:自己 Developer ID 签名 + 公证 + 通过官网或 GitHub Releases 分发的独立 Mac App。这也是 Notchy 这类小工具最常见的发布方式。
什么是 EdDSA
Sparkle 在客户端校验更新包真伪用的是 EdDSA 签名。这一步对安全至关重要 —— 没有它,任何能劫持你 appcast URL 的人都能给你的用户推一个伪造的更新包。
EdDSA(Edwards-curve Digital Signature Algorithm)是基于椭圆曲线的数字签名算法。相比经典的 RSA,密钥短得多(公私钥各 32 字节,base64 后大约一行),签名快,对边信道攻击有自带防御。
具体在 Sparkle 里:
- 你本机生成一对密钥(私钥 + 公钥)
- 公钥写进 App 的
Info.plist(SUPublicEDKey字段,会被烧进每一份发出去的 App) - 私钥只留在你的本机 Keychain 和 CI 的 secret 里
- 每次发版,用私钥给 zip 算一个签名,写进
appcast.xml - 客户端下载 zip 后,用嵌在自己 binary 里的公钥校验签名
关键是:就算坏人完全控制了 appcast 服务器,他也伪造不出合法签名—— 除非他拿到了你的私钥。Apple 的公证和 Developer ID 不是 Sparkle 安全的最后一道防线,Sparkle 自带的 EdDSA 签名才是。
也因此:私钥一旦丢了,就再也没法给老用户推新版了—— 公钥已经烧进每一份分发出去的 App 里,没有匹配的私钥就永远签不出合法的更新包。生成后立刻备份到 1Password 或加密 U 盘里,是必做的事。
怎么接
整个接入分四部分:客户端、Info.plist、密钥、CI 流水线。一个个来。
1. SPM 引依赖
Xcode 里 File → Add Package Dependencies,地址:
|
1 |
https://github.com/sparkle-project/Sparkle |
版本选 up to next major,最低 2.x。Sparkle 2 是现代版本,支持沙盒、XPC 隔离、阶段化推送等。
引入之后写一个最简的 controller:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import Sparkle @MainActor final class UpdaterController { static let shared = UpdaterController() let controller: SPUStandardUpdaterController private init() { controller = SPUStandardUpdaterController( startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil ) } func checkForUpdates() { controller.checkForUpdates(nil) } } |
然后在 AppDelegate.applicationDidFinishLaunching 里引用一下让它实例化:
|
1 |
_ = UpdaterController.shared |
定时检查、自动下载、弹框 UI、安装重启 —— Sparkle 全包了。”Check for Updates…” 按钮只需要接到 controller.checkForUpdates(nil)。
2. 配 Info.plist
Sparkle 至少需要这三个 key:
|
1 2 3 4 5 6 |
<key>SUFeedURL</key> <string>https://你的域名/appcast.xml</string> <key>SUPublicEDKey</key> <string>你生成的 EdDSA 公钥(base64)</string> <key>SUEnableAutomaticChecks</key> <true/> |
SUFeedURL 一般有两种托管方式:
- 单独搞个 GitHub Pages / 自家 CDN,URL 固定
- 直接用 GitHub Release 的稳定链接:
https://github.com/<owner>/<repo>/releases/latest/download/appcast.xml
第二种最省事 —— GitHub 这个 URL 永远 redirect 到最新 release 里叫 appcast.xml 的那个 asset,发版时把 xml 当资产上传一份就行,零额外服务。
3. 生成 EdDSA 密钥
Sparkle 自带 generate_keys 工具。SPM 解出来后能在这里找到:
|
1 |
~/Library/Developer/Xcode/DerivedData/<你的项目>-*/SourcePackages/artifacts/sparkle/Sparkle/bin/generate_keys |
直接跑(不带参数)会在 macOS Keychain 里生成一对 ed25519 密钥,并打印公钥。把这个公钥贴进 SUPublicEDKey。
要拿私钥(CI 要用):
|
1 |
generate_keys -x ~/Desktop/private.key |
文件里就是私钥的 base64 字符串。把这个字符串塞进 CI 的 secret 里(GitHub Actions 里我用的名字是 SPARKLE_PRIVATE_KEY)。本地的私钥文件备份后立刻删掉,因为它是明文。
4. 发版流水线
每次发版多做两件事:
- 用私钥给 zip 签名:Sparkle 自带的
sign_update工具,输入 zip + 私钥文件,输出sparkle:edSignature="..." length="..."这一行 - 生成 appcast.xml:把版本号、下载 URL、上一步的签名、文件大小、release notes 套进 RSS 模板
最终 appcast.xml 长这样:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
<?xml version="1.0" encoding="utf-8"?> <rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"> <channel> <title>YourApp</title> <item> <title>Version 1.2.3</title> <sparkle:version>1.2.3</sparkle:version> <sparkle:shortVersionString>1.2.3</sparkle:shortVersionString> <description><![CDATA[ <h2>Changes</h2> <ul><li>release notes 的 HTML</li></ul> ]]></description> <pubDate>Sat, 23 May 2026 10:00:00 +0000</pubDate> <enclosure url="https://.../YourApp-1.2.3.zip" sparkle:edSignature="..." length="2607122" type="application/octet-stream" /> </item> </channel> </rss> |
注意 <description> 里直接内嵌 release notes 的 HTML —— Sparkle 弹框会原地渲染。如果用 <sparkle:releaseNotesLink> 指向 GitHub release 详情页,Sparkle 会用 WebView 加载那个 URL,GitHub 整站的导航条、登录按钮、左侧栏全会被渲染进对话框里,体验极差。这是亲自踩过的坑。
几个不那么直觉的细节
Sparkle helper 的重签问题
这是当时折腾最久的一个。接入做完、编译一切正常、archive 顺利出来、codesign --verify --deep --strict 全绿、所有嵌套件都 --validated。一切看着都对,提交给 notarytool 跑公证 —— 几十秒后回来一个 status: Invalid。
紧接着脚本继续往下跑 staple,挂在了一个看似毫不相关的错误上:
|
1 2 |
CloudKit query for YourApp.app (...) failed due to "Record not found". The staple and validate action failed! Error 65. |
第一反应是网络抖动或 Apple 服务器问题,重跑一次 —— 还是一样。其实 CloudKit 这个错是 staple 在找还没存在的公证票据,根因是上一步 notarytool 返回了 Invalid。
跑一下 xcrun notarytool log <submission-id>,真相终于浮出水面:
|
1 2 3 4 5 |
"path": ".../Sparkle.framework/Versions/B/Updater.app/Contents/MacOS/Updater", "message": "The binary is not signed with a valid Developer ID certificate." "path": ".../Sparkle.framework/Versions/B/Updater.app/Contents/MacOS/Updater", "message": "The signature does not include a secure timestamp." … (Autoupdate、Downloader.xpc、Installer.xpc 同样两条 × 2) |
原因清楚了:Sparkle 通过 SPM 编译出来时,里面这四个 helper(Autoupdate、Updater.app、Downloader.xpc、Installer.xpc)自带的是 ad-hoc 签名,不是你的 Developer ID 签的,也没有安全时间戳。codesign --verify 只检查签名链是否完整、本机能否校验通过,不检查”谁签的”,所以看上去全绿 —— 但 Apple 公证要求每个嵌套可执行文件都用你的 Developer ID 加 secure timestamp 签。
解决办法:archive 完成之后,按由内向外的顺序手动重签 —— 四个 helper、framework 本身、外层 .app,每一步都带 --options runtime --timestamp,最外层那一次还要带回原本的 entitlements 文件。每改动一层嵌套件,外面的封印就破了,必须重新盖一次。
首次升级有断层
接入 Sparkle 的那一版才开始有 Sparkle,再老的版本没有。所以”老版本 → 你接入 Sparkle 的这一版”这次升级用户还得手动下,没办法。从下个版本起才是真的自动。
版本号字段都要写
Sparkle 客户端比较新旧版本时看的是 CFBundleShortVersionString 和 CFBundleVersion。两个字段都不能漏,且服务端 appcast 里 <sparkle:version> 和 <sparkle:shortVersionString> 要和 App 里的对得上,否则版本判断会出意外。
小结
接入 Sparkle 的核心动作就那几下:
- SPM 加依赖、写个最小的
SPUStandardUpdaterController - Info.plist 写好 feed URL 加公钥
- 生成 EdDSA 密钥对,公钥写 plist、私钥进 Keychain 和 CI secret
- 发版时多产出 appcast.xml,签好上传
接完之后,发版就是推个 tag,CI 自动签、公证、生成 appcast、上传。用户那边就是某天打开 App,弹框告诉他有新版本,点一下、重启,完事。
如果你也在写独立的 macOS 小工具,自动更新这一步真的值得做 —— 它把”我有时间发版”和”老用户能用上新功能”这两件事彻底脱钩。








