Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
22 changes: 14 additions & 8 deletions Sources/Update/UpdateController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -199,18 +199,23 @@ class UpdateController {
self.stopAttemptUpdateMonitoring()
}

checkForUpdates()
requestCheckForUpdates(presentation: .custom)
}

/// Check for updates (used by the menu item).
@objc func checkForUpdates() {
UpdateLogStore.shared.append("checkForUpdates invoked (state=\(viewModel.state.isIdle ? "idle" : "busy"))")
checkForUpdatesWhenReady(retries: readyRetryCount)
requestCheckForUpdates(presentation: .dialog)
}

private func performCheckForUpdates() {
private func requestCheckForUpdates(presentation: UpdateUserInitiatedCheckPresentation) {
UpdateLogStore.shared.append("checkForUpdates invoked (state=\(viewModel.state.isIdle ? "idle" : "busy"), presentation=\(presentation == .dialog ? "dialog" : "custom"))")
checkForUpdatesWhenReady(retries: readyRetryCount, presentation: presentation)
}

private func performCheckForUpdates(presentation: UpdateUserInitiatedCheckPresentation) {
startUpdaterIfNeeded()
ensureSparkleInstallationCache()
userDriver.prepareForUserInitiatedCheck(presentation: presentation)
if viewModel.state == .idle {
updater.checkForUpdates()
return
Expand All @@ -220,20 +225,21 @@ class UpdateController {
viewModel.state.cancel()

DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in
self?.userDriver.prepareForUserInitiatedCheck(presentation: presentation)
self?.updater.checkForUpdates()
}
Comment on lines 230 to 252

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 prepareForUserInitiatedCheck sets pending presentation before the previous check is cancelled

When viewModel.state != .idle, prepareForUserInitiatedCheck(presentation:) is called at line 218 before viewModel.state.cancel() at line 225. If cancelling the in-flight check causes Sparkle to call dismissUpdateInstallation synchronously (or before the 100 ms async block runs), usesStandardPresentation evaluates to true because pendingUserInitiatedCheckPresentation is already set. This causes standard.dismissUpdateInstallation() to be called on a SPUStandardUserDriver that was never activated (no showUserInitiatedUpdateCheck was forwarded to it), which could leave the standard driver in an unexpected internal state.

Moving the first prepareForUserInitiatedCheck call inside the async block alongside the second one would ensure the pending presentation is only set immediately before updater.checkForUpdates() is actually invoked:

private func performCheckForUpdates(presentation: UpdateUserInitiatedCheckPresentation) {
    startUpdaterIfNeeded()
    ensureSparkleInstallationCache()
    if viewModel.state == .idle {
        userDriver.prepareForUserInitiatedCheck(presentation: presentation)
        updater.checkForUpdates()
        return
    }

    installCancellable?.cancel()
    viewModel.state.cancel()

    DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in
        self?.userDriver.prepareForUserInitiatedCheck(presentation: presentation)
        self?.updater.checkForUpdates()
    }
}

}

/// Check for updates once the updater is ready (used by UI tests).
func checkForUpdatesWhenReady(retries: Int = 10) {
func checkForUpdatesWhenReady(retries: Int = 10, presentation: UpdateUserInitiatedCheckPresentation = .dialog) {
readyCheckWorkItem?.cancel()
readyCheckWorkItem = nil
startUpdaterIfNeeded()
ensureSparkleInstallationCache()
let canCheck = updater.canCheckForUpdates
UpdateLogStore.shared.append("checkForUpdatesWhenReady invoked (canCheck=\(canCheck))")
if canCheck {
performCheckForUpdates()
performCheckForUpdates(presentation: presentation)
return
}
if viewModel.state.isIdle {
Expand All @@ -248,14 +254,14 @@ class UpdateController {
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Updater is still starting. Try again in a moment."]
),
retry: { [weak self] in self?.checkForUpdates() },
retry: { [weak self] in self?.requestCheckForUpdates(presentation: presentation) },
dismiss: { [weak self] in self?.viewModel.state = .idle }
))
}
return
}
let workItem = DispatchWorkItem { [weak self] in
self?.checkForUpdatesWhenReady(retries: retries - 1)
self?.checkForUpdatesWhenReady(retries: retries - 1, presentation: presentation)
}
readyCheckWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + readyRetryDelay, execute: workItem)
Expand Down
5 changes: 4 additions & 1 deletion Sources/Update/UpdateDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,13 @@ extension UpdateDriver: SPUUpdaterDelegate {
}
}

