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:
Arkadiusz Fal
2026-04-23 04:51:00 +02:00
parent 29c67d3276
commit a2a4691957
14 changed files with 630 additions and 13 deletions

View File

@@ -4,6 +4,15 @@
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<!-- Sparkle auto-updates (Developer ID / Homebrew cask build only; ignored on App Store + iOS + tvOS) -->
<key>SUFeedURL</key>
<string>https://yattee.github.io/yattee/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>BLtSfi3Epsl97XpMy734PhlbscxWwWpi6moT/S++A+4=</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUEnableInstallerLauncherService</key>
<true/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>stream.yattee.app.feedRefresh</string>

View File

@@ -5142,6 +5142,16 @@
}
}
},
"menu.app.checkForUpdates" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Check for updates..."
}
}
}
},
"menu.app.settings" : {
"localizations" : {
"en" : {
@@ -14338,6 +14348,36 @@
}
}
},
"settings.updates.footer" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Check for and install new versions of Yattee. Beta updates include prereleases."
}
}
}
},
"settings.updates.receiveBeta" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Receive beta updates"
}
}
}
},
"settings.updates.title" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Updates"
}
}
}
},
"settings.videoActions.header" : {
"localizations" : {
"en" : {

View 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

View File

@@ -27,6 +27,9 @@ struct AdvancedSettingsView: View {
var body: some View {
SettingsFormContainer {
#if SPARKLE && os(macOS)
updatesSection
#endif
streamDetailsSection
mpvSection
settingsSection
@@ -350,6 +353,26 @@ struct AdvancedSettingsView: View {
#if SPARKLE && os(macOS)
@ViewBuilder
private var updatesSection: some View {
SettingsFormSection("settings.updates.title", footer: "settings.updates.footer") {
Toggle(isOn: Binding(
get: { AppUpdater.shared.wantsBetaChannel },
set: { AppUpdater.shared.wantsBetaChannel = $0 }
)) {
Label(String(localized: "settings.updates.receiveBeta"), systemImage: "testtube.2")
}
Button {
AppUpdater.shared.checkForUpdates()
} label: {
Label(String(localized: "menu.app.checkForUpdates"), systemImage: "arrow.triangle.2.circlepath")
}
.disabled(!AppUpdater.shared.canCheckForUpdates)
}
}
#endif
@ViewBuilder
private var developerSection: some View {
SettingsFormSection(footer: "settings.developer.footer") {

View File

@@ -0,0 +1,27 @@
//
// CheckForUpdatesMenuItem.swift
// Yattee
//
// "Check for Updates" menu item shown in the macOS app menu for
// Developer ID builds. Wired to the Sparkle-backed `AppUpdater`.
//
// Compiled in only when the `SPARKLE` Swift flag is set (i.e. the
// `Release-DeveloperID` configuration). See AGENTS.md.
//
#if SPARKLE && os(macOS)
import SwiftUI
struct CheckForUpdatesMenuItem: View {
@State private var updater = AppUpdater.shared
var body: some View {
Button(String(localized: "menu.app.checkForUpdates")) {
updater.checkForUpdates()
}
.disabled(!updater.canCheckForUpdates)
}
}
#endif

View File

@@ -310,6 +310,11 @@ struct YatteeApp: App {
CommandGroup(replacing: .appSettings) {
SettingsWindowMenuItem()
}
#if SPARKLE
CommandGroup(after: .appInfo) {
CheckForUpdatesMenuItem()
}
#endif
}
#endif
}