fix: show sidebar update banner from background checks (#1543)

This commit is contained in:
Austin Wang 2026-03-16 20:40:35 -07:00 committed by GitHub
parent 3b507d361f
commit 971b2b4e77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 167 additions and 20 deletions

View file

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

View file

@ -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

View file

@ -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

View file

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

View file

@ -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 {

View file

@ -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 {

View file

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