Push Notifications

APNs registration, the device_tokens pipeline, and sending test pushes.

Device-only. The simulator never receives an APNs device token, and registration requires a paid Apple Developer team (the aps-environment entitlement). Everything below needs a real device signed into your team — see the on-simulator behavior note at the end.

Push is wired end-to-end: request authorization → register with APNs → upsert the token to Supabase → send a test push through an Edge Function that keeps your Apple credentials server-side. It's labeled Integrated — it comes online once the entitlement + APNs key are configured, and no-ops safely before that.

Key files

The token pipeline

SwiftUI owns the app lifecycle, so AppDelegate (mounted via @UIApplicationDelegateAdaptor) bridges the UIKit APNs callbacks into the @Observable NotificationsController:

requestAuthorization()  →  registerForRemoteNotifications()


AppDelegate.didRegisterForRemoteNotificationsWithDeviceToken(_:)
        │  hex-encode the token

NotificationsController.handleDeviceToken(_:)


PushTokenRegistrar.upsert(token:userId:)  →  device_tokens (onConflict "user_id,token")

The registrar upserts (user_id, token, platform: "ios", updated_at) keyed on the composite (user_id, token), so re-registers are idempotent. It's session-aware:

  • Sign-in with a token already in hand → re-upsert bound to the new user.
  • Sign-outwipe() deletes the previously-registered row and clears pushToken.

The device_tokens table isn't in the template's shipped migrations — its SQL (columns + composite PK + RLS) is inlined in Supabase Setup. Without the table the upsert fails silently (best-effort) rather than crashing.

Authorization & priming

NotificationsController.requestAuthorization() asks for [.alert, .badge, .sound], refreshes the stored status, and registers with APNs on success. Two surfaces call it:

  • Permissions screen (PermissionsView) — a priming UI that explains the benefit before the native dialog, alongside Photos / Camera / Tracking. Reachable from the Me tab; onboarding intentionally does not auto-route here. Denied permissions switch the button to an "Open Settings" affordance.
  • Notifications screen (NotificationsView) — shows the current permission status, the device token (debug), the in-app inbox, and the test-push button.

The UNUserNotificationCenterDelegate on AppDelegate handles arrivals:

  • Foreground arrival → presented as [.banner, .list, .sound] and appended to the in-memory inbox (capped at 50 entries).
  • Tap → if the payload's userInfo carries a deeplink (or url) string, it's stashed and forwarded to DeepLinkHandler, which routes it. See Deep Links.

Testing with send-test-push

Rather than ship Apple credentials in the app, the template calls the send-test-push Edge Function (JWT-verified), which looks up the caller's own iOS device_tokens and sends via APNs using server-side secrets. The Notifications screen's "Send test push" button drives it:

// PushTestSender — invoked from NotificationsViewModel
let request = PushTestRequest(
    title: title, body: body,
    deeplink: "soar:///notifications",
    environment: "sandbox"        // sandbox vs production APNs host
)
try await client.functions.invoke("send-test-push", options: .init(body: request))

The function requires these Edge secrets (set in Supabase Setup):

SecretPurpose
APNS_TEAM_IDApple Developer Team ID
APNS_KEY_IDKey ID of the APNs Auth Key
APNS_BUNDLE_IDe.g. com.soarstarter.SoarStarterSwift
APNS_PRIVATE_KEYContents of the .p8 APNs Auth Key

It also prunes invalid tokens — a BadDeviceToken / Unregistered response deletes that row so the table self-heals.

Match the APNs environment to the build. A debug build registers on the sandbox APNs host; TestFlight/App Store builds use production. Sending to the wrong host returns BadDeviceToken. The aps-environment entitlement ships as development; the release build flips it to production at distribution time.

On the simulator

registerForRemoteNotifications() never yields a token in the simulator, so the test-push button stays disabled (it requires a non-nil pushToken). You can still exercise the local paths — request authorization, and drop a .apns payload file onto the simulator window to see foreground presentation, the inbox, and deep-link routing. Real APNs delivery needs a device.

Verify

  • On device, open Notifications → Enable → grant the dialog → the status flips to granted and a device token appears (debug).
  • Confirm a row in device_tokens for your user_id.
  • Tap Send test push → the notification arrives; tapping it routes to the Notifications screen (soar:///notifications).
  • Sign out → the device_tokens row is deleted.

On this page