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()