All skills
rshankras avatar

/push-notifications

@32566aa

Generate push notification infrastructure with APNs registration, handling, and rich notifications. Use when adding push notifications, configuring APNs, notification categories/actions, or rich notifications with images and custom UI.

Use this Skill: https://skilld.dev/gh/rshankras/claude-code-apple-skills/push-notifications

This session only. Nothing lands on disk.

push-notification-patterns.md

≈5k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Push Notification Patterns

Best practices for implementing push notifications in iOS/macOS apps.

Registration Flow

Complete Registration Sequence

import UserNotifications
import UIKit

@MainActor
@Observable
final class NotificationManager {
    static let shared = NotificationManager()

    private(set) var authorizationStatus: UNAuthorizationStatus = .notDetermined
    private(set) var deviceToken: String?

    private init() {}

    // MARK: - Authorization

    /// Request notification authorization.
    func requestAuthorization() async throws -> Bool {
        let center = UNUserNotificationCenter.current()

        let options: UNAuthorizationOptions = [
            .alert,
            .badge,
            .sound,
            .providesAppNotificationSettings  // iOS 15.4+
        ]

        let granted = try await center.requestAuthorization(options: options)

        // Update status
        authorizationStatus = try await center.notificationSettings().authorizationStatus

        return granted
    }

    /// Check current authorization status.
    func checkAuthorizationStatus() async -> UNAuthorizationStatus {
        let settings = await UNUserNotificationCenter.current().notificationSettings()
        authorizationStatus = settings.authorizationStatus
        return authorizationStatus
    }

    // MARK: - Registration

    /// Register for remote notifications.
    func registerForRemoteNotifications() {
        UIApplication.shared.registerForRemoteNotifications()
    }

    /// Unregister from remote notifications.
    func unregisterForRemoteNotifications() {
        UIApplication.shared.unregisterForRemoteNotifications()
        deviceToken = nil
    }

    /// Handle successful registration.
    func didRegister(with deviceToken: Data) {
        let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
        self.deviceToken = token
        print("📱 Device token: \(token)")

        // Send to your server
        Task {
            await sendTokenToServer(token)
        }
    }

    /// Handle registration failure.
    func didFailToRegister(with error: Error) {
        print("❌ Failed to register: \(error.localizedDescription)")
    }

    private func sendTokenToServer(_ token: String) async {
        // TODO: Implement server token upload
        // await APIClient.shared.registerDeviceToken(token)
    }

    // MARK: - Badge Management

    /// Clear the app badge.
    func clearBadge() async {
        try? await UNUserNotificationCenter.current().setBadgeCount(0)
    }

    /// Set badge count.
    func setBadge(_ count: Int) async {
        try? await UNUserNotificationCenter.current().setBadgeCount(count)
    }
}

Notification Delegate

Complete Implementation

import UserNotifications

