fix: restore Sparkle automatic update checks (#1597)

This commit is contained in:
Austin Wang 2026-03-17 03:07:38 -07:00 committed by GitHub
parent f585461868
commit e15825826f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 112 additions and 17 deletions

View file

@ -146,8 +146,16 @@
</dict>
</dict>
</dict>
<key>SUAutomaticallyUpdate</key>
<false/>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUFeedURL</key>
<string>https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml</string>
<key>SUScheduledCheckInterval</key>
<integer>86400</integer>
<key>SUSendProfileInfo</key>
<false/>
<key>SUPublicEDKey</key>
<string>$(SPARKLE_PUBLIC_KEY)</string>
</dict>

View file

@ -3,6 +3,47 @@ import Cocoa
import Combine
import SwiftUI
enum UpdateSettings {
static let automaticChecksKey = "SUEnableAutomaticChecks"
static let automaticallyUpdateKey = "SUAutomaticallyUpdate"
static let scheduledCheckIntervalKey = "SUScheduledCheckInterval"
static let sendProfileInfoKey = "SUSendProfileInfo"
static let migrationKey = "cmux.sparkle.automaticChecksMigration.v1"
static let scheduledCheckInterval: TimeInterval = 60 * 60 * 24
static func apply(to defaults: UserDefaults) {
defaults.register(defaults: [
automaticChecksKey: true,
automaticallyUpdateKey: false,
scheduledCheckIntervalKey: scheduledCheckInterval,
sendProfileInfoKey: false,
])
guard !defaults.bool(forKey: migrationKey) else { return }
// Repair older installs that may have ended up with automatic checks disabled
// before the updater defaults were embedded in Info.plist.
defaults.set(true, forKey: automaticChecksKey)
if let interval = defaults.object(forKey: scheduledCheckIntervalKey) as? NSNumber {
if interval.doubleValue <= 0 {
defaults.set(scheduledCheckInterval, forKey: scheduledCheckIntervalKey)
}
} else {
defaults.set(scheduledCheckInterval, forKey: scheduledCheckIntervalKey)
}
if defaults.object(forKey: automaticallyUpdateKey) == nil {
defaults.set(false, forKey: automaticallyUpdateKey)
}
if defaults.object(forKey: sendProfileInfoKey) == nil {
defaults.set(false, forKey: sendProfileInfoKey)
}
defaults.set(true, forKey: migrationKey)
}
}
/// Controller for managing Sparkle updates in cmux.
class UpdateController {
private(set) var updater: SPUUpdater
@ -27,13 +68,8 @@ class UpdateController {
}
init() {
// cmux checks for updates in the background, but keeps automatic download and
// profile submission disabled so all install intent stays user-driven.
let defaults = UserDefaults.standard
defaults.register(defaults: [
"SUSendProfileInfo": false,
"SUAutomaticallyUpdate": false,
])
UpdateSettings.apply(to: defaults)
let hostBundle = Bundle.main
self.userDriver = UpdateDriver(viewModel: .init(), hostBundle: hostBundle)
@ -63,19 +99,22 @@ class UpdateController {
// delegate now suppresses Sparkle's permission UI entirely.
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_RESET_SPARKLE_PERMISSION"] == "1" {
let defaults = UserDefaults.standard
defaults.removeObject(forKey: "SUEnableAutomaticChecks")
defaults.removeObject(forKey: "SUSendProfileInfo")
defaults.removeObject(forKey: "SUAutomaticallyUpdate")
defaults.removeObject(forKey: UpdateSettings.automaticChecksKey)
defaults.removeObject(forKey: UpdateSettings.automaticallyUpdateKey)
defaults.removeObject(forKey: UpdateSettings.scheduledCheckIntervalKey)
defaults.removeObject(forKey: UpdateSettings.sendProfileInfoKey)
defaults.removeObject(forKey: UpdateSettings.migrationKey)
defaults.synchronize()
UpdateLogStore.shared.append("reset sparkle permission defaults (ui test)")
}
#endif
do {
updater.automaticallyChecksForUpdates = true
updater.automaticallyDownloadsUpdates = false
updater.sendsSystemProfile = false
try updater.start()
didStartUpdater = true
let interval = Int(updater.updateCheckInterval.rounded())
UpdateLogStore.shared.append(
"updater started (autoChecks=\(updater.automaticallyChecksForUpdates), interval=\(interval)s, autoDownloads=\(updater.automaticallyDownloadsUpdates))"
)
} catch {
userDriver.viewModel.state = .error(.init(
error: error,

View file

@ -33,7 +33,15 @@ extension UpdateDriver: SPUUpdaterDelegate {
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeedURL)
UpdateLogStore.shared.append("update channel: \(resolved.isNightly ? "nightly" : "stable")")
recordFeedURLString(resolved.url, usedFallback: resolved.usedFallback)
return infoFeedURL
return resolved.url
}
func updater(_ updater: SPUUpdater, willScheduleUpdateCheckAfterDelay delay: TimeInterval) {
UpdateLogStore.shared.append("next update check scheduled in \(Int(delay.rounded()))s")
}
func updaterWillNotScheduleUpdateCheck(_ updater: SPUUpdater) {
UpdateLogStore.shared.append("automatic update checks disabled; no scheduled check")
}
/// Called when an update is scheduled to install silently,

View file

@ -27,11 +27,11 @@ class UpdateDriver: NSObject, SPUUserDriver {
return
}
#endif
// Never show Sparkle's permission UI. cmux relies on its in-app update pill instead,
// and defaults to manual update checks unless explicitly enabled elsewhere.
UpdateLogStore.shared.append("auto-deny update permission (no UI)")
// Never show Sparkle's permission UI. cmux always enables scheduled checks and keeps
// automatic downloads disabled so installs remain user-driven.
UpdateLogStore.shared.append("auto-allow update permission (no UI)")
DispatchQueue.main.async {
reply(SUUpdatePermissionResponse(automaticUpdateChecks: false, sendSystemProfile: false))
reply(SUUpdatePermissionResponse(automaticUpdateChecks: true, sendSystemProfile: false))
}
}

View file

@ -5190,6 +5190,46 @@ final class UpdateChannelSettingsTests: XCTestCase {
}
}
final class UpdateSettingsTests: XCTestCase {
func testApplyEnablesAutomaticChecksAndDailySchedule() {
let defaults = makeDefaults()
UpdateSettings.apply(to: defaults)
XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticChecksKey))
XCTAssertEqual(defaults.double(forKey: UpdateSettings.scheduledCheckIntervalKey), UpdateSettings.scheduledCheckInterval)
XCTAssertFalse(defaults.bool(forKey: UpdateSettings.automaticallyUpdateKey))
XCTAssertFalse(defaults.bool(forKey: UpdateSettings.sendProfileInfoKey))
XCTAssertTrue(defaults.bool(forKey: UpdateSettings.migrationKey))
}
func testApplyRepairsLegacyDisabledAutomaticChecksOnce() {
let defaults = makeDefaults()
defaults.set(false, forKey: UpdateSettings.automaticChecksKey)
defaults.set(0, forKey: UpdateSettings.scheduledCheckIntervalKey)
defaults.set(true, forKey: UpdateSettings.automaticallyUpdateKey)
UpdateSettings.apply(to: defaults)
XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticChecksKey))
XCTAssertEqual(defaults.double(forKey: UpdateSettings.scheduledCheckIntervalKey), UpdateSettings.scheduledCheckInterval)
XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticallyUpdateKey))
defaults.set(false, forKey: UpdateSettings.automaticChecksKey)
UpdateSettings.apply(to: defaults)
XCTAssertFalse(defaults.bool(forKey: UpdateSettings.automaticChecksKey))
}
private func makeDefaults() -> UserDefaults {
let suiteName = "UpdateSettingsTests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
fatalError("Failed to create isolated UserDefaults suite")
}
defaults.removePersistentDomain(forName: suiteName)
return defaults
}
}
final class SidebarRemoteErrorCopySupportTests: XCTestCase {
func testMenuLabelIsNilWhenThereAreNoErrors() {
XCTAssertNil(SidebarRemoteErrorCopySupport.menuLabel(for: []))