Skip to content

Let the app install its own updates - #6

Merged
migsilva89 merged 4 commits into
mainfrom
sparkle-auto-update
Aug 28, 2026
Merged

migsilva89 merged 4 commits into
mainfrom
sparkle-auto-update

Conversation

@migsilva89

Copy link
Copy Markdown
Owner

0.3.2 could tell you a new version existed. It could not do anything about it — you still had to open the page, download a disk image and drag the app over the old one. This replaces that with Sparkle, set up the same way Imark is.

The app now downloads the update, checks its signature and installs it. Automatic checks are on and run once a day; automatic installs are off, so nothing is replaced without you saying yes.

How it is wired. Sparkle 2.9.6, pinned exactly. The framework is embedded in Contents/Frameworks and signed inside-out with the hardened runtime, including Autoupdate and Updater.app. The feed is appcast.xml, published as a release asset and signed with an EdDSA key whose private half lives only in the login keychain; the app carries the public half and refuses an unsigned feed. Scripts/appcast.sh writes and signs the feed, and Scripts/release.sh refuses to publish without one.

The feed points at a second, identically-built -update.dmg, because GitHub counts downloads per asset and that is the only way to tell an update apart from a first install.

What goes away. UpdateCheck.swift, UpdateNotice.swift and their tests — they only described asking the GitHub API and comparing version strings, which is now Sparkle's job. Settings › Updates stays, driven by the updater: the running version, a toggle for automatic checks, and a button to check now. 311 → 299 tests, 0 failures.

Not verifiable before this ships. That an update installs end to end needs two notarised releases: install this one, publish the next, watch it replace itself. Everything up to that point is verified — signature, feed, guards.

Loadout 0.3.2 could tell you a new version existed and then send you to a
download page. That is the whole of what it did: every update ended in
somebody dragging an app over another one in Applications, and plenty of
people simply never did it.

Sparkle replaces that. It finds the release, checks it was signed with the
key this copy was built with, verifies it before unpacking, installs it and
reopens. Pinned to 2.9.6 rather than a range, because this is the one
dependency that installs executable code on somebody's disk.

The old checker is deleted rather than left beside it. Two mechanisms mean
two schedules, two preferences and two answers to "am I current?" — and the
first time they disagree, one of the places you looked was lying. Its tests
go with it: they described a GitHub API call and a version comparison that
Sparkle now does itself.

The preference survives the swap. Somebody who turned checks off in 0.3.2
asked not to be checked, so that answer is moved into Sparkle's own
preference before the updater starts, and the dead keys are cleared.

Settings › Updates stays, and now reads Sparkle instead of keeping its own
copy of the same facts.
The framework is embedded in Contents/Frameworks with ditto, because a
framework is a tree of symlinks and cp -R flattens it into something that
looks right and will not load. Sparkle's XPC services are dropped: they only
exist to carry the installer across a sandbox boundary, and Loadout is not
sandboxed, so keeping them ships two executables that can never run.

Signing is inside out and never with --deep. --deep is deprecated and hands
the outer bundle's options to nested code, which is exactly wrong here:
Autoupdate and Updater.app are separate programs that each need their own
signature and the hardened runtime. Signing the app before the framework
would be pointless anyway — changing anything inside a bundle breaks the
signature wrapped around it.

None of this fails loudly. A wrongly signed Sparkle builds, notarises and
installs happily, and only shows up months later when somebody accepts an
update and nothing happens. Hence Scripts/test-update.sh, which asks the
assembled bundle the questions swift test cannot: is the framework there, is
it signed, can the app find it, is the feed address right, is the public key
in place. CI runs it after the build.
An update feed is only worth anything signed: the app is built with
SURequireSignedFeed, so an unsigned one is treated as no feed at all and
every installed copy quietly stops finding updates. Scripts/appcast.sh signs
the image and the feed with the private key in this machine's login keychain
— never in this repository — and refuses to run if that key and the public
half baked into the app have drifted apart, which would make every installed
copy reject the release as forged.

The feed points at a second, byte-identical copy of the image published as
Loadout-<version>-update.dmg. GitHub counts downloads per asset and nothing
else, so this is the only way to tell an update from a first install. Both
have to go up or the updater follows the feed to a 404.

release.sh writes the feed after stapling rather than before — stapling
changes the bytes the signature covers — clears the previous release's feed
and update copy before building, and only prints the publish command when a
signed feed is actually sitting in dist/. Otherwise it says so, because a
release published without one breaks the updater for everybody who already
has the app.
The changelog entry is about the thing somebody notices: no more dragging an
app into Applications. The README says what a release now consists of, since
it is three files and leaving one out is silent. The spec gains the two
criteria that were missing — the bundle checks, and the one nobody can
automate: that an installed copy really does install what it is offered.
@migsilva89
migsilva89 merged commit b44538f into main Aug 28, 2026
2 checks passed
@migsilva89
migsilva89 deleted the sparkle-auto-update branch August 28, 2026 17:59
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