I am LAZY bones?
AN ancient AND boring SITE

2026年 05月 23日 的归档

给 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,十有八九背后都是它。

工作流大致这样:

  1. App 启动或定时,Sparkle 去服务器拉一份 appcast.xml
  2. xml 里列着最新版本号、下载地址、文件大小、加密签名
  3. 客户端对比版本,发现有更新就弹框问用户
  4. 用户点”安装”,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 里:

  1. 你本机生成一对密钥(私钥 + 公钥)
  2. 公钥写进 App 的 Info.plistSUPublicEDKey 字段,会被烧进每一份发出去的 App)
  3. 私钥只留在你的本机 Keychain 和 CI 的 secret 里
  4. 每次发版,用私钥给 zip 算一个签名,写进 appcast.xml
  5. 客户端下载 zip 后,用嵌在自己 binary 里的公钥校验签名

关键是:就算坏人完全控制了 appcast 服务器,他也伪造不出合法签名—— 除非他拿到了你的私钥。Apple 的公证和 Developer ID 不是 Sparkle 安全的最后一道防线,Sparkle 自带的 EdDSA 签名才是。

也因此:私钥一旦丢了,就再也没法给老用户推新版了—— 公钥已经烧进每一份分发出去的 App 里,没有匹配的私钥就永远签不出合法的更新包。生成后立刻备份到 1Password 或加密 U 盘里,是必做的事。

怎么接

整个接入分四部分:客户端、Info.plist、密钥、CI 流水线。一个个来。

1. SPM 引依赖

Xcode 里 File → Add Package Dependencies,地址:

版本选 up to next major,最低 2.x。Sparkle 2 是现代版本,支持沙盒、XPC 隔离、阶段化推送等。

引入之后写一个最简的 controller:

然后在 AppDelegate.applicationDidFinishLaunching 里引用一下让它实例化:

定时检查、自动下载、弹框 UI、安装重启 —— Sparkle 全包了。”Check for Updates…” 按钮只需要接到 controller.checkForUpdates(nil)

2. 配 Info.plist

Sparkle 至少需要这三个 key:

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 解出来后能在这里找到:

直接跑(不带参数)会在 macOS Keychain 里生成一对 ed25519 密钥,并打印公钥。把这个公钥贴进 SUPublicEDKey

要拿私钥(CI 要用):

文件里就是私钥的 base64 字符串。把这个字符串塞进 CI 的 secret 里(GitHub Actions 里我用的名字是 SPARKLE_PRIVATE_KEY)。本地的私钥文件备份后立刻删掉,因为它是明文。

4. 发版流水线

每次发版多做两件事:

  1. 用私钥给 zip 签名:Sparkle 自带的 sign_update 工具,输入 zip + 私钥文件,输出 sparkle:edSignature="..." length="..." 这一行
  2. 生成 appcast.xml:把版本号、下载 URL、上一步的签名、文件大小、release notes 套进 RSS 模板

最终 appcast.xml 长这样:

注意 <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,挂在了一个看似毫不相关的错误上:

第一反应是网络抖动或 Apple 服务器问题,重跑一次 —— 还是一样。其实 CloudKit 这个错是 staple 在找还没存在的公证票据,根因是上一步 notarytool 返回了 Invalid。

跑一下 xcrun notarytool log <submission-id>,真相终于浮出水面:

原因清楚了:Sparkle 通过 SPM 编译出来时,里面这四个 helper(AutoupdateUpdater.appDownloader.xpcInstaller.xpc)自带的是 ad-hoc 签名,不是你的 Developer ID 签的,也没有安全时间戳。codesign --verify 只检查签名链是否完整、本机能否校验通过,不检查”谁签的”,所以看上去全绿 —— 但 Apple 公证要求每个嵌套可执行文件都用你的 Developer ID 加 secure timestamp 签。

解决办法:archive 完成之后,按由内向外的顺序手动重签 —— 四个 helper、framework 本身、外层 .app,每一步都带 --options runtime --timestamp,最外层那一次还要带回原本的 entitlements 文件。每改动一层嵌套件,外面的封印就破了,必须重新盖一次。

首次升级有断层

接入 Sparkle 的那一版才开始有 Sparkle,再老的版本没有。所以”老版本 → 你接入 Sparkle 的这一版”这次升级用户还得手动下,没办法。从下个版本起才是真的自动。

版本号字段都要写

Sparkle 客户端比较新旧版本时看的是 CFBundleShortVersionStringCFBundleVersion。两个字段都不能漏,且服务端 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 小工具,自动更新这一步真的值得做 —— 它把”我有时间发版”和”老用户能用上新功能”这两件事彻底脱钩。