diff --git a/Resources/Info.plist b/Resources/Info.plist index 41572e05..c96a632f 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -146,8 +146,16 @@ + SUAutomaticallyUpdate + + SUEnableAutomaticChecks + SUFeedURL https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml + SUScheduledCheckInterval + 86400 + SUSendProfileInfo + SUPublicEDKey $(SPARKLE_PUBLIC_KEY) diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index 7cc9beb9..ef1176bf 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -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, diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift index b3adfc15..7de114d3 100644 --- a/Sources/Update/UpdateDelegate.swift +++ b/Sources/Update/UpdateDelegate.swift @@ -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, diff --git a/Sources/Update/UpdateDriver.swift b/Sources/Update/UpdateDriver.swift index 04dedebd..289df890 100644 --- a/Sources/Update/UpdateDriver.swift +++ b/Sources/Update/UpdateDriver.swift @@ -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)) } } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 60018a44..5ec9aae7 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -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: []))