From b5528d14d8684f0f60dd76ef911900ae6b6e1afa Mon Sep 17 00:00:00 2001 From: Siim Raud Date: Sat, 28 Feb 2026 18:08:22 +0200 Subject: [PATCH 1/2] feat: upgrade Sparkle from 1.27.1 to 2.9.0 Replace the deprecated SUUpdater singleton API with Sparkle 2's SPUStandardUpdaterController/SPUUpdater architecture: - Create SPUStandardUpdaterController programmatically in ApplicationController.init instead of XIB-based SUUpdater - Wire "Check for Updates..." menu item via IBOutlet + target/action - Rewrite SparkleObserver to use Combine publishers for canCheckForUpdates and lastUpdateCheckDate - Use SPUUpdater.automaticallyChecksForUpdates property directly instead of writing to UserDefaults - Update "Check Now" button to use canCheckForUpdates (allows manual checks even when auto-check is disabled) - Add SUPublicEDKey placeholder in Info.plist for EdDSA transition (retain SUPublicDSAKeyFile during transition period) - Skip updater initialization in test environment to prevent timeout --- English.lproj/MainMenu.xib | 17 ++----- Gas Mask.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 7 ++- Info.plist | 2 + Source/ApplicationController.h | 6 +++ Source/ApplicationController.m | 21 ++++++++- Source/Swift/GasMask-Bridging-Header.h | 1 + Source/Swift/PreferencesView.swift | 2 +- Source/Swift/SparkleObserver.swift | 47 +++++++++++-------- Tests/GasMaskTests/SparkleObserverTests.swift | 11 ++++- 10 files changed, 73 insertions(+), 43 deletions(-) diff --git a/English.lproj/MainMenu.xib b/English.lproj/MainMenu.xib index 5378e5b..8def9ed 100644 --- a/English.lproj/MainMenu.xib +++ b/English.lproj/MainMenu.xib @@ -771,9 +771,6 @@ RemoteHostsManager - - SUUpdater - @@ -1190,12 +1187,12 @@ 552 - - checkForUpdates: - + + checkForUpdatesMenuItem + - 606 + 623 @@ -1795,11 +1792,6 @@ - - 605 - - - 609 @@ -1897,7 +1889,6 @@ com.apple.InterfaceBuilder.CocoaPlugin com.apple.InterfaceBuilder.CocoaPlugin com.apple.InterfaceBuilder.CocoaPlugin - com.apple.InterfaceBuilder.CocoaPlugin com.apple.InterfaceBuilder.CocoaPlugin com.apple.InterfaceBuilder.CocoaPlugin com.apple.InterfaceBuilder.CocoaPlugin diff --git a/Gas Mask.xcodeproj/project.pbxproj b/Gas Mask.xcodeproj/project.pbxproj index 9473ed0..a5d57fc 100644 --- a/Gas Mask.xcodeproj/project.pbxproj +++ b/Gas Mask.xcodeproj/project.pbxproj @@ -1481,7 +1481,7 @@ repositoryURL = "https://github.com/sparkle-project/Sparkle.git"; requirement = { kind = exactVersion; - version = 1.27.1; + version = 2.9.0; }; }; AA1B2C3D4E5F000300000001 /* XCRemoteSwiftPackageReference "MASShortcut" */ = { diff --git a/Gas Mask.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Gas Mask.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 891922a..7d4ac8e 100644 --- a/Gas Mask.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Gas Mask.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,11 @@ { - "originHash" : "74784ff33407b9ca71d04ea3acb135980046aa512ad6ef909abb7da8127b1333", + "originHash" : "34d6a563af95a9a6489907cf183989fcd49db5e2b85aa1c4f84d6c2e053df114", "pins" : [ { "identity" : "masshortcut", "kind" : "remoteSourceControl", "location" : "https://github.com/shpakovski/MASShortcut", "state" : { - "branch" : "master", "revision" : "6f2603c6b6cc18f64a799e5d2c9d3bbc467c413a" } }, @@ -15,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle.git", "state" : { - "revision" : "7918c1c8fc68baa37917eeaa67286b077ad5e393", - "version" : "1.27.1" + "revision" : "21d8df80440b1ca3b65fa82e40782f1e5a9e6ba2", + "version" : "2.9.0" } } ], diff --git a/Info.plist b/Info.plist index bee1ca8..85f039b 100644 --- a/Info.plist +++ b/Info.plist @@ -95,6 +95,8 @@ http://gmask.clockwise.ee/check_update/ SUPublicDSAKeyFile sparkle_dsa_pub.pem + SUPublicEDKey + REPLACE_WITH_EDDSA_PUBLIC_KEY SUScheduledCheckInterval 86400 UTExportedTypeDeclarations diff --git a/Source/ApplicationController.h b/Source/ApplicationController.h index 8aa3fa8..48d2f41 100644 --- a/Source/ApplicationController.h +++ b/Source/ApplicationController.h @@ -21,19 +21,25 @@ #import "HostsMainController.h" @class AboutBoxController; +@class SPUStandardUpdaterController; +@class SPUUpdater; @interface ApplicationController : NSObject { @private IBOutlet NSProgressIndicator *busyIndicator; IBOutlet HostsMainController *hostsController; + IBOutlet NSMenuItem *checkForUpdatesMenuItem; int busyThreads; BOOL shouldQuit; BOOL editorWindowOpened; AboutBoxController *aboutBoxController; + SPUStandardUpdaterController *_updaterController; } + (ApplicationController*)defaultInstance; +@property (nonatomic, readonly) SPUUpdater *updater; + - (IBAction)openPreferencesWindow:(id)sender; - (IBAction)displayAboutBox:(id)sender; - (IBAction)reportBugs:(id)sender; diff --git a/Source/ApplicationController.m b/Source/ApplicationController.m index b35f19d..fd830ea 100644 --- a/Source/ApplicationController.m +++ b/Source/ApplicationController.m @@ -27,6 +27,7 @@ #import "LocalHostsController.h" #import "RemoteHostsController.h" #import "NotificationHelper.h" +#import @interface ApplicationController () { @@ -64,18 +65,31 @@ - (id)init if (self = [super init]) { busyThreads = 0; shouldQuit = YES; - + + BOOL isTesting = NSClassFromString(@"XCTestCase") != nil; + if (!isTesting) { + _updaterController = [[SPUStandardUpdaterController alloc] + initWithStartingUpdater:YES + updaterDelegate:nil + userDriverDelegate:nil]; + } + NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; [nc addObserver:self selector:@selector(activatePreviousFile:) name:ActivatePreviousFileNotification object:nil]; [nc addObserver:self selector:@selector(activateNextFile:) name:ActivateNextFileNotification object:nil]; [nc addObserver:self selector:@selector(notifyOfFileRestored:) name:RestoredHostsFileNotification object:nil]; - + sharedInstance = self; return self; } return sharedInstance; } +- (SPUUpdater *)updater +{ + return _updaterController.updater; +} + -(IBAction)openPreferencesWindow:(id)sender { [self showApplicationInDock]; @@ -179,6 +193,9 @@ - (void)applicationWillFinishLaunching:(NSNotification *)aNotification { (void)[GlobalShortcuts shared]; // Register global hotkeys + checkForUpdatesMenuItem.target = _updaterController; + checkForUpdatesMenuItem.action = @selector(checkForUpdates:); + [NSApp setServicesProvider:self]; [self initStructure]; diff --git a/Source/Swift/GasMask-Bridging-Header.h b/Source/Swift/GasMask-Bridging-Header.h index f9df72d..a64ded7 100644 --- a/Source/Swift/GasMask-Bridging-Header.h +++ b/Source/Swift/GasMask-Bridging-Header.h @@ -5,3 +5,4 @@ #import "Preferences.h" #import "Preferences+Remote.h" #import "LoginItem.h" +#import "ApplicationController.h" diff --git a/Source/Swift/PreferencesView.swift b/Source/Swift/PreferencesView.swift index c221c6e..77f4586 100644 --- a/Source/Swift/PreferencesView.swift +++ b/Source/Swift/PreferencesView.swift @@ -108,7 +108,7 @@ struct UpdateTab: View { Button("Check Now") { sparkleObserver.checkForUpdates() } - .disabled(!sparkleObserver.automaticChecksEnabled) + .disabled(!sparkleObserver.canCheckForUpdates) } .padding(20) } diff --git a/Source/Swift/SparkleObserver.swift b/Source/Swift/SparkleObserver.swift index 66ee4c1..71a387d 100644 --- a/Source/Swift/SparkleObserver.swift +++ b/Source/Swift/SparkleObserver.swift @@ -1,14 +1,15 @@ +import Combine import Foundation import Sparkle -/// Wraps `SUUpdater.shared()` as an `ObservableObject` for SwiftUI. -/// -/// Replaces the XIB-instantiated `SUUpdater` object and `UpdateDateTransformer`. +/// Wraps `SPUUpdater` as an `ObservableObject` for SwiftUI. final class SparkleObserver: ObservableObject { @Published var lastCheckDate: Date? @Published var automaticChecksEnabled: Bool + @Published var canCheckForUpdates = false - private var dateObservation: NSKeyValueObservation? + private var cancellables: Set = [] + private let updater: SPUUpdater? private static let dateFormatter: DateFormatter = { let formatter = DateFormatter() @@ -25,30 +26,36 @@ final class SparkleObserver: ObservableObject { return "Last Checked: \(Self.dateFormatter.string(from: date))" } - init() { - guard let updater = SUUpdater.shared() else { - self.lastCheckDate = nil - self.automaticChecksEnabled = false - return - } - self.lastCheckDate = updater.lastUpdateCheckDate - self.automaticChecksEnabled = updater.automaticallyChecksForUpdates + convenience init() { + self.init(updater: ApplicationController.defaultInstance()?.updater) + } + + init(updater: SPUUpdater?) { + self.updater = updater + self.lastCheckDate = updater?.lastUpdateCheckDate + self.automaticChecksEnabled = updater?.automaticallyChecksForUpdates ?? false - // Observe lastUpdateCheckDate via KVO - dateObservation = updater.observe(\.lastUpdateCheckDate, options: [.new]) { [weak self] _, change in - DispatchQueue.main.async { - self?.lastCheckDate = change.newValue ?? nil + guard let updater else { return } + + updater.publisher(for: \.canCheckForUpdates) + .receive(on: DispatchQueue.main) + .assign(to: &$canCheckForUpdates) + + updater.publisher(for: \.lastUpdateCheckDate) + .receive(on: DispatchQueue.main) + .sink { [weak self] date in + self?.lastCheckDate = date } - } + .store(in: &cancellables) } - /// Writes only to UserDefaults — SUUpdater observes this key via its own KVO. func setAutomaticChecks(_ enabled: Bool) { automaticChecksEnabled = enabled - UserDefaults.standard.set(enabled, forKey: "SUEnableAutomaticChecks") + updater?.automaticallyChecksForUpdates = enabled } func checkForUpdates() { - SUUpdater.shared()?.checkForUpdates(nil) + guard let updater, updater.canCheckForUpdates else { return } + updater.checkForUpdates() } } diff --git a/Tests/GasMaskTests/SparkleObserverTests.swift b/Tests/GasMaskTests/SparkleObserverTests.swift index fd44774..a6c707d 100644 --- a/Tests/GasMaskTests/SparkleObserverTests.swift +++ b/Tests/GasMaskTests/SparkleObserverTests.swift @@ -4,13 +4,20 @@ import XCTest final class SparkleObserverTests: XCTestCase { func testLastCheckDateFormatted_nil_returnsNever() { - let observer = SparkleObserver() + let observer = SparkleObserver(updater: nil) observer.lastCheckDate = nil XCTAssertEqual(observer.lastCheckDateFormatted, "Last Checked: Never") } + func testNilUpdater_defaultState() { + let observer = SparkleObserver(updater: nil) + XCTAssertNil(observer.lastCheckDate) + XCTAssertFalse(observer.automaticChecksEnabled) + XCTAssertFalse(observer.canCheckForUpdates) + } + func testLastCheckDateFormatted_date_returnsFormattedString() { - let observer = SparkleObserver() + let observer = SparkleObserver(updater: nil) // Use a fixed date: 2026-02-28 15:45:00 UTC var components = DateComponents() components.year = 2026 From 851e5e292e78922cd25284c5b624d12f39311778 Mon Sep 17 00:00:00 2001 From: Siim Raud Date: Sat, 28 Feb 2026 19:53:49 +0200 Subject: [PATCH 2/2] refactor: use assign(to:) for all Combine subscriptions in SparkleObserver Replace sink+cancellables with assign(to: &$property) for lastUpdateCheckDate, matching the pattern already used for canCheckForUpdates. Add reactive observation of automaticallyChecksForUpdates so external changes (e.g. Sparkle's first-launch dialog) propagate to the UI. --- Source/Swift/SparkleObserver.swift | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/Source/Swift/SparkleObserver.swift b/Source/Swift/SparkleObserver.swift index 71a387d..4dbd203 100644 --- a/Source/Swift/SparkleObserver.swift +++ b/Source/Swift/SparkleObserver.swift @@ -8,7 +8,6 @@ final class SparkleObserver: ObservableObject { @Published var automaticChecksEnabled: Bool @Published var canCheckForUpdates = false - private var cancellables: Set = [] private let updater: SPUUpdater? private static let dateFormatter: DateFormatter = { @@ -41,17 +40,21 @@ final class SparkleObserver: ObservableObject { .receive(on: DispatchQueue.main) .assign(to: &$canCheckForUpdates) + updater.publisher(for: \.automaticallyChecksForUpdates) + .receive(on: DispatchQueue.main) + .assign(to: &$automaticChecksEnabled) + updater.publisher(for: \.lastUpdateCheckDate) .receive(on: DispatchQueue.main) - .sink { [weak self] date in - self?.lastCheckDate = date - } - .store(in: &cancellables) + .assign(to: &$lastCheckDate) } func setAutomaticChecks(_ enabled: Bool) { - automaticChecksEnabled = enabled - updater?.automaticallyChecksForUpdates = enabled + if let updater { + updater.automaticallyChecksForUpdates = enabled + } else { + automaticChecksEnabled = enabled + } } func checkForUpdates() {