Embeddable program-update library for Zig host apps.
  • Zig 97.5%
  • Shell 2.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Mikael Säker 97f88bde08
All checks were successful
test / test (push) Successful in 31s
Downloads finish; progress has a total; the archive is removed
TeeWriter had no buffer and its drain skipped the buffered bytes, so a
reader asking for room got a drain of nothing back forever: a real HTTP
download spun at full CPU and wrote no byte. It is buffered now and
drains buffer[0..end] first, as std.Io.Writer requires, and the tee is
flushed before the hash is checked. Test streams through it.

An artifact's optional "size" is the download's progress total
(nothing set it). The helper deletes the spent archive after a swap.

Checked end to end on macOS: a 0.0.1 Courier.app checked a signed
manifest on a local server, downloaded, swapped to 0.0.2 and reopened;
a manifest changed after signing was refused before any download.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 10:50:28 +02:00
.forgejo/workflows Migrate to Zig 0.17 2026-10-06 12:01:10 +02:00
examples/demo Signed manifests; the helper removes the old install 2026-10-20 10:36:01 +02:00
scripts Migrate to Zig 0.17 2026-10-06 12:01:10 +02:00
src Downloads finish; progress has a total; the archive is removed 2026-10-20 10:50:28 +02:00
test Fixtures and demo: fetch from git.urverk.org, not the deleted codeberg repo 2026-10-06 12:48:24 +02:00
.gitignore scripts: setup-tools, the one every repo shares 2026-09-22 21:06:15 +02:00
build.zig Fixtures and demo: fetch from git.urverk.org, not the deleted codeberg repo 2026-10-06 12:48:24 +02:00
build.zig.zon Migrate to Zig 0.17 2026-10-06 12:01:10 +02:00
LICENSE Scaffold zupdate 2026-05-03 09:37:53 +02:00
README.md Downloads finish; progress has a total; the archive is removed 2026-10-20 10:50:28 +02:00

zupdate

Embeddable program-update library for Zig host apps. Pulls a JSON manifest, downloads + SHA-256-verifies the right artifact for the current target, then hands off to a small standalone helper binary that swaps the install in place after the host process exits and relaunches it.

Designed to be non-blocking — the host calls Updater.checkAsync or Updater.downloadAsync, then polls Updater.snapshot() each frame to render progress. Internal worker thread handles the I/O.

Status

Pre-0.1. macOS self-update works end-to-end — see examples/demo/ for a working CLI that updates itself against the fixtures in test/fixtures/. Linux + Windows apply paths are the next milestones — see milestones below.

Public API at a glance

const zupdate = @import("zupdate");

var updater = try zupdate.Updater.init(gpa, .{
    .gpa = gpa,
    .io = io,
    .manifest_url = "https://git.urverk.org/urverk/quote/raw/branch/main/MANIFEST.json",
    .current_version = "0.1.0",
    .target_triple = "aarch64-macos",
    .staging_path = "/tmp/quote-update.tar.gz",
    .helper_path = "/Applications/Quote.app/Contents/MacOS/zupdate-helper",
    .install_dir = "/Applications/Quote.app",
    .relaunch_argv = &.{"/Applications/Quote.app/Contents/MacOS/quote"},
});
defer updater.deinit();

try updater.checkAsync();

// Each frame:
switch (updater.snapshot()) {
    .idle, .checking => render_busy_indicator(),
    .no_update => render_up_to_date(),
    .update_available => |u| if (user_clicked_install) try updater.downloadAsync(),
    .downloading => |d| render_progress(d.bytes_done, d.bytes_total),
    .ready_to_apply => |r| if (user_clicked_restart) try updater.apply(),  // does not return
    .failed => |msg| render_error(msg),
}

Signed manifests

The manifest is the trust root: it pins each artifact's sha256, so it must be signed. Sign it with minisign or minizign and publish the signature beside it (manifest.json.minisig). The host embeds its release-signing public keys, a working key and an offline reserve, and passes them as trusted_keys:

.trusted_keys = &.{ @embedFile("keys/app-a.pub"), @embedFile("keys/app-b.pub") },

