fix: show sidebar update banner from background checks (#1543)
This commit is contained in:
parent
3b507d361f
commit
971b2b4e77
7 changed files with 167 additions and 20 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue