Skip to content

Commit 6f2c0f3

Browse files
fix: stabilize sparkle update checks
1 parent 284bbd6 commit 6f2c0f3

6 files changed

Lines changed: 267 additions & 42 deletions

File tree

KeyStats.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
A1000031 /* KeyboardHeatmapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000031 /* KeyboardHeatmapViewController.swift */; };
4343
A1000032 /* StatsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000032 /* StatsModels.swift */; };
4444
A1000033 /* KPSDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000033 /* KPSDetailView.swift */; };
45+
A1000034 /* UpdateCheckCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000034 /* UpdateCheckCoordinator.swift */; };
4546
/* End PBXBuildFile section */
4647

4748
/* Begin PBXContainerItemProxy section */
@@ -103,6 +104,7 @@
103104
A2000031 /* KeyboardHeatmapViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardHeatmapViewController.swift; sourceTree = "<group>"; };
104105
A2000032 /* StatsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsModels.swift; sourceTree = "<group>"; };
105106
A2000033 /* KPSDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KPSDetailView.swift; sourceTree = "<group>"; };
107+
A2000034 /* UpdateCheckCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateCheckCoordinator.swift; sourceTree = "<group>"; };
106108
A3000001 /* KeyStats.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KeyStats.app; sourceTree = BUILT_PRODUCTS_DIR; };
107109
A3000002 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
108110
A3000003 /* KeyStats.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KeyStats.entitlements; sourceTree = "<group>"; };
@@ -185,6 +187,7 @@
185187
A2000013 /* LaunchAtLoginManager.swift */,
186188
A2000014 /* SettingsViewController.swift */,
187189
A2000029 /* UpdateManager.swift */,
190+
A2000034 /* UpdateCheckCoordinator.swift */,
188191
A2000012 /* Localizable.strings */,
189192
A2000006 /* Assets.xcassets */,
190193
A2000007 /* Main.storyboard */,
@@ -353,6 +356,7 @@
353356
A1000014 /* HoverIconButton.swift in Sources */,
354357
A1000015 /* NotificationManager.swift in Sources */,
355358
A1000029 /* UpdateManager.swift in Sources */,
359+
A1000034 /* UpdateCheckCoordinator.swift in Sources */,
356360
A1000030 /* KeyboardHeatmapWindowController.swift in Sources */,
357361
A1000031 /* KeyboardHeatmapViewController.swift in Sources */,
358362
);

