Skip to content

Detect new releases and update in-app on macOS, Linux and Windows - #31

Merged
marcellosso merged 7 commits into
mainfrom
feat/auto-update
Oct 29, 2026
Merged

marcellosso merged 7 commits into
mainfrom
feat/auto-update

Conversation

@marcellosso

@marcellosso marcellosso commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

Closes #29.

localhost now looks for new versions on GitHub Releases, offers them in a notice, and installs them in place. Before this, users had to download each release by hand.

What ships per platform

Updater In-place install Feed
macOS (native SwiftUI app) Sparkle 2.10.1 (Swift package) with our own user driver, so Sparkle's windows never show yes: the .app from the DMG appcast.xml, signed (SURequireSignedFeed)
Windows (Tauri, NSIS) tauri-plugin-updater 2.14 yes: the installer runs in passive mode and relaunches the app latest.json
Linux AppImage (Tauri) tauri-plugin-updater yes: the AppImage is replaced, then the app relaunches latest.json
Linux .deb / .rpm tauri-plugin-updater, check only no: the notice's Download opens the release, because the package manager owns the install latest.json

The UX is the same on every platform:

  • localhost checks 10 s after launch and then every 6 hours (Sparkle uses SUScheduledCheckInterval 21600 plus a check at launch).
  • A notice at the foot of the sidebar says "localhost X.Y.Z is available", with Release Notes, Later and Update Now.
  • The download runs in the background with a progress bar. Then the notice offers Restart to Update. If the download fails, the notice says why and offers Try Again.
  • Check for Updates… is in the macOS app menu, in Settings → General ("Check Now") and in the command palette on both apps. If there is no update, it says "localhost is up to date". If the check fails, it says why.
  • Settings → General → "Check for updates automatically" turns the checks off. It is stored in the desktop app's settings.json (automaticUpdateChecks, default on) and in Sparkle's own default on macOS.
  • Builds without the updater public key never check. That covers every local build, so development builds don't nag.

