mcpField
P
push-notifications

push-notifications

github.com/dpearson2699開発1.2k4.2k 回インストール13 週間前に更新
リポジトリ

push-notifications について

iOSおよびmacOS開発者向けのプッシュ通知スキルです。ローカル通知、リモート通知、APNsの実装、レビュー、デバッグを扱い、権限やトークン登録からペイロード、操作、グループ化、リッチ通知まで説明します。

主な機能

  1. 01通知権限と状態の確認
  2. 02APNsトークン登録の処理
  3. 03ローカル通知のスケジュール
  4. 04リモート通知とサイレントプッシュのペイロード
  5. 05通知アクションとカテゴリ
  6. 06リッチ通知とグループ化

活用シーン

  1. 01端末内のリマインダーや予約通知の実装
  2. 02APNsトークン更新の処理
  3. 03バックグラウンドプッシュ設定の問題調査
  4. 04通知アクションとグループ化の追加

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/dpearson2699/swift-ios-skills --skill push-notifications

インストール後、Claude Code で/push-notificationsを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

evals/
evals.json
references/
notification-patterns.md
rich-notifications.md

詳細

プッシュ通知

UserNotifications と APNs を使用して、iOS/macOS のローカル通知とリモート通知を実装、レビュー、デバッグします。許可のフロー、トークン登録、ペイロードの構造、フォアグラウンドでの処理、通知アクション、グループ化、リッチ通知を扱います。iOS 26 以降を対象とし、特に記載がない限り iOS 16 まで後方互換性があります。

関連する別の領域は分けて扱います。Live Activity の content-state ペイロードは activitykit、PushKit/VoIP の通話プッシュは callkit、App Clip の一時的な通知設定は app-clips、サイレントプッシュ後の長時間実行またはスケジュール済みのバックグラウンド処理は background-processing に属します。

目次

修正レビュー

問題のある通知の提案をレビューする際は、違反している契約を明確に示してください。APNs トークンのレビューでは、トークン登録はアラートの許可とは独立していること、すべての didRegister コールバックでアップロードすること、ローカルキャッシュを正しい情報源として扱わないこと、トークンの長さを決めつけないこと、Simulator での登録失敗は想定内である一方、.apns ファイルや simctl push で配信をシミュレートできることを明記してください。バックグラウンドプッシュのレビューでは、content-available のみを使うこと、apns-push-type: background、apns-priority: 5、Remote notifications のバックグラウンドモード、優先度が低いこと、スロットリングされること、保証されないこと、数分おきに届くものではないこと、適切な UIBackgroundFetchResult を返す制限時間内の didReceiveRemoteNotification を明記してください。リッチ通知のレビューでは、サービス拡張に mutable-content: 1 とアラートペイロードの両方が必要であること、サイレントプッシュではサービス拡張が起動しないこと、添付ファイルにはシステムが検証して保存する、ディスク上のサポート対象ファイルを使うこと、シークレットには Keychain Sharing を使い、App Groups は共有ファイル/UserDefaults に使うこと、コミュニケーション通知には capability + NSUserActivityTypes + INInteraction の提供 + content.updating(from:) が必要であること、添付ファイルやダウンロードの失敗を含むサービス拡張のすべての経路で、元のコンテンツ、最善努力で作成したコンテンツ、または更新済みコンテンツを使って、コンテンツハンドラーを正確に 1 回呼び出すこと、さらに serviceExtensionTimeWillExpire() でも同様に呼び出すことを明記してください。

許可のフロー

ユーザーに表示されるアラート、サウンド、バッジをスケジュールまたは表示する前に、通知の許可をリクエストしてください。システムのプロンプトが表示されるのは一度だけです。その後の呼び出しでは保存済みの決定が返されます。APNs トークンの登録は別途行います。アプリでデバイストークンが必要になったら、ユーザーがアラートの許可を与えていなくても registerForRemoteNotifications() を呼び出してください。

import UserNotifications

