diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index d323ed40..fbf3fad3 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2247,8 +2247,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent configureUserNotifications() installMenuBarVisibilityObserver() syncMenuBarExtraVisibility() - // Sparkle updater is started lazily on first manual check. This avoids any - // first-launch permission prompts and keeps cmux aligned with the update pill UI. + updateController.startUpdaterIfNeeded() } titlebarAccessoryController.start() windowDecorationsController.start() diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index c7708c7c..7cffeba9 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7789,6 +7789,10 @@ struct VerticalTabsSidebar: View { Spacer() .frame(height: trafficLightPadding) + SidebarUpdateBanner(updateViewModel: updateViewModel) + .padding(.horizontal, 8) + .padding(.top, 8) + LazyVStack(spacing: tabRowSpacing) { ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in TabItemView( @@ -8719,6 +8723,107 @@ private final class SidebarShortcutHintModifierMonitor: ObservableObject { } } +private struct SidebarUpdateBanner: View { + @ObservedObject var updateViewModel: UpdateViewModel + + private var bannerVersion: String? { + if let detectedUpdateVersion = updateViewModel.detectedUpdateVersion { + return detectedUpdateVersion + } + if case .updateAvailable(let update) = updateViewModel.effectiveState { + return UpdateViewModel.normalizedDetectedUpdateVersion(from: update.appcastItem.displayVersionString) + } + return nil + } + + private var titleText: String { + guard let bannerVersion else { + return String(localized: "update.available.short", defaultValue: "Update Available") + } + return String(localized: "update.available.withVersion", defaultValue: "Update Available: \(bannerVersion)") + } + + private var messageText: String { + if case .updateAvailable = updateViewModel.effectiveState { + let message = updateViewModel.description + if !message.isEmpty { + return message + } + } + return String(localized: "update.downloadAndInstall", defaultValue: "Download and install the latest version") + } + + private var actionDisabled: Bool { + switch updateViewModel.effectiveState { + case .checking, .downloading, .extracting, .installing: + return true + default: + return false + } + } + + var body: some View { + if bannerVersion != nil { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "shippingbox.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(cmuxAccentColor()) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 4) { + Text(titleText) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.primary) + .accessibilityIdentifier("SidebarUpdateBannerTitle") + Text(messageText) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + } + + HStack { + Spacer(minLength: 0) + Button(String(localized: "common.installAndRelaunch", defaultValue: "Install and Relaunch")) { + installDetectedUpdate() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(actionDisabled) + .accessibilityIdentifier("SidebarUpdateBannerAction") + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(cmuxAccentColor().opacity(0.12)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(cmuxAccentColor().opacity(0.28), lineWidth: 1) + ) + .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .accessibilityIdentifier("SidebarUpdateBanner") + } + } + + private func installDetectedUpdate() { + if case .updateAvailable(let update) = updateViewModel.effectiveState { + update.reply(.install) + return + } + if updateViewModel.effectiveState.isInstallable { + updateViewModel.effectiveState.confirm() + return + } + AppDelegate.shared?.attemptUpdate(nil) + } +} + private struct SidebarFooter: View { @ObservedObject var updateViewModel: UpdateViewModel let onSendFeedback: () -> Void diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index 94fae950..7cc9beb9 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -27,10 +27,10 @@ class UpdateController { } init() { - // Default to manual update checks. This also prevents Sparkle from prompting at startup. + // 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: [ - "SUEnableAutomaticChecks": false, "SUSendProfileInfo": false, "SUAutomaticallyUpdate": false, ]) @@ -59,8 +59,8 @@ class UpdateController { guard !didStartUpdater else { return } ensureSparkleInstallationCache() #if DEBUG - // UI tests need to exercise Sparkle's permission request deterministically. - // Clearing these defaults causes Sparkle to re-request permission on next start. + // 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: "SUEnableAutomaticChecks") @@ -71,13 +71,9 @@ class UpdateController { } #endif do { - // cmux never enables automatic update checks; we rely on the in-app update pill. - // Sparkle reads these from defaults, but set them explicitly before starting. - let defaults = UserDefaults.standard - defaults.set(false, forKey: "SUEnableAutomaticChecks") - defaults.set(false, forKey: "SUSendProfileInfo") - defaults.set(false, forKey: "SUAutomaticallyUpdate") - + updater.automaticallyChecksForUpdates = true + updater.automaticallyDownloadsUpdates = false + updater.sendsSystemProfile = false try updater.start() didStartUpdater = true } catch { @@ -201,7 +197,7 @@ class UpdateController { /// Validate the check for updates menu item. func validateMenuItem(_ item: NSMenuItem) -> Bool { if item.action == #selector(checkForUpdates) { - // Always allow user-initiated checks; we start Sparkle lazily on first use. + // Always allow user-initiated checks; Sparkle can safely surface current progress. return true } return true diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift index dfcd457c..b3adfc15 100644 --- a/Sources/Update/UpdateDelegate.swift +++ b/Sources/Update/UpdateDelegate.swift @@ -13,6 +13,10 @@ enum UpdateFeedResolver { } extension UpdateDriver: SPUUpdaterDelegate { + func updaterShouldPromptForPermissionToCheck(forUpdates _: SPUUpdater) -> Bool { + false + } + func feedURLString(for updater: SPUUpdater) -> String? { #if DEBUG let env = ProcessInfo.processInfo.environment @@ -35,6 +39,7 @@ 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, @@ -56,6 +61,7 @@ extension UpdateDriver: SPUUpdaterDelegate { } func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { + viewModel.recordDetectedUpdate(item) let version = item.displayVersionString let fileURL = item.fileURL?.absoluteString ?? "" if fileURL.isEmpty { @@ -66,6 +72,7 @@ extension UpdateDriver: SPUUpdaterDelegate { } func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) { + viewModel.clearDetectedUpdate() let nsError = error as NSError let reasonValue = (nsError.userInfo[SPUNoUpdateFoundReasonKey] as? NSNumber)?.intValue let reason = reasonValue.map { SPUNoUpdateFoundReason(rawValue: OSStatus($0)) } ?? nil @@ -80,13 +87,18 @@ extension UpdateDriver: SPUUpdaterDelegate { } } - @MainActor + func updater(_ updater: SPUUpdater, userDidMake _: SPUUserUpdateChoice, forUpdate _: SUAppcastItem, state _: SPUUserUpdateState) { + viewModel.clearDetectedUpdate() + } + func updaterWillRelaunchApplication(_ updater: SPUUpdater) { - AppDelegate.shared?.persistSessionForUpdateRelaunch() - TerminalController.shared.stop() - NSApp.invalidateRestorableState() - for window in NSApp.windows { - window.invalidateRestorableState() + Task { @MainActor in + AppDelegate.shared?.persistSessionForUpdateRelaunch() + TerminalController.shared.stop() + NSApp.invalidateRestorableState() + for window in NSApp.windows { + window.invalidateRestorableState() + } } } } diff --git a/Sources/Update/UpdateTestSupport.swift b/Sources/Update/UpdateTestSupport.swift index 2809b434..77535482 100644 --- a/Sources/Update/UpdateTestSupport.swift +++ b/Sources/Update/UpdateTestSupport.swift @@ -6,6 +6,14 @@ enum UpdateTestSupport { static func applyIfNeeded(to viewModel: UpdateViewModel) { let env = ProcessInfo.processInfo.environment guard env["CMUX_UI_TEST_MODE"] == "1" else { return } + + if let detectedVersion = env["CMUX_UI_TEST_DETECTED_UPDATE_VERSION"], + !detectedVersion.isEmpty { + DispatchQueue.main.async { + viewModel.detectedUpdateVersion = UpdateViewModel.normalizedDetectedUpdateVersion(from: detectedVersion) + } + } + guard let state = env["CMUX_UI_TEST_UPDATE_STATE"] else { return } DispatchQueue.main.async { diff --git a/Sources/Update/UpdateViewModel.swift b/Sources/Update/UpdateViewModel.swift index 4bdb9ad2..7aa524d2 100644 --- a/Sources/Update/UpdateViewModel.swift +++ b/Sources/Update/UpdateViewModel.swift @@ -6,6 +6,7 @@ import Sparkle class UpdateViewModel: ObservableObject { @Published var state: UpdateState = .idle @Published var overrideState: UpdateState? + @Published var detectedUpdateVersion: String? #if DEBUG @Published var debugOverrideText: String? #endif @@ -14,6 +15,14 @@ class UpdateViewModel: ObservableObject { overrideState ?? state } + func recordDetectedUpdate(_ item: SUAppcastItem) { + detectedUpdateVersion = Self.normalizedDetectedUpdateVersion(from: item.displayVersionString) + } + + func clearDetectedUpdate() { + detectedUpdateVersion = nil + } + var text: String { #if DEBUG if let debugOverrideText { return debugOverrideText } @@ -334,6 +343,11 @@ class UpdateViewModel: ObservableObject { return nil } } + + static func normalizedDetectedUpdateVersion(from version: String) -> String? { + let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } } enum UpdateState: Equatable { diff --git a/cmuxUITests/UpdatePillUITests.swift b/cmuxUITests/UpdatePillUITests.swift index 3b8040a7..099dfb93 100644 --- a/cmuxUITests/UpdatePillUITests.swift +++ b/cmuxUITests/UpdatePillUITests.swift @@ -124,6 +124,19 @@ final class UpdatePillUITests: XCTestCase { assertVisibleSize(noUpdatePill) } + func testSidebarUpdateBannerShowsForBackgroundDetectedUpdate() { + 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) + + XCTAssertTrue(app.otherElements["SidebarUpdateBanner"].waitForExistence(timeout: 6.0)) + XCTAssertTrue(app.staticTexts["Update Available: 9.9.9"].waitForExistence(timeout: 2.0)) + XCTAssertTrue(app.buttons["SidebarUpdateBannerAction"].waitForExistence(timeout: 2.0)) + } + func testNoSparklePermissionDialogIsShown() { let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences") systemSettings.terminate()