Fix Sparkle update dialog requiring two presses#1908
Fix Sparkle update dialog requiring two presses#1908austinywang wants to merge 2 commits intomainfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis PR introduces an Changes
Estimated Code Review Effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly Related PRs
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Greptile SummaryThis 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 Key changes:
One notable gap: Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
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]
|
| if state.userInitiated, choice != .install { | ||
| finishUserInitiatedCheckPresentation() | ||
| } |
There was a problem hiding this comment.
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:
| 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.
| @@ -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() | |||
| } | |||
There was a problem hiding this comment.
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()
}
}
There was a problem hiding this comment.
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
📒 Files selected for processing (4)
Sources/Update/UpdateController.swiftSources/Update/UpdateDelegate.swiftSources/Update/UpdateDriver.swiftcmuxUITests/SidebarHelpMenuUITests.swift
| 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) |
There was a problem hiding this comment.
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.
| 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))) | ||
| } |
There was a problem hiding this comment.
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] inYou’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.
Summary
Closes #1906
Testing
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.SPUStandardUserDriverto show the standardSparkledialog for.dialogchecks.UpdateControllernow passes.dialogfor the menu item and.customfor background attempts.99.0.0appcast.Written for commit 72f2e3b. Summary will update on new commits.
Summary by CodeRabbit
Bug Fixes
Refactor