From e203c51c7ada98beaeed76b86d07cfa14660ac2b Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 18 Mar 2026 12:30:44 -0700 Subject: [PATCH] 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 * 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 * Change background update probe interval to 30 minutes Co-Authored-By: Claude Opus 4.6 * 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 --------- Co-authored-by: Claude Opus 4.6 --- Sources/Update/UpdateBadge.swift | 38 +++++++++++-------- Sources/Update/UpdateController.swift | 34 +++++++++++++++-- Sources/Update/UpdateDelegate.swift | 30 +++++++++------ Sources/Update/UpdatePill.swift | 8 +++- Sources/Update/UpdateViewModel.swift | 31 +++++++++++++++ .../ShortcutAndCommandPaletteTests.swift | 23 +++++++++++ cmuxUITests/UpdatePillUITests.swift | 15 ++++++++ 7 files changed, 147 insertions(+), 32 deletions(-) diff --git a/Sources/Update/UpdateBadge.swift b/Sources/Update/UpdateBadge.swift index 5cc3500f..76e39833 100644 --- a/Sources/Update/UpdateBadge.swift +++ b/Sources/Update/UpdateBadge.swift @@ -11,25 +11,31 @@ struct UpdateBadge: View { @ViewBuilder private var badgeContent: some View { - switch model.effectiveState { - case .downloading(let download): - if let expectedLength = download.expectedLength, expectedLength > 0 { - let progress = min(1, max(0, Double(download.progress) / Double(expectedLength))) - ProgressRingView(progress: progress) - } else { - Image(systemName: "arrow.down.circle") - } - - case .extracting(let extracting): - ProgressRingView(progress: min(1, max(0, extracting.progress))) - - case .checking: - BrowserStyleLoadingSpinner(size: 14, color: model.foregroundColor) - - default: + if model.showsDetectedBackgroundUpdate { if let iconName = model.iconName { Image(systemName: iconName) } + } else { + switch model.effectiveState { + case .downloading(let download): + if let expectedLength = download.expectedLength, expectedLength > 0 { + let progress = min(1, max(0, Double(download.progress) / Double(expectedLength))) + ProgressRingView(progress: progress) + } else { + Image(systemName: "arrow.down.circle") + } + + case .extracting(let extracting): + ProgressRingView(progress: min(1, max(0, extracting.progress))) + + case .checking: + BrowserStyleLoadingSpinner(size: 14, color: model.foregroundColor) + + default: + if let iconName = model.iconName { + Image(systemName: iconName) + } + } } } } diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index ef1176bf..3ac1394a 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -8,8 +8,9 @@ enum UpdateSettings { 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 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: [ @@ -26,7 +27,9 @@ enum UpdateSettings { defaults.set(true, forKey: automaticChecksKey) if let interval = defaults.object(forKey: scheduledCheckIntervalKey) as? NSNumber { - if interval.doubleValue <= 0 { + let currentInterval = interval.doubleValue + if currentInterval <= 0 || + abs(currentInterval - previousDefaultScheduledCheckInterval) < 1 { defaults.set(scheduledCheckInterval, forKey: scheduledCheckIntervalKey) } } else { @@ -54,9 +57,11 @@ class UpdateController { 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 @@ -88,6 +93,7 @@ class UpdateController { noUpdateDismissCancellable?.cancel() noUpdateDismissWorkItem?.cancel() readyCheckWorkItem?.cancel() + backgroundProbeTimer?.invalidate() } /// Start the updater. If startup fails, the error is shown via the custom UI. @@ -115,6 +121,7 @@ class UpdateController { UpdateLogStore.shared.append( "updater started (autoChecks=\(updater.automaticallyChecksForUpdates), interval=\(interval)s, autoDownloads=\(updater.automaticallyDownloadsUpdates))" ) + startLaunchUpdateProbeIfNeeded() } catch { userDriver.viewModel.state = .error(.init( error: error, @@ -130,6 +137,27 @@ class UpdateController { } } + 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 } diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift index 7de114d3..5017009e 100644 --- a/Sources/Update/UpdateDelegate.swift +++ b/Sources/Update/UpdateDelegate.swift @@ -47,14 +47,16 @@ extension UpdateDriver: SPUUpdaterDelegate { /// Called when an update is scheduled to install silently, /// which occurs when automatic download is enabled. func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) -> Bool { - viewModel.clearDetectedUpdate() - viewModel.state = .installing(.init( - isAutoUpdate: true, - retryTerminatingApplication: immediateInstallHandler, - dismiss: { [weak viewModel] in - viewModel?.state = .idle - } - )) + DispatchQueue.main.async { [weak viewModel] in + viewModel?.clearDetectedUpdate() + viewModel?.state = .installing(.init( + isAutoUpdate: true, + retryTerminatingApplication: immediateInstallHandler, + dismiss: { [weak viewModel] in + viewModel?.state = .idle + } + )) + } return true } @@ -69,7 +71,9 @@ extension UpdateDriver: SPUUpdaterDelegate { } func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { - viewModel.recordDetectedUpdate(item) + DispatchQueue.main.async { [weak viewModel] in + viewModel?.recordDetectedUpdate(item) + } let version = item.displayVersionString let fileURL = item.fileURL?.absoluteString ?? "" if fileURL.isEmpty { @@ -80,7 +84,9 @@ extension UpdateDriver: SPUUpdaterDelegate { } func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) { - viewModel.clearDetectedUpdate() + DispatchQueue.main.async { [weak viewModel] in + viewModel?.clearDetectedUpdate() + } let nsError = error as NSError let reasonValue = (nsError.userInfo[SPUNoUpdateFoundReasonKey] as? NSNumber)?.intValue let reason = reasonValue.map { SPUNoUpdateFoundReason(rawValue: OSStatus($0)) } ?? nil @@ -96,7 +102,9 @@ extension UpdateDriver: SPUUpdaterDelegate { } func updater(_ updater: SPUUpdater, userDidMake _: SPUUserUpdateChoice, forUpdate _: SUAppcastItem, state _: SPUUserUpdateState) { - viewModel.clearDetectedUpdate() + DispatchQueue.main.async { [weak viewModel] in + viewModel?.clearDetectedUpdate() + } } func updaterWillRelaunchApplication(_ updater: SPUUpdater) { diff --git a/Sources/Update/UpdatePill.swift b/Sources/Update/UpdatePill.swift index ed43c192..42abf9f8 100644 --- a/Sources/Update/UpdatePill.swift +++ b/Sources/Update/UpdatePill.swift @@ -11,8 +11,7 @@ struct UpdatePill: View { private let textFont = NSFont.systemFont(ofSize: 11, weight: .medium) var body: some View { - let state = model.effectiveState - if !state.isIdle { + if model.showsPill { pillButton .popover( isPresented: $showPopover, @@ -28,6 +27,11 @@ struct UpdatePill: View { @ViewBuilder private var pillButton: some View { Button(action: { + if model.showsDetectedBackgroundUpdate { + showPopover = false + AppDelegate.shared?.checkForUpdates(nil) + return + } if case .notFound(let notFound) = model.state { model.state = .idle notFound.acknowledgement() diff --git a/Sources/Update/UpdateViewModel.swift b/Sources/Update/UpdateViewModel.swift index 7aa524d2..93b4b799 100644 --- a/Sources/Update/UpdateViewModel.swift +++ b/Sources/Update/UpdateViewModel.swift @@ -15,6 +15,14 @@ class UpdateViewModel: ObservableObject { overrideState ?? state } + var showsDetectedBackgroundUpdate: Bool { + effectiveState.isIdle && detectedUpdateVersion != nil + } + + var showsPill: Bool { + !effectiveState.isIdle || showsDetectedBackgroundUpdate + } + func recordDetectedUpdate(_ item: SUAppcastItem) { detectedUpdateVersion = Self.normalizedDetectedUpdateVersion(from: item.displayVersionString) } @@ -27,6 +35,9 @@ class UpdateViewModel: ObservableObject { #if DEBUG if let debugOverrideText { return debugOverrideText } #endif + if let detectedText = detectedUpdateText { + return detectedText + } switch effectiveState { case .idle: return "" @@ -60,6 +71,9 @@ class UpdateViewModel: ObservableObject { } var maxWidthText: String { + if let detectedText = detectedUpdateText { + return detectedText + } switch effectiveState { case .downloading: return "Downloading: 100%" @@ -71,6 +85,9 @@ class UpdateViewModel: ObservableObject { } var iconName: String? { + if showsDetectedBackgroundUpdate { + return "shippingbox.fill" + } switch effectiveState { case .idle: return nil @@ -135,6 +152,9 @@ class UpdateViewModel: ObservableObject { } var iconColor: Color { + if showsDetectedBackgroundUpdate { + return cmuxAccentColor() + } switch effectiveState { case .idle: return .secondary @@ -154,6 +174,9 @@ class UpdateViewModel: ObservableObject { } var backgroundColor: Color { + if showsDetectedBackgroundUpdate { + return cmuxAccentColor() + } switch effectiveState { case .permissionRequest: return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.3, of: .black) ?? .systemBlue) @@ -169,6 +192,9 @@ class UpdateViewModel: ObservableObject { } var foregroundColor: Color { + if showsDetectedBackgroundUpdate { + return .white + } switch effectiveState { case .permissionRequest: return .white @@ -348,6 +374,11 @@ class UpdateViewModel: ObservableObject { let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } + + private var detectedUpdateText: String? { + guard showsDetectedBackgroundUpdate, let version = detectedUpdateVersion else { return nil } + return String(localized: "update.available.withVersion", defaultValue: "Update Available: \(version)") + } } enum UpdateState: Equatable { diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index cc5b0c01..a0ab831f 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -927,6 +927,29 @@ final class UpdateSettingsTests: XCTestCase { } } +final class UpdateViewModelPresentationTests: XCTestCase { + func testDetectedBackgroundUpdateShowsPillWhileIdle() { + let viewModel = UpdateViewModel() + + viewModel.detectedUpdateVersion = "9.9.9" + + XCTAssertTrue(viewModel.showsPill) + XCTAssertTrue(viewModel.showsDetectedBackgroundUpdate) + XCTAssertEqual(viewModel.text, "Update Available: 9.9.9") + XCTAssertEqual(viewModel.iconName, "shippingbox.fill") + } + + func testActiveUpdateStateTakesPrecedenceOverDetectedBackgroundVersion() { + let viewModel = UpdateViewModel() + + viewModel.detectedUpdateVersion = "9.9.9" + viewModel.state = .checking(.init(cancel: {})) + + XCTAssertTrue(viewModel.showsPill) + XCTAssertFalse(viewModel.showsDetectedBackgroundUpdate) + XCTAssertEqual(viewModel.text, "Checking for Updates…") + } +} @MainActor final class CommandPaletteOverlayPromotionPolicyTests: XCTestCase { diff --git a/cmuxUITests/UpdatePillUITests.swift b/cmuxUITests/UpdatePillUITests.swift index cfa86670..d76c40ba 100644 --- a/cmuxUITests/UpdatePillUITests.swift +++ b/cmuxUITests/UpdatePillUITests.swift @@ -44,6 +44,21 @@ final class UpdatePillUITests: XCTestCase { attachElementDebug(name: "update-available-pill", element: pill) } + func testDetectedBackgroundUpdateShowsPillWithoutManualCheck() { + let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences") + systemSettings.terminate() + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_DETECTED_UPDATE_VERSION"] = "9.9.9" + launchAndActivate(app) + + let pill = pillButton(app: app, expectedLabel: "Update Available: 9.9.9") + XCTAssertTrue(pill.waitForExistence(timeout: 6.0)) + XCTAssertEqual(pill.label, "Update Available: 9.9.9") + assertVisibleSize(pill) + attachScreenshot(name: "background-detected-update-available") + } + func testUpdatePillShowsForNoUpdateThenDismisses() { let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences") systemSettings.terminate()