The check fetches manifest_url ++ ".minisig" (or signature_url) and verifies it with any trusted key before parsing the manifest. With no keys every check fails; allow_unsigned = true trusts HTTPS alone and is for tests and fixtures only. Call zupdate.parseKeys in a host test, so a key that does not parse, or a reserve that is the working key again, fails the build rather than the first update. (minisign.zig comes from reveal and quote, which carry their own copies today.)

Manifest format

A single JSON file the host hosts at a stable URL. For Forgejo/Gitea projects, committing a MANIFEST.json to main and fetching via https://<forge>/<user>/<repo>/raw/branch/main/MANIFEST.json works without API tokens or rate limits.

{
  "version": "0.2.0",
  "released": "2026-05-03",
  "notes_url": "https://git.urverk.org/urverk/quote/releases/tag/v0.2.0",
  "artifacts": {
    "aarch64-macos":  { "url": "...", "sha256": "...", "size": 6218173 },
    "x86_64-macos":   { "url": "...", "sha256": "..." },
    "aarch64-linux":  { "url": "...", "sha256": "..." },
    "x86_64-linux":   { "url": "...", "sha256": "..." },
    "x86_64-windows": { "url": "...", "sha256": "..." }
  }
}

version is SemVer-like (MAJOR.MINOR.PATCH with optional -prerelease). The library only offers an update when the manifest's version compares strictly greater than current_version.

size (optional) is the artifact's length in bytes; the download reports progress against it. sha256 is hex (lower-case, 64 chars). Compute with sha256sum / shasum -a 256 against the artifact bytes.

Architecture

zupdate/
  src/
    zupdate.zig         public API + Updater state machine
    manifest.zig        Manifest JSON parsing, SemVer compare
    download.zig        HTTP fetch + streaming SHA-256 (+ Progress)
    archive.zig         tar/unzip extraction (next milestone)
    apply.zig           platform-dispatched swap logic (next milestone)
    helper/main.zig     standalone updater binary

The helper binary (zupdate-helper) is what makes replace-while-running work cleanly across macOS/Linux/Windows. The host calls Updater.apply(), which spawns the helper with arguments describing what to do, then exits. The helper waits on the parent PID to be gone, swaps the install, and relaunches the host. Same pattern Sparkle and Squirrel.Windows use.

Build

zig build                     # builds the library module + zupdate-helper
zig build test                # unit tests (manifest parse, SemVer, sha256)
zig build test -Dnet=true     # also runs integration tests against the
                              # fixtures in test/fixtures, served by git.urverk.org

Requires Zig 0.17.0 or newer.

Io note

On Zig 0.16, std.http.Client failed to set up TLS when handed std.Io.Threaded.global_single_threaded (every fetch returned error.TlsInitializationFailed), so host apps had to pass a full Io.Threaded.init(allocator, .{}). On Zig 0.17 an HTTPS fetch through global_single_threaded works (checked 2026-10-06); a full Threaded is still fine to pass.

Milestones

  • M1 Scaffold — module layout, manifest parsing, version compare, SHA-256 helper, public API + State machine, helper-binary stub, unit tests
  • M2 HTTP — std.http.Client wired through; integration tests fetch fixtures over HTTPS; live byte counters via Progress atomics
  • M3 macOS apply + demo — helper does PID-wait → tar -xzf → atomic install-dir swap (with .OLD-<ts> backup) → spawn relaunch. examples/demo/ self-updates end-to-end against the fixtures
  • M4 Linux apply — same shape as macOS; mostly already works since the helper is POSIX-shaped, but needs validation + fixtures for aarch64-linux / x86_64-linux
  • M5 Windows apply — replace kill(pid, 0) with OpenProcess/WaitForSingleObject, swap via MoveFileEx, fixtures for x86_64-windows
  • M6 Quote integration — :update command-palette entry, dvui dialog UI, ship zupdate-helper in Quote.app

Optional/future:

  • Delta updates (per-file bsdiff patches like Sparkle)
  • Background scheduled checks (currently strictly user-triggered)
  • Rollback UX (the helper deletes the old install once the new one is in place: kept copies piled up, and Launch Services could open one)

License

MIT — see LICENSE.