diff --git a/Packages/macOS/CmuxUpdater/Sources/CmuxUpdater/UpdateDriver+SPUUpdaterDelegate.swift b/Packages/macOS/CmuxUpdater/Sources/CmuxUpdater/UpdateDriver+SPUUpdaterDelegate.swift index 22ea0f9a5d20..2cfefe1097dc 100644 --- a/Packages/macOS/CmuxUpdater/Sources/CmuxUpdater/UpdateDriver+SPUUpdaterDelegate.swift +++ b/Packages/macOS/CmuxUpdater/Sources/CmuxUpdater/UpdateDriver+SPUUpdaterDelegate.swift @@ -120,6 +120,7 @@ extension UpdateDriver: @preconcurrency SPUUpdaterDelegate { func handleDidFinishUpdateCycle(_ updateCheck: SPUUpdateCheck, error: (any Error)?) { let errorText = error.map(formatErrorForLog) ?? "none" log.append("update cycle finished (check=\(updateCheck.rawValue), error=\(errorText))") + allowNextRelaunch = false relaunchGate.cancel() eventDelegate?.updateDriverDidFinishCycle(updateCheck, error: error.map { $0 as NSError }) } diff --git a/Packages/macOS/CmuxUpdater/Sources/CmuxUpdater/UpdateDriver.swift b/Packages/macOS/CmuxUpdater/Sources/CmuxUpdater/UpdateDriver.swift index dcc4e84d70b3..236d99612972 100644 --- a/Packages/macOS/CmuxUpdater/Sources/CmuxUpdater/UpdateDriver.swift +++ b/Packages/macOS/CmuxUpdater/Sources/CmuxUpdater/UpdateDriver.swift @@ -33,6 +33,10 @@ final class UpdateDriver: NSObject, @preconcurrency SPUUserDriver { /// mandatory Sparkle update-choice reply) before the task drops its capture. private var pendingCheckTransitionState: UpdateState? private var checkTimeoutTask: Task? + /// Sparkle can ask to postpone again after the user explicitly chooses Install Now or + /// Restart Now. Allow that one continuation through so the confirmation does not reopen the + /// same relaunch prompt indefinitely. + var allowNextRelaunch = false private(set) var lastFeedURLString: String? /// Holds a ready update's relaunch while agents are mid-turn or commands are running. let relaunchGate: UpdateRelaunchGate @@ -78,6 +82,7 @@ final class UpdateDriver: NSObject, @preconcurrency SPUUserDriver { func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) { log.append("show user-initiated update check") + allowNextRelaunch = false beginChecking(cancel: cancellation) } @@ -110,6 +115,7 @@ final class UpdateDriver: NSObject, @preconcurrency SPUUserDriver { acknowledgement: @escaping () -> Void) { let details = formatErrorForLog(error) log.append("show updater error: \(details)") + allowNextRelaunch = false relaunchGate.cancel() setState(.error(.init( error: error, @@ -180,6 +186,11 @@ final class UpdateDriver: NSObject, @preconcurrency SPUUserDriver { /// Restart Now on the install-on-quit prompt, and a resumed install after Later. Returns /// `true` to hold the relaunch until `installHandler` is invoked (see ``UpdateRelaunchGate``). func handleShouldPostponeRelaunch(installHandler: @escaping () -> Void) -> Bool { + if allowNextRelaunch { + allowNextRelaunch = false + log.append("update relaunch allowed after explicit confirmation") + return false + } guard !currentRelaunchBlockers().isEmpty else { return false } var isAutoUpdate = false if case .installing(let installing) = model.state { isAutoUpdate = installing.isAutoUpdate } @@ -203,26 +214,26 @@ final class UpdateDriver: NSObject, @preconcurrency SPUUserDriver { return installing.relaunchBlockers != nil }, publish: { [weak self] state in self?.setState(state) }, - relaunch: install, + relaunch: { [weak self] in + self?.allowNextRelaunch = true + install() + }, later: { [weak self] in self?.showRestartToComplete(install: install) } ) } /// The postponed Sparkle session stays open until `install` runs, so this state keeps it /// reachable: Restart Later only closes the popover (dropping `install` would leave every - /// later check waiting on a session that never ends), and Restart Now goes through the - /// gate again and installs at most once. + /// later check waiting on a session that never ends). Restart Now is an explicit confirmation, + /// so it continues through Sparkle once without reopening the relaunch gate. private func showRestartToComplete(install: @escaping () -> Void) { - let once = InstallOnce(install) + let once = InstallOnce { [weak self] in + self?.allowNextRelaunch = true + install() + } setState(.installing(.init( isAutoUpdate: true, - retryTerminatingApplication: { [weak self] in - guard let self else { - once.run() - return - } - self.holdRelaunch(isAutoUpdate: true, install: once.run) - }, + retryTerminatingApplication: { once.run() }, dismiss: {} ))) } @@ -230,7 +241,10 @@ final class UpdateDriver: NSObject, @preconcurrency SPUUserDriver { func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) { log.append("show installing update") setState(.installing(.init( - retryTerminatingApplication: retryTerminatingApplication, + retryTerminatingApplication: { [weak self] in + self?.allowNextRelaunch = true + retryTerminatingApplication() + }, dismiss: { [weak self] in self?.model.setState(.idle) } @@ -239,6 +253,7 @@ final class UpdateDriver: NSObject, @preconcurrency SPUUserDriver { func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) { log.append("show update installed (relaunched=\(relaunched))") + allowNextRelaunch = false relaunchGate.cancel() setState(.idle) acknowledgement() diff --git a/Packages/macOS/CmuxUpdater/Tests/CmuxUpdaterTests/UpdateRelaunchGateTests.swift b/Packages/macOS/CmuxUpdater/Tests/CmuxUpdaterTests/UpdateRelaunchGateTests.swift index 2ef8b0d771d0..e49eff1b7680 100644 --- a/Packages/macOS/CmuxUpdater/Tests/CmuxUpdaterTests/UpdateRelaunchGateTests.swift +++ b/Packages/macOS/CmuxUpdater/Tests/CmuxUpdaterTests/UpdateRelaunchGateTests.swift @@ -117,7 +117,7 @@ private final class CallCounter: @unchecked Sendable { #expect(installs.count == 1) } - @Test func laterKeepsRestartToCompleteAndRestartNowIsGatedAgain() async { + @Test func laterKeepsRestartToCompleteAndRestartNowHonorsExplicitConfirmation() { let driver = makeDriver() let installs = CallCounter() host.blockers = UpdateRelaunchBlockers(busyAgentCount: 1, runningCommandCount: 0) @@ -136,11 +136,41 @@ private final class CallCounter: @unchecked Sendable { #expect(model.text == "Restart to Complete Update") installing?.retryTerminatingApplication() - #expect(installs.count == 0) - #expect(waitingBlockers?.busyAgentCount == 1) + #expect(installs.count == 1) + #expect(!driver.relaunchGate.isWaiting) + } - host.blockers = .empty - await recheck { installs.count == 1 } + @Test func explicitInstallContinuationBypassesTheGateOnce() { + let driver = makeDriver() + let installs = CallCounter() + host.blockers = UpdateRelaunchBlockers(busyAgentCount: 0, runningCommandCount: 1) + + _ = driver.handleShouldPostponeRelaunch(installHandler: { installs.count += 1 }) + installing?.retryTerminatingApplication() + #expect(installs.count == 1) + + // Sparkle may ask again while carrying out the explicit install. The confirmation should + // allow that one callback through instead of reopening the same waiting prompt. + #expect(!driver.handleShouldPostponeRelaunch(installHandler: { installs.count += 1 })) + #expect(installs.count == 1) + + // The bypass is one-shot; a new relaunch request still observes the blocker. + #expect(driver.handleShouldPostponeRelaunch(installHandler: { installs.count += 1 })) + } + + @Test func retryingAnUnterminatedInstallHonorsExplicitConfirmation() { + let driver = makeDriver() + let retries = CallCounter() + host.blockers = UpdateRelaunchBlockers(busyAgentCount: 0, runningCommandCount: 1) + + driver.showInstallingUpdate( + withApplicationTerminated: false, + retryTerminatingApplication: { retries.count += 1 } + ) + installing?.retryTerminatingApplication() + + #expect(retries.count == 1) + #expect(!driver.handleShouldPostponeRelaunch(installHandler: { retries.count += 1 })) } @Test func repeatedRestartNowInstallsOnce() {