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: []))