oriel

Updater

In-app updates for desktop apps: an Ed25519-signed manifest, a verified download, an atomic swap of the executable, AppImage or .app bundle, and a restart.

oriel.updater lets an installed desktop app update itself. You publish a signed manifest (latest.json) next to your release assets; the app fetches it, checks the Ed25519 signature against the public key built into it, downloads the artifact the manifest names, verifies its size and SHA-256, swaps it atomically for the running executable (or AppImage, or .app bundle), and restarts.

The page gets three commands (updater_check, updater_install, updater_restart) and a progress event; nothing the page sends can change the manifest URL, the key or the file being replaced. Use the updater for apps you distribute yourself (GitHub releases, your own server). Apps from a store (Google Play, the App Store, Microsoft Store via MSIX) are updated by the store. The oriel CLI updates itself with the same engine (oriel update).

Availability

PlatformCheckInstallFormatsNotes
Linux✓✓raw, raw.gz, gzip, appimage, appimage.gzRunning from an AppImage, the outer .AppImage file is replaced.
Windows✓✓raw, raw.gz, gzipThe running .exe is renamed to <exe>.old, removed at the next start.
macOS✓✓raw, raw.gz, gzip, app.tar.gzapp.tar.gz replaces the whole .app bundle.
Android✓fails (Unsupported)Off by default (.updater = true to turn on): the store updates the app; checking can still tell the user a release is out.
iOSNot available: the App Store or TestFlight updates the app (oriel.updater.available is false).

The module is the Oriel dependency’s updater flag (on by default except on Android). It needs no permission. Manifest and artifact URLs must be https://.

Quick start

  1. Make a key pair, once, and keep the private key out of the repository:
zig build keygen -- --name my-app
# Private key written to: ~/.config/oriel/keys/my-app.key (mode 0600)
# Public key (base64): 1DqVHiw6bT4PjHafhfXcXS9jyw+XOPNwTllXlfHLtFQ=
  1. Put the public key in build.zig:
_ = oriel.addApp(b, dep, .{
    .name = "my-app",
    .root_source_file = b.path("src/main.zig"),
    .frontend = .{ .dir = "frontend" },
    .package = .{ .id = "dev.example.MyApp", .version = "1.0.0" },
    .update_public_key = "1DqVHiw6bT4PjHafhfXcXS9jyw+XOPNwTllXlfHLtFQ=",
});
  1. Add the commands, and the progress event, in src/main.zig:
const std = @import("std");
const oriel = @import("oriel");
const app = @import("oriel_app");

const Updater = oriel.updater.Commands(.{
    .app_id = "dev.example.MyApp",
    .manifest_url = "https://github.com/example/my-app/releases/latest/download/latest.json",
    .current_version = "1.0.0", // keep in step with .package.version
    .public_key_b64 = app.update_public_key orelse @compileError("set update_public_key in build.zig"),
});

pub const Commands = struct {
    pub const updater_check = Updater.updater_check;
    pub const updater_install = Updater.updater_install;
    pub const updater_restart = Updater.updater_restart;
    pub const async_commands = Updater.async_commands;
};

pub const Events = struct {
    @"updater://progress": struct { downloaded: u64, total: ?u64 },
};

pub fn main(init: std.process.Init) !u8 {
    return oriel.main(init, .{ .commands = Commands, .events = Events }, .{
        .app = app.build,
        .window = .{ .title = "My App" },
    });
}
  1. Call them from the page:
import { invoke, listen } from "./oriel";

const update = await invoke("updater_check");
if (update.available) {
  const stop = listen("updater://progress", ({ downloaded, total }) => {
    progress.value = total ? downloaded / total : 0;
  });
  await invoke("updater_install");
  stop();
  await invoke("updater_restart"); // the app restarts as the new version
}
  1. On each release, sign every platform’s artifact and publish one latest.json (below).

How it works

sequenceDiagram
  participant P as Page
  participant U as updater Commands (worker thread)
  participant S as Release server
  participant FS as Install directory
  P->>U: invoke("updater_check")
  U->>S: GET manifest_url (64 KiB max, 15 s)
  S-->>U: latest.json
  U->>U: pick platforms[target], verify Ed25519,<br/>app_id, target, expires, version > current
  U-->>P: { available, version }
  P->>U: invoke("updater_install")
  U->>S: GET url (3 redirects, 10 min)
  S-->>U: artifact bytes
  U-->>P: event updater://progress (at most every 100 ms)
  U->>FS: temp file next to the target, SHA-256 and size checked, fsync
  U->>FS: gunzip if *.gz, then atomic rename (or bundle swap)
  U-->>P: true
  P->>U: invoke("updater_restart")
  U->>FS: exec the new binary with the same arguments

