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".
This commit is contained in:
2026-07-23 23:29:40 +02:00
parent 0e0d43bc4d
commit 3469b122c2
20 changed files with 482 additions and 98 deletions

View File

@@ -12,6 +12,7 @@ final class AppGraph: ObservableObject {
let preferencesRepository: AppPreferencesRepository
let filePreviewRepository: FilePreviewRepository
let approvalCoordinator: ApprovalCoordinator
let transferNotificationCoordinator: TransferNotificationCoordinator
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
self.dependencies = dependencies
@@ -23,13 +24,17 @@ final class AppGraph: ObservableObject {
username: dependencies.environment.defaultUsername,
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
themeMode: .system,
notificationsEnabled: false,
diagnosticsEnabled: false
)
)
self.approvalCoordinator = ApprovalCoordinator(
repository: coreRepository,
preferences: preferencesRepository,
notifications: dependencies.notificationService,
visibility: visibility,
messages: messages
)
self.transferNotificationCoordinator = TransferNotificationCoordinator(
repository: coreRepository,
notifications: dependencies.notificationService,
visibility: visibility,
messages: messages

View File

@@ -25,7 +25,6 @@ struct AppPreferences: Equatable {
var username: String
var receiveFolder: ReceiveFolder
var themeMode: ThemeMode
var notificationsEnabled: Bool
var diagnosticsEnabled: Bool
var diagnosticsInstallId: String
}
@@ -34,7 +33,6 @@ struct AppPreferencesDefaults {
let username: String
let receiveFolder: ReceiveFolder
let themeMode: ThemeMode
var notificationsEnabled: Bool = false
var diagnosticsEnabled: Bool = false
}
@@ -51,7 +49,6 @@ final class AppPreferencesRepository: ObservableObject {
static let receiveFolderValue = "receive_folder_value"
static let receiveFolderDisplayName = "receive_folder_display_name"
static let themeMode = "theme_mode"
static let notificationsEnabled = "notifications_enabled"
static let diagnosticsEnabled = "diagnostics_enabled"
static let diagnosticsInstallId = "diagnostics_install_id"
}
@@ -66,14 +63,12 @@ final class AppPreferencesRepository: ObservableObject {
let username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
let notifications = defaults.object(forKey: Key.notificationsEnabled) as? Bool ?? fallback.notificationsEnabled
let diagnostics = defaults.object(forKey: Key.diagnosticsEnabled) as? Bool ?? fallback.diagnosticsEnabled
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
return AppPreferences(
username: username,
receiveFolder: folder,
themeMode: themeMode,
notificationsEnabled: notifications,
diagnosticsEnabled: diagnostics,
diagnosticsInstallId: installId
)
@@ -113,11 +108,6 @@ final class AppPreferencesRepository: ObservableObject {
reload()
}
func setNotificationsEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: Key.notificationsEnabled)
reload()
}
func setDiagnosticsEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: Key.diagnosticsEnabled)
reload()

View File

