Status: draft Effort: M
Supersedes the Phase C “deferred to M3” note in SPEC-011. SPEC-011 Phase A (install-aware banner) already shipped; this SPEC turns the banner’s “Download” button into a real in-app update for DMG users while keeping the Homebrew path untouched.
Existing users have to re-download the DMG (or run brew upgrade --cask
openquack) every release. Downloading a fresh DMG is a 30-second task
most users won’t do; non-cask alpha users miss every fix between the
one they installed and the next time they happen to revisit the site.
The popover already shows a “v… is here” banner via UpdateChecker +
UpgradeAction, but for DMG users tapping it still means “open browser
→ mount DMG → drag.” This SPEC closes that gap: Sparkle for DMG users,
brew untouched.
installMethod == .homebrew; the AppleScript-driven brew upgrade
--cask openquack flow is unchanged.BinaryDelta adds release-pipeline weight.Add Sparkle 2.x to Package.swift, pinned to a tagged version
(not a branch): .package(url: "https://github.com/sparkle-project/Sparkle.git", from: "2.6.0").
Link into OpenQuackApp only — OpenQuackKit stays UI-free.
Use the modern API: SPUStandardUpdaterController / SPUUpdater
(the older SUUpdater is deprecated). Instantiate
SPUStandardUpdaterController(startingUpdater: true, …) once at app
launch; OpenQuackApp holds it for the app’s lifetime so scheduled
checks run.
OpenQuackApp has no Info.plist on disk — SwiftPM generates one.
PR-A sorts the plist mechanics; required keys:
| Key | Value | Why |
|---|---|---|
SUFeedURL |
https://larryxiao.github.io/openquack/appcast.xml |
Stable channel; runtime override switches to alpha when toggled. |
SUEnableAutomaticChecks |
YES |
First-launch default. Runtime toggle binds to SPUUpdater.automaticallyChecksForUpdates (Sparkle persists it). |
SUScheduledCheckInterval |
86400 |
Daily; matches the existing UpdateChecker cadence. |
SUPublicEDKey |
base64 EdDSA pubkey | Sparkle 2.x verifies every update against this. DSA was dropped in Sparkle 2.x. |
SUAutomaticallyUpdate |
NO |
See non-goals: always confirm. |
Run Sparkle’s bundled generate_keys once. Public half → Info.plist
as SUPublicEDKey; private half lives in the maintainer’s Keychain
(where generate_keys puts it) and as a GH Actions secret. Never
commit either form. Each release runs sign_update <dmg>; the EdDSA
signature goes into <enclosure sparkle:edSignature="…">. PR-C
codifies this.
One <item> per release. Spec at
https://sparkle-project.org/documentation/. Minimal example:
<item>
<title>v2.0.0-alpha.11</title>
<sparkle:version>2.0.0.11</sparkle:version>
<sparkle:shortVersionString>2.0.0-alpha.11</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
<enclosure url="…/OpenQuack-2.0.0-alpha.11.dmg"
length="9123456" type="application/octet-stream"
sparkle:edSignature="…base64…" />
</item>
sparkle:version is the integer-dotted build number Sparkle uses for
ordering; sparkle:shortVersionString carries the human tag; length
is the DMG byte count.
Three appcasts under docs/: appcast.xml (stable, default),
appcast-beta.xml (reserved for the future stable/beta split, not
user-pickable yet), appcast-alpha.xml (prerelease toggle ON).
Channel selection is a pure function, testable like SPEC-023’s
reconcile:
enum UpdateChannel { case stable, beta, alpha }
func chooseAppcastURL(channel: UpdateChannel) -> URL
Maps .stable → appcast.xml, .beta → appcast-beta.xml, .alpha →
appcast-alpha.xml. The UI exposes only a binary toggle (.stable ↔
.alpha); the .beta case exists so the release script (PR-C) can
emit appcast-beta.xml against the same table later.
At launch, after SPUStandardUpdaterController starts, set
updater.feedURL = chooseAppcastURL(channel: …). Flipping the toggle
re-sets feedURL and triggers an immediate checkForUpdates(_:).
Hard rule: two installers must never fight over the same .app
bundle. Branch on the existing state.installMethod
(InstallMethodDetector, SPEC-011 Phase A).
installMethod |
Sparkle | Banner button |
|---|---|---|
.manual |
Registered, automaticallyChecksForUpdates = true, scheduled daily |
updater.checkForUpdates(nil) — Sparkle’s window downloads, verifies, relaunches |
.homebrew |
Registered, automaticallyChecksForUpdates = false, no dialogs |
Unchanged — UpgradeAction.run AppleScripts brew upgrade --cask openquack |
UpgradeAction.run already switches on InstallMethod. Extend the
.manual branch from “open DMG URL” to “trigger Sparkle”; the
.homebrew branch is untouched. Sparkle stays registered for brew
users so the channel toggle keeps working if they switch install
methods, but it polls nothing and shows nothing on its own.
UpdateChecker (GitHub Releases polling) keeps running for both
install methods — it populates the in-popover banner, which is the
primary CTA. On .manual, Sparkle’s scheduled check may also fire its
own dialog independently; both paths lead to the same signed install,
so we don’t suppress one in favour of the other.
appcast.xml, appcast-beta.xml, appcast-alpha.xml live under
docs/ and are served via GitHub Pages (configured in repo settings
to serve /docs; there is no _config.yml).sign_update, prepend the <item>, commit.Cache-Control: max-age=600 —
propagation takes ~10 minutes; PR-C must verify the deployed
appcast.xml is reachable before announcing the cutover.In Sources/OpenQuackApp/SettingsView.swift’s GeneralPane, add an
“Updates” section after “Startup” (SPEC-023):
SPUUpdater.automaticallyChecksForUpdates (Sparkle persists it
itself). The toggle reads/writes through Sparkle; an @AppStorage
mirror keeps the SwiftUI binding clean but isn’t authoritative.@AppStorage("receivePrereleases"). Flipping it recomputes
chooseAppcastURL, re-points updater.feedURL, and triggers an
immediate check.updater.checkForUpdates(nil).Prerelease first-launch default: ON if OpenQuackKit.version
contains -alpha or -beta (respect installed channel); OFF
otherwise. One-shot — only applied if the key has never been written.
Sparkle ships its own download/install/relaunch window — use it vanilla, no custom install UI.
chooseAppcastURL(channel:) through all three cases;
assert each returns the right URL. Pure function, no Sparkle mocking
(mirrors SPEC-023’s reconcile unit test).SUFeedURL at a localhost
appcast.xml with a newer sparkle:version. Confirm Sparkle’s
dialog appears, download proceeds, signature verifies, app
relaunches into the new build.state.installMethod == .homebrew, confirm
(a) no Sparkle scheduled check fires, (b) the popover banner still
surfaces via UpdateChecker, (c) tapping “Upgrade” still runs
brew upgrade --cask openquack in Terminal exactly as today.updater.feedURL re-points and the next update offered is
from appcast-alpha.xml.swift build && swift test green under the Xcode toolchain
(DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer).UpdateChecker to a Sparkle-driven
indicator — the banner stays as-is; Sparkle augments the install path.| PR | Title | SPEC cite | Effort |
|---|---|---|---|
| this | docs(SPEC-026): Sparkle auto-update |
SPEC-026 | XS |
| PR-A | feat(updates): Sparkle dependency, SPUStandardUpdaterController, Info.plist keys, appcast template |
SPEC-026 | M |
| PR-B | feat(settings): "Updates" pane — auto-check + prerelease toggle + Check now |
SPEC-026 | S |
| PR-C | chore(release): sign DMG with sign_update, prepend appcast item, push docs/appcast.xml |
SPEC-026 | S |
PR-A is feature-flag-safe: with no <item> newer than the running
build, nothing changes user-visibly. PR-B adds the toggles. PR-C makes
the pipeline real; until then, appcast items can be hand-crafted for
end-to-end tests.
-alpha and -beta binaries to the alpha channel
because that’s the only prerelease feed we’ll publish initially.
Once appcast-beta.xml is real, beta-installed users should default
to .beta, not .alpha. Revisit at that cutover.SUAutomaticallyUpdate for accessory apps. Sparkle docs
recommend YES for utilities so updates apply on relaunch without
prompting. The SPEC’s stance is NO (trust); flagged for revisit
if users complain about the prompt.appcast-nightly.xml. Overkill at current cadence; would also
need a nightly build job. Not planned.