feat(updater): perry/updater subsystem — auto-update for desktop apps#224
feat(updater): perry/updater subsystem — auto-update for desktop apps#224TheHypnoo wants to merge 3 commits intoPerryTS:mainfrom
Conversation
New `perry/updater` module: cross-platform auto-updater for Perry desktop
apps in the Tauri/Sparkle/autoUpdater shape. Pre-fix Perry had no built-in
update story — apps had to hand-roll fetch + replace + relaunch.
Desktop-only by design: iOS (App Store / TestFlight) and Android
(Play Store / sideloaded APK) own the install pipeline at the OS level,
so self-update is structurally impossible there.
What lands:
- `crates/perry-updater` — single crate with `core` and `desktop` as
internal modules. core: semver compare, SHA-256 file hash verify,
Ed25519 signature verify (raw 32-byte SHA-256 digest as the signed
payload — documented in the .d.ts), atomic sentinel write/read/clear.
desktop: per-OS exe-path resolution (walks to `.app` bundle on macOS,
honors `$APPIMAGE` on Linux), atomic install with `<exe>.prev` backup,
detached relaunch (`setsid` on Unix, `DETACHED_PROCESS` on Windows).
- Runtime additions: `js_fs_chmod_sync` (POSIX mode bits, no-op on
Windows); `js_child_process_spawn_detached` (general-purpose detached
spawn, reusable beyond the updater).
- Stdlib additions: `js_crypto_ed25519_verify` under the existing
`crypto` feature gate (`ed25519-dalek = "2.1"`). Raw verify primitive
parallel to `sha256_bytes` / X25519 — also usable from user code that
just needs Ed25519 sigverify without the full updater.
- Codegen: new `PERRY_UPDATER_TABLE` in `lower_call.rs` (12 entries)
+ dispatch arm at `module == "perry/updater"` mirroring the existing
`perry/system` / `perry/i18n` / `perry/plugin` shapes. New `chmodSync`
arm in the `fs` module dispatch. `perry/updater` added to
`NATIVE_MODULES` in `perry-hir/src/ir.rs` so imports resolve.
- TS surface: `types/perry/updater/index.d.ts` (flat ambient — primitive
symbols backed by the FFI table); `packages/perry-updater/` (npm
`@perry/updater`) — high-level API: `checkForUpdate({manifestUrl,
publicKey, currentVersion}) → Update | null`, `update.download()`,
`update.installAndRelaunch()`, `initUpdater()` for boot-time
crash-loop detection with sentinel-based rollback.
- Manifest schema: JSON `{schemaVersion: 1, version, pubDate, notes,
platforms: {<os>-<arch>: {url, sha256, signature, size}}}`. TS layer
does the fetch + write-to-staged via `fetch().arrayBuffer()` and
`fs.writeFileSync` + `fs.renameSync`; Rust only handles the
security-critical and platform-touching pieces.
Out of scope (separate PRs):
- UI primitives (modal with `ProgressView` + "Restart Now") — better
added directly to `perry/ui` than carved into a separate package.
- E2E self-update test in CI (unit tests cover the building blocks).
- Notarization / code-signing during install (binaries arrive
pre-notarized server-side).
- Privileged install for system-wide `/Applications` (this PR covers
`~/Applications` / `~/.local/bin` only).
- Delta updates (bsdiff), multi-channel, staged rollouts.
Verified: `cargo build --release -p perry-updater -p perry-stdlib`
clean; 9 unit tests pass (`core::tests::*` + `desktop::tests::*`)
covering semver edge cases, hash mismatch, Ed25519 signature roundtrip
with file tampering, install+rollback, sentinel idempotency,
env-var-driven path resolution. End-to-end smoke test against a real
Perry binary verified `compareVersions`, sentinel write/read/clear,
`getExePath`, `getSentinelPath`.
|
Love this! Let’s wait for the tests and then see to merge it! |
|
Thanks for this — the design is well-considered and the README is refreshingly honest about the unverified Win/Linux paths. Tauri-shaped manifest + signed-over-SHA256-digest + sentinel rollback is the right shape for v1, and keeping the download in TS rather than dragging another async runtime into Rust is the right call. A few things I'd want addressed before merge. Don't worry about rebasing yet — 1. Sentinel restart-counting will misfire on short-lived apps. 2. 3. Detached-spawn is duplicated. 4. Atomicity claims need a comment about what "atomic" actually means per-OS. "Atomic on the same filesystem on every supported platform" is true for Non-blockers but worth thinking about: signing the raw 32-byte SHA-256 with plain Ed25519 (vs Ed25519ph) is the Tauri convention so it's defensible, but a The unit test coverage is good for what it covers; the missing piece is end-to-end on Windows + a real AppImage on Linux, which you've already flagged. Happy to keep this open while you run those smoke tests. |
…+ spawn-detached, atomicity docs)
Four fixes from the PR review, plus the non-blocker doc note. None of
these change the public API surface — verify-after-merge sticks to the
existing tests.
1. **Sentinel false-positive on short-lived apps**
The 60s health-check timer alone misfires when users legitimately close
and reopen quickly (CLI tools, run-and-quit GUIs): the sentinel survives
the close, the next boot bumps `restartCount`, and after two such cycles
the new version gets rolled back as if it had been crash-looping.
Fix is option (a) from the review: register a `process.on('exit', ...)`
hook in `initUpdater` that clears the sentinel on graceful shutdown, so
only the "process died without unwinding" case survives to the next boot.
Also adds a `markHealthy()` export for apps that want explicit control
(UI apps wiring it from `onTerminate`, runtimes where `process.on` isn't
available, or apps that have a stronger health signal than "60s elapsed").
2. **`ed25519-dalek` linked twice**
Pre-fix: both `js_crypto_ed25519_verify` (perry-stdlib) and
`perry_updater_verify_signature` (perry-updater) pulled `ed25519-dalek`
and reimplemented the verify step with their own glue. End result was
the same crate compiled into libperry_stdlib.a twice.
The updater's signature verifier now declares `js_crypto_ed25519_verify`
as `extern "C"` and routes through it — perry-updater handles the
file-streaming SHA-256, base64 decode, and buffer marshaling, then hands
the (digest, sig, pk) triple to the stdlib primitive. `ed25519-dalek`
moved to `[dev-dependencies]` since the test still needs to generate a
test keypair to drive the verify path. Test gets a local
`#[no_mangle] js_crypto_ed25519_verify` stub that uses dalek directly,
since `cargo test -p perry-updater` doesn't link the stdlib.
One side effect: importing `perry/updater` now requires the `crypto`
feature on perry-stdlib. Wired in `crates/perry/src/commands/stdlib_features.rs`
so the auto-optimizer turns it on automatically — `"perry/updater" =>
&["crypto"]`. Pre-fix the auto-optimized stdlib was being built without
crypto when the user only imported `perry/updater`, leaving
`js_crypto_ed25519_verify` unresolved at link time.
3. **Detached-spawn duplicated**
`js_child_process_spawn_detached` (runtime) and `perry_updater_relaunch`
(updater) had the same `setsid` / `DETACHED_PROCESS|CREATE_NEW_PROCESS_GROUP`
body, copy-pasted. Extracted a shared `pub fn spawn_detached_command(cmd,
args, cwd) -> Option<u32>` in `perry-runtime::child_process`; both call
sites are thin wrappers over it now. Updater drops its `libc` direct dep
since the per-OS detach logic moved out.
4. **Atomicity comments per-OS**
Added a doc block above `perry_updater_install` in `desktop.rs` explaining
what "atomic on the same filesystem" actually means on each platform —
POSIX `rename(2)` keeps the old inode alive for the running process via
fd/image-section references on macOS+Linux, NTFS allows
`MoveFileExW(MOVEFILE_REPLACE_EXISTING)` over a running EXE because the
loader opens with `FILE_SHARE_DELETE` since Vista. Future debuggers will
thank us when they're tracking down a "why didn't this break" question.
Plus the non-blocker: a comment near `perry_updater_verify_signature`
explaining why we sign the SHA-256 digest with pure Ed25519 instead of
Ed25519ph. Same-strength construction (RFC 8032 §6), Tauri-compatible
wire format, no domain-separation prefix.
All 9 unit tests still pass (`core::tests::*` + `desktop::tests::*`).
Smoke test against a real Perry binary on macOS verified the post-
consolidation FFI dispatch — `compareVersions`, `computeFileSha256`,
`verifySignature` (now via stdlib), sentinel write/read/clear, and
`getExePath` all round-trip the expected values.
Adds a runnable smoke that drives the full perry/updater happy path:
fresh Ed25519 keypair → compile two test binaries (v1.0.0 + v1.0.1) →
sign v1.0.1's SHA-256 → serve a manifest over HTTP → run v1.0.0 → assert
v1.0.1 is what comes back up after the install.
Two scripts so contributors can run on whichever shell they have:
- scripts/smoke_updater.sh (macOS / Linux, bash)
- scripts/smoke_updater.ps1 (Windows, PowerShell)
Both follow the same shape and use openssl + python's http.server, so
they cover the security-critical primitives end-to-end on every
desktop OS this PR ships for. Mirrors the precedent set by
run_doc_tests.sh / run_doc_tests.ps1.
The fixture lives at crates/perry-updater/tests/fixtures/smoke.ts.tpl
with __VERSION__ / __PUBKEY__ placeholders the shell substitutes per
build. Both v1.0.0 and v1.0.1 share the same source — only v1.0.0
drives the update (manifest → verify → install → relaunch); v1.0.1
just stamps a marker file so the shell knows it actually booted.
Verified locally on darwin-aarch64 — full run of the .sh produces:
smoke test PASSED
marker:
1.0.0
1.0.1
…and confirms `<exe>.prev` backup, install hash matches v1.0.1, and the
new process detached cleanly from the old one.
# What this smoke does NOT cover
End-to-end from the *download* side. Perry's `await response.arrayBuffer()`
currently returns a metadata-only object (`{ byteLength: N }`) — the body
bytes never make it to TS, so `Buffer.from(buf)` / `new Uint8Array(buf)`
both end up with length 1, and `fs.writeFileSync` writes a single byte to
disk. The shell pre-stages v1.0.1 at `<exe>.staged` via `cp` so the smoke
can exercise verify → install → relaunch end-to-end while that
arrayBuffer-to-disk gap is tracked separately. The fixture's file header
documents the workaround.
Two other Perry runtime quirks surfaced and are noted inline in the
fixture:
- `console.log("...")` (single-arg) immediately before `await fetch(...)`
hangs the fetch silently; the two-arg form sidesteps it. Every log
call before an await uses the two-arg form deliberately.
- `fs.appendFileSync` writes 0 bytes; the marker write reads-then-
writes the concatenated content instead.
None of these are updater-specific — they're Perry runtime behaviour
worth filing as separate issues. The smoke documents the workarounds so
the next contributor doesn't burn an afternoon rediscovering them.
# Also fixed in this commit
`packages/perry-updater/src/index.ts` was using `Buffer.from(buf)` on the
fetched ArrayBuffer to write the staged binary. Same gap as the smoke
hit — would have written a 1-byte file at install time. Updated the
comment so the next reader knows it's a Perry limitation, not a typo. The
fix here is the comment + the smoke; making the actual download work
end-to-end in TS needs a Perry runtime change.
# Out of scope (separate follow-ups)
- AppImage-specific test (build via appimagetool, exercise $APPIMAGE
detection on Linux)
- Crash-loop rollback (different code path, deserves its own script)
- Wiring the smoke into CI — these tests are timing-sensitive enough
that manual pre-release runs catch more than green CI does
What and why
Perry doesn't have a built-in updater, which means apps shipping to desktop users either roll their own download + replace + relaunch flow or just don't ship updates at all. This adds a
perry/updatermodule that handles the boring and security-critical parts: manifest fetch, semver compare, hash + Ed25519 signature verification, atomic install with rollback, and detached relaunch.The design mostly follows Tauri's updater (small, opinionated, doesn't try to own the whole release pipeline). I also looked at Wails — similar shape but more bring-your-own — and Electron's autoUpdater, which is Squirrel-based and much heavier than what Perry needs. Tauri's manifest + Ed25519-over-the-digest + staged-file-then-rename approach was the closest fit, so the wire format and the trust model here are mostly a port of that.
What landed
crates/perry-updateris a single crate with two internal modules:core— pure cross-platform helpers: semver compare (via thesemvercrate), SHA-256 file hashing, Ed25519 verification (viaed25519-dalek), and the sentinel write/read/clear used to detect crash-loops. The signed payload is the raw 32-byte SHA-256 digest of the binary, not the hex string and not the file bytes themselves — that's documented at the top of the.d.tsso signing tools and the verifier don't drift apart later.desktop— per-OS exe path resolution (walks up to the.appbundle on macOS, honors\$APPIMAGEon Linux, falls back to a canonicalizedcurrent_exe()otherwise), atomic install with `.prev` backup, and detached relaunch (setsidviapre_execon Unix,DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUPon Windows).The split into core/desktop modules is because they really do have different audit surfaces — core is pure logic with no I/O on the executable, desktop touches files and processes — but they live in the same crate. There's no good reason to have two crates for what amounts to ~800 lines of Rust.
The download itself stays in TS, using the existing
fetch().arrayBuffer()+fs.writeFileSync+fs.renameSync. Pulling the network into Rust would mean either standing up a second async runtime or duplicating reqwest, and Perry's existingfetchis already wired and tested.Why no mobile
iOS apps go through App Store / TestFlight, Android through Play Store or sideloaded APKs. The OS owns the install pipeline on both — replacing your own binary at runtime is structurally impossible (and would get the app pulled even if it weren't). So this is unapologetically desktop-only. The crate still compiles on mobile targets because the
cfg(target_os)arms indesktoproute everything that isn't macOS / Windows to the unix branch — that way cross-platform user code doesn't have toifdefaround the import.What I changed elsewhere
A few primitives were missing and got added alongside the updater:
js_fs_chmod_syncin perry-runtime — needed for the install step on Unix to make sure the new binary is+x. POSIX-only effective; no-op success on Windows where mode bits don't apply.js_child_process_spawn_detachedin perry-runtime — a real detached spawn (different from the existingspawn_background, which keeps the child in a registry). Useful well beyond the updater for any CLI that launches a daemon or helper.js_crypto_ed25519_verifyin perry-stdlib, gated by the existingcryptofeature. Raw verify primitive parallel to the X25519 stuff already incrypto_e2e.rs. Also usable directly from user code that just wants Ed25519 sigverify without the full updater.PERRY_UPDATER_TABLEinperry-codegen/src/lower_call.rs(12 entries, one per FFI symbol) plus a dispatch arm atmodule == \"perry/updater\"mirroring the existingperry/system/perry/i18nshapes. NewchmodSyncarm in thefsblock.\"perry/updater\"added toNATIVE_MODULESinperry-hir/src/ir.rsso imports resolve.TS surface
types/perry/updater/index.d.ts— flat ambient, the primitives the codegen routes through.packages/perry-updater/(npm:@perry/updater) — high-level wrapper. Entry points arecheckForUpdate({manifestUrl, publicKey, currentVersion})returning anUpdateobject withdownload(onProgress?)andinstallAndRelaunch(), plusinitUpdater()for the boot-time crash-loop detection.Manifest schema is JSON, schema-versioned, one entry per
<os>-<arch>:```json
{
"schemaVersion": 1,
"version": "1.4.0",
"pubDate": "2026-04-27T10:00:00Z",
"notes": "...",
"platforms": {
"darwin-aarch64": { "url": "...", "sha256": "...", "signature": "...", "size": 12345 },
"windows-x86_64": { ... },
"linux-x86_64": { ... }
}
}
```
What's deliberately not here
ProgressView+ a "Restart Now" button). I think that belongs insideperry/uiproper rather than a separate@perry/updater-uipackage, so it's a follow-up PR./ApplicationsorProgram Files. This PR only handles user-writable locations (~/Applications,~/.local/bin,%LOCALAPPDATA%). UAC /SMJobBlessis a separate concern.Tests
9 unit tests in the new crate (
core::tests::*anddesktop::tests::*) covering semver edge cases including prerelease and invalid input, SHA-256 mismatch detection, Ed25519 roundtrip with file tampering, install + rollback, missing-backup rejection, sentinel idempotent clear, andPERRY_APP_ID-driven path resolution.I also ran an end-to-end smoke test against a real Perry binary on macOS to verify the codegen wiring —
compareVersions,getExePath,getSentinelPath, and sentinel write/read/clear all work through the FFI dispatch and round-trip the expected values.Honest test coverage
I built and tested this on macOS. Linux and Windows paths are gated behind
cfg(target_os)and didn't even compile on my host. The design matches well-documented OS semantics on all three platforms (POSIXrename(2)atomicity on the same filesystem, NTFS rename-while-open when the file is opened withFILE_SHARE_DELETE— which the PE loader does since Vista — and the kernel keeping mmap'd inodes alive on Linux), but I haven't run the install + relaunch path end-to-end on Linux or Windows. CI will catch compile errors; the runtime behavior on non-Mac is a calculated bet on documented OS semantics that should be smoke-tested before anyone relies on this in production.Two things worth verifying before declaring this production-ready:
\$APPIMAGEenv-var handling follows the AppImage convention but I've only tested with bare ELF binaries.Test plan
cargo test -p perry-updaterpasses on the CI runnerperry/updateron Linux and Windows