mirror of
https://github.com/yattee/yattee.git
synced 2026-05-13 19:05: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:
@@ -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>
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
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
|
||||
@@ -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") {
|
||||
|
||||
27
Yattee/Views/macOS/CheckForUpdatesMenuItem.swift
Normal file
27
Yattee/Views/macOS/CheckForUpdatesMenuItem.swift
Normal 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
|
||||
@@ -310,6 +310,11 @@ struct YatteeApp: App {
|
||||
CommandGroup(replacing: .appSettings) {
|
||||
SettingsWindowMenuItem()
|
||||
}
|
||||
#if SPARKLE
|
||||
CommandGroup(after: .appInfo) {
|
||||
CheckForUpdatesMenuItem()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user