diff --git a/Corner/System/NotificationScheduler.swift b/Corner/System/NotificationScheduler.swift index a774b77..8668fdb 100644 --- a/Corner/System/NotificationScheduler.swift +++ b/Corner/System/NotificationScheduler.swift @@ -2,12 +2,24 @@ import AppKit import UserNotifications import os +/// Identificadores compartidos entre el scheduler (MainActor) y el delegado +/// (nonisolated): fuera de ambos para que los dos puedan leerlos. +private enum NotificationIDs { + static let category = "TASK_REMINDER" + static let completeAction = "COMPLETE_TASK" +} + /// Entrega local de las alarmas por tarea (SPEC §4.1.6). Solo actúa cuando el /// sync con Recordatorios está apagado: con el sync activo, la alarma viaja en /// el recordatorio y la entrega Apple (Mac + iPhone + Watch), y notificar aquí /// duplicaría el aviso. +/// +/// Todas las llamadas a UNUserNotificationCenter usan las variantes con +/// completion handler `@Sendable`, no las `async`: los tipos de +/// UserNotifications no son Sendable en SDKs anteriores y las variantes async +/// no compilan en Xcode 16 (ver DEVELOPMENT.md, compatibilidad de SDK). @MainActor -final class NotificationScheduler: NSObject, UNUserNotificationCenterDelegate { +final class NotificationScheduler { /// true cuando Recordatorios se encarga de avisar. var isDeliveryDelegated: () -> Bool = { false } /// Tocar la notificación abre el panel. @@ -16,21 +28,29 @@ final class NotificationScheduler: NSObject, UNUserNotificationCenterDelegate { private let store: TaskStore private let center = UNUserNotificationCenter.current() private let log = Logger(subsystem: "com.andreetorres.corner", category: "Notifications") - private static let categoryID = "TASK_REMINDER" - private static let completeActionID = "COMPLETE_TASK" + private var delegate: NotificationDelegate? private var pendingRefresh = false init(store: TaskStore) { self.store = store - super.init() - center.delegate = self - let complete = UNNotificationAction(identifier: Self.completeActionID, + + let complete = UNNotificationAction(identifier: NotificationIDs.completeAction, title: String(localized: "Completar"), options: []) center.setNotificationCategories([ - UNNotificationCategory(identifier: Self.categoryID, actions: [complete], + UNNotificationCategory(identifier: NotificationIDs.category, actions: [complete], intentIdentifiers: [], options: []) ]) + + // El delegado vive aparte y es Sendable: el center lo invoca desde + // contexto nonisolated y aquí solo se reentra al MainActor. + let delegate = NotificationDelegate { [weak self] taskID, isCompleteAction in + Task { @MainActor in + self?.handleResponse(taskID: taskID, isCompleteAction: isCompleteAction) + } + } + self.delegate = delegate + center.delegate = delegate } /// Reprograma tras cambios; agrupa ráfagas de guardados. @@ -59,12 +79,13 @@ final class NotificationScheduler: NSObject, UNUserNotificationCenterDelegate { } let calendar = Calendar.current + let log = self.log for reminder in pending { let content = UNMutableNotificationContent() content.title = reminder.title content.body = String(localized: "Recordatorio de Corner") content.sound = .default - content.categoryIdentifier = Self.categoryID + content.categoryIdentifier = NotificationIDs.category content.userInfo = ["taskID": reminder.taskID.uuidString] let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], @@ -73,50 +94,84 @@ final class NotificationScheduler: NSObject, UNUserNotificationCenterDelegate { identifier: reminder.taskID.uuidString, content: content, trigger: UNCalendarNotificationTrigger(dateMatching: components, repeats: false)) - do { - try await center.add(request) - } catch { - log.error("no se pudo programar la alarma: \(error, privacy: .public)") + center.add(request) { @Sendable error in + if let error { + log.error("no se pudo programar la alarma: \(error, privacy: .public)") + } } } log.info("alarmas programadas: \(pending.count)") } - /// El permiso se pide solo cuando de verdad hay algo que notificar. + // MARK: - Permiso (solo cuando hay algo que notificar) + private func ensureAuthorization() async -> Bool { - let settings = await center.notificationSettings() - switch settings.authorizationStatus { + switch await currentStatus() { case .authorized, .provisional: return true case .notDetermined: - return (try? await center.requestAuthorization(options: [.alert, .sound])) ?? false + return await requestAuthorization() default: return false } } - // MARK: - Interacción con la notificación + /// Se extrae solo el estado (enum Sendable): UNNotificationSettings no + /// puede cruzar la frontera de actor. + private func currentStatus() async -> UNAuthorizationStatus { + await withCheckedContinuation { continuation in + center.getNotificationSettings { @Sendable settings in + continuation.resume(returning: settings.authorizationStatus) + } + } + } - nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse) async { - // Extraer valores Sendable antes de cruzar al MainActor: el userInfo - // es [AnyHashable: Any] y no puede viajar entre actores. - let raw = response.notification.request.content.userInfo["taskID"] as? String - let actionID = response.actionIdentifier - guard let id = raw.flatMap({ UUID(uuidString: $0) }) else { return } - await MainActor.run { - if actionID == Self.completeActionID { - if let task = store.task(withID: id), !task.isDone { store.toggle(task) } - } else { - onOpenPanel?() + private func requestAuthorization() async -> Bool { + await withCheckedContinuation { continuation in + center.requestAuthorization(options: [.alert, .sound]) { @Sendable granted, _ in + continuation.resume(returning: granted) } } } + // MARK: - Respuesta del usuario + + private func handleResponse(taskID: UUID, isCompleteAction: Bool) { + if isCompleteAction { + if let task = store.task(withID: taskID), !task.isDone { store.toggle(task) } + } else { + onOpenPanel?() + } + } +} + +/// Delegado nonisolated y Sendable: recibe los callbacks del sistema y +/// entrega solo valores Sendable (el UUID de la tarea y qué acción fue). +private final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, + @unchecked Sendable { + private let onResponse: @Sendable (UUID, Bool) -> Void + + init(onResponse: @escaping @Sendable (UUID, Bool) -> Void) { + self.onResponse = onResponse + super.init() + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + let raw = response.notification.request.content.userInfo["taskID"] as? String + let isCompleteAction = response.actionIdentifier == NotificationIDs.completeAction + if let id = raw.flatMap({ UUID(uuidString: $0) }) { + onResponse(id, isCompleteAction) + } + completionHandler() + } + /// Mostrar el aviso aunque Corner esté al frente. - nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, - willPresent notification: UNNotification) async - -> UNNotificationPresentationOptions { - [.banner, .sound] + func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: + @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.banner, .sound]) } }