- Zig 97.5%
- Shell 2.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
test / test (push) Successful in 31s
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> |
||
| .forgejo/workflows | ||
| examples/demo | ||
| scripts | ||
| src | ||
| test | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| LICENSE | ||
| README.md | ||
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.Clientwired through; integration tests fetch fixtures over HTTPS; live byte counters viaProgressatomics - 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)withOpenProcess/WaitForSingleObject, swap viaMoveFileEx, fixtures forx86_64-windows - M6 Quote integration —
:updatecommand-palette entry, dvui dialog UI, shipzupdate-helperinQuote.app
Optional/future:
- Delta updates (per-file
bsdiffpatches 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.