The engine is src/updater_core.zig, which needs neither GTK nor an App (the oriel CLI uses it too); src/modules/updater.zig wraps it with the page’s commands and the progress event. Manifest parsing, signing and verification are src/modules/update_manifest.zig (standard library only). The per-platform parts (replacing a file, restarting) are src/modules/updater/linux.zig, windows.zig, macos.zig and android.zig.

Startup. oriel.main sets the updater up on the main thread: it captures $APPIMAGE and $APPDIR (worker threads never read the environment) and, on Windows, deletes a <exe>.old left by the previous update. Apps don’t call anything for this.

Check. updater_check runs on a worker thread (it is an async command). It fetches the manifest with a deadline, retrying a 5xx answer twice (after 2 s and 5 s: GitHub’s download URLs answer 500 for a few minutes after a release is published). A combined manifest’s entry for this build’s target is selected, and its signature verified over the canonical bytes (format). The entry must name the configured app_id and the target it is filed under, must not be expired, and must be newer than current_version (semver). The verified update is kept in the module’s state; the page only learns { available, version }.

Install. updater_install downloads the kept update into a temporary file next to the file it replaces (same directory, so the final rename is atomic), streaming it through SHA-256 and refusing more bytes than the signed size. After the hash matches it is synced to disk; a .gz payload is expanded (at most 512 MiB) into a second temporary file; then the platform backend swaps it in:

  • Linux and macOS (files): rename(2) over the target, then the directory is synced. The target’s mode is kept (0755 when new). Symlinks are resolved first, so the real file is replaced.
  • Windows: MoveFileExW replaces the file; when the running executable is locked, it is renamed to <exe>.old and the new file moved into place (rolled back if that fails). .old is deleted at the next start.
  • macOS bundles (app.tar.gz): the tar is extracted into a staging directory next to the running .app (symlinks preserved, size bounded), which must contain exactly one <Name>.app with the same executable; renameatx_np(RENAME_SWAP) swaps the two atomically and the old bundle is deleted.

The signed format must match how the app is installed: an AppImage is only replaced by an appimage payload, and a plain executable never by one (error.FormatMismatch).

Restart. On Linux the process execs the new file with the original arguments (from /proc/self/cmdline). On Windows a new process is created suspended with the original command line, the single-instance lock is released, and the new process resumed. A macOS bundle is relaunched with open -n <bundle>; a plain macOS executable is replaced like on Linux.

Examples

Sign a release for every platform (CI)

After building each platform’s artifact, sign each one for its target and merge the manifests. This is GhostPen’s release job, shortened:

V=1.2.0
BASE=https://github.com/example/my-app/releases/download/v$V
printf '%s' "$UPDATE_KEY" > key && chmod 600 key   # the private key, from a CI secret

sign() { # <file> <target> <format>
  zig build sign-update -- "dist/$1" --app-id dev.example.MyApp --version "$V" \
    --target "$2" --format "$3" --url "$BASE/$1" --key key --out "dist/update-$2.json"
}
sign my-app-$V-x86_64.AppImage   x86_64-linux   appimage
sign my-app-x86_64-windows.exe   x86_64-windows raw
sign MyApp-aarch64-macos.app.tar.gz aarch64-macos app.tar.gz

zig build combine-manifests -- dist/update-*.json \
  --pub-date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --out dist/latest.json
rm key

Upload latest.json with the release. With .../releases/latest/download/latest.json as manifest_url, every installed copy sees the newest release.

A macOS bundle update

Tar the signed and notarized package, not oriel build’s ad-hoc zig-out/<Name>.app:

oriel package -Dmacos-sign-identity="Developer ID Application: ..." -Dmacos-notarize-profile=notary
cd zig-out/package && COPYFILE_DISABLE=1 tar -czf MyApp-aarch64-macos.app.tar.gz MyApp.app

COPYFILE_DISABLE=1 keeps macOS’s ._ metadata files out of the tar (they are skipped anyway). Sign it with --format app.tar.gz.

Your own flow in Zig

The commands are a convenience; the engine is public. Check at startup and emit your own event, for example:

const updater = oriel.updater;
// Events declares `update_ready: struct { version: []const u8 }`.
const events = oriel.App.events(Events);

fn config() updater.Config {
    return .{
        .app_id = "dev.example.MyApp",
        .manifest_url = "https://example.com/my-app/latest.json",
        .current_version = "1.0.0",
        .public_key_b64 = app.update_public_key.?,
    };
}

