From 7d78e7e9c350c7ba172c3bd8bdbf61ca4fb7c58b Mon Sep 17 00:00:00 2001 From: Daniel Jalkut Date: Thu, 21 May 2026 13:58:19 -0400 Subject: [PATCH 1/7] Introduce a timing buffer for the Check for Updates flow to avoid jarring UI. When a check completes or fails very quickly, the "Checking for Updates" window flashes briefly before being replaced by the next alert. This PR introduces a buffering mechanism whereby SPUStandardUserDriver defers presenting the progress window for 0.3 seconds. If a result is obtained within that time frame, the progress window is never displayed. When and if the checking window has actually appeared on screen, a minimum visible duration of 0.7 seconds must elapse before the panel is dismissed. --- Sparkle/SPUStandardUserDriver.m | 166 +++++++++++++++++++++++++++++++- 1 file changed, 161 insertions(+), 5 deletions(-) diff --git a/Sparkle/SPUStandardUserDriver.m b/Sparkle/SPUStandardUserDriver.m index 9562e8a4c..95890a1bb 100644 --- a/Sparkle/SPUStandardUserDriver.m +++ b/Sparkle/SPUStandardUserDriver.m @@ -41,6 +41,14 @@ - (void)activate; @end #endif +// Buffer the "Checking for updates…" window so it doesn't flicker when a check +// completes (or fails) almost immediately. We suppress the window entirely if a +// result comes back inside SUCheckingWindowShowDelay seconds, and we enforce a +// minimum visible duration of SUCheckingWindowMinDisplayTime seconds once it +// has actually appeared on screen. +static const NSTimeInterval SUCheckingWindowShowDelay = 0.3; +static const NSTimeInterval SUCheckingWindowMinDisplayTime = 0.7; + @interface SPUStandardUserDriver () // Note: we expose a private interface for activeUpdateAlert property in SPUStandardUserDriver+Private.h as NSWindowController @@ -77,11 +85,15 @@ @implementation SPUStandardUserDriver uint64_t _expectedContentLength; uint64_t _bytesDownloaded; double _timeSinceOpportuneUpdateNotice; + double _checkingWindowShownTime; + void (^_pendingPostCheckingCompletion)(void); + void (^_pendingPostCheckingCancellation)(void); BOOL _updateAlertWindowWasInactive; BOOL _loggedGentleUpdateReminderWarning; BOOL _regularApplicationUpdate; BOOL _updateReceivedUserAttention; + BOOL _checkingWindowPendingShow; } @synthesize activeUpdateAlert = _activeUpdateAlert; @@ -352,8 +364,26 @@ - (void)showUpdateFoundWithAppcastItem:(SUAppcastItem *)appcastItem state:(SPUUs { assert(NSThread.isMainThread); - [self closeCheckingWindow]; + [self _prepareUpdateFoundWithAppcastItem:appcastItem state:state reply:reply]; + __weak __typeof__(self) weakSelf = self; + [self _transitionFromCheckingWindowWithCompletion:^{ + __typeof__(self) strongSelf = weakSelf; + if (strongSelf != nil) { + [strongSelf setUpActiveUpdateAlertForScheduledUpdate:(state.userInitiated ? nil : appcastItem) state:state]; + } + } cancellation:^{ + __typeof__(self) strongSelf = weakSelf; + if (strongSelf != nil) { + [strongSelf->_activeUpdateAlert close]; + strongSelf->_activeUpdateAlert = nil; + } + reply(SPUUserUpdateChoiceDismiss); + }]; +} + +- (void)_prepareUpdateFoundWithAppcastItem:(SUAppcastItem *)appcastItem state:(SPUUserUpdateState *)state reply:(void (^)(SPUUserUpdateChoice))reply SPU_OBJC_DIRECT +{ if (_activeUpdateAlert != nil) { SULog(SULogLevelError, @"Error: -[%@ %@] should not be called when _activeUpdateAlert != nil:\n%@", NSStringFromClass([self class]), NSStringFromSelector(_cmd), NSThread.callStackSymbols); } @@ -443,8 +473,6 @@ - (void)showUpdateFoundWithAppcastItem:(SUAppcastItem *)appcastItem state:(SPUUs if (state.userInitiated && [delegate respondsToSelector:@selector(standardUserDriverWillHandleShowingUpdate:forUpdate:state:)]) { [delegate standardUserDriverWillHandleShowingUpdate:YES forUpdate:appcastItem state:state]; } - - [self setUpActiveUpdateAlertForScheduledUpdate:(state.userInitiated ? nil : appcastItem) state:state]; } - (void)showUpdateReleaseNotesWithDownloadData:(SPUDownloadData *)downloadData @@ -477,6 +505,7 @@ - (void)showUpdateInFocus [_statusController showWindow:nil]; mayNeedToActivateApp = YES; } else if (_checkingController != nil) { + [self _presentCheckingWindowIfPending]; [_checkingController showWindow:nil]; mayNeedToActivateApp = YES; } else if (_retryTerminatingApplication != nil) { @@ -557,6 +586,29 @@ - (void)showUserInitiatedUpdateCheckWithCancellation:(void (^)(void))cancellatio [self _activateApplication]; } + // Defer actually presenting the progress window until a minimum amount of time + // passes. This eliminates the potential for either a very brief flicker showing the + // window and then dismissing it, and of needing to show the window and then artificially + // sustain it for a long enough to avoid the flicker. See SUCheckingWindowShowDelay. + _checkingWindowPendingShow = YES; + _checkingWindowShownTime = 0.0; + SUStatusController *pendingController = _checkingController; + __weak __typeof__(self) weakSelf = self; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(SUCheckingWindowShowDelay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + __typeof__(self) strongSelf = weakSelf; + if (strongSelf != nil && strongSelf->_checkingController == pendingController) { + [strongSelf _presentCheckingWindowIfPending]; + } + }); +} + +- (void)_presentCheckingWindowIfPending SPU_OBJC_DIRECT +{ + if (_checkingController == nil || !_checkingWindowPendingShow) { + return; + } + _checkingWindowPendingShow = NO; + _checkingWindowShownTime = [self currentTime]; [_checkingController showWindow:self]; } @@ -568,10 +620,92 @@ - (void)closeCheckingWindow SPU_OBJC_DIRECT _checkingController = nil; _cancellation = nil; } + _checkingWindowPendingShow = NO; + _checkingWindowShownTime = 0.0; + _pendingPostCheckingCompletion = nil; + _pendingPostCheckingCancellation = nil; +} + +- (BOOL)_finishPendingPostCheckingTransition SPU_OBJC_DIRECT +{ + void (^pendingCompletion)(void) = _pendingPostCheckingCompletion; + if (pendingCompletion == nil) { + return NO; + } + + _pendingPostCheckingCompletion = nil; + _pendingPostCheckingCancellation = nil; + [self closeCheckingWindow]; + pendingCompletion(); + return YES; +} + +- (BOOL)_cancelPendingPostCheckingTransition SPU_OBJC_DIRECT +{ + void (^pendingCancellation)(void) = _pendingPostCheckingCancellation; + if (pendingCancellation == nil) { + return NO; + } + + _pendingPostCheckingCompletion = nil; + _pendingPostCheckingCancellation = nil; + [self closeCheckingWindow]; + pendingCancellation(); + return YES; +} + +// Closes the "Checking for updates…" window and then invokes the completion. +// If the checking window was never actually presented (because the buffered show +// delay hadn't elapsed yet), the close happens immediately. If the window has been +// shown for less than SUCheckingWindowMinDisplayTime, the close and the completion +// are deferred until the minimum display time has elapsed so the window doesn't flicker. +- (void)_transitionFromCheckingWindowWithCompletion:(void (^)(void))completion cancellation:(void (^)(void))cancellation SPU_OBJC_DIRECT +{ + if (_checkingController == nil) { + if (completion != nil) { + completion(); + } + return; + } + + if (_checkingWindowPendingShow) { + // The checking window never made it onto the screen — close silently and continue. + [self closeCheckingWindow]; + if (completion != nil) { + completion(); + } + return; + } + + double elapsedSeconds = ([self currentTime] - _checkingWindowShownTime) / (double)NSEC_PER_SEC; + if (elapsedSeconds >= SUCheckingWindowMinDisplayTime) { + [self closeCheckingWindow]; + if (completion != nil) { + completion(); + } + return; + } + + // Defer until the minimum display time has elapsed so the window doesn't flicker. + NSTimeInterval remainingSeconds = SUCheckingWindowMinDisplayTime - elapsedSeconds; + _pendingPostCheckingCompletion = [completion copy]; + _pendingPostCheckingCancellation = [cancellation copy]; + __weak __typeof__(self) weakSelf = self; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(remainingSeconds * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + __typeof__(self) strongSelf = weakSelf; + if (strongSelf == nil) { + return; + } + [strongSelf _finishPendingPostCheckingTransition]; + }); } - (void)cancelCheckForUpdates:(id)__unused sender { + if ([self _cancelPendingPostCheckingTransition]) { + return; + } + if (_cancellation != nil) { _cancellation(); _cancellation = nil; @@ -585,8 +719,19 @@ - (void)showUpdaterError:(NSError *)error acknowledgement:(void (^)(void))acknow { assert(NSThread.isMainThread); - [self closeCheckingWindow]; + __weak __typeof__(self) weakSelf = self; + [self _transitionFromCheckingWindowWithCompletion:^{ + __typeof__(self) strongSelf = weakSelf; + if (strongSelf != nil) { + [strongSelf _proceedWithUpdaterError:error acknowledgement:acknowledgement]; + } + } cancellation:^{ + acknowledgement(); + }]; +} +- (void)_proceedWithUpdaterError:(NSError *)error acknowledgement:(void (^)(void))acknowledgement SPU_OBJC_DIRECT +{ [_statusController close]; _statusController = nil; @@ -619,8 +764,19 @@ - (void)showUpdateNotFoundWithError:(NSError *)error acknowledgement:(void (^)(v { assert(NSThread.isMainThread); - [self closeCheckingWindow]; + __weak __typeof__(self) weakSelf = self; + [self _transitionFromCheckingWindowWithCompletion:^{ + __typeof__(self) strongSelf = weakSelf; + if (strongSelf != nil) { + [strongSelf _proceedWithUpdateNotFoundWithError:error acknowledgement:acknowledgement]; + } + } cancellation:^{ + acknowledgement(); + }]; +} +- (void)_proceedWithUpdateNotFoundWithError:(NSError *)error acknowledgement:(void (^)(void))acknowledgement SPU_OBJC_DIRECT +{ id delegate = _delegate; id customVersionDisplayer; From e025adce266f6ef06513df63672f72bb3f939963 Mon Sep 17 00:00:00 2001 From: Daniel Jalkut Date: Thu, 21 May 2026 15:09:58 -0400 Subject: [PATCH 2/7] Quiet warning about not calling completion handler when strongSelf is nil --- Sparkle/SPUStandardUserDriver.m | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sparkle/SPUStandardUserDriver.m b/Sparkle/SPUStandardUserDriver.m index 95890a1bb..c1b8696f2 100644 --- a/Sparkle/SPUStandardUserDriver.m +++ b/Sparkle/SPUStandardUserDriver.m @@ -770,6 +770,9 @@ - (void)showUpdateNotFoundWithError:(NSError *)error acknowledgement:(void (^)(v if (strongSelf != nil) { [strongSelf _proceedWithUpdateNotFoundWithError:error acknowledgement:acknowledgement]; } + else { + acknowledgement(); + } } cancellation:^{ acknowledgement(); }]; From fe7131e58cd5d5809ec43eb5f9270ae495b12ffc Mon Sep 17 00:00:00 2001 From: Daniel Jalkut Date: Thu, 21 May 2026 15:11:58 -0400 Subject: [PATCH 3/7] Another warning --- Sparkle/SPUStandardUserDriver.m | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sparkle/SPUStandardUserDriver.m b/Sparkle/SPUStandardUserDriver.m index c1b8696f2..eb4f480a5 100644 --- a/Sparkle/SPUStandardUserDriver.m +++ b/Sparkle/SPUStandardUserDriver.m @@ -725,6 +725,9 @@ - (void)showUpdaterError:(NSError *)error acknowledgement:(void (^)(void))acknow if (strongSelf != nil) { [strongSelf _proceedWithUpdaterError:error acknowledgement:acknowledgement]; } + else { + acknowledgement(); + } } cancellation:^{ acknowledgement(); }]; From 1f0270fa313148a2a8914911efd34ac0b95a2622 Mon Sep 17 00:00:00 2001 From: Daniel Jalkut Date: Thu, 28 May 2026 08:55:01 -0400 Subject: [PATCH 4/7] Update brace style to match Sparkle standard. --- Sparkle/SPUStandardUserDriver.m | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Sparkle/SPUStandardUserDriver.m b/Sparkle/SPUStandardUserDriver.m index eb4f480a5..9b2f2a17b 100644 --- a/Sparkle/SPUStandardUserDriver.m +++ b/Sparkle/SPUStandardUserDriver.m @@ -724,8 +724,7 @@ - (void)showUpdaterError:(NSError *)error acknowledgement:(void (^)(void))acknow __typeof__(self) strongSelf = weakSelf; if (strongSelf != nil) { [strongSelf _proceedWithUpdaterError:error acknowledgement:acknowledgement]; - } - else { + } else { acknowledgement(); } } cancellation:^{ @@ -772,8 +771,7 @@ - (void)showUpdateNotFoundWithError:(NSError *)error acknowledgement:(void (^)(v __typeof__(self) strongSelf = weakSelf; if (strongSelf != nil) { [strongSelf _proceedWithUpdateNotFoundWithError:error acknowledgement:acknowledgement]; - } - else { + } else { acknowledgement(); } } cancellation:^{ From 776af5cec1e02e1d6932c6677290dac3ab9cc7db Mon Sep 17 00:00:00 2001 From: Daniel Jalkut Date: Mon, 1 Jun 2026 12:32:41 -0400 Subject: [PATCH 5/7] Move the deferred-show / minimum-display-time mechanism out of SPUStandardUserDriver and into SUStatusController so that most of the same show/dismiss patterns can be applied, with the buffered timing happened automatically. Some special care was needed to ensure that edge case scenarios like when the user clicks "Cancel" after the underlying check had already completed, that it doesn't continue to show them the checking results. --- Sparkle/SPUStandardUserDriver.m | 391 ++++++++++++++------------------ Sparkle/SUStatusController.h | 22 ++ Sparkle/SUStatusController.m | 111 ++++++++- 3 files changed, 300 insertions(+), 224 deletions(-) diff --git a/Sparkle/SPUStandardUserDriver.m b/Sparkle/SPUStandardUserDriver.m index 9b2f2a17b..f4e2493d9 100644 --- a/Sparkle/SPUStandardUserDriver.m +++ b/Sparkle/SPUStandardUserDriver.m @@ -41,14 +41,6 @@ - (void)activate; @end #endif -// Buffer the "Checking for updates…" window so it doesn't flicker when a check -// completes (or fails) almost immediately. We suppress the window entirely if a -// result comes back inside SUCheckingWindowShowDelay seconds, and we enforce a -// minimum visible duration of SUCheckingWindowMinDisplayTime seconds once it -// has actually appeared on screen. -static const NSTimeInterval SUCheckingWindowShowDelay = 0.3; -static const NSTimeInterval SUCheckingWindowMinDisplayTime = 0.7; - @interface SPUStandardUserDriver () // Note: we expose a private interface for activeUpdateAlert property in SPUStandardUserDriver+Private.h as NSWindowController @@ -85,15 +77,11 @@ @implementation SPUStandardUserDriver uint64_t _expectedContentLength; uint64_t _bytesDownloaded; double _timeSinceOpportuneUpdateNotice; - double _checkingWindowShownTime; - void (^_pendingPostCheckingCompletion)(void); - void (^_pendingPostCheckingCancellation)(void); - + BOOL _updateAlertWindowWasInactive; BOOL _loggedGentleUpdateReminderWarning; BOOL _regularApplicationUpdate; BOOL _updateReceivedUserAttention; - BOOL _checkingWindowPendingShow; } @synthesize activeUpdateAlert = _activeUpdateAlert; @@ -363,77 +351,84 @@ - (void)applicationDidBecomeActive:(NSNotification *)__unused aNotification - (void)showUpdateFoundWithAppcastItem:(SUAppcastItem *)appcastItem state:(SPUUserUpdateState *)state reply:(void (^)(SPUUserUpdateChoice))reply { assert(NSThread.isMainThread); - - [self _prepareUpdateFoundWithAppcastItem:appcastItem state:state reply:reply]; - - __weak __typeof__(self) weakSelf = self; - [self _transitionFromCheckingWindowWithCompletion:^{ - __typeof__(self) strongSelf = weakSelf; - if (strongSelf != nil) { - [strongSelf setUpActiveUpdateAlertForScheduledUpdate:(state.userInitiated ? nil : appcastItem) state:state]; - } - } cancellation:^{ - __typeof__(self) strongSelf = weakSelf; - if (strongSelf != nil) { - [strongSelf->_activeUpdateAlert close]; - strongSelf->_activeUpdateAlert = nil; + + if (_activeUpdateAlert != nil) { + SULog(SULogLevelError, @"Error: -[%@ %@] should not be called when _activeUpdateAlert != nil:\n%@", NSStringFromClass([self class]), NSStringFromSelector(_cmd), NSThread.callStackSymbols); + } + + _regularApplicationUpdate = [appcastItem.installationType isEqualToString:SPUInstallationTypeApplication]; + + // For user initiated checks, let the delegate know we'll be showing an update. + // For scheduled checks, -setUpActiveUpdateAlertForUpdate:state: below will handle this. + id delegate = _delegate; + if (state.userInitiated && [delegate respondsToSelector:@selector(standardUserDriverWillHandleShowingUpdate:forUpdate:state:)]) { + [delegate standardUserDriverWillHandleShowingUpdate:YES forUpdate:appcastItem state:state]; + } + + // Defer building the alert until the checking window has finished closing. + // This prevents _activeUpdateAlert and _checkingController being non-nil at + // the same time, which would make ordering of nil tests in ... fragile. + [self _closeCheckingWindowWithCompletionBlock:^(SPUStandardUserDriver *s, BOOL userCancelled) { + if (s != nil && !userCancelled) { + [s _buildActiveUpdateAlertForAppcastItem:appcastItem state:state reply:reply]; + [s setUpActiveUpdateAlertForScheduledUpdate:(state.userInitiated ? nil : appcastItem) state:state]; + } else { + // Either the driver was deallocated mid-flow or the user cancelled + // during the buffered close; in either case the updater is still + // waiting on reply. Dismiss so it can tear down cleanly. + reply(SPUUserUpdateChoiceDismiss); } - reply(SPUUserUpdateChoiceDismiss); }]; } -- (void)_prepareUpdateFoundWithAppcastItem:(SUAppcastItem *)appcastItem state:(SPUUserUpdateState *)state reply:(void (^)(SPUUserUpdateChoice))reply SPU_OBJC_DIRECT +- (void)_buildActiveUpdateAlertForAppcastItem:(SUAppcastItem *)appcastItem state:(SPUUserUpdateState *)state reply:(void (^)(SPUUserUpdateChoice))reply SPU_OBJC_DIRECT { - if (_activeUpdateAlert != nil) { - SULog(SULogLevelError, @"Error: -[%@ %@] should not be called when _activeUpdateAlert != nil:\n%@", NSStringFromClass([self class]), NSStringFromSelector(_cmd), NSThread.callStackSymbols); - } - id delegate = _delegate; id customVersionDisplayer = nil; - + if ([delegate respondsToSelector:@selector(standardUserDriverRequestsVersionDisplayer)]) { customVersionDisplayer = [delegate standardUserDriverRequestsVersionDisplayer]; } - + id versionDisplayer = (customVersionDisplayer != nil) ? customVersionDisplayer : [SPUStandardVersionDisplay standardVersionDisplay]; - + BOOL needsToObserveUserAttention = [delegate respondsToSelector:@selector(standardUserDriverDidReceiveUserAttentionForUpdate:)]; - + __weak __typeof__(self) weakSelf = self; __weak id weakDelegate = delegate; _activeUpdateAlert = [[SUUpdateAlert alloc] initWithAppcastItem:appcastItem state:state host:_host versionDisplayer:versionDisplayer updaterSettings:_updaterSettings delegate:delegate completionBlock:^(SPUUserUpdateChoice choice, NSRect windowFrame, BOOL wasKeyWindow) { reply(choice); - + __typeof__(self) strongSelf = weakSelf; - + if (strongSelf != nil) { if (needsToObserveUserAttention && !strongSelf->_updateReceivedUserAttention) { strongSelf->_updateReceivedUserAttention = YES; - + id strongDelegate = weakDelegate; // needsToObserveUserAttention already checks delegate responds to this selector [strongDelegate standardUserDriverDidReceiveUserAttentionForUpdate:appcastItem]; } - + // Record the window frame of the update alert right before we deallocate it // So we can center future status window to where the update alert last was. // Also record if the window was inactive at the time a response was made // (the window may not be key if the window e.g. holds command while clicking on a response button) strongSelf->_updateAlertWindowFrameValue = [NSValue valueWithRect:windowFrame]; strongSelf->_updateAlertWindowWasInactive = !wasKeyWindow; - + strongSelf->_activeUpdateAlert = nil; } } didBecomeKeyBlock:^{ if (!needsToObserveUserAttention) { return; } - + if ([NSApp isActive]) { __typeof__(self) strongSelf = weakSelf; if (strongSelf != nil && !strongSelf->_updateReceivedUserAttention) { strongSelf->_updateReceivedUserAttention = YES; - + id strongDelegate = weakDelegate; // needsToObserveUserAttention already checks delegate responds to this selector [strongDelegate standardUserDriverDidReceiveUserAttentionForUpdate:appcastItem]; @@ -441,7 +436,7 @@ - (void)_prepareUpdateFoundWithAppcastItem:(SUAppcastItem *)appcastItem state:(S } else { // We need to listen for when the app becomes active again, and then test if the window alert // is still key. if it is, let the delegate know. Remove the observation after that. - + __typeof__(self) strongSelfOuter = weakSelf; if (strongSelfOuter != nil && strongSelfOuter->_applicationBecameActiveAfterUpdateAlertBecameKeyObserver == nil) { strongSelfOuter->_applicationBecameActiveAfterUpdateAlertBecameKeyObserver = [[NSNotificationCenter defaultCenter] addObserverForName:NSApplicationDidBecomeActiveNotification object:NSApp queue:NSOperationQueue.mainQueue usingBlock:^(NSNotification * _Nonnull __unused note) { @@ -449,15 +444,15 @@ - (void)_prepareUpdateFoundWithAppcastItem:(SUAppcastItem *)appcastItem state:(S if (strongSelf != nil) { if (!strongSelf->_updateReceivedUserAttention && [strongSelf->_activeUpdateAlert.window isKeyWindow]) { strongSelf->_updateReceivedUserAttention = YES; - + id strongDelegate = weakDelegate; // needsToObserveUserAttention already checks delegate responds to this selector [strongDelegate standardUserDriverDidReceiveUserAttentionForUpdate:appcastItem]; } - + if (strongSelf->_applicationBecameActiveAfterUpdateAlertBecameKeyObserver != nil) { [[NSNotificationCenter defaultCenter] removeObserver:strongSelf->_applicationBecameActiveAfterUpdateAlertBecameKeyObserver]; - + strongSelf->_applicationBecameActiveAfterUpdateAlertBecameKeyObserver = nil; } } @@ -465,14 +460,6 @@ - (void)_prepareUpdateFoundWithAppcastItem:(SUAppcastItem *)appcastItem state:(S } } }]; - - _regularApplicationUpdate = [appcastItem.installationType isEqualToString:SPUInstallationTypeApplication]; - - // For user initiated checks, let the delegate know we'll be showing an update - // For scheduled checks, -setUpActiveUpdateAlertForUpdate:state: below will handle this - if (state.userInitiated && [delegate respondsToSelector:@selector(standardUserDriverWillHandleShowingUpdate:forUpdate:state:)]) { - [delegate standardUserDriverWillHandleShowingUpdate:YES forUpdate:appcastItem state:state]; - } } - (void)showUpdateReleaseNotesWithDownloadData:(SPUDownloadData *)downloadData @@ -505,7 +492,6 @@ - (void)showUpdateInFocus [_statusController showWindow:nil]; mayNeedToActivateApp = YES; } else if (_checkingController != nil) { - [self _presentCheckingWindowIfPending]; [_checkingController showWindow:nil]; mayNeedToActivateApp = YES; } else if (_retryTerminatingApplication != nil) { @@ -585,132 +571,81 @@ - (void)showUserInitiatedUpdateCheckWithCancellation:(void (^)(void))cancellatio if ([SUApplicationInfo isBackgroundApplication:[NSApplication sharedApplication]]) { [self _activateApplication]; } - - // Defer actually presenting the progress window until a minimum amount of time - // passes. This eliminates the potential for either a very brief flicker showing the - // window and then dismissing it, and of needing to show the window and then artificially - // sustain it for a long enough to avoid the flicker. See SUCheckingWindowShowDelay. - _checkingWindowPendingShow = YES; - _checkingWindowShownTime = 0.0; - SUStatusController *pendingController = _checkingController; - __weak __typeof__(self) weakSelf = self; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(SUCheckingWindowShowDelay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - __typeof__(self) strongSelf = weakSelf; - if (strongSelf != nil && strongSelf->_checkingController == pendingController) { - [strongSelf _presentCheckingWindowIfPending]; - } - }); -} -- (void)_presentCheckingWindowIfPending SPU_OBJC_DIRECT -{ - if (_checkingController == nil || !_checkingWindowPendingShow) { - return; - } - _checkingWindowPendingShow = NO; - _checkingWindowShownTime = [self currentTime]; [_checkingController showWindow:self]; } -- (void)closeCheckingWindow SPU_OBJC_DIRECT +// Close the "Checking for updates…" window. If userCancelled is YES, this is +// the user clicking Cancel: a pending buffered close (if any) is expedited +// — its completion runs with userCancelled=YES, which lets each call site +// skip the queued next-UI step while still signaling the updater to end its +// session. If no buffered close is pending, the in-flight check is aborted via +// _cancellation directly. If userCancelled is NO, this is the abort/teardown +// path: the window is closed silently with no completion firing. +- (void)closeCheckingWindow:(BOOL)userCancelled SPU_OBJC_DIRECT { - if (_checkingController != nil) - { + if (userCancelled) { + // closeImmediately closes the window regardless. Its return tells us + // whether a pending buffered completion fired (and did its own + // cleanup) or not — if not, we still need to abort the active check. + if ([_checkingController closeImmediately]) { + return; + } + if (_cancellation != nil) { + _cancellation(); + _cancellation = nil; + } + _checkingController = nil; + return; + } + if (_checkingController != nil) { [_checkingController close]; _checkingController = nil; _cancellation = nil; } - _checkingWindowPendingShow = NO; - _checkingWindowShownTime = 0.0; - _pendingPostCheckingCompletion = nil; - _pendingPostCheckingCancellation = nil; } -- (BOOL)_finishPendingPostCheckingTransition SPU_OBJC_DIRECT -{ - void (^pendingCompletion)(void) = _pendingPostCheckingCompletion; - if (pendingCompletion == nil) { - return NO; - } - - _pendingPostCheckingCompletion = nil; - _pendingPostCheckingCancellation = nil; - [self closeCheckingWindow]; - pendingCompletion(); - return YES; -} - -- (BOOL)_cancelPendingPostCheckingTransition SPU_OBJC_DIRECT +- (void)cancelCheckForUpdates:(id)__unused sender { - void (^pendingCancellation)(void) = _pendingPostCheckingCancellation; - if (pendingCancellation == nil) { - return NO; - } - - _pendingPostCheckingCompletion = nil; - _pendingPostCheckingCancellation = nil; - [self closeCheckingWindow]; - pendingCancellation(); - return YES; + [self closeCheckingWindow:YES]; } -// Closes the "Checking for updates…" window and then invokes the completion. -// If the checking window was never actually presented (because the buffered show -// delay hadn't elapsed yet), the close happens immediately. If the window has been -// shown for less than SUCheckingWindowMinDisplayTime, the close and the completion -// are deferred until the minimum display time has elapsed so the window doesn't flicker. -- (void)_transitionFromCheckingWindowWithCompletion:(void (^)(void))completion cancellation:(void (^)(void))cancellation SPU_OBJC_DIRECT +// Initiate closing the "Checking for updates…" window, waiting for any time-buffering +// delay before continuing with the subsequent action. userCancelled is YES if +// the user expedited the close by clicking Cancel during the buffered period. +- (void)_closeCheckingWindowWithCompletionBlock:(void (^)(SPUStandardUserDriver * _Nullable s, BOOL userCancelled))completionBlock SPU_OBJC_DIRECT { if (_checkingController == nil) { - if (completion != nil) { - completion(); - } + completionBlock(self, NO); return; } - - if (_checkingWindowPendingShow) { - // The checking window never made it onto the screen — close silently and continue. - [self closeCheckingWindow]; - if (completion != nil) { - completion(); - } - return; - } - - double elapsedSeconds = ([self currentTime] - _checkingWindowShownTime) / (double)NSEC_PER_SEC; - if (elapsedSeconds >= SUCheckingWindowMinDisplayTime) { - [self closeCheckingWindow]; - if (completion != nil) { - completion(); - } - return; - } - - // Defer until the minimum display time has elapsed so the window doesn't flicker. - NSTimeInterval remainingSeconds = SUCheckingWindowMinDisplayTime - elapsedSeconds; - _pendingPostCheckingCompletion = [completion copy]; - _pendingPostCheckingCancellation = [cancellation copy]; __weak __typeof__(self) weakSelf = self; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(remainingSeconds * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [_checkingController closeWithCompletionBlock:^(BOOL userCancelled) { __typeof__(self) strongSelf = weakSelf; - if (strongSelf == nil) { - return; + if (strongSelf != nil) { + strongSelf->_checkingController = nil; + strongSelf->_cancellation = nil; } - [strongSelf _finishPendingPostCheckingTransition]; - }); + completionBlock(strongSelf, userCancelled); + }]; } -- (void)cancelCheckForUpdates:(id)__unused sender +// Same as -_closeCheckingWindowWithCompletionBlock: but for the install/progress status +// window. No `_cancellation` to clear on this path. +- (void)_closeStatusWindowWithCompletionBlock:(void (^)(SPUStandardUserDriver * _Nullable s, BOOL userCancelled))completionBlock SPU_OBJC_DIRECT { - if ([self _cancelPendingPostCheckingTransition]) { + if (_statusController == nil) { + completionBlock(self, NO); return; } - - if (_cancellation != nil) { - _cancellation(); - _cancellation = nil; - } - [self closeCheckingWindow]; + __weak __typeof__(self) weakSelf = self; + [_statusController closeWithCompletionBlock:^(BOOL userCancelled) { + __typeof__(self) strongSelf = weakSelf; + if (strongSelf != nil) { + strongSelf->_statusController = nil; + } + completionBlock(strongSelf, userCancelled); + }]; } #pragma mark Update Errors @@ -718,29 +653,32 @@ - (void)cancelCheckForUpdates:(id)__unused sender - (void)showUpdaterError:(NSError *)error acknowledgement:(void (^)(void))acknowledgement { assert(NSThread.isMainThread); - - __weak __typeof__(self) weakSelf = self; - [self _transitionFromCheckingWindowWithCompletion:^{ - __typeof__(self) strongSelf = weakSelf; - if (strongSelf != nil) { - [strongSelf _proceedWithUpdaterError:error acknowledgement:acknowledgement]; + + [self _closeCheckingWindowWithCompletionBlock:^(SPUStandardUserDriver *s, BOOL userCancelled) { + if (s != nil && !userCancelled) { + [s _proceedWithUpdaterError:error acknowledgement:acknowledgement]; } else { acknowledgement(); } - } cancellation:^{ - acknowledgement(); }]; } - + - (void)_proceedWithUpdaterError:(NSError *)error acknowledgement:(void (^)(void))acknowledgement SPU_OBJC_DIRECT { - [_statusController close]; - _statusController = nil; - + [self _closeStatusWindowWithCompletionBlock:^(SPUStandardUserDriver *s, BOOL userCancelled) { + if (s != nil && !userCancelled) { + [s _showUpdaterErrorAlertForError:error]; + } + acknowledgement(); + }]; +} + +- (void)_showUpdaterErrorAlertForError:(NSError *)error SPU_OBJC_DIRECT +{ #if SPARKLE_COPY_LOCALIZATIONS NSBundle *sparkleBundle = SUSparkleBundle(); #endif - + // Ideally we should use -[NSAlert alertWithError:] however // unfortunately Sparkle may return error messages with descriptions that contain // recovery suggestions. So we will check if an explicit recovery suggestion exists, @@ -755,27 +693,21 @@ - (void)_proceedWithUpdaterError:(NSError *)error acknowledgement:(void (^)(void alert.messageText = SULocalizedStringFromTableInBundle(@"Update Error!", SPARKLE_TABLE, sparkleBundle, nil); alert.informativeText = error.localizedDescription; } - + [alert addButtonWithTitle:SULocalizedStringFromTableInBundle(@"Cancel Update", SPARKLE_TABLE, sparkleBundle, nil)]; [self showAlert:alert secondaryAction:nil]; - - acknowledgement(); } - (void)showUpdateNotFoundWithError:(NSError *)error acknowledgement:(void (^)(void))acknowledgement { assert(NSThread.isMainThread); - - __weak __typeof__(self) weakSelf = self; - [self _transitionFromCheckingWindowWithCompletion:^{ - __typeof__(self) strongSelf = weakSelf; - if (strongSelf != nil) { - [strongSelf _proceedWithUpdateNotFoundWithError:error acknowledgement:acknowledgement]; + + [self _closeCheckingWindowWithCompletionBlock:^(SPUStandardUserDriver *s, BOOL userCancelled) { + if (s != nil && !userCancelled) { + [s _proceedWithUpdateNotFoundWithError:error acknowledgement:acknowledgement]; } else { acknowledgement(); } - } cancellation:^{ - acknowledgement(); }]; } @@ -927,7 +859,7 @@ - (void)createAndShowStatusControllerWithClosable:(BOOL)closable SPU_OBJC_DIRECT } _statusController = [[SUStatusController alloc] initWithHost:_host windowTitle:[NSString stringWithFormat:SULocalizedStringFromTableInBundle(@"Updating %@", SPARKLE_TABLE, SUSparkleBundle(), nil), _host.name] centerPointValue:centerPointValue minimizable:minimizable closable:closable]; - + if (_updateAlertWindowWasInactive) { [_statusController.window orderFront:nil]; } else { @@ -957,10 +889,17 @@ - (void)showDownloadInitiatedWithCancellation:(void (^)(void))cancellation - (void)cancelDownload:(id)__unused sender { + // closeImmediately closes the window regardless. Its return tells us + // whether a pending buffered completion fired (and did its own cleanup) or + // not — if not, we still need to invoke the active-download cancellation. + if ([_statusController closeImmediately]) { + return; + } if (_cancellation != nil) { _cancellation(); _cancellation = nil; } + _statusController = nil; } - (void)showDownloadDidReceiveExpectedContentLength:(uint64_t)expectedContentLength @@ -1033,55 +972,63 @@ - (void)showInstallingUpdateWithApplicationTerminated:(BOOL)applicationTerminate // The "quit" event can always be canceled or delayed by the application we're updating // So we can't easily predict how long the installation will take or if it won't happen right away // We close our status window because we don't want it persisting for too long and have it obscure other windows - [_statusController close]; - _statusController = nil; - - // Keep retry handler in case user tries to show update in focus again - _retryTerminatingApplication = [retryTerminatingApplication copy]; + // The retry handler is assigned alongside the close completion so it + // never co-exists with _statusController. + void (^retry)(void) = [retryTerminatingApplication copy]; + [self _closeStatusWindowWithCompletionBlock:^(SPUStandardUserDriver *s, BOOL __unused userCancelled) { + // userCancelled is ignored: the install is in flight regardless of + // what the user does about the visible status window, so the retry + // handler still needs to be available. + if (s != nil) { + s->_retryTerminatingApplication = retry; + } + }]; } } - (void)showUpdateInstalledAndRelaunched:(BOOL)relaunched acknowledgement:(void (^)(void))acknowledgement { assert(NSThread.isMainThread); - - // Close window showing update is installing - [_statusController close]; - _statusController = nil; - - // Only show installed prompt when the app is not relaunched - // When the app is relaunched, there is enough of a UI from relaunching the app. - if (!relaunched) { + + // Only show installed prompt when the app is not relaunched — + // when the app is relaunched, there is enough of a UI from relaunching the app. + [self _closeStatusWindowWithCompletionBlock:^(SPUStandardUserDriver *s, BOOL userCancelled) { + if (s != nil && !userCancelled && !relaunched) { + [s _showUpdateInstalledAlert]; + } + acknowledgement(); + }]; +} + +- (void)_showUpdateInstalledAlert SPU_OBJC_DIRECT +{ #if SPARKLE_COPY_LOCALIZATIONS - NSBundle *sparkleBundle = SUSparkleBundle(); + NSBundle *sparkleBundle = SUSparkleBundle(); #endif + + NSAlert *alert = [[NSAlert alloc] init]; + alert.messageText = SULocalizedStringFromTableInBundle(@"Update Installed", SPARKLE_TABLE, sparkleBundle, nil); - NSAlert *alert = [[NSAlert alloc] init]; - alert.messageText = SULocalizedStringFromTableInBundle(@"Update Installed", SPARKLE_TABLE, sparkleBundle, nil); - - // Extract information from newly updated bundle if available - NSString *hostName; - NSString *hostVersion; - NSBundle *newBundle = [NSBundle bundleWithURL:_oldHostBundleURL]; - if (newBundle != nil) { - SUHost *newHost = [[SUHost alloc] initWithBundle:newBundle]; - hostName = newHost.name; - hostVersion = newHost.displayVersion; - } else { - // This may happen if Sparkle's normalization is enabled - hostName = _oldHostName; - hostVersion = nil; - } + // Extract information from newly updated bundle if available + NSString *hostName; + NSString *hostVersion; + NSBundle *newBundle = [NSBundle bundleWithURL:_oldHostBundleURL]; + if (newBundle != nil) { + SUHost *newHost = [[SUHost alloc] initWithBundle:newBundle]; + hostName = newHost.name; + hostVersion = newHost.displayVersion; + } else { + // This may happen if Sparkle's normalization is enabled + hostName = _oldHostName; + hostVersion = nil; + } - if (hostVersion != nil) { - alert.informativeText = [NSString stringWithFormat:SULocalizedStringFromTableInBundle(@"%@ is now updated to version %@!", SPARKLE_TABLE, sparkleBundle, nil), hostName, hostVersion]; - } else { - alert.informativeText = [NSString stringWithFormat:SULocalizedStringFromTableInBundle(@"%@ is now updated!", SPARKLE_TABLE, sparkleBundle, nil), hostName]; - } - [self showAlert:alert secondaryAction:nil]; + if (hostVersion != nil) { + alert.informativeText = [NSString stringWithFormat:SULocalizedStringFromTableInBundle(@"%@ is now updated to version %@!", SPARKLE_TABLE, sparkleBundle, nil), hostName, hostVersion]; + } else { + alert.informativeText = [NSString stringWithFormat:SULocalizedStringFromTableInBundle(@"%@ is now updated!", SPARKLE_TABLE, sparkleBundle, nil), hostName]; } - - acknowledgement(); + [self showAlert:alert secondaryAction:nil]; } #pragma mark Aborting Everything @@ -1105,8 +1052,8 @@ - (void)dismissUpdateInstallation _cancellation = nil; _retryTerminatingApplication = nil; - [self closeCheckingWindow]; - + [self closeCheckingWindow:NO]; + if (_permissionPrompt) { [_permissionPrompt close]; _permissionPrompt = nil; diff --git a/Sparkle/SUStatusController.h b/Sparkle/SUStatusController.h index 82b6b8c0b..38d44cf8c 100644 --- a/Sparkle/SUStatusController.h +++ b/Sparkle/SUStatusController.h @@ -32,6 +32,28 @@ // If isDefault is YES, the button's key equivalent will be \r. - (void)setButtonTitle:(NSString *)buttonTitle target:(id)target action:(SEL)action isDefault:(BOOL)isDefault accessibilityIdentifier:(NSString *)accessibilityIdentifier SPU_OBJC_DIRECT; +// -showWindow: is internally buffered so that the window appears only after a short +// delay so a fast-completing operation never causes a flicker. If -close is +// called before the delay elapses, the window never appears at all. Calling +// -showWindow: on an already-visible window brings it to front normally. +- (void)showWindow:(id)sender; + +// Close the window, observing an internal minimum display time. completion +// runs once the window has actually closed — immediately if the window isn't +// on screen (or has been visible long enough), or after the remaining time +// otherwise. The userCancelled flag is YES if the close was expedited by +// -closeImmediately (i.e., the user explicitly cancelled); NO if the +// minimum-display timer elapsed normally. +- (void)closeWithCompletionBlock:(void (^)(BOOL userCancelled))completion SPU_OBJC_DIRECT; + +// Close the window now. If a deferred close scheduled by +// -closeWithCompletionBlock: is still waiting out the minimum-display time, +// fire its completion now with userCancelled=YES. Returns YES if a pending +// completion fired (so the caller knows the completion's cleanup ran); +// returns NO if there was no pending completion (caller is responsible for +// any state cleanup that would have happened in the completion). +- (BOOL)closeImmediately SPU_OBJC_DIRECT; + @end #endif diff --git a/Sparkle/SUStatusController.m b/Sparkle/SUStatusController.m index b6835a5cf..8b51e8dc4 100644 --- a/Sparkle/SUStatusController.m +++ b/Sparkle/SUStatusController.m @@ -16,6 +16,13 @@ static NSString *const SUStatusControllerTouchBarIdentifier = @"" SPARKLE_BUNDLE_IDENTIFIER ".SUStatusController"; +// Buffering parameters for -showWindow: and -closeWithCompletionBlock:. We hide +// the window entirely if a close arrives within SUStatusDisplayDelay, +// and we enforce a minimum visible duration of SUStatusMinimumDisplayTime +// once the window has actually appeared on screen. +static const NSTimeInterval SUStatusDisplayDelay = 0.3; +static const NSTimeInterval SUStatusMinimumDisplayTime = 0.7; + @interface SUStatusController () // These properties are used for bindings @@ -32,11 +39,15 @@ @implementation SUStatusController NSString *_buttonTitle; SUHost *_host; NSButton *_touchBarButton; - + IBOutlet NSButton *_actionButton; IBOutlet NSTextField *_statusTextField; IBOutlet NSProgressIndicator *_progressBar; - + + BOOL _waitingToShowWindow; + NSTimeInterval _windowShownTime; + void (^_pendingCloseCompletion)(BOOL userCancelled); + BOOL _minimizable; BOOL _closable; } @@ -153,6 +164,102 @@ - (BOOL)isButtonEnabled return [_actionButton isEnabled]; } +- (void)showWindow:(id)sender +{ + // If the window is already visible just call through for default handling + if (self.window.visible) { + [super showWindow:sender]; + return; + } + + // Already scheduled - just keep waiting + if (_waitingToShowWindow) { + return; + } + + _waitingToShowWindow = YES; + + __weak __typeof__(self) weakSelf = self; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(SUStatusDisplayDelay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + __typeof__(self) strongSelf = weakSelf; + if (strongSelf != nil && strongSelf->_waitingToShowWindow) { + [strongSelf _reallyShowWindow:sender]; + } + }); +} + +- (void)_reallyShowWindow:(id)sender SPU_OBJC_DIRECT +{ + _waitingToShowWindow = NO; + _windowShownTime = [NSDate timeIntervalSinceReferenceDate]; + [super showWindow:sender]; +} + +- (void)closeWithCompletionBlock:(void (^)(BOOL userCancelled))completion +{ + // If the window isn't on screen, close silently and complete immediately. + if (_waitingToShowWindow || !self.window.visible) { + [self close]; + if (completion != nil) { + completion(NO); + } + return; + } + + NSTimeInterval elapsed = [NSDate timeIntervalSinceReferenceDate] - _windowShownTime; + if (elapsed >= SUStatusMinimumDisplayTime) { + [self close]; + if (completion != nil) { + completion(NO); + } + return; + } + + // Replace any previously pending close. (Not expected, but safe.) + _pendingCloseCompletion = [completion copy]; + + NSTimeInterval remaining = SUStatusMinimumDisplayTime - elapsed; + __weak __typeof__(self) weakSelf = self; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(remaining * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + __typeof__(self) strongSelf = weakSelf; + if (strongSelf == nil) { + return; + } + void (^pendingCompletion)(BOOL) = strongSelf->_pendingCloseCompletion; + if (pendingCompletion == nil) { + // -close was called externally; pending state was discarded. + return; + } + strongSelf->_pendingCloseCompletion = nil; + [strongSelf close]; + pendingCompletion(NO); + }); +} + +- (BOOL)closeImmediately +{ + void (^pending)(BOOL) = _pendingCloseCompletion; + _pendingCloseCompletion = nil; + [self close]; + if (pending == nil) { + return NO; + } + pending(YES); + return YES; +} + +- (void)close +{ + // -close is the abort path: silently discard any pending buffered + // presentation or deferred close. Callers driving the buffered API use + // -closeWithCompletionBlock: instead, which gates the + // window's actual disappearance behind the minimum display time. + _waitingToShowWindow = NO; + _windowShownTime = 0; + _pendingCloseCompletion = nil; + [super close]; +} + - (void)setMaxProgressValue:(double)value { if (value < 0.0) value = 0.0; From ef45c3a959febf7c28a70571fe00dd0b38210e1e Mon Sep 17 00:00:00 2001 From: Daniel Jalkut Date: Mon, 1 Jun 2026 12:46:50 -0400 Subject: [PATCH 6/7] Restore trailing whitespace stripped by a local pre-commit hook, to minimize noise in the patch. --- Sparkle/SPUStandardUserDriver.m | 62 ++++++++++++++++----------------- Sparkle/SUStatusController.m | 4 +-- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/Sparkle/SPUStandardUserDriver.m b/Sparkle/SPUStandardUserDriver.m index f4e2493d9..1590175d3 100644 --- a/Sparkle/SPUStandardUserDriver.m +++ b/Sparkle/SPUStandardUserDriver.m @@ -77,7 +77,7 @@ @implementation SPUStandardUserDriver uint64_t _expectedContentLength; uint64_t _bytesDownloaded; double _timeSinceOpportuneUpdateNotice; - + BOOL _updateAlertWindowWasInactive; BOOL _loggedGentleUpdateReminderWarning; BOOL _regularApplicationUpdate; @@ -351,11 +351,11 @@ - (void)applicationDidBecomeActive:(NSNotification *)__unused aNotification - (void)showUpdateFoundWithAppcastItem:(SUAppcastItem *)appcastItem state:(SPUUserUpdateState *)state reply:(void (^)(SPUUserUpdateChoice))reply { assert(NSThread.isMainThread); - + if (_activeUpdateAlert != nil) { SULog(SULogLevelError, @"Error: -[%@ %@] should not be called when _activeUpdateAlert != nil:\n%@", NSStringFromClass([self class]), NSStringFromSelector(_cmd), NSThread.callStackSymbols); } - + _regularApplicationUpdate = [appcastItem.installationType isEqualToString:SPUInstallationTypeApplication]; // For user initiated checks, let the delegate know we'll be showing an update. @@ -385,50 +385,50 @@ - (void)_buildActiveUpdateAlertForAppcastItem:(SUAppcastItem *)appcastItem state { id delegate = _delegate; id customVersionDisplayer = nil; - + if ([delegate respondsToSelector:@selector(standardUserDriverRequestsVersionDisplayer)]) { customVersionDisplayer = [delegate standardUserDriverRequestsVersionDisplayer]; } - + id versionDisplayer = (customVersionDisplayer != nil) ? customVersionDisplayer : [SPUStandardVersionDisplay standardVersionDisplay]; - + BOOL needsToObserveUserAttention = [delegate respondsToSelector:@selector(standardUserDriverDidReceiveUserAttentionForUpdate:)]; - + __weak __typeof__(self) weakSelf = self; __weak id weakDelegate = delegate; _activeUpdateAlert = [[SUUpdateAlert alloc] initWithAppcastItem:appcastItem state:state host:_host versionDisplayer:versionDisplayer updaterSettings:_updaterSettings delegate:delegate completionBlock:^(SPUUserUpdateChoice choice, NSRect windowFrame, BOOL wasKeyWindow) { reply(choice); - + __typeof__(self) strongSelf = weakSelf; - + if (strongSelf != nil) { if (needsToObserveUserAttention && !strongSelf->_updateReceivedUserAttention) { strongSelf->_updateReceivedUserAttention = YES; - + id strongDelegate = weakDelegate; // needsToObserveUserAttention already checks delegate responds to this selector [strongDelegate standardUserDriverDidReceiveUserAttentionForUpdate:appcastItem]; } - + // Record the window frame of the update alert right before we deallocate it // So we can center future status window to where the update alert last was. // Also record if the window was inactive at the time a response was made // (the window may not be key if the window e.g. holds command while clicking on a response button) strongSelf->_updateAlertWindowFrameValue = [NSValue valueWithRect:windowFrame]; strongSelf->_updateAlertWindowWasInactive = !wasKeyWindow; - + strongSelf->_activeUpdateAlert = nil; } } didBecomeKeyBlock:^{ if (!needsToObserveUserAttention) { return; } - + if ([NSApp isActive]) { __typeof__(self) strongSelf = weakSelf; if (strongSelf != nil && !strongSelf->_updateReceivedUserAttention) { strongSelf->_updateReceivedUserAttention = YES; - + id strongDelegate = weakDelegate; // needsToObserveUserAttention already checks delegate responds to this selector [strongDelegate standardUserDriverDidReceiveUserAttentionForUpdate:appcastItem]; @@ -436,7 +436,7 @@ - (void)_buildActiveUpdateAlertForAppcastItem:(SUAppcastItem *)appcastItem state } else { // We need to listen for when the app becomes active again, and then test if the window alert // is still key. if it is, let the delegate know. Remove the observation after that. - + __typeof__(self) strongSelfOuter = weakSelf; if (strongSelfOuter != nil && strongSelfOuter->_applicationBecameActiveAfterUpdateAlertBecameKeyObserver == nil) { strongSelfOuter->_applicationBecameActiveAfterUpdateAlertBecameKeyObserver = [[NSNotificationCenter defaultCenter] addObserverForName:NSApplicationDidBecomeActiveNotification object:NSApp queue:NSOperationQueue.mainQueue usingBlock:^(NSNotification * _Nonnull __unused note) { @@ -444,15 +444,15 @@ - (void)_buildActiveUpdateAlertForAppcastItem:(SUAppcastItem *)appcastItem state if (strongSelf != nil) { if (!strongSelf->_updateReceivedUserAttention && [strongSelf->_activeUpdateAlert.window isKeyWindow]) { strongSelf->_updateReceivedUserAttention = YES; - + id strongDelegate = weakDelegate; // needsToObserveUserAttention already checks delegate responds to this selector [strongDelegate standardUserDriverDidReceiveUserAttentionForUpdate:appcastItem]; } - + if (strongSelf->_applicationBecameActiveAfterUpdateAlertBecameKeyObserver != nil) { [[NSNotificationCenter defaultCenter] removeObserver:strongSelf->_applicationBecameActiveAfterUpdateAlertBecameKeyObserver]; - + strongSelf->_applicationBecameActiveAfterUpdateAlertBecameKeyObserver = nil; } } @@ -571,7 +571,7 @@ - (void)showUserInitiatedUpdateCheckWithCancellation:(void (^)(void))cancellatio if ([SUApplicationInfo isBackgroundApplication:[NSApplication sharedApplication]]) { [self _activateApplication]; } - + [_checkingController showWindow:self]; } @@ -653,7 +653,7 @@ - (void)_closeStatusWindowWithCompletionBlock:(void (^)(SPUStandardUserDriver * - (void)showUpdaterError:(NSError *)error acknowledgement:(void (^)(void))acknowledgement { assert(NSThread.isMainThread); - + [self _closeCheckingWindowWithCompletionBlock:^(SPUStandardUserDriver *s, BOOL userCancelled) { if (s != nil && !userCancelled) { [s _proceedWithUpdaterError:error acknowledgement:acknowledgement]; @@ -662,7 +662,7 @@ - (void)showUpdaterError:(NSError *)error acknowledgement:(void (^)(void))acknow } }]; } - + - (void)_proceedWithUpdaterError:(NSError *)error acknowledgement:(void (^)(void))acknowledgement SPU_OBJC_DIRECT { [self _closeStatusWindowWithCompletionBlock:^(SPUStandardUserDriver *s, BOOL userCancelled) { @@ -672,13 +672,13 @@ - (void)_proceedWithUpdaterError:(NSError *)error acknowledgement:(void (^)(void acknowledgement(); }]; } - + - (void)_showUpdaterErrorAlertForError:(NSError *)error SPU_OBJC_DIRECT { #if SPARKLE_COPY_LOCALIZATIONS NSBundle *sparkleBundle = SUSparkleBundle(); #endif - + // Ideally we should use -[NSAlert alertWithError:] however // unfortunately Sparkle may return error messages with descriptions that contain // recovery suggestions. So we will check if an explicit recovery suggestion exists, @@ -693,7 +693,7 @@ - (void)_showUpdaterErrorAlertForError:(NSError *)error SPU_OBJC_DIRECT alert.messageText = SULocalizedStringFromTableInBundle(@"Update Error!", SPARKLE_TABLE, sparkleBundle, nil); alert.informativeText = error.localizedDescription; } - + [alert addButtonWithTitle:SULocalizedStringFromTableInBundle(@"Cancel Update", SPARKLE_TABLE, sparkleBundle, nil)]; [self showAlert:alert secondaryAction:nil]; } @@ -701,7 +701,7 @@ - (void)_showUpdaterErrorAlertForError:(NSError *)error SPU_OBJC_DIRECT - (void)showUpdateNotFoundWithError:(NSError *)error acknowledgement:(void (^)(void))acknowledgement { assert(NSThread.isMainThread); - + [self _closeCheckingWindowWithCompletionBlock:^(SPUStandardUserDriver *s, BOOL userCancelled) { if (s != nil && !userCancelled) { [s _proceedWithUpdateNotFoundWithError:error acknowledgement:acknowledgement]; @@ -859,7 +859,7 @@ - (void)createAndShowStatusControllerWithClosable:(BOOL)closable SPU_OBJC_DIRECT } _statusController = [[SUStatusController alloc] initWithHost:_host windowTitle:[NSString stringWithFormat:SULocalizedStringFromTableInBundle(@"Updating %@", SPARKLE_TABLE, SUSparkleBundle(), nil), _host.name] centerPointValue:centerPointValue minimizable:minimizable closable:closable]; - + if (_updateAlertWindowWasInactive) { [_statusController.window orderFront:nil]; } else { @@ -989,7 +989,7 @@ - (void)showInstallingUpdateWithApplicationTerminated:(BOOL)applicationTerminate - (void)showUpdateInstalledAndRelaunched:(BOOL)relaunched acknowledgement:(void (^)(void))acknowledgement { assert(NSThread.isMainThread); - + // Only show installed prompt when the app is not relaunched — // when the app is relaunched, there is enough of a UI from relaunching the app. [self _closeStatusWindowWithCompletionBlock:^(SPUStandardUserDriver *s, BOOL userCancelled) { @@ -999,13 +999,13 @@ - (void)showUpdateInstalledAndRelaunched:(BOOL)relaunched acknowledgement:(void acknowledgement(); }]; } - + - (void)_showUpdateInstalledAlert SPU_OBJC_DIRECT { #if SPARKLE_COPY_LOCALIZATIONS NSBundle *sparkleBundle = SUSparkleBundle(); #endif - + NSAlert *alert = [[NSAlert alloc] init]; alert.messageText = SULocalizedStringFromTableInBundle(@"Update Installed", SPARKLE_TABLE, sparkleBundle, nil); @@ -1022,7 +1022,7 @@ - (void)_showUpdateInstalledAlert SPU_OBJC_DIRECT hostName = _oldHostName; hostVersion = nil; } - + if (hostVersion != nil) { alert.informativeText = [NSString stringWithFormat:SULocalizedStringFromTableInBundle(@"%@ is now updated to version %@!", SPARKLE_TABLE, sparkleBundle, nil), hostName, hostVersion]; } else { @@ -1053,7 +1053,7 @@ - (void)dismissUpdateInstallation _retryTerminatingApplication = nil; [self closeCheckingWindow:NO]; - + if (_permissionPrompt) { [_permissionPrompt close]; _permissionPrompt = nil; diff --git a/Sparkle/SUStatusController.m b/Sparkle/SUStatusController.m index 8b51e8dc4..b2499dfa6 100644 --- a/Sparkle/SUStatusController.m +++ b/Sparkle/SUStatusController.m @@ -39,7 +39,7 @@ @implementation SUStatusController NSString *_buttonTitle; SUHost *_host; NSButton *_touchBarButton; - + IBOutlet NSButton *_actionButton; IBOutlet NSTextField *_statusTextField; IBOutlet NSProgressIndicator *_progressBar; @@ -47,7 +47,7 @@ @implementation SUStatusController BOOL _waitingToShowWindow; NSTimeInterval _windowShownTime; void (^_pendingCloseCompletion)(BOOL userCancelled); - + BOOL _minimizable; BOOL _closable; } From 8531e1924f757495295da07ec6fb5ef6a69cbd07 Mon Sep 17 00:00:00 2001 From: Daniel Jalkut Date: Fri, 5 Jun 2026 15:36:41 -0400 Subject: [PATCH 7/7] Move the delegate notification that the update alert will show to inside the checking window completion block. --- Sparkle/SPUStandardUserDriver.m | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/Sparkle/SPUStandardUserDriver.m b/Sparkle/SPUStandardUserDriver.m index 1590175d3..30fbec7de 100644 --- a/Sparkle/SPUStandardUserDriver.m +++ b/Sparkle/SPUStandardUserDriver.m @@ -358,19 +358,21 @@ - (void)showUpdateFoundWithAppcastItem:(SUAppcastItem *)appcastItem state:(SPUUs _regularApplicationUpdate = [appcastItem.installationType isEqualToString:SPUInstallationTypeApplication]; - // For user initiated checks, let the delegate know we'll be showing an update. - // For scheduled checks, -setUpActiveUpdateAlertForUpdate:state: below will handle this. - id delegate = _delegate; - if (state.userInitiated && [delegate respondsToSelector:@selector(standardUserDriverWillHandleShowingUpdate:forUpdate:state:)]) { - [delegate standardUserDriverWillHandleShowingUpdate:YES forUpdate:appcastItem state:state]; - } - // Defer building the alert until the checking window has finished closing. // This prevents _activeUpdateAlert and _checkingController being non-nil at // the same time, which would make ordering of nil tests in ... fragile. + __weak id weakDelegate = _delegate; [self _closeCheckingWindowWithCompletionBlock:^(SPUStandardUserDriver *s, BOOL userCancelled) { if (s != nil && !userCancelled) { [s _buildActiveUpdateAlertForAppcastItem:appcastItem state:state reply:reply]; + + // For user initiated checks, let the delegate know we'll be showing an update. + // For scheduled checks, -setUpActiveUpdateAlertForUpdate:state: below will handle this. + id strongDelegate = weakDelegate; + if (state.userInitiated && [strongDelegate respondsToSelector:@selector(standardUserDriverWillHandleShowingUpdate:forUpdate:state:)]) { + [strongDelegate standardUserDriverWillHandleShowingUpdate:YES forUpdate:appcastItem state:state]; + } + [s setUpActiveUpdateAlertForScheduledUpdate:(state.userInitiated ? nil : appcastItem) state:state]; } else { // Either the driver was deallocated mid-flow or the user cancelled