跳转至

Sparkle 框架与 Qt C++ / CEF 集成指南

面向第一次接触 Sparkle 的开发者,介绍 Sparkle 的用途、Qt C++ / CEF 客户端接入方法、后续发版流程,以及 ChatGPT.app 中没有 SUFeedURL 的原因。

文档整理日期:2026-07-23

本文是 ADR-0010:macOS 热修基于 Sparkle 的实现参考。 决策以 ADR-0010 为准,本文是它指向的"怎么接",属参考层(docs/research/),非规范。

1. Sparkle 是什么

Sparkle 是一个用于 macOS 软件自动更新的开源框架,采用 MIT 许可证。

它主要解决官网直接分发的 Mac App 如何完成以下流程:

  1. 检查是否存在新版本。
  2. 比较当前版本和服务器版本。
  3. 展示更新说明。
  4. 下载 ZIP、DMG 等更新包。
  5. 验证更新包签名。
  6. 退出旧版本程序。
  7. 替换旧 App。
  8. 启动新版本程序。

Sparkle 还支持:

  • 后台定时检查更新
  • 自动下载和安装
  • 增量更新
  • Beta 等更新通道
  • 分阶段灰度发布
  • 强制或关键更新提示
  • 自定义更新界面
  • App Sandbox

截至本文整理时,Sparkle 当前稳定版本为 2.9.4

Sparkle 不负责什么

Sparkle 不会替你完成:

  • 编译客户端程序
  • 托管更新文件
  • Apple Developer ID 签名
  • Apple Notarization 公证
  • 编写业务更新说明
  • 管理发布服务器或 CDN

因此,完整接入分为两部分:

客户端接入
    +
服务端文件托管与发版流程

2. 哪些程序适合使用 Sparkle

适合

  • 从公司官网下载安装的 macOS App
  • 通过 DMG、ZIP 或企业渠道分发的 macOS App
  • Swift、Objective-C、Qt、CEF 等技术栈构建的 macOS App
  • 希望自己控制更新节奏、更新通道和发布服务器的 App

不适合

  • iOS、iPadOS、Windows 或 Linux 应用
  • 只通过 Mac App Store 分发的版本

Mac App Store 版本通常应由 App Store 管理更新,不应自行下载并替换可执行程序。

如果同一产品同时存在“官网版”和“Mac App Store 版”,建议使用两个构建配置:

官网版:启用 Sparkle
商店版:不包含或不启动 Sparkle

3. Sparkle 的完整更新流程

用户启动 App 或点击“检查更新”
Sparkle 获取 appcast.xml
读取服务器上的最新版本号
与当前 CFBundleVersion 比较
是否存在更高版本?
      ├── 否 → 提示“已是最新版本”
      └── 是
       展示更新说明
       下载 ZIP / DMG
       验证 EdDSA 签名和 Apple 代码签名
       请求客户端正常退出
       替换旧 App
       启动新版本

appcast.xml 是什么

appcast.xml 是 Sparkle 使用的更新信息文件,格式类似 RSS。

其中通常包含:

  • 最新版本号
  • 用户可见版本号
  • 更新包下载地址
  • 更新包大小
  • EdDSA 签名
  • 更新说明地址
  • 最低 macOS 版本
  • 发布时间

建议使用 Sparkle 自带的 generate_appcast 工具生成,不要手工维护 XML。

4. 客户端首次接入需要做什么

无论客户端使用 Swift、Qt 还是 CEF,首次接入的核心工作基本相同。

4.1 添加 Sparkle.framework

可以通过以下方式取得 Sparkle:

  • Swift Package Manager
  • 下载官方预编译 Release
  • Carthage
  • 手工集成 Framework

对于 CMake、qmake、Qt 或 CEF 项目,使用官方预编译的 Sparkle.framework 通常更直接。

最终需要将它放进 App Bundle:

MyApp.app/
└── Contents/
    └── Frameworks/
        └── Sparkle.framework/

复制 Framework 时必须保留:

  • 符号链接
  • 文件权限
  • 可执行权限
  • Framework 原始目录结构

4.2 生成 Sparkle 密钥

找到 Sparkle 发布包中的 bin 目录,执行:

./bin/generate_keys

这个命令会:

  1. 生成 EdDSA 私钥。
  2. 默认将私钥保存到当前 Mac 的钥匙串。
  3. 输出对应的公钥。

私钥用于每次发布时签署更新包。

公钥放进客户端,用于验证下载到的更新包。

私钥绝对不能提交到 Git,也不应放在公开的更新服务器上。应将其备份到安全位置。

4.3 配置 Info.plist

最基本的配置如下:

<key>CFBundleIdentifier</key>
<string>com.example.MyApp</string>

<key>CFBundleShortVersionString</key>
<string>1.2.0</string>

<key>CFBundleVersion</key>
<string>120</string>

<key>SUFeedURL</key>
<string>https://updates.example.com/appcast.xml</string>

<key>SUPublicEDKey</key>
<string>这里填写 generate_keys 输出的公钥</string>

版本字段含义:

字段 作用 示例
CFBundleShortVersionString 展示给用户看的版本 1.2.0
CFBundleVersion Sparkle 用来比较的内部版本 120

每次发布新版本时,CFBundleVersion 必须严格递增。

推荐采用简单整数:

100 → 101 → 102 → 110

4.4 可选的自动更新设置

<key>SUEnableAutomaticChecks</key>
<true/>

<key>SUAutomaticallyUpdate</key>
<false/>

含义:

  • SUEnableAutomaticChecks:是否默认允许后台检查。
  • SUAutomaticallyUpdate:是否默认自动下载并安装。

如果不配置,Sparkle 会使用自己的默认行为,并可能在第二次启动时询问用户是否允许自动检查。

5. Qt C++ / CEF 程序如何接入

Qt C++ / CEF 程序可以使用 Sparkle。Sparkle 官方文档中提供了 Qt 接入示例。

因为 Sparkle 的公开 API 是 Objective-C API,纯 C++ 不能直接调用,所以需要增加一层 Objective-C++ 桥接。

推荐结构:

Qt QAction 或 CEF 页面按钮
纯 C++ Updater 接口
Objective-C++ 桥接文件 updater_sparkle.mm
SPUStandardUpdaterController
Sparkle 更新流程

5.1 推荐文件结构

src/
├── updater.h
└── updater_sparkle.mm

其中:

  • updater.h:供 Qt/C++ 调用。
  • updater_sparkle.mm:调用 Sparkle Objective-C API。

5.2 核心 Objective-C++ 调用

updater_sparkle.mm 必须使用 Objective-C++ 编译,并开启 ARC:

-fobjc-arc

核心逻辑如下:

#import <Sparkle/Sparkle.h>

_updaterController =
    [[SPUStandardUpdaterController alloc]
        initWithStartingUpdater:YES
        updaterDelegate:nil
        userDriverDelegate:nil];

手动检查更新:

[_updaterController checkForUpdates:nil];

注意:

  • SPUStandardUpdaterController 必须作为长期成员保存。
  • 不能在临时函数中创建后立即释放。
  • 初始化和界面操作应在 macOS/Qt 主线程完成。

5.3 Qt 菜单触发

Qt 菜单中可以创建一个 QAction

QAction *checkUpdateAction =
    new QAction(tr("检查更新…"), this);

connect(checkUpdateAction,
        &QAction::triggered,
        updater,
        &Updater::checkForUpdates);

Sparkle 的 canCheckForUpdates 属性支持 KVO。官方 Qt 示例使用 KVO 自动控制菜单是否可点击。

5.4 CEF 页面触发

如果“检查更新”按钮放在 CEF 页面中,可以走以下调用链:

JavaScript 点击按钮
CEF JavaScript / C++ Bridge
Qt/C++ Updater::checkForUpdates()
updater_sparkle.mm
Sparkle

第一版建议使用 Sparkle 自带的原生 AppKit 更新窗口。

如果希望将整个更新界面显示在 CEF 页面中,需要实现 Sparkle 自定义用户界面,例如自定义 SPUUserDriver,开发和测试成本会明显增加。

5.5 链接和运行时路径

需要链接 Sparkle:

-framework Sparkle

并配置 Framework 搜索目录:

-F/path/to/Sparkle

常见运行时搜索路径:

@executable_path/../Frameworks

或者:

@loader_path/../Frameworks

可以使用以下命令检查最终产物:

otool -L MyApp.app/Contents/MacOS/MyApp
otool -l MyApp.app/Contents/MacOS/MyApp

5.6 CMake 配置示意

下面只展示核心思路,实际路径需要根据项目调整:

find_library(
    SPARKLE_FRAMEWORK
    Sparkle
    PATHS "${CMAKE_SOURCE_DIR}/third_party/Sparkle"
    NO_DEFAULT_PATH
)

