gpui-updater
Cross-platform self-update for GPUI desktop apps, hosted on GitHub Releases, static JSON manifests, or Sparkle appcasts — and, on macOS, optionally driven by Sparkle itself.
GPUI ships no updater of its own, and Zed's auto_update crate is GPL-licensed
and wired to Zed's private update server. gpui-updater is an independent,
MIT/Apache implementation of the same idea: check a release source, download the
platform artifact, verify it, and swap it into place — on macOS (.dmg),
Linux (.tar.gz), and Windows (bare .exe, or .msi for installer-shipped
apps).
What it does
- Sources —
GitHubSourcereads a repo's Releases (latest or pre-releases), picks the asset for the running platform, and resolves aSHA256SUMSchecksum and an optional.minisigsignature.StaticManifestSourcereads a CDN/S3/R2-friendlylatest.jsonwith a flat asset list.AppcastSourcereads a Sparkle / WinSparkle appcast. Bring your own by implementingUpdateSource. - Verification — SHA-256 against the published checksums, plus optional
minisign or Sparkle-compatible Ed25519 (
sparkle:edSignature/SUPublicEDKey) signature verification. Transport security alone is not trusted. - Install — platform-native swaps:
- macOS: mount the
.dmg,dittothe new (already-notarized).apponto the target volume, then atomically replace the bundle. Nothing is re-signed at runtime — the new bundle carries its own signature. - Linux: extract the
.tar.gzand atomically replace the binary. - Windows: rename-in-place for a bare
.exe, or a staged msiexec handoff for an.msi— the verified package is applied (/passive /norestart) after the app exits, via the restart path, then the app relaunches. (Restart-Manager integration for locked sibling DLLs remains future work.)
- macOS: mount the
- GPUI adapters — an observable
Entity<Updater>that runs the work on the background executor and setsApp::set_restart_pathwhen an update is staged. No background polling: trigger checks explicitly, which suits a privacy-conscious "Check for updates" button. - Sparkle driver (
gpui-updater-sparkle) — hands the whole flow on macOS to Sparkle 2 (installs for standard users, installs on quit, delta updates, Sparkle's UI) behind the sameEntity<Updater>andUpdateStatus.
Installation
Since 0.1.0 the blocking engine is separate from two independent GPUI adapters. 0.2.0 adds Sparkle appcasts, Ed25519 verification, and the Sparkle driver, with a few breaking changes (see Migrating from 0.1). Pick the adapter matching your application's GPUI package:
| Package | Backend | Rust requirement |
|---|---|---|
gpui-updater-core |
None (blocking I/O engine) | 1.85; tested on 1.85.0 |
gpui-updater |
Registry gpui ^0.2.2 |
1.98; tested on 1.98.1 |
gpui-updater-pre |
Registry gpui-pre ^0.3.4 |
1.98; tested on 1.98.1 |
gpui-updater-sparkle |
Sparkle 2 on macOS (loaded at run time); add it next to an adapter | 1.85; tested on 1.85.0 |
Adapter requirements are conservative tested support floors, not a claim that older compilers necessarily fail. They do not raise the core's requirement. Registry dependencies for the 0.2 releases:
[]
# Core only (blocking engine, no GPUI):
= "0.2"
# OR official GPUI (Entity<Updater>):
= "0.2.2"
= "0.2"
# OR gpui-pre (keep existing gpui_updater imports via an alias):
= { = "gpui-pre", = "0.3.4" }
= { = "gpui-updater-pre", = "0.2" }
# Optional, next to either adapter: hand the macOS flow to Sparkle 2.
= "0.2"
The core and the two adapters are alternatives, not dependencies to combine;
gpui-updater-sparkle is additive. Both adapters re-export
the entire core API, including EngineConfig, Verification, Version,
StaticManifestSource, Release, Installed, UpdateStatus, and UpdateEngine.
Only the adapters export Updater. Without the alias, pre imports use
gpui_updater_pre. Before a release is published, use the corresponding
crates/<package> path from this checkout.
Migrating from 0.1
Release,Asset, andEngineConfigare#[non_exhaustive]. Build them withRelease::new/Asset::new/EngineConfig::newand thewith_*or builder methods; the fields stay public for reading and adjusting.EngineConfigalso gainsed25519_public_key.- Adapters:
Updater::restarttakes&mut self. It needsContext<Self>, so it is called fromupdateclosures and call sites are unchanged. - Adapters:
Updateris no longerSendorSync, because it may hold a main-thread-only driver such asSparkleDriver.Entity<Updater>is stillSend + Sync; only code that moves anUpdatervalue itself across threads is affected. Verification::SignatureandStrictalso accept a valid Sparkle Ed25519 signature onceed25519_public_keyis set. With only a minisign key configured, verification behaves as before.
Migrating from 0.0.x
- Blocking consumers: replace
gpui-updaterwithgpui-updater-coreand change imports togpui_updater_core(or retain the old import via a Cargo alias). - GPUI consumers: select official or pre above and remove
features = ["gpui"].Updater::new,check,download_and_install,status,available, andrestartretain their behavior. No update policy or polling is added. - The adapters depend only on core, never each other. Small GPUI bindings are intentionally duplicated; download, verification, and install code are not.
- Then apply Migrating from 0.1.
Published adapters contain no baked-in Zed Git dependency. A consumer using a Git GPUI must patch at its workspace root so every dependency uses that same source, for example:
[]
= { = "https://github.com/zed-industries/zed", = "<your-verified-commit>", = "=0.2.2" }
The explicit version disambiguates package selection: some Zed revisions also
contain a gpui 0.0.0 fixture, so a Git URL and revision alone are ambiguous.
The Git package version must satisfy ^0.2.2, and the commit must actually be
API-compatible. No Git pin is claimed as verified by this workspace. An alias
does not make gpui-pre and gpui interchangeable: they have distinct Cargo
package/source identities and their Entity/Context types cannot be mixed.
Adapters disable GPUI default features; the application selects its platform
startup crate/features. No platform startup library is a mandatory adapter
runtime dependency.
Usage
Pick an update source
Use one of the built-in sources, or implement UpdateSource for your own
release service:
| Source | Best for | How it works |
|---|---|---|
GitHubSource |
Existing GitHub Releases pipelines | Calls the GitHub Releases API, selects a matching asset, and can read SHA256SUMS / .minisig assets. |
StaticManifestSource |
Cloudflare R2, S3, MinIO, B2, CDNs, static hosting | Fetches a static latest.json, selects an asset from assets[], then downloads the asset URL directly. |
AppcastSource |
Apps that publish (or want to publish) a Sparkle appcast | Fetches an appcast, picks the newest applicable <item> and the enclosure for this platform, and carries its sparkle:edSignature. |
GitHub Releases
use ;
use Version;
let source = new
.asset_contains
.asset_contains
.with_checksums
.with_minisig;
let engine = new;
if let Some = engine.check?
Static manifests (S3/R2/CDN)
Use StaticManifestSource when your release metadata is hosted as a static JSON
file on Cloudflare R2, AWS S3, MinIO, Backblaze B2, GitHub Pages, or any normal
HTTPS file server. The updater does not speak the S3 API; it fetches JSON and
downloads direct artifact URLs. This keeps credentials and provider SDKs out of
the app: upload artifacts however you like, then publish a small JSON pointer.
use ;
let source = new
.os
.arch
.format;
let engine = new;
Manifest v1 describes one latest release and a flat list of downloadable assets.
schema_version, version, assets[].name, and assets[].url are the only
required fields; all other fields are optional and unknown fields are ignored.
Put channel pointers at mutable URLs such as /channels/stable/latest.json, but
keep artifact URLs versioned and immutable.
Recommended layout:
https://dl.example.com/
├── channels/
│ └── stable/
│ └── latest.json # mutable pointer
└── releases/
└── v1.2.3/
├── App-1.2.3-macos-arm64.dmg
├── App-1.2.3-macos-arm64.dmg.minisig
└── SHA256SUMS
sha256 is checked after download when present. signature may contain an
inline minisign signature; signature_url may point to a detached signature file.
If both are present and a minisign public key is configured, the inline signature
is used.
By default (Verification::BestEffort) these checks are skipped when their input
is absent, so an unsigned release still installs. To fail closed, set a
stricter policy:
use Verification;
new
.minisign_public_key
.verification;
| Policy | Behaviour |
|---|---|
BestEffort (default) |
Verify what's available; skip missing checks (fails open). |
Off |
Skip all checks (tests/local dev only). |
Checksum |
Require a matching SHA-256. |
Signature |
Require a public key and an advertised signature it can check (minisign or Ed25519). |
Strict |
Require both a valid signature and a matching SHA-256. |
When a release publishes both a minisign and an Ed25519 signature and both keys are configured, both are verified and either mismatch fails the download.
Under Signature/Strict, a release that cannot be verified is rejected at
check() time — before it is ever surfaced as available — and again before a
download is verified.
Asset selection is explicit when you use os, arch, and format:
let source = new
.os
.arch
.format;
You can also add filename substring filters with asset_contains(...), or
replace them completely with asset_patterns(...). Matching is
case-insensitive. If no selector is configured, the source falls back to a
platform extension guess: .dmg on macOS, .exe on Windows, and .tar.gz on
Linux/other platforms. Installer-shipped Windows apps should select their MSI
explicitly with .format("msi") — the install step stages it and applies via
msiexec on restart.
For R2/S3-style hosting, the recommended release flow is:
- Build and sign/notarize platform artifacts.
- Upload artifacts to an immutable, versioned prefix such as
/releases/v1.2.3/. - Generate SHA-256 hashes and, optionally, minisign signatures.
- Upload
latest.jsonto a mutable channel prefix such as/channels/stable/latest.jsonafter all artifacts are in place.
Sparkle appcasts
AppcastSource reads the RSS feed that Sparkle (macOS) and WinSparkle
(Windows) consume, so one feed can serve a Sparkle-driven macOS build and the
built-in engine everywhere else:
use ;
let source = new
.channel; // optional: also offer `sparkle:channel` "beta"
let engine = new;
- Signatures — the enclosure's
sparkle:edSignatureis verified againsted25519_public_key, the same base64 key Sparkle'sgenerate_keysprints forSUPublicEDKey. Archives signed with Sparkle'ssign_updateorgenerate_appcastverify unchanged (pinned by golden tests). - Versions — the crate orders by semver, so it compares
sparkle:shortVersionString(falling back tosparkle:version). Pick.version_field(AppcastVersion::BundleVersion)if yourCFBundleVersionis itself a semantic version. - Filters —
sparkle:channel,sparkle:minimumSystemVersion/maximumSystemVersion(macOS),sparkle:hardwareRequirements(arm64), andsparkle:informationalUpdateare honoured. Enclosures are matched onsparkle:os(macoswhen absent; WinSparkle'swindows-x64etc. on Windows;linux-x64/linux-arm64/linuxon Linux). - Not supported — delta updates, phased rollouts, critical updates, and
verifying Sparkle 2.10's signed-feed comment; the engine always downloads the
full archive. On macOS the engine installs only
.dmgenclosures (a.zipor.tar.xzfails with a clear error); Sparkle itself handles all formats.
GPUI entity
Choose an adapter (see Installation); with the pre alias above the same imports work:
use ;
let updater = cx.new;
// Re-render on status changes:
cx.observe.detach;
// Drive it from buttons:
updater.update;
// when status is Available → u.download_and_install(cx)
// when status is Staged → u.restart(cx)
UpdateStatus: Idle → Checking → {UpToDate | Available(v)} → Downloading { downloaded, total } → Installing → Staged(v) | Errored(msg).
Downloading carries live byte counts (total is None when the server omits
Content-Length).
Keep the entity alive while updating. Dropping it cancels its foreground task,
but does not interrupt an already-running blocking download/install or roll it
back. A completed download may remain on disk. A check that finds no newer
release or fails retains the previous available() release, as in 0.0.x.
Sparkle on macOS
gpui-updater-sparkle provides SparkleDriver, an UpdateDriver that hands
checking, downloading, installing, and relaunching to Sparkle 2. Build the
entity with Updater::with_driver instead of Updater::new (both adapters
have it); views keep using UpdateStatus and the same check /
download_and_install / restart calls.
use ;
use ;
let updater = cx.new;
Sparkle is loaded at run time from Contents/Frameworks/Sparkle.framework, so
cargo build and cargo test never need it. The bundle must embed and sign
the framework and set SUPublicEDKey and SUFeedURL in Info.plist; see the
crate README for the checklist, the
call mapping, and the end-to-end smoke test. Updater::driver_mut::<SparkleDriver>()
reaches Sparkle's own settings (automatic checks and downloads).
Platform notes
- macOS (built-in engine) replacing an app in
/Applicationsneeds write permission to it. An admin who drag-installed the app can replace it without a prompt; a standard user cannot. A privilege-escalation fallback (anosascript … with administrator privilegesprompt, as Velopack does) is not yet implemented — a permission error just surfaces as a failed update. Sparkle handles this case with its installer, which is one reason to usegpui-updater-sparkle. - Adapters need their GPUI backend's native toolchain (including Xcode/Metal where required on macOS). Core does not need GPUI or a graphics toolchain.
- Linux with registry
gpui0.2.2: libc 0.2.190 removedENOATTR, which breaksxattr0.2.3 (pulled in bygpui_http_client→zed-async-tar). If a fresh lockfile picks that libc, hold it back withcargo update -p libc --precise 0.2.189.gpui-preis not affected.
Development notes
The workspace defaults to core; cargo test is not adapter coverage. Run:
RUSTDOCFLAGS="-D warnings"
CI tests core/platform installers on Linux, macOS and Windows, and builds each
registry adapter independently on Linux. Adapter tests use GPUI's seeded test
executor, local one-shot HTTP fixtures, and temporary install roots. They
exercise observer notifications, busy guards, progress timing, errors, and
entity/task lifetime. gpui-pre captures the fake platform restart request;
official 0.2.2 discards its path, so exact-path coverage uses a small isolated
handoff boundary. No test restarts a real app or installs over the test binary.
cargo xtask check-packages packages all four crates without publishing,
checks archive contents, builds fresh consumers from extracted packages, and
checks the selected normal/build dependency closures (core and Sparkle: no
GPUI; official: no pre; pre: no official). Package locks may list unrelated
platform/optional dependencies; they are not evidence that those dependencies
are compiled. Pass package names to check only those, for example
cargo xtask check-packages gpui-updater-core gpui-updater-pre on a Mac
without the Metal toolchain.
Release order: publish gpui-updater-core 0.2.0 first; after it is available in
the registry, publish gpui-updater, gpui-updater-pre and
gpui-updater-sparkle 0.2.0. Run normal package verification again before
publishing. A repository tag is not needed for registry resolution; if a
coordinated release tag is created, v0.2.0 should identify the published
workspace commit. Never rewrite an existing release tag or embed a consumer's
GPUI Git pin here.
On macOS inside some Nix shells, Cargo may pick Nix's clang wrapper as cc and
fail to link build scripts with missing system symbols such as __Unwind_* or
_pthread_*. Point Cargo at Apple's linker for the host target:
CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER=/usr/bin/cc
The same prefix works for cargo check and cargo clippy.
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.