From 0172cdc56de3c70e9d99dcb296964d60c11c39d4 Mon Sep 17 00:00:00 2001 From: kibermaks Date: Sun, 17 May 2026 13:26:18 +0100 Subject: [PATCH] Add notification delivery model --- Sources/CodexBar/AppNotifications.swift | 140 ++++++++++++--- Sources/CodexBar/NotificationSettings.swift | 129 ++++++++++++++ .../CodexBarTests/AppNotificationsTests.swift | 161 ++++++++++++++++++ 3 files changed, 402 insertions(+), 28 deletions(-) create mode 100644 Sources/CodexBar/NotificationSettings.swift create mode 100644 Tests/CodexBarTests/AppNotificationsTests.swift diff --git a/Sources/CodexBar/AppNotifications.swift b/Sources/CodexBar/AppNotifications.swift index 56065dfb5..126935940 100644 --- a/Sources/CodexBar/AppNotifications.swift +++ b/Sources/CodexBar/AppNotifications.swift @@ -6,41 +6,87 @@ import Foundation final class AppNotifications { static let shared = AppNotifications() - private let centerProvider: @Sendable () -> UNUserNotificationCenter + private let authorizationStatusProvider: @Sendable () async -> UNAuthorizationStatus? + private let authorizationRequester: @Sendable () async -> Bool + private let requestPoster: @Sendable (UNNotificationRequest) async throws -> Void + private let soundPlayer: @MainActor @Sendable (NotificationSoundOption, Double) -> Bool + private let allowsPostingWhenRunningUnderTests: Bool private let logger = CodexBarLog.logger(LogCategories.notifications) private var authorizationTask: Task? - init(centerProvider: @escaping @Sendable () -> UNUserNotificationCenter = { UNUserNotificationCenter.current() }) { - self.centerProvider = centerProvider + init( + authorizationStatusProvider: @escaping @Sendable () async -> UNAuthorizationStatus? = { + let center = UNUserNotificationCenter.current() + return await withCheckedContinuation { continuation in + center.getNotificationSettings { settings in + continuation.resume(returning: settings.authorizationStatus) + } + } + }, + authorizationRequester: @escaping @Sendable () async -> Bool = { + let center = UNUserNotificationCenter.current() + return await withCheckedContinuation { continuation in + center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in + continuation.resume(returning: granted) + } + } + }, + requestPoster: @escaping @Sendable (UNNotificationRequest) async throws -> Void = { request in + try await UNUserNotificationCenter.current().add(request) + }, + soundPlayer: @escaping @MainActor @Sendable (NotificationSoundOption, Double) -> Bool = { sound, volume in + NotificationSoundPlayer.play(sound, volume: volume) + }, + allowsPostingWhenRunningUnderTests: Bool = false) + { + self.authorizationStatusProvider = authorizationStatusProvider + self.authorizationRequester = authorizationRequester + self.requestPoster = requestPoster + self.soundPlayer = soundPlayer + self.allowsPostingWhenRunningUnderTests = allowsPostingWhenRunningUnderTests } - func requestAuthorizationOnStartup() { - guard !Self.isRunningUnderTests else { return } + func requestAuthorizationOnStartup(notificationsEnabled: Bool = true) { + guard notificationsEnabled, self.canPostInCurrentEnvironment else { return } _ = self.ensureAuthorizationTask() } + @discardableResult func post( idPrefix: String, title: String, body: String, badge: NSNumber? = nil, - soundEnabled: Bool = true) + soundEnabled: Bool = true, + event: AppNotificationEvent? = nil, + provider: String? = nil, + notificationsEnabled: Bool = true, + notificationVolume: Double = 1.0, + settings: NotificationDeliverySettings? = nil) -> Task? { - guard !Self.isRunningUnderTests else { return } - let center = self.centerProvider() - let logger = self.logger + guard self.canPostInCurrentEnvironment else { return nil } + + return Task { @MainActor in + let deliverySettings = settings ?? .localDefault + guard notificationsEnabled, deliverySettings.enabled else { + self.logger.debug( + "disabled; skipping notification", + metadata: self.metadata(event: event, idPrefix: idPrefix, provider: provider)) + return + } - Task { @MainActor in let granted = await self.ensureAuthorized() guard granted else { - logger.debug("not authorized; skipping post", metadata: ["prefix": idPrefix]) + self.logger.debug( + "not authorized; skipping notification", + metadata: self.metadata(event: event, idPrefix: idPrefix, provider: provider)) return } let content = UNMutableNotificationContent() content.title = title content.body = body - content.sound = soundEnabled ? .default : nil + content.sound = soundEnabled && deliverySettings.sound == .systemDefault ? .default : nil content.badge = badge let request = UNNotificationRequest( @@ -48,12 +94,22 @@ final class AppNotifications { content: content, trigger: nil) - logger.info("posting", metadata: ["prefix": idPrefix]) + self.logger.info( + "posting", + metadata: self.metadata(event: event, idPrefix: idPrefix, provider: provider)) do { - try await center.add(request) + try await self.requestPoster(request) + self.playSoundIfNeeded( + event: event, + idPrefix: idPrefix, + provider: provider, + settings: deliverySettings, + soundEnabled: soundEnabled, + notificationVolume: notificationVolume) } catch { - let errorText = String(describing: error) - logger.error("failed to post", metadata: ["prefix": idPrefix, "error": errorText]) + var metadata = self.metadata(event: event, idPrefix: idPrefix, provider: provider) + metadata["error"] = "\(error)" + self.logger.error("failed to post", metadata: metadata) } } } @@ -74,7 +130,7 @@ final class AppNotifications { } private func requestAuthorization() async -> Bool { - if let existing = await self.notificationAuthorizationStatus() { + if let existing = await self.authorizationStatusProvider() { if existing == .authorized || existing == .provisional { return true } @@ -83,21 +139,43 @@ final class AppNotifications { } } - let center = self.centerProvider() - return await withCheckedContinuation { continuation in - center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in - continuation.resume(returning: granted) - } + return await self.authorizationRequester() + } + + private var canPostInCurrentEnvironment: Bool { + self.allowsPostingWhenRunningUnderTests || !Self.isRunningUnderTests + } + + private func playSoundIfNeeded( + event: AppNotificationEvent?, + idPrefix: String, + provider: String?, + settings: NotificationDeliverySettings, + soundEnabled: Bool, + notificationVolume: Double) + { + guard soundEnabled else { return } + guard settings.sound != .none, settings.sound != .systemDefault else { return } + var metadata = self.metadata(event: event, idPrefix: idPrefix, provider: provider) + metadata["sound"] = settings.sound.rawValue + metadata["volume"] = "\(notificationVolume)" + + if self.soundPlayer(settings.sound, notificationVolume) { + self.logger.info("played sound", metadata: metadata) + } else { + self.logger.error("failed to play sound", metadata: metadata) } } - private func notificationAuthorizationStatus() async -> UNAuthorizationStatus? { - let center = self.centerProvider() - return await withCheckedContinuation { continuation in - center.getNotificationSettings { settings in - continuation.resume(returning: settings.authorizationStatus) - } + private func metadata(event: AppNotificationEvent?, idPrefix: String, provider: String?) -> [String: String] { + var metadata = [ + "event": event?.rawValue ?? "legacy", + "prefix": idPrefix, + ] + if let provider = Self.normalizedProvider(provider) { + metadata["provider"] = provider } + return metadata } private static var isRunningUnderTests: Bool { @@ -112,4 +190,10 @@ final class AppNotifications { if env["SWIFT_TESTING"] != nil { return true } return NSClassFromString("XCTestCase") != nil } + + private nonisolated static func normalizedProvider(_ provider: String?) -> String? { + let trimmed = provider?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let trimmed, !trimmed.isEmpty else { return nil } + return trimmed + } } diff --git a/Sources/CodexBar/NotificationSettings.swift b/Sources/CodexBar/NotificationSettings.swift new file mode 100644 index 000000000..cbe3699ba --- /dev/null +++ b/Sources/CodexBar/NotificationSettings.swift @@ -0,0 +1,129 @@ +import AppKit +import Foundation + +struct NotificationDeliverySettings: Equatable, Sendable { + var enabled: Bool + var sound: NotificationSoundOption + + static let localDefault = NotificationDeliverySettings( + enabled: true, + sound: .systemDefault) +} + +enum NotificationSoundOption: String, CaseIterable, Identifiable, Sendable { + case none + case systemDefault + case basso + case blow + case bottle + case frog + case funk + case glass + case hero + case morse + case ping + case pop + case purr + case sosumi + case submarine + case tink + + var id: String { + self.rawValue + } + + var label: String { + switch self { + case .none: + "None" + case .systemDefault: + "System Default" + case .basso: + "Basso" + case .blow: + "Blow" + case .bottle: + "Bottle" + case .frog: + "Frog" + case .funk: + "Funk" + case .glass: + "Glass" + case .hero: + "Hero" + case .morse: + "Morse" + case .ping: + "Ping" + case .pop: + "Pop" + case .purr: + "Purr" + case .sosumi: + "Sosumi" + case .submarine: + "Submarine" + case .tink: + "Tink" + } + } + + var systemSoundName: String? { + switch self { + case .none, .systemDefault: + nil + case .basso: + "Basso" + case .blow: + "Blow" + case .bottle: + "Bottle" + case .frog: + "Frog" + case .funk: + "Funk" + case .glass: + "Glass" + case .hero: + "Hero" + case .morse: + "Morse" + case .ping: + "Ping" + case .pop: + "Pop" + case .purr: + "Purr" + case .sosumi: + "Sosumi" + case .submarine: + "Submarine" + case .tink: + "Tink" + } + } +} + +@MainActor +enum NotificationSoundPlayer { + @discardableResult + static func play(_ sound: NotificationSoundOption, volume: Double = 1.0) -> Bool { + guard let name = sound.systemSoundName else { return false } + guard let sound = NSSound(named: NSSound.Name(name)) else { return false } + sound.stop() + sound.volume = Float(min(max(volume, 0.0), 1.0)) + return sound.play() + } +} + +enum AppNotificationEvent: String, CaseIterable, Identifiable, Sendable { + case sessionQuotaDepleted + case sessionQuotaRestored + case providerLogin + case augmentSessionExpired + + var id: String { + self.rawValue + } +} diff --git a/Tests/CodexBarTests/AppNotificationsTests.swift b/Tests/CodexBarTests/AppNotificationsTests.swift new file mode 100644 index 000000000..a78659900 --- /dev/null +++ b/Tests/CodexBarTests/AppNotificationsTests.swift @@ -0,0 +1,161 @@ +import Foundation +import Testing +@preconcurrency import UserNotifications +@testable import CodexBar + +@MainActor +struct AppNotificationsTests { + private final class Recorder: @unchecked Sendable { + private let lock = NSLock() + private var requests: [UNNotificationRequest] = [] + private var sounds: [(sound: NotificationSoundOption, volume: Double)] = [] + + func record(request: UNNotificationRequest) { + self.lock.withLock { + self.requests.append(request) + } + } + + func record(sound: NotificationSoundOption, volume: Double) { + self.lock.withLock { + self.sounds.append((sound: sound, volume: volume)) + } + } + + func requestCount() -> Int { + self.lock.withLock { + self.requests.count + } + } + + func soundsSnapshot() -> [(sound: NotificationSoundOption, volume: Double)] { + self.lock.withLock { + self.sounds + } + } + } + + @Test + func `disabled notification skips local delivery`() async { + let recorder = Recorder() + let notifications = Self.makeNotifications(recorder: recorder, authorizationStatus: .authorized) + + await Self.post( + notifications, + event: .providerLogin, + notificationsEnabled: true, + notificationVolume: 0.8, + settings: NotificationDeliverySettings( + enabled: false, + sound: .hero)) + + #expect(recorder.requestCount() == 0) + #expect(recorder.soundsSnapshot().isEmpty) + } + + @Test + func `global notifications toggle disables local delivery`() async { + let recorder = Recorder() + let notifications = Self.makeNotifications(recorder: recorder, authorizationStatus: .authorized) + + await Self.post( + notifications, + event: .sessionQuotaRestored, + notificationsEnabled: false, + notificationVolume: 0.5, + settings: NotificationDeliverySettings( + enabled: true, + sound: .glass)) + + #expect(recorder.requestCount() == 0) + #expect(recorder.soundsSnapshot().isEmpty) + } + + @Test + func `denied authorization skips local delivery`() async { + let recorder = Recorder() + let notifications = Self.makeNotifications(recorder: recorder, authorizationStatus: .denied) + + await Self.post( + notifications, + event: .providerLogin, + notificationsEnabled: true, + notificationVolume: 0.8, + settings: NotificationDeliverySettings( + enabled: true, + sound: .hero)) + + #expect(recorder.requestCount() == 0) + #expect(recorder.soundsSnapshot().isEmpty) + } + + @Test + func `authorized notification posts request and custom sound`() async { + let recorder = Recorder() + let notifications = Self.makeNotifications(recorder: recorder, authorizationStatus: .authorized) + + await Self.post( + notifications, + event: .sessionQuotaDepleted, + notificationsEnabled: true, + notificationVolume: 0.35, + settings: NotificationDeliverySettings( + enabled: true, + sound: .submarine)) + + #expect(recorder.requestCount() == 1) + #expect(recorder.soundsSnapshot().count == 1) + #expect(recorder.soundsSnapshot().first?.sound == .submarine) + #expect(recorder.soundsSnapshot().first?.volume == 0.35) + } + + @Test + func `system default sound does not use custom sound player`() async { + let recorder = Recorder() + let notifications = Self.makeNotifications(recorder: recorder, authorizationStatus: .authorized) + + await Self.post( + notifications, + event: .providerLogin, + notificationsEnabled: true, + notificationVolume: 0.8, + settings: .localDefault) + + #expect(recorder.requestCount() == 1) + #expect(recorder.soundsSnapshot().isEmpty) + } + + private static func makeNotifications( + recorder: Recorder, + authorizationStatus: UNAuthorizationStatus) + -> AppNotifications + { + AppNotifications( + authorizationStatusProvider: { authorizationStatus }, + authorizationRequester: { authorizationStatus == .authorized || authorizationStatus == .provisional }, + requestPoster: { request in recorder.record(request: request) }, + soundPlayer: { sound, volume in + recorder.record(sound: sound, volume: volume) + return true + }, + allowsPostingWhenRunningUnderTests: true) + } + + private static func post( + _ notifications: AppNotifications, + event: AppNotificationEvent, + notificationsEnabled: Bool, + notificationVolume: Double, + settings: NotificationDeliverySettings) async + { + let task = notifications.post( + idPrefix: "test-\(event.rawValue)", + title: event.rawValue, + body: event.rawValue, + event: event, + notificationsEnabled: notificationsEnabled, + notificationVolume: notificationVolume, + settings: settings) + await task?.value + } +}