Skip to content

feat(updater): perry/updater subsystem — auto-update for desktop apps#224

Open
TheHypnoo wants to merge 3 commits intoPerryTS:mainfrom
TheHypnoo:feat/perry-updater
Open

feat(updater): perry/updater subsystem — auto-update for desktop apps#224
TheHypnoo wants to merge 3 commits intoPerryTS:mainfrom
TheHypnoo:feat/perry-updater

Conversation

@TheHypnoo
Copy link
Copy Markdown
Contributor

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/updater module 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-updater is a single crate with two internal modules:

  • core — pure cross-platform helpers: semver compare (via the semver crate), SHA-256 file hashing, Ed25519 verification (via ed25519-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.ts so signing tools and the verifier don't drift apart later.
  • desktop — per-OS exe path resolution (walks up to the .app bundle on macOS, honors \$APPIMAGE on Linux, falls back to a canonicalized current_exe() otherwise), atomic install with `.prev` backup, and detached relaunch (setsid via pre_exec on Unix, DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP on 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 existing fetch is 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 in desktop route everything that isn't macOS / Windows to the unix branch — that way cross-platform user code doesn't have to ifdef around the import.

What I changed elsewhere

A few primitives were missing and got added alongside the updater:

  • js_fs_chmod_sync in 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_detached in perry-runtime — a real detached spawn (different from the existing spawn_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_verify in perry-stdlib, gated by the existing crypto feature. Raw verify primitive parallel to the X25519 stuff already in crypto_e2e.rs. Also usable directly from user code that just wants Ed25519 sigverify without the full updater.
  • New PERRY_UPDATER_TABLE in perry-codegen/src/lower_call.rs (12 entries, one per FFI symbol) plus a dispatch arm at module == \"perry/updater\" mirroring the existing perry/system / perry/i18n shapes. New chmodSync arm in the fs block. \"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, the primitives the codegen routes through.
  • packages/perry-updater/ (npm: @perry/updater) — high-level wrapper. Entry points are checkForUpdate({manifestUrl, publicKey, currentVersion}) returning an Update object with download(onProgress?) and installAndRelaunch(), plus initUpdater() 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

  • UI primitives (a modal with ProgressView + a "Restart Now" button). I think that belongs inside perry/ui proper rather than a separate @perry/updater-ui package, so it's a follow-up PR.
  • An end-to-end self-update test in CI. The unit tests cover all the building blocks (semver compare, hash mismatch, signature roundtrip with tampering, install + rollback, sentinel idempotency, env-var path resolution), but the full install → relaunch → boot-with-sentinel-check loop only exists as a manual smoke test right now.
  • Notarization / code-signing during install. Binaries are expected to arrive already signed; the updater doesn't try to be a notarization tool.
  • Privileged install for system-wide /Applications or Program Files. This PR only handles user-writable locations (~/Applications, ~/.local/bin, %LOCALAPPDATA%). UAC / SMJobBless is a separate concern.
  • Delta updates (bsdiff), multi-channel (stable/beta), staged rollouts.

Tests

9 unit tests in the new crate (core::tests::* and desktop::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, and PERRY_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 (POSIX rename(2) atomicity on the same filesystem, NTFS rename-while-open when the file is opened with FILE_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:

  1. Windows: compile Perry, fake an install, confirm the running EXE gets replaced and the new one launches. The OS-level support is there but I haven't exercised it.
  2. Linux: try this against a real AppImage. The \$APPIMAGE env-var handling follows the AppImage convention but I've only tested with bare ELF binaries.

Test plan

  • cargo test -p perry-updater passes on the CI runner
  • Cross-platform compile of a tiny Perry binary that imports perry/updater on Linux and Windows
  • Manual smoke of install + relaunch on Windows
  • Manual smoke against an AppImage on Linux
  • At merge time: maintainer bumps version, adds CLAUDE.md entry per CONTRIBUTING.md

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`.
@proggeramlug
Copy link
Copy Markdown
Contributor

Love this! Let’s wait for the tests and then see to merge it!

@proggeramlug
Copy link
Copy Markdown
Contributor

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 — lower_call.rs is in flux locally (we just split it into sub-modules and stood up a perry-dispatch crate that PERRY_UPDATER_TABLE should ultimately live in); we'll handle the merge mechanics when our in-flight work lands.

1. Sentinel restart-counting will misfire on short-lived apps. initUpdater arms a 60s setTimeout to clear the sentinel post-update. If the user legitimately closes the app within that window — CLI tools, quick-launch GUIs, anything a user might run-and-quit — the sentinel survives, and the next legitimate boot bumps the count toward rollback. The threshold of 2 makes a normal close-and-reopen pattern indistinguishable from a crash loop. Two ways out: (a) clear-on-clean-exit hook so a graceful shutdown clears the sentinel regardless of the timer, (b) reframe restartCount as "restarts without graceful shutdown" and only increment when the previous run didn't clear cleanly. (a) is simpler.

2. ed25519-dalek is linked twice. js_crypto_ed25519_verify in perry-stdlib/crypto.rs and perry_updater_verify_signature in perry-updater/core.rs both pull ed25519-dalek and have their own glue. Both end up in libperry_stdlib.a. Pick one home — easiest is to have the updater route through the stdlib primitive (the updater's verify_signature becomes "compute SHA-256 of file, then call into the stdlib verify").

3. Detached-spawn is duplicated. js_child_process_spawn_detached (runtime) and perry_updater_relaunch (updater) have the same setsid / DETACHED_PROCESS|CREATE_NEW_PROCESS_GROUP body. Extract a shared helper — the updater's relaunch should be js_child_process_spawn_detached with the staged exe path.

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 rename(2) between two paths, but on macOS replacing a .app is a directory rename whose live-process safety comes from the running fds keeping their inodes alive after the dirent moves — that's correct, but it's worth a comment in desktop.rs citing the actual mechanism per platform (POSIX rename(2) inode survival, NTFS FILE_SHARE_DELETE on the loader's image-section mapping). Future debuggers will thank you.

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 // Note: ... near the verify function explaining the choice would head off the inevitable security-review question.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants