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 or .AppImage), 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.FallbackSourcetries several sources in order, such as a CDN manifest first and GitHub Releases second. - Verification — the published size, SHA-256, and optional minisign or
Sparkle-compatible Ed25519 (
sparkle:edSignature/SUPublicEDKey) signatures, with minisign key rotation and signatures bound to a version. On macOS the new.appmust declare the release version and carry the installed app's code-signing team; on Windows the.exeor.msimust carry the installed app's Authenticode publisher. Static manifests can be signed and expire, so a stale one cannot be replayed. Everything is fetched over HTTPS with bounded sizes into a directory only the current user can access, but transport security alone is not trusted. See Verification and trust. - Downloads — a retry resumes with an HTTP
Rangerequest instead of starting over, and a verified download from an earlier run is reused.Updater::cancel(or aCancelToken) stops a download. The proxy comes from the environment (ALL_PROXY,HTTPS_PROXY,HTTP_PROXY) or fromHttpOptions, which also adds request headers. See Proxies and request headers. - Delta updates (optional
deltafeature) — download small per-file patches instead of the full artifact (chained across several releases when needed) and rebuild the new.appbundle or executable from the installed one. Any problem falls back to the full download. See Delta updates. - 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. When the install root is a directory (anapp.app/withbin/andlib/, as Zed ships), the whole directory is replaced instead. An app that runs as anAppImagereplaces the.AppImagefile with the new one. - Windows: rename-in-place for a bare
.exe, or a staged msiexec handoff for an.msi— the verified package is applied (/passive /norestartby default;MsiUipicks/qbor/quiet) after the app exits, via the restart path, then the app relaunches. msiexec logs to<package>.log; exit code 3010 (reboot needed) counts as success. CallUpdateEngine::remove_leftoversat startup to delete the.old.exea bare-.exeupdate leaves behind. (Restart Manager integration for locked sibling DLLs remains future work.)
- macOS: mount the
- Install location and ownership — before downloading, the engine
rejects an App Translocation path on macOS and an install folder the user
cannot write to.
install::ownershipreports when Homebrew, apt, Flatpak, Snap, Nix, Scoop, winget, or an app store owns the install, so the app can point to that tool instead. See Installs a package manager owns. - Errors — every failure carries an
ErrorKind(Network,Verification,Location,Permission,Cancelled, and others), so the app can choose what to tell the user. - 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. 0.3.0 adds delta updates (optional delta feature),
version-bound signatures, key rotation, signed manifests, and macOS bundle
checks, and requires HTTPS with bounded downloads into a private directory,
with a few breaking changes (see Migrating from 0.2).
0.4.0 classifies errors by ErrorKind, makes downloads cancellable and
resumable, adds proxies and request headers, FallbackSource, install
location checks, Authenticode checks on Windows, Linux app directories and
AppImages, and package-manager detection, with a few breaking changes (see
Migrating from 0.3).
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.4 releases:
[]
# Core only (blocking engine, no GPUI):
= "0.4"
# OR official GPUI (Entity<Updater>):
= "0.2.2"
= "0.4"
# OR gpui-pre (keep existing gpui_updater imports via an alias):
= { = "gpui-pre", = "0.3.4" }
= { = "gpui-updater-pre", = "0.4" }
# Optional, next to either adapter: hand the macOS flow to Sparkle 2.
= "0.4"
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.3
Breaking changes:
UpdateStatus::Errored(String)→Errored(UpdateError). Show the error with itsDisplay(ormessage()), and branch onkind(), anErrorKind. See GPUI entity for what each kind means.DriverEvent::Failed(String)→Failed(UpdateError). A custom driver builds one withUpdateError::new(ErrorKind::Install, message).UpdateStatusis now#[non_exhaustive]: add a wildcard arm to matches.
New, with no change needed: Updater::cancel, CancelToken with
UpdateEngine::download_cancellable, UpdateDriver::cancel (defaults to
doing nothing), Release::published_at / release_url, HttpOptions,
FallbackSource, UpdateEngine::check_location / install::check_location,
CodeSigning::Publisher, InstallOptions / MsiUi with
EngineConfig::install_options and install::install_with,
UpdateEngine::remove_leftovers / install::remove_leftovers, and
install::ownership / UpdateEngine::ownership with Ownership,
OwnershipHints, and PackageManager.
Behavior changes:
- Dropping the
Updaternow cancels a running download; before, the download ran on to the end. - A download that fails mid-body continues with a
Rangerequest when the server allows it, instead of starting over. - With
EngineConfig::download_dir, an earlier download that still verifies is reused instead of downloaded again. downloadfails withLocationorPermissionbefore downloading when the update could not be installed where the app is. Before, the download finished and the install failed.- On Linux,
current_install_rootreturns theAppImagefile when the app runs from inside its mount, not the executable in the read-only mount. An install root that is a directory is replaced as a whole. - On Windows,
CodeSigning::MatchInstalled(the default) now checks Authenticode: an app with a valid signature takes only updates signed by the same publisher. Before, nothing was checked on Windows. Opt out with.code_signing(CodeSigning::Off). - The
.msiapply script relaunches the app after exit code 3010 (installed, reboot needed); before, it treated 3010 as a failure. It also writes<package>.log.
Migrating from 0.2
EngineConfig::minisign_public_key(the field) becameminisign_public_keys: Vec<String>. The builder method of the same name is unchanged and may be called more than once to trust several keys.- On macOS, downloads are now checked before install: the
.appmust declare the release version, and when the installed app is signed by a team, the update must be validly signed by the same team. Opt out with.bundle_version(BundleVersion::Ignore)or.code_signing(CodeSigning::Off). UpdateEngine::downloadreturns anArtifactinstead of aPathBuf, andUpdateEngine::installtakes&Artifact, so the engine only installs a file it downloaded and checked. Read the location withartifact.path(); callinstall::installdirectly to install some other file on purpose.- Every request, and every redirect it follows, must use HTTPS. A plain
http://URL fails withError::InsecureUrl, andcheck()rejects a release whose artifact orsignature_urlis not HTTPS. Plain HTTP still works for loopback hosts (localhost,127.0.0.1,[::1]), for local testing. - A download is cut off once it exceeds the size the release publishes
(GitHub always publishes one) or, without one,
EngineConfig::max_download_size(4 GiB by default). Manifests, appcasts, and GitHub API responses are limited to 10 MiB, signature and checksums files to 1 MiB. - The default download directory is created fresh with owner-only permissions
(
0700on Unix), and so is a configureddownload_dirthe engine has to create. Asset names from release metadata are reduced to a plain file name, so a name such as../xcannot place the download outside that directory. Releasegainsdeltas(build it withRelease::new/with_deltas), andErrorgainsPayload,ManifestExpired,Delta, andInsecureUrl.
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.- Then apply Migrating from 0.2.
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. |
FallbackSource |
A mirror or second host for when the primary is down | Asks its sources in order and returns the release from the first one that answers. When all fail, returns the primary's error. |
FallbackSource::new(primary).or(mirror) does not compare its sources: a
primary that answers wins, even if the mirror knows a newer release. Each
source verifies what it fetches with its own settings, such as a manifest's
signing keys, and the engine verifies the artifact the same way whichever
source named it.
Proxies and request headers
By default every request uses the proxy from the environment (ALL_PROXY,
HTTPS_PROXY, HTTP_PROXY, or their lowercase forms) and adds no headers.
HttpOptions changes this. Give it to the source for metadata requests and
to EngineConfig::http for the artifact, deltas, and their checksum and
signature files:
use ;
let http = new
.header
.proxy; // or .no_proxy()
let source = new
.http;
let config = new.http;
Put a credential in authorization. A redirect drops it (and cookie), so
the storage host behind a release asset never sees it. Every other header
also goes to redirect targets. A header the source sets itself, such as the
authorization from GitHubSource::token, takes precedence. HTTPS requests
are tunnelled through the proxy, so TLS still ends at the update server.
HttpOptions's Debug output shows header names only, never values or the
proxy URL.
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
size, sha256, signature (inline minisign signature), signature_url
(detached signature file), and ed25519_signature (base64, as Sparkle's
sign_update prints it) are checked after download as described in
Verification and trust. If both signature and
signature_url are present, the inline signature is used. expires only
matters for a signed manifest; see there too.
published_at (RFC 3339) and release_url fill Release::published_at and
Release::release_url, for a "published on" line and a "View release" link.
Both are for display only. A published_at that does not parse, or a
release_url that is not an http(s) URL, is dropped; the check does not
fail. GitHubSource fills the same fields from the release's published_at
and html_url, and AppcastSource from the item's <pubDate> and <link>.
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. An AppImage app selects its asset with
asset_patterns(vec![".AppImage".into()]). 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. If the app verifies the manifest, uploadlatest.json.minisigwith it, and re-sign it before itsexpirestime.
Verification and trust
Transport and staging. Every request, including each redirect it
follows, must use HTTPS; plain HTTP is accepted only for loopback hosts, for
local testing. Downloads land in a fresh directory that only the current user
can access, under a file name reduced to its last path component, and
UpdateEngine::install accepts only the Artifact that download returns,
so nothing reaches the installer without passing the checks below. HTTPS
protects the transfer; whether the file is trusted is decided by those checks.
Retries and resuming. A download that fails with a transient network
error (a dropped connection, a timeout, a 5xx status) is retried with
backoff. When the server named the body with a strong ETag or a
Last-Modified date, the retry asks for the rest only, with Range and
If-Range. The file continues only if the server answers 206 for exactly
that range; any other answer starts the file over. Up to five attempts in a
row may fail without moving the download forward. Resuming works within one
download call; a new call starts over. The checks below always run on the
whole file.
Reusing a finished download. With EngineConfig::download_dir, the
directory persists between downloads. If it already holds the release's
artifact, of the published size, and that file passes a published checksum
or signature and the payload check, download returns it without fetching
anything. An app that restarts between downloading and installing therefore
does not download again. A file that nothing published can check is
downloaded again.
Every downloaded file, the full artifact or a delta, goes through the same checks, in this order:
- Size. When the release publishes one (
sizein a manifest; GitHub always does), a response announcing another size is rejected and a body growing past it is cut off. Without a published size, the body is cut off atEngineConfig::max_download_size(4 GiB by default). Either way a hostile mirror cannot fill the disk. - SHA-256 against the inline
sha256or the release's checksums file. - Signatures. A minisign signature (inline
signature, elsesignature_url) made by any configured public key, and a Sparkle-style Ed25519 signature (ed25519_signature) checked againsted25519_public_key. Every signature that is published and has a configured key is verified; any mismatch fails the download. - Signed version. If the signature's trusted comment contains
version:<semver>, it must equal the version the file is supposed to be: the release version, or for a delta the version it produces. Sign withminisign -S -m App.dmg -t "version:1.2.3". This stops an older, validly signed artifact from being served as a newer release.VersionBinding::Requiredalso rejects signatures without the token, once all your releases carry it. Ed25519 signatures cover the file alone and cannot name a version; on macOS check 5 covers them. - App bundle (macOS). The
.appin the update must declare the release version asCFBundleShortVersionString(BundleVersion::MustMatch, where1.2matches1.2.0). When the installed app is signed by a team, the update must passcodesign --verify --deep --strictand be signed by the same Team ID (CodeSigning::MatchInstalled). Pin a team withCodeSigning::TeamId("…"), or turn either check off. A delta-rebuilt bundle failing these checks falls back to the full download. Like Sparkle, this keeps a stolen minisign key alone from shipping someone else's app. - Authenticode (Windows). When the installed
.exehas a valid Authenticode signature, the downloaded.exeor.msimust have a valid one whose certificate has the same common name (CN), such asExample Corp(CodeSigning::MatchInstalled, as electron-updater'spublisherName). Pin a publisher withCodeSigning::Publisher("…"). An unsigned installed app is not checked. The check runs PowerShell'sGet-AuthenticodeSignature, so it accepts what Windows trusts, catalog signatures included. When PowerShell cannot read a signature, of the update or of the installed app, the download fails withError::Payload(ErrorKind::Verification) instead of skipping the check. Where PowerShell is blocked, turn the check off withCodeSigning::Off. Windows executables need not declare a version, soBundleVersiondoes not apply.
By default (Verification::BestEffort) checks 2 and 3 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, including the bundle 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. |
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.
For the strongest setup, combine Verification::Strict, a version: token in
every signature with VersionBinding::Required, and, for static manifests, a
signed manifest with expires (see Signed manifests below).
Key rotation. Call minisign_public_key more than once: a signature by
any configured key is accepted. Ship a release that trusts both the old and
the new key, then start signing with the new one.
Signed manifests. Signed artifacts prove what a file is, but not that the
manifest listing them is current. Whoever controls the host could keep
serving an old manifest, so users never see a security fix (a freeze
attack). StaticManifestSource can require the manifest itself to be signed:
let source = new
.minisign_public_key; // verifies latest.json against latest.json.minisig
The bytes are verified before they are parsed; .signature_url(…) overrides
the default <url>.minisig. With a top-level "expires" (RFC 3339) in the
signed manifest, check() fails with Error::ManifestExpired after that time
instead of reporting "up to date", which bounds how long a replay works.
Re-sign the manifest on a schedule shorter than its lifetime, e.g. weekly with
a two-week expires. Rollback is never possible: check() only offers
versions newer than the running one.
Delta updates
Enable the delta feature on whichever crate you depend on
(gpui-updater-core, gpui-updater, or gpui-updater-pre). Nothing else
changes in your app: download tries deltas from the running version first
and otherwise behaves exactly as before.
= { = "0.4", = ["delta"] }
A delta in the files-bsdiff-v1 format rebuilds the new install tree (the
.app bundle on macOS, the executable on Linux) from the installed one. It
works on unpacked content, because diffing the compressed .dmg or .tar.gz
gains almost nothing. Each file is stored the cheapest way: copied from the
installed tree (also across renames), patched with bsdiff, or stored whole.
Patches and whole files are LZMA-compressed at the xz -9e level. Signed
Mach-O images have the page hashes in their code signature zeroed before
diffing and recomputed when the delta is applied, because those hashes change
on every build and do not diff well. Every file records SHA-256 hashes of both
the installed file it starts from and the exact file it must produce, so the
rebuilt bundle is still byte-identical to the notarized one.
Chains. When no delta starts at the running version, the engine chains
deltas (1.2.0 → 1.2.2 → 1.2.3). It picks the chain with the fewest bytes, of
at most max_delta_hops deltas (default 4), and only if that total is smaller
than the full download.
When an update is applied:
- Each delta is downloaded and verified under the same
checks as the full artifact: size,
sha256, signature, and the signed version (the version the delta produces). UnderStrict, an unsigned delta is never downloaded. - Its header must say exactly the
from -> toit was listed with, so a validly signed delta cannot be replayed for another upgrade. - Each installed file is hashed before use. A user-modified or partially updated install is rejected instead of being patched into a broken one.
- Each rebuilt file is hashed after writing. The next delta of a chain starts
from the result. The final directory holds the rebuilt bundle, which goes
through the macOS bundle checks and then
installswaps it in exactly like a mounted.dmg. Nothing is re-signed.
If any step fails (no usable chain, a network error, a bad checksum or
signature, a modified install), the failure is logged with tracing::warn!
and the full artifact is downloaded instead. The progress callback then
restarts from zero. Deltas never make an update fail. Turn them off at runtime
with EngineConfig::deltas(false). Windows still uses full downloads.
Generating deltas (release CI). Pick the earlier releases to build deltas from, unpack each of them and the new build, then create one delta per base:
MAKE="cargo run -p gpui-updater-core --features delta --release --example make_delta --"
bases prints the 3 newest earlier releases plus those 2, 4, 8, … releases
further back (--direct N changes the 3). When every release is published
this way, a user d releases behind reaches the newest one in at most
⌊log₂ d⌋ + 1 deltas, while each release ships only a handful of them. Older
installs just take the full download. --verify rebuilds the new build from
the old one with the fresh delta before you publish it. Library users can call
gpui_updater_core::delta::create and delta::delta_bases directly.
Generation keeps whole files in memory and is CPU-heavy, so it belongs in CI,
not in the app.
Publishing. On GitHub Releases, name the delta
<asset>.from-<version>.delta, e.g. MyApp-macos-arm64.dmg.from-1.2.2.delta.
Its checksum is read from the same checksums file and its signature from
<delta name>.minisig. Delta files are never picked as the main asset. To
chain deltas attached to earlier releases, let the source see them:
let source = new
.asset_contains.asset_contains
.with_checksums
.with_minisig
.delta_history; // still one API request
In a static manifest, list deltas on the asset they rebuild:
from and url are required. to defaults to the manifest's version; set
it to list deltas between earlier releases for chaining. format defaults to
files-bsdiff-v1. A delta in an unknown format is ignored, so newer formats
can be published next to older ones. Deltas take the same sha256,
signature, signature_url, and ed25519_signature fields as assets, and
the same policy applies: under Strict, a delta without a checksum and a
signature you can check is skipped in favour of the full download.
AppcastSource lists no deltas, so appcast feeds always get the full archive.
Measured on OpenLogi macOS arm64 releases (19.7 MB .dmg, 51.4 MB unpacked):
| Upgrade | Delta | Share of full download |
|---|---|---|
| 0.8.12 → 0.8.13 | 1.42 MB | 7.2% |
| 0.8.11 → 0.8.13 | 2.36 MB | 12.0% |
| 0.8.11 → 0.8.12 → 0.8.13 (chain, not chosen) | 3.50 MB | 17.8% |
Compared with plain bsdiff patches (bzip2-compressed), LZMA saved 18% and
normalizing Mach-O code signatures another 6%.
Generating a delta took about 18 s with 4 worker threads and a peak of about
1 GB of memory, and the output is identical on every run. Downloading and
applying it locally took under a second. The installed result was identical
to the full 0.8.13 bundle, and codesign --verify --deep --strict and
Gatekeeper (spctl, "Notarized Developer ID") both accepted it.
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). - Display fields — the item's
<pubDate>(RFC 2822) becomesRelease::published_at, and its<link>becomesRelease::release_urlwhen it is anhttp(s)URL. - Not supported — Sparkle's own delta updates (
sparkle:deltas), 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.
Installs a package manager owns
An app that Homebrew, apt, or Scoop installed should not replace itself. The
package manager then reports a version it did not install, and its next
upgrade can undo the update. Call install::ownership (or
UpdateEngine::ownership) at startup. When it is not
Ownership::SelfUpdate, show how to update instead of an update button.
use ;
// A distribution that builds the app sets MYAPP_UPDATE_EXPLANATION, as Zed's
// packagers set ZED_UPDATE_EXPLANATION.
let hints = new.explanation;
let owner = current_install_root
.map_or;
if !owner.is_self_update
The check does not block downloads; the app decides. It looks, in order, at:
| Source | Recognized as |
|---|---|
OwnershipHints::explanation, when not blank |
Ownership::Packager(text) |
/nix/store/… |
Nix |
/snap/<name>/… |
Snap |
/app/… with FLATPAK_ID, or …/flatpak/app/<id>/… |
Flatpak |
<prefix>/Cellar/<formula>/…, prefix from HOMEBREW_PREFIX or the defaults |
Homebrew |
…\scoop\apps\<app>\…, or under SCOOP / SCOOP_GLOBAL |
Scoop |
<ChocolateyInstall>\lib\<package>\… |
Chocolatey |
…\Microsoft\WinGet\Packages\<id>_<source>\… |
winget |
C:\Program Files\WindowsApps\… |
Microsoft Store |
Contents/_MASReceipt/receipt in the bundle |
Mac App Store |
A Caskroom/<token>/<version>/<App>.app symlink back to the app |
Homebrew cask |
dpkg-query --search, then rpm -qf, then pacman -Qo (Linux) |
apt, rpm, pacman |
The Linux queries run with LC_ALL=C for at most two seconds each. A missing
command, a timeout, or an unreadable folder does not claim the install, so
the result is then SelfUpdate. Turn the queries off with
OwnershipHints::query_package_database(false).
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 Downloading → u.cancel(cx)
// when status is Staged → u.restart(cx)
UpdateStatus: Idle → Checking → {UpToDate | Available(v)} → Downloading { downloaded, total } → Installing → Staged(v) | Errored(error).
Downloading carries live byte counts (total is None when the server omits
Content-Length).
UpdateStatus is #[non_exhaustive], so match it with a wildcard arm.
Errored carries an UpdateError. Its Display is the message to show, and
kind() returns an ErrorKind that tells the UI what to do:
ErrorKind |
Meaning | Typical UI |
|---|---|---|
Network |
DNS, connect, timeout, or a non-success HTTP status | "Try again later" |
Metadata |
The release metadata is malformed or lists no artifact for this platform | "Contact the developer" |
Verification |
A checksum, signature, TLS, or policy check failed | Security warning; do not offer a retry |
Location |
The app runs from a disk image or an App Translocation path | "Move the app to Applications" |
Permission |
The user may not write where the update must go | "Ask an administrator" or a manual download |
Install |
Installing failed for another reason | Manual download link |
Unsupported |
No install strategy for this platform or artifact | Manual download link |
Cancelled |
The operation was cancelled on request | Nothing |
Other |
Anything else | Show the message |
The engine's Error::kind() returns the same classification.
Location and Permission come before anything is downloaded.
UpdateEngine::download first calls UpdateEngine::check_location, which
rejects an App Translocation path on macOS. It then creates and removes a
probe file next to the install root: installing replaces the app from inside
that folder. A Windows .msi skips the probe, because msiexec elevates
itself. Call check_location yourself to tell the user before offering the
download; install::check_location does the same for any path.
cancel stops a download. The status returns to Available(v), not
Errored, and the partial file is removed. The download stops at its next
checkpoint: after each 64 KiB of the body, during a retry wait, or while it
waits for response headers. A body read that has stalled can take up to the
30-second stall timeout. An install that has started cannot be cancelled.
Without the entity, pass a CancelToken to
UpdateEngine::download_cancellable.
Keep the entity alive while updating. Dropping it cancels a running download,
as cancel does, but does not interrupt an install that has started 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. Instead,UpdateEngine::check_locationreports the missing permission asErrorKind::Permissionbefore anything is downloaded. Sparkle handles this case with its installer, which is one reason to usegpui-updater-sparkle. - Linux app directories:
current_install_rootcannot know that the executable belongs to a larger directory, so setEngineConfig::install_rootto that directory. The.tar.gzmay hold the directory itself, a single wrapper folder such asapp-1.2.0/, or the directory's contents. When the running executable is inside the directory, the update must hold an executable at the same relative path, and that path is the restart path. A symlink to the directory is kept, and the directory it points to is replaced. - Linux
AppImage: the root is$APPIMAGEonly when the running executable is inside$APPDIR. A program started from anotherAppImage, such as a terminal, inherits both variables; this check keeps it from replacing that otherAppImage. - Linux has no bundle metadata or platform code signature to check, so
BundleVersionandCodeSigningdo not apply there, and on Windows onlyCodeSigningdoes. Sign your artifacts and bind signatures to versions instead. - 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"
The delta feature adds qbsdiff and a pure-Rust bzip2 (both only used to
create deltas; applying them uses this crate's own bounds-checked bspatch) and
the pure-Rust lzma-rust2.
qbsdiff's suffix sorting builds a small C library (cdivsufsort), so the
feature needs a C compiler, as ring already does.
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.4.0 first; after it is available in
the registry, publish gpui-updater, gpui-updater-pre and
gpui-updater-sparkle 0.4.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.4.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.