cmux/Sources/Update/UpdateController.swift
Austin Wang e203c51c7a
Show update-available banner automatically on launch (#1651)
* Show update-available banner automatically on launch

Probe for updates immediately on launch via Sparkle's
checkForUpdateInformation() so the sidebar surfaces a passive update
indicator without waiting for the 24h scheduler. When Sparkle detects
an available update in the background, the pill now shows
"Update Available: X.Y.Z" with accent styling while the updater is
idle. Clicking it triggers the full interactive update flow.

Also fixes thread safety in delegate callbacks by dispatching
@Published mutations to the main queue.

Closes #1643

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add periodic background update probe every 15 minutes

The launch-only probe wouldn't catch updates published while the app
is already running. Add a repeating 15-minute timer that calls
checkForUpdateInformation() so the sidebar banner appears within a
reasonable window after a new version is published.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Change background update probe interval to 30 minutes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Change update check interval to 1 hour and migrate existing users

Reduce Sparkle's scheduled check interval from 24h to 1h so update
banners appear sooner. Migrate users stuck on the old 24h default by
bumping the migration key to v2. Align background probe interval with
the Sparkle check interval instead of hardcoding 30 minutes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 12:30:44 -07:00

366 lines
14 KiB
Swift

import Sparkle
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.v2"
static let previousDefaultScheduledCheckInterval: TimeInterval = 60 * 60 * 24
static let scheduledCheckInterval: TimeInterval = 60 * 60
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 {
let currentInterval = interval.doubleValue
if currentInterval <= 0 ||
abs(currentInterval - previousDefaultScheduledCheckInterval) < 1 {
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
private let userDriver: UpdateDriver
private var installCancellable: AnyCancellable?
private var attemptInstallCancellable: AnyCancellable?
private var didObserveAttemptUpdateProgress: Bool = false
private var noUpdateDismissCancellable: AnyCancellable?
private var noUpdateDismissWorkItem: DispatchWorkItem?
private var readyCheckWorkItem: DispatchWorkItem?
private var backgroundProbeTimer: Timer?
private var didStartUpdater: Bool = false
private let readyRetryDelay: TimeInterval = 0.25
private let readyRetryCount: Int = 20
private let backgroundProbeInterval: TimeInterval = UpdateSettings.scheduledCheckInterval
var viewModel: UpdateViewModel {
userDriver.viewModel
}
/// True if we're force-installing an update.
var isInstalling: Bool {
installCancellable != nil
}
init() {
let defaults = UserDefaults.standard
UpdateSettings.apply(to: defaults)
let hostBundle = Bundle.main
self.userDriver = UpdateDriver(viewModel: .init(), hostBundle: hostBundle)
self.updater = SPUUpdater(
hostBundle: hostBundle,
applicationBundle: hostBundle,
userDriver: userDriver,
delegate: userDriver
)
installNoUpdateDismissObserver()
}
deinit {
installCancellable?.cancel()
attemptInstallCancellable?.cancel()
noUpdateDismissCancellable?.cancel()
noUpdateDismissWorkItem?.cancel()
readyCheckWorkItem?.cancel()
backgroundProbeTimer?.invalidate()
}
/// Start the updater. If startup fails, the error is shown via the custom UI.
func startUpdaterIfNeeded() {
guard !didStartUpdater else { return }
ensureSparkleInstallationCache()
#if DEBUG
// Keep the permission-related defaults resettable for UI tests even though the
// 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: 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 {
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))"
)
startLaunchUpdateProbeIfNeeded()
} catch {
userDriver.viewModel.state = .error(.init(
error: error,
retry: { [weak self] in
self?.userDriver.viewModel.state = .idle
self?.didStartUpdater = false
self?.startUpdaterIfNeeded()
},
dismiss: { [weak self] in
self?.userDriver.viewModel.state = .idle
}
))
}
}
private func startLaunchUpdateProbeIfNeeded() {
guard updater.automaticallyChecksForUpdates else {
UpdateLogStore.shared.append("launch update probe skipped (automatic checks disabled)")
return
}
// Probe immediately on launch so the sidebar can surface a passive update indicator
// without waiting for Sparkle's scheduled check or opening interactive update UI.
UpdateLogStore.shared.append("starting launch update probe")
updater.checkForUpdateInformation()
// Re-probe every hour so the banner appears even if the app has been running
// for a while when a new version is published.
backgroundProbeTimer?.invalidate()
backgroundProbeTimer = Timer.scheduledTimer(withTimeInterval: backgroundProbeInterval, repeats: true) { [weak self] _ in
guard let self, self.updater.automaticallyChecksForUpdates else { return }
UpdateLogStore.shared.append("periodic background update probe")
self.updater.checkForUpdateInformation()
}
}
/// Force install the current update by auto-confirming all installable states.
func installUpdate() {
guard viewModel.state.isInstallable else { return }
guard installCancellable == nil else { return }
installCancellable = viewModel.$state.sink { [weak self] state in
guard let self else { return }
guard state.isInstallable else {
self.installCancellable = nil
return
}
state.confirm()
}
}
/// Check for updates and auto-confirm install if one is found.
func attemptUpdate() {
stopAttemptUpdateMonitoring()
didObserveAttemptUpdateProgress = false
attemptInstallCancellable = viewModel.$state
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self else { return }
if state.isInstallable || !state.isIdle {
self.didObserveAttemptUpdateProgress = true
}
if case .updateAvailable = state {
UpdateLogStore.shared.append("attemptUpdate auto-confirming available update")
state.confirm()
return
}
guard self.didObserveAttemptUpdateProgress, !state.isInstallable else {
return
}
self.stopAttemptUpdateMonitoring()
}
checkForUpdates()
}
/// Check for updates (used by the menu item).
@objc func checkForUpdates() {
UpdateLogStore.shared.append("checkForUpdates invoked (state=\(viewModel.state.isIdle ? "idle" : "busy"))")
checkForUpdatesWhenReady(retries: readyRetryCount)
}
private func performCheckForUpdates() {
startUpdaterIfNeeded()
ensureSparkleInstallationCache()
if viewModel.state == .idle {
updater.checkForUpdates()
return
}
installCancellable?.cancel()
viewModel.state.cancel()
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in
self?.updater.checkForUpdates()
}
}
/// Check for updates once the updater is ready (used by UI tests).
func checkForUpdatesWhenReady(retries: Int = 10) {
readyCheckWorkItem?.cancel()
readyCheckWorkItem = nil
startUpdaterIfNeeded()
ensureSparkleInstallationCache()
let canCheck = updater.canCheckForUpdates
UpdateLogStore.shared.append("checkForUpdatesWhenReady invoked (canCheck=\(canCheck))")
if canCheck {
performCheckForUpdates()
return
}
if viewModel.state.isIdle {
viewModel.state = .checking(.init(cancel: {}))
}
guard retries > 0 else {
UpdateLogStore.shared.append("checkForUpdatesWhenReady timed out")
if case .checking = viewModel.state {
viewModel.state = .error(.init(
error: NSError(
domain: "cmux.update",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Updater is still starting. Try again in a moment."]
),
retry: { [weak self] in self?.checkForUpdates() },
dismiss: { [weak self] in self?.viewModel.state = .idle }
))
}
return
}
let workItem = DispatchWorkItem { [weak self] in
self?.checkForUpdatesWhenReady(retries: retries - 1)
}
readyCheckWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + readyRetryDelay, execute: workItem)
}
/// Validate the check for updates menu item.
func validateMenuItem(_ item: NSMenuItem) -> Bool {
if item.action == #selector(checkForUpdates) {
// Always allow user-initiated checks; Sparkle can safely surface current progress.
return true
}
return true
}
private func stopAttemptUpdateMonitoring() {
attemptInstallCancellable?.cancel()
attemptInstallCancellable = nil
didObserveAttemptUpdateProgress = false
}
private func installNoUpdateDismissObserver() {
noUpdateDismissCancellable = Publishers.CombineLatest(viewModel.$state, viewModel.$overrideState)
.receive(on: DispatchQueue.main)
.sink { [weak self] state, overrideState in
self?.scheduleNoUpdateDismiss(for: state, overrideState: overrideState)
}
}
private func scheduleNoUpdateDismiss(for state: UpdateState, overrideState: UpdateState?) {
noUpdateDismissWorkItem?.cancel()
noUpdateDismissWorkItem = nil
guard overrideState == nil else { return }
guard case .notFound(let notFound) = state else { return }
recordUITestTimestamp(key: "noUpdateShownAt")
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
guard self.viewModel.overrideState == nil,
case .notFound = self.viewModel.state else { return }
withAnimation(.easeInOut(duration: 0.25)) {
self.recordUITestTimestamp(key: "noUpdateHiddenAt")
self.viewModel.state = .idle
}
notFound.acknowledgement()
}
noUpdateDismissWorkItem = workItem
DispatchQueue.main.asyncAfter(
deadline: .now() + UpdateTiming.noUpdateDisplayDuration,
execute: workItem
)
}
private func recordUITestTimestamp(key: String) {
#if DEBUG
let env = ProcessInfo.processInfo.environment
guard env["CMUX_UI_TEST_MODE"] == "1" else { return }
guard let path = env["CMUX_UI_TEST_TIMING_PATH"] else { return }
let url = URL(fileURLWithPath: path)
var payload: [String: Double] = [:]
if let data = try? Data(contentsOf: url),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Double] {
payload = object
}
payload[key] = Date().timeIntervalSince1970
if let data = try? JSONSerialization.data(withJSONObject: payload) {
try? data.write(to: url)
}
#endif
}
private func ensureSparkleInstallationCache() {
guard let bundleIdentifier = Bundle.main.bundleIdentifier else { return }
guard let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return }
let baseURL = cachesURL
.appendingPathComponent(bundleIdentifier)
.appendingPathComponent("org.sparkle-project.Sparkle")
let installURL = baseURL.appendingPathComponent("Installation")
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: installURL.path, isDirectory: &isDirectory) {
if !isDirectory.boolValue {
do {
try FileManager.default.removeItem(at: installURL)
} catch {
UpdateLogStore.shared.append("Failed removing Sparkle installation cache file: \(error)")
return
}
} else {
return
}
}
do {
try FileManager.default.createDirectory(
at: installURL,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
UpdateLogStore.shared.append("Ensured Sparkle installation cache at \(installURL.path)")
} catch {
UpdateLogStore.shared.append("Failed creating Sparkle installation cache: \(error)")
}
}
}