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
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.
// 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 |