Both feeds come from releases/latest/download/…, so drafts and pre-releases are never offered. An update reaches users when Marcel clicks Publish. In CI, the feed URLs are taken from github.repository, so after the repository move (#40) new builds point to the new repository.

Runs and the shared core: a decision for you to confirm

What I implemented: an update stops every Service first, the same way Quit does, and never does it silently. Clicking Restart to Update while anything is active opens a confirmation, "Stop N services and update?", which lists the active Services by Project. That includes Services an agent started through localhost mcp. The buttons are Stop and Update and Cancel. After confirming, the app:

  1. calls stop_every_run and waits for every process tree to end;
  2. retires the shared core with a new protocol request, Retire, so the core exits as soon as its last client disconnects instead of after the 10 s idle timeout;
  3. installs and relaunches the app. The relaunched app starts a core of its own version.

Why I didn't keep the core and its Runs alive across the update:

  • Windows: the core is localhost-app.exe --daemon, the same executable the installer must replace. The NSIS installer closes processes with that name, which is the risk noted in Windows: shared core over a named pipe, so the app and localhost mcp share Runs #28. Its Runs sit in a kill-on-close Job Object, so they would die without a graceful stop. Stopping them ourselves first is the only way to make that stop graceful and visible.
  • macOS and Linux: the relaunch is faster than the idle timeout, so the new app would connect to the old core. With a protocol bump the app would refuse it (CoreUnavailable). Without a bump it would keep running old core code for as long as the app stays open.
  • It matches the existing rule that "Quitting the app stops every Service".

The cost is that the user starts their Services again after the update. If you'd rather keep Runs alive across updates on macOS and Linux, that needs a core handover (version-aware reconnect). I'd treat that as a separate issue.

Edge case: if an agent is still connected when the app restarts, the retired core keeps serving that agent until it disconnects. It has no Runs by then, because everything was stopped. On Windows the installer closes it anyway. The agent's next call then starts a fresh core.

Setting up the signing keys (one-time, for Marcel)

These keys are separate from the code-signing certificates. No key is generated or committed in this PR. Each installed app trusts only the public keys it was built with, so create the keys once and keep them.

  1. On your Mac, from a clone of the repository, with gh logged in:
    brew install openssl@3            # if not installed
    (cd apps/desktop && pnpm install)
    scripts/create-updater-keys.sh ~/localhost-updater-keys --upload
    This creates a Tauri (minisign) keypair with a random password and a Sparkle (Ed25519) keypair. It doesn't touch your keychain. It then stores these with gh:
    • secrets: TAURI_SIGNING_PRIVATE_KEY, TAURI_SIGNING_PRIVATE_KEY_PASSWORD, SPARKLE_PRIVATE_KEY
    • variables (public keys, not secrets): TAURI_UPDATER_PUBLIC_KEY, SPARKLE_PUBLIC_KEY
  2. Check them under Settings → Secrets and variables → Actions. There should be 3 secrets and 2 variables.
  3. Back up ~/localhost-updater-keys in a password manager or on an encrypted disk. GitHub can't give the secrets back. If they are lost, every user has to download the next version by hand.

The keys go to marcellosso/localhost explicitly (LOCALHOST_REPO=owner/name overrides it), so a clone with several remotes works. If the keys already exist, scripts/create-updater-keys.sh ~/localhost-updater-keys --upload-only stores them without generating new ones. It checks that all five files are there and that the Sparkle pair matches.

To do it by hand instead, drop --upload and run the five gh secret set / gh variable set lines in RELEASING.md → Updates.

Missing keys fail the release, and that's deliberate. The first job checks all five and stops with "Missing the updater keys: … see RELEASING.md (Updates)" before anything is built. I chose failing over skipping with a warning because a release without signed feeds would install fine, but its users could never update in-app again. That's worse than a release that waits five minutes for the setup. The workflow also refuses mismatched keys:

  • Sparkle: the private key must derive to SPARKLE_PUBLIC_KEY (scripts/sparkle-public-key.sh).
  • Tauri: each .sig must carry the public key's key id (scripts/updater-manifest.sh).

That way a wrong secret fails the release instead of every user's update.

Other changes

  • Core: Request::Retire (protocol 4 → 5), Targets::retire_shared_core, and its FFI/Swift wrapper. Tested in a_retired_core_exits_as_soon_as_its_last_client_leaves.
  • Release workflow: the key check. The Linux and Windows builds get createUpdaterArtifacts, the public key and the feed URL via --config. The workflow signs the .deb/.rpm itself if the bundler didn't. The .sig files are folded into latest.json and kept out of the release assets. On macOS, Sparkle's tools are pinned by version and SHA-256; the workflow signs the DMG after stapling, then writes and signs appcast.xml.
  • scripts/build-release.sh: adds SUPublicEDKey and FEED_URL to Info.plist, and signs Sparkle's helpers (XPC services, Autoupdate, Updater.app) with hardened runtime before signing the app.
  • Manifest shape: latest.json keys every package by bundle (linux-x86_64-appimage, -deb, -rpm, windows-x86_64-nsis) and never by platform alone, so a .deb install can never pick up the AppImage. requireSignedVersion is on.
  • Dependencies: tauri-plugin-updater (without its zip feature) and the Sparkle Swift package. AGENTS.md now mentions Sparkle as the macOS app's one package.
  • Docs: README and /docs/install (Updates), RELEASING.md (Updates: keys, feeds, how it fits together), AGENTS.md (a Sessions rule and a Decision), and the 0.2.0 changelog.

How it was checked

Locally on macOS (Apple silicon):

  • cd core && cargo test --test shared && cargo clippy --all-targets -D warnings && cargo fmt --check. Includes the new retire test.
  • apps/desktop: pnpm typecheck && pnpm test (85 tests, including new ones for the notice and Check for Updates), and in src-tauri, cargo fmt --check && cargo clippy --all-targets -D warnings && cargo test. New tests cover: the shape of latest.json parsed by the plugin's own RemoteRelease, with no bundle-less entry; which packages install themselves; the release-notes fallback; the list of active Services; and the new setting's default and persistence. bindings.ts is regenerated.
  • scripts/test-updater-scripts.sh with throwaway keys, also added to CI. It checks that latest.json is right, that a wrong key or a missing .sig fails, that the Sparkle public key is derived correctly, and that the appcast is right. Also shellcheck scripts/*.sh.
  • scripts/create-updater-keys.sh run into /tmp without --upload. Its Sparkle key signs and verifies with Sparkle's sign_update.
  • The macOS app builds (Debug) with Sparkle embedded and no new warnings.
  • A macOS end-to-end update with real release builds. I built scripts/build-release.sh DMGs for 0.0.1, 0.0.2 and 0.0.3 (ad hoc, hardened runtime, throwaway Sparkle key kept in /tmp, feed served on 127.0.0.1). Each appcast was written with scripts/appcast.sh and signed with sign_update. The app checked at launch, and the sidebar showed "localhost 0.0.2 is available". Update Now downloaded and verified it, and the notice changed to "is ready". With a Service started from the app, Restart to Update showed "Stop 1 service and update? … E2E: Sleeper". After Stop and Update, within about 2 s the Service had stopped and the old core had exited at once (no client for 0ns, the retire). Sparkle installed 0.0.3 and relaunched it with a new core. Check for Updates… in the app menu showed "localhost is up to date" when there was nothing newer.
    • This found a bug, now fixed: signed release builds crashed at launch. The hardened runtime's library validation refused the embedded Sparkle.framework ("different Team IDs"), because ad hoc and self-signed builds have no Team ID, and that's how releases are signed today. The app now has com.apple.security.cs.disable-library-validation; notarization accepts it. CI's macOS job builds Debug unsigned, so it can't catch this. Please run the release DMG once before publishing (RELEASING.md step 4 already says so).
    • Seen during the test: if you quit after an update has downloaded, Sparkle installs it as the app quits (its install-on-quit behaviour). The docs now say so. The Tauri app keeps the download in memory, so there it doesn't install on quit.

Not verified locally:

  • Windows and Linux bundles. I have no Windows or Linux machine here, and the release workflow only runs on tags, which this PR must not push. So these are untested end to end: the in-place NSIS and AppImage install, the restart, bundle_type() detection in a real .deb, and the bundler emitting .sig files. The PR's CI covers the Linux build, clippy and tests. The first tag after merging is the real test. RELEASING.md step 5 now includes installing an update with a Service running.
  • The release workflow edits. The YAML parses and the scripts it calls are tested, but the jobs themselves only run on a tag.

Merge danger

  • The next tag fails until the five keys exist. Run step 1 above before tagging.
  • Protocol 4 → 5. If an older core is still running when someone installs this version by hand (an agent holding it, say), the new app gets CoreUnavailable until that core exits. Every earlier protocol bump has had the same effect.
  • Keys are forever. Once a release ships with these public keys, replacing them strands existing installs.
  • The first release with this code is the first that can update. Users on 0.1.x must still download it by hand.
  • Repository move (Homebrew tap for the macOS app #40): apps built before the move check the old URL and depend on GitHub's redirect for releases/latest/download/…. Builds made after the move use the new URL.

@vercel

vercel Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
localhost-web Ready Ready Preview Oct 29, 2026 7:12pm UTC

@marcellosso
marcellosso merged commit 1f004fe into main Oct 29, 2026
9 checks passed
@marcellosso
marcellosso deleted the feat/auto-update branch October 29, 2026 19:24
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.

Auto-update: detect new releases and update in-app

1 participant