From fdde0502bf0f5c5a7405027dcd8ae5c8f2b4e6d1 Mon Sep 17 00:00:00 2001 From: AndreeTorres <00015722@uca.edu.sv> Date: Fri, 24 Jul 2026 22:21:47 -0600 Subject: [PATCH 1/2] fix: usa las APIs con completion handler de UserNotifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Las variantes async devuelven tipos no-Sendable (UNNotificationSettings) que no cruzan la frontera de actor en el SDK de Xcode 16, aunque sí compilen con el SDK más nuevo local. Se extrae solo el estado de autorización dentro del callback @Sendable, y el delegado se mueve a una clase nonisolated Sendable que entrega únicamente el UUID de la tarea y qué acción fue. --- Corner/System/NotificationScheduler.swift | 111 ++++++++++++++++------ 1 file changed, 81 insertions(+), 30 deletions(-) diff --git a/Corner/System/NotificationScheduler.swift b/Corner/System/NotificationScheduler.swift index a774b77..8a0f419 100644 --- a/Corner/System/NotificationScheduler.swift +++ b/Corner/System/NotificationScheduler.swift @@ -6,8 +6,13 @@ import os /// 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, sección de 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,14 +21,15 @@ 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 + fileprivate static let categoryID = "TASK_REMINDER" + fileprivate static let completeActionID = "COMPLETE_TASK" + init(store: TaskStore) { self.store = store - super.init() - center.delegate = self + let complete = UNNotificationAction(identifier: Self.completeActionID, title: String(localized: "Completar"), options: []) @@ -31,6 +37,16 @@ final class NotificationScheduler: NSObject, UNUserNotificationCenterDelegate { UNNotificationCategory(identifier: Self.categoryID, 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,6 +75,7 @@ final class NotificationScheduler: NSObject, UNUserNotificationCenterDelegate { } let calendar = Calendar.current + let log = self.log for reminder in pending { let content = UNMutableNotificationContent() content.title = reminder.title @@ -73,50 +90,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 == NotificationScheduler.completeActionID + 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]) } } From db8b24b5df5d5169a954cd9f0b3b25564c175813 Mon Sep 17 00:00:00 2001 From: AndreeTorres <00015722@uca.edu.sv> Date: Fri, 24 Jul 2026 22:22:36 -0600 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20constantes=20de=20notificaci=C3=B3n?= =?UTF-8?q?=20fuera=20del=20actor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un 'static let' dentro de una clase @MainActor queda aislado al actor y el delegado nonisolated no puede leerlo. --- Corner/System/NotificationScheduler.swift | 24 +++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/Corner/System/NotificationScheduler.swift b/Corner/System/NotificationScheduler.swift index 8a0f419..8668fdb 100644 --- a/Corner/System/NotificationScheduler.swift +++ b/Corner/System/NotificationScheduler.swift @@ -2,15 +2,22 @@ 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, sección de compatibilidad de SDK). +/// 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 { /// true cuando Recordatorios se encarga de avisar. @@ -24,17 +31,14 @@ final class NotificationScheduler { private var delegate: NotificationDelegate? private var pendingRefresh = false - fileprivate static let categoryID = "TASK_REMINDER" - fileprivate static let completeActionID = "COMPLETE_TASK" - init(store: TaskStore) { self.store = store - 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: []) ]) @@ -81,7 +85,7 @@ final class NotificationScheduler { 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], @@ -156,7 +160,7 @@ private final class NotificationDelegate: NSObject, UNUserNotificationCenterDele didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { let raw = response.notification.request.content.userInfo["taskID"] as? String - let isCompleteAction = response.actionIdentifier == NotificationScheduler.completeActionID + let isCompleteAction = response.actionIdentifier == NotificationIDs.completeAction if let id = raw.flatMap({ UUID(uuidString: $0) }) { onResponse(id, isCompleteAction) }