@MainActor
func requestNotificationPermission() async -> Bool {
    let center = UNUserNotificationCenter.current()
    do {
        let granted = try await center.requestAuthorization(
            options: [.alert, .sound, .badge]
        )
        return granted
    } catch {
        print("Authorization request failed: \(error)")
        return false
    }
}
現在のステータスを確認する

許可があると決めつける前に、必ずステータスを確認してください。ユーザーはいつでも設定を変更できます。

@MainActor
func checkNotificationStatus() async -> UNAuthorizationStatus {
    let settings = await UNUserNotificationCenter.current().notificationSettings()
    return settings.authorizationStatus
    // .notDetermined, .denied, .authorized, .provisional, .ephemeral
}
暫定的な通知

暫定的な通知は、ユーザーの操作を妨げることなく通知センターに静かに配信されます。その後、ユーザーは通知を受け取り続けるか、オフにするかを選べます。完全な許可を求める前に価値を示したいオンボーディングのフローで使用してください。

// Delivers silently -- no permission prompt shown to the user
try await center.requestAuthorization(options: [.alert, .sound, .badge, .provisional])
重大な通知

重大な通知は「おやすみモード」と消音スイッチを迂回します。Apple から特別なエンタイトルメントを取得する必要があります(Developer Portal から申請してください)。健康、安全、またはセキュリティに関わる場合に限って使用してください。

// Requires com.apple.developer.usernotifications.critical-alerts entitlement
try await center.requestAuthorization(
    options: [.alert, .sound, .badge, .criticalAlert]
)
拒否された許可への対応

ユーザーが通知を拒否した場合は、UIApplication.openSettingsURLString を使って「設定」へ誘導してください。繰り返しプロンプトを表示したり、しつこく促したりしないでください。

APNs 登録

SwiftUI アプリでデバイストークンを受け取るには、UIApplicationDelegateAdaptor を使用してください。APNs トークンを受け取る方法は AppDelegate のコールバックのみです。

@main
struct MyApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
    ) -> Bool {
        UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
        return true
    }

    func application(
        _ application: UIApplication,
        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
    ) {
        let token = deviceToken.map { String(format: "%02x", $0) }.joined()
        print("APNs token: \(token)")
        // Send token to your server
        Task { await TokenService.shared.upload(token: token) }
    }

    func application(
        _ application: UIApplication,
        didFailToRegisterForRemoteNotificationsWithError error: Error
    ) {
        print("APNs registration failed: \(error.localizedDescription)")
        // Simulator can simulate pushes, but it does not register with APNs.
    }
}
登録の順序

起動時にデリゲートとカテゴリを設定します。その後、通知を画面に表示するためのユーザー通知の許可を、適切なタイミングでリクエストし、アプリがデバイストークンを必要とするたびに APNs に登録します。.authorized を条件に APNs への登録を制限しないでください。アラートの許可がなくても、リモート通知はサイレントで配信されます。

@MainActor
func configureNotifications() async {
    let center = UNUserNotificationCenter.current()
    let settings = await center.notificationSettings()

    if settings.authorizationStatus == .notDetermined {
        _ = await requestNotificationPermission()
    }

    // Needed for APNs token delivery and silent remote notifications.
    UIApplication.shared.registerForRemoteNotifications()
}
トークンの処理

デバイストークンは変更されます。トークンを初めて受信したときだけでなく、didRegisterForRemoteNotificationsWithDeviceToken が呼び出されるたびに、サーバーへトークンを再送信してください。ローカルに保存したトークンを正しい情報源として扱ったり、トークンの長さが一定だと想定したりしないでください。

ローカル通知

サーバーを使わず、デバイスから直接通知をスケジュールします。リマインダー、タイマー、位置情報に基づくアラートに便利です。

