Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

## Unreleased

- Add the macOS `RivetUpdate` Swift module: a shared host-side update client
for installed `.app` bundles, so applications stop hand-rolling the same
security-critical download/verify/swap flow per product. It verifies the
family's signed manifest wrapper (Ed25519 over exact payload bytes before
any JSON parsing), downloads through a delegate-driven session whose
completion callbacks are definitive — byte-iterating `AsyncBytes` can park
its continuation forever on a lost end-of-stream flag — enforces signed
size during transfer and on the delivered file, confirms the unpacked
bundle version, and swaps atomically with rollback and relaunch.
Configuration fails closed (HTTPS-only feeds outside loopback drills,
raw-32-byte keys, bare `*.app` bundle names), task cancellation always
resumes promptly, and progress stays within 0…100. The swap handoff owns
two traps verified on a real stalled OTA: the spawned script keeps the
work directory (`cleanupStaleWorkDirectories` sweeps crashed attempts at
startup), and a presented sheet vetoes `terminate` silently — the module
ends every sheet, terminates, and stops the run loop if the quit was
deferred.

## 0.8.0

- Reject self-contradictory signed update manifests before publication or
Expand Down
15 changes: 15 additions & 0 deletions docs/release-and-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,21 @@ The lower-level `make-install-plan`, `execute-install-plan!`, and `recover-insta

Build metadata does not affect version precedence.

## Host-side updates on macOS

The engine API above covers applications whose update flow runs in Racket. Applications whose macOS host owns the whole flow natively should not hand-roll it: the Swift package exposes `RivetUpdate` (`platform/macos`), a shared update client for installed `.app` bundles. It performs the authenticated check against the same signed wrapper the engine verifies (`RivetUpdateService.check`), a bounded delegate-driven artifact download with progress and cancellation (`downloadAndInstall`), SHA-256 and size verification against the signed manifest, `ditto` unpacking, a bundle-version confirmation, and an atomic swap-with-rollback that relaunches the application.

Applications configure four facts — feed URL, Ed25519 public key, key id, and bundle name — and keep their own UI. Configuration fails closed at construction: non-HTTPS feeds outside loopback drills, malformed bundle names, empty key ids, and public keys that are not the 32 raw Ed25519 bytes are rejected before any transfer starts. The downloader rejects a definitive `Content-Length` that disagrees with the signed size immediately instead of downloading a wrong artifact to the end, and the file on disk must match the signed size even when the response carried no length.

The downloader is delegate-driven on purpose. Byte-iterating `URLSession.AsyncBytes` can park its continuation forever when the stream's end-of-stream flag never surfaces (observed live against the GitHub CDN: all bytes delivered, the loop never wakes, the update sheet sticks at 100%). Delegate completion callbacks are definitive, and the request/resource timeout intervals bound a silent stall. Two UX rules follow for hosts driving this from SwiftUI:

- A running transfer always offers an explicit Cancel affordance; task cancellation invalidates the session, so the awaited call always resumes promptly.
- The update surface stays dismissable by gesture except during the final swap-and-relaunch step, which terminates the process anyway.

The handoff at the end of an install owns two further traps the module encodes: the spawned swap script takes ownership of the work directory (an eager `defer` cleanup would race the script's grace sleep and destroy the payload — call `RivetUpdateService.cleanupStaleWorkDirectories()` at startup to sweep directories left by crashed attempts), and a presented SwiftUI sheet silently vetoes `NSApplication.terminate`, so the module ends every sheet, terminates, and stops the run loop if the quit was deferred — a surviving old process could otherwise hold a single-instance lease and quietly kill the relaunched update.

Windows hosts update through the MSI transaction (`msiexec` with elevation), and Linux AppImage/portable installs should use the engine's verified `prepare-platform-installation` / `execute-platform-installation!` replacement instead of re-implementing atomic swap in the host. Sharing the `RivetUpdate` pattern on those platforms belongs to their host story, not to each product.

## External validation points

CI can test signing, tampering, selection, hashing, packaging structure, and native compilation without publisher identities. A real release still needs the publisher's Authenticode certificate or Apple Developer ID/notary profile, an Ed25519 update key, an HTTPS artifact origin, and a final install/upgrade/rollback exercise on supported OS versions.
15 changes: 14 additions & 1 deletion platform/macos/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ let package = Package(
.library(name: "RivetDevice", targets: ["RivetDevice"]),
.library(name: "RivetWatchConnectivity", targets: ["RivetWatchConnectivity"]),
.library(name: "RivetEmbedding", targets: ["RivetEmbedding"]),
.library(name: "RivetSystem", targets: ["RivetSystem"])
.library(name: "RivetSystem", targets: ["RivetSystem"]),
.library(name: "RivetUpdate", targets: ["RivetUpdate"])
],
targets: [
.target(
Expand Down Expand Up @@ -53,6 +54,13 @@ let package = Package(
.linkedFramework("UserNotifications")
]
),
.target(
name: "RivetUpdate",
path: "Sources/RivetUpdate",
linkerSettings: [
.linkedFramework("CryptoKit")
]
),
.testTarget(
name: "RivetRuntimeTests",
dependencies: ["RivetRuntime"],
Expand All @@ -67,6 +75,11 @@ let package = Package(
name: "RivetSystemTests",
dependencies: ["RivetSystem"],
path: "Tests/RivetSystemTests"
),
.testTarget(
name: "RivetUpdateTests",
dependencies: ["RivetUpdate"],
path: "Tests/RivetUpdateTests"
)
]
)
Loading
Loading