Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
Expand Down
39 changes: 27 additions & 12 deletions Packages/macOS/CmuxUpdater/Sources/CmuxUpdater/UpdateDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
/// 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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 }
Expand All @@ -203,34 +214,37 @@ 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: {}
)))
}

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)
}
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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() {
Expand Down