@@ -16,12 +16,32 @@ struct LocalNotification {
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()
@@ -75,11 +95,6 @@ final class LocalNotificationService: ObservableObject {
center.removeDeliveredNotifications(withIdentifiers: [id])
}
func cancelAll() {
center.removeAllPendingNotificationRequests()
center.removeAllDeliveredNotifications()
}
private static func map(_ status: UNAuthorizationStatus) -> NotificationPermission {
switch status {
case .authorized, .provisional, .ephemeral: return .granted

View File

@@ -27,7 +27,6 @@ final class ApprovalCoordinator: ObservableObject {
@Published private(set) var state = ApprovalState()
private let repository: CoreGateway
private let preferences: AppPreferencesRepository
private let notifications: LocalNotificationService
private let visibility: AppVisibility
private let messages: UiMessageController
@@ -37,13 +36,11 @@ final class ApprovalCoordinator: ObservableObject {
init(
repository: CoreGateway,
preferences: AppPreferencesRepository,
notifications: LocalNotificationService,
visibility: AppVisibility,
messages: UiMessageController
) {
self.repository = repository
self.preferences = preferences
self.notifications = notifications
self.visibility = visibility
self.messages = messages
@@ -68,17 +65,15 @@ final class ApprovalCoordinator: ObservableObject {
.store(in: &cancellables)
// Recompute notifications when any input changes.
Publishers.CombineLatest4(
preferences.$preferences,
Publishers.CombineLatest3(
visibility.$isForeground,
$state,
notifications.$permission
)
.sink { [weak self] preferences, foreground, approvalState, permission in
.sink { [weak self] foreground, approvalState, permission in
guard let self else { return }
Task {
await self.synchronizeNotifications(
enabled: preferences.notificationsEnabled,
foreground: foreground,
pending: approvalState.pending,
permission: permission
@@ -137,16 +132,30 @@ final class ApprovalCoordinator: ObservableObject {
}
private func synchronizeNotifications(
enabled: Bool,
foreground: Bool,
pending: [PendingApproval],
permission: NotificationPermission
) async {
if foreground || !enabled || permission != .granted {
notifications.cancelAll()
// iOS suppresses notifications while the user is in the app (the modal shows
// instead); macOS presents them even when active (the app window is usually
// open), relying on the presenter delegate.
#if os(iOS)
let suppressed = foreground || permission != .granted
#else
let suppressed = permission != .granted
#endif
if suppressed {
// Cancel only our own approval notifications other coordinators
// (e.g. transfer-lifecycle) manage their own and must not be wiped.
for id in publishedNotificationIds { notifications.cancel(id: Self.notificationId(id)) }
return
}
for request in pending where !publishedNotificationIds.contains(request.id) {
// Reserve the id *before* awaiting: the CombineLatest can fire several
// times near-simultaneously, and without this each pass re-adds the same
// notification identifier. macOS coalesces a repeated add of an in-flight
// id into a silent update and shows no banner.
publishedNotificationIds.insert(request.id)
let receiver = request.receiverName
?? request.receiverDeviceName
?? String(localized: L10n.Approval.nearbyDevice)
@@ -155,9 +164,9 @@ final class ApprovalCoordinator: ObservableObject {
let result = await notifications.publish(
LocalNotification(id: Self.notificationId(request.id), title: title, body: body)
)
switch result {
case .success: publishedNotificationIds.insert(request.id)
case .failure(let error): messages.error(error)
if case .failure(let error) = result {
publishedNotificationIds.remove(request.id)
messages.error(error)
}
}
}

View File

@@ -0,0 +1,184 @@
import Combine
import Foundation
/// A transfer-lifecycle moment worth a local notification.
enum TransferNotificationKind: Equatable {
case sendFailed // A share you own failed.
case receiveCompleted // An incoming transfer finished downloading.
case receiveFailed // An incoming transfer failed.
case receiverCompleted // A receiver finished downloading your shared transfer.
}
/// A notification resolved from core state but not yet published. `transferName`
/// is the raw name (may be nil); the coordinator localizes and applies fallbacks.
struct PlannedNotification: Equatable {
let id: String
let kind: TransferNotificationKind
let transferName: String?
let receiver: String?
}
/// Pure: transfer-status notifications for this snapshot, excluding already-published
/// ids. A terminal transfer yields at most one notification, keyed by (kind, id).
func plannedTransferNotifications(_ transfers: [Transfer], published: Set<String>) -> [PlannedNotification] {
transfers.compactMap { transfer in
let kind: TransferNotificationKind
switch (transfer.direction, transfer.status) {
case (.send, .failed): kind = .sendFailed
case (.receive, .done): kind = .receiveCompleted
case (.receive, .failed): kind = .receiveFailed
default: return nil
}
let id = transferNotificationId(kind, transferId: transfer.transferId)
guard !published.contains(id) else { return nil }
return PlannedNotification(id: id, kind: kind, transferName: transfer.transferName, receiver: nil)
}
}
/// Pure: one notification per receiver that has finished downloading a shared
/// transfer, excluding already-published ids.
func plannedReceiverNotifications(_ requests: [ReceiverRequestModel], published: Set<String>) -> [PlannedNotification] {
requests.compactMap { request in
guard request.status == .completed else { return nil }
let id = "receiver-completed-\(request.id)"
guard !published.contains(id) else { return nil }
return PlannedNotification(
id: id, kind: .receiverCompleted,
transferName: request.transferName,
receiver: request.receiverName ?? request.receiverDeviceName
)
}
}
private func transferNotificationId(_ kind: TransferNotificationKind, transferId: UInt64) -> String {
switch kind {
case .sendFailed: return "send-failed-\(transferId)"
case .receiveCompleted: return "receive-completed-\(transferId)"
case .receiveFailed: return "receive-failed-\(transferId)"
case .receiverCompleted: return "receiver-completed-\(transferId)"
}
}
/// Fires local notifications for transfer-lifecycle moments (a receive finishing
/// or failing, a share failing, a receiver completing), so a user who left the
/// app can see the outcome. Approval prompts are handled by `ApprovalCoordinator`.
///
/// Gated on the OS notification permission (and, on iOS, on being backgrounded).
/// Each moment is terminal, so it is marked seen the first time it is observed and
/// never re-published. The first state snapshot which includes existing history
/// such as past receives only primes those ids as seen, so only new transitions
/// notify.
@MainActor
final class TransferNotificationCoordinator: ObservableObject {
private let repository: CoreGateway
private let notifications: LocalNotificationService
private let visibility: AppVisibility
private let messages: UiMessageController
private var published = Set<String>()
private var primedTransfers = false
private var cancellables = Set<AnyCancellable>()
init(
repository: CoreGateway,
notifications: LocalNotificationService,
visibility: AppVisibility,
messages: UiMessageController
) {
self.repository = repository
self.notifications = notifications
self.visibility = visibility
self.messages = messages
repository.statePublisher
.sink { [weak self] core in
guard let self, core.isInitialized else { return }
Task { await self.syncTransfers(core.transfers) }
}
.store(in: &cancellables)
repository.signals
.sink { [weak self] signal in
guard let self else { return }
switch signal {
case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
Task { await self.syncReceivers(transferId: transferId) }
case .approvalChanged:
break
}
}
.store(in: &cancellables)
}
/// iOS suppresses notifications while the user is in the app (the convention);
/// macOS presents them even when active (also the convention the app window
/// is usually open), relying on the presenter delegate to show the banner.
private var canPublish: Bool {
guard notifications.permission == .granted else { return false }
#if os(iOS)
return !visibility.isForeground
#else
return true
#endif
}
private func syncTransfers(_ transfers: [Transfer]) async {
let planned = plannedTransferNotifications(transfers, published: published)
guard primedTransfers else {
// The first snapshot includes existing history (e.g. past receives).
// Mark those terminal transfers seen without notifying, so only new
// transitions notify.
primedTransfers = true
for plan in planned { published.insert(plan.id) }
return
}
for plan in planned { await deliver(plan) }
}
private func syncReceivers(transferId: UInt64) async {
let result = await repository.receiverRequests(transferId: transferId)
switch result {
case .success(let requests):
for plan in plannedReceiverNotifications(requests, published: published) {
await deliver(plan)
}
case .failure(let error):
messages.error(error)
}
}
/// Mark seen unconditionally (a terminal moment notifies at most once), then
/// publish only when the gate allows.
private func deliver(_ plan: PlannedNotification) async {
published.insert(plan.id)
guard canPublish else { return }
let name = plan.transferName ?? String(localized: L10n.Receive.unknownTransfer)
let notification: LocalNotification
switch plan.kind {
case .sendFailed:
notification = LocalNotification(
id: plan.id,
title: String(localized: L10n.Notifications.sendFailedTitle),
body: L10n.Notifications.sendFailedBody(transferName: name))
case .receiveCompleted:
notification = LocalNotification(
id: plan.id,
title: String(localized: L10n.Notifications.receiveCompletedTitle),
body: L10n.Notifications.receiveCompletedBody(transferName: name))
case .receiveFailed:
notification = LocalNotification(
id: plan.id,
title: String(localized: L10n.Notifications.receiveFailedTitle),
body: L10n.Notifications.receiveFailedBody(transferName: name))
case .receiverCompleted:
let receiver = plan.receiver ?? String(localized: L10n.Approval.nearbyDevice)
notification = LocalNotification(
id: plan.id,
title: String(localized: L10n.Notifications.receiverCompletedTitle),
body: L10n.Notifications.receiverCompletedBody(receiver: receiver, transferName: name))
}
if case .failure(let error) = await notifications.publish(notification) {
messages.error(error)
}
}
}

View File

@@ -41,7 +41,6 @@ struct SettingsState: Equatable {
var isValidatingFolder = false
var supportsCustomReceiveFolders = true
var themeMode: ThemeMode = .system
var notificationsEnabled = false
var notificationPermission: NotificationPermission = .notDetermined
var diagnosticsEnabled = false
var deviceInfo: DeviceInfo?
@@ -63,7 +62,7 @@ struct SettingsState: Equatable {
&& lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus
&& lhs.isValidatingFolder == rhs.isValidatingFolder
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
&& lhs.themeMode == rhs.themeMode && lhs.notificationsEnabled == rhs.notificationsEnabled
&& lhs.themeMode == rhs.themeMode
&& lhs.notificationPermission == rhs.notificationPermission
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion
&& lhs.isLoadingDeviceInfo == rhs.isLoadingDeviceInfo
@@ -93,7 +92,6 @@ final class SettingsModel: ObservableObject {
private let bugReports: BugReportService
private let diagnosticsIncluded: Bool
private var enableNotificationsAfterSettings = false
private var usernamePersistTask: Task<Void, Never>?
private var hasLocalUsernameDraft = false
private var cancellables = Set<AnyCancellable>()
@@ -131,7 +129,6 @@ final class SettingsModel: ObservableObject {
self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
self.state.receiveFolder = folder
self.state.themeMode = prefs.themeMode
self.state.notificationsEnabled = prefs.notificationsEnabled
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
if folder != previousFolder { Task { await self.validateFolder(folder) } }
}
@@ -171,26 +168,14 @@ final class SettingsModel: ObservableObject {
func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.message(reason)) }
func resetReceiveFolder() { preferences.resetReceiveFolder() }
func setNotificationsEnabled(_ enabled: Bool) {
/// Ask the OS for notification permission. This is the only time the app can
/// grant it; disabling or fine-tuning afterwards happens in the Settings app.
func requestNotifications() {
Task {
if !enabled {
preferences.setNotificationsEnabled(false)
notifications.cancelAll()
return
}
let permission = await notifications.requestPermission()
state.notificationPermission = permission
if permission == .granted {
await enableNotifications()
} else {
preferences.setNotificationsEnabled(false)
let key = permission == .unsupported ? L10n.Notifications.unsupported : L10n.Notifications.permissionDenied
messages.show(UiMessage(
text: .resource(key),
tone: .warning,
actionLabel: permission == .denied ? .resource(L10n.Button.openSettings) : nil,
onAction: permission == .denied ? { self.openNotificationSettings() } : nil
))
if permission == .unsupported {
messages.show(UiMessage(text: .resource(L10n.Notifications.unsupported), tone: .warning))
}
}
}
@@ -253,34 +238,20 @@ final class SettingsModel: ObservableObject {
func openNotificationSettings() {
Task {
enableNotificationsAfterSettings = true
let result = await notifications.openSettings()
if case .failure = result {
enableNotificationsAfterSettings = false
if case .failure = await notifications.openSettings() {
messages.show(UiMessage(text: .resource(L10n.Notifications.settingsOpenFailed), tone: .error))
}
}
}
/// Re-read the OS permission (called on appear and when returning to the
/// foreground, e.g. after a trip to Settings) so the toggle stays in sync.
func refreshNotificationPermission() {
Task {
let permission = await notifications.refreshPermission()
state.notificationPermission = permission
if enableNotificationsAfterSettings {
enableNotificationsAfterSettings = false
if permission == .granted { await enableNotifications() }
} else if permission != .granted && state.notificationsEnabled {
preferences.setNotificationsEnabled(false)
notifications.cancelAll()
}
state.notificationPermission = await notifications.refreshPermission()
}
}
private func enableNotifications() async {
preferences.setNotificationsEnabled(true)
messages.show(UiMessage(text: .resource(L10n.Notifications.enabledMessage), tone: .success))
}
// MARK: - Storage
/// Recomputes the on-disk usage breakdown off the main actor.

View File

@@ -45,16 +45,22 @@ struct NotificationSettings: View {
var body: some View {
Section {
Toggle(isOn: Binding(
get: { model.state.notificationsEnabled },
set: { model.setNotificationsEnabled($0) }
)) {
Text(String(localized: L10n.Notifications.localTitle))
}
if model.state.notificationPermission == .denied {
Text(String(localized: L10n.Notifications.description)).foregroundStyle(.secondary)
switch model.state.notificationPermission {
case .notDetermined:
Button(String(localized: L10n.Notifications.localTitle), action: model.requestNotifications)
case .granted:
// Allowed the OS Settings app is where you disable or fine-tune.
Text(String(localized: L10n.Notifications.enabledMessage)).foregroundStyle(.secondary)
Button(String(localized: L10n.Button.openSettings), action: model.openNotificationSettings)
case .denied:
Text(String(localized: L10n.Notifications.permissionDenied)).foregroundStyle(.secondary)
Button(String(localized: L10n.Button.openSettings), action: model.openNotificationSettings)
case .unsupported:
Text(String(localized: L10n.Notifications.unsupported)).foregroundStyle(.secondary)
}
}
.onAppear { model.refreshNotificationPermission() }
}
}