From c670dda0a913d7476c2004918bc6e55b559b49be Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:46:05 +0200 Subject: [PATCH] fix(apple): run the notification delegate on the main actor (iOS crash) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping an approval notification while the app was backgrounded crashed on iOS with "Call must be made on main thread". The UNUserNotificationCenterDelegate methods are `async` and nonisolated, so their continuation resumes off the main thread at the return point — where UIKit synchronously runs state-restoration / snapshot work, tripping the main-thread assertion. (The empty iOS `didReceive` body didn't matter; even an empty async method returns off-main.) Isolate NotificationPresenter to `@MainActor` so the delegate returns on the main thread. `@preconcurrency` on the UNUserNotificationCenterDelegate conformance is required because those requirements are nonisolated with non-Sendable UN* parameters, which strict concurrency won't otherwise let a main-actor type witness. The macOS branch's now-redundant `await MainActor.run { … }` is dropped. --- .../Core/LocalNotificationService.swift | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/apple/VniDrop/Core/LocalNotificationService.swift b/apple/VniDrop/Core/LocalNotificationService.swift index 8b246e2..653bd7a 100644 --- a/apple/VniDrop/Core/LocalNotificationService.swift +++ b/apple/VniDrop/Core/LocalNotificationService.swift @@ -19,7 +19,19 @@ struct LocalNotification { /// Presents notifications even while the app is active. Without a delegate the /// system drops the banner when the app is frontmost — very visible on macOS, /// where the app window is usually open when a transfer completes. -private final class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate { +/// +/// `@MainActor` is required, not just convenient: these delegate methods are +/// `async`, so their continuation resumes at the return point on whatever executor +/// they ran on. When the system hands a notification-tap back to UIKit it performs +/// state-restoration/snapshot work synchronously on that thread — which asserts +/// "Call must be made on main thread" and crashes if the method returned off-main. +/// Main-actor isolation guarantees the return happens on the main thread. +// `@preconcurrency` on the conformance: these delegate requirements are nonisolated +// with non-Sendable UN* parameters, which strict concurrency won't otherwise let a +// main actor-isolated type witness. The main-actor isolation is what fixes the +// crash (see the type doc above); the attribute inserts the runtime hop. +@MainActor +private final class NotificationPresenter: NSObject, @preconcurrency UNUserNotificationCenterDelegate { func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification @@ -36,14 +48,12 @@ private final class NotificationPresenter: NSObject, UNUserNotificationCenterDel didReceive response: UNNotificationResponse ) async { #if os(macOS) - await MainActor.run { - NSApp.activate(ignoringOtherApps: true) - // Reopen/focus the single main window (activation triggers SwiftUI's - // reopen handling when it was closed). - for window in NSApp.windows where window.canBecomeMain { - window.makeKeyAndOrderFront(nil) - break - } + NSApp.activate(ignoringOtherApps: true) + // Reopen/focus the single main window (activation triggers SwiftUI's + // reopen handling when it was closed). + for window in NSApp.windows where window.canBecomeMain { + window.makeKeyAndOrderFront(nil) + break } #endif }