func updater(_ updater: SPUUpdater, userDidMake _: SPUUserUpdateChoice, forUpdate _: SUAppcastItem, state _: SPUUserUpdateState) {
func updater(_ updater: SPUUpdater, userDidMake choice: SPUUserUpdateChoice, forUpdate _: SUAppcastItem, state: SPUUserUpdateState) {
DispatchQueue.main.async { [weak viewModel] in
viewModel?.clearDetectedUpdate()
}
if state.userInitiated, choice != .install {
finishUserInitiatedCheckPresentation()
}
Comment on lines +108 to +110

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Redundant finishUserInitiatedCheckPresentation call for standard presentation

For the .dialog (standard) presentation path, finishUserInitiatedCheckPresentation() is already called inside the showUpdateFound reply closure (UpdateDriver.swift, line 78) whenever choice != .install. The delegate method userDidMake:forUpdate:state: fires immediately afterward and calls it a second time. The double-call is idempotent (both nils are already cleared), but it can be confusing. Consider guarding this call to only apply to the .custom path where showUpdateFound does not handle cleanup directly:

Suggested change
if state.userInitiated, choice != .install {
finishUserInitiatedCheckPresentation()
}
if state.userInitiated, choice != .install, activeUserInitiatedCheckPresentation == .custom {
finishUserInitiatedCheckPresentation()
}

Alternatively, since finishUserInitiatedCheckPresentation is already idempotent, this can be left as-is — but worth documenting the intentional double-call to avoid future confusion.

}

func updaterWillRelaunchApplication(_ updater: SPUUpdater) {
Expand Down
148 changes: 144 additions & 4 deletions Sources/Update/UpdateDriver.swift
Original file line number Diff line number Diff line change
@@ -1,20 +1,35 @@
import Cocoa
import Sparkle

enum UpdateUserInitiatedCheckPresentation {
case dialog
case custom
}

/// SPUUserDriver that updates the view model for custom update UI.
class UpdateDriver: NSObject, SPUUserDriver {
let viewModel: UpdateViewModel
private let standard: SPUStandardUserDriver
private let minimumCheckDuration: TimeInterval = UpdateTiming.minimumCheckDisplayDuration
private var lastCheckStart: Date?
private var pendingCheckTransition: DispatchWorkItem?
private var checkTimeoutWorkItem: DispatchWorkItem?
private var lastFeedURLString: String?
private var pendingUserInitiatedCheckPresentation: UpdateUserInitiatedCheckPresentation?
private var activeUserInitiatedCheckPresentation: UpdateUserInitiatedCheckPresentation?

init(viewModel: UpdateViewModel, hostBundle _: Bundle) {
init(viewModel: UpdateViewModel, hostBundle: Bundle) {
self.viewModel = viewModel
self.standard = SPUStandardUserDriver(hostBundle: hostBundle, delegate: nil)
super.init()
}

func prepareForUserInitiatedCheck(presentation: UpdateUserInitiatedCheckPresentation) {
runOnMain { [weak self] in
self?.pendingUserInitiatedCheckPresentation = presentation
}
}

func show(_ request: SPUUpdatePermissionRequest,
reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) {
#if DEBUG
Expand All @@ -36,14 +51,36 @@ class UpdateDriver: NSObject, SPUUserDriver {
}

func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {
UpdateLogStore.shared.append("show user-initiated update check")
beginChecking(cancel: cancellation)
let presentation = activateUserInitiatedCheckPresentation()
UpdateLogStore.shared.append("show user-initiated update check (\(describe(presentation)))")
let cancel = { [weak self] in
self?.finishUserInitiatedCheckPresentation()
cancellation()
}

switch presentation {
case .dialog:
clearCustomStateForStandardPresentation()
standard.showUserInitiatedUpdateCheck(cancellation: cancel)
case .custom:
beginChecking(cancel: cancel)
}
}

func showUpdateFound(with appcastItem: SUAppcastItem,
state: SPUUserUpdateState,
reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
UpdateLogStore.shared.append("show update found: \(appcastItem.displayVersionString)")
if usesStandardPresentation {
clearCustomStateForStandardPresentation()
standard.showUpdateFound(with: appcastItem, state: state) { [weak self] choice in
if choice != .install {
self?.finishUserInitiatedCheckPresentation()
}
reply(choice)
}
return
}
setStateAfterMinimumCheckDelay(.updateAvailable(.init(appcastItem: appcastItem, reply: reply)))
}

Expand All @@ -58,13 +95,29 @@ class UpdateDriver: NSObject, SPUUserDriver {
func showUpdateNotFoundWithError(_ error: any Error,
acknowledgement: @escaping () -> Void) {
UpdateLogStore.shared.append("show update not found: \(formatErrorForLog(error))")
if usesStandardPresentation {
clearCustomStateForStandardPresentation()
standard.showUpdateNotFoundWithError(error) { [weak self] in
self?.finishUserInitiatedCheckPresentation()
acknowledgement()
}
return
}
setStateAfterMinimumCheckDelay(.notFound(.init(acknowledgement: acknowledgement)))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

func showUpdaterError(_ error: any Error,
acknowledgement: @escaping () -> Void) {
let details = formatErrorForLog(error)
UpdateLogStore.shared.append("show updater error: \(details)")
if usesStandardPresentation {
clearCustomStateForStandardPresentation()
standard.showUpdaterError(error) { [weak self] in
self?.finishUserInitiatedCheckPresentation()
acknowledgement()
}
return
}
setState(.error(.init(
error: error,
retry: { [weak viewModel] in
Expand All @@ -85,6 +138,10 @@ class UpdateDriver: NSObject, SPUUserDriver {

func showDownloadInitiated(cancellation: @escaping () -> Void) {
UpdateLogStore.shared.append("show download initiated")
if usesStandardPresentation {
standard.showDownloadInitiated(cancellation: cancellation)
return
}
setState(.downloading(.init(
cancel: cancellation,
expectedLength: nil,
Expand All @@ -93,6 +150,10 @@ class UpdateDriver: NSObject, SPUUserDriver {

func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {
UpdateLogStore.shared.append("download expected length: \(expectedContentLength)")
if usesStandardPresentation {
standard.showDownloadDidReceiveExpectedContentLength(expectedContentLength)
return
}
guard case let .downloading(downloading) = viewModel.state else {
return
}
Expand All @@ -105,6 +166,10 @@ class UpdateDriver: NSObject, SPUUserDriver {

func showDownloadDidReceiveData(ofLength length: UInt64) {
UpdateLogStore.shared.append("download received data: \(length)")
if usesStandardPresentation {
standard.showDownloadDidReceiveData(ofLength: length)
return
}
guard case let .downloading(downloading) = viewModel.state else {
return
}
Expand All @@ -117,21 +182,37 @@ class UpdateDriver: NSObject, SPUUserDriver {

func showDownloadDidStartExtractingUpdate() {
UpdateLogStore.shared.append("show extraction started")
if usesStandardPresentation {
standard.showDownloadDidStartExtractingUpdate()
return
}
setState(.extracting(.init(progress: 0)))
}

func showExtractionReceivedProgress(_ progress: Double) {
UpdateLogStore.shared.append(String(format: "show extraction progress: %.2f", progress))
if usesStandardPresentation {
standard.showExtractionReceivedProgress(progress)
return
}
setState(.extracting(.init(progress: progress)))
}

func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
UpdateLogStore.shared.append("show ready to install")
if usesStandardPresentation {
standard.showReady(toInstallAndRelaunch: reply)
return
}
reply(.install)
}

func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
UpdateLogStore.shared.append("show installing update")
if usesStandardPresentation {
standard.showInstallingUpdate(withApplicationTerminated: applicationTerminated, retryTerminatingApplication: retryTerminatingApplication)
return
}
setState(.installing(.init(
retryTerminatingApplication: retryTerminatingApplication,
dismiss: { [weak viewModel] in
Expand All @@ -142,16 +223,29 @@ class UpdateDriver: NSObject, SPUUserDriver {

func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
UpdateLogStore.shared.append("show update installed (relaunched=\(relaunched))")
if usesStandardPresentation {
standard.showUpdateInstalledAndRelaunched(relaunched) { [weak self] in
self?.finishUserInitiatedCheckPresentation()
acknowledgement()
}
return
}
setState(.idle)
acknowledgement()
}

func showUpdateInFocus() {
// No-op; cmux never shows Sparkle dialogs.
if usesStandardPresentation {
standard.showUpdateInFocus()
}
}

func dismissUpdateInstallation() {
UpdateLogStore.shared.append("dismiss update installation")
if usesStandardPresentation {
standard.dismissUpdateInstallation()
return
}
if case .error = viewModel.state {
UpdateLogStore.shared.append("dismiss update installation ignored (error visible)")
return
Expand Down Expand Up @@ -275,6 +369,13 @@ class UpdateDriver: NSObject, SPUUserDriver {
return parts.joined(separator: " | ")
}

func finishUserInitiatedCheckPresentation() {
runOnMain { [weak self] in
self?.pendingUserInitiatedCheckPresentation = nil
self?.activeUserInitiatedCheckPresentation = nil
}
}

private func describe(_ state: UpdateState) -> String {
switch state {
case .idle:
Expand Down Expand Up @@ -302,6 +403,45 @@ class UpdateDriver: NSObject, SPUUserDriver {
}
}

private func describe(_ presentation: UpdateUserInitiatedCheckPresentation) -> String {
switch presentation {
case .dialog:
return "dialog"
case .custom:
return "custom"
}
}

private var usesStandardPresentation: Bool {
currentUserInitiatedCheckPresentation() == .dialog
}

private func currentUserInitiatedCheckPresentation() -> UpdateUserInitiatedCheckPresentation? {
activeUserInitiatedCheckPresentation ?? pendingUserInitiatedCheckPresentation
}

private func activateUserInitiatedCheckPresentation() -> UpdateUserInitiatedCheckPresentation {
let presentation = currentUserInitiatedCheckPresentation() ?? .dialog
activeUserInitiatedCheckPresentation = presentation
pendingUserInitiatedCheckPresentation = nil
return presentation
}

private func clearCustomStateForStandardPresentation() {
runOnMain { [weak self] in
guard let self else { return }
pendingCheckTransition?.cancel()
pendingCheckTransition = nil
checkTimeoutWorkItem?.cancel()
checkTimeoutWorkItem = nil
lastCheckStart = nil
if case .idle = viewModel.state {
return
}
applyState(.idle)
}
}

private func runOnMain(_ action: @escaping () -> Void) {
if Thread.isMainThread {
action()
Expand Down
12 changes: 7 additions & 5 deletions cmuxUITests/SidebarHelpMenuUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,12 @@ final class SidebarHelpMenuUITests: XCTestCase {
XCTAssertTrue(app.staticTexts["ShortcutRecordingHint"].waitForExistence(timeout: 6.0))
}

func testHelpMenuCheckForUpdatesTriggersSidebarUpdatePill() {
func testHelpMenuCheckForUpdatesShowsSparkleDialogOnFirstAttempt() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
app.launchEnvironment["CMUX_UI_TEST_FEED_URL"] = "https://cmux.test/appcast.xml"
app.launchEnvironment["CMUX_UI_TEST_FEED_MODE"] = "available"
app.launchEnvironment["CMUX_UI_TEST_UPDATE_VERSION"] = "9.9.9"
app.launchEnvironment["CMUX_UI_TEST_UPDATE_VERSION"] = "99.0.0"
app.launchEnvironment["CMUX_UI_TEST_AUTO_ALLOW_PERMISSION"] = "1"
launchAndActivate(app)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

Expand All @@ -72,9 +72,11 @@ final class SidebarHelpMenuUITests: XCTestCase {
)
checkForUpdatesItem.click()

let updatePill = app.buttons["UpdatePill"]
XCTAssertTrue(updatePill.waitForExistence(timeout: 6.0))
XCTAssertEqual(updatePill.label, "Update Available: 9.9.9")
let installButton = app.buttons["Install Update"]
XCTAssertTrue(installButton.waitForExistence(timeout: 8.0))
XCTAssertTrue(app.buttons["Remind Me Later"].exists)
XCTAssertTrue(app.buttons["Skip This Version"].exists)
XCTAssertTrue(app.staticTexts["99.0.0"].exists)
}

func testHelpMenuSendFeedbackOpensComposerSheet() {
Expand Down
Loading