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 如何完成以下流程:
- 检查是否存在新版本。
- 比较当前版本和服务器版本。
- 展示更新说明。
- 下载 ZIP、DMG 等更新包。
- 验证更新包签名。
- 退出旧版本程序。
- 替换旧 App。
- 启动新版本程序。
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 版”,建议使用两个构建配置:
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:
复制 Framework 时必须保留:
- 符号链接
- 文件权限
- 可执行权限
- Framework 原始目录结构
4.2 生成 Sparkle 密钥¶
找到 Sparkle 发布包中的 bin 目录,执行:
这个命令会:
- 生成 EdDSA 私钥。
- 默认将私钥保存到当前 Mac 的钥匙串。
- 输出对应的公钥。
私钥用于每次发布时签署更新包。
公钥放进客户端,用于验证下载到的更新包。
私钥绝对不能提交到 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 必须严格递增。
推荐采用简单整数:
4.4 可选的自动更新设置¶
含义:
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 推荐文件结构¶
其中:
updater.h:供 Qt/C++ 调用。updater_sparkle.mm:调用 Sparkle Objective-C API。
5.2 核心 Objective-C++ 调用¶
updater_sparkle.mm 必须使用 Objective-C++ 编译,并开启 ARC:
核心逻辑如下:
#import <Sparkle/Sparkle.h>
_updaterController =
[[SPUStandardUpdaterController alloc]
initWithStartingUpdater:YES
updaterDelegate:nil
userDriverDelegate: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 搜索目录:
常见运行时搜索路径:
或者:
可以使用以下命令检查最终产物:
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 复制到:
具体复制方式应确保保留 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¶
增加:
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 已经拥有:
通常不需要启用 Sparkle 的 Downloader XPC Service。
如果 App 没有网络访问权限,并希望使用 Sparkle Downloader Service,则需要进一步配置:
这部分必须根据程序现有 Entitlements 设计,不建议盲目复制。
7. CEF 多进程需要特别注意什么¶
CEF 通常包含多个进程:
Sparkle 安装更新时,需要主程序和相关子进程正确退出。
推荐做法¶
- 在 Qt 的退出流程中关闭所有 CEF Browser。
- 等待 Browser 生命周期结束。
- 在正确线程调用
CefShutdown()。 - 确保 Helper 进程不会在主程序退出后长期存活。
- 不要将 Sparkle 集成到每个 CEF Helper 中,只集成到主 App。
- 确认应用退出确认框不会永久拦截 Sparkle 的安装退出。
- 对正在进行的任务、未保存文档等状态做友好处理。
可能出现的问题¶
- Sparkle 一直等待客户端退出。
- Helper 进程仍占用 App Bundle 内文件。
- 更新已经下载,但无法安装。
- 安装成功后无法正常重新启动。
- 主程序退出时没有执行完整的 CEF 清理。
正式发布前必须测试:
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
└── 主程序
正式发布时需要:
- 使用 Developer ID Application 证书。
- 启用 Hardened Runtime。
- 正确签名所有嵌套 Framework、Helper 和可执行文件。
- 从内到外签名。
- 最后签名主 App。
- 提交 Apple Notarization。
- 验证 Gatekeeper 是否接受。
推荐检查命令:
不要简单依赖:
Sparkle 官方明确提醒,--deep 可能导致 Sparkle XPC Service 的 Entitlements 或签名不正确。
另外需要区分两种签名:
| 签名 | 用途 |
|---|---|
| Apple Developer ID 代码签名 | 证明 App 来自可信开发者,并供 Gatekeeper 验证 |
| Sparkle EdDSA 签名 | 证明下载的更新包确实由发布者签发且没有被篡改 |
两者不能互相替代。
9. 每次发布新版本的流程¶
首次接入完成以后,每次发布主要执行以下步骤。
假设从:
升级到:
第一步:修改版本¶
<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
例如:
如果更新说明文件和更新包同名,generate_appcast 可以自动将它关联为 Release Notes。
第四步:生成 Appcast¶
该工具会:
- 为更新包生成 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 托管
第六步:旧版本升级测试¶
必须使用真实旧版本测试:
10. ChatGPT.app 为什么没有 SUFeedURL¶
对本机以下文件进行了只读检查:
检查结果表明:
Info.plist中没有SUFeedURLInfo.plist中存在SUPublicEDKey- App 内包含
Sparkle.framework - App 内包含 Electron/Node 原生模块
Resources/native/sparkle.node sparkle.node链接了 Sparkle 2sparkle.node实现了feedURLStringForUpdater:- Electron 资源中包含生产环境 Appcast URL
观察到的生产更新源为:
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 配置:
优点:
- 配置简单
- 容易排查
- 符合 Sparkle 默认使用方式
- 不需要额外 Delegate 逻辑
需要动态更新源的客户端¶
可以实现 SPUUpdaterDelegate:
适合:
- 正式版和 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。
如果新版本出现严重问题,应当:
- 将代码回退到稳定实现。
- 发布一个更高的内部构建号。
- 让用户更新到这个“代码回退但版本号更高”的修复版本。
例如: