软件 · 文章

使用Sparkle快速完成Mac软件更新分发功能

VibeCoding时代来临,越来越多人也开始做独立开发了,一款优秀完善的产品,软件的更新分发是必不可少的。

这篇文章主要就是基于这个功能,对Mac app的更新分发的实现做一期简单的讲解。

当开发的Mac软件并不希望直接是啊苹果的应用商店的情况下,大部分人都会采用官网直的形式,也就是让用户从官网下载安装包。

在Mac OS直售分发的场景中,一个比较成熟的方案是,我们可以选择使用Sparkle 。它是一个专门为Mac OS应用设计的自动更新框架,负责:

  • 检查新版本

  • 展示更新提示

  • 下载更新包

  • 签名校验并替换应用

开源地址: https://github.com/sparkle-project/Sparkle


Sparkle自动更新架构

Sparkle自动更新系统通常由三部分组成。

  1. 集成 Sparkle framework,在启动时初始化 updater,并提供“检查更新”入口。

  2. 一个 XML 文件,通常叫 appcast.xml,里面描述最新版本、构建号、下载地址、文件大小、签名和发布说明。

  3. 一般是 zip 包,里面包含新的 .app。Sparkle 下载 zip 后,会校验签名,然后完成替换安装。

e7d4088d-79d7-4119-b610-e8aa2ed77a7d.png

2.1 . 引入Sparkle

如果你的项目用的是Swift Package Manager,那么你可以在Package.swift 中加入Sparkle :

dependencies: [
    .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.0.0"),
],
targets: [
    .executableTarget(
        name: "YourApp",
        dependencies: [
            .product(name: "Sparkle", package: "Sparkle"),
        ]
    )
]

这样应用代码里就可以使用Sparkle提供的SPUStandardUpdaterController。这是使用Sparkle第一步。

2.2 封装更新器

操作Sparkle的方式很多,但是比较建议的是不要在各个View中直接操作Sparkle,而是封装一个独立的Updater服务。

import AppKit
import Sparkle

/// AppUpdater 是应用自动更新管理器(基于 Sparkle 框架)
/// 使用单例模式,确保整个应用中只有一个更新控制器实例
@MainActor
final class AppUpdater {
    /// 单例实例
    static let shared = AppUpdater()

    /// Sparkle 的标准更新控制器
    private var updaterController: SPUStandardUpdaterController?

    /// 私有初始化,防止外部直接创建实例
    private init() {}

    /// 判断更新功能是否已正确配置
    /// 需要在 Info.plist 中同时设置 SUFeedURL 和 SUPublicEDKey
    var isConfigured: Bool {
        feedURL != nil && publicKey?.isEmpty == false
    }

    /// 如果已配置,则启动更新控制器
    /// 此方法会在应用启动时或需要更新时被调用
    func startIfConfigured() {
        guard isConfigured else {
            NSLog("Updater is not configured.")
            return
        }

        // 避免重复创建控制器
        if updaterController == nil {
            updaterController = SPUStandardUpdaterController(
                startingUpdater: true,        // 立即启动检查器
                updaterDelegate: nil,         // 使用默认代理
                userDriverDelegate: nil       // 使用默认用户界面驱动
            )
        }
    }

    /// 手动检查更新(通常由用户在菜单中触发)
    func checkForUpdates() {
        guard isConfigured else {
            presentConfigurationMissingAlert()
            return
        }

        startIfConfigured()
        updaterController?.checkForUpdates(nil)
    }

    /// 从 Info.plist 中读取更新 feed URL
    private var feedURL: URL? {
        guard let value = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String,
              !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
        else {
            return nil
        }
        return URL(string: value)
    }

    /// 从 Info.plist 中读取 Sparkle 公钥(用于验证更新包签名)
    private var publicKey: String? {
        Bundle.main.object(forInfoDictionaryKey: "SUPublicEDKey") as? String
    }

    /// 显示配置缺失提示弹窗
    /// 通常在 Debug 构建或未正确配置 Info.plist 时出现
    private func presentConfigurationMissingAlert() {
        let alert = NSAlert()
        alert.messageText = "Updates Not Configured"
        alert.informativeText = "Set SUFeedURL and SUPublicEDKey in Info.plist for release builds."
        alert.alertStyle = .informational
        alert.addButton(withTitle: "OK")
        alert.runModal()
    }
}

