Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 112 additions & 28 deletions Sources/CodexBar/AppNotifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,54 +6,110 @@ 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<Bool, Never>?

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<Void, Never>?
{
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(
identifier: "codexbar-\(idPrefix)-\(UUID().uuidString)",
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)
}
}
}
Expand All @@ -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
}
Expand All @@ -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 {
Expand All @@ -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
}
}
129 changes: 129 additions & 0 deletions Sources/CodexBar/NotificationSettings.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading