ios-push-notifications/SKILL.md
APNs push notifications, rich notifications, notification extensions, background push, and notification categories for iOS. Use when implementing remote push notifications, UNUserNotificationCenter, UNNotificationServiceExtension...
npx skillsauth add peterbamuhigire/skills-web-dev ios-push-notificationsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
ios-push-notifications or would be better handled by a more specific companion skill.SKILL.md first, then load only the referenced deep-dive files that are necessary for the task.// AppDelegate / App init
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UNUserNotificationCenter.current().delegate = self
// Auth BEFORE registering — some iOS versions ignore register without prior auth
Task {
let granted = try? await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .badge, .sound, .provisional])
if granted == true {
await MainActor.run { application.registerForRemoteNotifications() }
}
}
return true
}
// Token received — send to server every launch, not just first
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
Task { await MyPushTokenService.shared.upload(token: token) }
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
// Log but never crash — simulator always fails in older Xcode
Logger.push.error("APNs registration failed: \(error)")
}
registerForRemoteNotifications() must be called on the main thread — always wrap in await MainActor.run.provisional authorization delivers silently to Notification Centre without prompting user — use as a trial ramp before requesting full permission.timeSensitive interruption level bypasses Focus modes; requires the com.apple.developer.usernotifications.time-sensitive entitlementapi.sandbox.push.apple.com); App Store builds use production — mismatch silently drops pushesUIBackgroundModes: remote-notification in Info.plist and content-available: 1 in payload; missing either means no background wake// Without willPresent, all notifications are silently suppressed when app is active
extension AppDelegate: UNUserNotificationCenterDelegate {
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
// Explicit options required — passing [] suppresses banner
completionHandler([.banner, .badge, .sound])
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
defer { completionHandler() } // must always be called
let userInfo = response.notification.request.content.userInfo
// Route to correct screen — check response.actionIdentifier for action buttons
NotificationRouter.shared.handle(userInfo: userInfo, actionID: response.actionIdentifier)
}
}
Delegate timing trap: If delegate is set after a notification arrives (e.g. set inside a lazy-loaded view controller), willPresent is never called for that notification. Always set in didFinishLaunching.
// Payload: { "aps": { "content-available": 1 } } — no alert/sound/badge
// Info.plist: UIBackgroundModes = ["remote-notification"]
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
// Hard 30-second budget — system terminates if you exceed it
Task {
do {
try await SyncCoordinator.shared.performBackgroundSync(userInfo: userInfo)
completionHandler(.newData)
} catch {
completionHandler(.failed)
}
}
}
APNs header rules for silent push:
apns-priority: 5 (not 10) — priority 10 for silent push causes delivery rejectionapns-push-type: background — required by APNs since iOS 13; missing causes drops on newer OS// Separate target: File > New > Target > Notification Service Extension
// Payload must include "mutable-content": 1 — without it this extension never fires
// Extension has its own sandbox — cannot access main app's Keychain without App Groups
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
guard
let urlString = request.content.userInfo["image_url"] as? String,
let url = URL(string: urlString)
else {
contentHandler(request.content)
return
}
URLSession.shared.downloadTask(with: url) { [weak self] tempURL, _, error in
guard let self else { return }
guard let tempURL, error == nil,
let attachment = try? UNNotificationAttachment(
identifier: UUID().uuidString,
url: tempURL,
options: [UNNotificationAttachmentOptionsTypeHintKey: kUTTypeJPEG]
)
else {
self.contentHandler?(self.bestAttemptContent ?? request.content)
return
}
self.bestAttemptContent?.attachments = [attachment]
self.contentHandler?(self.bestAttemptContent ?? request.content)
}.resume()
}
override func serviceExtensionTimeWillExpire() {
// ~30s budget — system calls this before killing; deliver whatever you have
contentHandler?(bestAttemptContent ?? UNNotificationContent())
}
}
App Groups gotcha: If the extension needs to write data the main app reads (e.g. badge counts, analytics), configure a shared App Group container. The extension runs in a separate process with its own container by default.
Attachment file persistence: The system moves the downloaded file to a new location after delivery. Store the attachment identifier if you need to look it up later — the original temp URL is gone.
// Must be registered before any notification with that category arrives
// Best practice: register in didFinishLaunching unconditionally
func registerNotificationCategories() {
let replyAction = UNTextInputNotificationAction(
identifier: "REPLY",
title: "Reply",
options: [],
textInputButtonTitle: "Send",
textInputPlaceholder: "Message…"
)
let archiveAction = UNNotificationAction(
identifier: "ARCHIVE",
title: "Archive",
options: [.destructive] // shown in red
)
let viewAction = UNNotificationAction(
identifier: "VIEW",
title: "View",
options: [.foreground] // brings app to foreground
)
let messageCategory = UNNotificationCategory(
identifier: "MESSAGE",
actions: [replyAction, viewAction, archiveAction],
intentIdentifiers: [INSendMessageIntent.intentIdentifiers], // Siri suggestions
options: [.customDismissAction] // fires didReceive on dismiss too
)
UNUserNotificationCenter.current().setNotificationCategories([messageCategory])
}
// In didReceive response:
switch response.actionIdentifier {
case "REPLY":
let text = (response as? UNTextInputNotificationResponse)?.userText ?? ""
await MessageService.shared.reply(text: text, to: notificationID)
case "ARCHIVE":
await MessageService.shared.archive(notificationID)
case UNNotificationDefaultActionIdentifier:
// Tap on notification body
NotificationRouter.shared.navigate(to: notificationID)
case UNNotificationDismissActionIdentifier:
// Requires .customDismissAction option on category
Analytics.track(.notificationDismissed)
default:
break
}
// Separate target: Notification Content Extension
// Info.plist keys (under NSExtension > NSExtensionAttributes):
// UNNotificationExtensionCategory: "MESSAGE" (or array of strings)
// UNNotificationExtensionInitialContentSizeRatio: 0.5 (height = width * ratio)
// UNNotificationExtensionDefaultContentHidden: true (hides default system UI)
// UNNotificationExtensionUserInteractionEnabled: true (enables touches in view)
class NotificationViewController: UIViewController, UNNotificationContentExtension {
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var bodyLabel: UILabel!
func didReceive(_ notification: UNNotification) {
let content = notification.request.content
titleLabel.text = content.title
bodyLabel.text = content.body
// Populate from content.userInfo for rich data
}
func didReceive(
_ response: UNNotificationResponse,
completionHandler completion: @escaping (UNNotificationContentExtensionResponseOption) -> Void
) {
switch response.actionIdentifier {
case "REPLY":
// Handle inline — do not forward to app
completion(.doNotDismiss) // keep UI visible, update it
default:
// Forward to app's didReceive(_:UNNotificationResponse:)
completion(.dismissAndForwardAction)
}
}
}
UNNotificationContentExtensionResponseOption values:
.doNotDismiss — stay visible (update UI for inline replies).dismiss — remove without opening app.dismissAndForwardAction — remove and call app delegate's didReceive{
"aps": {
"alert": {
"title": "New message",
"subtitle": "From Alice",
"body": "Hey, are you free tonight?"
},
"badge": 3,
"sound": "default",
"category": "MESSAGE",
"content-available": 1,
"mutable-content": 1,
"thread-id": "conversation-abc123",
"interruption-level": "active",
"relevance-score": 0.8
},
"image_url": "https://cdn.example.com/photo.jpg",
"conversation_id": "abc123"
}
Key field notes:
mutable-content: 1 — triggers UNNotificationServiceExtension; without it the extension is bypassedthread-id — groups notifications in Notification Centre; use conversation or entity IDsinterruption-level: passive (no sound/screen wake), active (default), time-sensitive (Focus bypass), critical (requires Apple approval, always sounds)relevance-score: 0.0–1.0; higher scores surface the notification in the notification summaryaps) are passed through in userInfo| Entitlement | How to Add | When Required |
|---|---|---|
| aps-environment: development | Xcode auto-adds on debug | Debug/simulator builds |
| aps-environment: production | Xcode auto-adds on release | App Store / TestFlight |
| Push Notifications capability | Target > Signing & Capabilities | All push |
| com.apple.developer.usernotifications.time-sensitive | Capabilities pane | time-sensitive interruption level |
| com.apple.developer.usernotifications.critical-alerts | Apple Developer portal request | Critical alerts (requires justification) |
Common mismatch: Provisioning profile generated before Push Notifications capability was added will silently fail. Regenerate profile after adding the capability.
// Client: upload token with metadata
struct PushTokenPayload: Encodable {
let token: String
let userID: String
let bundleID: String
let environment: String // "sandbox" or "production"
let appVersion: String
let osVersion: String
}
// Server token management rules:
// - Store: token + userID + environment + updatedAt
// - On APNs HTTP/2 response 410 (Unregistered): delete token immediately
// - On APNs HTTP/2 response 400 BadDeviceToken: log and delete — app was uninstalled
// - On APNs HTTP/2 response 400 TopicDisallowed: wrong environment endpoint
// - Never treat a token as permanent — refresh on every app launch
// - One user can have multiple tokens (multiple devices)
APNs HTTP/2 authentication: prefer auth keys (.p8) over certificates (.p12). Keys never expire, work across all App IDs in a team, and require a single key for sandbox + production.
Simulator (iOS 16+):
# Drag .apns file onto simulator, or:
xcrun simctl push booted com.example.MyApp payload.apns
Minimal .apns file:
{
"aps": { "alert": { "title": "Test", "body": "Hello" } },
"Simulator Target Bundle": "com.example.MyApp"
}
Device testing:
POST https://api.sandbox.push.apple.com/3/device/{token}apns-topic (bundle ID), apns-push-type, authorization (JWT from p8 key)JWT for APNs auth key:
Header: { "alg": "ES256", "kid": "KEY_ID" }
Payload: { "iss": "TEAM_ID", "iat": <now> }
Signed with p8 private key — valid for 1 hour, rotate before expiry
| Anti-Pattern | Consequence | Fix |
|---|---|---|
| Set delegate after didFinishLaunching | willPresent never fires for early notifications | Set in didFinishLaunching, before any push arrives |
| Register for remote notifications before auth grant | Ignored on some iOS versions | Auth first, register in auth completion |
| Store token only on first launch | Stale token when token rotates | Upload on every cold launch |
| Omit mutable-content: 1 | Service extension never fires | Always include for rich/encrypted push |
| Skip serviceExtensionTimeWillExpire | Notification silently lost if download slow | Always implement with bestAttemptContent fallback |
| Use production endpoint for sandbox builds | Push never delivered; no error | Match endpoint to aps-environment entitlement |
| Use apns-priority: 10 for silent push | APNs rejects or throttles | Use apns-priority: 5 + apns-push-type: background |
| Ignore 410 from APNs | Server keeps sending to dead tokens, wastes quota | Delete token immediately on 410 |
| Share Keychain item without App Group | Extension cannot read main app secrets | Add App Group; use shared container |
| Forget apns-push-type header | Delivery drops on iOS 13+ | Always set (alert, background, voip, etc.) |
UNUserNotificationCenter.current().delegate = self set in didFinishLaunching before any notification can arriverequestAuthorization called before registerForRemoteNotificationsregisterForRemoteNotifications() wrapped in await MainActor.runwillPresent delegate returns .banner (or desired options) for foreground displaydidReceive response routes tap to correct screen and calls completionHandler()mutable-content: 1 in payload for any notification using service extensionserviceExtensionTimeWillExpire delivers bestAttemptContent fallbackUIBackgroundModes: remote-notification in Info.plist for silent pushapns-priority: 5 and apns-push-type: background for silent push payloadsaps-environment entitlement matches build configuration (debug = development)didFinishLaunchingdata-ai
Use when adding AI-powered analytics to a SaaS platform — semantic search over business data, natural language queries, trend detection, anomaly alerts, and AI-generated insights for dashboards. Covers embeddings, NL2SQL, and per-tenant analytics...
data-ai
Design AI-powered analytics dashboards — what metrics to show, how to display AI predictions and confidence, drill-down patterns, KPI cards, trend visualisation, AI Insights panels, export design, and role-based dashboard variants. Invoke when...
development
Use when designing, building, reviewing, or upgrading production software systems that must be secure, performant, maintainable, scalable, and user-centered. Apply before writing specs, code, architecture, APIs, databases, mobile apps, SaaS platforms, or ERP systems.
development
Professional web app UI using commercial templates (Tabler/Bootstrap 5) with strong frontend design direction when needed. Use for CRUD interfaces, dashboards, admin panels with SweetAlert2, DataTables, Flatpickr. Clone seeder-page.php, use...