这里需要注意两个关键点:

  • startingUpdater:true 表示启动Sparkle更新检查机制。

  • checkForUpdates(nil) 用于衣服主动点击 检查更新 UI 。


2.3 启动时初始化

在AppDelegate 或者应用启动入口调用:

AppUpdater.shared.startIfConfigured()

这样正式发布包启动之后,Sparkle就会根据自己的策略自动检查更新。同时可以在设置页、菜单栏或者About等页面提供手动检查更新的入口。

Button("Check for Updates...") {
    AppUpdater.shared.checkForUpdates()
}

2.4 配置Info.plist

Sparkle依赖两个重要的配置,SUFeedURL和SUPublicEDKey 。

SUFeedURL 是 appcast 地址。

SUPublicEDKey 是 Sparkle 的 EdDSA 公钥,用来验证更新包签名。对应的私钥只应该保存在发布机器或 CI 的安全环境里,如果你的项目使用远程仓库托管,绝不能提交到仓库。

实际项目里,建议不要把这两个值硬编码在开发用 Info.plist 中,而是在 release 打包脚本里动态写入。这样 Debug 构建可以不启用自动更新,Release 构建才启用。


2.5 生成Sparkle密钥

Sparkle 提供 generate_keys 工具生成公私钥。

大致流程是:

generate_key

生成后会得到:

  • public key:写进应用的 SUPublicEDKey

  • private key:发布时用于签名 appcast/zip

私钥要妥善保存,例如放在 Keychain、安全目录或 CI secret 中。


2.6 打包发布zip

ditto -c -k --norsrc --keepParent YourApp.app YourApp-1.0.0.zip

正式发布时,还要确保 app 已完成:

  • Developer ID 签名

  • hardened runtime

  • Apple notarization

  • staple 公证票据

否则用户下载后可能遇到 Gatekeeper 拦截。


2.7 生成 appcast.xml

appcast 是 Sparkle 的更新索引。一个简化版本如下:

<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
  <channel>
    <title>YourApp</title>
    <item>
      <title>1.0.1</title>
      <sparkle:version>2</sparkle:version>
      <sparkle:shortVersionString>1.0.1</sparkle:shortVersionString>
      <sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
      <enclosure
        url="https://releases.example.com/YourApp-1.0.1.zip"
        length="12345678"
        type="application/octet-stream"
        sparkle:edSignature="..." />
    </item>
  </channel>
</rss>
  • sparkle:shortVersionString:用户看到的版本号,例如 1.0.1

  • sparkle:version:构建号,对应 CFBundleVersion,必须递增

  • enclosure url:更新 zip 下载地址

  • sparkle:edSignature:zip 的签名

  • sparkle:minimumSystemVersion:最低系统版本

Sparkle 提供 generate_appcast 工具自动生成 appcast,并可以自动写入签名:

generate_appcast \
  --download-url-prefix https://releases.example.com/ \
  --ed-key-file /secure/path/private_key \
  dist/appcast

dist/appcast 目录里放好 zip 后,工具会生成或更新 appcast.xml。


2.8 上传发布文件

最终需要上传这些文件到你的发布服务器或者对象存储中,个人用的是Cloudflare。

https://releases.example.com/appcast.xml
https://releases.example.com/YourApp-1.0.1.zip
https://releases.example.com/release-notes/1.0.1/en.html

推荐缓存策略:

  • appcast.xml:no-cache

  • zip/delta 包:长期缓存,文件名带版本号和构建号

  • release notes:可以 no-cache

原因是 appcast.xml 是更新入口,用户每次检查更新都依赖它,不能被 CDN 长时间缓存旧版本。


2.9 注意安全

Sparkle 的安全关键在于签名校验。

发布端使用私钥给更新包签名:

private key -> sign zip -> appcast.xml 写入 edSignature

客户端应用内置公钥:

SUPublicEDKey -> verify downloaded zip

如果攻击者篡改 zip,签名校验会失败,Sparkle 不会安装。

所以:

  • 私钥不能进仓库

  • 私钥不能出现在日志里

  • 更换公钥会影响老版本升级能力

  • appcast 和 zip 最好使用 HTTPS 托管


版权声明

本文内容版权归作者或相关权利人所有。转载、引用或其他使用请遵循相应授权条款,并保留本文链接。

本文链接:https://xuyi.dev/2026-06-30-qvzdko