gpui-updater 0.2.0

Observable updater adapter for official GPUI
Documentation

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 — GitHubSource reads a repo's Releases (latest or pre-releases), picks the asset for the running platform, and resolves a SHA256SUMS checksum and an optional .minisig signature. StaticManifestSource reads a CDN/S3/R2-friendly latest.json with a flat asset list. AppcastSource reads a Sparkle / WinSparkle appcast. Bring your own by implementing UpdateSource.
  • 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, ditto the new (already-notarized) .app onto 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.gz and 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.)
  • GPUI adapters — an observable Entity<Updater> that runs the work on the background executor and sets App::set_restart_path when 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 same Entity<Updater> and UpdateStatus.

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:

[dependencies]
# Core only (blocking engine, no GPUI):
gpui-updater-core = "0.2"

# OR official GPUI (Entity<Updater>):
gpui = "0.2.2"
gpui-updater = "0.2"

# OR gpui-pre (keep existing gpui_updater imports via an alias):
gpui = { package = "gpui-pre", version = "0.3.4" }
gpui-updater = { package = "gpui-updater-pre", version = "0.2" }

# Optional, next to either adapter: hand the macOS flow to Sparkle 2.
gpui-updater-sparkle = "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, and EngineConfig are #[non_exhaustive]. Build them with Release::new / Asset::new / EngineConfig::new and the with_* or builder methods; the fields stay public for reading and adjusting. EngineConfig also gains ed25519_public_key.
  • Adapters: Updater::restart takes &mut self. It needs Context<Self>, so it is called from update closures and call sites are unchanged.
  • Adapters: Updater is no longer Send or Sync, because it may hold a main-thread-only driver such as SparkleDriver. Entity<Updater> is still Send + Sync; only code that moves an Updater value itself across threads is affected.
  • Verification::Signature and Strict also accept a valid Sparkle Ed25519 signature once ed25519_public_key is set. With only a minisign key configured, verification behaves as before.

Migrating from 0.0.x

  • Blocking consumers: replace gpui-updater with gpui-updater-core and change imports to gpui_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, and restart retain 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:

