Repository navigation
App Updates Itself - #11
Merged
Merged
Conversation
tranphucbol
force-pushed
the
feature/app-updates-itself
branch
from
September 11, 2026 19:36
4e33c44 to
b96281e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
1. Purpose & Motivation
Description
Upgrading the desktop app took eight manual steps: read a tag notification, download a 128MB DMG,
mount it, drag the bundle over
/Applications, confirm the replace, unmount, then runxattr -dr com.apple.quarantinebecause the new copy is quarantined and an ad-hoc-signed app thatis quarantined reports as damaged. Nothing in the running app knew a release existed.
This adds an updater the app owns. It reads a signed manifest from the latest GitHub release,
offers the update, downloads the ZIP, and replaces its own bundle with a detached shell script.
Squirrel.Mac is not used, and could not be. It validates a downloaded bundle against the designated
requirement of the installed one, and two ad-hoc-signed builds never share one — so this is not
Squirrel left unconfigured but Squirrel ruled out.
docs/decisions/054records that at length.Context & Flow
2. Key Changes
Description
The engine is four modules under
packages/desktop/src/main/update/.manifest.tsholds theembedded public key and verifies the detached signature over the exact published bytes.
eligibility.tsis pure — it takes facts rather than reading them, so a translocated path is astring in a test instead of a mounted disk image nobody can produce in CI.
swap.tsgenerates theshell script.
updater.tsorchestrates and is the only part that touches the network.Both readers of an update state — the banner and the Settings row — take their sentences from
renderer/model/update.ts, so they cannot disagree about a phase. Onlyavailableandreadyraise a banner; a background check that could not reach GitHub is not worth a bar across someone's
window, and the Settings section says so for whoever looks.
release.ymlgains a ZIP artifact and a signing step that runs in a GitHub Environment. The DMGstays the first install; the ZIP is the update payload, because extracting an archive is one
failure surface and mounting a disk image unattended is several.
Technical Decisions
The signing key lives in a GitHub Environment and nowhere else. An environment can require a
reviewer, so editing a workflow is not by itself enough to sign something with a distribution root
key that has no Gatekeeper behind it and no revocation path. Custody is the whole mitigation, and
docs/decisions/054says so rather than implying the code handles it.Every eligibility guard fails closed. Apple publishes no supported API for detecting translocation,
so the check is a heuristic on the path — and a heuristic that guessed "eligible" would move a
bundle the app is not running.
The staging directory sits beside the target bundle rather than in
$TMPDIR, because a temporarydirectory can be on another volume, which turns the final rename into a 317MB copy. Same-volume
renames are the only reason the swap is atomic.
Critical Modules/Files
packages/desktop/src/main/update/swap.ts— the one piece that can leave a user with noinstalled copy of preman. Every path is quoted for
sh, and the window where nothing exists atthe target path is two renames wide, closed by a rollback.
packages/desktop/src/main/update/manifest.ts— the trust boundary. Everything downstream trustswhat it returns.
.github/workflows/release.yml— publishes the manifest that every installed copy will act on.Recommended Review Order
docs/decisions/054-the-app-signs-its-own-updates.md— why a custom updater exists at allpackages/desktop/src/main/update/manifest.tsandeligibility.tspackages/desktop/src/main/update/swap.ts, thenupdater.tspackages/desktop/src/main/main.ts,preload/bridge.ts,renderer/model/update.ts.github/workflows/release.ymland.github/sign-manifest.mjstest/desktop.update.test.ts3. Verification & Testing
Provide reproducible inputs or steps for each test case, followed by the result and supporting evidence.
Automated: 1983 tests pass, with lint, format and both typechecks clean. Beyond those, the flow was
rehearsed with genuine packaged artifacts — two real ad-hoc-signed builds, 0.9.0 and 0.9.1, a
126MB ZIP, and a manifest signed by
.github/sign-manifest.mjsinvoked exactly asrelease.ymlinvokes it. The installed copy was placed outside
/Applicationsso no real install was touched.A copy running from a mounted DMG refuses to update and names the reason
Ran the real 0.9.0 build straight off the mounted DMG. The pane names the reason and tells the
user what to do about it.
The guards are asked after the manifest is fetched, not before: a few hundred signed bytes are
cheap, and asking first would nag someone about where their app lives while there is nothing to
install anyway. Only the 128MB payload is gated on eligibility, which is what
docs/decisions/054commits to. That ordering had one bad edge, fixed inb96281e— when thefetch itself failed there was no newer version to weigh the refusal against, so an ineligible
copy was told "The update could not be fetched." An unpackaged build hitting a 404 blamed the
network instead of saying it was a development build. The certain local fact now wins.
A signed manifest offers the update; a manifest signed by another key is rejected
0.9.0 installed outside
/Applicationsfetched the manifest and its signature and offered 0.9.1.Re-signing the same manifest with an attacker key produced "This update could not be verified."
and logged
the update manifest was not signed by preman.The payload downloads, matches the hash in the signed manifest, and
dittopreserves a real Electron bundle's framework symlinks126MB transferred, hash matched, extracted to 322MB beside the target on the same volume. The
staged bundle kept
Electron Framework -> Versions/Current/Electron Frameworkand its threesiblings, which is what
unzipwould have flattened, and its ad-hoc signature survived.The swap replaces the bundle, leaves no backup or staging directory, and relaunches into the new version
Logged
handing over to the update swap script, quit, and came back as 0.9.1 five seconds later.The install directory holds
preman.appand nothing else: no.old, no.preman-update-<uuid>,no quarantine attribute.
Preferences written by the old bundle are read back by the new one after its identity changes
0.9.0 wrote
dracula-darkandcompact. After the swap the bundle's cdhash is different, and0.9.1 came up rendering Dracula at compact density and reporting "Up to date" — the same
screenshot answers both this and the case above.
An archive
dittorefuses is reported as unpackable, not as a failed fetchThis is the second commit. Pointing a real build at a real release asset that is a DMG rather
than a ZIP produced "The update could not be fetched." for bytes that had arrived and matched the
signed hash. Now it reads "This update could not be unpacked." A genuine
net::ERR_CONNECTION_RESETduring the same session still reports as a failed fetch, which is the distinction working.
The asset downloads over real TLS, following GitHub's 302 to
release-assets.githubusercontent.comThe local harness serves plaintext and cannot exercise this, so the asset URL was pointed at a
genuine release asset with its true SHA-256. 134MB arrived over TLS through GitHub's redirect and
matched the hash, which is how extraction was reached at all in the case above.
4. Impact and Scope
Desktop only, and macOS arm64 only — the one slice
electron-builder.ymlproduces. Every otherplatform and architecture reports
architectureand does nothing. Nothing underpackages/core/or
packages/cli/changes, so the CLI and the engine are unaffected.The app now makes an outbound request to
github.comroughly ten seconds after the window loads,and once a day after that. It can be turned off in Settings, and the preference is persisted.
Security: the app will replace its own bundle on the strength of an Ed25519 signature it verifies
against a key compiled into it. That key's private half is the root of this trust, held in a GitHub
Environment. An asset URL must begin with
https://, the payload must match the hash inside thesigned manifest, and a manifest signed by any other key is refused before its contents are read.
macOS may prompt for Local Network access again after an update, because the new build is a
different signature to it. The Settings pane says so before the restart rather than leaving the
prompt unexplained.