Skip to content

App Updates Itself - #11

Merged
tranphucbol merged 5 commits into
mainfrom
feature/app-updates-itself
Sep 11, 2026
Merged

tranphucbol merged 5 commits into
mainfrom
feature/app-updates-itself

Conversation

@tranphucbol

@tranphucbol tranphucbol commented Sep 11, 2026 •

Copy link
Copy Markdown
Owner

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 run
xattr -dr com.apple.quarantine because the new copy is quarantined and an ad-hoc-signed app that
is 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/054 records that at length.

Context & Flow

App start → 10s after the window loads → check()
                │
                ├── [OLD] nothing. The user finds out from a tag, a README or a colleague,
                │         then performs the eight manual steps by hand
                │
                └── [NEW] updateEligibility(facts)
                            │
                            ├── unpackaged / wrong arch / translocated / on a volume /
                            │   parent not writable → report the reason, fetch nothing
                            │
                            └── eligible → GET update-manifest.json + .sig
                                        │
                                        ├── Ed25519 verify fails → "could not be verified"
                                        ├── same or older version → "up to date"
                                        └── newer → banner, user presses Download
                                                    │
                                                    ├── stream asset → SHA-256
                                                    │     └── mismatch → "could not be verified"
                                                    ├── ditto -x -k into a staging dir
                                                    │     └── refused → "could not be unpacked"
                                                    └── ready → user presses Restart and install
                                                                │
                                                                └── detached /bin/sh:
                                                                    wait for pid → mv target .old
                                                                    → mv staged target
                                                                    → executable? ├── no → roll back
                                                                                  └── yes → xattr -dr,
                                                                                      open, rm backup
                                                                                      and staging

2. Key Changes

Description

The engine is four modules under packages/desktop/src/main/update/. manifest.ts holds the
embedded public key and verifies the detached signature over the exact published bytes.
eligibility.ts is pure — it takes facts rather than reading them, so a translocated path is a
string in a test instead of a mounted disk image nobody can produce in CI. swap.ts generates the
shell script. updater.ts orchestrates 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. Only available and ready
raise 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.yml gains a ZIP artifact and a signing step that runs in a GitHub Environment. The DMG
stays 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/054 says 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 temporary
directory 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 no
    installed copy of preman. Every path is quoted for sh, and the window where nothing exists at
    the target path is two renames wide, closed by a rollback.
  • packages/desktop/src/main/update/manifest.ts — the trust boundary. Everything downstream trusts
    what it returns.
  • .github/workflows/release.yml — publishes the manifest that every installed copy will act on.

Recommended Review Order

  1. docs/decisions/054-the-app-signs-its-own-updates.md — why a custom updater exists at all
  2. packages/desktop/src/main/update/manifest.ts and eligibility.ts
  3. packages/desktop/src/main/update/swap.ts, then updater.ts
  4. packages/desktop/src/main/main.ts, preload/bridge.ts, renderer/model/update.ts
  5. .github/workflows/release.yml and .github/sign-manifest.mjs
  6. test/desktop.update.test.ts

3. 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.mjs invoked exactly as release.yml
invokes it. The installed copy was placed outside /Applications so 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/054 commits to. That ordering had one bad edge, fixed in b96281e — when the
    fetch 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.

    01-read-only-volume-guard.png

  • A signed manifest offers the update; a manifest signed by another key is rejected

    0.9.0 installed outside /Applications fetched 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.

    02-update-available-real-build.png

  • The payload downloads, matches the hash in the signed manifest, and ditto preserves a real Electron bundle's framework symlinks

    126MB transferred, hash matched, extracted to 322MB beside the target on the same volume. The
    staged bundle kept Electron Framework -> Versions/Current/Electron Framework and its three
    siblings, which is what unzip would have flattened, and its ad-hoc signature survived.

    03-update-ready.png

  • 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.app and 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-dark and compact. After the swap the bundle's cdhash is different, and
    0.9.1 came up rendering Dracula at compact density and reporting "Up to date" — the same
    screenshot answers both this and the case above.

    04-after-update-up-to-date.png

  • An archive ditto refuses is reported as unpackable, not as a failed fetch

    This 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_RESET
    during the same session still reports as a failed fetch, which is the distinction working.

    05-real-tls-unpackable.png

  • The asset downloads over real TLS, following GitHub's 302 to release-assets.githubusercontent.com

    The 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.yml produces. Every other
platform and architecture reports architecture and does nothing. Nothing under packages/core/
or packages/cli/ changes, so the CLI and the engine are unaffected.

The app now makes an outbound request to github.com roughly 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 the
signed 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.

@tranphucbol
tranphucbol force-pushed the feature/app-updates-itself branch from 4e33c44 to b96281e Compare September 11, 2026 19:36
@tranphucbol
tranphucbol merged commit a0914df into main Sep 11, 2026
3 checks passed
@tranphucbol
tranphucbol deleted the feature/app-updates-itself branch September 11, 2026 19:52
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