109 lines · 3.9 KB · Swift
RawDownload
import CodexBarCoreimport Foundation@preconcurrency import UserNotifications @MainActorfinal class AppNotifications {    static let shared = AppNotifications()     private let centerProvider: @Sendable () -> UNUserNotificationCenter    private let logger = CodexBarLog.logger(LogCategories.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 {        // Swift Testing doesn't always set XCTest env vars, and removing XCTest imports from        // the test target can make NSClassFromString("XCTestCase") return nil. If we're not        // running inside an app bundle, treat it as "tests/headless" to avoid crashes when        // accessing UNUserNotificationCenter.        if Bundle.main.bundleURL.pathExtension != "app" { return true }        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    }}