feat: Sparkle 2 auto-update + 检查更新菜单#3
Conversation
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.
Reviewer's GuideIntegrates 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 actionsequenceDiagram
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
Entity relationship diagram for Sparkle appcast feed structureerDiagram
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
}
Class diagram for Sparkle-based updater integrationclassDiagram
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
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- In
scripts/build-app.sh, the Sparkle helper codesign steps swallow errors with2>/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.shflow directly in-place editsscripts/build-app.sh(APP_VERSION/BUILD_NUMBER) and rewritesappcast.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>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| /usr/bin/awk -v new_item="${NEW_ITEM}" ' | ||
| /<description>OpenLess update feed<\/description>/ { | ||
| print "" | ||
| print new_item | ||
| next | ||
| } | ||
| { print } |
There was a problem hiding this comment.
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.
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.
这个 PR 做了什么
接入 Sparkle 2(macOS 上 OSS 标配的自动更新框架),让老用户每次启动自动检查 GitHub 上的新版本,发现新版后静默下载、替换、重启——不需要再手动
xattr -dr。实现要点
应用层
Package.swiftfrom: "2.9.0"),挂在 OpenLessApp targetSources/OpenLessApp/Updater/UpdaterController.swift(新)SPUStandardUpdaterController,启动时自动 start updaterAppDelegateUpdaterController、保留引用、传给ApplicationMenu.install(...)ApplicationMenu构建/发布
scripts/build-app.shSparkle.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+ 传 zipappcast.xml(新)密钥
iT00+eUw/55obn1suEnWqI7za2pc8mHIFIdRbOWXW1Q=https://sparkle-project.org。私钥丢失 = 老用户永远收不到新版(appcast 签名验证不过)。Merge 之前请:
https://sparkle-project.org」的项.p12只做一次。之后这台 Mac 是唯一能签更新的来源。
测试 plan
swift build通过RESET_TCC=0 ./scripts/build-app.sh产出 5.9MB 的 .app(之前 3MB)Sparkle.framework嵌入完整:含Sparkle二进制 +Updater.app+XPCServices/{Installer,Downloader}.xpc+AutoupdateInfo.plist包含SUFeedURL=https://raw.githubusercontent.com/appergb/openless/main/appcast.xml和SUPublicEDKey=iT00+eUw/...codesign --verify通过<item>)./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:
Enhancements:
Build: