From 4b5ef47c83aa035fb1736c0e61909d7cefa553b9 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 27 May 2026 23:18:32 +0900 Subject: [PATCH] Split Notifications settings and gate Dock badge on macOS state Move app-controlled options (bell, sound, move-to-top, dock bounce) into the Notifications section and system-dependent ones (system banners, dock badge) into a new System section. The Dock badge is rendered by macOS only when notifications are allowed and 'Badge app icon' is on (it does not depend on Prowl's banner toggle). Query that via SystemNotificationClient.dockBadgeAuthorization, refresh it when the pane appears, and disable + gray the badge toggle with an explanatory caption when unavailable. --- .../SystemNotificationClient.swift | 24 +++++++++++ .../Settings/Reducer/SettingsFeature.swift | 15 +++++++ .../Views/NotificationsSettingsView.swift | 40 +++++++++++++++---- supacodeTests/SettingsFeatureTests.swift | 13 ++++++ 4 files changed, 85 insertions(+), 7 deletions(-) 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()) { -- 2.51.2