grammargem/Sources/GrammaGem/System/UpdaterManager.swift
BigMarc e8b7dff65d Add Sparkle auto-update (appcast on grammagem.app)
Installed apps now show an 'Update available' button (menu bar) and can
one-click install signed updates. Adds the Sparkle dependency, an
UpdaterManager wired into AppState, the menu-bar banner + 'Check for
Updates', and Info.plist SUFeedURL (https://grammagem.app/appcast.xml),
SUPublicEDKey, and scheduled background checks.

Build / release:
- build.sh embeds + rpaths Sparkle.framework; sign-app.sh signs it
  inside-out (XPC services, Updater.app, Autoupdate, framework, then app)
  for a valid, notarizable signature.
- release.sh stamps the version, builds, signs, notarizes, staples, zips,
  and emits the EdDSA-signed appcast.xml to upload to grammagem.app.
- A hidden --update-selftest checks a feed headlessly (CI/build pipeline).

Verified: a v0.1.0 build detects a v0.2.0 update against a local signed
appcast (UPDATE_FOUND: 0.2.0); the signed app passes codesign --strict.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 08:22:35 +02:00

82 lines
3.1 KiB
Swift

import Foundation
import Sparkle
/// Wraps Sparkle's updater. It background-checks the appcast on grammagem.app and
/// publishes `updateAvailableVersion` when a newer, EdDSA-verified build exists, so
/// the menu bar can show an "Update available" button. Triggering an update runs
/// Sparkle's verified download + install flow.
///
/// The updater is only started inside the packaged `.app` (where the Info.plist
/// carries `SUFeedURL` + `SUPublicEDKey`); under `swift run`, tests, or the
/// self-test binaries it stays inert so nothing crashes.
@MainActor
final class UpdaterManager: NSObject, ObservableObject {
@Published private(set) var updateAvailableVersion: String?
private var controller: SPUStandardUpdaterController?
override init() {
super.init()
guard Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") != nil else {
return // not the packaged app — no updater
}
controller = SPUStandardUpdaterController(
startingUpdater: true, updaterDelegate: self, userDriverDelegate: nil)
}
var canCheckForUpdates: Bool { controller?.updater.canCheckForUpdates ?? false }
/// User-initiated check — shows Sparkle's UI (used by the menu item and the
/// "Update" button on the availability banner).
func checkForUpdates() {
controller?.updater.checkForUpdates()
}
}
extension UpdaterManager: SPUUpdaterDelegate {
func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
updateAvailableVersion = item.displayVersionString
}
func updaterDidNotFindUpdate(_ updater: SPUUpdater) {
updateAvailableVersion = nil
}
}
// MARK: - Headless update self-test (build pipeline / CI)
/// Checks `feedURL` for a newer version with no UI and prints the result, then
/// exits. Used to verify the appcast + signature + version-comparison path.
enum UpdaterSelfTest {
static func run(feedURL: String) {
let delegate = UpdateProbeDelegate(feed: feedURL)
UpdateProbeDelegate.retained = delegate
UpdateProbeDelegate.controller = SPUStandardUpdaterController(
startingUpdater: true, updaterDelegate: delegate, userDriverDelegate: nil)
UpdateProbeDelegate.controller?.updater.checkForUpdateInformation()
RunLoop.main.run() // Sparkle's network check needs the run loop; delegate exits.
}
}
private final class UpdateProbeDelegate: NSObject, SPUUpdaterDelegate {
static var controller: SPUStandardUpdaterController?
static var retained: UpdateProbeDelegate?
let feed: String
init(feed: String) { self.feed = feed; super.init() }
func feedURLString(for updater: SPUUpdater) -> String? { feed.isEmpty ? nil : feed }
func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
print("UPDATE_FOUND: \(item.displayVersionString)")
exit(0)
}
func updaterDidNotFindUpdate(_ updater: SPUUpdater) {
print("UPDATE_NONE")
exit(0)
}
func updater(_ updater: SPUUpdater, didAbortWithError error: Error) {
print("UPDATE_ERROR: \(error.localizedDescription)")
exit(1)
}
}