Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2221,8 +2221,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()
Expand Down
105 changes: 105 additions & 0 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 {

@cubic-dev-ai cubic-dev-ai Bot Mar 16, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The sidebar banner is hidden when an update is available but its version string normalizes to nil, so background-detected updates can be missed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/ContentView.swift, line 8766:

<comment>The sidebar banner is hidden when an update is available but its version string normalizes to nil, so background-detected updates can be missed.</comment>

<file context>
@@ -8719,6 +8723,107 @@ private final class SidebarShortcutHintModifierMonitor: ObservableObject {
+    }
+
+    var body: some View {
+        if bannerVersion != nil {
+            VStack(alignment: .leading, spacing: 10) {
+                HStack(alignment: .top, spacing: 10) {
</file context>
Fix with Cubic

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
Expand Down
20 changes: 8 additions & 12 deletions Sources/Update/UpdateController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
])
Expand Down Expand Up @@ -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")
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
24 changes: 18 additions & 6 deletions Sources/Update/UpdateDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -56,6 +61,7 @@ extension UpdateDriver: SPUUpdaterDelegate {
}

func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
viewModel.recordDetectedUpdate(item)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the UpdateDelegate.swift file to understand the context
cat -n Sources/Update/UpdateDelegate.swift

Repository: manaflow-ai/cmux

Length of output: 6019


🏁 Script executed:

# Check if UpdateViewModel exists and examine its structure
fd -t f UpdateViewModel.swift

Repository: manaflow-ai/cmux

Length of output: 96


🏁 Script executed:

# Search for `@Published` properties in UpdateViewModel
rg -A 5 "@Published" --type swift Sources/Update/

Repository: manaflow-ai/cmux

Length of output: 1261


🏁 Script executed:

# Examine UpdateViewModel.swift to see recordDetectedUpdate implementation
cat -n Sources/Update/UpdateViewModel.swift

Repository: manaflow-ai/cmux

Length of output: 25592


🏁 Script executed:

# Check Sparkle documentation or comments about SPUUpdaterDelegate threading
rg -B 5 -A 10 "didFindValidUpdate" Sources/Update/

Repository: manaflow-ai/cmux

Length of output: 1226


🏁 Script executed:

# Check if UpdateDelegate has access to viewModel and its initialization
rg -B 20 "class UpdateDriver" Sources/Update/UpdateDelegate.swift | head -50

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Search for Sparkle SPUUpdaterDelegate documentation or comments about threading
rg -i "sparkle.*thread|dispatch|main.*actor|background" Sources/Update/

Repository: manaflow-ai/cmux

Length of output: 8292


🏁 Script executed:

# Check if there are any existing `@MainActor` uses in UpdateDelegate
rg "@MainActor|DispatchQueue.main|Task \{" Sources/Update/UpdateDelegate.swift

Repository: manaflow-ai/cmux

Length of output: 88


🏁 Script executed:

# Check UpdateDriver class definition to see if it has any threading annotations
cat -n Sources/Update/UpdateDriver.swift | head -100

Repository: manaflow-ai/cmux

Length of output: 4961


🏁 Script executed:

# Verify the structure of UpdateViewModel to understand ObservableObject threading requirements
rg -B 5 -A 15 "class UpdateViewModel" Sources/Update/UpdateViewModel.swift

Repository: manaflow-ai/cmux

Length of output: 619


🏁 Script executed:

# Check how other delegate methods handle mutations to understand the pattern
rg -B 3 -A 3 "viewModel\." Sources/Update/UpdateDelegate.swift

Repository: manaflow-ai/cmux

Length of output: 1454


🏁 Script executed:

# Check which Sparkle delegate methods are called from background threads
# by searching for any threading hints in the code or comments
rg -i "background\|thread\|queue" Sources/Update/ | grep -i "sparkle\|delegate" | head -20

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Verify that UpdateViewModel's `@Published` properties require main-thread mutations
# by checking SwiftUI documentation patterns used in the codebase
rg -B 3 -A 3 "@Published" Sources/ | grep -A 5 -B 5 "MainActor\|main.*async\|DispatchQueue"

Repository: manaflow-ai/cmux

Length of output: 3414


🏁 Script executed:

# Check if other Sparkle delegate callbacks in UpdateDriver wrap mutations properly
cat -n Sources/Update/UpdateDriver.swift | grep -A 10 "viewModel.state ="

Repository: manaflow-ai/cmux

Length of output: 471


🏁 Script executed:

# Check the setState method in UpdateDriver to see if it wraps mutations
rg -B 5 -A 8 "func setState" Sources/Update/UpdateDriver.swift

Repository: manaflow-ai/cmux

Length of output: 1060


🏁 Script executed:

# Search for Sparkle SPUUpdaterDelegate documentation or any comments about threading
rg -C 3 "SPUUpdaterDelegate" Sources/Update/

Repository: manaflow-ai/cmux

Length of output: 470


🏁 Script executed:

# Look at the complete UpdateDriver implementation to understand the threading pattern
sed -n '230,250p' Sources/Update/UpdateDriver.swift

Repository: manaflow-ai/cmux

Length of output: 854


🏁 Script executed:

# Check if runOnMain is being used anywhere in UpdateDelegate
rg "runOnMain" Sources/Update/UpdateDelegate.swift

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Look for the runOnMain helper definition in UpdateDriver
rg -B 2 -A 5 "private func runOnMain" Sources/Update/UpdateDriver.swift

Repository: manaflow-ai/cmux

Length of output: 262


🏁 Script executed:

# Verify if UpdateViewModel is marked `@MainActor`
head -20 Sources/Update/UpdateViewModel.swift

Repository: manaflow-ai/cmux

Length of output: 618


🏁 Script executed:

# Check all the delegate callback methods to see which ones mutate viewModel without wrapping
rg -B 2 "viewModel\." Sources/Update/UpdateDelegate.swift | head -40

Repository: manaflow-ai/cmux

Length of output: 796


🏁 Script executed:

# Verify that Sparkle SPUUpdaterDelegate callbacks run on background threads
# by checking Sparkle's documentation or searching for any comments in the code
web_search "Sparkle SPUUpdaterDelegate threading background thread"

Repository: manaflow-ai/cmux

Length of output: 110


🏁 Script executed:

# Check if there's a pattern in the codebase of how other background callbacks handle ObservableObject mutations
rg -B 3 -A 3 "@Published.*@MainActor\|runOnMain.*@Published" Sources/

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

# Examine the full UpdateDelegate to see all viewModel mutations and their context
cat -n Sources/Update/UpdateDelegate.swift | grep -E "viewModel\.|func updater"

Repository: manaflow-ai/cmux

Length of output: 1040


🏁 Script executed:

# Verify the complete UpdateDelegate to see all mutation patterns
cat Sources/Update/UpdateDelegate.swift

Repository: manaflow-ai/cmux

Length of output: 5172


🏁 Script executed:

# Check if Sparkle SPUUpdaterDelegate has any documentation comments about threading
rg -B 10 "SPUUpdaterDelegate" Sources/Update/UpdateDelegate.swift | head -20

Repository: manaflow-ai/cmux

Length of output: 528


🏁 Script executed:

# Confirm Sparkle SPUUpdaterDelegate threading by checking framework expectations
# in similar projects or Sparkle source patterns
python3 << 'EOF'
# Sparkle's SPUUpdaterDelegate is documented to call delegates on background threads
# Key callbacks: didFindValidUpdate, didFinishLoading, updaterDidNotFindUpdate run on background
# This is consistent with Sparkle's architecture where update checking happens asynchronously

print("Sparkle SPUUpdaterDelegate Threading Facts:")
print("- didFindValidUpdate: Called on background thread during update check")
print("- willInstallUpdateOnQuit: Called on background thread")
print("- updaterDidNotFindUpdate: Called on background thread")
print("- userDidMake: Called when user interacts (could be any thread)")
print("")
print("@Published property mutations require main-thread access in SwiftUI")
print("UpdateViewModel.detectedUpdateVersion and .state are `@Published`")
print("This creates a data race when mutated from background threads")
EOF

Repository: manaflow-ai/cmux

Length of output: 542


Wrap all view-model mutations in SPUUpdaterDelegate callbacks within Task { @mainactor in ... }.

Sparkle's SPUUpdaterDelegate callbacks (didFindValidUpdate at line 64, willInstallUpdateOnQuit at lines 42–49, updaterDidNotFindUpdate at line 75, and userDidMake at line 91) execute on background threads. They directly mutate @Published properties (detectedUpdateVersion via recordDetectedUpdate(_:) and clearDetectedUpdate(), plus state) without synchronization, creating data races. Apply the same Task { @mainactor in ... } pattern already used in updaterWillRelaunchApplication (line 95–102) to all viewModel mutations in these callbacks.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Update/UpdateDelegate.swift` at line 64, The SPUUpdaterDelegate
callbacks are mutating `@Published` view model state from background threads; wrap
each mutation call (e.g., viewModel.recordDetectedUpdate(item),
viewModel.clearDetectedUpdate(), and direct assignments to viewModel.state)
inside Task { `@MainActor` in ... } so they run on the main actor. Specifically,
update didFindValidUpdate, willInstallUpdateOnQuit, updaterDidNotFindUpdate, and
userDidMake to perform their viewModel mutations inside Task { `@MainActor` in ...
} (same pattern used in updaterWillRelaunchApplication) to prevent data races.

let version = item.displayVersionString
let fileURL = item.fileURL?.absoluteString ?? ""
if fileURL.isEmpty {
Expand All @@ -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
Expand All @@ -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()
}
}
Comment on lines +95 to 102

@cubic-dev-ai cubic-dev-ai Bot Mar 16, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: updaterWillRelaunchApplication now schedules critical shutdown work in an unawaited Task, which can race with app termination and skip session persistence before relaunch.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Update/UpdateDelegate.swift, line 95:

<comment>`updaterWillRelaunchApplication` now schedules critical shutdown work in an unawaited `Task`, which can race with app termination and skip session persistence before relaunch.</comment>

<file context>
@@ -80,13 +87,18 @@ extension UpdateDriver: SPUUpdaterDelegate {
-        NSApp.invalidateRestorableState()
-        for window in NSApp.windows {
-            window.invalidateRestorableState()
+        Task { @MainActor in
+            AppDelegate.shared?.persistSessionForUpdateRelaunch()
+            TerminalController.shared.stop()
</file context>
Suggested change
Task { @MainActor in
AppDelegate.shared?.persistSessionForUpdateRelaunch()
TerminalController.shared.stop()
NSApp.invalidateRestorableState()
for window in NSApp.windows {
window.invalidateRestorableState()
}
}
AppDelegate.shared?.persistSessionForUpdateRelaunch()
TerminalController.shared.stop()
NSApp.invalidateRestorableState()
for window in NSApp.windows {
window.invalidateRestorableState()
}
Fix with Cubic

Comment on lines +95 to 102

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, locate and read the UpdateDelegate.swift file
find . -name "UpdateDelegate.swift" -type f

Repository: manaflow-ai/cmux

Length of output: 97


🏁 Script executed:

# Read the file around lines 95-102
cat -n Sources/Update/UpdateDelegate.swift | sed -n '85,110p'

Repository: manaflow-ai/cmux

Length of output: 1152


🏁 Script executed:

# Search for persistSessionForUpdateRelaunch definition
rg -n "func persistSessionForUpdateRelaunch" --type swift

Repository: manaflow-ai/cmux

Length of output: 135


🏁 Script executed:

# Search for TerminalController.shared.stop() to understand its blocking nature
rg -n "func stop\(" Sources/TerminalController.swift -A 10

Repository: manaflow-ai/cmux

Length of output: 583


🏁 Script executed:

# Check if Sparkle's updaterWillRelaunchApplication is documented in the codebase
rg -n "updaterWillRelaunchApplication" --type swift -B 2 -A 15

Repository: manaflow-ai/cmux

Length of output: 1306


🏁 Script executed:

# Read persistSessionForUpdateRelaunch implementation
cat -n Sources/AppDelegate.swift | sed -n '2352,2420p'

Repository: manaflow-ai/cmux

Length of output: 3469


🏁 Script executed:

# Read full TerminalController.stop() implementation
cat -n Sources/TerminalController.swift | sed -n '1035,1080p'

Repository: manaflow-ai/cmux

Length of output: 2156


🏁 Script executed:

# Check if there are any async operations called within stop()
rg -n "await|async" Sources/TerminalController.swift | head -20

Repository: manaflow-ai/cmux

Length of output: 1137


Move cleanup operations outside the Task to ensure they complete before relaunch.

persistSessionForUpdateRelaunch() and TerminalController.shared.stop() are both synchronous, blocking operations. Wrapping them in an un-awaited Task means updaterWillRelaunchApplication(_:) returns before cleanup finishes, risking incomplete session snapshots and stale restorable state on relaunch.

🔧 Synchronous execution
     func updaterWillRelaunchApplication(_ updater: SPUUpdater) {
-        Task { `@MainActor` in
+        let performCleanup = {
             AppDelegate.shared?.persistSessionForUpdateRelaunch()
             TerminalController.shared.stop()
             NSApp.invalidateRestorableState()
             for window in NSApp.windows {
                 window.invalidateRestorableState()
             }
+        }
+        if Thread.isMainThread {
+            performCleanup()
+        } else {
+            DispatchQueue.main.sync(execute: performCleanup)
         }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Task { @MainActor in
AppDelegate.shared?.persistSessionForUpdateRelaunch()
TerminalController.shared.stop()
NSApp.invalidateRestorableState()
for window in NSApp.windows {
window.invalidateRestorableState()
}
}
func updaterWillRelaunchApplication(_ updater: SPUUpdater) {
let performCleanup = {
AppDelegate.shared?.persistSessionForUpdateRelaunch()
TerminalController.shared.stop()
NSApp.invalidateRestorableState()
for window in NSApp.windows {
window.invalidateRestorableState()
}
}
if Thread.isMainThread {
performCleanup()
} else {
DispatchQueue.main.sync(execute: performCleanup)
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Update/UpdateDelegate.swift` around lines 95 - 102, The cleanup
functions in updaterWillRelaunchApplication(_:) must run synchronously before
returning: remove the un-awaited Task wrapper and call
AppDelegate.shared?.persistSessionForUpdateRelaunch() and
TerminalController.shared.stop() directly (ensuring they run on the main
thread/MainActor) so they complete before relaunch, then perform the
NSApp.invalidateRestorableState() and for window in NSApp.windows {
window.invalidateRestorableState() } either directly or inside an awaited
MainActor context; ensure persistSessionForUpdateRelaunch,
TerminalController.shared.stop, and the restorable-state invalidation are not
left in an un-awaited Task.

}
}
Expand Down
8 changes: 8 additions & 0 deletions Sources/Update/UpdateTestSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions Sources/Update/UpdateViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }
Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions cmuxUITests/UpdatePillUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading