Skip to content

Fix Sparkle update dialog requiring two presses - #1908

Merged
austinywang merged 3 commits into
mainfrom
issue-1906-update-dialog-double-press
Mar 22, 2026
Merged

austinywang merged 3 commits into
mainfrom
issue-1906-update-dialog-double-press

Conversation

@austinywang

@austinywang austinywang commented Mar 21, 2026 •

Copy link
Copy Markdown
Contributor

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, while keeping our custom, unobtrusive flow for background and auto‑install checks. Also ensures the dialog always reflects the latest available update.

  • Bug Fixes
    • Route user‑initiated checks through .dialog using SPUStandardUserDriver; background stays .custom.
    • Probe the appcast before manual checks and clear stale deferred updates so older cached versions don’t surface (via SPUUpdater resumable update reset).
    • Coalesce overlapping checks and allow cancel while probing; show a temporary checking state when needed.
    • Finish/reset presentation state when the user doesn’t install or on errors; clear custom state when switching to the standard dialog.
    • UI tests cover first‑click dialog and replacing a deferred 98.0.0 with the latest 99.0.0.

Written for commit 44b4374. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • User-initiated update checks can use either the app’s custom UI or the standard Sparkle dialog; presentation is preserved across retries.
  • Bug Fixes

    • More reliable queuing, retrying and cancellation of update checks; preserves user-initiated presentation state.
  • Refactor

    • Reworked update-check orchestration to coalesce concurrent probes, refresh deferred candidates, and better manage checking state.
  • Tests

    • UI tests updated to validate the Sparkle-style update dialog and locale handling.

@vercel

vercel Bot commented Mar 21, 2026 •

Copy link
Copy Markdown

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 10:20pm

@coderabbitai

coderabbitai Bot commented Mar 21, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Routes user-initiated update checks through a new request entry with a presentation mode, probes the latest appcast item before starting updates (with coalescing and cancellation), adds debug injection for UI tests, and introduces reflection helpers, a network appcast probe, and a parser. Also adds presentation-tracking and delegation to Sparkle's standard driver.

Changes

Cohort / File(s) Summary
Update Controller Entry Points & Probing
Sources/Update/UpdateController.swift
Added requestCheckForUpdates(presentation:); checkForUpdatesWhenReady now accepts presentation (default .dialog) and preserves it across retries. Before invoking updater, runs refreshLatestUpdateSelectionIfNeeded(presentation:completion:) which coalesces concurrent requests, probes latest appcast, clears resumable deferred candidate if newer, and cancels probe on deinit. Adds DEBUG env injection for deferred candidate.
Presentation State & Driver Routing
Sources/Update/UpdateDriver.swift
Added UpdateUserInitiatedCheckPresentation enum (.dialog, .custom), state fields (pendingUserInitiatedCheckPresentation, activeUserInitiatedCheckPresentation), standard: SPUStandardUserDriver, prepareForUserInitiatedCheck(presentation:), and finishUserInitiatedCheckPresentation(). showUserInitiatedUpdateCheck and lifecycle methods now branch to either standard Sparkle driver or existing custom flow; init parameter labeled hostBundle: Bundle.
Delegate Lifecycle Updates
Sources/Update/UpdateDelegate.swift
updater(_:userDidMake:forUpdate:state:) now names choice and state parameters and conditionally calls finishUserInitiatedCheckPresentation() when the check was user-initiated and the choice is not .install.
Appcast Probe & Parsing Helpers
Sources/Update/... (UpdateLatestItemProbe, LatestAppcastFeedParser, reflection helpers)`
Added network-based UpdateLatestItemProbe to fetch/parse appcast, LatestAppcastFeedParser to extract candidate versions and apply basic system-version filtering, and reflection helpers to read/clear Sparkle’s resumable deferred update state.
UI Tests & Debug Hooks
cmuxUITests/SidebarHelpMenuUITests.swift
Added configureEnglishLocale(_:) helper; updated tests to assert Sparkle dialog appears on first attempt (checks for Install/Remind/Skip controls and version 99.0.0); added DEBUG env-based deferred update injection values and renamed one test accordingly.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant UpdateController
  participant UpdateLatestItemProbe
  participant AppcastServer
  participant UpdateDriver
  participant SPUStandardUserDriver

  User->>UpdateController: requestCheckForUpdates(presentation)
  UpdateController->>UpdateController: coalesce requests / set checking state
  UpdateController->>UpdateLatestItemProbe: probe latest appcast
  UpdateLatestItemProbe->>AppcastServer: GET appcast feed
  AppcastServer-->>UpdateLatestItemProbe: appcast XML
  UpdateLatestItemProbe-->>UpdateController: latest item (version)
  UpdateController->>UpdateController: clear deferred candidate if needed
  UpdateController->>UpdateDriver: prepareForUserInitiatedCheck(presentation)
  UpdateDriver->>UpdateDriver: choose presentation (dialog/custom)
  alt dialog
    UpdateDriver->>SPUStandardUserDriver: showUserInitiatedUpdateCheck()
  else custom
    UpdateDriver->>UpdateDriver: begin custom checking UI flow
  end
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

Poem

🐰 I poked the feed with a curious nose,
Found newer carrots where the appcast grows,
One tap now wakes the dialog’s bright light,
Deferred crumbs cleared on the very first bite,
Hooray—updates hop in plain sight! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main fix: restoring Sparkle's standard dialog so it appears on the first press instead of requiring two presses.
Description check ✅ Passed The description covers the key changes and testing approach, but lacks explicit details on manual testing steps and is missing the demo video section.
Linked Issues check ✅ Passed The PR successfully addresses issue #1906 by restoring the standard Sparkle dialog for user-initiated checks, implementing appcast probing to handle stale deferred updates, and adding regression tests that verify the dialog appears on first trigger.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing the double-press bug and implementing the requested regression test; no unrelated modifications detected.

✏️ 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

greptile-apps Bot commented Mar 21, 2026 •

Copy link
Copy Markdown
Contributor

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.swift — showUpdateReleaseNotes 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
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.

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

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

Copy link
Copy Markdown

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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 thread cmuxUITests/SidebarHelpMenuUITests.swift
Comment thread Sources/Update/UpdateDriver.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/Update/UpdateController.swift (1)

274-278: ⚠️ Potential issue | 🟡 Minor

Localize the startup-timeout error.

This message is surfaced via the update error UI, so it should not be a bare string literal.

🌐 One possible fix
                     error: NSError(
                         domain: "cmux.update",
                         code: 1,
-                        userInfo: [NSLocalizedDescriptionKey: "Updater is still starting. Try again in a moment."]
+                        userInfo: [
+                            NSLocalizedDescriptionKey: String(
+                                localized: "update.error.starting",
+                                defaultValue: "Updater is still starting. Try again in a moment."
+                            )
+                        ]
                     ),

As per coding guidelines: "All user-facing strings must be localized using String(localized: "key.name", defaultValue: "English text") for every string shown in the UI..."

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

In `@Sources/Update/UpdateController.swift` around lines 274 - 278, The NSError
created in UpdateController.swift for the “Updater is still starting. Try again
in a moment.” message must be localized: replace the hard-coded
NSLocalizedDescriptionKey value with a localized string using String(localized:
"update.startupTimeout", defaultValue: "Updater is still starting. Try again in
a moment.") (or an appropriate key) so the NSError userInfo uses the localized
text; update the creation site (the NSError(...) call in UpdateController) and
add the new localization key to your .strings/stringsdict resources.
♻️ Duplicate comments (1)
Sources/Update/UpdateDriver.swift (1)

218-243: ⚠️ Potential issue | 🟠 Major

Finish .custom on the remaining install/dismiss exits.

The new cleanup on the not-found/error branches is good, but these custom install paths still only drive viewModel.state back to .idle. After a dismissed or completed attemptUpdate(), activeUserInitiatedCheckPresentation can remain .custom, so the next Help → Check for Updates can fall back to the silent path again.

🔁 One possible fix
         setState(.installing(.init(
             retryTerminatingApplication: retryTerminatingApplication,
-            dismiss: { [weak viewModel] in
+            dismiss: { [weak self, weak viewModel] in
+                self?.finishUserInitiatedCheckPresentation()
                 viewModel?.state = .idle
             }
         )))
…
-        setState(.idle)
+        finishUserInitiatedCheckPresentation()
+        setState(.idle)
         acknowledgement()
…
-        setState(.idle)
+        finishUserInitiatedCheckPresentation()
+        setState(.idle)

Also applies to: 251-270

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

In `@Sources/Update/UpdateDriver.swift` around lines 218 - 243, The custom
presentation exits in UpdateDriver are not finishing the user-initiated
presentation, leaving activeUserInitiatedCheckPresentation as .custom; in
showInstallingUpdate(withApplicationTerminated:retryTerminatingApplication:)
ensure the dismiss closure calls finishUserInitiatedCheckPresentation() (in
addition to setting viewModel?.state = .idle), and in
showUpdateInstalledAndRelaunched(_:, acknowledgement:) call
finishUserInitiatedCheckPresentation() before/after setting
state/acknowledgement when usesStandardPresentation is false; apply the same fix
to the other custom install/dismiss paths in this file (the other methods
handling install completion/failure/dismissal) so every custom-path exit invokes
finishUserInitiatedCheckPresentation().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/Update/UpdateController.swift`:
- Around line 433-441: The log currently assumes clearing the deferred update
always succeeds; instead check the boolean returned by
clearDeferredUpdateCandidate() (which forwards the success from
SparkleResumableUpdateReflection.clearDeferredUpdate(from:)) and only call
UpdateLogStore.shared.append(...) when that returned flag is true; apply the
same change for the other occurrences around the blocks using
shouldReplaceDeferredUpdate(...) (the similar sites at the other noted
locations) so the "cleared stale deferred update ..." message is only logged on
actual success.
- Around line 424-447: The probe callback path that waits for
UpdateLatestItemProbe (probe.start) needs a bounded fallback: when starting
UpdateLatestItemProbe in the block that sets latestItemProbe and
latestItemProbeQueuedFollowUp, schedule a short timeout (e.g., via
DispatchQueue.asyncAfter or a DispatchWorkItem) that, if fired, clears
latestItemProbe, grabs the followUp (same logic used in the success path: use
latestItemProbeQueuedFollowUp ?? completion then nil it), logs or note a
timeout, and calls followUp(); when the real probe completion occurs cancel the
timeout before performing the existing cleanup (clearing latestItemProbe,
merging follow-ups, deciding deferred update replacement, and calling
followUp()). Ensure this also mirrors the same timeout addition for the other
probe site referenced (the similar block around lines 557-600).

---

Outside diff comments:
In `@Sources/Update/UpdateController.swift`:
- Around line 274-278: The NSError created in UpdateController.swift for the
“Updater is still starting. Try again in a moment.” message must be localized:
replace the hard-coded NSLocalizedDescriptionKey value with a localized string
using String(localized: "update.startupTimeout", defaultValue: "Updater is still
starting. Try again in a moment.") (or an appropriate key) so the NSError
userInfo uses the localized text; update the creation site (the NSError(...)
call in UpdateController) and add the new localization key to your
.strings/stringsdict resources.

---

Duplicate comments:
In `@Sources/Update/UpdateDriver.swift`:
- Around line 218-243: The custom presentation exits in UpdateDriver are not
finishing the user-initiated presentation, leaving
activeUserInitiatedCheckPresentation as .custom; in
showInstallingUpdate(withApplicationTerminated:retryTerminatingApplication:)
ensure the dismiss closure calls finishUserInitiatedCheckPresentation() (in
addition to setting viewModel?.state = .idle), and in
showUpdateInstalledAndRelaunched(_:, acknowledgement:) call
finishUserInitiatedCheckPresentation() before/after setting
state/acknowledgement when usesStandardPresentation is false; apply the same fix
to the other custom install/dismiss paths in this file (the other methods
handling install completion/failure/dismissal) so every custom-path exit invokes
finishUserInitiatedCheckPresentation().

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d984e9cc-6380-4f38-b191-7ed1f010a8fa

📥 Commits

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

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

Comment on lines +424 to +447
let probe = UpdateLatestItemProbe()
latestItemProbe = probe
latestItemProbeQueuedFollowUp = nil
probe.start { [weak self] result in
guard let self else { return }
self.latestItemProbe = nil
let followUp = self.latestItemProbeQueuedFollowUp ?? completion
self.latestItemProbeQueuedFollowUp = nil

if let latestValidUpdate = result.latestValidUpdate,
self.shouldReplaceDeferredUpdate(
deferredUpdateCandidate,
with: latestValidUpdate
) {
self.clearDeferredUpdateCandidate()
UpdateLogStore.shared.append(
"cleared stale deferred update \(deferredUpdateCandidate.displayVersionString) in favor of \(latestValidUpdate.displayVersionString)"
)
} else if let error = result.error {
UpdateLogStore.shared.append("latest update probe failed: \(self.userDriver.formatErrorForLog(error))")
}

followUp()
}

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 | 🟠 Major

Give the preflight probe a bounded fallback.

This path waits for UpdateLatestItemProbe to finish before calling performUserInitiatedCheck, and that probe has no local deadline. In the exact deferred-update case this PR targets, a slow or hung feed request can leave Help → Check for Updates stuck in .checking instead of ever opening Sparkle’s dialog. Add a short timeout and continue with followUp() when it expires.

Also applies to: 557-600

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

In `@Sources/Update/UpdateController.swift` around lines 424 - 447, The probe
callback path that waits for UpdateLatestItemProbe (probe.start) needs a bounded
fallback: when starting UpdateLatestItemProbe in the block that sets
latestItemProbe and latestItemProbeQueuedFollowUp, schedule a short timeout
(e.g., via DispatchQueue.asyncAfter or a DispatchWorkItem) that, if fired,
clears latestItemProbe, grabs the followUp (same logic used in the success path:
use latestItemProbeQueuedFollowUp ?? completion then nil it), logs or note a
timeout, and calls followUp(); when the real probe completion occurs cancel the
timeout before performing the existing cleanup (clearing latestItemProbe,
merging follow-ups, deciding deferred update replacement, and calling
followUp()). Ensure this also mirrors the same timeout addition for the other
probe site referenced (the similar block around lines 557-600).

Comment on lines +433 to +441
if let latestValidUpdate = result.latestValidUpdate,
self.shouldReplaceDeferredUpdate(
deferredUpdateCandidate,
with: latestValidUpdate
) {
self.clearDeferredUpdateCandidate()
UpdateLogStore.shared.append(
"cleared stale deferred update \(deferredUpdateCandidate.displayVersionString) in favor of \(latestValidUpdate.displayVersionString)"
)

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 | 🟡 Minor

Only log a stale-candidate clear when it actually succeeds.

SparkleResumableUpdateReflection.clearDeferredUpdate(from:) already returns a success flag, but clearDeferredUpdateCandidate() discards it and the caller logs success unconditionally. If that clear no-ops, the stale deferred update remains in place and this fix silently regresses.

🛠️ One possible fix
-    private func clearDeferredUpdateCandidate() {
+    private func clearDeferredUpdateCandidate() -> Bool {
 `#if` DEBUG
         debugDeferredUpdateCandidate = nil
 `#endif`
-        _ = SparkleResumableUpdateReflection.clearDeferredUpdate(from: updater)
+        return SparkleResumableUpdateReflection.clearDeferredUpdate(from: updater)
     }
…
-                self.clearDeferredUpdateCandidate()
-                UpdateLogStore.shared.append(
-                    "cleared stale deferred update \(deferredUpdateCandidate.displayVersionString) in favor of \(latestValidUpdate.displayVersionString)"
-                )
+                if self.clearDeferredUpdateCandidate() {
+                    UpdateLogStore.shared.append(
+                        "cleared stale deferred update \(deferredUpdateCandidate.displayVersionString) in favor of \(latestValidUpdate.displayVersionString)"
+                    )
+                } else {
+                    UpdateLogStore.shared.append("failed to clear stale deferred update candidate")
+                }

Also applies to: 476-480, 529-535

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

In `@Sources/Update/UpdateController.swift` around lines 433 - 441, The log
currently assumes clearing the deferred update always succeeds; instead check
the boolean returned by clearDeferredUpdateCandidate() (which forwards the
success from SparkleResumableUpdateReflection.clearDeferredUpdate(from:)) and
only call UpdateLogStore.shared.append(...) when that returned flag is true;
apply the same change for the other occurrences around the blocks using
shouldReplaceDeferredUpdate(...) (the similar sites at the other noted
locations) so the "cleared stale deferred update ..." message is only logged on
actual success.

@austinywang
austinywang merged commit 661a4e8 into main Mar 22, 2026
17 checks passed
austinywang added a commit that referenced this pull request Mar 25, 2026
* Restore inline sidebar update checks and embed appcast changelog

* Revert Sparkle manual update dialog flow
bn-l pushed a commit to bn-l/cmux that referenced this pull request Apr 3, 2026
…e-dialog-double-press

Fix Sparkle update dialog requiring two presses
bn-l pushed a commit to bn-l/cmux that referenced this pull request Apr 3, 2026
…low-ai#2090)

* Restore inline sidebar update checks and embed appcast changelog

* Revert Sparkle manual update dialog flow

This branch was successfully deployed

1 active deployment
Preview — 44b4374f Deployed Mar 21, 2026 by vercel[bot]
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