target_sources(MyApp PRIVATE
    src/updater_sparkle.mm
)

set_source_files_properties(
    src/updater_sparkle.mm
    PROPERTIES
    COMPILE_OPTIONS "-fobjc-arc"
)

target_link_libraries(MyApp PRIVATE
    "${SPARKLE_FRAMEWORK}"
    "-framework AppKit"
)

target_link_options(MyApp PRIVATE
    "-Wl,-rpath,@executable_path/../Frameworks"
)

还需要在打包阶段将 Sparkle.framework 复制到:

MyApp.app/Contents/Frameworks/

具体复制方式应确保保留 Framework 的符号链接和可执行权限。

5.7 qmake 配置示意

macx {
    SOURCES += src/updater_sparkle.mm

    QMAKE_OBJECTIVE_CFLAGS += -fobjc-arc

    LIBS += -F$$PWD/third_party/Sparkle
    LIBS += -framework Sparkle

    QMAKE_LFLAGS += -Wl,-rpath,@executable_path/../Frameworks
}

同样需要在最终打包阶段嵌入 Sparkle.framework

6. App Sandbox 配置

如果 App 没有开启 App Sandbox,可以跳过本节。

如果开启了 App Sandbox,Sparkle 需要通过 XPC Service 在沙盒外完成安装。

6.1 Info.plist

增加:

<key>SUEnableInstallerLauncherService</key>
<true/>

6.2 Entitlements

增加:

<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
    <string>$(PRODUCT_BUNDLE_IDENTIFIER)-spks</string>
    <string>$(PRODUCT_BUNDLE_IDENTIFIER)-spki</string>
</array>

如果 App 已经拥有:

com.apple.security.network.client

通常不需要启用 Sparkle 的 Downloader XPC Service。

如果 App 没有网络访问权限,并希望使用 Sparkle Downloader Service,则需要进一步配置:

<key>SUEnableDownloaderService</key>
<true/>

这部分必须根据程序现有 Entitlements 设计,不建议盲目复制。

7. CEF 多进程需要特别注意什么

CEF 通常包含多个进程:

主进程
Renderer Helper
GPU Helper
Utility Helper
Crashpad Handler
其他子进程

Sparkle 安装更新时,需要主程序和相关子进程正确退出。

推荐做法

  • 在 Qt 的退出流程中关闭所有 CEF Browser。
  • 等待 Browser 生命周期结束。
  • 在正确线程调用 CefShutdown()
  • 确保 Helper 进程不会在主程序退出后长期存活。
  • 不要将 Sparkle 集成到每个 CEF Helper 中,只集成到主 App。
  • 确认应用退出确认框不会永久拦截 Sparkle 的安装退出。
  • 对正在进行的任务、未保存文档等状态做友好处理。

可能出现的问题

  • Sparkle 一直等待客户端退出。
  • Helper 进程仍占用 App Bundle 内文件。
  • 更新已经下载,但无法安装。
  • 安装成功后无法正常重新启动。
  • 主程序退出时没有执行完整的 CEF 清理。

正式发布前必须测试:

旧版本主程序
    +
CEF 页面正常运行
    +
多个 Helper 进程存在
点击更新并安装
所有进程退出
新版本正常启动

8. 代码签名和 Apple 公证

Qt、CEF 和 Sparkle App 中通常包含大量嵌套组件:

MyApp.app
├── Contents/Frameworks/Qt*.framework
├── Contents/Frameworks/Chromium Embedded Framework.framework
├── Contents/Frameworks/Sparkle.framework
├── CEF Helper.app
└── 主程序

正式发布时需要:

  1. 使用 Developer ID Application 证书。
  2. 启用 Hardened Runtime。
  3. 正确签名所有嵌套 Framework、Helper 和可执行文件。
  4. 从内到外签名。
  5. 最后签名主 App。
  6. 提交 Apple Notarization。
  7. 验证 Gatekeeper 是否接受。

推荐检查命令:

codesign --verify --verbose=4 MyApp.app
spctl --assess --type execute --verbose=4 MyApp.app

不要简单依赖:

codesign --deep

Sparkle 官方明确提醒,--deep 可能导致 Sparkle XPC Service 的 Entitlements 或签名不正确。

另外需要区分两种签名:

签名 用途
Apple Developer ID 代码签名 证明 App 来自可信开发者,并供 Gatekeeper 验证
Sparkle EdDSA 签名 证明下载的更新包确实由发布者签发且没有被篡改