/// On a worker thread: blocks on the network.
fn checkAndInstall(io: std.Io, gpa: std.mem.Allocator) !void {
    var update = (try updater.checkForUpdate(io, gpa, config())) orelse return;
    defer update.deinit();
    const path = try updater.download(io, gpa, update, .{ .progress = .{ .callback = onProgress } });
    defer gpa.free(path);
    events.emit(.update_ready, .{ .version = update.version });
}

fn onProgress(_: ?*anyopaque, downloaded: u64, total: ?u64) void {
    updater.emitProgress(downloaded, total);
}

Restart later with updater.restart(io, path). GhostPen (src/updates.zig) works this way, with a build option to point the updater at a test manifest.

Reference

oriel.updater.Commands

pub fn Commands(comptime config: Config) type

Checked at compile time: manifest_url starts with https://, public_key_b64 decodes to a 32-byte key, app_id isn’t empty. The returned struct has async_commands (all three) and:

CommandPage callReturnsErrors
updater_checkinvoke("updater_check"){ available: boolean, version: string | null }UpdaterBusy, and the check’s errors
updater_installinvoke("updater_install")trueUpdaterBusy, NoUpdatePending (no successful check first), FormatMismatch, and the download’s errors
updater_restartinvoke("updater_restart")doesn’t return on successUpdaterBusy, CannotReadCmdline, CmdlineTooLarge, TooManyArguments, RestartFailed, Unsupported

A failed command rejects the page’s promise with the error’s name. One operation runs at a time: another command while one is in progress fails with UpdaterBusy. updater_install consumes the update kept by the last updater_check; updater_restart restarts the installed file, or the current executable when nothing was installed.

CheckResult is struct { available: bool, version: ?[]const u8 = null }.

Event

EventPayloadWhen
updater://progress{ downloaded: u64, total: ?u64 }During updater_install, at most every 100 ms, and once at the end. total is the signed size.

Declare it in Events (as in the quick start) for a typed listen. oriel.updater.emitProgress(downloaded, total) emits it from your own code.

Config

FieldTypeDefaultMeaning
app_id[]const u8requiredMust equal the manifest’s app_id (use package.id).
manifest_url[]const u8requiredhttps:// URL of a combined or single-platform manifest.
current_version[]const u8requiredThe installed version (semver; a leading v is ignored).
public_key_b64[]const u8requiredThe 44-character base64 Ed25519 public key (oriel_app.update_public_key).
target[]const u8DEFAULT_TARGETThe manifest entry to use: "<arch>-<os>" of this build (x86_64-linux, aarch64-macos). Set it for variant builds ("x86_64-linux-cuda").
timeout_msu3215_000Deadline for fetching the manifest.
download_timeout_msu32600_000Deadline for the artifact download.
forceboolfalseAlso offer the current version (a reinstall).
allow_downgradeboolfalseOffer any version the manifest names. Only for an explicit request for one version, whose manifest the caller then checks names exactly that version; otherwise a replayed old manifest downgrades.
allocator?Allocatorstd.heap.smp_allocatorAllocator for the update state.

Zig API

DeclarationMeaning
available: booltrue where the module is built.
checkForUpdate(io, gpa, config) !?UpdateFetch and verify the manifest; null when nothing newer. Blocks: call it off the main thread.
download(io, gpa, update, options) ![]u8Download, verify and install update; returns the installed path (caller frees). options is a DownloadOptions.
restart(io, exe_path) !noreturnReplace this process with exe_path (see How it works).
resolveDestPath(io, gpa, dest_path) ![]u8The file an update replaces: dest_path (must be absolute), else $APPIMAGE when running from it, else the executable.
runningAsAppImage(io, gpa) !bool$APPIMAGE set and the executable under $APPDIR.
emitProgress(downloaded, total) voidEmit updater://progress.
UpdateA verified update: the manifest’s fields and an arena; deinit() frees it.
DownloadOptions{ dest_path: ?[]const u8 = null, progress: ?ProgressCallback = null, timeout_ms: u32 = DEFAULT_DOWNLOAD_TIMEOUT_MS }. dest_path null: the running executable, or $APPIMAGE.
ProgressCallback{ context: ?*anyopaque = null, callback: *const fn (?*anyopaque, u64, ?u64) void }.
DEFAULT_TARGET, DEFAULT_TIMEOUT_MS, DEFAULT_DOWNLOAD_TIMEOUT_MSConstants.
check(gpa, ctx) !CheckThe module’s self-test (oriel doctor, examples/smoke).

Manifest format

A single-platform manifest (oriel-update-v2), as sign-update writes it:

{
  "app_id": "dev.example.MyApp",
  "version": "1.2.0",
  "target": "x86_64-linux",
  "format": "appimage",
  "size": 9437184,
  "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
  "url": "https://github.com/example/my-app/releases/download/v1.2.0/my-app-1.2.0-x86_64.AppImage",
  "expires": null,
  "signature": "base64 Ed25519 signature, 88 characters"
}
FieldRules
app_id, target, formatNon-empty, no control characters.
versionSemver (1.2.0, 1.2.0-rc.1).
formatraw, raw.gz, gzip, appimage, appimage.gz, app.tar.gz. A .gz or gzip payload is expanded; app.tar.gz is a bundle.
sizeBytes of the artifact as downloaded; greater than 0, at most 512 MiB.
sha25664 hex characters (compared case-insensitively).
urlhttps://.
expiresOptional Unix time; after it the manifest is refused (ManifestExpired), and so is a clock at or before 1970 (InvalidSystemTime). Not 0.
signatureEd25519 over the canonical bytes below.

The signature covers, exactly:

"oriel-update-v2\n" app_id "\n" version "\n" target "\n" format "\n" size "\n" sha256 "\n" url "\n" expires "\n"

with size in decimal and expires in decimal, or 0 when absent. Every field is signed, so a manifest can’t be retargeted (another platform), repointed (another URL) or replayed under another app id.

A combined manifest (latest.json, what combine-manifests writes) holds up to 32 single-platform manifests with the same app_id and version:

{
  "app_id": "dev.example.MyApp",
  "version": "1.2.0",
  "pub_date": "2026-10-12T09:00:00Z",
  "notes": "Faster startup.",
  "platforms": {
    "x86_64-linux": { "app_id": "...", "target": "x86_64-linux", "signature": "..." },
    "x86_64-windows": { "...": "..." },
    "aarch64-macos": { "...": "..." }
  }
}

The top-level fields are informational; the app verifies only the entry under its own target, and refuses an entry whose signed target differs from its key (TargetMismatch). A single-platform manifest works as manifest_url too.

keygen, sign-update, combine-manifests

Steps addApp registers for desktop targets, running update_tool (tools/update_tool.zig); arguments go after --.

zig build keygen -- [options]:

OptionDefaultMeaning
--name <name>orielKey file names: <name>.key, <name>.pub.
--out-dir <dir>$XDG_CONFIG_HOME/oriel/keys, else ~/.config/oriel/keysDirectory (created 0700).
--key <path>, --pub <path>Exact file paths instead.
--forceOverwrite an existing private key (also a corrupt one).

The private key file holds the base64 32-byte seed (mode 0600); the public key file and the output hold the base64 public key. Files are written through a temporary file and renamed.

zig build sign-update -- <artifact> --app-id <id> --version <ver> --url <url> --key <file> [options]:

OptionDefaultMeaning
--target <arch-os>the build machine’sThe manifest’s target. Always pass it in CI.
--format <fmt>from the file name: .AppImage → appimage, .gz → raw.gz, else rawPass app.tar.gz explicitly for macOS bundles.
--size <bytes>the file’s sizeMust equal the file’s size.
--expires <unix seconds>noneSigned expiry.
--out <file>stdout onlyAlso write the manifest here.
--artifact <file>Instead of the positional argument.

A leading v in --version is dropped. The manifest is printed to stdout.

zig build combine-manifests -- <manifest.json>... --out <latest.json> [--pub-date <RFC 3339>] [--notes <text>]: merges manifests of one app id and version, one per target (a duplicate target, or a different app id or version, is an error; at most 32). Signatures are checked for form only; clients verify each entry.

oriel update: the CLI’s own updates

oriel update updates the oriel CLI with the same engine; it isn’t for apps.

OptionMeaning
--checkReport whether a newer release exists; install nothing.
--version <tag>Install that release (v1.2.3, 1.2.3-rc.1), older or the same included; its manifest must name exactly that version.
--yesDon’t ask; required without a terminal.

It reads <releases>/download/<latest tag>/latest.json from https://github.com/highercomve/Oriel/releases (app id dev.oriel.cli), verifies it against the key the CLI was built with (Oriel’s -Dupdate-public-key), and replaces the running binary. ORIEL_RELEASES_URL points it at another releases URL (<url>/latest/download/latest.json), and ORIEL_UPDATE_TARGET installs to another path; both exist for tests.

Limits

WhatLimit
Manifest64 KiB, 15 s (timeout_ms)
Artifact download10 minutes (download_timeout_ms), 3 redirects, exactly the signed size
Expanded payload512 MiB (gzip and app.tar.gz)
Retries2 on a 5xx answer (2 s, 5 s), before anything is written
Platforms per latest.json32
Restart arguments16 KiB of command line, 256 arguments (Linux)

Errors

ErrorMeaning
InsecureUrlA manifest or artifact URL isn’t https://.
Timeout, BadHttpStatus, ManifestTooLargeThe manifest couldn’t be fetched.
InvalidManifest, InvalidFormat, InvalidSemver, InvalidSize, InvalidSha256Length, InvalidSha256Hex, EmptyField, ControlCharactersInField, InvalidExpirationThe manifest is malformed.
InvalidPublicKeyLength, InvalidSignatureLength, InvalidPaddingA key or signature isn’t valid base64 of the right length.
SignatureVerificationFailedThe signature doesn’t match: tampered, or another key.
TargetNotInManifestlatest.json has no entry for this target.
AppIdMismatch, TargetMismatchThe entry is for another app or platform.
ManifestExpired, InvalidSystemTimeexpires passed, or the clock is unset.
FormatMismatchAn AppImage payload for a non-AppImage install, or the reverse.
PayloadTooLarge, PayloadSizeExceeded, PayloadSizeMismatch, PayloadHashMismatch, UpdateTooLargeThe download doesn’t match the manifest, or expands past 512 MiB.
DestinationPathNotAbsoluteAn explicit dest_path is relative.
NotInAppBundle, InvalidAppBundle, UnsupportedFormatapp.tar.gz for an app not in a bundle, a tar without exactly one .app with the same executable, or on another OS.
RenameRunningToOldFailed, RenameTmpToTargetFailed, RenameFailedThe swap failed (Windows rolls back).
UnsupportedInstalling or restarting on Android.

Security

  • The manifest URL, the public key and the replaced file are fixed in Zig; the page can only ask to check, install and restart.
  • TLS only: http:// is refused outside test builds, for the manifest and the artifact.
  • Every manifest field is signed with a domain-separated prefix; size and SHA-256 are checked before anything replaces the app, and the swap is atomic, so a failed update leaves the old version in place.
  • No downgrades unless asked for (allow_downgrade); expires limits how long a captured manifest can be replayed.
  • Keep the private key out of the repository and the app: a CI secret, written to a 0600 file for the signing step and deleted after.

Migrating from 0.11

  • oriel_app.update_public_key is unchanged; the other oriel_app declarations moved into oriel_app.build (Building).
  • download(io, gpa, update, dest_path, progress) and downloadWithOptions(..., timeout_ms) are one download(io, gpa, update, .{ .dest_path, .progress, .timeout_ms }).
  • updater.init and updater.deinit are gone from the API: oriel.main sets the updater up; delete your own init call.
  • Config.allow_http_for_test, setTestDestOverride, setMockExecFn, MockServer and test_payload_gz were Oriel’s test hooks and are no longer public.
  • verifyManifest, verifyManifestWithOptions, unpack, splitCmdline, decideDestPath, isUnderDir, Manifest, Semver, State, MAX_CMDLINE_LEN, MAX_ARGV_COUNT, core, backend and update_manifest are internal now; checkForUpdate verifies the manifest.

Troubleshooting

  • FormatMismatch from an AppImage: the artifact was signed as raw; sign AppImages with --format appimage (the default for .AppImage file names).
  • TargetNotInManifest: latest.json has no entry for this build’s "<arch>-<os>". Sign with --target for each platform; a variant build (CUDA) needs its own Config.target and entry.
  • SignatureVerificationFailed for every release: the app was built with another public key than the one matching your private key; compare update_public_key with the key’s .pub.
  • available is false although a release is out: current_version isn’t lower than the manifest’s version; check it follows .package.version.
  • Windows: a .old file next to the executable: the previous process still held it at startup; the next start removes it.
  • macOS: the updated app asks for permissions again: the release is ad-hoc signed; sign every release with the same certificate (Signing).
  • Right after publishing, checks fail with BadHttpStatus: GitHub answers 5xx for a few minutes; the updater retries twice, then gives up until the next check.

Related

  • Signing: code signing, which updates on macOS and Windows also need.
  • Packaging: the AppImage, executables and bundles you publish.
  • Commands: how invoke, async commands and events work.
  • Building: update_public_key and the oriel_app module.

Search documentation

Type to search the documentation.