コンテンツの作成
let content = UNMutableNotificationContent()
content.title = "Workout Reminder"
content.subtitle = "Time to move"
content.body = "You have a scheduled workout in 15 minutes."
content.sound = .default
content.badge = NSNumber(value: 1)
content.userInfo = ["workoutId": "abc123"]
content.threadIdentifier = "workouts"  // groups in notification center
トリガーの種類
// Fire after a time interval (minimum 60 seconds for repeating)
let timeTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 300, repeats: false)

// Fire at a specific date/time
var dateComponents = DateComponents()
dateComponents.hour = 8
dateComponents.minute = 30
let calendarTrigger = UNCalendarNotificationTrigger(
    dateMatching: dateComponents, repeats: true  // daily at 8:30 AM
)

// Fire when entering a geographic region
let region = CLCircularRegion(
    center: CLLocationCoordinate2D(latitude: 37.33, longitude: -122.01),
    radius: 100,
    identifier: "gym"
)
region.notifyOnEntry = true
region.notifyOnExit = false
let locationTrigger = UNLocationNotificationTrigger(region: region, repeats: false)
// Requires "When In Use" location permission at minimum
スケジュール設定と管理
let request = UNNotificationRequest(
    identifier: "workout-reminder-abc123",
    content: content,
    trigger: timeTrigger
)

let center = UNUserNotificationCenter.current()
try await center.add(request)

// Remove specific pending notifications
center.removePendingNotificationRequests(withIdentifiers: ["workout-reminder-abc123"])

// Remove all pending
center.removeAllPendingNotificationRequests()

// Remove delivered notifications from notification center
center.removeDeliveredNotifications(withIdentifiers: ["workout-reminder-abc123"])
center.removeAllDeliveredNotifications()

// List all pending requests
let pending = await center.pendingNotificationRequests()

リモート通知のペイロード

APNs の標準ペイロード
{
    "aps": {
        "alert": {
            "title": "New Message",
            "subtitle": "From Alice",
            "body": "Hey, are you free for lunch?"
        },
        "badge": 3,
        "sound": "default",
        "thread-id": "chat-alice",
        "category": "MESSAGE_CATEGORY"
    },
    "messageId": "msg-789",
    "senderId": "user-alice"
}
サイレント/バックグラウンドプッシュ

アラート、サウンド、バッジを含めずに content-available: 1 を設定します。「Background Modes > Remote notifications」と APNs ヘッダー apns-push-type: background および apns-priority: 5 が必要です。システムはこれらの通知を低優先度として扱い、配信を抑制する場合があり、配信も保証されません。数分おきに送信したり、即時の最新状態の反映を期待したりしないでください。didReceiveRemoteNotification では、バックグラウンド実行時間内に、UIBackgroundFetchResult を速やかに返せる範囲の処理を行ってください。

{
    "aps": {
        "content-available": 1
    },
    "updateType": "new-data"
}

AppDelegate で処理します:

func application(
    _ application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable: Any]
) async -> UIBackgroundFetchResult {
    guard let updateType = userInfo["updateType"] as? String else {
        return .noData
    }
    do {
        try await DataSyncService.shared.sync(trigger: updateType)
        return .newData
    } catch {
        return .failed
    }
}
可変コンテンツ

mutable-content: 1 と alert ディクショナリを設定すると、Notification Service Extension で、アラートを表示するリモート通知を表示前に変更できます。サイレントプッシュではサービス拡張機能は起動しません。サービス拡張機能は、ディスクへの保存に対応した添付ファイルのダウンロード、表示テキストの復号、コミュニケーション通知の設定など、範囲を限定した処理に使用します。成功、失敗、タイムアウトのいずれの場合も、コンテンツハンドラーを呼び出してください。コミュニケーション通知では、機能を有効にして NSUserActivityTypes を追加し、INInteraction を寄付してから、content.updating(from:) を呼び出します。

{
    "aps": {
        "alert": { "title": "Photo", "body": "Alice sent a photo" },
        "mutable-content": 1
    },
    "imageUrl": "https://example.com/photo.jpg"
}
ローカライズされた通知

ローカライズキーを使用すると、通知をユーザーの言語で表示できます。

