Files
vnidrop/apple/VniDrop/Core/LocalNotificationService.swift
cdricms 3469b122c2 feat(apple): local notifications for transfer lifecycle events
Adds background notifications for the "the thing you were waiting for is
done" moments, alongside the existing incoming-approval-request one:

  - a receive finished downloading            (receive -> done)
  - a receive failed / was interrupted         (receive -> failed)
  - a share you own failed                      (send -> failed)
  - a receiver finished downloading your share  (receiver status completed)

A new TransferNotificationCoordinator observes core state + signals and
publishes these; the decision of which moments notify is a pure function
(plannedTransferNotifications / plannedReceiverNotifications), unit-tested
independently. The first state snapshot only primes existing history as seen
so launch doesn't spam.

Notification permission is now the single source of truth. The in-app
notifications toggle and its decoupled UserDefaults preference are gone;
the Settings section shows an "Allow notifications" button that requests the
OS permission (or deep-links to Settings once decided), and notifications
gate purely on `permission == .granted`.

macOS delivery fixes:
  - add a UNUserNotificationCenterDelegate so banners present even while the
    app is active (the app window is usually open on macOS)
  - present-when-active on macOS, suppress-when-foregrounded on iOS
  - reserve the notification id before awaiting publish: the CombineLatest
    fired several times and re-added the same identifier, which macOS
    coalesces into a silent update with no banner
  - LocalNotificationService seeds its permission at init so gating can't
    race a not-yet-refreshed .notDetermined

Eight localized title/body strings added (apple-only); the shared
notifications_description copy is generalized from "receive requests" to
"transfer activity".
2026-07-23 23:29:40 +02:00

118 lines
3.5 KiB
Swift

import Foundation
import Combine
import UserNotifications
/// Notification permission state, ported from `NotificationPermission`.
enum NotificationPermission {
case notDetermined
case granted
case denied
case unsupported
}
struct LocalNotification {
let id: String
let title: String
let body: String
}
/// 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 {
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
[.banner, .sound, .list]
}
}
/// Local notification service backed by `UNUserNotificationCenter`.
@MainActor
final class LocalNotificationService: ObservableObject {
@Published private(set) var permission: NotificationPermission = .notDetermined
private let center = UNUserNotificationCenter.current()
private let presenter = NotificationPresenter()
init() {
center.delegate = presenter
// Seed the permission immediately so gating (approval/lifecycle
// notifications) never races a not-yet-refreshed `.notDetermined`.
Task { _ = await refreshPermission() }
}
func refreshPermission() async -> NotificationPermission {
let settings = await center.notificationSettings()
let mapped = Self.map(settings.authorizationStatus)
permission = mapped
return mapped
}
func requestPermission() async -> NotificationPermission {
do {
_ = try await center.requestAuthorization(options: [.alert, .sound, .badge])
} catch {
AppLogger.error("notifications", "authorization request failed", error)
}
return await refreshPermission()
}
func openSettings() async -> Result<Void, Error> {
#if os(iOS)
guard let url = URL(string: UIApplication.openSettingsURLString) else {
return .failure(NotificationError.settingsUnavailable)
}
let opened = await UIApplication.shared.open(url)
return opened ? .success(()) : .failure(NotificationError.settingsUnavailable)
#else
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") else {
return .failure(NotificationError.settingsUnavailable)
}
NSWorkspace.shared.open(url)
return .success(())
#endif
}
@discardableResult
func publish(_ notification: LocalNotification) async -> Result<Void, Error> {
let content = UNMutableNotificationContent()
content.title = notification.title
content.body = notification.body
content.sound = .default
let request = UNNotificationRequest(identifier: notification.id, content: content, trigger: nil)
do {
try await center.add(request)
return .success(())
} catch {
return .failure(error)
}
}
func cancel(id: String) {
center.removePendingNotificationRequests(withIdentifiers: [id])
center.removeDeliveredNotifications(withIdentifiers: [id])
}
private static func map(_ status: UNAuthorizationStatus) -> NotificationPermission {
switch status {
case .authorized, .provisional, .ephemeral: return .granted
case .denied: return .denied
case .notDetermined: return .notDetermined
@unknown default: return .notDetermined
}
}
}
private enum NotificationError: LocalizedError {
case settingsUnavailable
var errorDescription: String? { "Could not open notification settings" }
}
#if os(iOS)
import UIKit
#else
import AppKit
#endif