final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, @unchecked Sendable {
    static let shared = NotificationDelegate()

    private override init() {
        super.init()
    }

    // MARK: - Foreground Notifications

    /// Called when notification arrives while app is in foreground.
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification
    ) async -> UNNotificationPresentationOptions {

        let userInfo = notification.request.content.userInfo

        // Log or process the notification
        print("📬 Received notification in foreground: \(userInfo)")

        // Parse payload
        if let payload = NotificationPayload(userInfo: userInfo) {
            await handlePayload(payload, inForeground: true)
        }

        // Return presentation options
        // Customize based on notification type
        return [.banner, .sound, .badge, .list]
    }

    // MARK: - Notification Response

    /// Called when user interacts with notification.
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse
    ) async {

        let userInfo = response.notification.request.content.userInfo
        let actionIdentifier = response.actionIdentifier

        print("📬 User responded to notification: \(actionIdentifier)")

        // Handle based on action
        switch actionIdentifier {
        case UNNotificationDefaultActionIdentifier:
            // User tapped notification
            if let payload = NotificationPayload(userInfo: userInfo) {
                await handlePayload(payload, inForeground: false)
            }

        case UNNotificationDismissActionIdentifier:
            // User dismissed notification
            break

        default:
            // Custom action
            await handleCustomAction(actionIdentifier, userInfo: userInfo)
        }
    }

    // MARK: - Settings

    /// Called when user wants to manage notification settings from the notification.
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        openSettingsFor notification: UNNotification?
    ) {
        // Open app's notification settings
        NotificationCenter.default.post(
            name: .openNotificationSettings,
            object: nil
        )
    }

    // MARK: - Handlers

    private func handlePayload(_ payload: NotificationPayload, inForeground: Bool) async {
        switch payload.type {
        case .message:
            // Navigate to message
            await MainActor.run {
                NotificationCenter.default.post(
                    name: .navigateToMessage,
                    object: nil,
                    userInfo: ["messageId": payload.resourceId ?? ""]
                )
            }

        case .reminder:
            // Handle reminder
            break

        case .update:
            // Handle update notification
            break

        case .unknown:
            break
        }
    }

    private func handleCustomAction(_ identifier: String, userInfo: [AnyHashable: Any]) async {
        switch identifier {
        case "REPLY_ACTION":
            // Handle reply
            break

        case "MARK_READ_ACTION":
            // Mark as read
            break

        case "COMPLETE_ACTION":
            // Complete task
            break

        case "SNOOZE_ACTION":
            // Snooze reminder
            await scheduleSnooze(userInfo: userInfo)

        default:
            print("Unknown action: \(identifier)")
        }
    }

    private func scheduleSnooze(userInfo: [AnyHashable: Any]) async {
        // Re-schedule notification for later
        let content = UNMutableNotificationContent()
        content.title = userInfo["title"] as? String ?? "Reminder"
        content.body = userInfo["body"] as? String ?? ""
        content.sound = .default
        content.userInfo = userInfo

        let trigger = UNTimeIntervalNotificationTrigger(
            timeInterval: 15 * 60,  // 15 minutes
            repeats: false
        )

        let request = UNNotificationRequest(
            identifier: UUID().uuidString,
            content: content,
            trigger: trigger
        )

        try? await UNUserNotificationCenter.current().add(request)
    }
}

// MARK: - Notification Names

extension Notification.Name {
    static let openNotificationSettings = Notification.Name("openNotificationSettings")
    static let navigateToMessage = Notification.Name("navigateToMessage")
}

Notification Categories

Category Registration

import UserNotifications

enum NotificationCategories {

    // MARK: - Category Identifiers

    enum Identifier: String {
        case message = "MESSAGE_CATEGORY"
        case reminder = "REMINDER_CATEGORY"
        case update = "UPDATE_CATEGORY"
    }

    // MARK: - Action Identifiers

    enum Action: String {
        // Message actions
        case reply = "REPLY_ACTION"
        case markRead = "MARK_READ_ACTION"

        // Reminder actions
        case complete = "COMPLETE_ACTION"
        case snooze = "SNOOZE_ACTION"

        // Update actions
        case viewUpdate = "VIEW_UPDATE_ACTION"
        case dismiss = "DISMISS_ACTION"
    }

    // MARK: - Registration

    static func registerAll() {
        let categories: Set<UNNotificationCategory> = [
            messageCategory,
            reminderCategory,
            updateCategory
        ]

        UNUserNotificationCenter.current().setNotificationCategories(categories)
    }

    // MARK: - Category Definitions

    private static var messageCategory: UNNotificationCategory {
        let replyAction = UNNotificationAction(
            identifier: Action.reply.rawValue,
            title: "Reply",
            options: [.foreground]
        )

        let markReadAction = UNNotificationAction(
            identifier: Action.markRead.rawValue,
            title: "Mark as Read",
            options: []
        )

        return UNNotificationCategory(
            identifier: Identifier.message.rawValue,
            actions: [replyAction, markReadAction],
            intentIdentifiers: [],
            options: [.customDismissAction]
        )
    }

    private static var reminderCategory: UNNotificationCategory {
        let completeAction = UNNotificationAction(
            identifier: Action.complete.rawValue,
            title: "Complete",
            options: [.foreground]
        )

        let snoozeAction = UNNotificationAction(
            identifier: Action.snooze.rawValue,
            title: "Snooze 15 min",
            options: []
        )

        return UNNotificationCategory(
            identifier: Identifier.reminder.rawValue,
            actions: [completeAction, snoozeAction],
            intentIdentifiers: [],
            options: []
        )
    }

    private static var updateCategory: UNNotificationCategory {
        let viewAction = UNNotificationAction(
            identifier: Action.viewUpdate.rawValue,
            title: "View",
            options: [.foreground]
        )

        return UNNotificationCategory(
            identifier: Identifier.update.rawValue,
            actions: [viewAction],
            intentIdentifiers: [],
            options: []
        )
    }
}

Payload Parsing

Type-Safe Payload

import Foundation

/// Parsed notification payload.
struct NotificationPayload {
    let type: NotificationType
    let title: String?
    let body: String?
    let resourceId: String?
    let deepLink: URL?
    let imageURL: URL?
    let extra: [String: Any]

    enum NotificationType: String {
        case message
        case reminder
        case update
        case unknown
    }

    init?(userInfo: [AnyHashable: Any]) {
        // Parse APS
        guard let aps = userInfo["aps"] as? [String: Any] else {
            return nil
        }

        // Parse alert
        if let alert = aps["alert"] as? [String: Any] {
            title = alert["title"] as? String
            body = alert["body"] as? String
        } else if let alert = aps["alert"] as? String {
            title = nil
            body = alert
        } else {
            title = nil
            body = nil
        }

        // Parse custom fields
        let typeString = userInfo["type"] as? String ?? "unknown"
        type = NotificationType(rawValue: typeString) ?? .unknown

        resourceId = userInfo["resource_id"] as? String

        if let deepLinkString = userInfo["deep_link"] as? String {
            deepLink = URL(string: deepLinkString)
        } else {
            deepLink = nil
        }

        if let imageString = userInfo["image_url"] as? String {
            imageURL = URL(string: imageString)
        } else {
            imageURL = nil
        }

        // Store extra fields
        var extra: [String: Any] = [:]
        for (key, value) in userInfo {
            if let key = key as? String,
               key != "aps" && key != "type" && key != "resource_id" &&
               key != "deep_link" && key != "image_url" {
                extra[key] = value
            }
        }
        self.extra = extra
    }
}

// MARK: - Expected Payload Format

/*
 Server should send payloads in this format:

 {
   "aps": {
     "alert": {
       "title": "New Message",
       "body": "You have a new message from John"
     },
     "badge": 1,
     "sound": "default",
     "category": "MESSAGE_CATEGORY",
     "mutable-content": 1  // Required for Notification Service Extension
   },
   "type": "message",
   "resource_id": "msg_12345",
   "deep_link": "myapp://messages/msg_12345",
   "image_url": "https://example.com/image.jpg"
 }
 */

Rich Notifications

Notification Service Extension

import UserNotifications

class NotificationService: UNNotificationServiceExtension {

    private var contentHandler: ((UNNotificationContent) -> Void)?
    private var bestAttemptContent: UNMutableNotificationContent?

    override func didReceive(
        _ request: UNNotificationRequest,
        withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
    ) {
        self.contentHandler = contentHandler
        bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)

        guard let bestAttemptContent else {
            contentHandler(request.content)
            return
        }

        // Download and attach image if URL provided
        if let imageURLString = request.content.userInfo["image_url"] as? String,
           let imageURL = URL(string: imageURLString) {
            downloadAndAttachImage(from: imageURL, to: bestAttemptContent) {
                contentHandler(bestAttemptContent)
            }
        } else {
            contentHandler(bestAttemptContent)
        }
    }

    override func serviceExtensionTimeWillExpire() {
        // Deliver best attempt if time expires
        if let contentHandler, let bestAttemptContent {
            contentHandler(bestAttemptContent)
        }
    }

    private func downloadAndAttachImage(
        from url: URL,
        to content: UNMutableNotificationContent,
        completion: @escaping () -> Void
    ) {
        let task = URLSession.shared.downloadTask(with: url) { localURL, _, error in
            defer { completion() }

            guard let localURL, error == nil else { return }

            // Move to accessible location
            let fileManager = FileManager.default
            let tmpDir = fileManager.temporaryDirectory
            let fileName = url.lastPathComponent
            let destinationURL = tmpDir.appendingPathComponent(fileName)

            try? fileManager.removeItem(at: destinationURL)
            try? fileManager.moveItem(at: localURL, to: destinationURL)

            // Create attachment
            if let attachment = try? UNNotificationAttachment(
                identifier: "image",
                url: destinationURL,
                options: nil
            ) {
                content.attachments = [attachment]
            }
        }
        task.resume()
    }
}

Silent Notifications

Handling Background Updates

// In AppDelegate
func application(
    _ application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable: Any]
) async -> UIBackgroundFetchResult {

    // Check if silent notification
    guard let aps = userInfo["aps"] as? [String: Any],
          aps["content-available"] as? Int == 1 else {
        return .noData
    }

    do {
        // Perform background work
        let updated = try await performBackgroundUpdate(userInfo: userInfo)
        return updated ? .newData : .noData
    } catch {
        return .failed
    }
}

private func performBackgroundUpdate(userInfo: [AnyHashable: Any]) async throws -> Bool {
    // Example: Sync data
    // let syncManager = SyncManager.shared
    // return try await syncManager.syncIfNeeded()
    return true
}

Silent Notification Payload

{
  "aps": {
    "content-available": 1
  },
  "sync_type": "messages",
  "timestamp": "2024-01-15T10:30:00Z"
}

Local Notifications

Scheduling

extension NotificationManager {

    /// Schedule a local notification.
    func scheduleLocalNotification(
        title: String,
        body: String,
        at date: Date,
        category: NotificationCategories.Identifier? = nil,
        userInfo: [String: Any] = [:]
    ) async throws -> String {

        let content = UNMutableNotificationContent()
        content.title = title
        content.body = body
        content.sound = .default
        content.userInfo = userInfo

        if let category {
            content.categoryIdentifier = category.rawValue
        }

        let components = Calendar.current.dateComponents(
            [.year, .month, .day, .hour, .minute],
            from: date
        )
        let trigger = UNCalendarNotificationTrigger(
            dateMatching: components,
            repeats: false
        )

        let identifier = UUID().uuidString
        let request = UNNotificationRequest(
            identifier: identifier,
            content: content,
            trigger: trigger
        )

        try await UNUserNotificationCenter.current().add(request)
        return identifier
    }

    /// Cancel a scheduled notification.
    func cancelNotification(identifier: String) {
        UNUserNotificationCenter.current()
            .removePendingNotificationRequests(withIdentifiers: [identifier])
    }

    /// Get all pending notifications.
    func getPendingNotifications() async -> [UNNotificationRequest] {
        await UNUserNotificationCenter.current().pendingNotificationRequests()
    }
}

App Delegate Integration

Complete Setup

import UIKit
import UserNotifications

class AppDelegate: NSObject, UIApplicationDelegate {

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {

        // Set delegate
        UNUserNotificationCenter.current().delegate = NotificationDelegate.shared

        // Register categories
        NotificationCategories.registerAll()

        // Check if launched from notification
        if let notification = launchOptions?[.remoteNotification] as? [AnyHashable: Any] {
            handleLaunchNotification(notification)
        }

        return true
    }

    // MARK: - Remote Notification Registration

    func application(
        _ application: UIApplication,
        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
    ) {
        NotificationManager.shared.didRegister(with: deviceToken)
    }

    func application(
        _ application: UIApplication,
        didFailToRegisterForRemoteNotificationsWithError error: Error
    ) {
        NotificationManager.shared.didFailToRegister(with: error)
    }

    // MARK: - Background Notifications

    func application(
        _ application: UIApplication,
        didReceiveRemoteNotification userInfo: [AnyHashable: Any]
    ) async -> UIBackgroundFetchResult {
        // Handle silent notifications
        return await handleSilentNotification(userInfo)
    }

    // MARK: - Helpers

    private func handleLaunchNotification(_ userInfo: [AnyHashable: Any]) {
        if let payload = NotificationPayload(userInfo: userInfo) {
            // App was launched from notification
            // Handle deep link or navigation
        }
    }

    private func handleSilentNotification(_ userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
        guard let aps = userInfo["aps"] as? [String: Any],
              aps["content-available"] as? Int == 1 else {
            return .noData
        }

        // Perform background work
        return .newData
    }
}

Source: SKILL.md on GitHub

1 warning2mo3 checks · Risk SAFE
  • Gen Agent Trust Hub2mo

    The skill provides templates and guidance for implementing push notification infrastructure on iOS and macOS platforms. It includes standard components for APNs registration, notification delegates, and payload parsing. All external references are to official Apple domains, and no malicious patterns or vulnerabilities were identified.

  • Socket2mo

    No alerts

  • Snyk2mo

    Risk: MEDIUM · 1 issue

Signed by skilld at 32566aa. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Steadyupdated 3 months ago
What it can do
Reads files Edits files Runs commands
last_verified
2026-07-16
review_by
2027-06-22
os_version
iOS 27 / macOS 27
All 7 allowed tools
ReadWriteEditGlobGrepBashAskUserQuestion

README badge

README badge for rshankras/claude-code-apple-skills/push-notifications