import CodexBarCoreimport Foundation@preconcurrency import UserNotifications @MainActorfinal class AppNotifications { static let shared = AppNotifications() private let centerProvider: @Sendable () -> UNUserNotificationCenter private let logger = CodexBarLog.logger("notifications") private var authorizationTask: Task<Bool, Never>? init(centerProvider: @escaping @Sendable () -> UNUserNotificationCenter = { UNUserNotificationCenter.current() }) { self.centerProvider = centerProvider } func requestAuthorizationOnStartup() { guard !Self.isRunningUnderTests else { return } _ = self.ensureAuthorizationTask() } func post(idPrefix: String, title: String, body: String, badge: NSNumber? = nil) { guard !Self.isRunningUnderTests else { return } let center = self.centerProvider() let logger = self.logger Task { @MainActor in let granted = await self.ensureAuthorized() guard granted else { logger.debug("not authorized; skipping post", metadata: ["prefix": idPrefix]) return } let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .default content.badge = badge let request = UNNotificationRequest( identifier: "codexbar-\(idPrefix)-\(UUID().uuidString)", content: content, trigger: nil) logger.info("posting", metadata: ["prefix": idPrefix]) do { try await center.add(request) } catch { let errorText = String(describing: error) logger.error("failed to post", metadata: ["prefix": idPrefix, "error": errorText]) } } } // MARK: - Private private func ensureAuthorizationTask() -> Task<Bool, Never> { if let authorizationTask { return authorizationTask } let task = Task { @MainActor in await self.requestAuthorization() } self.authorizationTask = task return task } private func ensureAuthorized() async -> Bool { await self.ensureAuthorizationTask().value } private func requestAuthorization() async -> Bool { if let existing = await self.notificationAuthorizationStatus() { if existing == .authorized || existing == .provisional { return true } if existing == .denied { return false } } let center = self.centerProvider() return await withCheckedContinuation { continuation in center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in continuation.resume(returning: granted) } } } private func notificationAuthorizationStatus() async -> UNAuthorizationStatus? { let center = self.centerProvider() return await withCheckedContinuation { continuation in center.getNotificationSettings { settings in continuation.resume(returning: settings.authorizationStatus) } } } private static var isRunningUnderTests: Bool { let env = ProcessInfo.processInfo.environment if env["XCTestConfigurationFilePath"] != nil { return true } if env["TESTING_LIBRARY_VERSION"] != nil { return true } if env["SWIFT_TESTING"] != nil { return true } return NSClassFromString("XCTestCase") != nil }}
import CodexBarCore
import Foundation
@preconcurrency import UserNotifications
@MainActor
final class AppNotifications {
static let shared = AppNotifications()
private let centerProvider: @Sendable () -> UNUserNotificationCenter
private let logger = CodexBarLog.logger("notifications")
private var authorizationTask: Task<Bool, Never>?
init(centerProvider: @escaping @Sendable () -> UNUserNotificationCenter = { UNUserNotificationCenter.current() }) {
self.centerProvider = centerProvider
}
func requestAuthorizationOnStartup() {
guard !Self.isRunningUnderTests else { return }
_ = self.ensureAuthorizationTask()
}
func post(idPrefix: String, title: String, body: String, badge: NSNumber? = nil) {
guard !Self.isRunningUnderTests else { return }
let center = self.centerProvider()
let logger = self.logger
Task { @MainActor in
let granted = await self.ensureAuthorized()
guard granted else {
logger.debug("not authorized; skipping post", metadata: ["prefix": idPrefix])
return
}
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.badge = badge
let request = UNNotificationRequest(
identifier: "codexbar-\(idPrefix)-\(UUID().uuidString)",
content: content,
trigger: nil)
logger.info("posting", metadata: ["prefix": idPrefix])
do {
try await center.add(request)
} catch {
let errorText = String(describing: error)
logger.error("failed to post", metadata: ["prefix": idPrefix, "error": errorText])
}
}
}
// MARK: - Private
private func ensureAuthorizationTask() -> Task<Bool, Never> {
if let authorizationTask { return authorizationTask }
let task = Task { @MainActor in
await self.requestAuthorization()
}
self.authorizationTask = task
return task
}
private func ensureAuthorized() async -> Bool {
await self.ensureAuthorizationTask().value
}
private func requestAuthorization() async -> Bool {
if let existing = await self.notificationAuthorizationStatus() {
if existing == .authorized || existing == .provisional {
return true
}
if existing == .denied {
return false
}
}
let center = self.centerProvider()
return await withCheckedContinuation { continuation in
center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
continuation.resume(returning: granted)
}
}
}
private func notificationAuthorizationStatus() async -> UNAuthorizationStatus? {
let center = self.centerProvider()
return await withCheckedContinuation { continuation in
center.getNotificationSettings { settings in
continuation.resume(returning: settings.authorizationStatus)
}
}
}
private static var isRunningUnderTests: Bool {
let env = ProcessInfo.processInfo.environment
if env["XCTestConfigurationFilePath"] != nil { return true }
if env["TESTING_LIBRARY_VERSION"] != nil { return true }
if env["SWIFT_TESTING"] != nil { return true }
return NSClassFromString("XCTestCase") != nil
}
}