Skip to content

Auto-Updater

The auto-updater service (src/services/updater.rs) checks for new versions, downloads them, verifies Ed25519 signatures and macOS codesign, and swaps the binary on next launch. It runs on a 4-hour periodic timer and dispatches notifications when updates are available.

This page covers the update lifecycle, manifest format, signature verification, configuration, and the release pipeline. For the notification system that delivers update prompts, see Notifications. For the CI workflow, see CI/CD for Rust desktop apps.

The updater follows a state machine with seven states:

Idle -> Checking -> Available -> Downloading -> Downloaded -> ReadyToInstall
| |
v v
(on failure) (next launch swap)
|
v
Error

Each state is represented by UpdateStatus:

pub enum UpdateStatus {
Idle,
Checking,
UpToDate,
Available { version: String, notes: String },
Downloading { progress: u32 }, // 0-100
Downloaded { version: String, path: String },
ReadyToInstall,
Error(String),
}

The current state is stored as a GPUI global (UpdateSnapshot). Any component can read it with snapshot(cx). The status bar in src/shell/status_bar.rs uses this to display update indicators.

The updater checks on three triggers:

  1. Startup: 5 seconds after initialize(cx) is called during app init.
  2. Periodic: Every 4 hours, but only if the current status is Idle, UpToDate, or Error. Checks are skipped while a download is in progress.
  3. Manual: Dispatch the CheckForUpdates action from a button or menu item.

Before each check, the updater verifies network connectivity. If the app is offline, the check fails immediately and retry backoff kicks in.

Call initialize during app startup, after the Tokio runtime global is installed:

services::updater::initialize(cx);

This installs the UpdateSnapshot global, registers the CheckForUpdates action handler, and schedules both the startup and periodic check loops.

You can trigger a check programmatically:

use crate::services::updater;
// From an action handler or button callback:
updater::check_for_updates(cx);
// Or dispatch the action:
cx.dispatch_action(Box::new(updater::CheckForUpdates));

The check is guarded: if the status is already Checking or Downloading, the call is a no-op. On a successful check, the manifest version is compared against the current app version using semver. If newer, the status moves to Available and a notification is dispatched.

Once an update is available, call download_update to start the download:

updater::download_update(cx);

The download streams the asset to a temp directory with progress reporting (updates every 10% change). After download, three verification steps run:

  1. Ed25519 signature: Verifies the manifest’s base64 signature against the SHA-256 hash of the downloaded file using the hardcoded public key. Failure deletes the download.
  2. macOS codesign: Runs codesign --verify --deep --strict on macOS. Failure rejects the download.
  3. Size audit: The downloaded byte count is logged.

After verification, call apply_update(cx) to schedule the swap. This writes a pending swap marker to the app data directory. On next launch, check_pending_swap(cx) reads the marker and replaces the binary or .app bundle.

On macOS, the updater detects .app bundles and replaces the entire bundle. For standalone binaries (or on Linux/Windows), the new binary is renamed over the current executable. The swap runs at startup before the event loop to avoid file locks.

The updater fetches a JSON manifest from a configurable URL:

{
"version": "0.3.0",
"release_notes": "Fixed crash on startup, added dark theme variants.",
"platforms": {
"macos-aarch64": {
"url": "https://github.com/myorg/gpui-app/releases/download/v0.3.0/GPUI-Starter-0.3.0.dmg",
"signature": "base64-encoded-ed25519-signature",
"size": 84926864
},
"linux-x86_64": {
"url": "https://example.com/app-linux.tar.gz",
"signature": "",
"size": 42318848
}
}
}

Platform keys use the pattern {os}-{arch}: macos-aarch64, linux-x86_64, windows-x86_64. The release_notes, platforms, and signature fields default to empty if omitted. An empty signature skips Ed25519 verification with a logged warning.

Set the manifest URL at compile time via the GPUI_UPDATE_MANIFEST_URL environment variable:

Terminal window
GPUI_UPDATE_MANIFEST_URL=https://releases.myapp.com/manifest.json cargo build --release

If not set, it defaults to https://releases.example.com/manifest.json. Set this env var in your build pipeline to point to your actual manifest.

Channels are stored in the persisted app config and read at startup:

// Read current channel
let snap = updater::snapshot(cx);
println!("Channel: {}", snap.update_channel);
// Change channel at runtime
updater::set_channel("beta", cx);

The default channel is "stable". The channel value is persisted in the config file and survives restarts. Your manifest server can use the channel to serve different versions to different user segments.

Failed checks and downloads retry up to 3 times with exponential backoff. The base delay is 30 seconds, so retries happen at 30s, 60s, and 120s. After 3 failures, the status moves to Error permanently and a notification is dispatched. Successful checks and downloads reset the retry counter.

ConstantValuePurpose
STARTUP_CHECK_DELAY_SECS5Delay before first check after launch
PERIODIC_CHECK_INTERVAL_SECS14400 (4h)Time between automatic checks
MAX_UPDATE_RETRIES3Max retry attempts for check or download
RETRY_BASE_DELAY_SECS30Base delay for exponential backoff

The updater verifies that downloaded assets were signed by you and not tampered with. The manifest includes a base64-encoded Ed25519 signature for each platform asset. The updater computes the SHA-256 hash of the downloaded file and verifies the signature against that hash using the embedded public key (src/services/updater_public_key.bin, a raw 32-byte file compiled in via include_bytes!).

Generate a key pair with Python’s cryptography package:

Terminal window
python3 -c "
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
key = Ed25519PrivateKey.generate()
pub = key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
open('updater_public_key.bin', 'wb').write(pub)
import base64; print(base64.b64encode(key.private_bytes_raw()).decode())
"

Place updater_public_key.bin at src/services/updater_public_key.bin. Store the printed base64 private key as the UPDATE_SIGNING_KEY GitHub secret.

The .github/workflows/release.yml workflow handles the full release cycle:

  1. Build: Compiles a release binary, creates a .app bundle, codesigns (or ad-hoc signs), and packages a DMG.
  2. Publish: Creates a GitHub Release with the DMG attached.
  3. Sign: scripts/generate-manifest.sh computes the asset SHA-256, signs it with UPDATE_SIGNING_KEY, and produces manifest.json.
  4. Deploy: Pushes manifest.json to the gh-pages branch.

Trigger a release by pushing a tag (git tag v0.3.0 && git push origin v0.3.0) or using the workflow dispatch.

The UpdateSnapshot global holds the current update state:

let snap = updater::snapshot(cx);
match &snap.status {
UpdateStatus::Available { version, notes } => {
// Show "Update available: v{version}" with notes
}
UpdateStatus::Downloading { progress } => {
// Show progress bar at {progress}%
}
UpdateStatus::Downloaded { version, .. } => {
// Show "Restart to install v{version}"
}
UpdateStatus::Error(err) => { /* show error */ }
_ => {}
}

The status bar (src/shell/status_bar.rs) renders these states. You can use it as a reference.