Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions App/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
109 changes: 91 additions & 18 deletions App/Sources/Preferences/Components/CheckForUpdatesView.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// App/Sources/Preferences/Components/CheckForUpdatesView.swift
import AppKit
import SwiftUI
import SharedKit
import Sparkle

@MainActor
Expand All @@ -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
}
}

Expand All @@ -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<Void, Never>?

Expand All @@ -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
}
}
}
Expand Down Expand Up @@ -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)
}
}

Expand Down
Loading
Loading