* Fix update attempt refreshing pill without actually updating The attemptUpdate() subscriber watched for .updateAvailable state to auto-confirm, but showUpdateFound used setStateAfterMinimumCheckDelay which delays the transition by up to 2 seconds. During that window, dismissUpdateInstallation (from a background probe race) could cancel the pending transition, reverting state to idle without ever confirming. The subscriber then tore down on the transient idle, silently abandoning the update. Fix: move auto-confirm to the Sparkle driver level via an autoInstallOnNextUpdate flag. When set, showUpdateFound immediately calls reply(.install) bypassing the delay entirely. The subscriber is kept as a fallback but no longer tears down on transient idle while the flag is active. Closes https://github.com/manaflow-ai/cmux/issues/2166 * Revert "Fix update attempt refreshing pill without actually updating" This reverts commit 1cd842dd924bf114b096f222851c47d2e36ad4d9. * Fix update attempt refreshing pill without actually updating The attemptUpdate() subscriber tore down monitoring whenever it saw .idle after observing progress. During check startup (retry loop, background probe race), state can transiently return to .idle before Sparkle's interactive check begins. The subscriber interpreted this as "check completed" and stopped monitoring, so the auto-confirm for .updateAvailable never fired. Fix: add !state.isIdle to the teardown guard so monitoring only stops on terminal failures (.notFound, .error), not transient idle. Closes https://github.com/manaflow-ai/cmux/issues/2166 --------- Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
374 lines
14 KiB
Swift
374 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
|
|
}
|
|
|
|
// Only stop on terminal failure states (.notFound, .error).
|
|
// Don't stop on .idle — the check may still be starting up
|
|
// (e.g. retry loop, background probe finishing).
|
|
guard self.didObserveAttemptUpdateProgress, !state.isInstallable, !state.isIdle 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)
|
|
}
|
|
|
|
/// Check for updates using the custom popover-based UI.
|
|
func checkForUpdatesInCustomUI() {
|
|
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)")
|
|
}
|
|
}
|
|
}
|