native macOS codings agent orchestrator
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #206 from onevcat/feature/silent-update-detection

Surface background-detected updates via a toolbar badge

authored by

Wei Wang and committed by
GitHub
8075c61d 2ed859e5

+344 -37
+20
supacode/Assets.xcassets/ProwlAccent.colorset/Contents.json
··· 1 + { 2 + "colors" : [ 3 + { 4 + "color" : { 5 + "color-space" : "srgb", 6 + "components" : { 7 + "alpha" : "1.000", 8 + "blue" : "0xC0", 9 + "green" : "0xE4", 10 + "red" : "0x5B" 11 + } 12 + }, 13 + "idiom" : "universal" 14 + } 15 + ], 16 + "info" : { 17 + "author" : "xcode", 18 + "version" : 1 19 + } 20 + }
+183 -12
supacode/Clients/Updates/UpdaterClient.swift
··· 1 1 import ComposableArchitecture 2 2 import Sparkle 3 3 4 + private let updaterLogger = SupaLogger("Updater") 5 + 4 6 struct UpdaterClient { 5 7 var configure: @MainActor @Sendable (_ checks: Bool, _ downloads: Bool, _ checkInBackground: Bool) -> Void 6 8 var setUpdateChannel: @MainActor @Sendable (UpdateChannel) -> Void 7 9 var checkForUpdates: @MainActor @Sendable () -> Void 10 + var events: @MainActor @Sendable () -> AsyncStream<Event> 11 + } 12 + 13 + extension UpdaterClient { 14 + enum Event: Equatable, Sendable { 15 + case silentUpdateFound(version: String?) 16 + } 8 17 } 9 18 10 19 @MainActor 11 - class SparkleUpdateDelegate: NSObject, SPUUpdaterDelegate { 20 + final class SparkleUpdateDelegate: NSObject, SPUUpdaterDelegate { 12 21 var updateChannel: UpdateChannel = .stable 13 22 14 23 nonisolated func allowedChannels(for updater: SPUUpdater) -> Set<String> { ··· 17 26 } 18 27 } 19 28 29 + /// Custom Sparkle user driver that turns background "update found" prompts into a silent signal, 30 + /// while forwarding user-initiated flows to the standard Sparkle UI. 31 + @MainActor 32 + final class SilentUpdateDriver: NSObject, SPUUserDriver { 33 + private let standard: SPUStandardUserDriver 34 + private var continuation: AsyncStream<UpdaterClient.Event>.Continuation? 35 + 36 + init(hostBundle: Bundle) { 37 + self.standard = SPUStandardUserDriver(hostBundle: hostBundle, delegate: nil) 38 + super.init() 39 + } 40 + 41 + func setContinuation(_ continuation: AsyncStream<UpdaterClient.Event>.Continuation) { 42 + self.continuation?.finish() 43 + self.continuation = continuation 44 + } 45 + 46 + nonisolated func show( 47 + _ request: SPUUpdatePermissionRequest, 48 + reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void 49 + ) { 50 + MainActor.assumeIsolated { 51 + standard.show(request, reply: reply) 52 + } 53 + } 54 + 55 + nonisolated func showUserInitiatedUpdateCheck(cancellation: @escaping @Sendable () -> Void) { 56 + MainActor.assumeIsolated { 57 + standard.showUserInitiatedUpdateCheck(cancellation: cancellation) 58 + } 59 + } 60 + 61 + nonisolated func showUpdateFound( 62 + with appcastItem: SUAppcastItem, 63 + state: SPUUserUpdateState, 64 + reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void 65 + ) { 66 + MainActor.assumeIsolated { 67 + if state.userInitiated { 68 + standard.showUpdateFound(with: appcastItem, state: state, reply: reply) 69 + return 70 + } 71 + // Background check: surface the availability silently, then defer so Sparkle 72 + // will re-offer the same update on the next (user-initiated) check. 73 + continuation?.yield(.silentUpdateFound(version: appcastItem.displayVersionString)) 74 + reply(.dismiss) 75 + } 76 + } 77 + 78 + nonisolated func showUpdateReleaseNotes(with downloadData: SPUDownloadData) { 79 + MainActor.assumeIsolated { 80 + standard.showUpdateReleaseNotes(with: downloadData) 81 + } 82 + } 83 + 84 + nonisolated func showUpdateReleaseNotesFailedToDownloadWithError(_ error: any Error) { 85 + MainActor.assumeIsolated { 86 + standard.showUpdateReleaseNotesFailedToDownloadWithError(error) 87 + } 88 + } 89 + 90 + nonisolated func showUpdateNotFoundWithError( 91 + _ error: any Error, 92 + acknowledgement: @escaping @Sendable () -> Void 93 + ) { 94 + MainActor.assumeIsolated { 95 + standard.showUpdateNotFoundWithError(error, acknowledgement: acknowledgement) 96 + } 97 + } 98 + 99 + nonisolated func showUpdaterError( 100 + _ error: any Error, 101 + acknowledgement: @escaping @Sendable () -> Void 102 + ) { 103 + MainActor.assumeIsolated { 104 + standard.showUpdaterError(error, acknowledgement: acknowledgement) 105 + } 106 + } 107 + 108 + nonisolated func showDownloadInitiated(cancellation: @escaping @Sendable () -> Void) { 109 + MainActor.assumeIsolated { 110 + standard.showDownloadInitiated(cancellation: cancellation) 111 + } 112 + } 113 + 114 + nonisolated func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) { 115 + MainActor.assumeIsolated { 116 + standard.showDownloadDidReceiveExpectedContentLength(expectedContentLength) 117 + } 118 + } 119 + 120 + nonisolated func showDownloadDidReceiveData(ofLength length: UInt64) { 121 + MainActor.assumeIsolated { 122 + standard.showDownloadDidReceiveData(ofLength: length) 123 + } 124 + } 125 + 126 + nonisolated func showDownloadDidStartExtractingUpdate() { 127 + MainActor.assumeIsolated { 128 + standard.showDownloadDidStartExtractingUpdate() 129 + } 130 + } 131 + 132 + nonisolated func showExtractionReceivedProgress(_ progress: Double) { 133 + MainActor.assumeIsolated { 134 + standard.showExtractionReceivedProgress(progress) 135 + } 136 + } 137 + 138 + nonisolated func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { 139 + MainActor.assumeIsolated { 140 + standard.showReady(toInstallAndRelaunch: reply) 141 + } 142 + } 143 + 144 + nonisolated func showInstallingUpdate( 145 + withApplicationTerminated applicationTerminated: Bool, 146 + retryTerminatingApplication: @escaping @Sendable () -> Void 147 + ) { 148 + MainActor.assumeIsolated { 149 + standard.showInstallingUpdate( 150 + withApplicationTerminated: applicationTerminated, 151 + retryTerminatingApplication: retryTerminatingApplication 152 + ) 153 + } 154 + } 155 + 156 + nonisolated func showUpdateInstalledAndRelaunched( 157 + _ relaunched: Bool, 158 + acknowledgement: @escaping @Sendable () -> Void 159 + ) { 160 + MainActor.assumeIsolated { 161 + standard.showUpdateInstalledAndRelaunched(relaunched, acknowledgement: acknowledgement) 162 + } 163 + } 164 + 165 + nonisolated func showUpdateInFocus() { 166 + MainActor.assumeIsolated { 167 + standard.showUpdateInFocus() 168 + } 169 + } 170 + 171 + nonisolated func dismissUpdateInstallation() { 172 + MainActor.assumeIsolated { 173 + standard.dismissUpdateInstallation() 174 + } 175 + } 176 + } 177 + 20 178 extension UpdaterClient: DependencyKey { 21 179 static let liveValue: UpdaterClient = { 180 + let hostBundle = Bundle.main 22 181 let delegate = SparkleUpdateDelegate() 23 - let controller = SPUStandardUpdaterController( 24 - startingUpdater: true, 25 - updaterDelegate: delegate, 26 - userDriverDelegate: nil 182 + let driver = SilentUpdateDriver(hostBundle: hostBundle) 183 + let updater = SPUUpdater( 184 + hostBundle: hostBundle, 185 + applicationBundle: hostBundle, 186 + userDriver: driver, 187 + delegate: delegate 27 188 ) 28 - let updater = controller.updater 189 + do { 190 + try updater.start() 191 + } catch { 192 + updaterLogger.warning("SPUUpdater start failed: \(String(describing: error))") 193 + } 29 194 return UpdaterClient( 30 - configure: { checks, downloads, checkInBackground in 31 - _ = controller 195 + configure: { checks, _, checkInBackground in 32 196 updater.automaticallyChecksForUpdates = checks 33 - updater.automaticallyDownloadsUpdates = downloads 197 + // Silent update flow requires Sparkle to always prompt us via `showUpdateFound` 198 + // so we can decide whether to surface the toolbar button. Auto-download would 199 + // bypass that callback, so we force it off regardless of user preference. 200 + updater.automaticallyDownloadsUpdates = false 34 201 if checkInBackground, checks { 35 202 updater.checkForUpdatesInBackground() 36 203 } 37 204 }, 38 205 setUpdateChannel: { channel in 39 - _ = controller 40 206 delegate.updateChannel = channel 41 207 updater.updateCheckInterval = 3600 42 208 if updater.automaticallyChecksForUpdates { ··· 44 210 } 45 211 }, 46 212 checkForUpdates: { 47 - _ = controller 48 213 updater.checkForUpdates() 214 + }, 215 + events: { 216 + let (stream, continuation) = AsyncStream.makeStream(of: Event.self) 217 + driver.setContinuation(continuation) 218 + return stream 49 219 } 50 220 ) 51 221 }() ··· 53 223 static let testValue = UpdaterClient( 54 224 configure: { _, _, _ in }, 55 225 setUpdateChannel: { _ in }, 56 - checkForUpdates: {} 226 + checkForUpdates: {}, 227 + events: { AsyncStream { _ in } } 57 228 ) 58 229 } 59 230
+4
supacode/Features/App/Reducer/AppFeature.swift
··· 156 156 return .merge( 157 157 .send(.repositories(.task)), 158 158 .send(.settings(.task)), 159 + .send(.updates(.task)), 159 160 .run { _ in 160 161 await MainActor.run { 161 162 NSApplication.shared.dockTile.badgeLabel = nil ··· 912 913 #if DEBUG 913 914 case .commandPalette(.delegate(.debugTestToast(let toast))): 914 915 return .send(.repositories(.showToast(toast))) 916 + 917 + case .commandPalette(.delegate(.debugSimulateUpdateFound)): 918 + return .send(.updates(.debugSimulateUpdateFound)) 915 919 #endif 916 920 917 921 case .commandPalette:
+4 -3
supacode/Features/CommandPalette/CommandPaletteItem.swift
··· 43 43 case installCLI 44 44 #if DEBUG 45 45 case debugTestToast(RepositoriesFeature.StatusToast) 46 + case debugSimulateUpdateFound 46 47 #endif 47 48 } 48 49 ··· 65 66 case .worktreeSelect, .removeWorktree, .archiveWorktree: 66 67 return false 67 68 #if DEBUG 68 - case .debugTestToast: 69 + case .debugTestToast, .debugSimulateUpdateFound: 69 70 return true 70 71 #endif 71 72 } ··· 91 92 .archiveWorktree: 92 93 return false 93 94 #if DEBUG 94 - case .debugTestToast: 95 + case .debugTestToast, .debugSimulateUpdateFound: 95 96 return false 96 97 #endif 97 98 } ··· 127 128 .archiveWorktree: 128 129 return nil 129 130 #if DEBUG 130 - case .debugTestToast: 131 + case .debugTestToast, .debugSimulateUpdateFound: 131 132 return nil 132 133 #endif 133 134 }
+10 -1
supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift
··· 52 52 case installCLI 53 53 #if DEBUG 54 54 case debugTestToast(RepositoriesFeature.StatusToast) 55 + case debugSimulateUpdateFound 55 56 #endif 56 57 } 57 58 ··· 436 437 subtitle: "Simulates a success toast", 437 438 kind: .debugTestToast(.success("Pull request merged")) 438 439 ), 440 + CommandPaletteItem( 441 + id: "debug.update.simulate-found", 442 + title: "[Debug] Simulate Update Found", 443 + subtitle: "Shows the toolbar update badge without querying Sparkle", 444 + kind: .debugSimulateUpdateFound 445 + ), 439 446 ] 440 447 } 441 448 #endif ··· 584 591 #if DEBUG 585 592 case .debugTestToast(let toast): 586 593 return .debugTestToast(toast) 594 + case .debugSimulateUpdateFound: 595 + return .debugSimulateUpdateFound 587 596 #endif 588 597 } 589 598 } ··· 621 630 .ghosttyCommand: 622 631 return nil 623 632 #if DEBUG 624 - case .debugTestToast: 633 + case .debugTestToast, .debugSimulateUpdateFound: 625 634 return nil 626 635 #endif 627 636 }
+5 -3
supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift
··· 358 358 case .archiveWorktree: 359 359 return "Archive" 360 360 #if DEBUG 361 - case .debugTestToast: 361 + case .debugTestToast, .debugSimulateUpdateFound: 362 362 return "Debug" 363 363 #endif 364 364 } ··· 407 407 #if DEBUG 408 408 case .debugTestToast: 409 409 return "ladybug" 410 + case .debugSimulateUpdateFound: 411 + return "ladybug" 410 412 #endif 411 413 } 412 414 } ··· 422 424 case .worktreeSelect, .removeWorktree, .archiveWorktree: 423 425 return false 424 426 #if DEBUG 425 - case .debugTestToast: 427 + case .debugTestToast, .debugSimulateUpdateFound: 426 428 return true 427 429 #endif 428 430 } ··· 534 536 case .installCLI: 535 537 base = "Install Command Line Tool" 536 538 #if DEBUG 537 - case .debugTestToast: 539 + case .debugTestToast, .debugSimulateUpdateFound: 538 540 base = row.title 539 541 #endif 540 542 }
+25
supacode/Features/Repositories/Views/ToolbarUpdateButton.swift
··· 1 + import SwiftUI 2 + 3 + struct ToolbarUpdateButton: View { 4 + let availableVersion: String? 5 + let onCheckForUpdates: () -> Void 6 + 7 + private var tooltip: String { 8 + if let availableVersion, !availableVersion.isEmpty { 9 + return "Version \(availableVersion) is available. Click to review and install." 10 + } 11 + return "A new version is available. Click to review and install." 12 + } 13 + 14 + var body: some View { 15 + Button { 16 + onCheckForUpdates() 17 + } label: { 18 + Image(systemName: "arrow.down.circle.fill") 19 + .foregroundStyle(Color("ProwlAccent")) 20 + .accessibilityHidden(true) 21 + } 22 + .help(tooltip) 23 + .accessibilityLabel("Install update") 24 + } 25 + }
+52 -13
supacode/Features/Repositories/Views/WorktreeDetailView.swift
··· 13 13 let runScriptEnabled: Bool 14 14 let runScriptIsRunning: Bool 15 15 let customCommands: [UserCustomCommand] 16 + let isUpdateAvailable: Bool 17 + let availableUpdateVersion: String? 16 18 } 17 19 18 20 @Bindable var store: StoreOf<AppFeature> ··· 61 63 .toolbar(removing: repositories.isShowingCanvas ? nil : .title) 62 64 .toolbar { 63 65 if repositories.isShowingCanvas { 64 - ToolbarItem(placement: .primaryAction) { 65 - ToolbarNotificationsPopoverButton( 66 - groups: notificationGroups, 67 - unseenWorktreeCount: unseenNotificationWorktreeCount, 68 - onSelectNotification: selectToolbarNotification, 69 - onDismissAll: { dismissAllToolbarNotifications(in: notificationGroups) } 70 - ) 71 - } 66 + canvasToolbarContent( 67 + notificationGroups: notificationGroups, 68 + unseenNotificationWorktreeCount: unseenNotificationWorktreeCount, 69 + isUpdateAvailable: state.updates.isUpdateAvailable, 70 + availableUpdateVersion: state.updates.availableVersion 71 + ) 72 72 } else if hasActiveTerminalTarget, 73 73 let toolbarState = toolbarState( 74 74 input: ToolbarStateInput( ··· 80 80 showExtras: commandKeyObserver.isPressed, 81 81 runScriptEnabled: runScriptEnabled, 82 82 runScriptIsRunning: runScriptIsRunning, 83 - customCommands: customCommands 83 + customCommands: customCommands, 84 + isUpdateAvailable: state.updates.isUpdateAvailable, 85 + availableUpdateVersion: state.updates.availableVersion 84 86 ) 85 87 ) 86 88 { ··· 107 109 onStopRunScript: { store.send(.stopRunScript) }, 108 110 onRunCustomCommand: { index in 109 111 store.send(.runCustomCommand(index)) 110 - } 112 + }, 113 + onCheckForUpdates: { store.send(.updates(.checkForUpdates)) } 111 114 ) 112 115 } 113 116 } ··· 120 123 return applyFocusedActions(content: content, actions: actions) 121 124 } 122 125 126 + @ToolbarContentBuilder 127 + private func canvasToolbarContent( 128 + notificationGroups: [ToolbarNotificationRepositoryGroup], 129 + unseenNotificationWorktreeCount: Int, 130 + isUpdateAvailable: Bool, 131 + availableUpdateVersion: String? 132 + ) -> some ToolbarContent { 133 + ToolbarItemGroup(placement: .primaryAction) { 134 + ToolbarNotificationsPopoverButton( 135 + groups: notificationGroups, 136 + unseenWorktreeCount: unseenNotificationWorktreeCount, 137 + onSelectNotification: selectToolbarNotification, 138 + onDismissAll: { dismissAllToolbarNotifications(in: notificationGroups) } 139 + ) 140 + if isUpdateAvailable { 141 + ToolbarUpdateButton(availableVersion: availableUpdateVersion) { 142 + store.send(.updates(.checkForUpdates)) 143 + } 144 + } 145 + } 146 + } 147 + 123 148 private func toolbarState(input: ToolbarStateInput) -> WorktreeToolbarState? { 124 149 guard 125 150 let title = DetailToolbarTitle.forSelection( ··· 146 171 showExtras: input.showExtras, 147 172 runScriptEnabled: input.runScriptEnabled, 148 173 runScriptIsRunning: input.runScriptIsRunning, 149 - customCommands: input.customCommands 174 + customCommands: input.customCommands, 175 + isUpdateAvailable: input.isUpdateAvailable, 176 + availableUpdateVersion: input.availableUpdateVersion 150 177 ) 151 178 } 152 179 ··· 391 418 let runScriptEnabled: Bool 392 419 let runScriptIsRunning: Bool 393 420 let customCommands: [UserCustomCommand] 421 + let isUpdateAvailable: Bool 422 + let availableUpdateVersion: String? 394 423 } 395 424 396 425 fileprivate struct WorktreeToolbarContent: ToolbarContent { ··· 404 433 let onRunScript: () -> Void 405 434 let onStopRunScript: () -> Void 406 435 let onRunCustomCommand: (Int) -> Void 436 + let onCheckForUpdates: () -> Void 407 437 @Environment(\.resolvedKeybindings) private var resolvedKeybindings 408 438 409 439 var body: some ToolbarContent { ··· 432 462 onSelectNotification: onSelectNotification, 433 463 onDismissAll: onDismissAllNotifications 434 464 ) 465 + if toolbarState.isUpdateAvailable { 466 + ToolbarUpdateButton( 467 + availableVersion: toolbarState.availableUpdateVersion, 468 + onCheckForUpdates: onCheckForUpdates 469 + ) 470 + } 435 471 } 436 472 437 473 ToolbarSpacer(.flexible) ··· 865 901 modifiers: UserCustomShortcutModifiers() 866 902 ) 867 903 ), 868 - ] 904 + ], 905 + isUpdateAvailable: true, 906 + availableUpdateVersion: "2026.5.1" 869 907 ) 870 908 let observer = CommandKeyObserver() 871 909 observer.isPressed = false ··· 888 926 onDismissAllNotifications: {}, 889 927 onRunScript: {}, 890 928 onStopRunScript: {}, 891 - onRunCustomCommand: { _ in } 929 + onRunCustomCommand: { _ in }, 930 + onCheckForUpdates: {} 892 931 ) 893 932 } 894 933 .environment(commandKeyObserver)
+9 -5
supacode/Features/Settings/Views/UpdatesSettingsView.swift
··· 14 14 Text("Tip").tag(UpdateChannel.tip) 15 15 } 16 16 } 17 - Section("Automatic Updates") { 17 + Section { 18 18 Toggle( 19 19 "Check for updates automatically", 20 20 isOn: $settingsStore.updatesAutomaticallyCheckForUpdates 21 21 ) 22 - Toggle( 23 - "Download and install updates automatically", 24 - isOn: $settingsStore.updatesAutomaticallyDownloadUpdates 22 + } header: { 23 + Text("Automatic Updates") 24 + } footer: { 25 + Text( 26 + "When a new version is available, a small badge appears next to the notifications bell. " 27 + + "Click it to review and install the update." 25 28 ) 26 - .disabled(!settingsStore.updatesAutomaticallyCheckForUpdates) 29 + .font(.callout) 30 + .foregroundStyle(.secondary) 27 31 } 28 32 } 29 33 .formStyle(.grouped)
+31
supacode/Features/Updates/Reducer/UpdatesFeature.swift
··· 6 6 @ObservableState 7 7 struct State: Equatable { 8 8 var didConfigureUpdates = false 9 + var isUpdateAvailable = false 10 + var availableVersion: String? 9 11 } 10 12 11 13 enum Action { 14 + case task 12 15 case applySettings( 13 16 updateChannel: UpdateChannel, 14 17 automaticallyChecks: Bool, 15 18 automaticallyDownloads: Bool 16 19 ) 17 20 case checkForUpdates 21 + case updaterEvent(UpdaterClient.Event) 22 + #if DEBUG 23 + case debugSimulateUpdateFound 24 + #endif 18 25 } 19 26 20 27 @Dependency(AnalyticsClient.self) private var analyticsClient ··· 23 30 var body: some Reducer<State, Action> { 24 31 Reduce { state, action in 25 32 switch action { 33 + case .task: 34 + return .run { send in 35 + for await event in await updaterClient.events() { 36 + await send(.updaterEvent(event)) 37 + } 38 + } 39 + 26 40 case .applySettings(let channel, let checks, let downloads): 27 41 let checkInBackground = !state.didConfigureUpdates 28 42 state.didConfigureUpdates = true ··· 33 47 34 48 case .checkForUpdates: 35 49 analyticsClient.capture("update_checked", nil) 50 + // Clear the badge so a fresh user-initiated check drives the standard dialog. 51 + // If the update is still available, Sparkle re-triggers `showUpdateFound` and 52 + // the standard driver takes over. 53 + state.isUpdateAvailable = false 54 + state.availableVersion = nil 36 55 return .run { _ in 37 56 await updaterClient.checkForUpdates() 38 57 } 58 + 59 + case .updaterEvent(.silentUpdateFound(let version)): 60 + state.isUpdateAvailable = true 61 + state.availableVersion = version 62 + return .none 63 + 64 + #if DEBUG 65 + case .debugSimulateUpdateFound: 66 + state.isUpdateAvailable = true 67 + state.availableVersion = "9999.1.1" 68 + return .none 69 + #endif 39 70 } 40 71 } 41 72 }
+1
supacodeTests/CommandPaletteFeatureTests.swift
··· 23 23 expectedIDs.append(contentsOf: [ 24 24 "debug.toast.inProgress", 25 25 "debug.toast.success", 26 + "debug.update.simulate-found", 26 27 ]) 27 28 #endif 28 29 expectNoDifference(items.map(\.id), expectedIDs)