native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #126 from supabitapp/notification-banner

Notification in system banner

authored by

khoi and committed by
GitHub
4d8138b4 b7c92d47

+493 -30
+33
supacode/Clients/Notifications/NotificationSoundClient.swift
··· 1 + import AppKit 2 + import ComposableArchitecture 3 + import Foundation 4 + 5 + private let appNotificationSound: NSSound? = { 6 + guard let url = Bundle.main.url(forResource: "notification", withExtension: "wav") else { 7 + return nil 8 + } 9 + return NSSound(contentsOf: url, byReference: true) 10 + }() 11 + 12 + struct NotificationSoundClient { 13 + var play: @MainActor @Sendable () -> Void 14 + } 15 + 16 + extension NotificationSoundClient: DependencyKey { 17 + static let liveValue = NotificationSoundClient( 18 + play: { 19 + _ = appNotificationSound?.play() 20 + } 21 + ) 22 + 23 + static let testValue = NotificationSoundClient( 24 + play: {} 25 + ) 26 + } 27 + 28 + extension DependencyValues { 29 + var notificationSoundClient: NotificationSoundClient { 30 + get { self[NotificationSoundClient.self] } 31 + set { self[NotificationSoundClient.self] = newValue } 32 + } 33 + }
+109
supacode/Clients/Notifications/SystemNotificationClient.swift
··· 1 + import AppKit 2 + import ComposableArchitecture 3 + import Foundation 4 + import UserNotifications 5 + 6 + private final class ForegroundSystemNotificationDelegate: NSObject, UNUserNotificationCenterDelegate { 7 + func userNotificationCenter( 8 + _ center: UNUserNotificationCenter, 9 + willPresent notification: UNNotification 10 + ) async -> UNNotificationPresentationOptions { 11 + [.badge, .sound, .banner] 12 + } 13 + } 14 + 15 + @MainActor 16 + private let foregroundSystemNotificationDelegate = ForegroundSystemNotificationDelegate() 17 + 18 + @MainActor 19 + private func configuredNotificationCenter() -> UNUserNotificationCenter { 20 + let center = UNUserNotificationCenter.current() 21 + if center.delegate !== foregroundSystemNotificationDelegate { 22 + center.delegate = foregroundSystemNotificationDelegate 23 + } 24 + return center 25 + } 26 + 27 + struct SystemNotificationClient { 28 + struct AuthorizationRequestResult: Equatable { 29 + let granted: Bool 30 + let errorMessage: String? 31 + } 32 + 33 + enum AuthorizationStatus: Equatable { 34 + case authorized 35 + case denied 36 + case notDetermined 37 + } 38 + 39 + var authorizationStatus: @MainActor @Sendable () async -> AuthorizationStatus 40 + var requestAuthorization: @MainActor @Sendable () async -> AuthorizationRequestResult 41 + var send: @MainActor @Sendable (_ title: String, _ body: String) async -> Void 42 + var openSettings: @MainActor @Sendable () async -> Void 43 + } 44 + 45 + extension SystemNotificationClient: DependencyKey { 46 + static let liveValue = SystemNotificationClient( 47 + authorizationStatus: { 48 + let center = configuredNotificationCenter() 49 + let settings = await center.notificationSettings() 50 + switch settings.authorizationStatus { 51 + case .authorized, .provisional: 52 + return .authorized 53 + case .denied: 54 + return .denied 55 + case .notDetermined: 56 + return .notDetermined 57 + @unknown default: 58 + return .denied 59 + } 60 + }, 61 + requestAuthorization: { 62 + let center = configuredNotificationCenter() 63 + do { 64 + let granted = try await center.requestAuthorization( 65 + options: [.alert, .badge, .sound] 66 + ) 67 + return AuthorizationRequestResult(granted: granted, errorMessage: nil) 68 + } catch { 69 + return AuthorizationRequestResult( 70 + granted: false, 71 + errorMessage: error.localizedDescription 72 + ) 73 + } 74 + }, 75 + send: { title, body in 76 + let center = configuredNotificationCenter() 77 + let content = UNMutableNotificationContent() 78 + content.title = title 79 + content.body = body 80 + content.sound = .default 81 + let request = UNNotificationRequest( 82 + identifier: UUID().uuidString, 83 + content: content, 84 + trigger: nil 85 + ) 86 + try? await center.add(request) 87 + }, 88 + openSettings: { 89 + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") else { 90 + return 91 + } 92 + _ = NSWorkspace.shared.open(url) 93 + } 94 + ) 95 + 96 + static let testValue = SystemNotificationClient( 97 + authorizationStatus: { .notDetermined }, 98 + requestAuthorization: { AuthorizationRequestResult(granted: false, errorMessage: nil) }, 99 + send: { _, _ in }, 100 + openSettings: {} 101 + ) 102 + } 103 + 104 + extension DependencyValues { 105 + var systemNotificationClient: SystemNotificationClient { 106 + get { self[SystemNotificationClient.self] } 107 + set { self[SystemNotificationClient.self] = newValue } 108 + } 109 + }
+65 -10
supacode/Features/App/Reducer/AppFeature.swift
··· 4 4 import PostHog 5 5 import SwiftUI 6 6 7 - private let notificationSound: NSSound? = { 8 - guard let url = Bundle.main.url(forResource: "notification", withExtension: "wav") else { 9 - return nil 10 - } 11 - return NSSound(contentsOf: url, byReference: true) 12 - }() 13 - 14 7 private enum CancelID { 15 8 static let periodicRefresh = "app.periodicRefresh" 16 9 } ··· 29 22 var isRunScriptPromptPresented = false 30 23 var runScriptStatusByWorktreeID: [Worktree.ID: Bool] = [:] 31 24 var notificationIndicatorCount: Int = 0 25 + var lastKnownSystemNotificationsEnabled: Bool 32 26 @Presents var alert: AlertState<Alert>? 33 27 var commandPaletteItems: [CommandPaletteItem] { 34 28 CommandPaletteFeature.commandPaletteItems(from: repositories) ··· 40 34 ) { 41 35 self.repositories = repositories 42 36 self.settings = settings 37 + lastKnownSystemNotificationsEnabled = settings.systemNotificationsEnabled 43 38 } 44 39 } 45 40 ··· 69 64 case navigateSearchNext 70 65 case navigateSearchPrevious 71 66 case endSearch 67 + case systemNotificationsPermissionFailed(errorMessage: String?) 72 68 case alert(PresentationAction<Alert>) 73 69 case terminalEvent(TerminalClient.Event) 74 70 } ··· 76 72 enum Alert: Equatable { 77 73 case dismiss 78 74 case confirmQuit 75 + case openSystemNotificationSettings 79 76 } 80 77 81 78 @Dependency(AnalyticsClient.self) private var analyticsClient 82 79 @Dependency(RepositoryPersistenceClient.self) private var repositoryPersistence 83 80 @Dependency(WorkspaceClient.self) private var workspaceClient 84 81 @Dependency(SettingsWindowClient.self) private var settingsWindowClient 82 + @Dependency(NotificationSoundClient.self) private var notificationSoundClient 83 + @Dependency(SystemNotificationClient.self) private var systemNotificationClient 85 84 @Dependency(TerminalClient.self) private var terminalClient 86 85 @Dependency(WorktreeInfoWatcherClient.self) private var worktreeInfoWatcher 87 86 ··· 248 247 return .none 249 248 250 249 case .settings(.delegate(.settingsChanged(let settings))): 250 + let shouldCheckSystemNotificationPermission = 251 + settings.systemNotificationsEnabled && !state.lastKnownSystemNotificationsEnabled 252 + state.lastKnownSystemNotificationsEnabled = settings.systemNotificationsEnabled 251 253 if let selectedWorktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) { 252 254 let rootURL = selectedWorktree.repositoryRootURL 253 255 @Shared(.repositorySettings(rootURL)) var repositorySettings ··· 288 290 await worktreeInfoWatcher.send( 289 291 .setPullRequestTrackingEnabled(settings.githubIntegrationEnabled) 290 292 ) 293 + }, 294 + .run { send in 295 + guard shouldCheckSystemNotificationPermission else { return } 296 + let status = await systemNotificationClient.authorizationStatus() 297 + switch status { 298 + case .authorized: 299 + return 300 + case .notDetermined: 301 + let result = await systemNotificationClient.requestAuthorization() 302 + if !result.granted { 303 + await send( 304 + .systemNotificationsPermissionFailed(errorMessage: result.errorMessage) 305 + ) 306 + } 307 + case .denied: 308 + await send(.systemNotificationsPermissionFailed(errorMessage: "Authorization status is denied.")) 309 + } 291 310 } 292 311 ) 293 312 ··· 520 539 state.selectedRunScript = settings.runScript 521 540 return .none 522 541 542 + case .systemNotificationsPermissionFailed(let errorMessage): 543 + let message: String 544 + if let errorMessage, !errorMessage.isEmpty { 545 + message = 546 + "Supacode cannot send system notifications.\n\n" 547 + + "Error: \(errorMessage)" 548 + } else { 549 + message = "Supacode cannot send system notifications while permission is denied." 550 + } 551 + state.alert = AlertState { 552 + TextState("Enable Notifications in System Settings") 553 + } actions: { 554 + ButtonState(action: .openSystemNotificationSettings) { 555 + TextState("Open System Settings") 556 + } 557 + ButtonState(role: .cancel, action: .dismiss) { 558 + TextState("Cancel") 559 + } 560 + } message: { 561 + TextState(message) 562 + } 563 + return .send(.settings(.setSystemNotificationsEnabled(false))) 564 + 523 565 case .alert(.dismiss): 524 566 state.alert = nil 525 567 return .none 568 + 569 + case .alert(.presented(.openSystemNotificationSettings)): 570 + state.alert = nil 571 + return .run { _ in 572 + await systemNotificationClient.openSettings() 573 + } 526 574 527 575 case .alert(.presented(.confirmQuit)): 528 576 analyticsClient.capture("app_quit", nil) ··· 604 652 case .commandPalette: 605 653 return .none 606 654 607 - case .terminalEvent(.notificationReceived(let worktreeID, _, _)): 655 + case .terminalEvent(.notificationReceived(let worktreeID, let title, let body)): 608 656 var effects: [Effect<Action>] = [ 609 657 .send(.repositories(.worktreeNotificationReceived(worktreeID))) 610 658 ] 611 - if state.settings.notificationSoundEnabled { 659 + if state.settings.systemNotificationsEnabled { 660 + effects.append( 661 + .run { _ in 662 + await systemNotificationClient.send(title, body) 663 + } 664 + ) 665 + } 666 + if state.settings.notificationSoundEnabled && !state.settings.systemNotificationsEnabled { 612 667 effects.append( 613 668 .run { _ in 614 - await MainActor.run { _ = notificationSound?.play() } 669 + await notificationSoundClient.play() 615 670 } 616 671 ) 617 672 }
+7
supacode/Features/Settings/Models/GlobalSettings.swift
··· 7 7 var updatesAutomaticallyDownloadUpdates: Bool 8 8 var inAppNotificationsEnabled: Bool 9 9 var notificationSoundEnabled: Bool 10 + var systemNotificationsEnabled: Bool 10 11 var moveNotifiedWorktreeToTop: Bool 11 12 var analyticsEnabled: Bool 12 13 var crashReportsEnabled: Bool ··· 24 25 updatesAutomaticallyDownloadUpdates: false, 25 26 inAppNotificationsEnabled: true, 26 27 notificationSoundEnabled: true, 28 + systemNotificationsEnabled: false, 27 29 moveNotifiedWorktreeToTop: true, 28 30 analyticsEnabled: true, 29 31 crashReportsEnabled: true, ··· 42 44 updatesAutomaticallyDownloadUpdates: Bool, 43 45 inAppNotificationsEnabled: Bool, 44 46 notificationSoundEnabled: Bool, 47 + systemNotificationsEnabled: Bool = false, 45 48 moveNotifiedWorktreeToTop: Bool, 46 49 analyticsEnabled: Bool, 47 50 crashReportsEnabled: Bool, ··· 58 61 self.updatesAutomaticallyDownloadUpdates = updatesAutomaticallyDownloadUpdates 59 62 self.inAppNotificationsEnabled = inAppNotificationsEnabled 60 63 self.notificationSoundEnabled = notificationSoundEnabled 64 + self.systemNotificationsEnabled = systemNotificationsEnabled 61 65 self.moveNotifiedWorktreeToTop = moveNotifiedWorktreeToTop 62 66 self.analyticsEnabled = analyticsEnabled 63 67 self.crashReportsEnabled = crashReportsEnabled ··· 87 91 notificationSoundEnabled = 88 92 try container.decodeIfPresent(Bool.self, forKey: .notificationSoundEnabled) 89 93 ?? Self.default.notificationSoundEnabled 94 + systemNotificationsEnabled = 95 + try container.decodeIfPresent(Bool.self, forKey: .systemNotificationsEnabled) 96 + ?? Self.default.systemNotificationsEnabled 90 97 moveNotifiedWorktreeToTop = 91 98 try container.decodeIfPresent(Bool.self, forKey: .moveNotifiedWorktreeToTop) 92 99 ?? Self.default.moveNotifiedWorktreeToTop
+20 -7
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 13 13 var updatesAutomaticallyDownloadUpdates: Bool 14 14 var inAppNotificationsEnabled: Bool 15 15 var notificationSoundEnabled: Bool 16 + var systemNotificationsEnabled: Bool 16 17 var moveNotifiedWorktreeToTop: Bool 17 18 var analyticsEnabled: Bool 18 19 var crashReportsEnabled: Bool ··· 33 34 updatesAutomaticallyDownloadUpdates = settings.updatesAutomaticallyDownloadUpdates 34 35 inAppNotificationsEnabled = settings.inAppNotificationsEnabled 35 36 notificationSoundEnabled = settings.notificationSoundEnabled 37 + systemNotificationsEnabled = settings.systemNotificationsEnabled 36 38 moveNotifiedWorktreeToTop = settings.moveNotifiedWorktreeToTop 37 39 analyticsEnabled = settings.analyticsEnabled 38 40 crashReportsEnabled = settings.crashReportsEnabled ··· 52 54 updatesAutomaticallyDownloadUpdates: updatesAutomaticallyDownloadUpdates, 53 55 inAppNotificationsEnabled: inAppNotificationsEnabled, 54 56 notificationSoundEnabled: notificationSoundEnabled, 57 + systemNotificationsEnabled: systemNotificationsEnabled, 55 58 moveNotifiedWorktreeToTop: moveNotifiedWorktreeToTop, 56 59 analyticsEnabled: analyticsEnabled, 57 60 crashReportsEnabled: crashReportsEnabled, ··· 67 70 case task 68 71 case settingsLoaded(GlobalSettings) 69 72 case setSelection(SettingsSection?) 73 + case setSystemNotificationsEnabled(Bool) 70 74 case repositorySettings(RepositorySettingsFeature.Action) 71 75 case delegate(Delegate) 72 76 case binding(BindingAction<State>) ··· 107 111 state.updatesAutomaticallyDownloadUpdates = normalizedSettings.updatesAutomaticallyDownloadUpdates 108 112 state.inAppNotificationsEnabled = normalizedSettings.inAppNotificationsEnabled 109 113 state.notificationSoundEnabled = normalizedSettings.notificationSoundEnabled 114 + state.systemNotificationsEnabled = normalizedSettings.systemNotificationsEnabled 110 115 state.moveNotifiedWorktreeToTop = normalizedSettings.moveNotifiedWorktreeToTop 111 116 state.analyticsEnabled = normalizedSettings.analyticsEnabled 112 117 state.crashReportsEnabled = normalizedSettings.crashReportsEnabled ··· 117 122 return .send(.delegate(.settingsChanged(normalizedSettings))) 118 123 119 124 case .binding: 120 - let settings = state.globalSettings 121 - @Shared(.settingsFile) var settingsFile 122 - $settingsFile.withLock { $0.global = settings } 123 - if settings.analyticsEnabled { 124 - analyticsClient.capture("settings_changed", nil) 125 - } 126 - return .send(.delegate(.settingsChanged(settings))) 125 + return persist(state) 126 + 127 + case .setSystemNotificationsEnabled(let isEnabled): 128 + state.systemNotificationsEnabled = isEnabled 129 + return persist(state) 127 130 128 131 case .setSelection(let selection): 129 132 state.selection = selection ?? .general ··· 139 142 .ifLet(\.repositorySettings, action: \.repositorySettings) { 140 143 RepositorySettingsFeature() 141 144 } 145 + } 146 + 147 + private func persist(_ state: State) -> Effect<Action> { 148 + let settings = state.globalSettings 149 + @Shared(.settingsFile) var settingsFile 150 + $settingsFile.withLock { $0.global = settings } 151 + if settings.analyticsEnabled { 152 + analyticsClient.capture("settings_changed", nil) 153 + } 154 + return .send(.delegate(.settingsChanged(settings))) 142 155 } 143 156 }
+5
supacode/Features/Settings/Views/NotificationsSettingsView.swift
··· 19 19 ) 20 20 .help("Play a sound when a notification is received") 21 21 Toggle( 22 + "System notifications", 23 + isOn: $store.systemNotificationsEnabled 24 + ) 25 + .help("Show macOS system notifications") 26 + Toggle( 22 27 "Move notified worktree to top", 23 28 isOn: $store.moveNotifiedWorktreeToTop 24 29 )
+1
supacode/Features/Settings/Views/SettingsView.swift
··· 121 121 } 122 122 } 123 123 .navigationSplitViewStyle(.balanced) 124 + .alert(store: store.scope(state: \.$alert, action: \.alert)) 124 125 .frame(minWidth: 750, minHeight: 500) 125 126 .background { 126 127 WindowAppearanceSetter(colorScheme: settingsStore.appearanceMode.colorScheme)
+14 -13
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 698 698 } 699 699 700 700 private func appendNotification(title: String, body: String, surfaceId: UUID) { 701 - guard notificationsEnabled else { return } 702 701 let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) 703 702 let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) 704 703 guard !(trimmedTitle.isEmpty && trimmedBody.isEmpty) else { return } 705 - let previousHasUnseen = hasUnseenNotification 706 - let isRead = isSelected() && isFocusedSurface(surfaceId) 707 - notifications.insert( 708 - WorktreeTerminalNotification( 709 - surfaceId: surfaceId, 710 - title: trimmedTitle, 711 - body: trimmedBody, 712 - isRead: isRead 713 - ), 714 - at: 0 715 - ) 716 - emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) 704 + if notificationsEnabled { 705 + let previousHasUnseen = hasUnseenNotification 706 + let isRead = isSelected() && isFocusedSurface(surfaceId) 707 + notifications.insert( 708 + WorktreeTerminalNotification( 709 + surfaceId: surfaceId, 710 + title: trimmedTitle, 711 + body: trimmedBody, 712 + isRead: isRead 713 + ), 714 + at: 0 715 + ) 716 + emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) 717 + } 717 718 onNotificationReceived?(trimmedTitle, trimmedBody) 718 719 } 719 720
+215
supacodeTests/AppFeatureSystemNotificationTests.swift
··· 1 + import ComposableArchitecture 2 + import DependenciesTestSupport 3 + import Foundation 4 + import Testing 5 + 6 + @testable import supacode 7 + 8 + @MainActor 9 + struct AppFeatureSystemNotificationTests { 10 + @Test(.dependencies) func firstTimeDeniedTurnsSystemNotificationsBackOffWithAlert() async { 11 + let storage = SettingsTestStorage() 12 + let authorizationRequests = LockIsolated(0) 13 + let store = withDependencies { 14 + $0.settingsFileStorage = storage.storage 15 + } operation: { 16 + TestStore(initialState: AppFeature.State()) { 17 + AppFeature() 18 + } withDependencies: { 19 + $0.systemNotificationClient.authorizationStatus = { .notDetermined } 20 + $0.systemNotificationClient.requestAuthorization = { 21 + authorizationRequests.withValue { $0 += 1 } 22 + return SystemNotificationClient.AuthorizationRequestResult( 23 + granted: false, 24 + errorMessage: "Mock request error" 25 + ) 26 + } 27 + } 28 + } 29 + store.exhaustivity = .off 30 + 31 + await store.send(.settings(.binding(.set(\.systemNotificationsEnabled, true)))) { 32 + $0.settings.systemNotificationsEnabled = true 33 + } 34 + await store.receive(\.systemNotificationsPermissionFailed) 35 + await store.receive(\.settings.setSystemNotificationsEnabled) { 36 + $0.settings.systemNotificationsEnabled = false 37 + } 38 + 39 + let expectedAlert = AlertState<AppFeature.Alert> { 40 + TextState("Enable Notifications in System Settings") 41 + } actions: { 42 + ButtonState(action: .openSystemNotificationSettings) { 43 + TextState("Open System Settings") 44 + } 45 + ButtonState(role: .cancel, action: .dismiss) { 46 + TextState("Cancel") 47 + } 48 + } message: { 49 + TextState("Supacode cannot send system notifications.\n\nError: Mock request error") 50 + } 51 + 52 + #expect(authorizationRequests.value == 1) 53 + #expect(store.state.settings.systemNotificationsEnabled == false) 54 + #expect(store.state.alert == expectedAlert) 55 + } 56 + 57 + @Test(.dependencies) func deniedStatusShowsAlertAndOpensSystemSettings() async { 58 + let storage = SettingsTestStorage() 59 + let authorizationRequests = LockIsolated(0) 60 + let openedSettings = LockIsolated(0) 61 + let store = withDependencies { 62 + $0.settingsFileStorage = storage.storage 63 + } operation: { 64 + TestStore(initialState: AppFeature.State()) { 65 + AppFeature() 66 + } withDependencies: { 67 + $0.systemNotificationClient.authorizationStatus = { .denied } 68 + $0.systemNotificationClient.requestAuthorization = { 69 + authorizationRequests.withValue { $0 += 1 } 70 + return SystemNotificationClient.AuthorizationRequestResult( 71 + granted: false, 72 + errorMessage: "Mock request error" 73 + ) 74 + } 75 + $0.systemNotificationClient.openSettings = { 76 + openedSettings.withValue { $0 += 1 } 77 + } 78 + } 79 + } 80 + store.exhaustivity = .off 81 + 82 + await store.send(.settings(.binding(.set(\.systemNotificationsEnabled, true)))) { 83 + $0.settings.systemNotificationsEnabled = true 84 + } 85 + await store.receive(\.systemNotificationsPermissionFailed) 86 + await store.receive(\.settings.setSystemNotificationsEnabled) { 87 + $0.settings.systemNotificationsEnabled = false 88 + } 89 + 90 + let expectedAlert = AlertState<AppFeature.Alert> { 91 + TextState("Enable Notifications in System Settings") 92 + } actions: { 93 + ButtonState(action: .openSystemNotificationSettings) { 94 + TextState("Open System Settings") 95 + } 96 + ButtonState(role: .cancel, action: .dismiss) { 97 + TextState("Cancel") 98 + } 99 + } message: { 100 + TextState("Supacode cannot send system notifications.\n\nError: Authorization status is denied.") 101 + } 102 + 103 + #expect(authorizationRequests.value == 0) 104 + #expect(store.state.settings.systemNotificationsEnabled == false) 105 + #expect(store.state.alert == expectedAlert) 106 + 107 + await store.send(.alert(.presented(.openSystemNotificationSettings))) { 108 + $0.alert = nil 109 + } 110 + await store.finish() 111 + #expect(openedSettings.value == 1) 112 + } 113 + 114 + @Test(.dependencies) func notificationReceivedSendsSystemNotificationWhenEnabled() async { 115 + var globalSettings = GlobalSettings.default 116 + globalSettings.systemNotificationsEnabled = true 117 + let sends = LockIsolated<[(String, String)]>([]) 118 + let store = TestStore( 119 + initialState: AppFeature.State( 120 + settings: SettingsFeature.State(settings: globalSettings) 121 + ) 122 + ) { 123 + AppFeature() 124 + } withDependencies: { 125 + $0.systemNotificationClient.send = { title, body in 126 + sends.withValue { $0.append((title, body)) } 127 + } 128 + } 129 + store.exhaustivity = .off 130 + 131 + await store.send( 132 + .terminalEvent( 133 + .notificationReceived( 134 + worktreeID: "/tmp/repo/wt-1", 135 + title: "Done", 136 + body: "Build succeeded" 137 + ) 138 + ) 139 + ) 140 + await store.finish() 141 + 142 + #expect(sends.value.count == 1) 143 + #expect(sends.value.first?.0 == "Done") 144 + #expect(sends.value.first?.1 == "Build succeeded") 145 + } 146 + 147 + @Test(.dependencies) func notificationReceivedSkipsLocalSoundWhenSystemNotificationsEnabled() async { 148 + var globalSettings = GlobalSettings.default 149 + globalSettings.systemNotificationsEnabled = true 150 + globalSettings.notificationSoundEnabled = true 151 + let plays = LockIsolated(0) 152 + let store = TestStore( 153 + initialState: AppFeature.State( 154 + settings: SettingsFeature.State(settings: globalSettings) 155 + ) 156 + ) { 157 + AppFeature() 158 + } withDependencies: { 159 + $0.notificationSoundClient.play = { 160 + plays.withValue { $0 += 1 } 161 + } 162 + } 163 + store.exhaustivity = .off 164 + 165 + await store.send( 166 + .terminalEvent( 167 + .notificationReceived( 168 + worktreeID: "/tmp/repo/wt-1", 169 + title: "Done", 170 + body: "Build succeeded" 171 + ) 172 + ) 173 + ) 174 + await store.finish() 175 + 176 + #expect(plays.value == 0) 177 + } 178 + 179 + @Test(.dependencies) func notificationReceivedPlaysLocalSoundWhenSystemNotificationsDisabled() async { 180 + var globalSettings = GlobalSettings.default 181 + globalSettings.systemNotificationsEnabled = false 182 + globalSettings.notificationSoundEnabled = true 183 + let plays = LockIsolated(0) 184 + let sends = LockIsolated(0) 185 + let store = TestStore( 186 + initialState: AppFeature.State( 187 + settings: SettingsFeature.State(settings: globalSettings) 188 + ) 189 + ) { 190 + AppFeature() 191 + } withDependencies: { 192 + $0.notificationSoundClient.play = { 193 + plays.withValue { $0 += 1 } 194 + } 195 + $0.systemNotificationClient.send = { _, _ in 196 + sends.withValue { $0 += 1 } 197 + } 198 + } 199 + store.exhaustivity = .off 200 + 201 + await store.send( 202 + .terminalEvent( 203 + .notificationReceived( 204 + worktreeID: "/tmp/repo/wt-1", 205 + title: "Done", 206 + body: "Build succeeded" 207 + ) 208 + ) 209 + ) 210 + await store.finish() 211 + 212 + #expect(plays.value == 1) 213 + #expect(sends.value == 0) 214 + } 215 + }
+23
supacodeTests/SettingsFeatureTests.swift
··· 19 19 updatesAutomaticallyDownloadUpdates: true, 20 20 inAppNotificationsEnabled: false, 21 21 notificationSoundEnabled: true, 22 + systemNotificationsEnabled: true, 22 23 moveNotifiedWorktreeToTop: false, 23 24 analyticsEnabled: false, 24 25 crashReportsEnabled: true, ··· 45 46 $0.inAppNotificationsEnabled = false 46 47 $0.notificationSoundEnabled = true 47 48 $0.moveNotifiedWorktreeToTop = false 49 + $0.systemNotificationsEnabled = true 48 50 $0.analyticsEnabled = false 49 51 $0.crashReportsEnabled = true 50 52 $0.githubIntegrationEnabled = true ··· 65 67 updatesAutomaticallyDownloadUpdates: false, 66 68 inAppNotificationsEnabled: false, 67 69 notificationSoundEnabled: false, 70 + systemNotificationsEnabled: false, 68 71 moveNotifiedWorktreeToTop: true, 69 72 analyticsEnabled: true, 70 73 crashReportsEnabled: false, ··· 92 95 updatesAutomaticallyDownloadUpdates: initialSettings.updatesAutomaticallyDownloadUpdates, 93 96 inAppNotificationsEnabled: initialSettings.inAppNotificationsEnabled, 94 97 notificationSoundEnabled: initialSettings.notificationSoundEnabled, 98 + systemNotificationsEnabled: initialSettings.systemNotificationsEnabled, 95 99 moveNotifiedWorktreeToTop: initialSettings.moveNotifiedWorktreeToTop, 96 100 analyticsEnabled: initialSettings.analyticsEnabled, 97 101 crashReportsEnabled: initialSettings.crashReportsEnabled, ··· 105 109 expectNoDifference(settingsFile.global, expectedSettings) 106 110 } 107 111 112 + @Test(.dependencies) func setSystemNotificationsEnabledPersistsChanges() async { 113 + var initialSettings = GlobalSettings.default 114 + initialSettings.systemNotificationsEnabled = false 115 + @Shared(.settingsFile) var settingsFile 116 + $settingsFile.withLock { $0.global = initialSettings } 117 + 118 + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { 119 + SettingsFeature() 120 + } 121 + 122 + await store.send(.setSystemNotificationsEnabled(true)) { 123 + $0.systemNotificationsEnabled = true 124 + } 125 + await store.receive(\.delegate.settingsChanged) 126 + #expect(settingsFile.global.systemNotificationsEnabled == true) 127 + } 128 + 108 129 @Test(.dependencies) func selectionDoesNotMutateRepositorySettings() async { 109 130 let selection = SettingsSection.repository("repo-id") 110 131 let store = TestStore(initialState: SettingsFeature.State()) { ··· 142 163 updatesAutomaticallyDownloadUpdates: true, 143 164 inAppNotificationsEnabled: false, 144 165 notificationSoundEnabled: false, 166 + systemNotificationsEnabled: true, 145 167 moveNotifiedWorktreeToTop: true, 146 168 analyticsEnabled: true, 147 169 crashReportsEnabled: false, ··· 161 183 $0.inAppNotificationsEnabled = false 162 184 $0.notificationSoundEnabled = false 163 185 $0.moveNotifiedWorktreeToTop = true 186 + $0.systemNotificationsEnabled = true 164 187 $0.analyticsEnabled = true 165 188 $0.crashReportsEnabled = false 166 189 $0.githubIntegrationEnabled = true
+1
supacodeTests/SettingsFilePersistenceTests.swift
··· 102 102 #expect(settings.global.updatesAutomaticallyDownloadUpdates == true) 103 103 #expect(settings.global.inAppNotificationsEnabled == true) 104 104 #expect(settings.global.notificationSoundEnabled == true) 105 + #expect(settings.global.systemNotificationsEnabled == false) 105 106 #expect(settings.global.moveNotifiedWorktreeToTop == true) 106 107 #expect(settings.global.analyticsEnabled == true) 107 108 #expect(settings.global.crashReportsEnabled == true)