最后一块拼图 — 更新文件放在哪里

第 1 篇完成了 Developer ID 证书和公证的配置,第 2 篇完成了 Sparkle 签名密钥的准备。至此,我们已具备签名应用、公证应用,以及验证更新真实性的全部手段。

然而,第 2 篇中写入应用 Info.plistSUFeedURLhttps://updates.example.com/appcast.xml)所指向的位置,目前还什么都没有。本篇(最终篇)将托管更新 Feed,并完成构建配置,收尾整个一次性准备工作。

与第 1、2 篇相同,所有名称和域名(FocusTimerexample.comexample-dev 等)均为示例值,实际使用时请替换为你自己的信息。

为何要单独建立更新仓库

自动更新要正常运作,互联网上必须有以下两样内容:

  • appcast.xml — 更新 Feed,告知应用哪个版本是最新的以及从哪里获取
  • .dmg — 实际的应用安装文件

这里有一个重要限制:应用内的 Sparkle 通过简单的、无认证的 HTTPS GET 请求获取这些文件。也就是说,用户电脑上的应用必须能够不经任何登录等验证流程直接下载。

很多开发者将应用的主源码仓库设为私有 (private)。然而私有仓库的发布文件需要认证,Sparkle 无法获取。因此,常见的做法是分离仓库

  • 主仓库(如 FocusTimer)— 源代码,可设为私有。
  • 更新仓库(如 FocusTimer-updates)— 仅托管 appcast.xml必须为公开 (public)

本文将使用 GitHub 免费的静态托管服务 GitHub Pages 运营更新仓库。

第 1 步 — 创建公开更新仓库

