FI
ForgePush

ForgeSilentPush

Route background (content-available) push notifications to registered handlers concurrently and aggregate their fetch results.

SilentPushHandler

Conform to this protocol to handle a specific type of silent push. Implement matchesPayload(_:) to claim the notification and handle(_:context:) to do the work. Each handler has a unique id used for logging and deduplication.

import ForgeSilentPush

protocol SilentPushHandler: Sendable {
    /// Unique identifier for this handler. Used for logging and deduplication.
    var id: String { get }

    /// Return true if this handler should process the given push payload.
    func matchesPayload(_ payload: [AnyHashable: Any]) -> Bool

    /// Process the payload and return a SilentPushResult indicating fetch outcome.
    func handle(_ payload: [AnyHashable: Any], context: SilentPushContext) async -> SilentPushResult
}

SilentPushContext

Runtime context passed to handle(_:context:) — provides connectivity status and whether protected data (Keychain, CoreData) is accessible.

// Passed to each handler's handle(_:context:) call
struct SilentPushContext: Sendable {
    /// Current network connectivity status.
    let connectivity: ConnectivityStatus

    /// Whether protected data (Keychain, CoreData) is accessible.
    let protectedDataAvailable: Bool
}

SilentPushResult

Return value from each handler. Maps directly to UIBackgroundFetchResult via .backgroundFetchResult.

enum SilentPushResult: Sendable {
    case newData       // Handler fetched new content
    case noData        // Handler ran successfully but found nothing new
    case failed        // Handler encountered an error

    /// Maps to UIBackgroundFetchResult for the completion handler
    var backgroundFetchResult: UIBackgroundFetchResult { ... }
}

Implementing a Handler

TaskSyncHandler.swift
import ForgeSilentPush

struct TaskSyncHandler: SilentPushHandler {
    let id = "task-sync"

    func matchesPayload(_ payload: [AnyHashable: Any]) -> Bool {
        payload["type"] as? String == "task_sync"
    }

    func handle(_ payload: [AnyHashable: Any], context: SilentPushContext) async -> SilentPushResult {
        guard context.protectedDataAvailable else { return .failed }
        do {
            try await TaskRepository.shared.syncFromRemote()
            return .newData
        } catch {
            return .failed
        }
    }
}

SilentPushRouter

Initialize with connectivity and protected data observers. Add handlers with addHandler(_:). When a silent push arrives, the router calls matchesPayload on each handler and dispatches all matching handlers concurrently via TaskGroup.

import ForgeSilentPush

let router = SilentPushRouter(
    connectivity: connectivityObserver,
    protectedData: protectedDataObserver
)
router.addHandler(TaskSyncHandler())
router.addHandler(ReminderUpdateHandler())
router.addHandler(BadgeResetHandler())

Removing Handlers

// Remove a handler by its id
router.removeHandler("task-sync")

AppDelegate Integration

The router provides both a completion-handler variant (which manages the Task internally) and an async variant.

AppDelegate.swift
// Option 1: Completion handler variant
func application(_ application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable: Any],
    fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
    silentRouter.handlePush(payload: userInfo, completionHandler: completionHandler)
}

// Option 2: Async variant
func application(_ application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable: Any],
    fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
    Task {
        let result = await silentRouter.handlePush(payload: userInfo)
        completionHandler(result.backgroundFetchResult)
    }
}

Result Aggregation

When multiple handlers process the same notification, results are merged using this priority:

// When multiple handlers match the same push, results are aggregated:
// - Any .newData  → aggregate is .newData
// - All .noData   → aggregate is .noData
// - Any .failed (and no .newData) → aggregate is .failed
Handlers' results Aggregate
Any .newData.newData
All .noData.noData
Any .failed, no .newData.failed
No handler matched.noData