diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings index 29757cb..d010333 100644 --- a/App/Resources/Localizable.xcstrings +++ b/App/Resources/Localizable.xcstrings @@ -8295,6 +8295,160 @@ } } } + }, + "Automatically Check for Updates" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アップデートを自動確認" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "업데이트 자동 확인" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动检查更新" + } + } + } + }, + "Look for new versions in the background" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "バックグラウンドで新しいバージョンを確認します" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "백그라운드에서 새 버전을 확인합니다" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在后台查找新版本" + } + } + } + }, + "Check Frequency" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "確認の頻度" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확인 주기" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "检查频率" + } + } + } + }, + "Daily" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "毎日" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "매일" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每天" + } + } + } + }, + "Weekly" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "毎週" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "매주" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每周" + } + } + } + }, + "Monthly" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "毎月" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "매월" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每月" + } + } + } + }, + "Look for a new version now" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今すぐ新しいバージョンを確認します" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지금 새 버전을 확인합니다" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "立即查找新版本" + } + } + } } }, "version" : "1.1" diff --git a/App/Sources/Preferences/Components/CheckForUpdatesView.swift b/App/Sources/Preferences/Components/CheckForUpdatesView.swift index e59c66a..e865b8f 100644 --- a/App/Sources/Preferences/Components/CheckForUpdatesView.swift +++ b/App/Sources/Preferences/Components/CheckForUpdatesView.swift @@ -1,6 +1,7 @@ // App/Sources/Preferences/Components/CheckForUpdatesView.swift import AppKit import SwiftUI +import SharedKit import Sparkle @MainActor @@ -18,10 +19,24 @@ final class UpdateManager: NSObject, ObservableObject { @Published private(set) var canCheckForUpdates = false @Published private(set) var status: Status? + @Published var automaticallyChecksForUpdates: Bool = true { + didSet { + guard oldValue != automaticallyChecksForUpdates else { return } + updater.automaticallyChecksForUpdates = automaticallyChecksForUpdates + } + } + @Published var updateCheckFrequency: UpdateCheckFrequency = .daily { + didSet { + guard oldValue != updateCheckFrequency else { return } + updater.updateCheckInterval = updateCheckFrequency.timeInterval + } + } + /// Independent of `automaticallyChecksForUpdates`: switching background + /// checks off must not disturb what the user chose here. @Published var automaticallyDownloadsUpdates: Bool = false { didSet { guard oldValue != automaticallyDownloadsUpdates else { return } - updater.automaticallyDownloadsUpdates = automaticallyDownloadsUpdates + updater.storedAutomaticallyDownloadsUpdates = automaticallyDownloadsUpdates } } @@ -33,6 +48,7 @@ final class UpdateManager: NSObject, ObservableObject { private var manualCheckState: ManualCheckState = .none private var probeFoundValidUpdate = false private var canCheckObservation: NSKeyValueObservation? + private var autoCheckObservation: NSKeyValueObservation? private var autoDownloadObservation: NSKeyValueObservation? private var clearStatusTask: Task? @@ -42,19 +58,45 @@ final class UpdateManager: NSObject, ObservableObject { userDriverDelegate: nil ) - var updater: SPUUpdater { updaterController.updater } + private let injectedUpdater: (any SoftwareUpdating)? + + var updater: any SoftwareUpdating { injectedUpdater ?? updaterController.updater } - override init() { + /// - Parameter updater: Overrides Sparkle's updater. Tests pass a stand-in; + /// the app passes nothing so the real `SPUStandardUpdaterController` is used. + init(updater: (any SoftwareUpdating)? = nil) { + injectedUpdater = updater super.init() - automaticallyDownloadsUpdates = updater.automaticallyDownloadsUpdates - canCheckObservation = updater.observe(\.canCheckForUpdates, options: [.initial, .new]) { [weak self] updater, _ in + // Property observers don't run inside an initializer, so seeding these + // never writes back to the updater. + canCheckForUpdates = self.updater.canCheckForUpdates + automaticallyChecksForUpdates = self.updater.automaticallyChecksForUpdates + updateCheckFrequency = UpdateCheckFrequency(closestTo: self.updater.updateCheckInterval) + automaticallyDownloadsUpdates = self.updater.storedAutomaticallyDownloadsUpdates + startObservingSparkleUpdater() + } + + /// Sparkle's properties are KVO-compliant; a stand-in updater is not, and + /// keeps whatever state the test sets on it. + private func startObservingSparkleUpdater() { + guard let sparkleUpdater = updater as? SPUUpdater else { return } + + canCheckObservation = sparkleUpdater.observe(\.canCheckForUpdates, options: [.initial, .new]) { [weak self] updater, _ in Task { @MainActor in self?.canCheckForUpdates = updater.canCheckForUpdates } } - autoDownloadObservation = updater.observe(\.automaticallyDownloadsUpdates, options: [.new]) { [weak self] updater, _ in + autoCheckObservation = sparkleUpdater.observe(\.automaticallyChecksForUpdates, options: [.new]) { [weak self] updater, _ in Task { @MainActor in - self?.automaticallyDownloadsUpdates = updater.automaticallyDownloadsUpdates + self?.automaticallyChecksForUpdates = updater.automaticallyChecksForUpdates + } + } + // Sparkle's own update dialog can flip the auto-install preference, so + // stay in sync with it — but re-read the stored value rather than the + // masked one this notification carries. + autoDownloadObservation = sparkleUpdater.observe(\.automaticallyDownloadsUpdates, options: [.new]) { [weak self] updater, _ in + Task { @MainActor in + self?.automaticallyDownloadsUpdates = updater.storedAutomaticallyDownloadsUpdates } } } @@ -84,38 +126,69 @@ final class UpdateManager: NSObject, ObservableObject { manualCheckState = .none probeFoundValidUpdate = false } -} -extension UpdateManager: SPUUpdaterDelegate { - func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { + // MARK: Manual probe outcomes + // + // Split out from the `SPUUpdaterDelegate` callbacks, whose signatures require + // a live `SPUUpdater`, so the manual-check flow stays testable. + + /// The silent probe found an update, so the interactive flow can take over. + func handleProbeFoundUpdate() { guard manualCheckState == .probing else { return } probeFoundValidUpdate = true status = nil } - func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) { + /// The silent probe found nothing. + func handleProbeFoundNoUpdate(error: any Error) { guard manualCheckState == .probing else { return } let message = error.localizedDescription.isEmpty ? String(localized: "You’re up to date!") : error.localizedDescription showStatus(message, kind: .success) } - func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { + /// The silent probe was aborted. Sparkle reports "no update found" this way + /// too (error 1001), which `handleProbeFoundNoUpdate` has already covered. + func handleProbeAborted(error: any Error) { guard manualCheckState == .probing else { return } let nsError = error as NSError guard !(nsError.domain == SUSparkleErrorDomain && nsError.code == 1001) else { return } showStatus(error.localizedDescription, kind: .error, autoDismissAfter: .seconds(6)) } - func updater(_ updater: SPUUpdater, didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, error: (any Error)?) { - guard manualCheckState == .probing, updateCheck == .updateInformation else { return } + /// The silent probe finished. Hand over to Sparkle's interactive flow when + /// there is something to install. + /// + /// This is user-initiated, so Sparkle always presents the update rather than + /// installing it silently, whatever the automatic check and install settings + /// happen to be. + func handleProbeFinished(error: (any Error)?) { + guard manualCheckState == .probing else { return } let shouldLaunchInteractiveFlow = probeFoundValidUpdate && error == nil clearManualProbeState() - if shouldLaunchInteractiveFlow { - status = nil - updater.checkForUpdates() - } + guard shouldLaunchInteractiveFlow else { return } + status = nil + updater.checkForUpdates() + } +} + +extension UpdateManager: SPUUpdaterDelegate { + func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { + handleProbeFoundUpdate() + } + + func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) { + handleProbeFoundNoUpdate(error: error) + } + + func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { + handleProbeAborted(error: error) + } + + func updater(_ updater: SPUUpdater, didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, error: (any Error)?) { + guard updateCheck == .updateInformation else { return } + handleProbeFinished(error: error) } } diff --git a/App/Sources/Preferences/Components/SoftwareUpdating.swift b/App/Sources/Preferences/Components/SoftwareUpdating.swift new file mode 100644 index 0000000..7d64a17 --- /dev/null +++ b/App/Sources/Preferences/Components/SoftwareUpdating.swift @@ -0,0 +1,64 @@ +// App/Sources/Preferences/Components/SoftwareUpdating.swift +import Foundation +import Sparkle + +/// The slice of Sparkle's updater that Capso drives. +/// +/// `UpdateManager` talks to this instead of `SPUUpdater` directly so its behaviour +/// can be unit tested without starting a real updater. `SPUUpdater` is already +/// main-actor isolated (`NS_SWIFT_UI_ACTOR`), matching this protocol's isolation. +@MainActor +protocol SoftwareUpdating: AnyObject { + /// Whether the user can start an update check right now. + var canCheckForUpdates: Bool { get } + /// Whether Sparkle checks for updates in the background. + var automaticallyChecksForUpdates: Bool { get set } + /// The stored "install updates automatically" preference. + /// + /// Deliberately not `SPUUpdater.automaticallyDownloadsUpdates`: that getter + /// reports `false` whenever automatic checks are off, which would drag the + /// two settings around together. + var storedAutomaticallyDownloadsUpdates: Bool { get set } + /// How often background checks run, in seconds. + var updateCheckInterval: TimeInterval { get set } + + /// Silently fetches update information without showing Sparkle's UI. + func checkForUpdateInformation() + /// Runs a user-initiated check, showing Sparkle's UI. + func checkForUpdates() +} + +/// Reads and writes Sparkle's persisted "install updates automatically" +/// preference, bypassing `SPUUpdater`'s accessors for it. +/// +/// Both of Sparkle's accessors are gated on `allowsAutomaticUpdates`, which is +/// itself tied to `automaticallyChecksForUpdates`: the getter reports `false` +/// and the setter silently drops the write while background checks are off +/// (`SPUUpdaterSettings.setAutomaticallyDownloadsUpdates:`). Capso keeps the two +/// settings independent, so it goes to the stored value directly. Sparkle +/// observes this key and picks the change up on its own. +enum AutomaticInstallPreference { + static let key = "SUAutomaticallyUpdate" + + /// - Parameter fallback: The bundle's `SUAutomaticallyUpdate` value, used + /// until the user has made a choice. + static func value(in defaults: UserDefaults, fallback: Bool?) -> Bool { + defaults.object(forKey: key) as? Bool ?? fallback ?? false + } + + static func setValue(_ newValue: Bool, in defaults: UserDefaults) { + defaults.set(newValue, forKey: key) + } +} + +extension SPUUpdater: SoftwareUpdating { + var storedAutomaticallyDownloadsUpdates: Bool { + get { + AutomaticInstallPreference.value( + in: .standard, + fallback: Bundle.main.object(forInfoDictionaryKey: AutomaticInstallPreference.key) as? Bool + ) + } + set { AutomaticInstallPreference.setValue(newValue, in: .standard) } + } +} diff --git a/App/Sources/Preferences/Components/UpdatesSettingsSection.swift b/App/Sources/Preferences/Components/UpdatesSettingsSection.swift new file mode 100644 index 0000000..f4b4a82 --- /dev/null +++ b/App/Sources/Preferences/Components/UpdatesSettingsSection.swift @@ -0,0 +1,50 @@ +// App/Sources/Preferences/Components/UpdatesSettingsSection.swift +import SwiftUI +import SharedKit + +/// The Updates group in Preferences → General. +/// +/// Lives in its own view so it can observe `UpdateManager` directly: the +/// frequency and auto-install rows have to grey out as soon as automatic +/// checks are switched off. Greying them leaves both stored values alone, so +/// switching checks back on restores what the user had. +struct UpdatesSettingsSection: View { + @ObservedObject var updateManager: UpdateManager + + var body: some View { + SettingGroup(title: "Updates") { + SettingCard { + SettingRow( + label: "Automatically Check for Updates", + sublabel: "Look for new versions in the background" + ) { + Toggle("", isOn: $updateManager.automaticallyChecksForUpdates) + .toggleStyle(.switch) + .controlSize(.small) + } + SettingRow(label: "Check Frequency", showDivider: true) { + Picker("", selection: $updateManager.updateCheckFrequency) { + Text("Daily").tag(UpdateCheckFrequency.daily) + Text("Weekly").tag(UpdateCheckFrequency.weekly) + Text("Monthly").tag(UpdateCheckFrequency.monthly) + } + .frame(width: 130) + .disabled(!updateManager.automaticallyChecksForUpdates) + } + SettingRow( + label: "Automatically Install Updates", + sublabel: "Install updates in the background when available", + showDivider: true + ) { + Toggle("", isOn: $updateManager.automaticallyDownloadsUpdates) + .toggleStyle(.switch) + .controlSize(.small) + .disabled(!updateManager.automaticallyChecksForUpdates) + } + SettingRow(label: "Check for Updates", sublabel: "Look for a new version now", showDivider: true) { + CheckForUpdatesView(updateManager: updateManager) + } + } + } + } +} diff --git a/App/Sources/Preferences/Tabs/GeneralSettingsView.swift b/App/Sources/Preferences/Tabs/GeneralSettingsView.swift index b194e15..a98a4a9 100644 --- a/App/Sources/Preferences/Tabs/GeneralSettingsView.swift +++ b/App/Sources/Preferences/Tabs/GeneralSettingsView.swift @@ -77,21 +77,7 @@ struct GeneralSettingsView: View { } if let updateManager { - SettingGroup(title: "Updates") { - SettingCard { - SettingRow(label: "Automatically Install Updates", sublabel: "Install updates in the background when available") { - Toggle("", isOn: Binding( - get: { updateManager.automaticallyDownloadsUpdates }, - set: { updateManager.automaticallyDownloadsUpdates = $0 } - )) - .toggleStyle(.switch) - .controlSize(.small) - } - SettingRow(label: "Check for Updates", sublabel: "Automatically checks daily", showDivider: true) { - CheckForUpdatesView(updateManager: updateManager) - } - } - } + UpdatesSettingsSection(updateManager: updateManager) } SettingGroup(title: "History") { diff --git a/App/Tests/UpdateManagerTests.swift b/App/Tests/UpdateManagerTests.swift new file mode 100644 index 0000000..c54f3a9 --- /dev/null +++ b/App/Tests/UpdateManagerTests.swift @@ -0,0 +1,362 @@ +import XCTest +@testable import Capso +import SharedKit + +/// Stand-in for Sparkle's `SPUUpdater` so `UpdateManager` can be exercised +/// without starting a real updater inside the test host. +@MainActor +final class FakeSoftwareUpdater: SoftwareUpdating { + var canCheckForUpdates = true + var updateCheckInterval: TimeInterval = 86_400 + var checkForUpdateInformationCount = 0 + var checkForUpdatesCount = 0 + + var automaticallyChecksForUpdates = true { + didSet { automaticallyChecksForUpdatesWriteCount += 1 } + } + private(set) var automaticallyChecksForUpdatesWriteCount = 0 + + var storedAutomaticallyDownloadsUpdates = false { + didSet { automaticallyDownloadsUpdatesWriteCount += 1 } + } + private(set) var automaticallyDownloadsUpdatesWriteCount = 0 + + func checkForUpdateInformation() { + checkForUpdateInformationCount += 1 + } + + func checkForUpdates() { + checkForUpdatesCount += 1 + } +} + +/// The values Sparkle starts from before the user picks anything, which live in +/// the app's Info.plist rather than in code. +/// +/// These are deliberately unchanged by this feature: an existing install has +/// nothing stored for them, so editing the plist would silently move everyone +/// who upgrades. Anyone who wants a different interval can pick one. +final class ShippedUpdateDefaultsTests: XCTestCase { + func testAutomaticChecksAreOnByDefault() { + XCTAssertEqual(Bundle.main.object(forInfoDictionaryKey: "SUEnableAutomaticChecks") as? Bool, true) + } + + func testAutomaticInstallIsOffByDefault() { + XCTAssertNil(Bundle.main.object(forInfoDictionaryKey: AutomaticInstallPreference.key)) + } + + func testDefaultCheckFrequencyIsDaily() { + let interval = Bundle.main.object(forInfoDictionaryKey: "SUScheduledCheckInterval") as? TimeInterval + + XCTAssertEqual(interval, UpdateCheckFrequency.daily.timeInterval) + XCTAssertEqual(UpdateCheckFrequency(closestTo: interval ?? 0), .daily) + } +} + +/// Sparkle drops `setAutomaticallyDownloadsUpdates:` while automatic checks are +/// off, so Capso persists the preference by writing Sparkle's own key. These +/// cover that storage directly, since no stand-in updater can catch it. +final class AutomaticInstallPreferenceTests: XCTestCase { + private func makeDefaults(_ name: String) -> UserDefaults { + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return defaults + } + + func testDefaultsToFalseWhenNothingIsStored() { + let defaults = makeDefaults("test.automaticInstall.unset") + + XCTAssertFalse(AutomaticInstallPreference.value(in: defaults, fallback: nil)) + } + + func testFallsBackToTheInfoPlistValue() { + let defaults = makeDefaults("test.automaticInstall.fallback") + + XCTAssertTrue(AutomaticInstallPreference.value(in: defaults, fallback: true)) + XCTAssertFalse(AutomaticInstallPreference.value(in: defaults, fallback: false)) + } + + func testStoredValueWinsOverTheInfoPlistValue() { + let defaults = makeDefaults("test.automaticInstall.override") + + AutomaticInstallPreference.setValue(false, in: defaults) + + XCTAssertFalse(AutomaticInstallPreference.value(in: defaults, fallback: true)) + } + + func testWritingPersistsUnderSparklesOwnKey() { + let defaults = makeDefaults("test.automaticInstall.persist") + + AutomaticInstallPreference.setValue(true, in: defaults) + + XCTAssertTrue(AutomaticInstallPreference.value(in: defaults, fallback: nil)) + XCTAssertEqual(defaults.object(forKey: "SUAutomaticallyUpdate") as? Bool, true) + } +} + +@MainActor +final class UpdateManagerTests: XCTestCase { + func testInitReadsCurrentUpdaterState() { + let updater = FakeSoftwareUpdater() + updater.canCheckForUpdates = false + updater.storedAutomaticallyDownloadsUpdates = true + + let manager = UpdateManager(updater: updater) + + XCTAssertFalse(manager.canCheckForUpdates) + XCTAssertTrue(manager.automaticallyDownloadsUpdates) + } + + func testInitReadsAutomaticCheckSetting() { + let updater = FakeSoftwareUpdater() + updater.automaticallyChecksForUpdates = false + + let manager = UpdateManager(updater: updater) + + XCTAssertFalse(manager.automaticallyChecksForUpdates) + } + + func testDisablingAutomaticChecksWritesThroughToTheUpdater() { + let updater = FakeSoftwareUpdater() + let manager = UpdateManager(updater: updater) + + manager.automaticallyChecksForUpdates = false + + XCTAssertFalse(updater.automaticallyChecksForUpdates) + } + + func testEnablingAutomaticChecksWritesThroughToTheUpdater() { + let updater = FakeSoftwareUpdater() + updater.automaticallyChecksForUpdates = false + let manager = UpdateManager(updater: updater) + + manager.automaticallyChecksForUpdates = true + + XCTAssertTrue(updater.automaticallyChecksForUpdates) + } + + func testSettingTheSameAutomaticCheckValueDoesNotRewrite() { + let updater = FakeSoftwareUpdater() + let manager = UpdateManager(updater: updater) + let writes = updater.automaticallyChecksForUpdatesWriteCount + + manager.automaticallyChecksForUpdates = true + + XCTAssertEqual(updater.automaticallyChecksForUpdatesWriteCount, writes) + } + + func testInitDerivesFrequencyFromTheUpdaterInterval() { + let updater = FakeSoftwareUpdater() + updater.updateCheckInterval = UpdateCheckFrequency.weekly.timeInterval + + let manager = UpdateManager(updater: updater) + + XCTAssertEqual(manager.updateCheckFrequency, .weekly) + } + + func testInitSnapsAnUnknownIntervalToTheClosestFrequency() { + let updater = FakeSoftwareUpdater() + updater.updateCheckInterval = 3_600 + + let manager = UpdateManager(updater: updater) + + XCTAssertEqual(manager.updateCheckFrequency, .daily) + } + + func testChangingFrequencyWritesTheIntervalToTheUpdater() { + let updater = FakeSoftwareUpdater() + let manager = UpdateManager(updater: updater) + + manager.updateCheckFrequency = .monthly + + XCTAssertEqual(updater.updateCheckInterval, UpdateCheckFrequency.monthly.timeInterval) + } + + func testChangingFrequencyLeavesTheAutomaticCheckSettingAlone() { + let updater = FakeSoftwareUpdater() + let manager = UpdateManager(updater: updater) + let writes = updater.automaticallyChecksForUpdatesWriteCount + + manager.updateCheckFrequency = .weekly + + XCTAssertTrue(updater.automaticallyChecksForUpdates) + XCTAssertEqual(updater.automaticallyChecksForUpdatesWriteCount, writes) + } + + func testFrequencyStillPersistsWhileAutomaticChecksAreOff() { + let updater = FakeSoftwareUpdater() + updater.automaticallyChecksForUpdates = false + let manager = UpdateManager(updater: updater) + + manager.updateCheckFrequency = .weekly + + XCTAssertEqual(updater.updateCheckInterval, UpdateCheckFrequency.weekly.timeInterval) + } + + func testDisablingAutomaticChecksLeavesTheAutomaticInstallToggleOn() { + let updater = FakeSoftwareUpdater() + updater.storedAutomaticallyDownloadsUpdates = true + let manager = UpdateManager(updater: updater) + + manager.automaticallyChecksForUpdates = false + + XCTAssertTrue(manager.automaticallyDownloadsUpdates) + XCTAssertTrue(updater.storedAutomaticallyDownloadsUpdates) + } + + func testDisablingAutomaticChecksNeverWritesTheAutomaticInstallPreference() { + let updater = FakeSoftwareUpdater() + updater.storedAutomaticallyDownloadsUpdates = true + let manager = UpdateManager(updater: updater) + let writes = updater.automaticallyDownloadsUpdatesWriteCount + + manager.automaticallyChecksForUpdates = false + + XCTAssertEqual(updater.automaticallyDownloadsUpdatesWriteCount, writes) + } + + func testAutomaticInstallWritesStillPersistWhileAutomaticChecksAreOff() { + let updater = FakeSoftwareUpdater() + updater.automaticallyChecksForUpdates = false + let manager = UpdateManager(updater: updater) + + manager.automaticallyDownloadsUpdates = true + + XCTAssertTrue(updater.storedAutomaticallyDownloadsUpdates) + } + + func testInitReadsTheStoredAutomaticInstallPreferenceWhileChecksAreOff() { + let updater = FakeSoftwareUpdater() + updater.automaticallyChecksForUpdates = false + updater.storedAutomaticallyDownloadsUpdates = true + + let manager = UpdateManager(updater: updater) + + XCTAssertTrue(manager.automaticallyDownloadsUpdates) + } + + func testEnablingAutomaticChecksLeavesTheAutomaticInstallToggleOff() { + let updater = FakeSoftwareUpdater() + updater.automaticallyChecksForUpdates = false + updater.storedAutomaticallyDownloadsUpdates = false + let manager = UpdateManager(updater: updater) + + manager.automaticallyChecksForUpdates = true + + XCTAssertFalse(manager.automaticallyDownloadsUpdates) + XCTAssertFalse(updater.storedAutomaticallyDownloadsUpdates) + } + + func testManualCheckStillRunsWhileAutomaticChecksAreOff() { + let updater = FakeSoftwareUpdater() + updater.automaticallyChecksForUpdates = false + let manager = UpdateManager(updater: updater) + + manager.checkForUpdates() + + XCTAssertEqual(updater.checkForUpdateInformationCount, 1) + XCTAssertEqual(manager.status?.kind, .checking) + } + + func testManualCheckIsIgnoredWhenTheUpdaterCannotCheck() { + let updater = FakeSoftwareUpdater() + updater.canCheckForUpdates = false + let manager = UpdateManager(updater: updater) + + manager.checkForUpdates() + + XCTAssertEqual(updater.checkForUpdateInformationCount, 0) + XCTAssertNil(manager.status) + } + + // MARK: Manual check with automatic checks off + + func testManualCheckWithAutomaticChecksOffAndAutomaticInstallOnTouchesNoPreference() { + let updater = FakeSoftwareUpdater() + updater.automaticallyChecksForUpdates = false + updater.storedAutomaticallyDownloadsUpdates = true + let manager = UpdateManager(updater: updater) + let checkWrites = updater.automaticallyChecksForUpdatesWriteCount + let installWrites = updater.automaticallyDownloadsUpdatesWriteCount + + manager.checkForUpdates() + + XCTAssertEqual(updater.checkForUpdateInformationCount, 1) + XCTAssertEqual(updater.automaticallyChecksForUpdatesWriteCount, checkWrites) + XCTAssertEqual(updater.automaticallyDownloadsUpdatesWriteCount, installWrites) + XCTAssertFalse(manager.automaticallyChecksForUpdates) + XCTAssertTrue(manager.automaticallyDownloadsUpdates) + } + + func testManualProbeThatFindsAnUpdateEscalatesToTheInteractiveFlow() { + let updater = FakeSoftwareUpdater() + updater.automaticallyChecksForUpdates = false + updater.storedAutomaticallyDownloadsUpdates = true + let manager = UpdateManager(updater: updater) + + manager.checkForUpdates() + manager.handleProbeFoundUpdate() + manager.handleProbeFinished(error: nil) + + XCTAssertEqual(updater.checkForUpdatesCount, 1) + XCTAssertNil(manager.status) + } + + func testEscalatingAManualCheckLeavesBothPreferencesAlone() { + let updater = FakeSoftwareUpdater() + updater.automaticallyChecksForUpdates = false + updater.storedAutomaticallyDownloadsUpdates = true + let manager = UpdateManager(updater: updater) + + manager.checkForUpdates() + manager.handleProbeFoundUpdate() + manager.handleProbeFinished(error: nil) + + XCTAssertFalse(updater.automaticallyChecksForUpdates) + XCTAssertTrue(updater.storedAutomaticallyDownloadsUpdates) + } + + func testManualProbeThatFindsNothingDoesNotEscalate() { + let updater = FakeSoftwareUpdater() + let manager = UpdateManager(updater: updater) + + manager.checkForUpdates() + manager.handleProbeFinished(error: nil) + + XCTAssertEqual(updater.checkForUpdatesCount, 0) + } + + func testFailedManualProbeDoesNotEscalate() { + let updater = FakeSoftwareUpdater() + let manager = UpdateManager(updater: updater) + let failure = NSError(domain: "test.appcast", code: 42) + + manager.checkForUpdates() + manager.handleProbeFoundUpdate() + manager.handleProbeFinished(error: failure) + + XCTAssertEqual(updater.checkForUpdatesCount, 0) + } + + func testProbeCallbacksArrivingOutsideAManualCheckAreIgnored() { + let updater = FakeSoftwareUpdater() + let manager = UpdateManager(updater: updater) + + manager.handleProbeFoundUpdate() + manager.handleProbeFinished(error: nil) + + XCTAssertEqual(updater.checkForUpdatesCount, 0) + XCTAssertNil(manager.status) + } + + func testInitDoesNotWriteBackToTheUpdater() { + let updater = FakeSoftwareUpdater() + let checksWrites = updater.automaticallyChecksForUpdatesWriteCount + let downloadsWrites = updater.automaticallyDownloadsUpdatesWriteCount + + _ = UpdateManager(updater: updater) + + XCTAssertEqual(updater.automaticallyChecksForUpdatesWriteCount, checksWrites) + XCTAssertEqual(updater.automaticallyDownloadsUpdatesWriteCount, downloadsWrites) + } +} diff --git a/Packages/SharedKit/Sources/SharedKit/Settings/UpdateCheckFrequency.swift b/Packages/SharedKit/Sources/SharedKit/Settings/UpdateCheckFrequency.swift new file mode 100644 index 0000000..0d236ca --- /dev/null +++ b/Packages/SharedKit/Sources/SharedKit/Settings/UpdateCheckFrequency.swift @@ -0,0 +1,37 @@ +import Foundation + +/// How often the app checks for updates in the background. +/// +/// The selected interval is stored by Sparkle itself (`SPUUpdater.updateCheckInterval`, +/// persisted as `SUScheduledCheckInterval`), so this type only maps between the +/// user-facing choices and the raw interval Sparkle expects. +public enum UpdateCheckFrequency: String, CaseIterable, Sendable { + case daily + case weekly + case monthly + + /// The background check interval, in seconds. + public var timeInterval: TimeInterval { + switch self { + case .daily: return 86_400 + case .weekly: return 604_800 + case .monthly: return 2_592_000 + } + } + + /// The frequency whose interval sits closest to `interval`. + /// + /// Sparkle stores an arbitrary `TimeInterval`, which may have been written by an + /// older build or by the Info.plist default, so an exact match is not guaranteed. + /// Non-positive intervals resolve to `.daily`. + public init(closestTo interval: TimeInterval) { + guard interval > 0 else { + self = .daily + return + } + + self = Self.allCases.min { lhs, rhs in + abs(lhs.timeInterval - interval) < abs(rhs.timeInterval - interval) + } ?? .daily + } +} diff --git a/Packages/SharedKit/Tests/SharedKitTests/UpdateCheckFrequencyTests.swift b/Packages/SharedKit/Tests/SharedKitTests/UpdateCheckFrequencyTests.swift new file mode 100644 index 0000000..96d418a --- /dev/null +++ b/Packages/SharedKit/Tests/SharedKitTests/UpdateCheckFrequencyTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +@testable import SharedKit + +@Suite("UpdateCheckFrequency") +struct UpdateCheckFrequencyTests { + @Test("Cases are ordered from most to least frequent") + func caseOrder() { + #expect(UpdateCheckFrequency.allCases == [.daily, .weekly, .monthly]) + } + + @Test("Raw values are stable identifiers") + func rawValues() { + #expect(UpdateCheckFrequency.daily.rawValue == "daily") + #expect(UpdateCheckFrequency.weekly.rawValue == "weekly") + #expect(UpdateCheckFrequency.monthly.rawValue == "monthly") + } + + @Test("Each frequency maps to its Sparkle check interval") + func timeIntervals() { + #expect(UpdateCheckFrequency.daily.timeInterval == 86_400) + #expect(UpdateCheckFrequency.weekly.timeInterval == 604_800) + #expect(UpdateCheckFrequency.monthly.timeInterval == 2_592_000) + } + + @Test("Exact intervals round-trip") + func exactIntervalsRoundTrip() { + for frequency in UpdateCheckFrequency.allCases { + #expect(UpdateCheckFrequency(closestTo: frequency.timeInterval) == frequency) + } + } + + @Test("Unknown intervals snap to the closest frequency") + func unknownIntervalsSnap() { + #expect(UpdateCheckFrequency(closestTo: 3_600) == .daily) + #expect(UpdateCheckFrequency(closestTo: 200_000) == .daily) + #expect(UpdateCheckFrequency(closestTo: 500_000) == .weekly) + #expect(UpdateCheckFrequency(closestTo: 1_500_000) == .weekly) + #expect(UpdateCheckFrequency(closestTo: 2_000_000) == .monthly) + #expect(UpdateCheckFrequency(closestTo: 10_000_000) == .monthly) + } + + @Test("Non-positive intervals fall back to daily") + func nonPositiveIntervalsFallBackToDaily() { + #expect(UpdateCheckFrequency(closestTo: 0) == .daily) + #expect(UpdateCheckFrequency(closestTo: -1) == .daily) + } +}