diff --git a/supacode/Clients/Updates/UpdaterClient.swift b/supacode/Clients/Updates/UpdaterClient.swift index e0e9e532..5af4d499 100644 --- a/supacode/Clients/Updates/UpdaterClient.swift +++ b/supacode/Clients/Updates/UpdaterClient.swift @@ -1,3 +1,4 @@ +import AppKit import ComposableArchitecture import Sparkle @@ -65,13 +66,17 @@ final class SilentUpdateDriver: NSObject, SPUUserDriver { ) { MainActor.assumeIsolated { if state.userInitiated { + if shouldConfirmInstallAndRelaunchImmediately(for: state.stage) { + reply(confirmInstallAndRelaunchChoice()) + return + } standard.showUpdateFound(with: appcastItem, state: state, reply: reply) return } // Background check: surface the availability silently, then defer so Sparkle // will re-offer the same update on the next (user-initiated) check. continuation?.yield(.silentUpdateFound(version: appcastItem.displayVersionString)) - reply(.dismiss) + reply(silentBackgroundUpdateChoice(for: state.stage)) } } @@ -137,7 +142,7 @@ final class SilentUpdateDriver: NSObject, SPUUserDriver { nonisolated func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { MainActor.assumeIsolated { - standard.showReady(toInstallAndRelaunch: reply) + reply(confirmInstallAndRelaunchChoice()) } } @@ -175,6 +180,35 @@ final class SilentUpdateDriver: NSObject, SPUUserDriver { } } +func silentBackgroundUpdateChoice(for stage: SPUUserUpdateStage) -> SPUUserUpdateChoice { + switch stage { + case .notDownloaded, .downloaded: + .dismiss + case .installing: + .skip + @unknown default: + .dismiss + } +} + +func installAndRelaunchChoice(didConfirm: Bool) -> SPUUserUpdateChoice { + didConfirm ? .install : .skip +} + +func shouldConfirmInstallAndRelaunchImmediately(for stage: SPUUserUpdateStage) -> Bool { + stage == .installing +} + +@MainActor +private func confirmInstallAndRelaunchChoice() -> SPUUserUpdateChoice { + let alert = NSAlert() + alert.messageText = "Install Update and Relaunch?" + alert.informativeText = "Prowl will quit and relaunch to finish installing the update." + alert.addButton(withTitle: "Install and Relaunch") + alert.addButton(withTitle: "Later") + return installAndRelaunchChoice(didConfirm: alert.runModal() == .alertFirstButtonReturn) +} + extension UpdaterClient: DependencyKey { static let liveValue: UpdaterClient = { let hostBundle = Bundle.main diff --git a/supacodeTests/UpdaterClientTests.swift b/supacodeTests/UpdaterClientTests.swift new file mode 100644 index 00000000..e9850b43 --- /dev/null +++ b/supacodeTests/UpdaterClientTests.swift @@ -0,0 +1,23 @@ +import Sparkle +import Testing + +@testable import supacode + +struct UpdaterClientTests { + @Test func backgroundUpdateDoesNotPreserveInstallingState() { + #expect(silentBackgroundUpdateChoice(for: .notDownloaded) == .dismiss) + #expect(silentBackgroundUpdateChoice(for: .downloaded) == .dismiss) + #expect(silentBackgroundUpdateChoice(for: .installing) == .skip) + } + + @Test func installAndRelaunchRequiresConfirmation() { + #expect(installAndRelaunchChoice(didConfirm: true) == .install) + #expect(installAndRelaunchChoice(didConfirm: false) == .skip) + } + + @Test func userInitiatedInstallingUpdateRequiresImmediateConfirmation() { + #expect(!shouldConfirmInstallAndRelaunchImmediately(for: .notDownloaded)) + #expect(!shouldConfirmInstallAndRelaunchImmediately(for: .downloaded)) + #expect(shouldConfirmInstallAndRelaunchImmediately(for: .installing)) + } +}