KeyStats/StatsPopoverViewController.swift

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -665,22 +665,28 @@ class StatsPopoverViewController: NSViewController {
665665

666666
private func startUpdateAvailabilityUpdates() {
667667
if let token = updateAvailabilityToken {
668-
UpdateManager.shared.removeUpdateAvailabilityHandler(token)
668+
UpdateManager.shared.removeUpdateButtonStateHandler(token)
669669
}
670670

671-
updateAvailabilityToken = UpdateManager.shared.addUpdateAvailabilityHandler { [weak self] hasUpdate in
672-
self?.checkUpdatesButton.isHidden = !hasUpdate
671+
updateAvailabilityToken = UpdateManager.shared.addUpdateButtonStateHandler { [weak self] state in
672+
self?.applyUpdateButtonState(state)
673673
}
674674
UpdateManager.shared.probeForUpdateAvailability()
675675
}
676676

677677
private func stopUpdateAvailabilityUpdates() {
678678
if let token = updateAvailabilityToken {
679-
UpdateManager.shared.removeUpdateAvailabilityHandler(token)
679+
UpdateManager.shared.removeUpdateButtonStateHandler(token)
680680
}
681681
updateAvailabilityToken = nil
682682
}
683683

684+
private func applyUpdateButtonState(_ state: UpdateButtonState) {
685+
checkUpdatesButton.isHidden = state.isHidden
686+
checkUpdatesButton.isEnabled = state.isEnabled
687+
updatePreferredPopoverSizeIfNeeded()
688+
}
689+
684690
private func scheduleStatsRefresh() {
685691
guard !pendingStatsRefresh else { return }
686692
pendingStatsRefresh = true
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import Foundation
2+
3+
enum UpdateCheckRequestAction: Equatable {
4+
case none
5+
case queued
6+
case startNow
7+
}
8+
9+
struct UpdateCheckCoordinator {
10+
private(set) var hasPendingForegroundCheck = false
11+
private var lastAvailabilityProbeStartedAt: Date?
12+
13+
mutating func requestForegroundCheck(canCheckForUpdates: Bool) -> UpdateCheckRequestAction {
14+
guard canCheckForUpdates else {
15+
hasPendingForegroundCheck = true
16+
return .queued
17+
}
18+
19+
return .startNow
20+
}
21+
22+
mutating func didUpdateCanCheckForUpdates(_ canCheckForUpdates: Bool) -> UpdateCheckRequestAction {
23+
guard canCheckForUpdates, hasPendingForegroundCheck else { return .none }
24+
hasPendingForegroundCheck = false
25+
return .startNow
26+
}
27+
28+
mutating func requestAvailabilityProbe(
29+
now: Date,
30+
hasAvailableUpdate: Bool,
31+
sessionInProgress: Bool,
32+
throttleInterval: TimeInterval
33+
) -> UpdateCheckRequestAction {
34+
guard !hasAvailableUpdate, !sessionInProgress else { return .none }
35+
36+
if let lastAvailabilityProbeStartedAt,
37+
now.timeIntervalSince(lastAvailabilityProbeStartedAt) < throttleInterval {
38+
return .none
39+
}
40+
41+
lastAvailabilityProbeStartedAt = now
42+
return .startNow
43+
}
44+
}
45+
46+
struct UpdateButtonState: Equatable {
47+
let isHidden: Bool
48+
let isEnabled: Bool
49+
50+
init(hasAvailableUpdate: Bool, canCheckForUpdates: Bool) {
51+
isHidden = !hasAvailableUpdate
52+
isEnabled = hasAvailableUpdate && canCheckForUpdates
53+
}
54+
}

KeyStats/UpdateManager.swift

Lines changed: 115 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,37 @@ import Foundation
22
import Sparkle
33

44
/// Manages Sparkle update checks and lifecycle.
5-
final class UpdateManager {
5+
final class UpdateManager: NSObject {
66
static let shared = UpdateManager()
77

8-
private let updaterController: SPUStandardUpdaterController
9-
private var updateAvailabilityHandlers: [UUID: (Bool) -> Void] = [:]
8+
private lazy var updaterController = SPUStandardUpdaterController(
9+
startingUpdater: true,
10+
updaterDelegate: self,
11+
userDriverDelegate: nil
12+
)
13+
private let availabilityProbeThrottleInterval: TimeInterval = 15 * 60
14+
private var updateButtonStateHandlers: [UUID: (UpdateButtonState) -> Void] = [:]
1015
private var sparkleNotificationObservers: [NSObjectProtocol] = []
16+
private var canCheckForUpdatesObservation: NSKeyValueObservation?
17+
private var updateCheckCoordinator = UpdateCheckCoordinator()
1118
private(set) var hasAvailableUpdate = false
19+
private(set) var canCheckForUpdates = false
1220

13-
private init() {
14-
updaterController = SPUStandardUpdaterController(
15-
startingUpdater: true,
16-
updaterDelegate: nil,
17-
userDriverDelegate: nil
18-
)
21+
private override init() {
22+
super.init()
1923
registerSparkleObservers()
24+
observeCanCheckForUpdates()
2025
probeForUpdateAvailability()
2126
}
2227

2328
// MARK: - Updates
2429

2530
func checkForUpdates() {
2631
if Thread.isMainThread {
27-
updaterController.checkForUpdates(nil)
32+
requestForegroundUpdateCheck()
2833
} else {
2934
DispatchQueue.main.async { [weak self] in
30-
self?.updaterController.checkForUpdates(nil)
35+
self?.requestForegroundUpdateCheck()
3136
}
3237
}
3338
}
@@ -36,8 +41,19 @@ final class UpdateManager {
3641
let probe = { [weak self] in
3742
guard let self = self else { return }
3843
let updater = self.updaterController.updater
39-
guard !updater.sessionInProgress else { return }
40-
updater.checkForUpdateInformation()
44+
switch self.updateCheckCoordinator.requestAvailabilityProbe(
45+
now: Date(),
46+
hasAvailableUpdate: self.hasAvailableUpdate,
47+
sessionInProgress: updater.sessionInProgress,
48+
throttleInterval: self.availabilityProbeThrottleInterval
49+
) {
50+
case .startNow:
51+
updater.checkForUpdateInformation()
52+
case .queued:
53+
break
54+
case .none:
55+
break
56+
}
4157
}
4258

4359
if Thread.isMainThread {
@@ -49,39 +65,30 @@ final class UpdateManager {
4965
}
5066
}
5167

52-
func addUpdateAvailabilityHandler(_ handler: @escaping (Bool) -> Void) -> UUID {
68+
func addUpdateButtonStateHandler(_ handler: @escaping (UpdateButtonState) -> Void) -> UUID {
5369
let token = UUID()
54-
updateAvailabilityHandlers[token] = handler
55-
handler(hasAvailableUpdate)
70+
updateButtonStateHandlers[token] = handler
71+
handler(updateButtonState)
5672
return token
5773
}
5874

59-
func removeUpdateAvailabilityHandler(_ token: UUID) {
60-
updateAvailabilityHandlers.removeValue(forKey: token)
75+
func removeUpdateButtonStateHandler(_ token: UUID) {
76+
updateButtonStateHandlers.removeValue(forKey: token)
6177
}
6278

6379
// MARK: - Private
6480

81+
private var updateButtonState: UpdateButtonState {
82+
UpdateButtonState(
83+
hasAvailableUpdate: hasAvailableUpdate,
84+
canCheckForUpdates: canCheckForUpdates
85+
)
86+
}
87+
6588
private func registerSparkleObservers() {
6689
let center = NotificationCenter.default
6790
let updater = updaterController.updater
6891

69-
let didFindObserver = center.addObserver(
70-
forName: NSNotification.Name.SUUpdaterDidFindValidUpdate,
71-
object: updater,
72-
queue: .main
73-
) { [weak self] _ in
74-
self?.setHasAvailableUpdate(true)
75-
}
76-
77-
let didNotFindObserver = center.addObserver(
78-
forName: NSNotification.Name.SUUpdaterDidNotFindUpdate,
79-
object: updater,
80-
queue: .main
81-
) { [weak self] _ in
82-
self?.setHasAvailableUpdate(false)
83-
}
84-
8592
let willRestartObserver = center.addObserver(
8693
forName: NSNotification.Name.SUUpdaterWillRestart,
8794
object: updater,
@@ -90,12 +97,84 @@ final class UpdateManager {
9097
self?.setHasAvailableUpdate(false)
9198
}
9299

93-
sparkleNotificationObservers = [didFindObserver, didNotFindObserver, willRestartObserver]
100+
sparkleNotificationObservers = [willRestartObserver]
101+
}
102+
103+
private func observeCanCheckForUpdates() {
104+
canCheckForUpdatesObservation = updaterController.updater.observe(
105+
\.canCheckForUpdates,
106+
options: [.initial, .new]
107+
) { [weak self] updater, _ in
108+
DispatchQueue.main.async {
109+
self?.setCanCheckForUpdates(updater.canCheckForUpdates)
110+
}
111+
}
112+
}
113+
114+
private func requestForegroundUpdateCheck() {
115+
let updater = updaterController.updater
116+
setCanCheckForUpdates(updater.canCheckForUpdates, startsPendingCheck: false)
117+
118+
switch updateCheckCoordinator.requestForegroundCheck(canCheckForUpdates: updater.canCheckForUpdates) {
119+
case .startNow:
120+
updaterController.checkForUpdates(nil)
121+
case .queued:
122+
break
123+
case .none:
124+
break
125+
}
94126
}
95127

96128
private func setHasAvailableUpdate(_ hasUpdate: Bool) {
97129
guard hasAvailableUpdate != hasUpdate else { return }
98130
hasAvailableUpdate = hasUpdate
99-
updateAvailabilityHandlers.values.forEach { $0(hasUpdate) }
131+
notifyUpdateButtonStateHandlers()
132+
}
133+
134+
private func setHasAvailableUpdateOnMain(_ hasUpdate: Bool) {
135+
if Thread.isMainThread {
136+
setHasAvailableUpdate(hasUpdate)
137+
} else {
138+
DispatchQueue.main.async { [weak self] in
139+
self?.setHasAvailableUpdate(hasUpdate)
140+
}
141+
}
142+
}
143+
144+
private func setCanCheckForUpdates(_ canCheck: Bool, startsPendingCheck: Bool = true) {
145+
let previousCanCheck = canCheckForUpdates
146+
canCheckForUpdates = canCheck
147+
if previousCanCheck != canCheck {
148+
notifyUpdateButtonStateHandlers()
149+
}
150+
151+
guard startsPendingCheck else { return }
152+
switch updateCheckCoordinator.didUpdateCanCheckForUpdates(canCheck) {
153+
case .startNow:
154+
updaterController.checkForUpdates(nil)
155+
case .queued:
156+
break
157+
case .none:
158+
break
159+
}
160+
}
161+
162+
private func notifyUpdateButtonStateHandlers() {
163+
let state = updateButtonState
164+
updateButtonStateHandlers.values.forEach { $0(state) }
165+
}
166+
}
167+
168+
extension UpdateManager: SPUUpdaterDelegate {
169+
func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
170+
setHasAvailableUpdateOnMain(true)
171+
}
172+
173+
func updaterDidNotFindUpdate(_ updater: SPUUpdater) {
174+
setHasAvailableUpdateOnMain(false)
175+
}
176+
177+
func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) {
178+
setHasAvailableUpdateOnMain(false)
100179
}
101180
}

0 commit comments

Comments
 (0)