Skip to content

✨ feat: Sparkle auto-update system#25

Merged
vaayne merged 31 commits intomainfrom
feature/sparkle-update
Mar 28, 2026
Merged

✨ feat: Sparkle auto-update system#25
vaayne merged 31 commits intomainfrom
feature/sparkle-update

Conversation

@vaayne
Copy link
Copy Markdown
Owner

@vaayne vaayne commented Mar 27, 2026

Summary

  • Integrates Sparkle 2.9.0 for in-app auto-update, modeled after Ghostty's implementation
  • Adds a titlebar pill badge (top-right) that shows update state with progress ring, animated icons, and a detail popover with Install/Skip/Later actions
  • Adds "Check for Updates…" to the app menu and command palette
  • CI generates appcast.xml with EdDSA signatures and publishes to gh-pages branch
  • 42 localized strings (en + zh-Hans)
  • 9 new files in Sources/Mori/Update/, 3 new scripts, updated bundle/sign scripts

New Files

File Purpose
Sources/Mori/Update/UpdateState.swift State enum (idle, checking, available, downloading, etc.)
Sources/Mori/Update/UpdateViewModel.swift ObservableObject driving all UI
Sources/Mori/Update/UpdateDriver.swift SPUUserDriver bridging Sparkle → state
Sources/Mori/Update/UpdateDelegate.swift SPUUpdaterDelegate (feed URL, install hooks)
Sources/Mori/Update/UpdateController.swift SPUUpdater wrapper with force-install chain
Sources/Mori/Update/UpdateBadge.swift Progress ring + animated icons
Sources/Mori/Update/UpdatePill.swift Pill-shaped titlebar button
Sources/Mori/Update/UpdatePopoverView.swift Detail popover with actions
Sources/Mori/Update/UpdateAccessoryView.swift NSTitlebarAccessoryViewController
scripts/generate-appcast.sh CI appcast generation with EdDSA signing
scripts/setup-gh-pages.sh One-time gh-pages branch setup
docs/auto-update.md Full documentation

Pre-release checklist

  • Generate EdDSA keypair (generate_keys)
  • Replace PLACEHOLDER_EDDSA_PUBLIC_KEY in scripts/bundle.sh
  • Add SPARKLE_PRIVATE_KEY GitHub secret
  • Run scripts/setup-gh-pages.sh and push gh-pages branch
  • Enable GitHub Pages in repo settings

Test plan

  • Build succeeds with swift build --product Mori
  • Run app twice → Sparkle permission prompt appears on second launch
  • "Check for Updates…" menu item works
  • Command palette "Check for Updates" action works
  • Titlebar pill appears during update check, auto-dismisses "No Updates" after 5s
  • Popover shows correct state-specific views
  • scripts/generate-appcast.sh produces valid appcast.xml (after key setup)

vaayne added 28 commits March 27, 2026 20:15
Add Sparkle framework (from: "2.7.0") to Package.swift and wire it
into the Mori executable target. This enables the Sparkle auto-update
infrastructure for future update checking and in-app updates.
Create Sources/Mori/Update/ with stub files for the Sparkle update
system: UpdateState, UpdateViewModel, UpdateDriver, UpdateDelegate,
UpdateController, UpdateBadge, UpdatePill, UpdatePopoverView, and
UpdateAccessoryView. These will be implemented in Phase 2 and 3.
…e script

- Add SUPublicEDKey (placeholder) and SUFeedURL to Info.plist generation
- Embed Sparkle.framework from SPM artifacts into Contents/Frameworks/
- Copy Sparkle XPC services to Contents/XPCServices/
- Add XPC service signing to sign.sh (inside-out before main app)
Document the Sparkle auto-update system: EdDSA key generation and
export, public/private key storage locations, update flow overview,
appcast feed hosting, and key regeneration procedure.
Add state enum with associated data structs for permission request,
checking, update available, downloading, extracting, installing,
not found, and error states. Includes cancel/confirm helpers,
manual Equatable conformance, and Mori-specific ReleaseNotes
linking to GitHub releases.
ObservableObject with @published state for Combine sink support.
Computed properties for text, maxWidthText, iconName, description,
badge, iconColor, backgroundColor, and foregroundColor matching
each update lifecycle state.
SPUUserDriver implementation that translates all Sparkle lifecycle
callbacks into UpdateState transitions on the view model. Falls back
to SPUStandardUserDriver when no visible window is available for
unobtrusive badge rendering.
SPUUpdaterDelegate extension on UpdateDriver providing the appcast
feed URL (GitHub Pages), handling install-on-quit state transition,
and invalidating restorable state before relaunch.
…orce-install

@mainactor controller owning SPUUpdater with startUpdater(),
checkForUpdates(), and installUpdate() methods. Force-install uses
Combine $state.sink to auto-confirm each update step. Wires retry
callback to UpdateDriver for error recovery.
…rk itself

Inside-out signing requires deepest nested bundles to be signed first.
Sparkle.framework contains XPC services that must be signed before the
framework wrapping them.
SwiftUI badge view showing state-appropriate visual indicators:
progress ring for downloading/extracting, rotating animation for
checking state, and static SF Symbol icons for other states.
Pill-shaped button wrapping UpdateBadge + text label with state-dependent
colors. Shows popover on click, auto-dismisses .notFound after 5 seconds.
Fixed text width prevents resizing during progress updates.
Full popover content for all update states: permission request, checking
with spinner, update available with version/size/date and action buttons,
downloading/extracting with progress bars, installing with restart prompt,
not found confirmation, and error with retry option.
NSTitlebarAccessoryViewController subclass hosting UpdatePill via
NSHostingView, positioned trailing in the titlebar for unobtrusive
update badge display.
…sory

Create UpdateController after window is shown (so hasUnobtrusiveTarget has
a visible window), start the updater, and add the UpdatePill as a trailing
titlebar accessory via MainWindowController.addUpdateAccessory().
Add "Check for Updates" as a searchable action in the command palette
with the arrow.triangle.2.circlepath icon.
Address Phase 2 reviewer feedback: hasUnobtrusiveTarget now checks for
MainWindowController specifically instead of any window with a content
view controller. The willClose notification handler also filters to only
react when Mori's main window is closing.
Downloads Sparkle 2.9.0 tools, signs release archives with EdDSA,
and generates appcast.xml for the auto-update feed. Supports merging
with existing appcast.xml to preserve previous release entries.
Adds two new steps to the release pipeline: generate appcast.xml with
EdDSA signatures using Sparkle tools, then publish it to the gh-pages
branch for the auto-update feed. Uses SPARKLE_PRIVATE_KEY secret.
Creates scripts/setup-gh-pages.sh that initializes an orphan gh-pages
branch with a redirect index.html and empty appcast.xml placeholder.
Run once before the first release to set up the update feed endpoint.
…update docs

Documents the full release pipeline flow, appcast generation process,
SPARKLE_PRIVATE_KEY secret setup, gh-pages branch initialization,
manual appcast generation, and troubleshooting tips.
Instead of a separate gh-pages branch, the appcast is now committed to
vaayne/homebrew-tap alongside the cask update. This reuses existing CI
infra and supports multiple products in the tap repo with namespaced
filenames.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 794aeeb12a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/bundle.sh Outdated
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>SUPublicEDKey</key>
<string>PLACEHOLDER_EDDSA_PUBLIC_KEY</string>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Fail builds when SUPublicEDKey is still placeholder

The bundle script always writes PLACEHOLDER_EDDSA_PUBLIC_KEY into Info.plist, while the release workflow signs appcast.xml with the real SPARKLE_PRIVATE_KEY. If this placeholder is not manually edited before a tag, every shipped app will reject updates because the embedded public key cannot verify the feed signatures. This should be injected from CI (or hard-fail when still placeholder) to avoid silently publishing non-updatable builds.

Useful? React with 👍 / 👎.

HStack {
Spacer()
Button(String.localized("OK")) {
notFound.acknowledgement()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Prevent double-calling not-found acknowledgement

In the “No Updates Found” popover path, pressing OK calls notFound.acknowledgement() but does not transition state away from .notFound; the auto-dismiss task in UpdatePill still fires after 5 seconds and calls the same acknowledgement closure again. Sparkle acknowledgement callbacks are intended as one-shot completions, so this can trigger duplicate completion behavior when the popover stays open through a no-update check.

Useful? React with 👍 / 👎.

items.append(.action(
id: "action.check-for-updates",
title: .localized("Check for Updates"),
subtitle: .localized("Check for available Mori updates")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Localize the new update command subtitle

The new command palette subtitle uses .localized("Check for available Mori updates"), but that key was not added to either en.lproj or zh-Hans.lproj. Per the repo guideline in /workspace/mori/AGENTS.md (“All new user-facing strings … add entries to both … Localizable.strings”), this introduces a user-visible i18n regression where the raw English key/fallback appears in non-English locales.

Useful? React with 👍 / 👎.

…n string

- NotFoundView OK button now sets state to .idle before calling
  acknowledgement(), preventing the auto-dismiss task from calling
  it again after 5s
- Added missing "Check for available Mori updates" to en + zh-Hans
  Localizable.strings
vaayne added 2 commits March 28, 2026 09:52
- Prevent double-reply via callOnce wrapper on Sparkle reply closures
- Add @mainactor to UpdateDriver for thread-safety enforcement
- Replace DispatchQueue.main.asyncAfter with Task.sleep for consistency
- Defer installCancellable cleanup to avoid cancelling mid-callback
- Remove duplicate feed URL override (rely on SUFeedURL in Info.plist)
- Simplify ReleaseNotes single-case enum to computed URL? property
- Mark UpdateController, UpdateDriver, UpdateViewModel as final
- Add clarifying comment on Installing.isAutoUpdate dual code paths
Extract normalizedProgress on Downloading/Extracting to eliminate duplicated
clamping logic across 3 files. Throttle download progress to ~100 UI updates
instead of ~1,900. Cache hasUnobtrusiveTarget via window lifecycle notifications.
Centralize NotFound dismissal, fix NotFoundView leaf-view pattern, remove dead
guard in installUpdate sink, cache textWidth measurement, extract GitHub URL
constant.
@vaayne vaayne merged commit 3ead3f9 into main Mar 28, 2026
1 check passed
@vaayne vaayne deleted the feature/sparkle-update branch March 28, 2026 02:12
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