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-out →
wipe()deletes the previously-registered row and clearspushToken.
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.
Foreground presentation, inbox & tap → deep link
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
userInfocarries adeeplink(orurl) string, it's stashed and forwarded toDeepLinkHandler, 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):
| Secret | Purpose |
|---|---|
APNS_TEAM_ID | Apple Developer Team ID |
APNS_KEY_ID | Key ID of the APNs Auth Key |
APNS_BUNDLE_ID | e.g. com.soarstarter.SoarStarterSwift |
APNS_PRIVATE_KEY | Contents 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_tokensfor youruser_id. - Tap Send test push → the notification arrives; tapping it routes to the
Notifications screen (
soar:///notifications). - Sign out → the
device_tokensrow is deleted.