在 GitHub 上创建新仓库。

  1. 点击 New repository
  2. 名称:FocusTimer-updates — 该名称稍后会用于 Feed 地址,请精确设置,包括大小写。
  3. 所有者 (Owner):你的账户或组织(示例:example-dev
  4. 可见性:Public — 必须为公开,Sparkle 需要无认证即可访问。
  5. 勾选 Add a README file(方便创建第一个提交)
  6. 点击 Create repository

第 2 步 — 启用 GitHub Pages

将刚创建的仓库作为静态网站发布。

  1. 进入仓库的 Settings → 左侧菜单选择 Pages
  2. Source:选择 Deploy from a branch
  3. Branch:选择 main / (root) → 点击 Save
  4. 等待 1~2 分钟,若 https://example-dev.github.io/FocusTimer-updates/ 可以访问,则表示成功。

至此,已有了一个可以存放更新文件的公开地址。但还有最后一步。

第 3 步 — 连接自定义域名

直接使用 GitHub Pages 的默认地址(example-dev.github.io/...)也可以正常工作。但如果将该地址硬编码到应用的 SUFeedURL 中,日后若需要将托管迁移到 GitHub Pages 以外的地方,就会遇到麻烦——因为所有已分发用户的应用仍指向旧地址。

解决方案是中间加一层自己控制的域名。将 SUFeedURL 设为 https://updates.example.com/appcast.xml 等自己的域名后,日后迁移托管时只需修改一行 DNS 配置,现有用户即可自动跟随到新位置。这个配置只需做一次,永久有效。

3-1. 添加 DNS 记录

在管理域名(example.com)的 DNS 服务商的配置界面中,添加以下记录:

字段
TypeCNAME
Nameupdates
Valueexample-dev.github.io
TTL3600(默认值)

这样,updates.example.com 子域名就会指向 GitHub Pages。

3-2. 在仓库中添加 CNAME 文件

在更新仓库(FocusTimer-updates)的根目录创建名为 CNAME 的文件,内容只写一行域名:

updates.example.com

提交并推送此文件。

3-3. 在 GitHub Pages 中注册域名

在仓库的 Settings → Pages → Custom domain 字段中输入 updates.example.com 并点击 Save。GitHub 会自动颁发 HTTPS 证书,证书颁发完成后勾选 Enforce HTTPS

3-4. 验证

DNS 传播和证书颁发通常需要约 10 分钟。稍等片刻后用以下命令验证:

curl -I https://updates.example.com/appcast.xml

若响应第一行显示 HTTP/2 200,则表示正常。(若尚未上传 appcast.xml,可能会返回 404,但域名和 HTTPS 连接本身可通过其他方式确认。关键是 https://updates.example.com 能够返回响应。)

补充说明:.dmg 安装文件通常上传到 GitHub Releases,而非 GitHub Pages。直接将大型二进制文件放入仓库会使仓库体积膨胀。Releases 的服务路径与 Pages 不同,因此与上述自定义域名配置无关,保持现状即可。

第 4 步 — 在本地保存更新仓库

发布时需要编辑并提交 appcast.xml,因此要在本地也克隆一份更新仓库。将其克隆到主项目文件夹内的合适位置(如 release/updates),构建和发布脚本就能在同一个地方操作两个仓库,非常方便。

git clone [email protected]:example-dev/FocusTimer-updates.git release/updates

将一个仓库嵌套在另一个仓库中时,请将 release/updates/ 添加到主仓库的 .gitignore 中,以避免相互干扰。

第 5 步 — ExportOptions.plist

接下来是构建侧的配置。Xcode 的 xcodebuild -exportArchive 命令在将归档导出为分发用 .app 时,会读取名为 ExportOptions.plist 的配置文件。直接分发(Developer ID 渠道)的内容如下:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>method</key>
    <string>developer-id</string>
    <key>signingStyle</key>
    <string>automatic</string>
    <key>teamID</key>
    <string>ABCDE12345</string>
</dict>
</plist>
  • methoddeveloper-id,表示"直接分发,而非 Mac App Store"。
  • signingStyleautomatic,让 Xcode 自动选用第 1 篇中申请的证书。
  • teamID — 第 1 篇中记下的 Team ID。

在项目中创建此文件一次(如 release/ExportOptions.plist),即可在所有后续发布中复用。

第 6 步 — 检查应用侧配置

最后整理一下应用项目本身必须包含的配置。首次为新应用集成 Sparkle 时,可将此列表作为检查清单使用。

Info.plist

这些是第 2 篇中添加的 Sparkle 键。Info.plist 必须包含以下键:

<key>SUFeedURL</key>
<string>https://updates.example.com/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>5vT3kQbA9mZ0wR1yX8cD2eF4gH6jK7lN0pS2uV5xW8c=</string>

请再次确认 SUFeedURL 指向第 3 步创建的自定义域名,且 SUPublicEDKey 与第 2 篇中生成的公钥一致。

权限 (Entitlements)

若应用使用 App Sandbox,Sparkle 需要额外的例外权限以便与内部服务通信来安装更新。将以下条目添加到 FocusTimer.entitlements 文件中:

<key>com.apple.security.app-sandbox</key><true/>
<key>com.apple.security.network.client</key><true/>
<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>

构建时,Xcode 会将 $(PRODUCT_BUNDLE_IDENTIFIER) 自动替换为实际的 Bundle Identifier(com.example.FocusTimer)。详细条目请参考 Sparkle 官方沙盒指南

对于直接分发的应用,沙盒并非必须(沙盒是 Mac App Store 的要求)。若应用不使用沙盒,则无需上述 mach-lookup 例外。但由于自动更新需要使用网络,network.client 权限和 Hardened Runtime 必须开启,以满足公证要求。

构建设置 (Build Settings)

在 Xcode 的构建设置中确认以下项目:

  • CODE_SIGN_ENTITLEMENTS — 上述权限文件的路径
  • ENABLE_HARDENED_RUNTIME = YES — 公证的必要条件
  • ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES — 允许用于检查更新的网络访问

系列小结 — 一次性准备工作完成

历经三篇的直接分发一次性准备工作全部完成。现在你已经准备好以下内容:

  • (第 1 篇) Developer ID Application 证书 + 公证用凭据
  • (第 2 篇) Sparkle EdDSA 签名密钥对 + 私钥备份
  • (第 3 篇) 连接到自定义域名的公开更新仓库 + ExportOptions.plist + 应用侧配置

以上是只需做一次的配置。这些工作不需要在每次发布新版本时重复进行。

从现在起,分发新版本的流程每次大致相同——构建归档 → 用 ExportOptions.plist 导出 → 用 Developer ID 证书签名 → 用 notarytool 公证 → 创建 .dmg → 用 Sparkle 密钥签名 → 更新 appcast.xml → 将 .dmg 上传到 GitHub Release。这一重复流程大多可以用一个脚本自动化,那是另一篇文章的主题。

其中**制作 .dmg**涉及背景图片和图标排列等设计要素,将在独立系列 macOS 应用分发用 DMG 设计 中详细介绍。

直接分发初次配置时看似繁琐,但核心在于**“一旦搭建好,便可持续复用”**。放弃 App Store 的部分便利,换来的是对整个分发流程的完全掌控。

参考资料