Auto-Update
Ship updates to your Perry desktop app without rolling your own download + replace + relaunch flow. Two modules cooperate:
@perry/updater— high-level wrapper. The 90% case: manifest fetch, semver compare, download, verify, install, relaunch, crash-loop rollback.perry/updater— ambient primitives the wrapper is built on. Reach for these only when you need a custom flow (multi-channel rollouts, your own progress UI, an integration with an external supervisor).
The trust model and wire format follow Tauri’s updater:
JSON manifest over HTTPS, SHA-256 + Ed25519 signature over the digest, atomic
binary replace with a .prev backup, detached relaunch. Every snippet below
is excerpted from
docs/examples/updater/snippets.ts — CI
compile-links it on every PR.
Desktop only. iOS / TestFlight, Android Play Store, and sideloaded APKs own the install pipeline at the OS level — replacing your own binary at runtime is structurally impossible there. The crate still compiles on mobile targets so cross-platform code doesn’t need
#ifdefs, but the install path is a no-op. Gate updater code withprocess.platformif your app ships everywhere.
Quick start
// import { checkForUpdate, initUpdater, markHealthy } from "@perry/updater"
Drop a “Check for updates” handler somewhere in your menu or a periodic
timer. checkForUpdate returns null when up to date or when the manifest
has no entry for the current platform.
// Pseudocode using @perry/updater's wrapper. Drop into a "Check for updates"
// menu item or a periodic timer. The manifest URL must serve over HTTPS.
//
// const update = await checkForUpdate({
// manifestUrl: "https://updates.example.com/myapp.json",
// publicKey: "BASE64_ED25519_PUBKEY",
// currentVersion: "1.4.0",
// })
//
// if (update !== null) {
// console.log(`v${update.version} is available`)
// console.log(update.notes)
//
// await update.download((downloaded, total) => {
// const pct = Math.round((downloaded / total) * 100)
// console.log(`downloading: ${pct}%`)
// })
//
// await update.installAndRelaunch() // never returns — process.exit inside
// }
Call initUpdater() once near the top of main(). It handles boot-time
crash-loop detection: if the new binary you just installed crashes during
boot more than crashLoopThreshold times, the wrapper restores the previous
version and exits so the OS / launcher restarts you on the rollback.
// Boot-time: detect a crash-looping new install and roll back. Call this
// near the top of `main()`, right after process initialization.
//
// await initUpdater({
// autoRollback: true, // default
// healthCheckMs: 60_000, // clear sentinel after this many ms alive
// crashLoopThreshold: 2, // restarts before rollback fires
// })
//
// // Optional: tell the updater explicitly that this version is healthy
// // (e.g. after a successful login or migration finished).
// // markHealthy()
Manifest
Serve a single JSON file over HTTPS. One entry per <os>-<arch> you
publish for; clients ignore entries that don’t match their platform.
// The manifest is a single JSON file you serve over HTTPS. Each platform
// triple is `<os>-<arch>` (darwin-aarch64, darwin-x86_64, windows-x86_64,
// linux-x86_64, linux-aarch64). The wrapper picks the entry matching the
// running host and ignores the rest.
//
// {
// "schemaVersion": 1,
// "version": "1.4.0",
// "pubDate": "2026-04-27T10:00:00Z",
// "notes": "Bug fixes and performance improvements",
// "platforms": {
// "darwin-aarch64": {
// "url": "https://example.com/app-1.4.0-darwin-aarch64.bin",
// "sha256": "0123456789abcdef...",
// "signature": "base64sig==",
// "size": 12345678
// }
// }
// }
| Field | Meaning |
|---|---|
schemaVersion | 1 (legacy, digest-only signature) or 2 (recommended — version-bound signature; see below). |
version | Semver string of the offered version (e.g. "1.4.0"). |
pubDate | ISO-8601 timestamp the build was published — surfaced as metadata. |
notes | Markdown release notes shown to the user. |
platforms.<os>-<arch>.url | Direct download URL (HTTPS). |
platforms.<os>-<arch>.sha256 | Lowercase hex SHA-256 of the binary. |
platforms.<os>-<arch>.signature | Base64 Ed25519 signature. v1: over the raw 32-byte digest. v2: over digest || version_utf8. |
platforms.<os>-<arch>.size | Byte length of the binary — used for progress reporting. |
Platform keys are canonical Rust-style triples:
| Host | Triple |
|---|---|
| Apple Silicon mac | darwin-aarch64 |
| Intel mac | darwin-x86_64 |
| Windows 10/11 64-bit | windows-x86_64 |
| Linux 64-bit | linux-x86_64 |
| Linux ARM64 | linux-aarch64 |
Trust model
schemaVersion: 2 (recommended) — version-bound signature
The signed payload is SHA256(binary) || version_utf8 — the 32-byte
raw digest concatenated with the UTF-8 bytes of the version string.
This binds the version into the signature, so an on-path attacker
can’t replay a previously-signed older binary as a “new version” by
serving a manifest that pairs the old binary’s URL + signature with a
higher version number (#229).
Sign side:
payload = sha256(binary).digest() + version.encode("utf-8")
signature = ed25519_sign(secret_key, payload) # 64-byte signature
Verification on the client:
- SHA-256 the downloaded file. Reject if it doesn’t match
manifest.sha256. - Build the v2 payload (
digest || version_utf8) usingmanifest.version. - Ed25519-verify the signature against the bundled public key.
- Reject on any decode error, size mismatch, or signature failure.
If an attacker swaps the manifest’s version field while keeping the
old signature, step 3 fails because the signature was made over the
original version. If they swap both version and signature (using
a previously-signed older binary), step 1 fails because sha256 of
the older binary doesn’t match the rewritten higher-version label
either — every plausible attack on the version metadata invalidates
something.
schemaVersion: 1 (legacy, digest-only)
The signed payload is the raw 32-byte digest only. This shape is vulnerable to old-binary replay (#229): an on-path attacker can serve a manifest claiming a higher version while pointing at a previously-signed older binary, and signature verification still passes because the version isn’t bound into the signature. Existing v1 manifests stay supported by the client during migration; new deployments should use v2.
Migration
@perry/updater v0.5.391+ accepts both schemaVersion: 1 and
schemaVersion: 2. Bumping your manifest from 1 → 2 requires:
- Update sign-side tooling to compute the new payload
(
sha256(binary) + version.encode("utf-8")) and sign that. - Bump
schemaVersionin the manifest from1to2. - Make sure all deployed clients are running a perry-updater version
that knows about v2 BEFORE you publish a v2 manifest. Older clients
reject
schemaVersion: 2with anunsupported manifest schemaVersionerror. (Plan: ship a perry-updater bump with v2 support to your users via a v1 manifest first; once they’re on the v2-aware client, the next manifest can be v2.)
Keypair
You generate the keypair once and bake the public key into your app at build time; the secret key stays on a release-signing machine alongside the rest of your build artifacts. Compromise of the manifest server alone never lets an attacker push a binary your client will accept.
Sign-side CLI (v0.5.395+)
perry updater ships three subcommands that produce v2-shape signatures
without needing any custom tooling:
# 1) One-time keypair generation. Save kp.json with mode 0600 and
# NEVER commit the secret_key field.
perry updater keygen --output kp.json
# 2) Per-release signing. The output JSON envelope contains every
# manifest-entry field (sha256, signature, size, version, schemaVersion=2).
perry updater sign \
--binary perry-darwin-aarch64.tar.gz \
--version 1.2.3 \
--secret-key kp.json
# 3) Sanity-check the signature locally before uploading the manifest —
# this is the same algorithm the runtime uses, so a passing verify
# here predicts a passing verify on the client.
perry updater verify \
--binary perry-darwin-aarch64.tar.gz \
--version 1.2.3 \
--signature '<base64 from step 2>' \
--pubkey '<public_key from kp.json>'
Compose a final manifest by piping sign output through jq for each
asset, then merging into the per-platform layout shown above. CI tip:
pass --secret-key-b64 "$ED25519_SECRET_KEY" instead of --secret-key file so the secret can come from a repository secret without ever
hitting the worker’s filesystem.
Install + rollback flow
manifest fetch → semver compare → download to <exe>.staged
↓
sha256 verify → ed25519 verify
↓
arm sentinel (state: "armed")
↓
install: rename <exe> → <exe>.prev
rename <exe>.staged → <exe>
chmod +x (Unix only)
↓
detached relaunch → process.exit(0)
next boot → initUpdater() reads sentinel
├── healthCheckMs alive → clearSentinel (success)
├── graceful exit → clearSentinel (success)
└── restartCount ≥ N → performRollback + exit
installUpdate is atomic where the OS lets us be: POSIX rename(2) on the
same filesystem, NTFS rename-while-open (the PE loader opens with
FILE_SHARE_DELETE so Windows tolerates this since Vista), and Linux’s
mmap’d-inode-stays-alive semantics. If the staging directory ends up on a
different filesystem (a separate mount for /tmp, for instance) the rename
falls back to copy + remove, which has a small non-atomic window.
The sentinel is a JSON file at a per-OS user-writable path:
| Platform | Default location |
|---|---|
| macOS | ~/Library/Application Support/<app>/updater.sentinel |
| Windows | %LOCALAPPDATA%\<app>\updater.sentinel |
| Linux | $XDG_STATE_HOME/<app>/updater.sentinel |
<app> comes from the PERRY_APP_ID environment variable, falling back
to the basename of the running exe. Set PERRY_APP_ID in your launch
environment so the sentinel path stays stable across rename / relocation
of the binary.
Low-level primitives
Use these when the high-level wrapper doesn’t fit — custom progress UI, a multi-channel manifest, an external supervisor that handles restarts, etc.
import {
compareVersions,
verifyHash,
verifySignature,
computeFileSha256,
writeSentinel,
readSentinel,
clearSentinel,
getExePath,
getBackupPath,
getSentinelPath,
installUpdate,
performRollback,
relaunch,
} from "perry/updater"
compareVersions(current, candidate)
Returns -1 (update available), 0 (equal), 1 (downgrade — never offered),
or -2 (parse error). Prerelease tags handled per the semver spec.
// Returns -1 (current < candidate, update available), 0 (equal),
// 1 (current > candidate, never offered as an update), -2 (parse error).
const cmp = compareVersions("1.4.0", "1.4.1")
if (cmp === -1) {
console.log("update available")
} else if (cmp === 0) {
console.log("up to date")
}
verifyHash / verifySignature / computeFileSha256
// SHA-256 + Ed25519 verification of a binary on disk. The signed payload is
// the *raw 32-byte SHA-256 digest* — not the hex string and not the file
// bytes themselves. Sign side: `sha256(file) | ed25519_sign(secret_key)`.
const stagedPath = getExePath() + ".staged"
const expectedHex = "0123456789abcdef..." // from your manifest
const sigB64 = "...base64..." // 64-byte signature, base64
const pubB64 = "...base64..." // 32-byte public key, base64
if (verifyHash(stagedPath, expectedHex) !== 1) {
const actual = computeFileSha256(stagedPath)
console.error(`hash mismatch — expected ${expectedHex}, got ${actual}`)
}
if (verifySignature(stagedPath, sigB64, pubB64) !== 1) {
console.error("signature verification failed")
}
verifyHash and verifySignature return 1 on success, 0 on any failure
(file missing, decode error, mismatch). computeFileSha256 returns the hex
digest as a string, or "" on failure — useful for logging the actual hash
when a verifyHash mismatch fires.
installUpdate / performRollback / relaunch
// `installUpdate` atomically replaces `targetPath` with `stagedPath`,
// keeping the displaced version at `<target>.prev` for rollback.
const target = getExePath()
const staged = target + ".staged"
if (installUpdate(staged, target) !== 1) {
console.error("install failed")
} else {
const pid = relaunch(target)
if (pid < 0) {
console.error("relaunch failed; restart manually")
} else {
// Detached child is now running the new binary — get out of its way.
process.exit(0)
}
}
// `performRollback` restores `<target>.prev` over `target` and moves the
// current (likely-broken) target to `<target>.broken` as a safety net.
if (performRollback(getExePath()) !== 1) {
console.error("no backup to roll back to")
}
relaunch returns the child PID, or -1 on failure. The new process is
fully detached (setsid on Unix, DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
on Windows) so closing the current process doesn’t take it down.
Path resolution
// Resolved per platform. macOS walks up to the surrounding `.app` bundle;
// Linux honors $APPIMAGE; Windows / bare ELF returns the canonical exe.
console.log("running exe :", getExePath())
console.log("backup target :", getBackupPath())
// Sentinel path is keyed off PERRY_APP_ID — set this env var so the path
// stays stable across rename/relocation of the binary.
console.log("sentinel path :", getSentinelPath())
getExePath() accounts for platform quirks:
- macOS: walks up to the surrounding
.appbundle if applicable — the.appdirectory is the codesign unit, so that’s what you replace. - Linux: honors
$APPIMAGEwhen set. The AppImage runtime pointscurrent_exe()inside a read-only squashfs mount; the real target to replace is the AppImage file itself. - Windows / bare ELF / bare Mach-O: returns the canonicalized exe path.
Sentinel
// Low-level sentinel API. Most apps use `initUpdater()` from @perry/updater
// instead of touching this directly, but it's here when you need it (custom
// rollback policies, multi-process apps, integration with another supervisor).
const sentinelPath = getSentinelPath()
writeSentinel(sentinelPath, JSON.stringify({ state: "armed", restartCount: 0 }))
const raw = readSentinel(sentinelPath)
if (raw) {
const state = JSON.parse(raw) as { state: string; restartCount: number }
if (state.restartCount >= 2) {
// looks like a crash loop — recover or roll back
clearSentinel(sentinelPath)
}
}
writeSentinel is atomic (tmp file + rename), creates the parent directory
if needed, and returns 1 on success / 0 on any IO error.
clearSentinel is idempotent — returns 1 whether the file existed or not.
What’s not here (yet)
- UI primitives — a “Restart now” modal with
ProgressViewbelongs insideperry/uiproper rather than the updater package. Tracked as a follow-up. - Privileged install for system-wide locations (
/Applications,Program Files). The current install path only handles user-writable locations (~/Applications,~/.local/bin,%LOCALAPPDATA%). UAC /SMJobBlessis a separate concern. - Delta updates (bsdiff), multi-channel (stable / beta), staged rollouts.
- Notarization / code-signing during install. Binaries are expected to arrive already signed; the updater doesn’t try to be a notarization tool.
Testing your update flow
The crate ships smoke-test scripts that exercise verify → install → relaunch end-to-end against a real Perry binary:
- Unix:
scripts/smoke_updater.sh - Windows:
scripts/smoke_updater.ps1
Both spin up a tiny HTTP server, build a v1.0.0 binary that drives the update flow, build a v1.0.1 binary that proves it ran, and verify the relaunch handed off correctly. Run them locally before shipping a release that depends on the updater wiring.
Next Steps
- System APIs — the rest of
perry/system - HTTP & Networking —
fetch()is what the wrapper uses internally - Cryptography — Ed25519 sign / verify primitives for tooling that builds the manifest