{
    "aps": {
        "alert": {
            "title-loc-key": "NEW_MESSAGE_TITLE",
            "loc-key": "NEW_MESSAGE_BODY",
            "loc-args": ["Alice"]
        }
    }
}

通知の処理

UNUserNotificationCenterDelegate

デリゲートを実装して、フォアグラウンドでの表示を制御し、ユーザーのタップを処理します。デリゲートはできるだけ早く、application(_:didFinishLaunchingWithOptions:) または App.init で設定してください。

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

    // Called when notification arrives while app is in FOREGROUND
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification
    ) async -> UNNotificationPresentationOptions {
        // Return which presentation elements to show
        // Without this, foreground notifications are silently suppressed
        return [.banner, .sound, .badge]
    }

    // Called when user TAPS the notification
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse
    ) async {
        let userInfo = response.notification.request.content.userInfo
        let actionIdentifier = response.actionIdentifier

        switch actionIdentifier {
        case UNNotificationDefaultActionIdentifier:
            // User tapped the notification body
            await handleNotificationTap(userInfo: userInfo)
        case UNNotificationDismissActionIdentifier:
            // User dismissed the notification
            break
        default:
            // Custom action button tapped
            await handleCustomAction(actionIdentifier, userInfo: userInfo)
        }
    }
}
通知からのディープリンク

共有の @Observable ルーターを使って、通知のタップを適切な画面にルーティングします。デリゲートが遷移先を保留状態にし、SwiftUI ビューがその状態を監視して処理します。

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

    var pendingDestination: AppDestination?
}

// In NotificationDelegate:
func handleNotificationTap(userInfo: [AnyHashable: Any]) async {
    guard let id = userInfo["messageId"] as? String else { return }
    DeepLinkRouter.shared.pendingDestination = .chat(id: id)
}

// In SwiftUI -- observe and consume:
.onChange(of: router.pendingDestination) { _, destination in
    if let destination {
        path.append(destination)
        router.pendingDestination = nil
    }
}

タブ切り替えを含むディープリンクハンドラーの全容については、references/notification-patterns.md を参照してください。

通知のアクションとカテゴリ

通知上にボタンとして表示される、操作可能なアクションを定義します。起動時にカテゴリを登録してください。

カテゴリとアクションの定義
func registerNotificationCategories() {
    let replyAction = UNTextInputNotificationAction(
        identifier: "REPLY_ACTION",
        title: "Reply",
        options: [],
        textInputButtonTitle: "Send",
        textInputPlaceholder: "Type a reply..."
    )

    let likeAction = UNNotificationAction(
        identifier: "LIKE_ACTION",
        title: "Like",
        options: []
    )

    let deleteAction = UNNotificationAction(
        identifier: "DELETE_ACTION",
        title: "Delete",
        options: [.destructive, .authenticationRequired]
    )

    let messageCategory = UNNotificationCategory(
        identifier: "MESSAGE_CATEGORY",
        actions: [replyAction, likeAction, deleteAction],
        intentIdentifiers: [],
        options: [.customDismissAction]  // fires didReceive on dismiss too
    )

    UNUserNotificationCenter.current().setNotificationCategories([messageCategory])
}
アクションへの応答処理
func handleCustomAction(_ identifier: String, userInfo: [AnyHashable: Any]) async {
    switch identifier {
    case "REPLY_ACTION":
        // response is UNTextInputNotificationResponse for text input actions
        break
    case "LIKE_ACTION":
        guard let messageId = userInfo["messageId"] as? String else { return }
        await MessageService.shared.likeMessage(id: messageId)
    case "DELETE_ACTION":
        guard let messageId = userInfo["messageId"] as? String else { return }
        await MessageService.shared.deleteMessage(id: messageId)
    default:
        break
    }
}

アクションのオプション:

  • .authenticationRequired -- アクションの実行にはデバイスのロック解除が必要です
  • .destructive -- 赤色で表示されます。削除や除去のアクションに使用します
  • .foreground -- タップするとアプリをフォアグラウンドで起動します

