import Foundation import Observation #if canImport(UserNotifications) import UserNotifications #endif /// Surfaces download events two ways: a macOS Notification Center alert (works when /// the app is backgrounded; one-time permission prompt) AND an in-app banner via the /// observable `lastBanner` (always set, even when notifications are denied or /// unavailable — e.g. a non-bundled test run). Posting is a no-op when /// `notifyDownloads` is off. @Observable @MainActor public final class NotificationsService { public static let shared = NotificationsService() public init() {} @ObservationIgnored private var authorized = false @ObservationIgnored private var asked = false /// Most recent in-app banner message (the UI shows + auto-clears it). public private(set) var lastBanner: String? public func clearBanner() { lastBanner = nil } /// In-app toast only — always shown, independent of `notifyDownloads`. public func showBanner(_ message: String) { lastBanner = message } /// Ask once for Notification Center permission (the in-app banner needs none). public func requestAuthorizationIfNeeded() { #if canImport(UserNotifications) guard !asked else { return } asked = true UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { [weak self] ok, _ in Task { @MainActor in self?.authorized = ok } } #endif } /// Post a notification (+ banner). Gated by the `notifyDownloads` setting. public func post(title: String, body: String = "") { guard SettingsStore.load().notifyDownloads else { return } lastBanner = body.isEmpty ? title : "\(title) — \(body)" #if canImport(UserNotifications) guard authorized else { return } let content = UNMutableNotificationContent() content.title = title if !body.isEmpty { content.body = body } content.sound = .default UNUserNotificationCenter.current().add( UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)) #endif } }