diff --git a/supacode/Clients/Updates/UpdaterClient.swift b/supacode/Clients/Updates/UpdaterClient.swift index ef8cf140..420e904e 100644 --- a/supacode/Clients/Updates/UpdaterClient.swift +++ b/supacode/Clients/Updates/UpdaterClient.swift @@ -4,26 +4,49 @@ import Sparkle private let updaterLogger = SupaLogger("Updater") struct UpdaterClient { - var configure: @MainActor @Sendable (_ checks: Bool, _ downloads: Bool, _ checkInBackground: Bool) -> Void + var configure: @MainActor @Sendable (_ checks: Bool, _ checkInBackground: Bool) -> Void var setUpdateChannel: @MainActor @Sendable (UpdateChannel) -> Void var checkForUpdates: @MainActor @Sendable () -> Void + var installDownloadedUpdate: @MainActor @Sendable () -> Void var events: @MainActor @Sendable () -> AsyncStream } extension UpdaterClient { enum Event: Equatable, Sendable { case silentUpdateFound(version: String?) + case downloadedUpdateReadyToInstall(version: String?) } } @MainActor final class SparkleUpdateDelegate: NSObject, SPUUpdaterDelegate { var updateChannel: UpdateChannel = .stable + private var continuation: AsyncStream.Continuation? + private var immediateInstallHandler: (() -> Void)? nonisolated func allowedChannels(for updater: SPUUpdater) -> Set { // Tip channel is no longer published separately; treat it the same as stable. [] } + + func setContinuation(_ continuation: AsyncStream.Continuation) { + self.continuation?.finish() + self.continuation = continuation + } + + func installDownloadedUpdate() { + immediateInstallHandler?() + } + + func updater( + _ updater: SPUUpdater, + willInstallUpdateOnQuit item: SUAppcastItem, + immediateInstallationBlock immediateInstallHandler: @escaping () -> Void + ) -> Bool { + self.immediateInstallHandler = immediateInstallHandler + continuation?.yield(.downloadedUpdateReadyToInstall(version: item.displayVersionString)) + return true + } } /// Custom Sparkle user driver that turns background "update found" prompts into a silent signal, @@ -32,12 +55,17 @@ final class SparkleUpdateDelegate: NSObject, SPUUpdaterDelegate { final class SilentUpdateDriver: NSObject, SPUUserDriver { private let standard: SPUStandardUserDriver private var continuation: AsyncStream.Continuation? + private var automaticallyChecksForUpdates = GlobalSettings.default.updatesAutomaticallyCheckForUpdates init(hostBundle: Bundle) { self.standard = SPUStandardUserDriver(hostBundle: hostBundle, delegate: nil) super.init() } + func setAutomaticUpdatePreferences(checks: Bool) { + automaticallyChecksForUpdates = checks + } + func setContinuation(_ continuation: AsyncStream.Continuation) { self.continuation?.finish() self.continuation = continuation @@ -51,7 +79,13 @@ final class SilentUpdateDriver: NSObject, SPUUserDriver { _ request: SPUUpdatePermissionRequest, reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void ) { - standard.show(request, reply: reply) + reply( + SUUpdatePermissionResponse( + automaticUpdateChecks: automaticallyChecksForUpdates, + automaticUpdateDownloading: nil, + sendSystemProfile: false + ) + ) } func showUserInitiatedUpdateCheck(cancellation: @escaping @Sendable () -> Void) { @@ -162,12 +196,9 @@ extension UpdaterClient: DependencyKey { updaterLogger.warning("SPUUpdater start failed: \(String(describing: error))") } return UpdaterClient( - configure: { checks, _, checkInBackground in + configure: { checks, checkInBackground in + driver.setAutomaticUpdatePreferences(checks: checks) updater.automaticallyChecksForUpdates = checks - // Silent update flow requires Sparkle to always prompt us via `showUpdateFound` - // so we can decide whether to surface the toolbar button. Auto-download would - // bypass that callback, so we force it off regardless of user preference. - updater.automaticallyDownloadsUpdates = false if checkInBackground, checks { updater.checkForUpdatesInBackground() } @@ -182,18 +213,23 @@ extension UpdaterClient: DependencyKey { checkForUpdates: { updater.checkForUpdates() }, + installDownloadedUpdate: { + delegate.installDownloadedUpdate() + }, events: { let (stream, continuation) = AsyncStream.makeStream(of: Event.self) driver.setContinuation(continuation) + delegate.setContinuation(continuation) return stream } ) }() static let testValue = UpdaterClient( - configure: { _, _, _ in }, + configure: { _, _ in }, setUpdateChannel: { _ in }, checkForUpdates: {}, + installDownloadedUpdate: {}, events: { AsyncStream { _ in } } ) } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 797e1bd2..565b5976 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -673,8 +673,7 @@ struct AppFeature { .updates( .applySettings( updateChannel: settings.updateChannel, - automaticallyChecks: settings.updatesAutomaticallyCheckForUpdates, - automaticallyDownloads: settings.updatesAutomaticallyDownloadUpdates + automaticallyChecks: settings.updatesAutomaticallyCheckForUpdates ) ) ), diff --git a/supacode/Features/Repositories/Views/ToolbarUpdateButton.swift b/supacode/Features/Repositories/Views/ToolbarUpdateButton.swift index e9f34a16..6a10ac19 100644 --- a/supacode/Features/Repositories/Views/ToolbarUpdateButton.swift +++ b/supacode/Features/Repositories/Views/ToolbarUpdateButton.swift @@ -2,9 +2,16 @@ import SwiftUI struct ToolbarUpdateButton: View { let availableVersion: String? - let onCheckForUpdates: () -> Void + let isReadyToInstall: Bool + let onActivate: () -> Void private var tooltip: String { + if isReadyToInstall { + if let availableVersion, !availableVersion.isEmpty { + return "Version \(availableVersion) has been downloaded. Click to relaunch and install." + } + return "An update has been downloaded. Click to relaunch and install." + } if let availableVersion, !availableVersion.isEmpty { return "Version \(availableVersion) is available. Click to review and install." } @@ -13,13 +20,13 @@ struct ToolbarUpdateButton: View { var body: some View { Button { - onCheckForUpdates() + onActivate() } label: { Image(systemName: "arrow.down.circle.fill") .foregroundStyle(Color("ProwlAccent")) .accessibilityHidden(true) } .help(tooltip) - .accessibilityLabel("Install update") + .accessibilityLabel(isReadyToInstall ? "Relaunch to install update" : "Install update") } } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 9db02d14..f19424b2 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -15,6 +15,7 @@ struct WorktreeDetailView: View { let runScriptIsRunning: Bool let customCommands: [UserCustomCommand] let isUpdateAvailable: Bool + let isUpdateReadyToInstall: Bool let availableUpdateVersion: String? let showRunButtonInToolbar: Bool let showDefaultEditorInToolbar: Bool @@ -27,6 +28,7 @@ struct WorktreeDetailView: View { let runScriptIsRunning: Bool let customCommands: [UserCustomCommand] let isUpdateAvailable: Bool + let isUpdateReadyToInstall: Bool let availableUpdateVersion: String? let showRunButtonInToolbar: Bool } @@ -93,6 +95,7 @@ struct WorktreeDetailView: View { runScriptIsRunning: runScriptIsRunning, customCommands: customCommands, isUpdateAvailable: state.updates.isUpdateAvailable, + isUpdateReadyToInstall: state.updates.isUpdateReadyToInstall, availableUpdateVersion: state.updates.availableVersion, showRunButtonInToolbar: settingsFile.global.showRunButtonInToolbar ) @@ -110,6 +113,7 @@ struct WorktreeDetailView: View { runScriptIsRunning: runScriptIsRunning, customCommands: customCommands, isUpdateAvailable: state.updates.isUpdateAvailable, + isUpdateReadyToInstall: state.updates.isUpdateReadyToInstall, availableUpdateVersion: state.updates.availableVersion, showRunButtonInToolbar: settingsFile.global.showRunButtonInToolbar, showDefaultEditorInToolbar: settingsFile.global.showDefaultEditorInToolbar @@ -174,7 +178,7 @@ struct WorktreeDetailView: View { onRunCustomCommand: { index in store.send(.runCustomCommand(index)) }, - onCheckForUpdates: { store.send(.updates(.checkForUpdates)) } + onActivateUpdateButton: { store.send(.updates(.activateUpdateButton)) } ) } @@ -190,8 +194,11 @@ struct WorktreeDetailView: View { onDismissAll: { dismissAllToolbarNotifications(in: state.notificationGroups) } ) if state.isUpdateAvailable { - ToolbarUpdateButton(availableVersion: state.availableUpdateVersion) { - store.send(.updates(.checkForUpdates)) + ToolbarUpdateButton( + availableVersion: state.availableUpdateVersion, + isReadyToInstall: state.isUpdateReadyToInstall + ) { + store.send(.updates(.activateUpdateButton)) } } } @@ -310,6 +317,7 @@ struct WorktreeDetailView: View { runScriptIsRunning: input.runScriptIsRunning, customCommands: input.customCommands, isUpdateAvailable: input.isUpdateAvailable, + isUpdateReadyToInstall: input.isUpdateReadyToInstall, availableUpdateVersion: input.availableUpdateVersion, showRunButtonInToolbar: input.showRunButtonInToolbar, showDefaultEditorInToolbar: input.showDefaultEditorInToolbar @@ -708,6 +716,7 @@ struct WorktreeDetailView: View { let runScriptIsRunning: Bool let customCommands: [UserCustomCommand] let isUpdateAvailable: Bool + let isUpdateReadyToInstall: Bool let availableUpdateVersion: String? let showRunButtonInToolbar: Bool let showDefaultEditorInToolbar: Bool @@ -726,7 +735,7 @@ struct WorktreeDetailView: View { let onRunScript: () -> Void let onStopRunScript: () -> Void let onRunCustomCommand: (Int) -> Void - let onCheckForUpdates: () -> Void + let onActivateUpdateButton: () -> Void @Environment(\.resolvedKeybindings) private var resolvedKeybindings var body: some ToolbarContent { @@ -758,7 +767,8 @@ struct WorktreeDetailView: View { if toolbarState.isUpdateAvailable { ToolbarUpdateButton( availableVersion: toolbarState.availableUpdateVersion, - onCheckForUpdates: onCheckForUpdates + isReadyToInstall: toolbarState.isUpdateReadyToInstall, + onActivate: onActivateUpdateButton ) } } @@ -1210,6 +1220,7 @@ private struct WorktreeToolbarPreview: View { ) ], isUpdateAvailable: true, + isUpdateReadyToInstall: false, availableUpdateVersion: "2026.5.1", showRunButtonInToolbar: true, showDefaultEditorInToolbar: true @@ -1238,7 +1249,7 @@ private struct WorktreeToolbarPreview: View { onRunScript: {}, onStopRunScript: {}, onRunCustomCommand: { _ in }, - onCheckForUpdates: {} + onActivateUpdateButton: {} ) } .environment(commandKeyObserver) diff --git a/supacode/Features/Settings/Views/UpdatesSettingsView.swift b/supacode/Features/Settings/Views/UpdatesSettingsView.swift index 662c5ba2..2f2cf096 100644 --- a/supacode/Features/Settings/Views/UpdatesSettingsView.swift +++ b/supacode/Features/Settings/Views/UpdatesSettingsView.swift @@ -24,7 +24,7 @@ struct UpdatesSettingsView: View { } footer: { Text( "When a new version is available, a small badge appears next to the notifications bell. " - + "Click it to review and install the update." + + "Click it to review, install, and choose future background downloads." ) .font(.callout) .foregroundStyle(.secondary) diff --git a/supacode/Features/Updates/Reducer/UpdatesFeature.swift b/supacode/Features/Updates/Reducer/UpdatesFeature.swift index 7833ced8..d2d27cd2 100644 --- a/supacode/Features/Updates/Reducer/UpdatesFeature.swift +++ b/supacode/Features/Updates/Reducer/UpdatesFeature.swift @@ -7,6 +7,7 @@ struct UpdatesFeature { struct State: Equatable { var didConfigureUpdates = false var isUpdateAvailable = false + var isUpdateReadyToInstall = false var availableVersion: String? } @@ -14,9 +15,9 @@ struct UpdatesFeature { case task case applySettings( updateChannel: UpdateChannel, - automaticallyChecks: Bool, - automaticallyDownloads: Bool + automaticallyChecks: Bool ) + case activateUpdateButton case checkForUpdates case updaterEvent(UpdaterClient.Event) #if DEBUG @@ -37,20 +38,32 @@ struct UpdatesFeature { } } - case .applySettings(let channel, let checks, let downloads): + case .applySettings(let channel, let checks): let checkInBackground = !state.didConfigureUpdates state.didConfigureUpdates = true return .run { _ in await updaterClient.setUpdateChannel(channel) - await updaterClient.configure(checks, downloads, checkInBackground) + await updaterClient.configure(checks, checkInBackground) } + case .activateUpdateButton: + if state.isUpdateReadyToInstall { + state.isUpdateAvailable = false + state.isUpdateReadyToInstall = false + state.availableVersion = nil + return .run { _ in + await updaterClient.installDownloadedUpdate() + } + } + return .send(.checkForUpdates) + case .checkForUpdates: analyticsClient.capture("update_checked", nil) // Clear the badge so a fresh user-initiated check drives the standard dialog. // If the update is still available, Sparkle re-triggers `showUpdateFound` and // the standard driver takes over. state.isUpdateAvailable = false + state.isUpdateReadyToInstall = false state.availableVersion = nil return .run { _ in await updaterClient.checkForUpdates() @@ -58,12 +71,20 @@ struct UpdatesFeature { case .updaterEvent(.silentUpdateFound(let version)): state.isUpdateAvailable = true + state.isUpdateReadyToInstall = false + state.availableVersion = version + return .none + + case .updaterEvent(.downloadedUpdateReadyToInstall(let version)): + state.isUpdateAvailable = true + state.isUpdateReadyToInstall = true state.availableVersion = version return .none #if DEBUG case .debugSimulateUpdateFound: state.isUpdateAvailable = true + state.isUpdateReadyToInstall = false state.availableVersion = "9999.1.1" return .none #endif diff --git a/supacodeTests/UpdatesFeatureTests.swift b/supacodeTests/UpdatesFeatureTests.swift new file mode 100644 index 00000000..85e443d9 --- /dev/null +++ b/supacodeTests/UpdatesFeatureTests.swift @@ -0,0 +1,106 @@ +import ComposableArchitecture +import DependenciesTestSupport +import Testing + +@testable import supacode + +@MainActor +@Suite(.serialized) +struct UpdatesFeatureTests { + private struct Configuration: Equatable { + var channel: UpdateChannel + var checks: Bool + var checkInBackground: Bool + } + + @Test(.dependencies) func applySettingsConfiguresAutomaticChecks() async { + let configuredChannel = LockIsolated(nil) + let configuration = LockIsolated(nil) + let store = TestStore(initialState: UpdatesFeature.State()) { + UpdatesFeature() + } withDependencies: { + $0.updaterClient.setUpdateChannel = { channel in + configuredChannel.withValue { $0 = channel } + } + $0.updaterClient.configure = { checks, checkInBackground in + configuration.withValue { + $0 = Configuration( + channel: configuredChannel.value ?? .stable, + checks: checks, + checkInBackground: checkInBackground + ) + } + } + } + + await store.send( + .applySettings( + updateChannel: .stable, + automaticallyChecks: true + ) + ) { + $0.didConfigureUpdates = true + } + + #expect( + configuration.value == Configuration(channel: .stable, checks: true, checkInBackground: true)) + } + + @Test(.dependencies) func downloadedUpdateEventMarksUpdateReadyToInstall() async { + let store = TestStore(initialState: UpdatesFeature.State()) { + UpdatesFeature() + } + + await store.send(.updaterEvent(.downloadedUpdateReadyToInstall(version: "2026.6.6"))) { + $0.isUpdateAvailable = true + $0.isUpdateReadyToInstall = true + $0.availableVersion = "2026.6.6" + } + } + + @Test(.dependencies) func updateButtonInstallsDownloadedUpdateWhenReady() async { + let installCount = LockIsolated(0) + var state = UpdatesFeature.State() + state.isUpdateAvailable = true + state.isUpdateReadyToInstall = true + state.availableVersion = "2026.6.6" + let store = TestStore(initialState: state) { + UpdatesFeature() + } withDependencies: { + $0.updaterClient.installDownloadedUpdate = { + installCount.withValue { $0 += 1 } + } + } + + await store.send(.activateUpdateButton) { + $0.isUpdateAvailable = false + $0.isUpdateReadyToInstall = false + $0.availableVersion = nil + } + + #expect(installCount.value == 1) + } + + @Test(.dependencies) func updateButtonChecksForUpdatesWhenOnlyAvailable() async { + let checkCount = LockIsolated(0) + var state = UpdatesFeature.State() + state.isUpdateAvailable = true + state.availableVersion = "2026.6.6" + let store = TestStore(initialState: state) { + UpdatesFeature() + } withDependencies: { + $0.analyticsClient.capture = { _, _ in } + $0.updaterClient.checkForUpdates = { + checkCount.withValue { $0 += 1 } + } + } + + await store.send(.activateUpdateButton) + await store.receive(\.checkForUpdates) { + $0.isUpdateAvailable = false + $0.availableVersion = nil + } + + #expect(checkCount.value == 1) + } +}