通知のグループ化

threadIdentifier(または APNs ペイロード内の thread-id)で関連する通知をグループ化します。一意のスレッドごとに、通知センター内で個別のグループが作成されます。

content.threadIdentifier = "chat-alice"  // all messages from Alice group together
content.summaryArgument = "Alice"
content.summaryArgumentCount = 3         // "3 more notifications from Alice"

カテゴリ内の概要形式文字列をカスタマイズします。

let category = UNNotificationCategory(
    identifier: "MESSAGE_CATEGORY",
    actions: [replyAction],
    intentIdentifiers: [],
    categorySummaryFormat: "%u more messages from %@",
    options: []
)

よくある間違い

してはいけないこと: アプリでサイレントプッシュやサーバーとのトークン紐付けが必要な場合に、アラートの許可を条件として APNs トークンの登録を制限します。 すること: アラート、サウンド、バッジの許可をリクエストし、デバイストークンが必要な場合は APNs に登録します。 してはいけないこと: デバイストークンを String(data: deviceToken, encoding: .utf8) で変換します。 すること: 16 進数を使用します:deviceToken.map { String(format: "%02x", $0) }.joined()。 してはいけないこと: 数分ごとのサイレント更新や、バックグラウンドでの即時配信を約束します。 すること: バックグラウンドプッシュは優先度が低く、スロットリングされ、配信が保証されず、実際には 1 時間あたり数回に制限されること、また、処理時間を制限した didReceiveRemoteNotification の処理を行い、適切な UIBackgroundFetchResult を返す必要があることを伝えます。 してはいけないこと: サイレントプッシュで Notification Service Extension が実行されると想定したり、コンテンツハンドラーを呼び出さずにエクステンションを終了したりします。 すること: アラートペイロードとともに mutable-content: 1 を使用し、システムが検証して保存する、サポート対象のディスク上の添付ファイルを使います。通信通知には INInteraction の寄付と content.updating(from:) を使用し、成功、失敗、タイムアウトのすべての経路で元のコンテンツまたは可能な限りのコンテンツを返します。 してはいけないこと: フォアグラウンドでの処理を忘れます。willPresent がないと、通知は表示されずに抑制されます。 すること: willPresent を実装し、.banner、.sound、.badge を返します。 してはいけないこと: デリゲートの設定が遅すぎたり、AppDelegate アダプターなしで SwiftUI ビューから登録したりします。 すること: App.init でデリゲートを設定し、APNs には UIApplicationDelegateAdaptor を使用します。 してはいけないこと: APNs トークンを「変更された」ときだけアップロードしたり、トークンの長さが固定だと想定したりします。すること: didRegister のコールバックのたびにアップロードし、トークンは 16 進数に変換した不透明なデータとして扱います。 してはいけないこと: Live Activity、VoIP、App Clip 固有の通知ルールをここに記載します。すること: それらは activitykit、callkit、app-clips に案内します。

レビューチェックリスト

  • 表示するアラート、サウンド、バッジの前に許可をリクエストし、拒否された場合の処理(設定へのリンク)を実装している
  • アラートの許可状態によって APNs 登録が誤ってブロックされていない
  • デバイストークンを 16 進数に変換し、コールバックのたびにアップロードしており、ローカルにキャッシュされた定数や固定長の定数として扱っていない
  • UNUserNotificationCenterDelegate を App.init または application(_:didFinishLaunching:) で設定している
  • フォアグラウンド(willPresent)とタップ(didReceive)の処理を実装している
  • インタラクティブな通知が必要な場合、起動時にカテゴリとアクションを登録している
  • サイレントプッシュで content-available: 1 を使用し、アラート/サウンド/バッジを含めず、apns-push-type: background、apns-priority: 5、Background Modes > Remote notifications を設定し、スロットリングに関する注意事項を踏まえ、適切な UIBackgroundFetchResult を返している

参考資料

コメント