Skip to content

Fix Sparkle update dialog requiring two presses#1908

Open
austinywang wants to merge 2 commits intomainfrom
issue-1906-update-dialog-double-press
Open

Fix Sparkle update dialog requiring two presses#1908
austinywang wants to merge 2 commits intomainfrom
issue-1906-update-dialog-double-press

Conversation

@austinywang
Copy link
Contributor

@austinywang austinywang commented Mar 21, 2026

Summary

  • restore the standard Sparkle dialog for normal user-initiated update checks on the first attempt
  • keep the custom unobtrusive update flow for background checks and the auto-install attempt path
  • add a UI regression that feeds Sparkle a fake 99.0.0 appcast and asserts the dialog appears after a single Check for Updates trigger

Closes #1906

Testing

  • ./scripts/reload.sh --tag issue1906
  • UI tests not run locally per repo policy

Summary by cubic

Fixes the double-press bug (#1906) by showing Sparkle’s standard update dialog on the first manual Check for Updates. Keeps our custom, unobtrusive flow for background and auto-install checks.

  • Bug Fixes
    • Added a presentation mode to route user-initiated checks through dialog vs custom UI.
    • Integrated SPUStandardUserDriver to show the standard Sparkle dialog for .dialog checks.
    • Reset presentation state when the user doesn’t install (e.g., remind later or skip).
    • UpdateController now passes .dialog for the menu item and .custom for background attempts.
    • UI test verifies the dialog appears after a single click using a fake 99.0.0 appcast.

Written for commit 72f2e3b. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes

    • Enhanced update check handling when the application is busy; checks now queue and retry more reliably.
    • Improved user update dialog presentation and choice handling.
  • Refactor

    • Restructured update check request flow for more flexible presentation modes and better state management.

@vercel
Copy link

vercel bot commented Mar 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Mar 21, 2026 6:07am

@coderabbitai
Copy link

coderabbitai bot commented Mar 21, 2026

📝 Walkthrough

Walkthrough

This PR introduces an UpdateUserInitiatedCheckPresentation enum to track whether user-initiated update checks should use Sparkle's standard dialog UI or custom presentation. UpdateController now routes user-initiated actions through a new requestCheckForUpdates(presentation:) entry point that threads the presentation mode through the update lifecycle. UpdateDriver manages presentation state transitions and conditionally delegates to the standard Sparkle driver based on the selected presentation mode.

Changes

Cohort / File(s) Summary
Update Controller Entry Points
Sources/Update/UpdateController.swift
New requestCheckForUpdates(presentation:) entry point accepts UpdateUserInitiatedCheckPresentation. Menu actions and attemptUpdate() now call this new entry point instead of checkForUpdates() directly. checkForUpdatesWhenReady extended to accept and propagate presentation parameter with default .dialog. Retry closure and scheduled work items preserve original presentation value.
Update Presentation State Management
Sources/Update/UpdateDriver.swift
Added UpdateUserInitiatedCheckPresentation enum (.dialog, .custom). Introduced presentation state tracking (pendingUserInitiatedCheckPresentation, activeUserInitiatedCheckPresentation) and control methods: prepareForUserInitiatedCheck(presentation:), finishUserInitiatedCheckPresentation(). Added standard: SPUStandardUserDriver property. Updated showUserInitiatedUpdateCheck and all subsequent lifecycle methods to conditionally route to standard driver or custom flow based on active presentation mode. Updated init signature to use labeled hostBundle parameter.
Delegate Lifecycle Updates
Sources/Update/UpdateDelegate.swift
Method updater(_:userDidMake:forUpdate:state:) now captures choice and state parameters by name. Added conditional call to finishUserInitiatedCheckPresentation() when update is user-initiated and choice is not .install, enabling presentation cleanup for non-install outcomes.
Test Updates
cmuxUITests/SidebarHelpMenuUITests.swift
Renamed test from testHelpMenuCheckForUpdatesTriggersSidebarUpdatePill() to testHelpMenuCheckForUpdatesShowsSparkleDialogOnFirstAttempt(). Updated mock version from 9.9.9 to 99.0.0. Changed assertions from verifying sidebar pill to verifying Sparkle dialog controls appear on first check attempt (Install Update button, Remind Me Later, Skip This Version, version text).

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly Related PRs

Poem

🐰 Two taps became one with a flick of the pen,
Presentation states dancing again and again,
The dialog now springs on the very first go,
No more hidden clicks—let the updates flow! ✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The description covers the summary and includes testing instructions, but lacks demo video URL, bot review requests, and checklist completeness. Complete the PR description template: add demo video link, uncomment the Review Trigger block, and mark checklist items to show what was completed and what remains.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix Sparkle update dialog requiring two presses' directly describes the main bug fix: eliminating the double-press requirement for the update dialog.
Linked Issues check ✅ Passed The code changes implement the core fix from #1906: routing user-initiated checks through .dialog presentation to show Sparkle's standard dialog on first attempt, while keeping custom flow for background checks. A UI test verifies the dialog appears after a single click.
Out of Scope Changes check ✅ Passed All changes are scoped to the update dialog presentation logic. UpdateController routes checks by presentation type, UpdateDriver manages presentation state and delegates to standard or custom UI, and the UI test validates the fix.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-1906-update-dialog-double-press

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link

greptile-apps bot commented Mar 21, 2026

Greptile Summary

This PR fixes issue #1906, where the standard Sparkle update dialog required two "Check for Updates" presses to appear. The root cause was that the first press fell through to the custom/unobtrusive update UI flow; this PR introduces a UpdateUserInitiatedCheckPresentation enum (.dialog / .custom) that is threaded from UpdateController all the way down to UpdateDriver, allowing the driver to selectively delegate to an embedded SPUStandardUserDriver for user-initiated menu-bar checks while keeping the custom flow for background probes and attemptUpdate.

Key changes:

  • UpdateDriver now holds a lazily-initialized SPUStandardUserDriver and forwards all SPUUserDriver callbacks to it when usesStandardPresentation is true (active/pending presentation is .dialog).
  • UpdateController.checkForUpdates() (the @objc menu-item action) now calls requestCheckForUpdates(presentation: .dialog), while attemptUpdate() uses .custom.
  • finishUserInitiatedCheckPresentation() clears the active presentation state after the dialog lifecycle ends (user dismisses, skips, or install+relaunch completes).
  • A UI regression test is added that feeds a fake 99.0.0 appcast and asserts the standard Sparkle dialog (Install / Remind Me Later / Skip buttons) appears after a single trigger.

One notable gap: showUpdateReleaseNotes and showUpdateReleaseNotesFailedToDownloadWithError are not forwarded to standard when usesStandardPresentation is true, so the standard dialog's release notes panel will be empty/stuck loading even when notes are available.

Confidence Score: 4/5

  • Safe to merge; the primary user path is fixed and tested, with a minor release-notes gap remaining.
  • The core fix is correct: the dual-mode presentation system cleanly separates the standard Sparkle dialog from the custom UI, and the UI regression test verifies the single-press behavior end-to-end. The remaining concerns are all P2 style issues — the most visible being that showUpdateReleaseNotes is not forwarded to the standard driver, which will cause the standard dialog to show without inline release notes. This is noticeable UX degradation but not a crash or data-loss risk, and it doesn't affect the primary fix path.
  • Sources/Update/UpdateDriver.swiftshowUpdateReleaseNotes and showUpdateReleaseNotesFailedToDownloadWithError need forwarding to standard when usesStandardPresentation is true.

Important Files Changed

Filename Overview
Sources/Update/UpdateDriver.swift Core change: introduces dual-mode presentation (.dialog vs .custom) by delegating to a newly instantiated SPUStandardUserDriver for all driver callbacks when a user-initiated check is flagged as .dialog. Well-structured, but showUpdateReleaseNotes and showUpdateReleaseNotesFailedToDownloadWithError are not forwarded to standard, so the standard dialog will render with missing/empty release notes.
Sources/Update/UpdateController.swift Refactors checkForUpdates / performCheckForUpdates to thread a UpdateUserInitiatedCheckPresentation through the whole ready-retry chain. The attemptUpdate path correctly uses .custom. Minor ordering concern: prepareForUserInitiatedCheck is called before cancelling an in-flight check in the non-idle branch, which can prematurely set usesStandardPresentation during dismissUpdateInstallation.
Sources/Update/UpdateDelegate.swift Adds a finishUserInitiatedCheckPresentation() call in userDidMake:forUpdate:state: for non-install user-initiated choices. Correct intent, though this produces a redundant double-call alongside the cleanup already done in the showUpdateFound reply closure for the .dialog path.
cmuxUITests/SidebarHelpMenuUITests.swift Renames the test to match new behavior and asserts the standard Sparkle dialog (Install/Remind Me Later/Skip This Version buttons + version string) appears after a single "Check for Updates" click. Solid regression coverage for the primary fix; bumped version string to 99.0.0 avoids stale-version collisions.

Sequence Diagram

sequenceDiagram
    actor User
    participant Menu as Help Menu
    participant Controller as UpdateController
    participant Driver as UpdateDriver
    participant Standard as SPUStandardUserDriver
    participant Sparkle as SPUUpdater

    Note over Controller,Driver: User-initiated check (.dialog path)
    User->>Menu: Click "Check for Updates"
    Menu->>Controller: checkForUpdates()
    Controller->>Controller: requestCheckForUpdates(.dialog)
    Controller->>Controller: checkForUpdatesWhenReady(presentation: .dialog)
    Controller->>Controller: performCheckForUpdates(.dialog)
    Controller->>Driver: prepareForUserInitiatedCheck(.dialog)
    Note right of Driver: pendingUserInitiatedCheckPresentation = .dialog
    Controller->>Sparkle: updater.checkForUpdates()
    Sparkle->>Driver: showUserInitiatedUpdateCheck(cancellation:)
    Driver->>Driver: activateUserInitiatedCheckPresentation() → .dialog
    Driver->>Standard: showUserInitiatedUpdateCheck(cancellation:)
    Sparkle->>Driver: showUpdateFound(appcastItem, state, reply)
    Driver->>Standard: showUpdateFound(appcastItem, state, reply)
    Standard-->>User: Show standard Sparkle dialog
    User->>Standard: Choose "Remind Me Later" / "Skip"
    Standard->>Driver: reply(.dismiss/.skip)
    Driver->>Driver: finishUserInitiatedCheckPresentation()
    Note right of Driver: active/pendingPresentation = nil

    Note over Controller,Driver: Background/attemptUpdate path (.custom)
    Controller->>Controller: attemptUpdate()
    Controller->>Controller: requestCheckForUpdates(.custom)
    Controller->>Driver: prepareForUserInitiatedCheck(.custom)
    Controller->>Sparkle: updater.checkForUpdates()
    Sparkle->>Driver: showUserInitiatedUpdateCheck(cancellation:)
    Driver->>Driver: activateUserInitiatedCheckPresentation() → .custom
    Driver->>Driver: beginChecking(cancel:) [custom UI]
Loading

Comments Outside Diff (1)

  1. Sources/Update/UpdateDriver.swift, line 87-93 (link)

    P2 Release notes not forwarded to standard driver

    showUpdateReleaseNotes and showUpdateReleaseNotesFailedToDownloadWithError are both no-ops and were intentionally so when only the custom UI was shown. But now that the standard Sparkle dialog is used for user-initiated checks, SPUStandardUserDriver needs to receive these callbacks in order to populate the release notes panel in its dialog. Without forwarding, the standard dialog will open with an empty or permanently-loading release notes view.

Last reviewed commit: "fix: show Sparkle di..."

Comment on lines +108 to +110
if state.userInitiated, choice != .install {
finishUserInitiatedCheckPresentation()
}
Copy link

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.

Comment on lines 215 to 230
@@ -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()
}
Copy link

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

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

