Skip to content
C.W.K.
Stream
Lesson 05 of 06 · published

Push Notifications: Silent Failures in Every Direction

~17 min · beyond-the-app, push, apns, usernotifications, swift-concurrency

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"A wrong environment looks like silence: the token registers, the relay sends, and the answer that says why sits in a response nobody reads."

The Only Way to Reach Someone Who Is Not Looking

A thin client's socket closes when the app goes to the background, so anything that must reach a person while no screen is attached travels by push. The family's engines send through a relay that holds an APNs authentication key; the phone asks for permission, receives a device token from iOS, and sends the relay a registration. Every step can fail without an error on either side, and each silent failure below cost real debugging.

The Key and the Environment

APNs has two environments. A development build's token routes through the sandbox host; a TestFlight or App Store build's token routes through production. Send to the wrong one and APNs answers 400 BadDeviceToken and nothing arrives. It stays a silent failure only because a relay that never reads the response cannot tell. The rule the family's apps share is one function: a simulator, or an install whose bundle contains embedded.mobileprovision (a development signature), is sandbox; a build with no embedded profile is production. Live Activity tokens were the measured exception, as the previous lesson showed, which is why the relay retries the other host once on BadDeviceToken.

The key has an environment too. Creating an APNs key in the developer portal now offers an environment choice behind Configure, and the default was sandbox only; the setting cannot be changed after creation. Production pushes then failed with 403 BadEnvironmentKeyInToken. The symptom ladder is worth keeping: an unknown key gives InvalidProviderToken, a sandbox-only key gives BadEnvironmentKeyInToken, and a correct key gives 200. The fix was a new key created with Sandbox and Production chosen during creation, and a restart of the relay that reads it at start.

The Delegate That Trapped at Launch

SwiftUI apps receive push callbacks through @UIApplicationDelegateAdaptor, which creates the delegate through its Objective-C -init. The shared delegate class takes its payload key in init(payloadKey:), so an app's subclass needs a no-argument initializer, and only one spelling works. override init() does not compile, because there is nothing to override. A plain init() compiles and traps at launch with "Use of unimplemented initializer 'init()'", reproduced for this lesson. @objc init() exports the initializer into the slot the adaptor uses. The notification callbacks have the same Swift 6 shape as the audio tap: the center's delegate is not guaranteed to run on the main thread, so the family's delegate implements the async forms as nonisolated and lets only the extracted id cross to the main actor.

The Tap Before the Model Exists

A tap on a notification can launch the app cold, and the delegate receives it before the app's model is ready to open anything. The tap is parked in a small queue and delivered when the model attaches; a second early tap replaces the first, because the person's last act is the one they mean. Payload keys are namespaced per app, so another family app's push can never be mistaken for this one's. And the app registers again on every launch and foreground, because tokens rotate and the relay's record is the only thing that routes.

Code

The environment rule, a namespaced payload, and a tap queue for cold launches·swift
import Foundation

enum APNsEnvironment: String, Sendable {
    case sandbox, production
}

/// A wrong environment fails silently: the token registers, the relay sends, Apple drops it.
/// A simulator or a development-signed install carries embedded.mobileprovision and routes through
/// the sandbox; a TestFlight or App Store build carries none and routes through production.
func apnsEnvironment(isSimulator: Bool, bundleURL: URL) -> APNsEnvironment {
    if isSimulator { return .sandbox }
    let profile = bundleURL.appending(path: "embedded.mobileprovision")
    return FileManager.default.fileExists(atPath: profile.path) ? .sandbox : .production
}

/// Namespaced, so another family app's push can never be mistaken for this one's.
func noteID(fromPayload payload: [AnyHashable: Any]) -> String? {
    (payload["spark"] as? [String: Any])?["note_id"] as? String
}

/// The delegate exists before the app's model does. A tap from a cold launch waits here.
@MainActor
final class PushTapQueue {
    private var parked: String?
    private var deliver: ((String) -> Void)?

    func tapped(_ id: String) {
        if let deliver { deliver(id) } else { parked = id }   // a second early tap replaces the first
    }

    func attach(_ handler: @escaping (String) -> Void) {
        deliver = handler
        if let parked {
            self.parked = nil
            handler(parked)
        }
    }
}
A delegate the SwiftUI adaptor can build, with callbacks that cross isolation safely·swift
import SwiftUI
import UIKit
import UserNotifications

@MainActor
class PushDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
    let payloadKey: String
    let taps = PushTapQueue()

    init(payloadKey: String) {
        self.payloadKey = payloadKey
        super.init()
    }

    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        UNUserNotificationCenter.current().delegate = self
        return true
    }

    /// Not guaranteed on main. The async form means no completion handler crosses isolation;
    /// only the extracted id does.
    nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter,
                                            didReceive response: UNNotificationResponse) async {
        guard let id = noteID(fromPayload: response.notification.request.content.userInfo) else { return }
        await taps.tapped(id)
    }
}

/// @UIApplicationDelegateAdaptor builds the delegate through the Objective-C -init slot.
/// `override init()` does not compile here, and a plain `init()` compiles and traps at launch.
final class SparkPushDelegate: PushDelegate {
    @objc init() { super.init(payloadKey: "spark") }
}

@main
struct SparkMobileApp: App {
    @UIApplicationDelegateAdaptor(SparkPushDelegate.self) private var pushDelegate: SparkPushDelegate

    var body: some Scene {
        WindowGroup {
            Text("Spark")
                .onAppear { pushDelegate.taps.attach { id in print("open note", id) } }
        }
    }
}

External links

Exercise

Reproduce the initializer trap on your Mac without any iOS code: declare an NSObject subclass with init(payloadKey:), a second subclass with a plain init(), and a generic make<T: NSObject>(_ type: T.Type) -> T { T.init() }, then run it and record the crash. Switch to @objc init() and run again. Next, write tests for apnsEnvironment(isSimulator:bundleURL:) using a temporary folder with and without an embedded.mobileprovision file, and for PushTapQueue proving that two early taps deliver only the second.
Hint
Mark the class hierarchy @MainActor only where the real delegate needs it; the environment function and the payload reader are plain values and test on macOS. MainActor.assumeIsolated lets a synchronous test body use the queue.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.