diff --git a/supacode/Clients/Notifications/SystemNotificationClient.swift b/supacode/Clients/Notifications/SystemNotificationClient.swift index fa32e174..b3722adf 100644 --- a/supacode/Clients/Notifications/SystemNotificationClient.swift +++ b/supacode/Clients/Notifications/SystemNotificationClient.swift @@ -64,7 +64,20 @@ struct SystemNotificationClient { case notDetermined } + /// Whether macOS will actually render the app's Dock badge. The Dock badge + /// is gated by the system notification permission plus the per-app "Badge + /// app icon" switch — it does not depend on Prowl's own banner toggle. + enum DockBadgeAuthorization: Equatable { + /// Notifications are allowed and "Badge app icon" is on. + case available + /// macOS is not allowing notifications for Prowl (denied or not yet asked). + case notificationsDenied + /// Notifications are allowed, but "Badge app icon" is turned off. + case badgeDisabled + } + var authorizationStatus: @MainActor @Sendable () async -> AuthorizationStatus + var dockBadgeAuthorization: @MainActor @Sendable () async -> DockBadgeAuthorization var requestAuthorization: @MainActor @Sendable () async -> AuthorizationRequestResult var send: @MainActor @Sendable (_ title: String, _ body: String, _ worktreeID: Worktree.ID?, _ surfaceID: UUID?) async -> Void @@ -87,6 +100,16 @@ extension SystemNotificationClient: DependencyKey { return .denied } }, + dockBadgeAuthorization: { + let center = configuredNotificationCenter() + let settings = await center.notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional: + return settings.badgeSetting == .enabled ? .available : .badgeDisabled + default: + return .notificationsDenied + } + }, requestAuthorization: { let center = configuredNotificationCenter() do { @@ -130,6 +153,7 @@ extension SystemNotificationClient: DependencyKey { static let testValue = SystemNotificationClient( authorizationStatus: { .notDetermined }, + dockBadgeAuthorization: { .available }, requestAuthorization: { AuthorizationRequestResult(granted: false, errorMessage: nil) }, send: { _, _, _, _ in }, openSettings: {} diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 2138df14..93ecdb09 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -47,6 +47,10 @@ struct SettingsFeature { var showNotificationDotOnDock: Bool var cliInstallStatus: CLIInstallStatus = .notInstalled var cliInstallShowAlert: Bool = true + /// Whether macOS will render the Dock notification badge (notification + /// permission + the per-app "Badge app icon" switch). Refreshed when the + /// Notifications settings pane appears. + var dockBadgeAuthorization: SystemNotificationClient.DockBadgeAuthorization = .available var selection: SettingsSection? = .general var repositorySettings: RepositorySettingsFeature.State? @Presents var alert: AlertState? @@ -148,6 +152,8 @@ struct SettingsFeature { case uninstallCLIButtonTapped case cliInstallCompleted(Result) case refreshCLIInstallStatus + case refreshDockBadgeAuthorization + case dockBadgeAuthorizationResponse(SystemNotificationClient.DockBadgeAuthorization) case showNotificationPermissionAlert(errorMessage: String?) case repositorySettings(RepositorySettingsFeature.Action) case alert(PresentationAction) @@ -344,6 +350,15 @@ struct SettingsFeature { state.cliInstallStatus = cliInstallClient.installationStatus(cliDefaultInstallPath) return .none + case .refreshDockBadgeAuthorization: + return .run { send in + await send(.dockBadgeAuthorizationResponse(systemNotificationClient.dockBadgeAuthorization())) + } + + case .dockBadgeAuthorizationResponse(let authorization): + state.dockBadgeAuthorization = authorization + return .none + case .showNotificationPermissionAlert(let errorMessage): let message: String if let errorMessage, !errorMessage.isEmpty { diff --git a/supacode/Features/Settings/Views/NotificationsSettingsView.swift b/supacode/Features/Settings/Views/NotificationsSettingsView.swift index 639414c1..0a0d3955 100644 --- a/supacode/Features/Settings/Views/NotificationsSettingsView.swift +++ b/supacode/Features/Settings/Views/NotificationsSettingsView.swift @@ -19,11 +19,6 @@ struct NotificationsSettingsView: View { isOn: $store.notificationSoundEnabled ) .help("Play a sound when a notification is received") - Toggle( - "System notifications", - isOn: $store.systemNotificationsEnabled - ) - .help("Show macOS system notifications") Toggle( "Move notified worktree to top", isOn: $store.moveNotifiedWorktreeToTop @@ -38,11 +33,24 @@ struct NotificationsSettingsView: View { } } .help("Bounce the Prowl app icon in the Dock when a notification is received.") + } + Section("System") { + Toggle( + "System notifications", + isOn: $store.systemNotificationsEnabled + ) + .help("Show macOS system notification banners") Toggle( - "Show notification dot on dock icon", + "Show notification badge on dock icon", isOn: $store.showNotificationDotOnDock ) - .help("Show a badge on the Prowl dock icon while notifications are pending.") + .help("Show the unread worktree count on the Prowl dock icon.") + .disabled(!dockBadgeAvailable) + if let dockBadgeCaption { + Text(dockBadgeCaption) + .font(.callout) + .foregroundStyle(.secondary) + } } Section("Command Finished") { Toggle( @@ -76,5 +84,23 @@ struct NotificationsSettingsView: View { .formStyle(.grouped) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .task { + store.send(.refreshDockBadgeAuthorization) + } + } + + private var dockBadgeAvailable: Bool { + store.dockBadgeAuthorization == .available + } + + private var dockBadgeCaption: String? { + switch store.dockBadgeAuthorization { + case .available: + return nil + case .notificationsDenied: + return "Allow notifications for Prowl in System Settings to show the Dock badge." + case .badgeDisabled: + return "Turn on “Badge app icon” for Prowl in System Settings > Notifications." + } } } diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index a71ed446..4dfa3162 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -126,6 +126,19 @@ struct SettingsFeatureTests { #expect(settingsFile.global.systemNotificationsEnabled == true) } + @Test(.dependencies) func refreshDockBadgeAuthorizationStoresSystemState() async { + let store = TestStore(initialState: SettingsFeature.State()) { + SettingsFeature() + } withDependencies: { + $0.systemNotificationClient.dockBadgeAuthorization = { .badgeDisabled } + } + + await store.send(.refreshDockBadgeAuthorization) + await store.receive(\.dockBadgeAuthorizationResponse) { + $0.dockBadgeAuthorization = .badgeDisabled + } + } + @Test(.dependencies) func selectionDoesNotMutateRepositorySettings() async { let selection = SettingsSection.repository("repo-id") let store = TestStore(initialState: SettingsFeature.State()) {