No issues found across 4 files

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cmuxUITests/SidebarHelpMenuUITests.swift`:
- Around line 51-57: The Sparkle UI tests hard-code English button labels;
update the app setup where XCUIApplication() is configured (the app variable and
its launchEnvironment before launchAndActivate(app)) to pin the test locale to
English by injecting AppleLanguages/AppleLocale (or passing -AppleLanguages and
-AppleLocale launch arguments) so the dialog labels are deterministic across
runners; ensure the same change is applied where the other block at lines 75-79
configures the app.

In `@Sources/Update/UpdateDriver.swift`:
- Around line 95-107: The custom presentation path never clears
activeUserInitiatedCheckPresentation when it reaches terminal states, so update
the custom terminal exits (e.g., in
showUpdateNotFoundWithError(_:acknowledgement:), and the other custom
.notFound/.error/.cancel/.dismiss handlers referenced near the other ranges) to
clear/reset the active presentation—call finishUserInitiatedCheckPresentation()
or otherwise clear activeUserInitiatedCheckPresentation before invoking
acknowledgement callbacks or before calling
setStateAfterMinimumCheckDelay(.notFound(...)) so that
currentUserInitiatedCheckPresentation() will no longer prefer the stale custom
presentation and future prepareForUserInitiatedCheck(.dialog) can open the
standard dialog again.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 98f74e3b-f48e-4acd-9f49-66c292ed1c4e

📥 Commits

Reviewing files that changed from the base of the PR and between a592ed1 and 72f2e3b.

📒 Files selected for processing (4)
  • Sources/Update/UpdateController.swift
  • Sources/Update/UpdateDelegate.swift
  • Sources/Update/UpdateDriver.swift
  • cmuxUITests/SidebarHelpMenuUITests.swift

Comment on lines 51 to 57
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)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Pin the locale for these Sparkle assertions.

These checks hard-code Sparkle’s English button labels. On a Japanese/non-English runner, the dialog can behave correctly and still fail this test because Install Update, Remind Me Later, and Skip This Version are localized.

🌐 Suggested stabilization
     func testHelpMenuCheckForUpdatesShowsSparkleDialogOnFirstAttempt() {
         let app = XCUIApplication()
+        app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"]
         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"] = "99.0.0"

Also applies to: 75-79

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

In `@cmuxUITests/SidebarHelpMenuUITests.swift` around lines 51 - 57, The Sparkle
UI tests hard-code English button labels; update the app setup where
XCUIApplication() is configured (the app variable and its launchEnvironment
before launchAndActivate(app)) to pin the test locale to English by injecting
AppleLanguages/AppleLocale (or passing -AppleLanguages and -AppleLocale launch
arguments) so the dialog labels are deterministic across runners; ensure the
same change is applied where the other block at lines 75-79 configures the app.

Comment on lines 95 to 107
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)))
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Finish .custom presentations when the custom flow reaches a terminal state.

attemptUpdate() now primes .custom, but these custom .notFound / .error exits never clear activeUserInitiatedCheckPresentation. Because currentUserInitiatedCheckPresentation() prefers active over pending, a later prepareForUserInitiatedCheck(.dialog) can be ignored and Help → Check for Updates will keep taking the custom path instead of reopening Sparkle’s dialog. Clear the presentation as soon as the custom flow leaves Sparkle, and apply the same treatment to the other custom cancel/dismiss exits.

🔁 One possible fix
     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
         }
+        finishUserInitiatedCheckPresentation()
         setStateAfterMinimumCheckDelay(.notFound(.init(acknowledgement: acknowledgement)))
     }

     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
         }
+        finishUserInitiatedCheckPresentation()
         setState(.error(.init(
             error: error,
             retry: { [weak viewModel] in

You’ll want the same reset around the remaining custom terminal cancel/dismiss paths as well.

Also applies to: 109-137, 372-377, 419-427

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

In `@Sources/Update/UpdateDriver.swift` around lines 95 - 107, The custom
presentation path never clears activeUserInitiatedCheckPresentation when it
reaches terminal states, so update the custom terminal exits (e.g., in
showUpdateNotFoundWithError(_:acknowledgement:), and the other custom
.notFound/.error/.cancel/.dismiss handlers referenced near the other ranges) to
clear/reset the active presentation—call finishUserInitiatedCheckPresentation()
or otherwise clear activeUserInitiatedCheckPresentation before invoking
acknowledgement callbacks or before calling
setStateAfterMinimumCheckDelay(.notFound(...)) so that
currentUserInitiatedCheckPresentation() will no longer prefer the stale custom
presentation and future prepareForUserInitiatedCheck(.dialog) can open the
standard dialog again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Update dialog requires two presses to appear

1 participant