mirror of
https://github.com/yattee/yattee.git
synced 2026-05-13 10:55:03 +00:00
Integrate Sparkle auto-updates for macOS Developer ID builds
New Release-DeveloperID configuration gates Sparkle behind a SPARKLE compile flag so the App Store Release build stays Sparkle-free. Adds SPUStandardUpdaterController wrapper, Check for Updates menu command, Advanced Settings section with beta channel toggle, and a Ruby script plus GitHub Actions job that signs each release and publishes the appcast to gh-pages for consumption by Sparkle and Homebrew cask.
This commit is contained in:
109
Yattee/Services/Updates/SparkleUpdater.swift
Normal file
109
Yattee/Services/Updates/SparkleUpdater.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
//
|
||||
// SparkleUpdater.swift
|
||||
// Yattee
|
||||
//
|
||||
// Sparkle-backed in-app updater for the Developer ID build.
|
||||
// Compiled in only when the `SPARKLE` Swift flag is set, which is
|
||||
// enabled for the `Release-DeveloperID` build configuration and
|
||||
// unset for `Release` (App Store / TestFlight) and `Debug`.
|
||||
//
|
||||
// See AGENTS.md "Build Configurations" for the channel split.
|
||||
//
|
||||
|
||||
#if SPARKLE
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Sparkle
|
||||
|
||||
/// Observable wrapper around `SPUStandardUpdaterController` so SwiftUI views
|
||||
/// can bind enable/disable state and trigger manual update checks.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppUpdater {
|
||||
static let shared = AppUpdater()
|
||||
|
||||
/// Whether the "Check for Updates…" menu item should be enabled.
|
||||
/// Mirrors `SPUUpdater.canCheckForUpdates` and tracks it via KVO.
|
||||
private(set) var canCheckForUpdates = false
|
||||
|
||||
/// User preference: receive prerelease (beta) updates in addition to
|
||||
/// stable ones. Persisted in UserDefaults so it survives relaunches.
|
||||
var wantsBetaChannel: Bool {
|
||||
didSet {
|
||||
guard oldValue != wantsBetaChannel else { return }
|
||||
UserDefaults.standard.set(wantsBetaChannel, forKey: Self.wantsBetaKey)
|
||||
// Trigger re-check so the delegate re-reads channels. Deferred off
|
||||
// the main thread — resetUpdateCycle may synchronously touch the
|
||||
// scheduler's feed cache which can stutter SwiftUI scrolling.
|
||||
let updater = self.updaterController.updater
|
||||
Task.detached(priority: .utility) {
|
||||
updater.resetUpdateCycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let wantsBetaKey = "AppUpdater.wantsBetaChannel"
|
||||
|
||||
private let delegate = AppUpdaterDelegate()
|
||||
private let updaterController: SPUStandardUpdaterController
|
||||
private var observation: NSKeyValueObservation?
|
||||
|
||||
private init() {
|
||||
// Load persisted beta preference before constructing the delegate
|
||||
let wantsBeta = UserDefaults.standard.bool(forKey: Self.wantsBetaKey)
|
||||
self.wantsBetaChannel = wantsBeta
|
||||
self.delegate.wantsBeta = wantsBeta
|
||||
|
||||
// `startingUpdater: true` boots the scheduler automatically;
|
||||
// Info.plist `SUEnableAutomaticChecks` governs whether it polls.
|
||||
self.updaterController = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: delegate,
|
||||
userDriverDelegate: nil
|
||||
)
|
||||
|
||||
// Keep delegate in sync when the toggle changes at runtime.
|
||||
self.delegate.wantsBetaProvider = { [weak self] in
|
||||
self?.wantsBetaChannel ?? false
|
||||
}
|
||||
|
||||
// Observe canCheckForUpdates so menu items can enable/disable correctly.
|
||||
// KVO fires on the thread that mutated the property; hop to @MainActor
|
||||
// explicitly. Dedupe to avoid SwiftUI re-render storms if Sparkle
|
||||
// toggles it rapidly during a feed check cycle.
|
||||
self.observation = updaterController.updater.observe(
|
||||
\.canCheckForUpdates,
|
||||
options: [.initial, .new]
|
||||
) { [weak self] updater, _ in
|
||||
let newValue = updater.canCheckForUpdates
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.canCheckForUpdates != newValue else { return }
|
||||
self.canCheckForUpdates = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Triggered by the "Check for Updates…" menu command.
|
||||
func checkForUpdates() {
|
||||
updaterController.checkForUpdates(nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delegate that exposes the user's channel preference to Sparkle.
|
||||
/// Sparkle calls `allowedChannels(for:)` each feed refresh.
|
||||
private final class AppUpdaterDelegate: NSObject, SPUUpdaterDelegate {
|
||||
/// Cached snapshot; read on the main actor by `AppUpdater`.
|
||||
var wantsBeta: Bool = false
|
||||
/// Live accessor so the delegate reflects the current toggle state.
|
||||
var wantsBetaProvider: (() -> Bool)?
|
||||
|
||||
func allowedChannels(for _: SPUUpdater) -> Set<String> {
|
||||
let beta = wantsBetaProvider?() ?? wantsBeta
|
||||
// Empty set = stable only. Adding "beta" means users get both
|
||||
// untagged (stable) items AND items tagged <sparkle:channel>beta</>.
|
||||
return beta ? ["beta"] : []
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user