两者不能互相替代。

9. 每次发布新版本的流程

首次接入完成以后,每次发布主要执行以下步骤。

假设从:

1.0.0 (100)

升级到:

1.1.0 (110)

第一步:修改版本

<key>CFBundleShortVersionString</key>
<string>1.1.0</string>

<key>CFBundleVersion</key>
<string>110</string>

第二步:构建正式 App

  • 使用 Release 配置。
  • 完成所有 Framework 和 Helper 签名。
  • 使用 Developer ID 签名主 App。
  • 启用 Hardened Runtime。
  • 提交 Apple Notarization。

第三步:制作更新包

Sparkle 支持多种格式。普通 App 推荐使用:

  • ZIP
  • DMG

例如:

updates/
├── MyApp-1.1.0.zip
└── MyApp-1.1.0.html

如果更新说明文件和更新包同名,generate_appcast 可以自动将它关联为 Release Notes。

第四步:生成 Appcast

./bin/generate_appcast /path/to/updates/

该工具会:

  • 为更新包生成 EdDSA 签名
  • 创建或更新 appcast.xml
  • 记录版本、大小和下载信息
  • 根据条件生成增量更新文件
  • 关联 HTML 或 Markdown 更新说明

第五步:上传服务器

上传以下内容:

https://updates.example.com/appcast.xml
https://updates.example.com/MyApp-1.1.0.zip
https://updates.example.com/MyApp-1.1.0.html

只要文件能通过 HTTPS 访问即可,不一定需要专门开发后端 API。

可以使用:

  • 普通 HTTPS 文件服务器
  • 对象存储
  • CDN
  • GitHub Releases 配合稳定的 Appcast 托管

第六步:旧版本升级测试

必须使用真实旧版本测试:

1.0.0 (100)
检查更新
发现 1.1.0 (110)
下载、验签、退出、安装
1.1.0 正常启动

10. ChatGPT.app 为什么没有 SUFeedURL

对本机以下文件进行了只读检查:

/Applications/ChatGPT.app/Contents/Info.plist

检查结果表明:

  • Info.plist 中没有 SUFeedURL
  • Info.plist 中存在 SUPublicEDKey
  • App 内包含 Sparkle.framework
  • App 内包含 Electron/Node 原生模块 Resources/native/sparkle.node
  • sparkle.node 链接了 Sparkle 2
  • sparkle.node 实现了 feedURLStringForUpdater:
  • Electron 资源中包含生产环境 Appcast URL

观察到的生产更新源为:

https://persistent.oaistatic.com/codex-app-prod/appcast.xml

sparkle.node 二进制中还可以观察到以下信息:

Sparkle init requires a feed URL string
Sparkle init requires a non-empty feed URL
fallbackFeedURL
feedURLStringForUpdater:
Switching Sparkle to the fallback feed.

因此可以判断,ChatGPT.app 并不是依赖 Info.plist 中的静态 SUFeedURL,而是通过自定义 Electron/Node 桥接层,在运行时把 Feed URL 交给 Sparkle。

整体结构如下:

Electron JavaScript
根据环境和功能开关选择更新地址
原生模块 sparkle.node
SPUUpdaterDelegate.feedURLStringForUpdater:
Sparkle.framework
appcast.xml

为什么 SUPublicEDKey 仍然放在 Info.plist

Feed URL 和公钥承担的职责不同:

配置 职责 是否适合动态变化
SUFeedURL 告诉 Sparkle 去哪里读取更新信息 可以动态切换
SUPublicEDKey 验证更新包是否由可信发布者签发 应保持稳定

公钥是客户端验证更新包的信任根,因此通常随 App 固定发布。

Feed URL 则可能因为以下原因动态变化:

  • 正式版和内部版使用不同更新源
  • Beta、Nightly 等不同更新通道
  • 功能开关控制
  • 内部 CDN 与公共 CDN 切换
  • 主更新源和备用更新源切换
  • 请求需要附加认证或实验请求头
  • 根据账号、地区或发布策略选择 Appcast

所以 ChatGPT.app 没有 SUFeedURL 是自定义更新架构的结果,不是漏配。

11. 静态 Feed 和动态 Feed 如何选择

普通 Qt/C++ 客户端

推荐直接在 Info.plist 配置:

<key>SUFeedURL</key>
<string>https://updates.example.com/appcast.xml</string>

优点:

  • 配置简单
  • 容易排查
  • 符合 Sparkle 默认使用方式
  • 不需要额外 Delegate 逻辑

需要动态更新源的客户端

可以实现 SPUUpdaterDelegate

- (NSString *)feedURLStringForUpdater:(SPUUpdater *)updater
{
    return self.currentFeedURL;
}

适合:

  • 正式版和 Beta 版动态切换
  • 多个 CDN
  • 企业内外网使用不同地址
  • 需要自定义请求头
  • 具有备用 Appcast
  • 更新源受远程配置控制

对于绝大多数普通项目,优先使用静态 SUFeedURL。只有明确存在动态更新需求时,才建议实现运行时 Feed。

12. 测试与常见问题检查表

客户端接入检查

  • [ ] Sparkle.framework 已放入 Contents/Frameworks
  • [ ] Framework 符号链接和权限没有损坏
  • [ ] 主程序已链接 -framework Sparkle
  • [ ] rpath 能找到 Contents/Frameworks
  • [ ] .mm 文件使用 Objective-C++ 编译
  • [ ] .mm 文件启用了 -fobjc-arc
  • [ ] Updater 对象在 App 生命周期内持续存在
  • [ ] Sparkle API 从主线程调用
  • [ ] SUPublicEDKey 配置正确
  • [ ] 静态或动态 Feed URL 至少存在一种

版本检查

  • [ ] CFBundleShortVersionString 是用户可见版本
  • [ ] CFBundleVersion 使用可比较的数字格式
  • [ ] 新版本的 CFBundleVersion 严格大于旧版本
  • [ ] Appcast 中的版本和 App 内版本一致

发布和安全检查

  • [ ] 使用 HTTPS
  • [ ] Sparkle 私钥没有提交到 Git
  • [ ] 私钥已安全备份
  • [ ] 更新包已生成 EdDSA 签名
  • [ ] 主 App、Qt Framework、CEF Framework 和 Helper 均已签名
  • [ ] 没有依赖 codesign --deep 粗暴处理
  • [ ] Hardened Runtime 配置正确
  • [ ] Developer ID 签名验证通过
  • [ ] Apple Notarization 通过
  • [ ] Gatekeeper 检查通过

CEF 检查

  • [ ] 更新时所有 CEF Browser 能正常关闭
  • [ ] 正确调用 CefShutdown()
  • [ ] Helper 进程不会残留
  • [ ] 退出确认逻辑不会永久阻止更新
  • [ ] Sparkle 只集成在主 App
  • [ ] 更新后主程序和 Helper 能正常启动

服务端检查

  • [ ] appcast.xml 可通过公网或目标内网访问
  • [ ] 更新包 URL 可以直接下载
  • [ ] 更新说明地址有效
  • [ ] CDN 没有长期缓存旧的 appcast.xml
  • [ ] MIME 类型和 HTTPS 证书正常
  • [ ] 主更新源故障时有明确处理方案

更新测试

  • [ ] 使用真实旧版本测试
  • [ ] 手动检查更新能发现新版本
  • [ ] 自动检查更新行为符合预期
  • [ ] 更新说明显示正常
  • [ ] 下载进度正常
  • [ ] 验签成功
  • [ ] 安装完成
  • [ ] 新版本能够重新启动
  • [ ] 在一台干净 Mac 上完成最终测试

常见问题

找不到新版本

优先检查:

  • CFBundleVersion 是否真的增加
  • appcast.xml 是否仍被 CDN 缓存
  • Feed URL 是否正确
  • Appcast 中是否存在架构或最低系统版本限制

启动时报找不到 Sparkle.framework

检查:

  • Framework 是否位于 Contents/Frameworks
  • rpath 是否包含 @executable_path/../Frameworks
  • otool -L 输出是否正确
  • Framework 符号链接是否被打包脚本破坏

更新下载成功但无法安装

检查:

  • App 是否运行在只读 DMG 中
  • App 是否位于可写位置
  • CEF Helper 是否仍然运行
  • Sparkle XPC Service 是否正确签名
  • App Sandbox Entitlements 是否正确

想回滚到旧版本

Sparkle 通常只接受更高的 CFBundleVersion

如果新版本出现严重问题,应当:

  1. 将代码回退到稳定实现。
  2. 发布一个更高的内部构建号。
  3. 让用户更新到这个“代码回退但版本号更高”的修复版本。

例如:

故障版本:1.2.0 (120)
回滚修复:1.2.1 (121)

13. 官方资料