Skip to content

feat(updates): in-app auto-update via Sparkle#3

Merged
Gnonymous merged 1 commit into
mainfrom
feature/sparkle-auto-update
Jun 23, 2026
Merged

feat(updates): in-app auto-update via Sparkle#3
Gnonymous merged 1 commit into
mainfrom
feature/sparkle-auto-update

Conversation

@Gnonymous

Copy link
Copy Markdown
Owner

Summary

Replaces the manual "open GitHub Releases" UpdateChecker with a full Sparkle 2.x integration. After this lands, users no longer have to download a .dmg from the website and clear Gatekeeper for every version — they enable a toggle once, and future versions install silently with one confirmation.

  • Opt-in daily background check. New Settings toggle "自动检查更新", default off to preserve the prior "never automatic" privacy promise. When on, Sparkle pulls a small appcast.xml once a day from GitHub Releases.
  • EdDSA-signed updates. Every release zip is signed by CI with the SPARKLE_PRIVATE_KEY repo secret; the matching public key (SUPublicEDKey) is baked into Info.plist. Sparkle hard-checks signatures, so a tampered feed/zip is silently rejected.
  • No Apple Developer ID needed. Trust anchor is our EdDSA key, not Apple's notarization chain. First install still hits Gatekeeper once (ad-hoc signature limitation); every update after that bypasses it.
  • CLAUDE.md privacy boundary updated to reflect the new contract — opt-in automatic, still no telemetry / no auth / no local data uploaded.

Implementation notes

  • SwiftPM + Sparkle.framework: package.sh ditto's .build/release/Sparkle.framework into Contents/Frameworks/ and adds @executable_path/../Frameworks to the binary's rpath via install_name_tool (SwiftPM doesn't know it's targeting an .app bundle).
  • Codesign order: inner-first (XPCServices → Updater.app → Autoupdate → framework), then outer .app without --deep. --deep would rewrite Sparkle's pre-signed nested binaries and break its internal trust chain.
  • Init at launch, not lazily: UpdateManager.shared is touched in applicationDidFinishLaunching so Sparkle's scheduled-check timer starts at app launch, not lazily when the Settings sheet is opened.
  • CI feed signing: release.yml pulls Sparkle 2.9.3's generate_appcast tool (version aligned with Package.resolved), runs it with the SPARKLE_PRIVATE_KEY secret, and grep-asserts sparkle:edSignature in the output so an unsigned feed can't ship.

One-time maintainer setup (already done for this repo)

  1. Generate EdDSA key pair locally with Sparkle's bin/generate_keys
  2. Paste public key into Scripts/Info.plist's SUPublicEDKey (already done in this PR)
  3. Paste private key into GitHub repo secret SPARKLE_PRIVATE_KEY

Full procedure documented in CLAUDE.md's new "Sparkle key setup" bullet.

Test plan

  • swift build — succeeds
  • ./Scripts/package.sh — produces a valid ad-hoc-signed .app with Sparkle.framework embedded
  • codesign --verify --deep --strict dist/CodingBar.app — passes (nested Sparkle signatures preserved)
  • dist/CodingBar.app/Contents/MacOS/CodingBar --self-test — ALL PASS
  • GUI launch + dyld trace — Sparkle.framework/Versions/B/Sparkle loaded from inside the .app bundle, no crashes
  • First end-to-end release after merge: confirm CI publishes appcast.xml alongside .dmg/.zip, then install from the previous version and verify Sparkle's "立刻更新" flow ships v0.2.0 silently
  • Confirm SPARKLE_PRIVATE_KEY secret is set in repo Settings before the first tag push (CI fails loudly with ::error::SPARKLE_PRIVATE_KEY secret is missing if it isn't)

🤖 Generated with Claude Code

Replace the manual UpdateChecker (which only opened GitHub Releases) with
Sparkle 2.x: daily background checks (opt-in toggle in Settings, default off),
EdDSA signature verification against a public key in Info.plist, silent
install + restart through Sparkle's standard user driver. After the first
install, subsequent updates skip the Gatekeeper prompt entirely — no more
"download from website + right-click → Open" for every version.

Why opt-in: the prior Privacy boundary in CLAUDE.md committed to "never
automatic" outside the quota path. The toggle defaults off, so users who
never flip it keep the original guarantee. CLAUDE.md is updated to reflect
the new contract (opt-in automatic check, still no telemetry / no auth /
no local data uploaded).

Why ad-hoc + Sparkle works without an Apple Developer ID: Sparkle's trust
anchor is the EdDSA public key in Info.plist, not Apple's notarization chain.
First install still gets one Gatekeeper prompt (ad-hoc signature limitation);
every subsequent update is verified by Sparkle's own signature check.

Build/sign notes:
- SwiftPM stages Sparkle.framework under .build/release/ — package.sh ditto's
  it into Contents/Frameworks/ and adds @executable_path/../Frameworks to the
  binary's rpath (SwiftPM doesn't know it's targeting an .app bundle).
- Codesign order is inner-first WITHOUT --deep on the outer .app; --deep
  would rewrite Sparkle's pre-signed nested binaries (XPCServices /
  Updater.app / Autoupdate) and break its internal trust chain.
- UpdateManager.shared is touched at applicationDidFinishLaunching so the
  scheduled-check timer starts at launch, not lazily when Settings is opened.
- CI runs Sparkle's generate_appcast with the SPARKLE_PRIVATE_KEY secret;
  sparkle:edSignature is grep-asserted in the generated appcast.xml so an
  unsigned feed can't ship.

One-time maintainer setup is documented in CLAUDE.md's new "Sparkle key
setup" bullet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@Gnonymous Gnonymous merged commit a4197f5 into main Jun 23, 2026
1 check passed
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