Auto-update server-only Macs and replace Sparkle's update UI
This commit is contained in:
@@ -8,8 +8,10 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ func serveCmd(args []string) {
|
|||||||
ctx, replaced := context.WithCancel(ctx)
|
ctx, replaced := context.WithCancel(ctx)
|
||||||
defer replaced()
|
defer replaced()
|
||||||
go watchExecutable(ctx, replaced)
|
go watchExecutable(ctx, replaced)
|
||||||
|
go updateDaily(ctx)
|
||||||
|
|
||||||
hs := &http.Server{
|
hs := &http.Server{
|
||||||
Addr: *listenAddr,
|
Addr: *listenAddr,
|
||||||
@@ -92,3 +95,30 @@ func watchExecutable(ctx context.Context, replaced context.CancelFunc) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateDaily(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
update(ctx)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(24 * time.Hour):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(ctx context.Context) {
|
||||||
|
bin, err := exec.LookPath("byre-update")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("find byre-update", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
out, err := exec.CommandContext(ctx, bin).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("byre-update", "err", err, "output", strings.TrimSpace(string(out)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("byre-update", "output", strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
|||||||
+4
-2
@@ -16,7 +16,7 @@ The app connects to byre's `tailscale serve` HTTPS URL, so its requests get the
|
|||||||
- `UserNotifications` for notifications.
|
- `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`).
|
- 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
|
## Signing
|
||||||
|
|
||||||
@@ -157,7 +157,9 @@ On first launch, and from Add Server…, the window shows one field, Server URL
|
|||||||
|
|
||||||
## Updates
|
## 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
|
## Persistence
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -123,11 +123,11 @@ The listen address is `byre serve --listen`, whose default is `defaultListen` in
|
|||||||
|
|
||||||
byre is served at the root of the machine's `ts.net` name on 443 (`https://<machine>.<tailnet>.ts.net`), not under a path: the API uses absolute paths, and the macOS app takes an origin with no path. `tailscale serve status` shows the URL and what else the machine serves. `launchctl print gui/$(id -u)/run.fc.byre` shows the service and the program it runs.
|
byre is served at the root of the machine's `ts.net` name on 443 (`https://<machine>.<tailnet>.ts.net`), not under a path: the API uses absolute paths, and the macOS app takes an origin with no path. `tailscale serve status` shows the URL and what else the machine serves. `launchctl print gui/$(id -u)/run.fc.byre` shows the service and the program it runs.
|
||||||
|
|
||||||
A new build reaches the service by replacing `/Applications/Byre.app`: `mac/deploy.sh` (see "Releases"), or the app's updater. `byre serve` stats its own executable (`os.Executable`) every 5 seconds (`watchExecutable` in `cmd/byre/serve.go`); once that path holds a different file, it logs `executable replaced, exiting` and shuts down as on SIGTERM, and launchd (`KeepAlive`) starts the new binary. A missing path counts as not yet replaced, so the moment in `install.sh` between removing the old bundle and moving the new one in doesn't trigger it. Logs go to `~/Library/Logs/byre.log`.
|
A new build reaches the service by replacing `/Applications/Byre.app`: `mac/deploy.sh` (see "Releases"), the app's updater, or `byre-update`. `byre serve` runs `byre-update` (found on its `PATH`, beside it in `Contents/Helpers`) when it starts and every 24 hours after, logging its output; it installs the newest release with Sparkle and no prompt unless the app is running from that bundle, which then updates itself (see "Updates" in `docs/macos.md`). `byre serve` stats its own executable (`os.Executable`) every 5 seconds (`watchExecutable` in `cmd/byre/serve.go`); once that path holds a different file, it logs `executable replaced, exiting` and shuts down as on SIGTERM, and launchd (`KeepAlive`) starts the new binary. A missing path counts as not yet replaced, so the moment in `install.sh` between removing the old bundle and moving the new one in doesn't trigger it. Logs go to `~/Library/Logs/byre.log`.
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
A release is one notarized disk image, `byre-<version>.dmg`, holding `Byre.app`: the macOS app, with the server (`Contents/Helpers/byre`) and `byre-hands` (`Contents/Helpers/byre-hands`) inside it. Every Mac installs the same app; the server's Mac also runs `byre install` from it (see "Running as a service"). Releases are on the repo's releases page on g.fc.run, `https://g.fc.run/ai/byre/releases`.
|
A release is one notarized disk image, `byre-<version>.dmg`, holding `Byre.app`: the macOS app, with the server (`Contents/Helpers/byre`), `byre-hands` (`Contents/Helpers/byre-hands`) and `byre-update` (`Contents/Helpers/byre-update`) inside it. Every Mac installs the same app; the server's Mac also runs `byre install` from it (see "Running as a service"). Releases are on the repo's releases page on g.fc.run, `https://g.fc.run/ai/byre/releases`.
|
||||||
|
|
||||||
`mac/package.sh` builds one for whatever the checkout holds, into `mac/.build/release/<version>/`, `<version>` being the bundle's `CFBundleShortVersionString`, and prints the image's path. It builds with `mac/bundle.sh` (signed with Developer ID, see `docs/macos.md`), zips the app and notarizes it with `xcrun notarytool submit --keychain-profile byre-notary --wait`, staples the ticket, builds the disk image with `hdiutil` (the app and an `/Applications` link), signs, notarizes and staples that too, and writes its SHA-256 beside it. It then signs the image for Sparkle with `sign_update --account byre` and writes `appcast.xml`, a feed with that one release.
|
`mac/package.sh` builds one for whatever the checkout holds, into `mac/.build/release/<version>/`, `<version>` being the bundle's `CFBundleShortVersionString`, and prints the image's path. It builds with `mac/bundle.sh` (signed with Developer ID, see `docs/macos.md`), zips the app and notarizes it with `xcrun notarytool submit --keychain-profile byre-notary --wait`, staples the ticket, builds the disk image with `hdiutil` (the app and an `/Applications` link), signs, notarizes and staples that too, and writes its SHA-256 beside it. It then signs the image for Sparkle with `sign_update --account byre` and writes `appcast.xml`, a feed with that one release.
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ Clients reach `tailscale serve` over HTTP/2; it proxies to byre over HTTP/1.1 an
|
|||||||
|
|
||||||
## macOS app
|
## macOS app
|
||||||
|
|
||||||
**Building.** `mac/bundle.sh`, from any directory, builds the release configuration with `swift build` in `~/Library/Caches/byre/build`, one build directory every worktree shares and takes turns in (`docs/macos.md`, "Stack", says why), and assembles `mac/.build/Byre.app`: the `Byre` executable in `Contents/MacOS`; the server, built with `go build ./cmd/byre`, and `byre-hands`, built from `hands/`, in `Contents/Helpers` (not beside `Byre`: the default APFS volume ignores case, so `byre` and `Byre` would be one file); Sparkle's framework in `Contents/Frameworks`; `mac/Info.plist` with a `ByreCommit` key added holding the worktree's `git rev-parse HEAD`, `CFBundleShortVersionString` its `git describe --tags --always` without the leading `v` (`0.1.0` on a release tag, `0.1.0-12-gabc1234` after it) and `CFBundleVersion` its `git rev-list --count HEAD` (the About panel shows them as "Version <version> (<count>)", and Sparkle compares the count); the `mac/fonts/` woff2 files, an icon rendered from `mac/icon.svg` with `rsvg-convert` and `iconutil` (cached as `mac/.build/byre.icns` until the SVG changes) and `mac/byre.provisionprofile`. It signs each piece with the Developer ID Application certificate, a secure timestamp and the hardened runtime, innermost first: `byre-hands` as `run.fc.byre.hands` and the server as `run.fc.byre`, both with `mac/automation.entitlements` (Apple Events, which the hardened runtime otherwise refuses), Sparkle's `Autoupdate`, `Updater.app` and framework (its XPC services, only needed by sandboxed apps, are removed), then the bundle with `mac/byre.entitlements`. It prints the bundle's path. It needs Xcode selected (`xcode-select --print-path` prints `/Applications/Xcode.app/Contents/Developer`), Go, `rsvg-convert` (Homebrew's `librsvg`), the team's Developer ID Application certificate and network access for the timestamp, and it stops first unless `mac/byre.provisionprofile` has `ProvisionsAllDevices` (a Developer ID profile; a development profile can't authorize the Developer ID signature); `docs/macos.md`, "Signing", covers the certificate and profile. Debug builds are refused by an `#error` in `ByreApp.swift`. The first build on a Mac fetches the Swift packages pinned in `mac/Package.resolved` and compiles them and the generated API client; later builds, from any worktree, recompile only the targets whose sources differ from the last build's, usually just `Byre`. The API client is generated from `api/openapi.yaml` at build time. The palette is `mac/Sources/Byre/Palette.swift`, one light and one dark value per colour.
|
**Building.** `mac/bundle.sh`, from any directory, builds the release configuration with `swift build` in `~/Library/Caches/byre/build`, one build directory every worktree shares and takes turns in (`docs/macos.md`, "Stack", says why), and assembles `mac/.build/Byre.app`: the `Byre` executable in `Contents/MacOS`; the server, built with `go build ./cmd/byre`, `byre-hands`, built from `hands/`, and `byre-update`, the `mac/` package's second executable, in `Contents/Helpers` (not beside `Byre`: the default APFS volume ignores case, so `byre` and `Byre` would be one file); Sparkle's framework in `Contents/Frameworks`; `mac/Info.plist` with a `ByreCommit` key added holding the worktree's `git rev-parse HEAD`, `CFBundleShortVersionString` its `git describe --tags --always` without the leading `v` (`0.1.0` on a release tag, `0.1.0-12-gabc1234` after it) and `CFBundleVersion` its `git rev-list --count HEAD` (the About panel shows them as "Version <version> (<count>)", and Sparkle compares the count); the `mac/fonts/` woff2 files, an icon rendered from `mac/icon.svg` with `rsvg-convert` and `iconutil` (cached as `mac/.build/byre.icns` until the SVG changes) and `mac/byre.provisionprofile`. It signs each piece with the Developer ID Application certificate, a secure timestamp and the hardened runtime, innermost first: `byre-hands` as `run.fc.byre.hands` and the server as `run.fc.byre`, both with `mac/automation.entitlements` (Apple Events, which the hardened runtime otherwise refuses), Sparkle's `Autoupdate`, `Updater.app` and framework (its XPC services, only needed by sandboxed apps, are removed), `byre-update` as `run.fc.byre.update`, then the bundle with `mac/byre.entitlements`. It prints the bundle's path. It needs Xcode selected (`xcode-select --print-path` prints `/Applications/Xcode.app/Contents/Developer`), Go, `rsvg-convert` (Homebrew's `librsvg`), the team's Developer ID Application certificate and network access for the timestamp, and it stops first unless `mac/byre.provisionprofile` has `ProvisionsAllDevices` (a Developer ID profile; a development profile can't authorize the Developer ID signature); `docs/macos.md`, "Signing", covers the certificate and profile. Debug builds are refused by an `#error` in `ByreApp.swift`. The first build on a Mac fetches the Swift packages pinned in `mac/Package.resolved` and compiles them and the generated API client; later builds, from any worktree, recompile only the targets whose sources differ from the last build's, usually just `Byre`. The API client is generated from `api/openapi.yaml` at build time. The palette is `mac/Sources/Byre/Palette.swift`, one light and one dark value per colour.
|
||||||
|
|
||||||
**Installing.** A release is installed from its disk image into `/Applications` (see "Releases") and updates itself (`docs/macos.md`, "Updates"). A deploy installs its build there the same way (see "Releases"). A build from a checkout can also run from where it was built, `open mac/.build/Byre.app` in the worktree that built it, after quitting the running app (`pkill -x Byre` quits every running copy). Every build shares the bundle identifier `run.fc.byre.mac` and the same store, so any build reconnects to the servers the last one saved, switched between with the window's server picker. Closing the window leaves the app running for notifications; ⌘Q quits it.
|
**Installing.** A release is installed from its disk image into `/Applications` (see "Releases") and updates itself (`docs/macos.md`, "Updates"). A deploy installs its build there the same way (see "Releases"). A build from a checkout can also run from where it was built, `open mac/.build/Byre.app` in the worktree that built it, after quitting the running app (`pkill -x Byre` quits every running copy). Every build shares the bundle identifier `run.fc.byre.mac` and the same store, so any build reconnects to the servers the last one saved, switched between with the window's server picker. Closing the window leaves the app running for notifications; ⌘Q quits it.
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
<string>350+qbxFCpm+LhbRlCxtBHST+FTz6o19jhGOSs8D0XU=</string>
|
<string>350+qbxFCpm+LhbRlCxtBHST+FTz6o19jhGOSs8D0XU=</string>
|
||||||
<key>SUEnableAutomaticChecks</key>
|
<key>SUEnableAutomaticChecks</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>SUAutomaticallyUpdate</key>
|
||||||
|
<true/>
|
||||||
<key>NSAppleEventsUsageDescription</key>
|
<key>NSAppleEventsUsageDescription</key>
|
||||||
<string>byre's agents drive Orion on this Mac through AppleScript when a task asks them to.</string>
|
<string>byre's agents drive Orion on this Mac through AppleScript when a task asks them to.</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -32,5 +32,13 @@ let package = Package(
|
|||||||
swiftSettings: [.strictMemorySafety()],
|
swiftSettings: [.strictMemorySafety()],
|
||||||
linkerSettings: [.unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@executable_path/../Frameworks"])]
|
linkerSettings: [.unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@executable_path/../Frameworks"])]
|
||||||
),
|
),
|
||||||
|
.executableTarget(
|
||||||
|
name: "byre-update",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "Sparkle", package: "Sparkle"),
|
||||||
|
],
|
||||||
|
swiftSettings: [.strictMemorySafety()],
|
||||||
|
linkerSettings: [.unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@executable_path/../Frameworks"])]
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,16 +1,108 @@
|
|||||||
import Foundation
|
import AppKit
|
||||||
import Sparkle
|
import Sparkle
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class Updater {
|
final class Updater: NSObject, SPUUserDriver, SPUUpdaterDelegate {
|
||||||
static let shared = Updater()
|
static let shared = Updater()
|
||||||
private lazy var controller = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
|
private var updater: SPUUpdater?
|
||||||
|
private var now = false
|
||||||
|
private var staged: (item: SUAppcastItem, install: () -> Void)?
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
_ = controller
|
let updater = SPUUpdater(hostBundle: .main, applicationBundle: .main, userDriver: self, delegate: self)
|
||||||
|
self.updater = updater
|
||||||
|
do {
|
||||||
|
try updater.start()
|
||||||
|
} catch {
|
||||||
|
tell("Updates are off", error.localizedDescription)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func check() {
|
func check() {
|
||||||
controller.checkForUpdates(nil)
|
if let staged {
|
||||||
|
if ask(staged.item) {
|
||||||
|
staged.install()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updater?.checkForUpdates()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func ask(_ item: SUAppcastItem) -> Bool {
|
||||||
|
let current = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? ""
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = "Byre \(item.displayVersionString) is available"
|
||||||
|
alert.informativeText = "You have \(current)."
|
||||||
|
alert.addButton(withTitle: "Update Now")
|
||||||
|
alert.addButton(withTitle: "Update on Quit")
|
||||||
|
NSApp.activate()
|
||||||
|
return alert.runModal() == .alertFirstButtonReturn
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tell(_ message: String, _ info: String) {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = message
|
||||||
|
alert.informativeText = info
|
||||||
|
NSApp.activate()
|
||||||
|
alert.runModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) -> Bool {
|
||||||
|
staged = (item, immediateInstallHandler)
|
||||||
|
if ask(item) {
|
||||||
|
immediateInstallHandler()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func show(_ request: SPUUpdatePermissionRequest, reply: @escaping (SUUpdatePermissionResponse) -> Void) {
|
||||||
|
reply(SUUpdatePermissionResponse(automaticUpdateChecks: true, sendSystemProfile: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {}
|
||||||
|
|
||||||
|
func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, reply: @escaping (SPUUserUpdateChoice) -> Void) {
|
||||||
|
now = ask(appcastItem)
|
||||||
|
if !now && state.stage == .installing {
|
||||||
|
reply(.dismiss)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply(.install)
|
||||||
|
}
|
||||||
|
|
||||||
|
func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {}
|
||||||
|
|
||||||
|
func showUpdateReleaseNotesFailedToDownloadWithError(_ error: any Error) {}
|
||||||
|
|
||||||
|
func showUpdateNotFoundWithError(_ error: any Error, acknowledgement: @escaping () -> Void) {
|
||||||
|
tell(error.localizedDescription, (error as NSError).localizedRecoverySuggestion ?? "")
|
||||||
|
acknowledgement()
|
||||||
|
}
|
||||||
|
|
||||||
|
func showUpdaterError(_ error: any Error, acknowledgement: @escaping () -> Void) {
|
||||||
|
tell("Update failed", error.localizedDescription)
|
||||||
|
acknowledgement()
|
||||||
|
}
|
||||||
|
|
||||||
|
func showDownloadInitiated(cancellation: @escaping () -> Void) {}
|
||||||
|
|
||||||
|
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {}
|
||||||
|
|
||||||
|
func showDownloadDidReceiveData(ofLength length: UInt64) {}
|
||||||
|
|
||||||
|
func showDownloadDidStartExtractingUpdate() {}
|
||||||
|
|
||||||
|
func showExtractionReceivedProgress(_ progress: Double) {}
|
||||||
|
|
||||||
|
func showReady(toInstallAndRelaunch reply: @escaping (SPUUserUpdateChoice) -> Void) {
|
||||||
|
reply(now ? .install : .dismiss)
|
||||||
|
}
|
||||||
|
|
||||||
|
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {}
|
||||||
|
|
||||||
|
func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
|
||||||
|
acknowledgement()
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissUpdateInstallation() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import AppKit
|
||||||
|
import Sparkle
|
||||||
|
|
||||||
|
func log(_ message: String) {
|
||||||
|
FileHandle.standardError.write(Data((message + "\n").utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class Driver: NSObject, SPUUserDriver {
|
||||||
|
func show(_ request: SPUUpdatePermissionRequest, reply: @escaping (SUUpdatePermissionResponse) -> Void) {
|
||||||
|
reply(SUUpdatePermissionResponse(automaticUpdateChecks: true, sendSystemProfile: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {}
|
||||||
|
|
||||||
|
func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, reply: @escaping (SPUUserUpdateChoice) -> Void) {
|
||||||
|
log("installing \(appcastItem.displayVersionString) (\(appcastItem.versionString))")
|
||||||
|
reply(.install)
|
||||||
|
}
|
||||||
|
|
||||||
|
func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {}
|
||||||
|
|
||||||
|
func showUpdateReleaseNotesFailedToDownloadWithError(_ error: any Error) {}
|
||||||
|
|
||||||
|
func showUpdateNotFoundWithError(_ error: any Error, acknowledgement: @escaping () -> Void) {
|
||||||
|
log("no update: \(error.localizedDescription)")
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func showUpdaterError(_ error: any Error, acknowledgement: @escaping () -> Void) {
|
||||||
|
log("update failed: \(error.localizedDescription)")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func showDownloadInitiated(cancellation: @escaping () -> Void) {}
|
||||||
|
|
||||||
|
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {}
|
||||||
|
|
||||||
|
func showDownloadDidReceiveData(ofLength length: UInt64) {}
|
||||||
|
|
||||||
|
func showDownloadDidStartExtractingUpdate() {}
|
||||||
|
|
||||||
|
func showExtractionReceivedProgress(_ progress: Double) {}
|
||||||
|
|
||||||
|
func showReady(toInstallAndRelaunch reply: @escaping (SPUUserUpdateChoice) -> Void) {
|
||||||
|
reply(.install)
|
||||||
|
}
|
||||||
|
|
||||||
|
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
|
||||||
|
log("installing")
|
||||||
|
}
|
||||||
|
|
||||||
|
func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
|
||||||
|
log("installed")
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissUpdateInstallation() {
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let app = Bundle.main.executableURL!.resolvingSymlinksInPath().deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
|
||||||
|
if NSWorkspace.shared.runningApplications.contains(where: { $0.bundleURL?.standardizedFileURL == app.standardizedFileURL }) {
|
||||||
|
log("\(app.path) is running; its own updater handles updates")
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
let bundle = Bundle(url: app)!
|
||||||
|
let driver = Driver()
|
||||||
|
let updater = SPUUpdater(hostBundle: bundle, applicationBundle: bundle, userDriver: driver, delegate: nil)
|
||||||
|
do {
|
||||||
|
try updater.start()
|
||||||
|
} catch {
|
||||||
|
log("start updater: \(error.localizedDescription)")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
updater.checkForUpdates()
|
||||||
|
RunLoop.main.run()
|
||||||
+4
-1
@@ -19,9 +19,10 @@ swift build --package-path "$2/mac" --configuration release --no-color-diagnosti
|
|||||||
swift build --package-path "$2/hands" --configuration release --no-color-diagnostics
|
swift build --package-path "$2/hands" --configuration release --no-color-diagnostics
|
||||||
cp "$2/mac/.build/release/Byre" "$3"
|
cp "$2/mac/.build/release/Byre" "$3"
|
||||||
cp "$2/hands/.build/release/byre-hands" "$4"
|
cp "$2/hands/.build/release/byre-hands" "$4"
|
||||||
|
cp "$2/mac/.build/release/byre-update" "$6"
|
||||||
rm -rf "$5"
|
rm -rf "$5"
|
||||||
ditto "$2/mac/.build/release/Sparkle.framework" "$5"
|
ditto "$2/mac/.build/release/Sparkle.framework" "$5"
|
||||||
' sh "$root" "$shared" "$build/Byre" "$build/byre-hands" "$build/Sparkle.framework"
|
' sh "$root" "$shared" "$build/Byre" "$build/byre-hands" "$build/Sparkle.framework" "$build/byre-update"
|
||||||
go -C "$root" build -trimpath -o "$build/server/byre" ./cmd/byre
|
go -C "$root" build -trimpath -o "$build/server/byre" ./cmd/byre
|
||||||
if [ ! "$icns" -nt "$svg" ]; then
|
if [ ! "$icns" -nt "$svg" ]; then
|
||||||
iconset=$build/byre.iconset
|
iconset=$build/byre.iconset
|
||||||
@@ -40,6 +41,7 @@ mkdir -p "$app/Contents/MacOS" "$app/Contents/Helpers" "$app/Contents/Frameworks
|
|||||||
cp "$build/Byre" "$app/Contents/MacOS/Byre"
|
cp "$build/Byre" "$app/Contents/MacOS/Byre"
|
||||||
cp "$build/server/byre" "$app/Contents/Helpers/byre"
|
cp "$build/server/byre" "$app/Contents/Helpers/byre"
|
||||||
cp "$build/byre-hands" "$app/Contents/Helpers/byre-hands"
|
cp "$build/byre-hands" "$app/Contents/Helpers/byre-hands"
|
||||||
|
cp "$build/byre-update" "$app/Contents/Helpers/byre-update"
|
||||||
ditto "$build/Sparkle.framework" "$app/Contents/Frameworks/Sparkle.framework"
|
ditto "$build/Sparkle.framework" "$app/Contents/Frameworks/Sparkle.framework"
|
||||||
cp "$mac/Info.plist" "$app/Contents/Info.plist"
|
cp "$mac/Info.plist" "$app/Contents/Info.plist"
|
||||||
plutil -insert ByreCommit -string "$(git -C "$root" rev-parse HEAD)" "$app/Contents/Info.plist"
|
plutil -insert ByreCommit -string "$(git -C "$root" rev-parse HEAD)" "$app/Contents/Info.plist"
|
||||||
@@ -58,5 +60,6 @@ sign --identifier run.fc.byre --entitlements "$mac/automation.entitlements" "$ap
|
|||||||
sign "$sparkle/Versions/B/Autoupdate"
|
sign "$sparkle/Versions/B/Autoupdate"
|
||||||
sign "$sparkle/Versions/B/Updater.app"
|
sign "$sparkle/Versions/B/Updater.app"
|
||||||
sign "$sparkle"
|
sign "$sparkle"
|
||||||
|
sign --identifier run.fc.byre.update "$app/Contents/Helpers/byre-update"
|
||||||
sign --entitlements "$mac/byre.entitlements" "$app"
|
sign --entitlements "$mac/byre.entitlements" "$app"
|
||||||
echo "$app"
|
echo "$app"
|
||||||
|
|||||||
Reference in New Issue
Block a user