[patch.crates-io]
gpui = { git = "https://github.com/zed-industries/zed", rev = "<your-verified-commit>", version = "=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 gpui_updater_core::{EngineConfig, GitHubSource, UpdateEngine};
use semver::Version;

let source = GitHubSource::new("AprilNEA", "OpenLogi")
    .asset_contains("macos")
    .asset_contains(".dmg")
    .with_checksums("SHA256SUMS")
    .with_minisig();

let engine = UpdateEngine::new(
    source,
    EngineConfig::new(Version::parse(env!("CARGO_PKG_VERSION"))?)
        .minisign_public_key("RWQ…"), // optional
);

if let Some(release) = engine.check()? {
    let artifact = engine.download(&release, |done, total| { /* progress */ })?;
    engine.install(&artifact)?;
}

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 gpui_updater_core::{EngineConfig, StaticManifestSource, UpdateEngine};

let source = StaticManifestSource::new("https://dl.example.com/channels/stable/latest.json")
    .os("macos")
    .arch("arm64")
    .format("dmg");

let engine = UpdateEngine::new(source, EngineConfig::new(current_version));

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
{
  "schema_version": 1,
  "app_id": "org.example.App",
  "version": "1.2.3",
  "tag": "v1.2.3",
  "channel": "stable",
  "published_at": "2026-06-01T12:00:00Z",
  "release_url": "https://example.com/releases/v1.2.3",
  "notes": "Markdown release notes.",
  "assets": [
    {
      "name": "App-1.2.3-macos-arm64.dmg",
      "url": "https://dl.example.com/releases/v1.2.3/App-1.2.3-macos-arm64.dmg",
      "os": "macos",
      "arch": "arm64",
      "format": "dmg",
      "size": 12345678,
      "sha256": "0123456789abcdef...",
      "signature": null,
      "signature_url": null,
      "minimum_os_version": "13.0"
    }
  ]
}

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 gpui_updater_core::Verification;

EngineConfig::new(current_version)
    .minisign_public_key("RWQ…")
    .verification(Verification::Strict);
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 = StaticManifestSource::new("https://dl.example.com/channels/stable/latest.json")
    .os("macos")
    .arch("arm64")
    .format("dmg");

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:

  1. Build and sign/notarize platform artifacts.
  2. Upload artifacts to an immutable, versioned prefix such as /releases/v1.2.3/.
  3. Generate SHA-256 hashes and, optionally, minisign signatures.
  4. Upload latest.json to a mutable channel prefix such as /channels/stable/latest.json after 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 gpui_updater_core::{AppcastSource, EngineConfig, UpdateEngine, Verification};

let source = AppcastSource::new("https://dl.example.com/appcast.xml")
    .channel("beta"); // optional: also offer `sparkle:channel` "beta"

let engine = UpdateEngine::new(
    source,
    EngineConfig::new(current_version)
        .ed25519_public_key("pfIShU4dEXqPd5ObYNfDBiQWcXozk7estwzTnF9BamQ=") // SUPublicEDKey
        .verification(Verification::Signature),
);
  • Signatures — the enclosure's sparkle:edSignature is verified against ed25519_public_key, the same base64 key Sparkle's generate_keys prints for SUPublicEDKey. Archives signed with Sparkle's sign_update or generate_appcast verify unchanged (pinned by golden tests).
  • Versions — the crate orders by semver, so it compares sparkle:shortVersionString (falling back to sparkle:version). Pick .version_field(AppcastVersion::BundleVersion) if your CFBundleVersion is itself a semantic version.
  • Filters — sparkle:channel, sparkle:minimumSystemVersion / maximumSystemVersion (macOS), sparkle:hardwareRequirements (arm64), and sparkle:informationalUpdate are honoured. Enclosures are matched on sparkle:os (macos when absent; WinSparkle's windows-x64 etc. on Windows; linux-x64 / linux-arm64 / linux on 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 .dmg enclosures (a .zip or .tar.xz fails 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 gpui_updater::{EngineConfig, GitHubSource, UpdateStatus, Updater};

let updater = cx.new(|cx| Updater::new(
    GitHubSource::new("AprilNEA", "OpenLogi")
        .asset_contains("macos").asset_contains(".dmg")
        .with_checksums("SHA256SUMS"),
    EngineConfig::new(current_version),
    cx,
));

// Re-render on status changes:
cx.observe(&updater, |_, _, cx| cx.notify()).detach();

// Drive it from buttons:
updater.update(cx, |u, cx| u.check(cx));
// 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 gpui_updater::{AppcastSource, EngineConfig, Updater};
use gpui_updater_sparkle::{SparkleDriver, SparkleOptions};

let updater = cx.new(|cx| match SparkleDriver::load(SparkleOptions::new()) {
    // Running from a bundle that embeds Sparkle.framework.
    Ok(Some(sparkle)) => Updater::with_driver(sparkle, cx),
    // `cargo run`, other platforms, or a misconfigured bundle: same feed,
    // built-in engine.
    _ => Updater::new(
        AppcastSource::new(FEED_URL),
        EngineConfig::new(current_version).ed25519_public_key(SU_PUBLIC_ED_KEY),
        cx,
    ),
});

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 /Applications needs 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 (an osascript … with administrator privileges prompt, 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 use gpui-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 gpui 0.2.2: libc 0.2.190 removed ENOATTR, which breaks xattr 0.2.3 (pulled in by gpui_http_client → zed-async-tar). If a fresh lockfile picks that libc, hold it back with cargo update -p libc --precise 0.2.189. gpui-pre is not affected.

Development notes

The workspace defaults to core; cargo test is not adapter coverage. Run:

cargo +1.85.0 test -p gpui-updater-core --all-targets --locked
cargo test --workspace --all-targets --locked
cargo test --workspace --doc --locked
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --locked -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --locked
cargo xtask check-packages

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 cargo test

The same prefix works for cargo check and cargo clippy.

License

Licensed under either of

at your option.