Skip to content

feat: Sparkle 2 auto-update + 检查更新菜单#3

Merged
appergb merged 4 commits into
mainfrom
feature/sparkle-auto-update
Apr 27, 2026
Merged

feat: Sparkle 2 auto-update + 检查更新菜单#3
appergb merged 4 commits into
mainfrom
feature/sparkle-auto-update

Conversation

@appergb
Copy link
Copy Markdown
Collaborator

@appergb appergb commented Apr 27, 2026

这个 PR 做了什么

接入 Sparkle 2(macOS 上 OSS 标配的自动更新框架),让老用户每次启动自动检查 GitHub 上的新版本,发现新版后静默下载、替换、重启——不需要再手动 xattr -dr

实现要点

应用层

文件 改动
Package.swift 加 Sparkle SPM 依赖(from: "2.9.0"),挂在 OpenLessApp target
Sources/OpenLessApp/Updater/UpdaterController.swift(新) 包一层 SPUStandardUpdaterController,启动时自动 start updater
AppDelegate 实例化 UpdaterController、保留引用、传给 ApplicationMenu.install(...)
ApplicationMenu OpenLess 菜单第一项加「检查更新…」,target 指向 updater

构建/发布

文件 改动
scripts/build-app.sh Sparkle.framework(含 Updater.app + XPC + Autoupdate)从 SPM artifacts 拷进 Contents/Frameworks/;Info.plist 注入 SUFeedURL / SUPublicEDKey / SUEnableAutomaticChecks / SUEnableInstallerLauncherService / SUScheduledCheckInterval=3600;显式逐个 sign Sparkle 的 helpers(--deep 在嵌套 XPC 上不可靠)
scripts/release.sh(新) 一条命令完成发版:./scripts/release.sh 1.0.02 → 改 build-app.sh 版本号 → 跑 build → ditto zip → sign_update 用 Keychain 私钥签 zip → 在 appcast.xml 顶部插入新 <item> → git commit + tag + push → gh release create + 传 zip
appcast.xml(新) 仓库根目录的空 RSS feed;items 由 release.sh 追加

密钥

  • 公钥(公开,已写进 build-app.sh + Info.plist):iT00+eUw/55obn1suEnWqI7za2pc8mHIFIdRbOWXW1Q=
  • 私钥:只在本机 Keychain。account = macOS 登录用户,name = https://sparkle-project.org

⚠️ Merge 前必须做一次性备份

私钥丢失 = 老用户永远收不到新版(appcast 签名验证不过)。Merge 之前请:

  1. 打开「钥匙串访问.app」
  2. 选「登录」keychain → 类别「密码」
  3. 找名称为「https://sparkle-project.org」的项
  4. 右键 → 「拷贝条目…」或导出为 .p12
  5. 存到 1Password / iCloud Keychain

只做一次。之后这台 Mac 是唯一能签更新的来源。

测试 plan

  • swift build 通过
  • RESET_TCC=0 ./scripts/build-app.sh 产出 5.9MB 的 .app(之前 3MB)
  • Sparkle.framework 嵌入完整:含 Sparkle 二进制 + Updater.app + XPCServices/{Installer,Downloader}.xpc + Autoupdate
  • Info.plist 包含 SUFeedURL=https://raw.githubusercontent.com/appergb/openless/main/appcast.xmlSUPublicEDKey=iT00+eUw/...
  • codesign --verify 通过
  • 启动 app,菜单栏左上角显示「OpenLess」(设置窗口打开时切到 .regular 才显示)
  • 点 OpenLess 菜单 → 「检查更新…」 → Sparkle 弹窗弹出(首次会显示「已是最新版」,因为 appcast.xml 还没有任何 <item>
  • Merge 后跑 ./scripts/release.sh 1.0.02 发一版,再装一次老的 1.0.01,让它启动后自检 → 看是否能拉到 1.0.02 → 静默升级

之后的发版流程

./scripts/release.sh 1.0.02 A1004
# 或者带 release notes
./scripts/release.sh 1.0.02 A1004 docs/release-notes/1.0.02.md

一行命令搞定:构建 → 签名 → 改 appcast → commit → push → 建 release → 传 zip。

Closes

(记 issue #1 的 hold-to-talk PR 是 #2,本 PR 独立。)

Summary by Sourcery

Integrate Sparkle-based automatic update checking into the macOS app and automate the release packaging pipeline with a signed appcast feed.

New Features:

  • Add Sparkle-powered automatic update channel that checks for and installs new versions in the background.
  • Expose a “检查更新…” menu item in the main application menu to manually trigger update checks.
  • Introduce an appcast.xml RSS feed as the source for published macOS app updates.

Enhancements:

  • Wire a dedicated UpdaterController into the app lifecycle to manage startup update checks and menu-driven updates.

Build:

  • Embed Sparkle.framework into the packaged .app bundle, configure Sparkle-related Info.plist keys, and extend codesigning to cover Sparkle helper components.
  • Add a release.sh script to build, sign, zip, and publish new versions while updating the appcast feed and GitHub releases.

Adds the standard pieces for Sparkle-based silent updates:

- Package.swift: add Sparkle 2 SPM dep, wired into OpenLessApp.
- Sources/OpenLessApp/Updater/UpdaterController.swift: thin wrapper
  around SPUStandardUpdaterController. Owned by AppDelegate so the
  updater starts on app launch.
- AppDelegate: instantiate UpdaterController before installing the
  main menu; pass it to ApplicationMenu so the menu item can target
  it.
- ApplicationMenu: add "检查更新…" as the first item in the OpenLess
  menu when an UpdaterController target is present.
- scripts/build-app.sh: copy Sparkle.framework (with Updater.app +
  XPC services + Autoupdate) from the SPM artifacts dir into
  Contents/Frameworks/, inject SUFeedURL / SUPublicEDKey /
  SUEnableAutomaticChecks / SUEnableInstallerLauncherService /
  SUScheduledCheckInterval into Info.plist, and ad-hoc sign the
  embedded helpers explicitly (deep sign alone is unreliable for
  Sparkle's nested XPC bundles).
- scripts/release.sh: one-shot release pipeline.
  ./scripts/release.sh <version> [<build>] [<notes>]
  Bumps version in build-app.sh, builds, zips, signs the zip with the
  Keychain-stored EdDSA private key (auto-downloads sign_update from
  the Sparkle 2.9.1 release on first run), prepends a new <item> to
  appcast.xml, commits + tags + pushes main, and creates the GitHub
  release with the zip uploaded.
- appcast.xml: empty initial feed file at repo root, served via the
  raw.githubusercontent.com URL referenced by SUFeedURL. Items are
  appended automatically by release.sh.

Public EdDSA key embedded in the build:
iT00+eUw/55obn1suEnWqI7za2pc8mHIFIdRbOWXW1Q=

Private key lives only in the local Keychain on the release machine
(account: macOS login user; service: https://sparkle-project.org).
First-time install still requires the user to remove the quarantine
xattr (no Apple Developer ID); subsequent updates flow through
Sparkle's installer helper and bypass quarantine.
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Apr 27, 2026

Reviewer's Guide

Integrates Sparkle 2-based automatic updates into the macOS app and adds a one-command release pipeline that builds, signs, publishes a new version, and updates the Sparkle appcast feed.

Sequence diagram for manual check-for-updates menu action

sequenceDiagram
    actor User
    participant NSApplication
    participant AppDelegate
    participant ApplicationMenu
    participant UpdaterController
    participant SPUStandardUpdaterController as SparkleUpdater
    participant SparkleServer as SparkleAppcastServer

    User->>NSApplication: Launch OpenLess
    NSApplication->>AppDelegate: applicationDidFinishLaunching
    AppDelegate->>UpdaterController: init()
    AppDelegate->>ApplicationMenu: install(updaterTarget)
    AppDelegate->>NSApplication: Run main event loop

    User->>ApplicationMenu: Select 检查更新…
    ApplicationMenu->>UpdaterController: checkForUpdates(sender)
    UpdaterController->>SparkleUpdater: checkForUpdates(sender)
    SparkleUpdater->>SparkleAppcastServer: GET appcast.xml
    SparkleAppcastServer-->>SparkleUpdater: appcast with latest item
    SparkleUpdater-->>User: Show built-in update UI
    SparkleUpdater->>SparkleAppcastServer: Download update zip
    SparkleUpdater-->>NSApplication: Install and relaunch app
Loading

Entity relationship diagram for Sparkle appcast feed structure

erDiagram
    CHANNEL ||--o{ ITEM : contains

    CHANNEL {
        string title
        string link
        string description
        string language
    }

    ITEM {
        string title
        datetime pubDate
        string sparkle_version
        string sparkle_shortVersionString
        string sparkle_minimumSystemVersion
        string description
        string enclosure_url
        int enclosure_length
        string enclosure_type
        string enclosure_sparkle_edSignature
    }
Loading

Class diagram for Sparkle-based updater integration

classDiagram
    class AppDelegate {
        -DictationCoordinator? coordinator
        -MenuBarController? menuBar
        -OnboardingWindowController? onboarding
        -UpdaterController? updaterController
        +applicationDidFinishLaunching(notification: Notification)
    }

    class ApplicationMenu {
        <<enum>>
        +install(updaterTarget: UpdaterController?)
    }

    class UpdaterController {
        +SPUStandardUpdaterController updater
        +init()
        +checkForUpdates(sender: Any?)
    }

    class SPUStandardUpdaterController {
        +init(startingUpdater: Bool, updaterDelegate: AnyObject?, userDriverDelegate: AnyObject?)
        +checkForUpdates(sender: Any?)
    }

    class DictationCoordinator {
        +menuBar: MenuBarController?
    }

    class MenuBarController {
        +init(coordinator: DictationCoordinator)
    }

    AppDelegate --> UpdaterController : holds
    AppDelegate ..> DictationCoordinator : creates
    AppDelegate ..> MenuBarController : creates
    AppDelegate ..> ApplicationMenu : calls install

    ApplicationMenu --> UpdaterController : updaterTarget

    UpdaterController --> SPUStandardUpdaterController : wraps

    DictationCoordinator --> MenuBarController : references
Loading

File-Level Changes

Change Details Files
Add Sparkle 2 as an SPM dependency and wire an updater controller into the app lifecycle and menu.
  • Declare Sparkle Git dependency and link the Sparkle product into the OpenLessApp target.
  • Introduce UpdaterController wrapper around SPUStandardUpdaterController that auto-starts Sparkle and exposes a checkForUpdates selector.
  • Instantiate UpdaterController in AppDelegate, keep a strong reference, and pass it into ApplicationMenu.install.
  • Extend ApplicationMenu.install to optionally add a "检查更新…" menu item targeting the updater controller before the standard items.
Package.swift
Sources/OpenLessApp/Updater/UpdaterController.swift
Sources/OpenLessApp/AppDelegate.swift
Sources/OpenLessApp/ApplicationMenu.swift
Embed Sparkle.framework into the app bundle, configure Sparkle via Info.plist, and codesign Sparkle helpers explicitly during build.
  • Define FRAMEWORKS_DIR and Sparkle feed/public key variables in build-app.sh and create the Frameworks directory when assembling the bundle.
  • Copy the Sparkle.framework slice from the SPM artifacts directory into Contents/Frameworks, failing the build if the framework is missing.
  • Inject Sparkle-related keys (SUFeedURL, SUPublicEDKey, SUEnableAutomaticChecks, SUEnableInstallerLauncherService, SUScheduledCheckInterval) into the generated Info.plist.
  • Change the code signing step to sign Sparkle XPC services, Updater.app, and Autoupdate individually before signing Sparkle.framework and finally the app bundle, avoiding reliance on --deep for nested XPCs.
scripts/build-app.sh
Introduce an automated release script that builds a specific version, signs the update with Sparkle tools, updates appcast.xml, tags, pushes, and creates a GitHub release.
  • Implement scripts/release.sh to bump APP_VERSION/BUILD_NUMBER in build-app.sh, run the build, zip the app, and compute the archive size.
  • Download and cache Sparkle’s sign_update tool from GitHub releases if not already present, then use it to generate an EdDSA signature from the Keychain-hosted private key.
  • Generate a new Sparkle appcast entry (including version, build number, minimum macOS version, signature, length, and optional release notes) and insert it near the top of appcast.xml via awk.
  • Automate git add/commit/tag/push for the updated files and invoke gh release create to publish the zipped app along with release notes.
scripts/release.sh
appcast.xml

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • In scripts/build-app.sh, the Sparkle helper codesign steps swallow errors with 2>/dev/null || true; consider at least logging failures and/or failing the build when a helper can’t be signed so you don’t silently ship an invalidly signed bundle.
  • The app menu title "检查更新…" is currently hard-coded in Chinese; if you intend to support non-Chinese setups, consider routing this through your existing localization mechanism or using the system language to select the label.
  • The scripts/release.sh flow directly in-place edits scripts/build-app.sh (APP_VERSION/BUILD_NUMBER) and rewrites appcast.xml; to make releases more robust against merge conflicts and manual edits, consider moving version metadata to a small dedicated config file that both scripts read and update, and making the appcast update logic more tolerant if the <description>OpenLess update feed</description> anchor line changes.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `scripts/build-app.sh`, the Sparkle helper codesign steps swallow errors with `2>/dev/null || true`; consider at least logging failures and/or failing the build when a helper can’t be signed so you don’t silently ship an invalidly signed bundle.
- The app menu title "检查更新…" is currently hard-coded in Chinese; if you intend to support non-Chinese setups, consider routing this through your existing localization mechanism or using the system language to select the label.
- The `scripts/release.sh` flow directly in-place edits `scripts/build-app.sh` (APP_VERSION/BUILD_NUMBER) and rewrites `appcast.xml`; to make releases more robust against merge conflicts and manual edits, consider moving version metadata to a small dedicated config file that both scripts read and update, and making the appcast update logic more tolerant if the `<description>OpenLess update feed</description>` anchor line changes.

## Individual Comments

### Comment 1
<location path="scripts/release.sh" line_range="109-116" />
<code_context>
+
+# 用 awk 在 <description>OpenLess update feed</description> 之后插入新 item
+TMP_APPCAST=$(/usr/bin/mktemp)
+/usr/bin/awk -v new_item="${NEW_ITEM}" '
+  /<description>OpenLess update feed<\/description>/ {
+    print
+    print ""
+    print new_item
+    next
+  }
+  { print }
+' "${APPCAST}" > "${TMP_APPCAST}"
+mv "${TMP_APPCAST}" "${APPCAST}"
</code_context>
<issue_to_address>
**issue (bug_risk):** Interpolating NEW_ITEM into awk -v breaks when the XML snippet contains double quotes and newlines.

Because NEW_ITEM includes XML with double quotes and newlines, expanding it in `-v new_item="${NEW_ITEM}"` causes awk to see unescaped `"..."` and line breaks in the command line, leading to syntax errors or truncated content. Please avoid passing the raw XML via `-v`; for example, insert via a placeholder in appcast.xml replaced with `sed`/`perl`, or feed the `<item>` block from stdin (here-doc) and let awk or another tool insert it. The key is to keep the XML out of shell-quoted awk arguments.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread scripts/release.sh
Comment on lines +109 to +116
/usr/bin/awk -v new_item="${NEW_ITEM}" '
/<description>OpenLess update feed<\/description>/ {
print
print ""
print new_item
next
}
{ print }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Interpolating NEW_ITEM into awk -v breaks when the XML snippet contains double quotes and newlines.

Because NEW_ITEM includes XML with double quotes and newlines, expanding it in -v new_item="${NEW_ITEM}" causes awk to see unescaped "..." and line breaks in the command line, leading to syntax errors or truncated content. Please avoid passing the raw XML via -v; for example, insert via a placeholder in appcast.xml replaced with sed/perl, or feed the <item> block from stdin (here-doc) and let awk or another tool insert it. The key is to keep the XML out of shell-quoted awk arguments.

baiqing added 3 commits April 27, 2026 18:53
Without this, the SPM-built executable links Sparkle's symbols but
ships with no rpath that points at the embedded framework, so dyld
fails at launch with:

  Library not loaded: @rpath/Sparkle.framework/Versions/B/Sparkle

…and the user sees the macOS Gatekeeper-styled "请与开发者联系,以确保
OpenLess 可以配合此 macOS 版本使用" error. Inject the rpath via
linkerSettings.unsafeFlags so the binary itself knows where to look.
@appergb appergb merged commit f38643f into main Apr 27, 2026
1 check passed
@appergb appergb deleted the feature/sparkle-auto-update branch April 30, 2026 03:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant