Auto-update server-only Macs and replace Sparkle's update UI

This commit is contained in:
Ian Gulliver
2026-10-19 16:04:47 -07:00
parent 26404758c6
commit 8bca7a4369
8 changed files with 226 additions and 11 deletions
+4 -2
View File
@@ -16,7 +16,7 @@ The app connects to byre's `tailscale serve` HTTPS URL, so its requests get the
- `UserNotifications` for notifications.
- Third-party code: Apple's `swift-openapi-generator` (with `swift-openapi-runtime` and `swift-openapi-urlsession`) for the API types and client, SwiftTerm for the terminal, and Sparkle for updates (its framework is a binary target, loaded from `Contents/Frameworks` through the `@executable_path/../Frameworks` rpath set in `mac/Package.swift`).
The project is a Swift package under `mac/`, built with `swift build`, not an Xcode project. It builds with Xcode's toolchain: `xcode-select --print-path` must print `/Applications/Xcode.app/Contents/Developer`. With the Command Line Tools selected while Xcode is installed, Swift Build finds two toolchains for the Command Line Tools path and picks one at random on each run; the pick is part of the build request, so every change of pick rebuilds every target. `mac/bundle.sh` builds the release configuration in one directory shared by every worktree, `~/Library/Caches/byre/build`: SwiftPM keys a build on the package's absolute path, so a build in a fresh worktree, or one sharing a `--scratch-path`, recompiles every dependency. Under a `lockf` lock on `~/Library/Caches/byre/build.lock`, so builds from different worktrees take turns, it copies the worktree's `mac/`, `api/` and `hands/` there with `rsync --checksum`, which rewrites only files whose contents differ and leaves the rest's modification times alone, builds both packages, and copies the executables and Sparkle's framework out to `mac/.build/Byre`, `mac/.build/byre-hands` and `mac/.build/Sparkle.framework`. The server is built outside the lock with `go build -o mac/.build/server/byre`, since Go's own cache is shared and safe to use concurrently. It passes `--no-color-diagnostics` because `COLOR_DIAGNOSTICS` otherwise follows whether output is a terminal and is part of the build request, so agents whose shells differ would each rebuild every target in turn; `build-request.json` under `.build/out/Intermediates.noindex/XCBuildData/` shows what a build was keyed on. It then assembles `mac/.build/Byre.app` (`Byre` in `Contents/MacOS`, the server and `byre-hands` in `Contents/Helpers`, Sparkle in `Contents/Frameworks`, `mac/Info.plist` with the fixed bundle identifier `run.fc.byre.mac` and a `ByreCommit` key added holding the worktree's `git rev-parse HEAD`, which `API` sends as the `Byre-Commit` header on every request beside `Byre-Host`, the Mac's name, `CFBundleShortVersionString` from `git describe --tags --always` and `CFBundleVersion` from `git rev-list --count HEAD`, the `mac/fonts/` files and an `.icns` rendered from `mac/icon.svg`, cached as `mac/.build/byre.icns` until the SVG changes) and signs it; notification permission needs the bundle identifier. `docs/operations.md`, "macOS app", lists the signing order.
The project is a Swift package under `mac/`, built with `swift build`, not an Xcode project. It builds with Xcode's toolchain: `xcode-select --print-path` must print `/Applications/Xcode.app/Contents/Developer`. With the Command Line Tools selected while Xcode is installed, Swift Build finds two toolchains for the Command Line Tools path and picks one at random on each run; the pick is part of the build request, so every change of pick rebuilds every target. `mac/bundle.sh` builds the release configuration in one directory shared by every worktree, `~/Library/Caches/byre/build`: SwiftPM keys a build on the package's absolute path, so a build in a fresh worktree, or one sharing a `--scratch-path`, recompiles every dependency. Under a `lockf` lock on `~/Library/Caches/byre/build.lock`, so builds from different worktrees take turns, it copies the worktree's `mac/`, `api/` and `hands/` there with `rsync --checksum`, which rewrites only files whose contents differ and leaves the rest's modification times alone, builds both packages, and copies the executables and Sparkle's framework out to `mac/.build/Byre`, `mac/.build/byre-update`, `mac/.build/byre-hands` and `mac/.build/Sparkle.framework`. The server is built outside the lock with `go build -o mac/.build/server/byre`, since Go's own cache is shared and safe to use concurrently. It passes `--no-color-diagnostics` because `COLOR_DIAGNOSTICS` otherwise follows whether output is a terminal and is part of the build request, so agents whose shells differ would each rebuild every target in turn; `build-request.json` under `.build/out/Intermediates.noindex/XCBuildData/` shows what a build was keyed on. It then assembles `mac/.build/Byre.app` (`Byre` in `Contents/MacOS`, the server, `byre-hands` and `byre-update` in `Contents/Helpers`, Sparkle in `Contents/Frameworks`, `mac/Info.plist` with the fixed bundle identifier `run.fc.byre.mac` and a `ByreCommit` key added holding the worktree's `git rev-parse HEAD`, which `API` sends as the `Byre-Commit` header on every request beside `Byre-Host`, the Mac's name, `CFBundleShortVersionString` from `git describe --tags --always` and `CFBundleVersion` from `git rev-list --count HEAD`, the `mac/fonts/` files and an `.icns` rendered from `mac/icon.svg`, cached as `mac/.build/byre.icns` until the SVG changes) and signs it; notification permission needs the bundle identifier. `docs/operations.md`, "macOS app", lists the signing order.
## Signing
@@ -157,7 +157,9 @@ On first launch, and from Add Server…, the window shows one field, Server URL
## Updates
`Updater.swift` runs Sparkle's standard updater (`SPUStandardUpdaterController`), started at launch. It reads `SUFeedURL` from `mac/Info.plist`, the appcast of the newest release (see "Releases" in `docs/operations.md`), checks it daily (`SUEnableAutomaticChecks`, so Sparkle doesn't first ask whether to), compares the appcast's `sparkle:version` with `CFBundleVersion`, and verifies the download's EdDSA signature against `SUPublicEDKey` and its Developer ID signature against the running app's. Check for Updates… in the app menu checks at once. Sparkle's own windows show the offer and the progress; the appcast carries no release notes, so its release-notes view, a web view, is never shown. When the bundle is replaced, a server running from its `Contents/Helpers/byre` restarts itself onto the new binary (see "Running as a service" in `docs/operations.md`).
`Updater.swift` runs a Sparkle `SPUUpdater`, started at launch, with `Updater` as its user driver and delegate instead of Sparkle's standard windows, so no update UI offers Skip or turning automatic updates off, which `byre-update` would override. It reads `SUFeedURL` from `mac/Info.plist`, the appcast of the newest release (see "Releases" in `docs/operations.md`), checks it daily (`SUEnableAutomaticChecks`, so Sparkle doesn't first ask whether to), compares the appcast's `sparkle:version` with `CFBundleVersion`, and verifies the download's EdDSA signature against `SUPublicEDKey` and its Developer ID signature against the running app's. `SUAutomaticallyUpdate` makes a scheduled check download a new release in the background and stage it to install when the app quits; Sparkle then calls the delegate's `willInstallUpdateOnQuit`, which shows an alert, "Byre <version> is available", with **Update Now** (Sparkle installs and relaunches the app) and **Update on Quit** (it stays staged), and returns true, so Sparkle shows nothing of its own later. Returning true also holds Sparkle's update session open until the app quits, so no further checks run; `Updater` keeps the staged item and Sparkle's install handler, and Check for Updates… then shows the alert for that update again instead of checking. Otherwise Check for Updates… in the app menu checks at once: an update found shows the same alert (`showUpdateFound`), and either answer downloads it; at `showReadyToInstallAndRelaunch` the driver answers Install for Update Now and Dismiss for Update on Quit, which leaves it to install on quit. No update, or an error, shows an alert with Sparkle's message. The alerts are `NSAlert.runModal`; there is no download progress UI, and the appcast carries no release notes. When the bundle is replaced, a server running from its `Contents/Helpers/byre` restarts itself onto the new binary (see "Running as a service" in `docs/operations.md`).
A Mac that runs the server but not the app is updated by `byre-update` (`mac/Sources/byre-update/main.swift`, in `Contents/Helpers`), which the server runs daily. It drives the same Sparkle updater with no UI: an `SPUUpdater` whose host and application bundle are the app it sits in, with a user driver that answers Install to every offer and logs to stderr instead of showing windows. It makes one check (`checkForUpdates`, which, as a user-initiated check, would ignore a version skipped in the app), and exits when Sparkle reports no update, an error, or the end of the update session. Sparkle's installer replaces the bundle, and since the app isn't running it doesn't relaunch it. When that app bundle is running (a `NSWorkspace` running application with that `bundleURL`), it does nothing and exits, leaving the update to the app's own updater and its alert. Both use the app's user defaults (`run.fc.byre.mac`), so a check by either sets Sparkle's last-check time for both.
## Persistence