Skip to content

Add macOS RivetUpdate shared host-side update client - #230

Merged
turinglambdaai merged 2 commits into
mainfrom
feat/macos-rivet-update
Oct 13, 2026
Merged

turinglambdaai merged 2 commits into
mainfrom
feat/macos-rivet-update

Conversation

@turinglambdaai

Copy link
Copy Markdown
Owner

Why

taskly's in-app OTA stalled for weeks on a macOS host-side bug (turinglambdaai/taskly#13): byte-iterating URLSession.AsyncBytes parked its continuation forever on a lost end-of-stream flag, and the hand-rolled downloader had no watchdog and no escape hatch. The engine side (rivet/distribution, hardened in 0.8.0) is complete — but every product hand-rolls the same ~460 lines of security-critical Swift for the host-native flow the family blessed. That bug class was free to recur in each copy.

What

Extract the battle-tested flow into the platform package as RivetUpdate, so hosts configure four facts (feed URL, Ed25519 public key, key id, bundle name) and keep their own UI:

  • Signed-wrapper verification matching rivet/distribution: Ed25519 over the exact payload bytes before any JSON parsing, schema and key-id checks, closed inner decode.
  • Delegate-driven downloader: completion is definitive, request/resource timeouts bound silent stalls, task cancellation always resumes promptly (the escape hatch the stuck sheet needed).
  • Signed size enforced twice: a disagreeing Content-Length fails the transfer immediately instead of downloading a wrong artifact to the end; the delivered file must match even without a length. Chunked sha256 over the file on disk.
  • ditto unpack, bundle-version confirmation, swap-with-rollback script, relaunch and terminate.
  • Configuration fails closed at construction: HTTPS-only feeds outside loopback drills, raw-32-byte keys, bare *.app bundle names, positive timeouts.

Docs: new "Host-side updates on macOS" section in docs/release-and-updates.md with the UX contract (always a Cancel affordance; dismissable except during the final swap) and pointers for Windows (msiexec) / Linux (engine adapter) hosts.

Testing

28 new Swift tests (swift-testing), local swift test --package-path platform/macos fully green alongside the existing suites: verification vectors (wrong key / key id / algorithm / schema / tampered payload / short signature / signed-but-unparsable), version comparison, configuration rejections, installed-bundle detection through the injected seam, swap-script content and quoting, sha256 known answer, URLProtocol-stubbed downloads (exact bytes, monotonic progress, size mismatch, transport failure, cancellation-always-resumes), and check() available / up-to-date / bad-status / dev-copy paths. CI swift-runtime job picks the target up automatically.

Every downstream application was hand-rolling the same security-critical
Swift flow (signed manifest check, artifact download, verify, atomic swap),
and the class of bug that stalled taskly's in-app OTA — AsyncBytes parking
its continuation on a lost end-of-stream flag — was free to recur in each
copy. This extracts that flow into the platform package as RivetUpdate so
hosts configure four facts (feed URL, Ed25519 public key, key id, bundle
name) and keep their own UI.

- Signed-wrapper verification matches rivet/distribution: Ed25519 over the
  exact payload bytes before any JSON parsing, schema and key-id checks,
  closed inner decode.
- Delegate-driven downloader: completion is definitive, request/resource
  timeouts bound silent stalls, task cancellation always resumes promptly.
- Signed size enforced twice — a disagreeing Content-Length fails the
  transfer immediately, and the delivered file must match even without a
  length; sha256 is chunked over the file on disk.
- ditto unpack, bundle-version confirmation, swap-with-rollback script,
  relaunch and terminate.
- Configuration fails closed at construction: HTTPS-only feeds outside
  loopback drills, raw-32-byte keys, bare *.app bundle names, positive
  timeouts.

28 tests cover the verification vectors, version comparison, configuration
rejections, installed-bundle detection, swap-script content, sha256 known
answer, and URLProtocol-stubbed download (exact bytes, monotonic progress,
size mismatch, transport failure, cancellation) plus the check() paths.
The same-day taskly verification (turinglambdaai/taskly@27ab8c6) exposed
two more coupled defects behind the stuck OTA beyond the AsyncBytes EOF
park; both belong in the shared module, not in each product:

- The spawned swap script owns the work directory. An unconditional defer
  cleanup races the script's grace sleep and destroys the payload it is
  about to unpack; gate the cleanup on a swapLaunched flag and add
  cleanupStaleWorkDirectories() for hosts to sweep crashed attempts at
  startup (fresh directories are kept — another process may be updating).
- A presented SwiftUI sheet silently vetoes NSApplication.terminate. The
  module now ends every sheet, terminates, and stops the run loop after a
  grace delay if the quit was deferred — a surviving old process holding a
  single-instance lease would otherwise quietly kill the relaunched update.

Docs and changelog describe both handoff traps. 29 tests.
@turinglambdaai
turinglambdaai merged commit 90f8535 into main Oct 13, 2026
23 checks passed
@turinglambdaai
turinglambdaai deleted the feat/macos-rivet-update branch October 13, 2026 04:19
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.

1 participant