Skip to content

Add self-update to an app

An app needs two things before it can update itself: the URL of its release manifest and the minisign public key that signs it. Sign and publish a release makes both. The crate supplies no URL, path or host of its own; the app passes them in.

let exe = std::env::current_exe()?;
renew::clean_old_binary(&exe);

clean_old_binary removes a leftover <exe>.staging (an install that was cut off) or <exe>.old (the binary Windows parks during a swap). It never fails: a missing file is fine. Call it before install, because install refuses to start while <exe>.staging exists.

const MANIFEST_URL: &str = "https://example.com/app/manifest.json";
const RELEASE_KEY: &str = "RWQ…";
let check = renew::check(MANIFEST_URL, RELEASE_KEY, env!("CARGO_PKG_VERSION"))?;

check(url, public_key, current) fetches the manifest (at most 1 MiB) and its signature at the same URL with .minisig appended (at most 4 KiB), each with a 3 s timeout, verifies the signature over the exact manifest bytes, and only then reads it. It answers a Check:

  • update: the newest release newer than current, whatever its major, with this platform’s binary, or None;
  • new_major: the same release when it is a later major than current, or None.

check keeps no state, so call it as often as you like: at start-up, on a timer, or from a menu item.

if let Some(update) = check.update {
if check.new_major.is_some() {
println!("{} is a new major version", update.version);
}
renew::install(&update.binary, &exe)?;
renew::restart(&exe)?;
std::process::exit(0);
}

install(&binary, &exe) downloads to <exe>.staging, a file it creates itself with mode 0755, hashing as it goes, with a 180 s timeout. It checks the exact size and the sha256, then renames the file over exe in one step. On any failure it removes the staging file it made and the old binary stays where it was.

restart(&exe) spawns the new binary detached, with no stdin, stdout or stderr; the app exits after it.

When exe runs from a .app bundle (<Name>.app/Contents/MacOS/<exe>), the same two calls update the whole bundle, because a new executable inside a signed, notarised bundle breaks its signature. Publish the bundle zipped under platforms.macos-arm64.app (Sign and publish), and install downloads and checks the zip, unpacks it beside the running bundle, refuses it unless its code signature verifies and its Team ID and identifier match the running app’s, and swaps the bundles in one step. The running process carries on from the old bundle, kept as <bundle>.old until the next clean_old_binary; restart opens the new one with open -n.

If the user can’t write the bundle or its folder, such as an app owned by root in /Applications or one run from a disk image, install answers NotWritable before downloading anything: show that the app can’t update itself there. Formats lists every step. This path is tested only on GitHub’s macOS runners, without a notarised bundle.

The app keeps its own ceremony around these calls (a backup first, a prompt to the user, a quiet moment to swap) and picks when to make them.

Every failure is a RenewError whose message names what went wrong:

Variant Message
ManifestUnreachable the release manifest could not be reached
ManifestMalformed the release manifest did not parse
BadPublicKey the public key is not a minisign public key
BadSignature the release manifest’s signature does not verify
NoPlatformBinary no binary is published for this platform
DownloadFailed the download did not arrive whole
BadVersion a version string did not parse
StageFailed the downloaded binary could not be staged: …
VerifyFailed the downloaded binary failed verification: …
InsecureUrl refused a plain http url: only https, or http to this machine, is fetched
SwapFailed the new binary could not be swapped in: …
NoAppBundle this app runs from a macOS .app bundle and no bundle is published for it
NotWritable … is not writable by this user, so the app can’t update itself

A missing .minisig counts as ManifestUnreachable. None of them leaves a new binary in place: an app can show the message and carry on at its current version.

use std::error::Error;
use renew::Check;
const MANIFEST_URL: &str = "https://example.com/app/manifest.json";
const RELEASE_KEY: &str = "RWQ…";
fn update() -> Result<(), Box<dyn Error>> {
let exe = std::env::current_exe()?;
renew::clean_old_binary(&exe);
let Check { update, new_major } =
renew::check(MANIFEST_URL, RELEASE_KEY, env!("CARGO_PKG_VERSION"))?;
let Some(update) = update else {
return Ok(());
};
if new_major.is_some() {
println!("{} is a new major version", update.version);
}
println!("installing {}", update.version);
renew::install(&update.binary, &exe)?;
renew::restart(&exe)?;
std::process::exit(0);
}

The API reference lists every public item, including platform_key(), staging_path(), AppBundle and the timeout and size constants.