diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..89d2161
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,70 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ linux:
+ name: Linux (Swift ${{ matrix.swift }}, ${{ matrix.configuration }})
+ runs-on: ubuntu-24.04
+ container: swift:${{ matrix.swift }}-noble
+ timeout-minutes: 30
+ env:
+ # Swift's backtracer adds minutes to a crashing test run in a container.
+ SWIFT_BACKTRACE: enable=no
+ strategy:
+ fail-fast: false
+ matrix:
+ # The oldest supported Swift (swift-tools-version 6.2) and the newest.
+ swift: ["6.2", "6.4"]
+ configuration: [debug, release]
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Toolchain
+ run: swift --version
+
+ - name: Build (warnings are errors)
+ run: |
+ flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors"
+ # @testable imports need testing enabled in an optimized build.
+ [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing"
+ echo "FLAGS=$flags" >> "$GITHUB_ENV"
+ swift build --build-tests $flags
+
+ # The tests answer every request with a URLProtocol stub; nothing reaches APNs.
+ - name: Test
+ run: swift test $FLAGS
+
+ macos:
+ name: macOS (Xcode ${{ matrix.xcode }}, ${{ matrix.configuration }})
+ runs-on: macos-26
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ # The oldest Xcode with Swift 6.2, and the runner's default.
+ xcode: ["26.0.1", "26.6"]
+ configuration: [debug, release]
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Select Xcode
+ run: |
+ sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
+ swift --version
+
+ - name: Build (warnings are errors)
+ run: |
+ flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors"
+ # @testable imports need testing enabled in an optimized build.
+ [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing"
+ echo "FLAGS=$flags" >> "$GITHUB_ENV"
+ swift build --build-tests $flags
+
+ - name: Test
+ run: swift test $FLAGS
diff --git a/Package.swift b/Package.swift
index b8d9245..a8f5c71 100644
--- a/Package.swift
+++ b/Package.swift
@@ -8,7 +8,7 @@ let package = Package(
.library(name: "PerfectNotifications", targets: ["PerfectNotifications"])
],
dependencies: [
- .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0")
+ .package(url: "https://github.com/apple/swift-crypto.git", "4.0.0"..<"5.0.0")
],
targets: [
.target(
diff --git a/README.md b/README.md
index 99d6bb5..426fddc 100644
--- a/README.md
+++ b/README.md
@@ -1,252 +1,290 @@
# Perfect-Notifications
-
-
+
+
-APNs remote Notifications for Perfect. This package adds push notification support to your server. Send notifications to iOS/macOS devices.
+An Apple Push Notification service (APNs) provider client for Swift servers. It sends
+notifications over the APNs HTTP/2 provider API with token-based (`.p8` key, ES256 JWT)
+authentication, using Foundation's `URLSession`. The only dependency is
+[apple/swift-crypto](https://github.com/apple/swift-crypto), for the JWT signature.
-**A from-scratch Swift 6 rewrite** of the original package. The public API (`APNSClient`) is a
-completely different, modern async/await surface — it does not use the original
-`NotificationPusher` class. No dependency on NIO, PerfectHTTP, or any other package here —
-networking runs over Foundation's `URLSession` async API, with
-[apple/swift-crypto](https://github.com/apple/swift-crypto) as the only external dependency, used
-to sign ES256 APNs provider JWTs.
+6.0.0 is a Swift 6 rewrite with a new async API. The 5.x code (`NotificationPusher`, on
+Perfect-Net) is on the [`legacy`](../../tree/legacy) branch; see [Migrating from 5.x](#migrating-from-5x).
-The pre-Swift-6 version of this package is preserved on the [`legacy`](../../tree/legacy) branch.
+## Installation
-Building
---------
-
-This is a Swift Package Manager based project targeting **Swift 6.2** (strict concurrency /
-`.swiftLanguageMode(.v6)`), macOS 12+. No other platform is currently declared in `Package.swift`
-(Linux support has not been verified).
-
-Add this repository as a dependency in your `Package.swift` file:
-
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-.package(url: "https://github.com/PerfectlySoft/Perfect-Notifications.git", branch: "main")
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-Overview
---------
-
-This system runs on the server side. Typically at app launch, an Apple device will register with Apple's system for remote notifications. Doing so will return to the device an ID which can be used by external systems to address the device and send notifications through APNs.
-
-When the device obtains its ID it will need to transmit it to **your** server. Your server will store this id and use it when sending notifications to one or more devices through APNs.
-
-The client itself, `APNSClient`, is a `Sendable` struct with `async throws` send methods — there is no callback-based API and no separate "configuration registry" step; you construct one client per set of APNs credentials and call `send`/`sendAlert`/`sendBackground` directly.
-
-Obtain APNs Auth Key
---------
-
-To connect your server to Apple's push notification system you will first need to obtain an "APNs Auth Key". This key is used on your server to configure its APNs access. You can generate this key through your Apple developer account portal. Log in to your developer account and choose "Certificates, IDs & Profiles" from the menu. Then, under "Keys", choose "All".
-
-If you haven't already created and downloaded the auth key, click "+" to create a new one. Enter a name for the key and make sure you select **Apple Push Notifications service (APNs)**. This one key can be used for both development or production and can be used for any of your iOS/macOS apps.
-
-Click "Continue", then "Confirm", then you will be given a chance to download the **private key**. You must download this key now and **save the file**. Also copy the "Key ID" shown in the same view. This will be a 10 character string.
+```swift
+.package(url: "https://github.com/PerfectlySoft/Perfect-Notifications.git", from: "6.0.0"),
+```
-Finally you will need to locate your developer team id. Click "Account" near the window's top. Select "Membership" in the menu. You will then be shown much of your personal information, including "Team ID". This is another 10 character string. Copy this value.
+and add `.product(name: "PerfectNotifications", package: "Perfect-Notifications")` to your target.
-Server Configuration
-------
+## Requirements
-To send notifications from your server your must have four pieces of information:
+- Swift 6.2 or later (`swift-tools-version: 6.2`, Swift 6 language mode).
+- macOS 12 or later, or Linux. CI covers Swift 6.2 and 6.4 on Ubuntu 24.04 and Xcode 26.0.1
+ and 26.6 on macOS, debug and release, with warnings as errors.
+- On Linux, `URLSession` comes from `FoundationNetworking`, which uses the system libcurl.
+ APNs needs HTTP/2, so libcurl must be built with nghttp2. Ubuntu's `libcurl4` is, and the
+ official `swift:*-noble` images have it.
+- swift-crypto `4.0.0..<5.0.0`.
-1. The private key (`.p8`) file which was downloaded, or its PEM contents
-2. The 10 character key id
-3. Your 10 character team id
-4. An iOS/macOS app id ("topic")
+iOS, tvOS and watchOS aren't supported targets: a provider runs on a server, and must not ship
+its signing key inside an app.
-Unlike the original Perfect-era API, there is no separate "add a named configuration, then look it up later" step — you construct an `APNSClient` directly with the key material, and it internally mints and caches (auto-rotating every 55 minutes, before Apple's 60-minute expiry) the ES256 JWT provider token via `APNSCredentials` and swift-crypto.
+## Quick start
```swift
import PerfectNotifications
-let apnsKeyIdentifier = "AB90CD56XY"
-let apnsTeamIdentifier = "YX65DC09BA"
-let apnsPrivateKeyFilePath = "./APNsAuthKey_AB90CD56XY.p8"
-let topic = "my.app.id"
-
let client = try APNSClient(
- keyID: apnsKeyIdentifier,
- teamID: apnsTeamIdentifier,
- privateKeyPath: apnsPrivateKeyFilePath,
- environment: .sandbox // .production for release builds
+ keyID: "ABCDE12345", // from the .p8 file name
+ teamID: "TEAM123456", // Apple Developer → Membership
+ privateKeyPath: "/etc/myapp/AuthKey_ABCDE12345.p8",
+ environment: .sandbox // .production for App Store / TestFlight builds
)
let notification = try APNSNotification(items: [
- .alertTitle("Hello!"),
- .sound("default")
+ .alertTitle("Order ready"),
+ .alertBody("Your order is ready for pickup."),
+ .sound("default"),
+ .badge(1),
+ .customPayload("orderID", "ORD-123"),
])
-let response = try await client.send(notification, to: deviceToken, topic: topic)
+let response = try await client.send(notification,
+ to: deviceToken, // hex string from the device
+ topic: "com.example.App") // your bundle ID
if response.isSuccess {
- print("sent — apns-id: \(response.apnsID ?? "?")")
-} else if let err = response.error {
- print("rejected: \(err.reason)")
+ print("sent, apns-id \(response.apnsID ?? "?")")
+} else if let error = response.error {
+ switch error.knownReason {
+ case .unregistered?, .badDeviceToken?:
+ // Stop sending to this token.
+ print("drop token (invalid since \(error.invalidatedAt.map { "\($0)" } ?? "?"))")
+ default:
+ print("rejected: \(error.reason)")
+ }
}
```
-`APNSCredentials` can also be supplied a PEM string directly (`APNSClient(keyID:teamID:pemString:environment:)`) instead of a file path — useful when the key is loaded from an environment variable or secrets store.
+Create one client per signing key and keep it: `URLSession` keeps the HTTP/2 connection to APNs
+open and multiplexes concurrent sends over it, as Apple asks. `APNSClient` is a `Sendable`
+struct, so it can be shared across tasks; copies share the cached provider token.
-Public API
-----
+`APNSClient(keyID:teamID:pemString:)` takes the key's PEM text instead of a path, for keys
+loaded from a secrets store.
-The full public API surface follows:
+### Getting the key
-```swift
-public struct APNSClient: Sendable {
- public enum Environment: Sendable { case sandbox, production }
-
- /// Load the signing key from a `.p8` file on disk.
- public init(keyID: String, teamID: String, privateKeyPath: String,
- environment: Environment = .sandbox,
- urlSession: URLSession = .shared) throws
-
- /// Supply the `.p8` key as a PEM string.
- public init(keyID: String, teamID: String, pemString: String,
- environment: Environment = .sandbox,
- urlSession: URLSession = .shared) throws
-
- /// Send a notification to a single device token.
- public func send(
- _ notification: APNSNotification,
- to deviceToken: String,
- topic: String,
- pushType: APNSPushType = .alert,
- priority: Int? = nil,
- expiration: APNSExpiration? = nil,
- collapseID: String? = nil
- ) async throws -> APNSResponse
-
- /// Send the same notification to multiple device tokens (sequential).
- public func send(
- _ notification: APNSNotification,
- to deviceTokens: [String],
- topic: String,
- pushType: APNSPushType = .alert,
- priority: Int? = nil,
- expiration: APNSExpiration? = nil,
- collapseID: String? = nil
- ) async throws -> [APNSResponse]
-
- /// Build and send a simple alert in one call.
- @discardableResult
- public func sendAlert(
- to deviceToken: String,
- topic: String,
- title: String,
- body: String? = nil,
- subtitle: String? = nil,
- badge: Int? = nil,
- sound: String? = nil,
- category: String? = nil,
- threadId: String? = nil,
- interruptionLevel: APNSNotificationItem.InterruptionLevel? = nil,
- collapseID: String? = nil,
- mutableContent: Bool = false
- ) async throws -> APNSResponse
-
- /// Send a silent background push (`content-available: 1`, priority 5).
- @discardableResult
- public func sendBackground(
- to deviceToken: String,
- topic: String,
- customData: [APNSNotificationItem] = []
- ) async throws -> APNSResponse
-}
-```
+In the Apple Developer portal, under Certificates, IDs & Profiles → Keys, create a key with
+**Apple Push Notifications service (APNs)** enabled and download the `.p8` file (it can only be
+downloaded once). The 10-character key ID is shown with the key, and the 10-character team ID is
+under Membership. One key works for every app in the team, in both environments.
-The remaining structures, including `APNSNotificationItem`, follow:
+## Sending
```swift
-/// Items that compose the `aps` dictionary and top-level custom keys of a push payload.
-/// `@unchecked Sendable` because `.customPayload` accepts `Any` for JSON flexibility.
-public enum APNSNotificationItem: @unchecked Sendable {
- case alertBody(String)
- case alertTitle(String)
- case alertSubtitle(String)
- case alertTitleLoc(String, [String]?)
- case alertActionLoc(String)
- case alertLoc(String, [String]?)
- case alertLaunchImage(String)
- case badge(Int)
- case sound(String)
- case contentAvailable
- case mutableContent
- case category(String)
- case threadId(String)
- case targetContentId(String)
- case interruptionLevel(InterruptionLevel)
- case relevanceScore(Double)
- case customPayload(String, Any)
-
- public enum InterruptionLevel: String, Sendable {
- case passive, active
- case timeSensitive = "time-sensitive"
- case critical
- }
-}
-
-/// A push notification payload ready to send.
-public struct APNSNotification: Sendable {
- public init(items: [APNSNotificationItem]) throws
- /// Supply a raw pre-encoded JSON payload.
- public init(raw: Data)
- /// Encode an `Encodable` value as the entire payload (must include the `aps` key).
- public init(encodable: T) throws
-}
-
-/// The type of push notification being sent (required by APNs since iOS 13).
-public enum APNSPushType: String, Sendable, CaseIterable {
- case alert, background, voip, complication
- case fileProvider = "fileprovider"
- case mdm, location
- case liveActivity = "liveactivity"
- case pushToTalk = "pushtotalk"
+func send(_ notification: APNSNotification, to deviceToken: String, topic: String,
+ pushType: APNSPushType = .alert, priority: APNSPriority? = nil,
+ expiration: APNSExpiration? = nil, collapseID: String? = nil) async throws -> APNSResponse
+```
- /// Background must use priority 5; all others default to 10.
- public var defaultPriority: Int
-}
+- **Push type** (`apns-push-type`): `.alert`, `.background`, `.voip`, `.complication`,
+ `.fileProvider`, `.mdm`, `.location`, `.liveActivity`, `.pushToTalk`. Several need a topic
+ suffix: `com.example.App.voip`, `.complication`, `.pushkit.fileprovider`,
+ `.push-type.liveactivity`, `.location-query`, `.voip-ptt`.
+- **Priority** (`apns-priority`): `.immediate` (10, the default), `.background` (5: delivery
+ that respects the device's power state) and `.low` (1: never wakes the device). Background
+ pushes must use `.background`, which is their default.
+- **Expiration** (`apns-expiration`): `.immediate` (deliver once or drop), `.relative(seconds)`
+ or `.absolute(epochSeconds)`.
+- **Collapse ID** (`apns-collapse-id`): 1–64 printable ASCII characters (`!` to `~`, no
+ spaces); newer notifications with the same ID replace older ones on the device.
+- `sendAlert(to:topic:title:body:...)` and `sendBackground(to:topic:customData:)` cover the
+ common cases.
+
+### Payloads
+
+`APNSNotificationItem` builds the `aps` dictionary (`.alertTitle`, `.alertBody`,
+`.alertSubtitle`, the `-loc` keys, `.badge`, `.sound`, `.contentAvailable`, `.mutableContent`,
+`.category`, `.threadId`, `.targetContentId`, `.interruptionLevel`, `.relevanceScore`) and
+custom top-level keys with `.customPayload(key, value)`.
+
+Custom values are `APNSJSONValue`, a `Sendable` JSON enum. Literals convert directly:
-public enum APNSPriority: Int, Sendable {
- case immediate = 10
- case background = 5
-}
+```swift
+.customPayload("orderID", "ORD-123")
+.customPayload("amount", 49)
+.customPayload("flags", ["new": true, "ids": [1, 2, 3]])
+.customPayload("orderID", .string(order.id)) // a variable: name the case
+.customPayload("legacy", try APNSJSONValue(jsonObject: someAnyDictionary))
+```
-/// When to discard an undeliverable notification.
-public enum APNSExpiration: Sendable {
- case immediate
- case relative(Int)
- case absolute(Int)
-}
+`APNSNotification(raw:)` sends JSON bytes as is, once it has checked that they are a JSON object
+with an `aps` dictionary, and `APNSNotification(encodable:)` encodes your own `Encodable` payload
+(it must contain `aps`). The payload limit is checked before sending: 4096 bytes, or 5120 for
+VoIP. `badge` must not be negative, `relevanceScore` must be 0–1, and JSON may nest at most 64
+levels.
-/// The outcome of a single APNs send request.
-public struct APNSResponse: Sendable {
- public let statusCode: Int
- public let apnsID: String?
- public let error: APNSResponseError?
- public var isSuccess: Bool { statusCode == 200 }
-}
+### Several devices
-/// An error reason returned by the APNs gateway (e.g. BadDeviceToken, Unregistered, ExpiredProviderToken).
-public struct APNSResponseError: Sendable, Equatable, CustomStringConvertible {
- public let reason: String
- public let timestamp: Int?
+```swift
+let results = try await client.send(notification, to: tokens, topic: "com.example.App")
+for r in results {
+ switch r.result {
+ case .success(let response) where response.isSuccess: break
+ case .success(let response): print(r.deviceToken, "rejected:", response.error?.reason ?? "\(response.statusCode)")
+ case .failure(let error): print(r.deviceToken, "not sent:", error)
+ }
}
```
-Additional Notes
-----
-
-APNs requests are made from your server to Apple's servers `api.sandbox.push.apple.com` (development) or `api.push.apple.com` (production) on port 443, via `URLSession`'s async `data(for:)`. `URLSession` handles HTTP/2 connection reuse internally, in accordance with Apple's recommended usage of APNs.
-
-The package targets **Swift 6.2 with strict concurrency** enabled (`.swiftLanguageMode(.v6)` on both the library and test targets). `APNSClient`, `APNSNotification`, `APNSPushType`, `APNSResponse`, and `APNSResponseError` are plain `Sendable`; `APNSCredentials` and `APNSNotificationItem` are `@unchecked Sendable` (an `NSLock`-guarded JWT cache and an `Any`-typed payload case, respectively). No actors are used — `APNSCredentials` synchronizes its cached-token state with `NSLock`.
-
-The `Perfect-NotificationsExample` project linked from the original PerfectlySoft repo targets the legacy callback-based `NotificationPusher` API and is **not** compatible with this fork's `APNSClient`; treat it as historical reference only, not a working example for this package.
-
-## Further Information
-For more information on the Perfect project, please visit [perfect.org](http://perfect.org).
+Tokens are sent one after another. Each gets its own result: a bad token or a network error
+for one token doesn't stop the rest. Problems shared by every token (topic, priority, payload
+size) throw before anything is sent. Cancelling the calling task stops the batch without losing
+what was sent: earlier tokens keep their results, and the token in flight and the rest get
+`.failure(.cancelled)` (the one in flight may still have been delivered). For concurrency, call
+`send(_:to:topic:)` from a task group; the requests share one HTTP/2 connection.
+
+### Responses and errors
+
+An APNs rejection is not a Swift error: it's an `APNSResponse` with `statusCode` and
+`error.reason` (`"BadDeviceToken"`, `"Unregistered"`, …; `error.knownReason` maps it onto
+`APNSErrorReason`). For HTTP 410, `error.timestamp` is when APNs last confirmed the token was
+invalid, in **milliseconds** since the epoch, and `error.invalidatedAt` is the same as a `Date`.
+`apnsID` and, in the sandbox, `apnsUniqueID` come from the response headers.
+
+`APNSClientError` is thrown for everything the client catches itself: invalid input (device
+token, topic, collapse ID, priority, expiration, key or team ID, payload size), `.timedOut`,
+`.responseTooLarge`, `.unexpectedRedirect`, `.network(...)`. Cancelling the calling task throws
+`CancellationError` promptly.
+
+### Provider tokens
+
+The client signs a JWT with your key and reuses it for 50 minutes (Apple accepts one for up to
+60). Apple refuses token updates more often than once every 20 minutes
+(`TooManyProviderTokenUpdates`), so the client never mints a new token sooner than that.
+After an `ExpiredProviderToken` rejection, it mints a new token and retries once if the old one is
+at least 20 minutes old. After `InvalidProviderToken` it doesn't retry (a new token from the same
+key won't help) but stops reusing the rejected token once 20 minutes have passed. Token age is
+measured on a monotonic clock that counts sleep, so stepping the wall clock doesn't change when
+tokens are replaced. Each
+`APNSClient` created from a key has its own token, so share one client rather than creating many
+for the same key.
+
+### Timeouts
+
+`APNSClient(..., timeout: 30)` sets a whole-request deadline in seconds (default 30, fractions
+allowed), covering connect, send and response. Past it, `send` throws `APNSClientError.timedOut`.
+
+### The `urlSession:` parameter
+
+The client sends through its own `URLSession`, built from the configuration of the session you
+pass (protocol classes, proxies, TLS settings and timeouts carry over), with its own delegate. It
+forwards authentication challenges, including server-trust evaluation for certificate pinning,
+to your session's delegate. Other delegate callbacks aren't forwarded. It needs its own delegate
+to refuse redirects and to cancel requests safely on Linux.
+
+## Security notes
+
+- **Inputs are validated before a request is built.** Device tokens must be 64–200 hex digits of
+ even length, so a token can't add path segments, a query or a fragment (`../`, `?`, `#`).
+ Topics must be bundle-ID syntax; collapse IDs are 1–64 printable ASCII characters; key and
+ team IDs must be 10 ASCII letters or digits. No header value can contain CR, LF, a space or
+ another control character. The JWT is built with `JSONEncoder`.
+- **Redirects are never followed.** APNs doesn't redirect, and following one would send the
+ provider token to another origin. A 3xx response, or one for another host, throws
+ `APNSClientError.unexpectedRedirect` instead of counting as a result.
+- **Secrets stay out of logs.** `"\(client)"`, `String(reflecting:)`, `dump(client)` and `Mirror`
+ show the environment, key ID, team ID and timeout, but never the private key or the provider
+ token. `APNSClientError.network` carries the transport error's description, which never
+ includes request headers.
+- **Response bodies are capped at 16 KiB** (APNs sends a few dozen bytes). The cap is enforced as
+ data arrives, and the request is cancelled when it passes it. On macOS that stops the
+ transfer. On Linux it doesn't; see below.
+- **Keep the `.p8` key out of source control**, and readable only by the server's user.
+- `Environment.custom(host:port:)` points the client at a mock APNs server for staging or tests.
+ The provider token is sent there, so only use a host you control.
+
+## Linux
+
+Verified on swift:6.2-noble and swift:6.4-noble, arm64 and x86_64: `URLSession` negotiates HTTP/2
+with ALPN `h2` against an HTTP/2-only server, reuses one connection, and multiplexes 20
+concurrent sends over it. Three corelibs Foundation behaviours to know about:
+
+- **An endless response body keeps downloading after the client gives up.** The client counts
+ the body as it arrives, throws `responseTooLarge` past 16 KiB, and discards anything later.
+ But corelibs keeps the transfer running after its task is cancelled. Against a local server
+ streaming an endless body, `send` threw within 0.14 s and memory stayed under 110 MB in 8 of
+ 8 runs. corelibs went on reading 0.2–2.7 GB, though, before the connection closed, wasting
+ bandwidth and CPU. (Before 6.0.0's final transport, the same server got the process
+ OOM-killed at about 6 GB within roughly 10 seconds, because `URLSession.data(for:)` buffered
+ everything.) Reaching this needs a server that passes TLS verification for
+ `api.push.apple.com` (or your custom host). A SwiftNIO HTTP/2 transport that resets the
+ stream at the cap is planned for 6.1.
+- **Timeouts don't cost the connection.** A timed-out or cancelled request is reset on its own;
+ requests multiplexed alongside it complete, and later ones reuse the connection (150
+ timeouts in a row used 2 connections).
+- **Cancellation is never done under a lock, and never races a callback.** Cancelling a
+ corelibs task while it completes could deadlock the Swift concurrency pool (the client used
+ to do exactly that). Answering a redirect callback after the task was cancelled crashes the
+ process. The client resumes your task first, cancels the transfer on a Dispatch thread, and
+ holds a cancel back until a pending redirect has been refused.
+
+## Testing
+
+The test suite is hermetic: requests go to a `URLProtocol` stub that records them (path,
+headers, body, JWT signature checked with swift-crypto) and returns scripted responses. Nothing
+contacts APNs. To test your own code, pass a `URLSession` whose configuration's `protocolClasses`
+includes such a stub, or point `Environment.custom(host:port:)` at a local mock server.
+
+The URLProtocol stub can't reproduce corelibs' real network timing, so the Linux deadlock,
+timeout, redirect and endless-body regressions are covered by scripted probes outside the
+package: a SwiftNIO HTTP/2-only server with adversarial behaviours (never answering, a 1 MiB
+header, 5,000 headers, a cross-origin 307, a trickled or endless body) and a client that runs
+the poison sequence, sub-second timeouts, hang bursts, and a race that cancels every one of
+1,000 sends while it is being redirected. Release verification runs them on
+Swift 6.2 and 6.4, arm64 and x86_64, in containers with no network.
+
+## Migrating from 5.x
+
+5.x registered named configurations on `NotificationPusher` and delivered results to callbacks.
+6.0 has one `APNSClient` per key and `async` sends.
+
+| 5.x | 6.0 |
+|---|---|
+| `NotificationPusher.addConfigurationAPNS(name:production:keyId:teamId:privateKeyPath:)` | `let client = try APNSClient(keyID:teamID:privateKeyPath:environment:)` with `.sandbox` or `.production` |
+| `NotificationPusher(apnsTopic:expiration:priority:collapseId:)` | `topic:`, `expiration:`, `priority:`, `collapseID:` parameters of `send` |
+| `pushAPNS(configurationName:deviceToken:notificationItems:callback:)` | `try await client.send(APNSNotification(items: ...), to: token, topic: ...)` |
+| `pushAPNS(configurationName:deviceTokens:notificationItems:callback:)` | `try await client.send(..., to: tokens, topic: ...)` → `[APNSBatchResult]` |
+| `NotificationResponse` (`status`, `body`) | `APNSResponse` (`statusCode`, `apnsID`, `apnsUniqueID`, `error`) |
+| `.customPayload(String, Any)` | `.customPayload(String, APNSJSONValue)`: literals work as before; wrap variables (`.string(x)`) or use `APNSJSONValue(jsonObject:)` |
+| Perfect-Net / Perfect-Thread | `URLSession` and swift-crypto |
+
+`APNSNotificationItem`'s other cases keep their names. New: `.alertSubtitle`, `.mutableContent`,
+`.targetContentId`, `.interruptionLevel`, `.relevanceScore`.
+
+### From the pre-release `main` (Swift 6 rewrite before 6.0.0)
+
+- `priority:` is an `APNSPriority?` (`.immediate`, `.background`, `.low`) instead of an `Int?`,
+ and `APNSPushType.defaultPriority` returns an `APNSPriority`. A background push with any other
+ priority throws `.invalidPriority`.
+- `.customPayload` takes an `APNSJSONValue`, and `APNSNotificationItem` is properly `Sendable`
+ (and `Equatable`). A custom `aps` key throws `.reservedPayloadKey`.
+- The multi-token `send` returns `[APNSBatchResult]` (token plus `Result`) instead of
+ `[APNSResponse]`, and no longer throws away every result when one token fails.
+- `APNSResponseError.timestamp` is an `Int64?` of milliseconds (it was documented as seconds).
+- Key IDs and team IDs must be 10 letters or digits; device tokens, topics, collapse IDs and
+ expirations are validated; payloads over the limit throw `.payloadTooLarge`.
+- `APNSClientError` has new cases (exhaustive `switch`es need updating), and the `init`s take a
+ `timeout:`. `APNSExpiration` is `Equatable`, `APNSNotification.payload` is public.
+- `APNSNotification(raw:)` throws: the bytes must be a JSON object with an `aps` dictionary, at
+ most 5120 bytes. Collapse IDs must be printable ASCII without spaces.
+- The client sends through its own session built from the one you pass; only authentication
+ challenges reach your session's delegate.
+- swift-crypto is `4.0.0..<5.0.0` (was `from: "3.0.0"`, which meant 3.x only and couldn't
+ resolve next to Perfect-SMTP 6).
diff --git a/README.zh_CN.md b/README.zh_CN.md
deleted file mode 100644
index 56d2354..0000000
--- a/README.zh_CN.md
+++ /dev/null
@@ -1,207 +0,0 @@
-# Perfect-Notifications [English](README.md)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Notifications for Perfect 之 iOS消息推送。此软件包为您的服务器添加了推送通知支持。向iOS / macOS设备发送通知。
-
-
-编译
---------
-
-请在您的Perfect项目中的Package.swift文件增加以下依存关系:
-
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-.package(url:"https://github.com/PerfectlySoft/Perfect-Notifications.git", from: "5.0.0")
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-## 综述
-
-该程序在服务器上运行,通常是苹果移动设备在启动后,会自动在苹果网站上注册,以获得一个设备编号。
-
-苹果设备需要在获得该编号之后,主动到⚠️**您的**⚠️服务器上登记。这样您的服务器就可以记录这个设备编号然后通过APN苹果网络服务给设备发消息。
-
-
-## 获取 APN 授权码
-
-要使用苹果设备消息推送服务APN,您需要首先获得一个APN授权代码。这个代码可以在苹果开发者网站上取得。登录到您的开发者账号并选择 "Certificates, IDs & Profiles" 菜单,在"Keys"下,选择"All"。
-
-如果还没有创建或者下载授权码,请点击”➕“创建一个新代码。输入私有钥匙的名称,并选择 **Apple Push Notifications service (APNs)**,即 Apple推送通知服务(APNs)。该授权码可以同时在开发阶段和生产部署阶段使用,适用于所有您的iOS / macOS设备。
-
-点击 "Continue" 后,再点击"Confirm",可以下载 **私有钥匙** 。此时必须下载并保存该文件,并拷贝屏幕上十位字长的“钥匙代码”。
-
-最后需要确定您的开发团队账号。点击"Account"浏览器上方的,选择 "Membership" 会员视图能够找到"Team ID"团队编号,也是10位字符串。请记录该字符串。
-
-## 服务器配置
-
-如果需要从服务器向苹果设备推送消息,您需要以下三个信息:
-
-1. 刚才下载的私有钥匙文件
-2. 10位授权码
-3. 10位开发团队代码
-4. 苹果设备或操作系统在APN上的编号
-
-上述内容用于推送消息,注意不要在您的服务器源代码上保存这些东西,最好区别与服务器程序分开保存。为了简单说明,本手册假设钥匙文件在当前工作目录下,并且各个编号代码都已经以Swift变量形式导入到程序。
-
-在服务器代码开始段,请导入函数库 `import PerfectNotifications`。随后请配置好上述内容用于实际消息推送。
-
-``` swift
-import PerfectNotifications
-
-// 应用程序名称(即Bundle Identifier),我们用这个名称来配置,但是不一定非得是这个形式
-let notificationsAppId = "my.app.id"
-
-let apnsKeyIdentifier = "AB90CD56XY"
-let apnsTeamIdentifier = "YX65DC09BA"
-let apnsPrivateKeyFilePath = "./APNsAuthKey_AB90CD56XY.p8"
-
-NotificationPusher.addConfigurationAPNS(
-name: notificationsTestId,
-production: false, // 如果是调试程序,则将这个生产服务器标志设置为“伪”
-keyId: apnsKeyIdentifier,
-teamId: apnsTeamIdentifier,
-privateKeyPath: apnsPrivateKeyFilePath)
-```
-
-配置完成后就可以随时推送消息了。具体做法是创建一个`NotificationPusher`对象,设置应用程序代码或者“消息主题”,然后调用 `pushAPNS`函数:
-
-``` swift
-let deviceIds: [String] = [...]
-let n = NotificationPusher(apnsTopic: notificationsTestId)
-n.pushAPNS(
-configurationName: notificationsTestId,
-deviceTokens: deviceIds,
-notificationItems: [.alertBody("Hello!"), .sound("default")]) {
-responses in
-print("\(responses)")
-...
-}
-```
-
-创建 NotificationPusher 的消息主题是必要的。此外,可选参数还包括消息有效期、优先级和消息层次折叠代码,详见苹果APN有关文档。
-
-## 函数界面
-
-详细函数调用参考如下:
-
-```swift
-public class NotificationPusher {
-
-/// 追加APNS 配置
-public static func addConfigurationAPNS(
-name: String,
-production: Bool,
-keyId: String,
-teamId: String,
-privateKeyPath: String)
-
-/// 初始化推送实例并设置消息主题
-public init(
-apnsTopic: String,
-expiration: APNSExpiration = .immediate,
-priority: APNSPriority = .immediate,
-collapseId: String? = nil)
-
-/// 推送消息
-/// 需要提供已经设定好了配置名称和设备编号
-/// 需要提供待通知对象列表 APNSNotificationItems
-/// 需要提供推送结果回调函数
-public func pushAPNS(
-configurationName: String,
-deviceToken: String,
-notificationItems: [APNSNotificationItem],
-callback: @escaping (NotificationResponse) -> ())
-
-/// 向多个设备推送消息
-/// 需要提供预制配置名称、一个或者多个设备代码。所有设备都会收到相同的消息
-/// 需要提供待通知对象列表 APNSNotificationItems
-/// 需要提供推送结果回调函数
-public func pushAPNS(
-configurationName: String, deviceTokens: [String],
-notificationItems: [APNSNotificationItem],
-callback: @escaping ([NotificationResponse]) -> ())
-}
-```
-
-其他数据结果包括 APNSNotificationItem :
-
-```swift
-/// Items to configure an individual notification push.
-public enum APNSNotificationItem {
-/// alert body child property
-case alertBody(String)
-/// alert title child property
-case alertTitle(String)
-/// alert title-loc-key
-case alertTitleLoc(String, [String]?)
-/// alert action-loc-key
-case alertActionLoc(String)
-/// alert loc-key
-case alertLoc(String, [String]?)
-/// alert launch-image
-case alertLaunchImage(String)
-/// aps badge key
-case badge(Int)
-/// aps sound key
-case sound(String)
-/// aps content-available key
-case contentAvailable
-/// aps category key
-case category(String)
-/// aps thread-id key
-case threadId(String)
-/// custom payload data
-case customPayload(String, Any)
-/// apn mutable-content key
-case mutableContent
-}
-
-public enum APNSPriority: Int {
-case immediate = 10
-case background = 5
-}
-
-/// 消息有效期,超过之后不会在发送
-public enum APNSExpiration {
-/// 如果无法立刻到达则丢弃消息
-case immediate
-/// 当前时间加秒数
-case relative(Int)
-/// UTC绝对时间
-case absolute(Int)
-}
-
-/// 推送后的响应对象
-public struct NotificationResponse: CustomStringConvertible {
-/// The response code for the request.
-public let status: HTTPResponseStatus
-/// The response body data bytes.
-public let body: [UInt8]
-/// The body data bytes interpreted as JSON and decoded into a Dictionary.
-public var jsonObjectBody: [String:Any]
-/// The body data bytes converted to String.
-public var stringBody: String
-public var description: String
-}
-```
-
-## 其他注意事项
-
-APNs 请求需要从您的服务器向苹果服务器 "api.development.push.apple.com" 或者 "api.push.apple.com" 443 端口进行通信。一次请求可以向一个或者多个设备推送消息。整个连接过程会一直持续,并且可以用于发送后续消息。如果连接终止或者空闲太久,则会重新打开一个新的连接。这种方式是苹果建议的方式用于处理大量同步通知。
-
-可以参考代码示范 [Perfect-NotificationsExample](https://github.com/PerfectExamples/Perfect-NotificationsExample) 用于配置您自己的服务器和应用程序消息推送。
diff --git a/Sources/PerfectNotifications/APNSClient.swift b/Sources/PerfectNotifications/APNSClient.swift
index 74b71ac..218298a 100644
--- a/Sources/PerfectNotifications/APNSClient.swift
+++ b/Sources/PerfectNotifications/APNSClient.swift
@@ -1,9 +1,84 @@
import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
-/// Error thrown when a request cannot reach the APNs gateway (not a gateway rejection).
-public enum APNSClientError: Error, Sendable {
+/// An error raised by the client itself, before or instead of an APNs response.
+/// APNs rejections are not errors: they come back as an ``APNSResponse`` with
+/// ``APNSResponse/error`` set.
+public enum APNSClientError: Error, Sendable, Equatable, CustomStringConvertible {
+ /// The device token is not 64–200 hex digits of even length.
+ case invalidDeviceToken
+ /// The topic is not a bundle ID (dot-separated ASCII letters, digits and `-`).
+ case invalidTopic
+ /// The collapse ID is not 1–64 printable ASCII characters (`!` through `~`, no spaces).
+ case invalidCollapseID
+ /// The priority isn't allowed for the push type (background pushes must use `.background`).
+ case invalidPriority(APNSPriority, pushType: APNSPushType)
+ /// A negative expiration, or a relative one that overflows.
+ case invalidExpiration
+ /// The key ID is not 10 ASCII letters or digits.
+ case invalidKeyID
+ /// The team ID is not 10 ASCII letters or digits.
+ case invalidTeamID
+ /// A ``APNSClient/Environment/custom(host:port:)`` host or port is malformed.
+ case invalidHost
+ /// The request timeout is not a positive, finite number of seconds.
+ case invalidTimeout
+ /// The payload exceeds the APNs limit for the push type (4096 bytes, 5120 for VoIP).
+ case payloadTooLarge(size: Int, limit: Int)
+ /// A custom payload used the reserved `aps` key.
+ case reservedPayloadKey(String)
+ /// ``APNSJSONValue/init(jsonObject:)`` met a value it can't represent.
+ case invalidJSONValue(String)
+ /// A payload value is out of range (`badge` < 0, `relevanceScore` outside 0–1), or raw
+ /// payload bytes aren't a JSON object with an `aps` dictionary.
+ case invalidPayload(String)
+ /// JSON nested deeper than the limit (64 levels).
+ case jsonNestingTooDeep(limit: Int)
+ /// Signing the provider token failed.
+ case tokenSigningFailed
+ /// The request did not complete within the client's timeout.
+ case timedOut
+ /// The response body exceeded the client's limit (16 KiB; APNs bodies are under 1 KiB).
+ case responseTooLarge(limit: Int)
+ /// The request failed below HTTP (DNS, TCP, TLS). The string is the underlying error's
+ /// description; it never contains the provider token.
case network(String)
+ /// The response was not HTTP.
case invalidResponse
+ /// The server answered with a redirect (or for another origin). APNs never redirects,
+ /// and the client never follows one, so the provider token stays with the APNs host.
+ case unexpectedRedirect(statusCode: Int)
+ /// In a batch send: the batch was cancelled before or while this token's request ran.
+ /// A token whose request was in flight may or may not have been delivered.
+ case cancelled
+
+ public var description: String {
+ switch self {
+ case .invalidDeviceToken: return "invalid device token (expected 64-200 hex digits)"
+ case .invalidTopic: return "invalid apns-topic"
+ case .invalidCollapseID: return "invalid apns-collapse-id (1-64 printable ASCII characters, no spaces)"
+ case .invalidPriority(let p, let t): return "priority \(p.rawValue) is not allowed for push type \(t.rawValue)"
+ case .invalidExpiration: return "invalid apns-expiration"
+ case .invalidKeyID: return "invalid key ID (expected 10 letters or digits)"
+ case .invalidTeamID: return "invalid team ID (expected 10 letters or digits)"
+ case .invalidHost: return "invalid custom host or port"
+ case .invalidTimeout: return "invalid timeout"
+ case .payloadTooLarge(let size, let limit): return "payload is \(size) bytes; the limit is \(limit)"
+ case .reservedPayloadKey(let k): return "custom payload key \"\(k)\" is reserved"
+ case .invalidJSONValue(let t): return "not a JSON value: \(t)"
+ case .invalidPayload(let why): return "invalid payload: \(why)"
+ case .jsonNestingTooDeep(let limit): return "JSON nested deeper than \(limit) levels"
+ case .tokenSigningFailed: return "signing the provider token failed"
+ case .timedOut: return "request timed out"
+ case .responseTooLarge(let limit): return "response body exceeded \(limit) bytes"
+ case .network(let s): return "network error: \(s)"
+ case .invalidResponse: return "response was not HTTP"
+ case .unexpectedRedirect(let status): return "unexpected redirect (HTTP \(status)); not followed"
+ case .cancelled: return "cancelled"
+ }
+ }
}
/// A Swift 6 client for the APNs HTTP/2 provider API (token-based auth).
@@ -31,27 +106,42 @@ public enum APNSClientError: Error, Sendable {
/// print("rejected: \(err.reason)")
/// }
/// ```
+///
+/// The client is a value type that shares one credentials object, so copies share the
+/// cached provider token. Create one client per signing key and reuse it: `URLSession`
+/// keeps the HTTP/2 connection open and multiplexes requests over it, as Apple asks.
public struct APNSClient: Sendable {
// MARK: Environment
- public enum Environment: Sendable {
+ public enum Environment: Sendable, Equatable {
case sandbox
case production
+ /// A local or staging APNs mock (`https://host:port`). The provider token is sent
+ /// to this host, so only point it at a server you control.
+ case custom(host: String, port: Int)
- var host: String {
+ var authority: String {
switch self {
case .sandbox: return "api.sandbox.push.apple.com"
case .production: return "api.push.apple.com"
+ case .custom(let host, let port): return port == 443 ? host : "\(host):\(port)"
}
}
}
+ /// The default whole-request timeout, in seconds.
+ public static let defaultTimeout: TimeInterval = 30
+ /// The largest response body the client reads. APNs error bodies are a few dozen bytes.
+ static let maxResponseBodySize = 16 * 1024
+
// MARK: Properties
private let credentials: APNSCredentials
- private let environment: Environment
- private let urlSession: URLSession
+ public let environment: Environment
+ /// The whole-request deadline, in seconds, covering connect, send and response.
+ public let timeout: TimeInterval
+ private let transport: APNSTransport
// MARK: Init
@@ -59,112 +149,126 @@ public struct APNSClient: Sendable {
///
/// - Parameters:
/// - keyID: The 10-character key ID from the `.p8` filename (e.g. `ABCDE12345`).
- /// - teamID: Your Apple Developer Team ID.
+ /// - teamID: Your 10-character Apple Developer Team ID.
/// - privateKeyPath: Path to the `.p8` private key file.
/// - environment: `.sandbox` (default) or `.production`.
- /// - urlSession: Custom session for testing or certificate pinning.
+ /// - urlSession: Its configuration (protocol classes, proxies, TLS settings) is used,
+ /// and its delegate receives authentication challenges, so certificate pinning keeps
+ /// working. The client sends through its own session built from it, which never
+ /// follows redirects.
+ /// - timeout: Whole-request deadline in seconds (default 30).
+ /// - Throws: ``APNSClientError/invalidKeyID``, ``APNSClientError/invalidTeamID``,
+ /// ``APNSClientError/invalidHost``, ``APNSClientError/invalidTimeout``, a file error,
+ /// or a swift-crypto error for a key that isn't a P-256 PEM.
public init(keyID: String, teamID: String, privateKeyPath: String,
environment: Environment = .sandbox,
- urlSession: URLSession = .shared) throws {
+ urlSession: URLSession = .shared,
+ timeout: TimeInterval = APNSClient.defaultTimeout) throws {
+ try Self.validate(environment: environment, timeout: timeout)
self.credentials = try APNSCredentials(keyID: keyID, teamID: teamID, privateKeyPath: privateKeyPath)
self.environment = environment
- self.urlSession = urlSession
+ self.transport = APNSTransport(basedOn: urlSession, maxBodySize: Self.maxResponseBodySize)
+ self.timeout = timeout
}
/// Supply the `.p8` key as a PEM string (useful when loading from env vars or config).
public init(keyID: String, teamID: String, pemString: String,
environment: Environment = .sandbox,
- urlSession: URLSession = .shared) throws {
+ urlSession: URLSession = .shared,
+ timeout: TimeInterval = APNSClient.defaultTimeout) throws {
+ try Self.validate(environment: environment, timeout: timeout)
self.credentials = try APNSCredentials(keyID: keyID, teamID: teamID, pemString: pemString)
self.environment = environment
- self.urlSession = urlSession
+ self.transport = APNSTransport(basedOn: urlSession, maxBodySize: Self.maxResponseBodySize)
+ self.timeout = timeout
+ }
+
+ init(credentials: APNSCredentials, environment: Environment, urlSession: URLSession,
+ timeout: TimeInterval = APNSClient.defaultTimeout) throws {
+ try Self.validate(environment: environment, timeout: timeout)
+ self.credentials = credentials
+ self.environment = environment
+ self.transport = APNSTransport(basedOn: urlSession, maxBodySize: Self.maxResponseBodySize)
+ self.timeout = timeout
+ }
+
+ private static func validate(environment: Environment, timeout: TimeInterval) throws(APNSClientError) {
+ if case .custom(let host, let port) = environment { try APNSValidation.host(host, port: port) }
+ guard timeout.isFinite, timeout > 0 else { throw .invalidTimeout }
}
// MARK: - Send
/// Send a notification to a single device token.
///
+ /// An `ExpiredProviderToken` rejection is retried once with a fresh token, unless the
+ /// current token is less than 20 minutes old (Apple's limit on token updates).
+ ///
/// - Parameters:
/// - notification: The payload to send.
- /// - deviceToken: The hex device token string registered by the app.
+ /// - deviceToken: The hex device token registered by the app.
/// - topic: Bundle ID of the app (`com.example.App`), or `com.example.App.voip` for VoIP.
/// - pushType: Defaults to `.alert`. Must be `.background` for silent pushes.
/// - priority: `apns-priority`. Defaults to ``APNSPushType/defaultPriority``.
/// - expiration: When to discard an undeliverable notification.
- /// - collapseID: Coalesces pending undelivered notifications with the same ID.
+ /// - collapseID: Coalesces pending undelivered notifications with the same ID (≤ 64 bytes).
+ /// - Returns: The APNs response; a rejection is a response with ``APNSResponse/error`` set.
+ /// - Throws: ``APNSClientError`` for invalid input, network failures and timeouts;
+ /// `CancellationError` if the task is cancelled.
public func send(
_ notification: APNSNotification,
to deviceToken: String,
topic: String,
pushType: APNSPushType = .alert,
- priority: Int? = nil,
+ priority: APNSPriority? = nil,
expiration: APNSExpiration? = nil,
collapseID: String? = nil
) async throws -> APNSResponse {
- let url = URL(string: "https://\(environment.host)/3/device/\(deviceToken)")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.httpBody = notification.payload
- request.setValue("application/json", forHTTPHeaderField: "content-type")
- request.setValue(topic, forHTTPHeaderField: "apns-topic")
- request.setValue(pushType.rawValue, forHTTPHeaderField: "apns-push-type")
- request.setValue("\(priority ?? pushType.defaultPriority)", forHTTPHeaderField: "apns-priority")
-
- let jwt = try credentials.token()
- request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization")
-
- if let exp = expiration {
- request.setValue("\(exp.headerValue)", forHTTPHeaderField: "apns-expiration")
- }
- if let cid = collapseID {
- request.setValue(cid, forHTTPHeaderField: "apns-collapse-id")
- }
-
- let data: Data
- let response: URLResponse
- do {
- (data, response) = try await urlSession.data(for: request)
- } catch {
- throw APNSClientError.network("\(error)")
- }
-
- guard let http = response as? HTTPURLResponse else {
- throw APNSClientError.invalidResponse
- }
-
- let apnsID = http.value(forHTTPHeaderField: "apns-id")
-
- guard http.statusCode != 200 else {
- return APNSResponse(statusCode: 200, apnsID: apnsID, error: nil)
- }
-
- if !data.isEmpty, let body = try? JSONDecoder().decode(APNSErrorBody.self, from: data) {
- let err = APNSResponseError(reason: body.reason, timestamp: body.timestamp)
- return APNSResponse(statusCode: http.statusCode, apnsID: apnsID, error: err)
- }
- return APNSResponse(statusCode: http.statusCode, apnsID: apnsID, error: nil)
+ let prepared = try prepare(notification, topic: topic, pushType: pushType,
+ priority: priority, expiration: expiration, collapseID: collapseID)
+ return try await deliver(prepared, to: deviceToken)
}
- /// Send the same notification to multiple device tokens (sequential).
- /// Returns one ``APNSResponse`` per token, in the same order.
+ /// Send the same notification to several device tokens, one after another.
+ ///
+ /// Returns one ``APNSBatchResult`` per token, in order. A token that fails (invalid
+ /// token, network error, timeout) gets a `.failure` and the rest are still sent.
+ /// Problems shared by every token (topic, priority, payload size, …) throw before
+ /// anything is sent.
+ ///
+ /// Cancelling the calling task doesn't throw away what was already sent: tokens that
+ /// were sent keep their results, the token in flight and every later one get
+ /// `.failure(.cancelled)`. (The one in flight may still have been delivered.)
public func send(
_ notification: APNSNotification,
to deviceTokens: [String],
topic: String,
pushType: APNSPushType = .alert,
- priority: Int? = nil,
+ priority: APNSPriority? = nil,
expiration: APNSExpiration? = nil,
collapseID: String? = nil
- ) async throws -> [APNSResponse] {
- var responses = [APNSResponse]()
- responses.reserveCapacity(deviceTokens.count)
+ ) async throws -> [APNSBatchResult] {
+ let prepared = try prepare(notification, topic: topic, pushType: pushType,
+ priority: priority, expiration: expiration, collapseID: collapseID)
+ var results = [APNSBatchResult]()
+ results.reserveCapacity(deviceTokens.count)
for token in deviceTokens {
- let r = try await send(notification, to: token, topic: topic,
- pushType: pushType, priority: priority,
- expiration: expiration, collapseID: collapseID)
- responses.append(r)
+ if Task.isCancelled {
+ results.append(APNSBatchResult(deviceToken: token, result: .failure(.cancelled)))
+ continue
+ }
+ let result: Result
+ do {
+ result = .success(try await deliver(prepared, to: token))
+ } catch let error as APNSClientError {
+ result = .failure(error)
+ } catch {
+ // CancellationError: this token's request was in flight.
+ result = .failure(.cancelled)
+ }
+ results.append(APNSBatchResult(deviceToken: token, result: result))
}
- return responses
+ return results
}
// MARK: - Convenience helpers
@@ -210,6 +314,135 @@ public struct APNSClient: Sendable {
items.append(.contentAvailable)
let notification = try APNSNotification(items: items)
return try await send(notification, to: deviceToken, topic: topic,
- pushType: .background, priority: 5)
+ pushType: .background, priority: .background)
+ }
+
+ // MARK: - Request pipeline
+
+ struct PreparedRequest: Sendable {
+ let body: Data
+ let headers: [(String, String)]
+ }
+
+ func prepare(_ notification: APNSNotification, topic: String, pushType: APNSPushType,
+ priority: APNSPriority?, expiration: APNSExpiration?,
+ collapseID: String?) throws(APNSClientError) -> PreparedRequest {
+ try APNSValidation.topic(topic)
+ let priority = priority ?? pushType.defaultPriority
+ if pushType == .background && priority != .background {
+ throw .invalidPriority(priority, pushType: pushType)
+ }
+ let limit = APNSValidation.payloadLimit(for: pushType)
+ guard notification.payload.count <= limit else {
+ throw .payloadTooLarge(size: notification.payload.count, limit: limit)
+ }
+ var headers: [(String, String)] = [
+ ("content-type", "application/json"),
+ ("apns-topic", topic),
+ ("apns-push-type", pushType.rawValue),
+ ("apns-priority", String(priority.rawValue)),
+ ]
+ if let expiration {
+ headers.append(("apns-expiration", String(try expiration.headerValue(now: Date()))))
+ }
+ if let collapseID {
+ try APNSValidation.collapseID(collapseID)
+ headers.append(("apns-collapse-id", collapseID))
+ }
+ return PreparedRequest(body: notification.payload, headers: headers)
+ }
+
+ /// Validates the token, sends, and handles provider-token rejections.
+ /// Throws only ``APNSClientError`` or `CancellationError`.
+ func deliver(_ prepared: PreparedRequest, to deviceToken: String) async throws -> APNSResponse {
+ try APNSValidation.deviceToken(deviceToken)
+ // The token is validated hex, so it can't change the path.
+ guard let url = URL(string: "https://\(environment.authority)/3/device/\(deviceToken)") else {
+ throw APNSClientError.invalidDeviceToken
+ }
+ var jwt = try providerToken()
+ var response = try await perform(prepared, url: url, jwt: jwt)
+
+ switch response.error?.knownReason {
+ case .expiredProviderToken?:
+ // Retry once, but only if a new token can be minted without exceeding
+ // Apple's one-update-per-20-minutes limit.
+ if credentials.reject(jwt) {
+ jwt = try providerToken()
+ response = try await perform(prepared, url: url, jwt: jwt)
+ if response.error?.knownReason == .expiredProviderToken
+ || response.error?.knownReason == .invalidProviderToken {
+ _ = credentials.reject(jwt)
+ }
+ }
+ case .invalidProviderToken?:
+ // A new token from the same key is unlikely to help; don't retry, but don't
+ // keep reusing a rejected token past the 20-minute floor either.
+ _ = credentials.reject(jwt)
+ default:
+ // TooManyProviderTokenUpdates: keep the current token. The credentials never
+ // mint more than once per 20 minutes on their own.
+ break
+ }
+ return response
+ }
+
+ private func providerToken() throws(APNSClientError) -> String {
+ do { return try credentials.token() } catch { throw .tokenSigningFailed }
+ }
+
+ private func perform(_ prepared: PreparedRequest, url: URL, jwt: String) async throws -> APNSResponse {
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.httpBody = prepared.body
+ // The deadline is enforced by the transport. URLSession's own idle timeout is set
+ // past it so the two never race (and because corelibs truncates it to whole
+ // seconds: a value under 1 s fails at once).
+ request.timeoutInterval = (timeout + 5).rounded(.up)
+ for (name, value) in prepared.headers { request.setValue(value, forHTTPHeaderField: name) }
+ request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization")
+
+ let (data, http) = try await transport.send(request, deadline: timeout)
+
+ // APNs never redirects, and the transport refuses to follow one. A 3xx, or a
+ // response for another origin, is reported rather than read as a result.
+ if (300...399).contains(http.statusCode) || http.url.map({ !Self.sameOrigin($0, url) }) == true {
+ throw APNSClientError.unexpectedRedirect(statusCode: http.statusCode)
+ }
+
+ let apnsID = http.value(forHTTPHeaderField: "apns-id")
+ let uniqueID = http.value(forHTTPHeaderField: "apns-unique-id")
+ guard http.statusCode != 200 else {
+ return APNSResponse(statusCode: 200, apnsID: apnsID, apnsUniqueID: uniqueID, error: nil)
+ }
+ var error: APNSResponseError?
+ if !data.isEmpty, let body = try? JSONDecoder().decode(APNSErrorBody.self, from: data) {
+ error = APNSResponseError(reason: body.reason, timestamp: body.timestamp)
+ }
+ return APNSResponse(statusCode: http.statusCode, apnsID: apnsID, apnsUniqueID: uniqueID, error: error)
+ }
+
+ private static func sameOrigin(_ a: URL, _ b: URL) -> Bool {
+ a.scheme?.lowercased() == b.scheme?.lowercased()
+ && a.host?.lowercased() == b.host?.lowercased()
+ && (a.port ?? 443) == (b.port ?? 443)
+ }
+}
+
+// MARK: - Redaction
+
+extension APNSClient: CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable {
+ public var description: String {
+ "APNSClient(environment: \(environment), keyID: \(credentials.keyID), teamID: \(credentials.teamID), timeout: \(timeout))"
+ }
+ public var debugDescription: String { description }
+ /// Shows the environment, IDs and timeout only: never the key or the provider token.
+ public var customMirror: Mirror {
+ Mirror(self, children: [
+ "environment": environment,
+ "keyID": credentials.keyID,
+ "teamID": credentials.teamID,
+ "timeout": timeout,
+ ], displayStyle: .struct)
}
}
diff --git a/Sources/PerfectNotifications/APNSCredentials.swift b/Sources/PerfectNotifications/APNSCredentials.swift
index 95d90fb..8ca0378 100644
--- a/Sources/PerfectNotifications/APNSCredentials.swift
+++ b/Sources/PerfectNotifications/APNSCredentials.swift
@@ -1,74 +1,140 @@
import Crypto
import Foundation
-/// Holds an APNs `.p8` private key, mints JWT provider tokens, and rotates them
-/// automatically before the 60-minute APNs expiry.
+/// Holds an APNs `.p8` private key and mints JWT provider tokens.
+///
+/// Apple accepts a provider token for up to 60 minutes, and refuses updates more often
+/// than once every 20 minutes (`TooManyProviderTokenUpdates`). So a token is reused for
+/// 50 minutes, is replaced early only after APNs rejected it, and is never replaced
+/// within 20 minutes of being minted.
+///
+/// Ages are measured on a monotonic clock that keeps counting while the machine sleeps
+/// (`CLOCK_MONOTONIC` on Darwin, `CLOCK_BOOTTIME` on Linux), so stepping the wall clock
+/// neither keeps an old token alive nor forces new ones. The wall clock is only used for
+/// the token's `iat` claim.
final class APNSCredentials: @unchecked Sendable {
- private let keyID: String
- private let teamID: String
+ /// Proactive refresh age, safely inside Apple's 60-minute limit.
+ static let refreshAge: TimeInterval = 50 * 60
+ /// Apple's minimum interval between token updates.
+ static let minimumRefreshInterval: TimeInterval = 20 * 60
+
+ let keyID: String
+ let teamID: String
private let privateKey: P256.Signing.PrivateKey
+ private let now: @Sendable () -> Date
+ private let uptime: @Sendable () -> TimeInterval
private let lock = NSLock()
private var cachedToken: String?
- private var tokenIssuedAt: Date = .distantPast
+ /// Monotonic time of the last mint.
+ private var mintedAt: TimeInterval = 0
+ private var rejected = false
// MARK: Init
- init(keyID: String, teamID: String, privateKeyPath: String) throws {
- self.keyID = keyID
- self.teamID = teamID
+ convenience init(keyID: String, teamID: String, privateKeyPath: String) throws {
let pem = try String(contentsOfFile: privateKeyPath, encoding: .utf8)
- self.privateKey = try P256.Signing.PrivateKey(pemRepresentation: pem)
+ try self.init(keyID: keyID, teamID: teamID, pemString: pem)
}
- init(keyID: String, teamID: String, pemString: String) throws {
- self.keyID = keyID
- self.teamID = teamID
- self.privateKey = try P256.Signing.PrivateKey(pemRepresentation: pemString)
+ convenience init(keyID: String, teamID: String, pemString: String) throws {
+ // Validate the IDs before parsing the key, so a bad ID is reported as such.
+ try Self.validate(keyID: keyID, teamID: teamID)
+ try self.init(keyID: keyID, teamID: teamID,
+ key: P256.Signing.PrivateKey(pemRepresentation: pemString))
}
- init(keyID: String, teamID: String, key: P256.Signing.PrivateKey) {
+ init(keyID: String, teamID: String, key: P256.Signing.PrivateKey,
+ now: @escaping @Sendable () -> Date = { Date() },
+ uptime: @escaping @Sendable () -> TimeInterval = monotonicSeconds) throws {
+ try Self.validate(keyID: keyID, teamID: teamID)
self.keyID = keyID
self.teamID = teamID
self.privateKey = key
+ self.now = now
+ self.uptime = uptime
+ }
+
+ private static func validate(keyID: String, teamID: String) throws(APNSClientError) {
+ guard APNSValidation.isTenCharacterID(keyID) else { throw .invalidKeyID }
+ guard APNSValidation.isTenCharacterID(teamID) else { throw .invalidTeamID }
}
// MARK: Token vending
- /// Returns a valid JWT, reusing the cached token if it is less than 55 minutes old.
+ /// Returns the cached JWT, or mints a new one when the cached one is 50 minutes old,
+ /// or was rejected by APNs and is at least 20 minutes old.
func token() throws -> String {
- lock.lock()
- defer { lock.unlock() }
- if Date().timeIntervalSince(tokenIssuedAt) < 55 * 60, let cached = cachedToken {
- return cached
+ try lock.withLock {
+ let current = uptime()
+ if let cached = cachedToken {
+ let age = current - mintedAt
+ if age < Self.minimumRefreshInterval { return cached }
+ if age < Self.refreshAge && !rejected { return cached }
+ }
+ let token = try makeJWT(issuedAt: now())
+ cachedToken = token
+ mintedAt = current
+ rejected = false
+ return token
+ }
+ }
+
+ /// Records that APNs rejected `token` (`ExpiredProviderToken` or `InvalidProviderToken`).
+ ///
+ /// - Returns: `true` if the next ``token()`` call will return a different token, either
+ /// because one has already replaced `token` or because a new one may be minted now.
+ func reject(_ token: String) -> Bool {
+ lock.withLock {
+ guard token == cachedToken else { return true }
+ rejected = true
+ return uptime() - mintedAt >= Self.minimumRefreshInterval
}
- let token = try makeJWT()
- cachedToken = token
- tokenIssuedAt = Date()
- return token
}
// MARK: JWT minting
- private func makeJWT() throws -> String {
- let iat = Int(Date().timeIntervalSince1970)
- let headerJSON = "{\"alg\":\"ES256\",\"kid\":\"\(keyID)\"}"
- let payloadJSON = "{\"iss\":\"\(teamID)\",\"iat\":\(iat)}"
+ private struct Header: Encodable { let alg: String; let kid: String }
+ private struct Claims: Encodable { let iss: String; let iat: Int }
- let signingInput = b64url(headerJSON) + "." + b64url(payloadJSON)
- let hash = SHA256.hash(data: Data(signingInput.utf8))
- let sig = try privateKey.signature(for: hash)
- return signingInput + "." + b64urlData(sig.rawRepresentation)
+ private func makeJWT(issuedAt: Date) throws -> String {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let header = try encoder.encode(Header(alg: "ES256", kid: keyID))
+ let claims = try encoder.encode(Claims(iss: teamID, iat: Int(issuedAt.timeIntervalSince1970)))
+ let signingInput = base64URL(header) + "." + base64URL(claims)
+ let signature = try privateKey.signature(for: Data(signingInput.utf8))
+ return signingInput + "." + base64URL(signature.rawRepresentation)
}
}
-// MARK: - Base64URL helpers (no padding, URL-safe alphabet)
+// MARK: - Redaction
-private func b64url(_ string: String) -> String {
- b64urlData(Data(string.utf8))
+extension APNSCredentials: CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable {
+ var description: String { "APNSCredentials(keyID: \(keyID), teamID: \(teamID), key: )" }
+ var debugDescription: String { description }
+ var customMirror: Mirror {
+ Mirror(self, children: ["keyID": keyID, "teamID": teamID, "key": ""],
+ displayStyle: .class)
+ }
}
-private func b64urlData(_ data: Data) -> String {
+// MARK: - Monotonic clock
+
+/// Seconds on a clock that never steps and keeps counting through sleep.
+@Sendable func monotonicSeconds() -> TimeInterval {
+ var ts = timespec()
+ #if canImport(Darwin)
+ clock_gettime(CLOCK_MONOTONIC, &ts)
+ #else
+ clock_gettime(CLOCK_BOOTTIME, &ts)
+ #endif
+ return TimeInterval(ts.tv_sec) + TimeInterval(ts.tv_nsec) / 1_000_000_000
+}
+
+// MARK: - Base64URL (no padding, URL-safe alphabet)
+
+func base64URL(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
diff --git a/Sources/PerfectNotifications/APNSJSONValue.swift b/Sources/PerfectNotifications/APNSJSONValue.swift
new file mode 100644
index 0000000..8608ef1
--- /dev/null
+++ b/Sources/PerfectNotifications/APNSJSONValue.swift
@@ -0,0 +1,166 @@
+import Foundation
+
+/// A JSON value: the `Sendable` replacement for the `Any` that 5.x's
+/// `customPayload(String, Any)` accepted.
+///
+/// Literals convert directly, so `.customPayload("orderId", "ORD-123")`,
+/// `.customPayload("amount", 49)`, `.customPayload("refresh", true)` and
+/// `.customPayload("tags", ["a", "b"])` compile as before. Wrap variables explicitly
+/// (`.string(orderID)`, `.int(count)`), or convert an existing `JSONSerialization`-style
+/// value with ``init(jsonObject:)``.
+public enum APNSJSONValue: Sendable, Hashable, Codable {
+ case string(String)
+ case int(Int)
+ case double(Double)
+ case bool(Bool)
+ case null
+ case array([APNSJSONValue])
+ case object([String: APNSJSONValue])
+
+ // MARK: Codable
+
+ public init(from decoder: any Decoder) throws {
+ let c = try decoder.singleValueContainer()
+ if c.decodeNil() { self = .null; return }
+ if let b = try? c.decode(Bool.self) { self = .bool(b); return }
+ if let i = try? c.decode(Int.self) { self = .int(i); return }
+ if let d = try? c.decode(Double.self) { self = .double(d); return }
+ if let s = try? c.decode(String.self) { self = .string(s); return }
+ if let a = try? c.decode([APNSJSONValue].self) { self = .array(a); return }
+ if let o = try? c.decode([String: APNSJSONValue].self) { self = .object(o); return }
+ throw DecodingError.dataCorruptedError(in: c, debugDescription: "not a JSON value")
+ }
+
+ public func encode(to encoder: any Encoder) throws {
+ var c = encoder.singleValueContainer()
+ switch self {
+ case .string(let s): try c.encode(s)
+ case .int(let i): try c.encode(i)
+ case .double(let d): try c.encode(d)
+ case .bool(let b): try c.encode(b)
+ case .null: try c.encodeNil()
+ case .array(let a): try c.encode(a)
+ case .object(let o): try c.encode(o)
+ }
+ }
+
+ // MARK: Conversion from untyped values
+
+ /// Convert an untyped JSON-compatible value (the kind `JSONSerialization` produces
+ /// or 5.x's `customPayload` accepted): `String`, `Bool`, integers, `Double`/`Float`,
+ /// `NSNumber`, `NSNull`, `[Any]`, `[String: Any]`, `Optional.none`, or an
+ /// `APNSJSONValue`.
+ ///
+ /// The conversion happens immediately, so the result is genuinely `Sendable`.
+ ///
+ /// - Throws: ``APNSClientError/invalidJSONValue(_:)`` naming the first unsupported type
+ /// (or a non-finite or out-of-range number), or ``APNSClientError/jsonNestingTooDeep(limit:)``
+ /// past 64 levels.
+ public init(jsonObject value: Any) throws {
+ self = try Self.convert(value, depth: 0)
+ }
+
+ /// The nesting depth: 0 for a scalar, 1 for a flat array or object. Iterative, so a
+ /// hostile value can't overflow the stack while being measured.
+ var depth: Int {
+ var deepest = 0
+ var stack: [(APNSJSONValue, Int)] = [(self, 0)]
+ while let (value, level) = stack.popLast() {
+ switch value {
+ case .array(let a):
+ deepest = max(deepest, level + 1)
+ stack.append(contentsOf: a.map { ($0, level + 1) })
+ case .object(let o):
+ deepest = max(deepest, level + 1)
+ stack.append(contentsOf: o.values.map { ($0, level + 1) })
+ default:
+ break
+ }
+ }
+ return deepest
+ }
+
+ private static func convert(_ value: Any, depth: Int) throws -> APNSJSONValue {
+ // `depth` counts the containers around `value`.
+ // Unwrap Optional boxed in Any.
+ let mirror = Mirror(reflecting: value)
+ if mirror.displayStyle == .optional {
+ guard let wrapped = mirror.children.first?.value else { return .null }
+ return try Self.convert(wrapped, depth: depth)
+ }
+ // Exact native Swift types first: on Darwin `as? NSNumber` would also match
+ // these through bridging, and `as? Bool` would match NSNumber(1).
+ switch value {
+ case let v as APNSJSONValue: return v
+ case let v as String: return .string(v)
+ case let v as Bool where type(of: value) == Bool.self: return .bool(v)
+ case let v as Int where type(of: value) == Int.self: return .int(v)
+ case let v as Int64 where type(of: value) == Int64.self:
+ guard let i = Int(exactly: v) else { throw APNSClientError.invalidJSONValue("Int64 out of range") }
+ return .int(i)
+ case let v as Int32 where type(of: value) == Int32.self: return .int(Int(v))
+ case let v as UInt where type(of: value) == UInt.self:
+ guard let i = Int(exactly: v) else { throw APNSClientError.invalidJSONValue("UInt out of range") }
+ return .int(i)
+ case let v as UInt64 where type(of: value) == UInt64.self:
+ guard let i = Int(exactly: v) else { throw APNSClientError.invalidJSONValue("UInt64 out of range") }
+ return .int(i)
+ case let v as Double where type(of: value) == Double.self: return try finite(v)
+ case let v as Float where type(of: value) == Float.self: return try finite(Double(v))
+ case is NSNull: return .null
+ case let n as NSNumber: return try convert(number: n)
+ case let a as [Any]:
+ guard depth < APNSValidation.maxJSONDepth else { throw APNSClientError.jsonNestingTooDeep(limit: APNSValidation.maxJSONDepth) }
+ return .array(try a.map { try Self.convert($0, depth: depth + 1) })
+ case let o as [String: Any]:
+ guard depth < APNSValidation.maxJSONDepth else { throw APNSClientError.jsonNestingTooDeep(limit: APNSValidation.maxJSONDepth) }
+ return .object(try o.mapValues { try Self.convert($0, depth: depth + 1) })
+ default:
+ throw APNSClientError.invalidJSONValue(String(describing: type(of: value)))
+ }
+ }
+
+ private static func finite(_ d: Double) throws -> APNSJSONValue {
+ guard d.isFinite else { throw APNSClientError.invalidJSONValue("non-finite number") }
+ return .double(d)
+ }
+
+ private static func convert(number n: NSNumber) throws -> APNSJSONValue {
+ #if canImport(Darwin)
+ if CFGetTypeID(n) == CFBooleanGetTypeID() { return .bool(n.boolValue) }
+ #endif
+ switch String(cString: n.objCType) {
+ case "c", "B":
+ #if canImport(Darwin)
+ return .int(n.intValue) // a real char-typed number, not a CFBoolean
+ #else
+ return .bool(n.boolValue)
+ #endif
+ case "f", "d":
+ return try finite(n.doubleValue)
+ case "Q", "L":
+ // Unsigned 64-bit: `int64Value` would wrap values above Int64.max to negatives.
+ guard let i = Int(exactly: n.uint64Value) else { throw APNSClientError.invalidJSONValue("number out of range") }
+ return .int(i)
+ default:
+ guard let i = Int(exactly: n.int64Value) else { throw APNSClientError.invalidJSONValue("number out of range") }
+ return .int(i)
+ }
+ }
+}
+
+// MARK: - Literals
+
+extension APNSJSONValue: ExpressibleByStringLiteral, ExpressibleByIntegerLiteral,
+ ExpressibleByFloatLiteral, ExpressibleByBooleanLiteral, ExpressibleByNilLiteral,
+ ExpressibleByArrayLiteral, ExpressibleByDictionaryLiteral {
+ public init(stringLiteral value: String) { self = .string(value) }
+ public init(integerLiteral value: Int) { self = .int(value) }
+ public init(floatLiteral value: Double) { self = .double(value) }
+ public init(booleanLiteral value: Bool) { self = .bool(value) }
+ public init(nilLiteral: ()) { self = .null }
+ public init(arrayLiteral elements: APNSJSONValue...) { self = .array(elements) }
+ public init(dictionaryLiteral elements: (String, APNSJSONValue)...) {
+ self = .object(Dictionary(elements, uniquingKeysWith: { _, last in last }))
+ }
+}
diff --git a/Sources/PerfectNotifications/APNSNotification.swift b/Sources/PerfectNotifications/APNSNotification.swift
index e085963..b48c9bb 100644
--- a/Sources/PerfectNotifications/APNSNotification.swift
+++ b/Sources/PerfectNotifications/APNSNotification.swift
@@ -1,11 +1,7 @@
import Foundation
/// Items that compose the `aps` dictionary and top-level custom keys of a push payload.
-///
-/// - Note: `@unchecked Sendable` because ``customPayload(_:_:)`` accepts `Any` for
-/// JSON flexibility. In practice only JSON-compatible types (String, Int, Double,
-/// Bool, [String: Any], [Any]) should be used.
-public enum APNSNotificationItem: @unchecked Sendable {
+public enum APNSNotificationItem: Sendable, Equatable {
// MARK: Alert
/// Main body text of the alert banner.
case alertBody(String)
@@ -43,8 +39,11 @@ public enum APNSNotificationItem: @unchecked Sendable {
case relevanceScore(Double)
// MARK: Custom
- /// A top-level JSON key/value pair outside the `aps` dictionary.
- case customPayload(String, Any)
+ /// A top-level JSON key/value pair outside the `aps` dictionary. The key `aps` is
+ /// reserved and makes ``APNSNotification/init(items:)`` throw.
+ ///
+ /// Literals work directly: `.customPayload("orderId", "ORD-123")`, `.customPayload("n", 49)`.
+ case customPayload(String, APNSJSONValue)
public enum InterruptionLevel: String, Sendable {
case passive
@@ -55,16 +54,38 @@ public enum APNSNotificationItem: @unchecked Sendable {
}
/// A push notification payload ready to send.
-public struct APNSNotification: Sendable {
- let payload: Data
+///
+/// The size limit (4096 bytes, or 5120 for VoIP) depends on the push type, so it is
+/// checked when the notification is sent.
+public struct APNSNotification: Sendable, Equatable {
+ /// The encoded JSON payload.
+ public let payload: Data
/// Build from notification items (recommended for standard alerts).
+ ///
+ /// - Throws: ``APNSClientError/reservedPayloadKey(_:)`` for a custom `aps` key,
+ /// ``APNSClientError/invalidPayload(_:)`` for a negative badge or a relevance score
+ /// outside 0–1, ``APNSClientError/jsonNestingTooDeep(limit:)`` for custom values nested
+ /// past 64 levels, or an `EncodingError` for a non-finite number.
public init(items: [APNSNotificationItem]) throws {
self.payload = try APNSNotificationItem.encodePayload(items)
}
- /// Supply a raw pre-encoded JSON payload.
- public init(raw: Data) {
+ /// Supply a raw pre-encoded JSON payload, sent as is.
+ ///
+ /// - Throws: ``APNSClientError/invalidPayload(_:)`` unless the bytes are a JSON object
+ /// with an `aps` dictionary, ``APNSClientError/payloadTooLarge(size:limit:)`` past
+ /// 5120 bytes (the largest limit; the push type's limit is checked when sending), or
+ /// ``APNSClientError/jsonNestingTooDeep(limit:)``.
+ public init(raw: Data) throws {
+ guard raw.count <= 5120 else { throw APNSClientError.payloadTooLarge(size: raw.count, limit: 5120) }
+ guard let value = try? JSONDecoder().decode(APNSJSONValue.self, from: raw),
+ case .object(let top) = value, case .object? = top["aps"] else {
+ throw APNSClientError.invalidPayload("raw payload must be a JSON object with an \"aps\" dictionary")
+ }
+ guard value.depth <= APNSValidation.maxJSONDepth else {
+ throw APNSClientError.jsonNestingTooDeep(limit: APNSValidation.maxJSONDepth)
+ }
self.payload = raw
}
@@ -78,50 +99,57 @@ public struct APNSNotification: Sendable {
extension APNSNotificationItem {
static func encodePayload(_ items: [APNSNotificationItem]) throws -> Data {
- var top = [String: Any]()
- var aps = [String: Any]()
- var alert = [String: Any]()
+ var top = [String: APNSJSONValue]()
+ var aps = [String: APNSJSONValue]()
+ var alert = [String: APNSJSONValue]()
var alertBody: String?
- var hasAlertDict = false
for item in items {
switch item {
- case .alertBody(let s): alertBody = s
- case .alertTitle(let s): alert["title"] = s; hasAlertDict = true
- case .alertSubtitle(let s): alert["subtitle"] = s; hasAlertDict = true
+ case .alertBody(let s): alertBody = s
+ case .alertTitle(let s): alert["title"] = .string(s)
+ case .alertSubtitle(let s): alert["subtitle"] = .string(s)
case .alertTitleLoc(let s, let a):
- alert["title-loc-key"] = s
- if let args = a { alert["title-loc-args"] = args }
- hasAlertDict = true
- case .alertActionLoc(let s):
- alert["action-loc-key"] = s; hasAlertDict = true
+ alert["title-loc-key"] = .string(s)
+ if let args = a { alert["title-loc-args"] = .array(args.map(APNSJSONValue.string)) }
+ case .alertActionLoc(let s): alert["action-loc-key"] = .string(s)
case .alertLoc(let s, let a):
- alert["loc-key"] = s
- if let args = a { alert["loc-args"] = args }
- hasAlertDict = true
- case .alertLaunchImage(let s):
- alert["launch-image"] = s; hasAlertDict = true
- case .badge(let i): aps["badge"] = i
- case .sound(let s): aps["sound"] = s
- case .contentAvailable: aps["content-available"] = 1
- case .mutableContent: aps["mutable-content"] = 1
- case .category(let s): aps["category"] = s
- case .threadId(let s): aps["thread-id"] = s
- case .targetContentId(let s):aps["target-content-id"] = s
- case .interruptionLevel(let l): aps["interruption-level"] = l.rawValue
- case .relevanceScore(let d): aps["relevance-score"] = d
- case .customPayload(let k, let v): top[k] = v
+ alert["loc-key"] = .string(s)
+ if let args = a { alert["loc-args"] = .array(args.map(APNSJSONValue.string)) }
+ case .alertLaunchImage(let s): alert["launch-image"] = .string(s)
+ case .badge(let i):
+ guard i >= 0 else { throw APNSClientError.invalidPayload("badge must not be negative") }
+ aps["badge"] = .int(i)
+ case .sound(let s): aps["sound"] = .string(s)
+ case .contentAvailable: aps["content-available"] = 1
+ case .mutableContent: aps["mutable-content"] = 1
+ case .category(let s): aps["category"] = .string(s)
+ case .threadId(let s): aps["thread-id"] = .string(s)
+ case .targetContentId(let s): aps["target-content-id"] = .string(s)
+ case .interruptionLevel(let l): aps["interruption-level"] = .string(l.rawValue)
+ case .relevanceScore(let d):
+ guard (0...1).contains(d) else { throw APNSClientError.invalidPayload("relevanceScore must be 0-1") }
+ aps["relevance-score"] = .double(d)
+ case .customPayload(let k, let v):
+ guard k != "aps" else { throw APNSClientError.reservedPayloadKey(k) }
+ // The value sits one level below the top-level object.
+ guard v.depth < APNSValidation.maxJSONDepth else {
+ throw APNSClientError.jsonNestingTooDeep(limit: APNSValidation.maxJSONDepth)
+ }
+ top[k] = v
}
}
if let body = alertBody {
- if hasAlertDict { alert["body"] = body; aps["alert"] = alert }
- else { aps["alert"] = body }
- } else if hasAlertDict {
- aps["alert"] = alert
+ if alert.isEmpty { aps["alert"] = .string(body) }
+ else { alert["body"] = .string(body); aps["alert"] = .object(alert) }
+ } else if !alert.isEmpty {
+ aps["alert"] = .object(alert)
}
- top["aps"] = aps
- return try JSONSerialization.data(withJSONObject: top)
+ top["aps"] = .object(aps)
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ return try encoder.encode(top)
}
}
diff --git a/Sources/PerfectNotifications/APNSPushType.swift b/Sources/PerfectNotifications/APNSPushType.swift
index 53b28b6..198402f 100644
--- a/Sources/PerfectNotifications/APNSPushType.swift
+++ b/Sources/PerfectNotifications/APNSPushType.swift
@@ -1,7 +1,13 @@
import Foundation
-/// The type of push notification being sent.
+/// The type of push notification being sent (`apns-push-type`).
/// Required by APNs since iOS 13. Each value carries a default priority.
+///
+/// APNs also expects a matching topic suffix for some types: `.voip` → `.voip`,
+/// `.complication` → `.complication`, `.fileProvider` → `.pushkit.fileprovider`,
+/// `.liveActivity` → `.push-type.liveactivity`, `.location` → `.location-query`,
+/// `.pushToTalk` → `.voip-ptt`. The client checks the topic's syntax, not the suffix;
+/// APNs answers a mismatch with `DeviceTokenNotForTopic` or `TopicDisallowed`.
public enum APNSPushType: String, Sendable, CaseIterable {
case alert
case background
@@ -13,34 +19,48 @@ public enum APNSPushType: String, Sendable, CaseIterable {
case liveActivity = "liveactivity"
case pushToTalk = "pushtotalk"
- /// Default `apns-priority` for this type. Background must use 5; others use 10.
- public var defaultPriority: Int {
- self == .background ? 5 : 10
+ /// Default `apns-priority` for this type: `.background` for background pushes
+ /// (APNs requires it), `.immediate` for everything else.
+ public var defaultPriority: APNSPriority {
+ self == .background ? .background : .immediate
}
}
-/// Push notification priority.
-public enum APNSPriority: Int, Sendable {
- /// Immediate delivery (10). Use for user-visible alerts.
+/// Push notification priority (`apns-priority`).
+public enum APNSPriority: Int, Sendable, CaseIterable {
+ /// 10: deliver immediately. The default for everything but background pushes.
case immediate = 10
- /// Power-friendly delivery (5). Required for background push.
+ /// 5: deliver based on the power considerations of the device. Required for
+ /// `APNSPushType.background`; also valid for alerts that can wait.
case background = 5
+ /// 1: prioritize the device's power considerations over all other factors, and
+ /// don't wake the device.
+ case low = 1
}
-/// When to discard an undeliverable notification.
-public enum APNSExpiration: Sendable {
- /// Discard immediately if the device is not reachable.
+/// When to discard an undeliverable notification (`apns-expiration`).
+public enum APNSExpiration: Sendable, Equatable {
+ /// Discard immediately if the device is not reachable (`apns-expiration: 0`).
case immediate
- /// Keep until this many seconds from now.
+ /// Keep until this many seconds from now. Must not be negative.
case relative(Int)
- /// Keep until this absolute UTC epoch second.
+ /// Keep until this absolute UTC epoch second. Must not be negative.
case absolute(Int)
- var headerValue: Int {
+ /// The header value, or ``APNSClientError/invalidExpiration`` for a negative value
+ /// or a relative value that overflows.
+ func headerValue(now: Date) throws(APNSClientError) -> Int {
switch self {
- case .immediate: return 0
- case .relative(let s): return Int(Date().timeIntervalSince1970) + s
- case .absolute(let t): return t
+ case .immediate:
+ return 0
+ case .relative(let seconds):
+ guard seconds >= 0 else { throw .invalidExpiration }
+ let (sum, overflow) = Int(now.timeIntervalSince1970).addingReportingOverflow(seconds)
+ guard !overflow else { throw .invalidExpiration }
+ return sum
+ case .absolute(let epoch):
+ guard epoch >= 0 else { throw .invalidExpiration }
+ return epoch
}
}
}
diff --git a/Sources/PerfectNotifications/APNSResponse.swift b/Sources/PerfectNotifications/APNSResponse.swift
index 4c7bcd0..8575afd 100644
--- a/Sources/PerfectNotifications/APNSResponse.swift
+++ b/Sources/PerfectNotifications/APNSResponse.swift
@@ -1,36 +1,99 @@
import Foundation
/// The outcome of a single APNs send request.
-public struct APNSResponse: Sendable {
+public struct APNSResponse: Sendable, Equatable {
/// HTTP status code returned by APNs (200 = success).
public let statusCode: Int
- /// The notification ID assigned by APNs (`apns-id` response header).
+ /// The notification ID (`apns-id` response header).
public let apnsID: String?
- /// Non-nil when APNs rejected the notification.
+ /// The `apns-unique-id` response header. APNs only sends it in the sandbox environment;
+ /// it identifies the notification in the Push Notifications Console delivery log.
+ public let apnsUniqueID: String?
+ /// Non-nil when APNs rejected the notification and said why.
public let error: APNSResponseError?
public var isSuccess: Bool { statusCode == 200 }
}
/// An error reason returned by the APNs gateway.
-///
-/// Common reason strings: `BadDeviceToken`, `DeviceTokenNotForTopic`, `Unregistered`,
-/// `ExpiredToken`, `PayloadTooLarge`, `InvalidProviderToken`, `ExpiredProviderToken`,
-/// `TooManyRequests`, `InternalServerError`, `ServiceUnavailable`.
public struct APNSResponseError: Sendable, Equatable, CustomStringConvertible {
+ /// The `reason` string, such as `BadDeviceToken`. See ``APNSErrorReason`` for the known values.
public let reason: String
- /// For `Unregistered`/`ExpiredToken` — epoch timestamp when the token became invalid.
- public let timestamp: Int?
+ /// For HTTP 410 (`Unregistered`, `ExpiredToken`): the last time APNs confirmed that the
+ /// device token was no longer valid for the topic, in **milliseconds** since the epoch.
+ public let timestamp: Int64?
+
+ /// ``reason`` as a known value, or `nil` for a reason this version doesn't know.
+ public var knownReason: APNSErrorReason? { APNSErrorReason(rawValue: reason) }
+
+ /// ``timestamp`` as a `Date`.
+ public var invalidatedAt: Date? {
+ timestamp.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000) }
+ }
public var description: String {
guard let ts = timestamp else { return reason }
- return "\(reason) (invalidated at \(ts))"
+ return "\(reason) (invalidated at \(ts) ms)"
}
}
+/// The `reason` values APNs documents (from Apple's "Handling notification responses from
+/// APNs"), grouped by HTTP status. APNs may add reasons; ``APNSResponseError/reason``
+/// always carries the raw string.
+public enum APNSErrorReason: String, Sendable, CaseIterable {
+ // 400
+ case badCollapseID = "BadCollapseId"
+ case badDeviceToken = "BadDeviceToken"
+ case badExpirationDate = "BadExpirationDate"
+ case badMessageID = "BadMessageId"
+ case badPriority = "BadPriority"
+ case badTopic = "BadTopic"
+ case deviceTokenNotForTopic = "DeviceTokenNotForTopic"
+ case duplicateHeaders = "DuplicateHeaders"
+ case idleTimeout = "IdleTimeout"
+ case invalidPushType = "InvalidPushType"
+ case missingDeviceToken = "MissingDeviceToken"
+ case missingTopic = "MissingTopic"
+ case payloadEmpty = "PayloadEmpty"
+ case topicDisallowed = "TopicDisallowed"
+ // 403
+ case badCertificate = "BadCertificate"
+ case badCertificateEnvironment = "BadCertificateEnvironment"
+ case expiredProviderToken = "ExpiredProviderToken"
+ case forbidden = "Forbidden"
+ case invalidProviderToken = "InvalidProviderToken"
+ case missingProviderToken = "MissingProviderToken"
+ case unrelatedKeyIDInToken = "UnrelatedKeyIdInToken"
+ case badEnvironmentKeyInToken = "BadEnvironmentKeyInToken"
+ // 404, 405
+ case badPath = "BadPath"
+ case methodNotAllowed = "MethodNotAllowed"
+ // 410
+ case expiredToken = "ExpiredToken"
+ case unregistered = "Unregistered"
+ // 413
+ case payloadTooLarge = "PayloadTooLarge"
+ // 429
+ case tooManyProviderTokenUpdates = "TooManyProviderTokenUpdates"
+ case tooManyRequests = "TooManyRequests"
+ // 500, 503
+ case internalServerError = "InternalServerError"
+ case serviceUnavailable = "ServiceUnavailable"
+ case shutdown = "Shutdown"
+}
+
+/// The per-token outcome of sending one notification to several device tokens.
+public struct APNSBatchResult: Sendable, Equatable {
+ public let deviceToken: String
+ /// The APNs response (which may itself be a rejection), or the client error that
+ /// stopped this token's request: an invalid token, a network failure, a timeout, or
+ /// ``APNSClientError/cancelled`` for tokens reached after the batch was cancelled.
+ public let result: Result
+}
+
// MARK: - Internal decoding
struct APNSErrorBody: Decodable {
let reason: String
- let timestamp: Int?
+ let timestamp: Int64?
}
diff --git a/Sources/PerfectNotifications/APNSTransport.swift b/Sources/PerfectNotifications/APNSTransport.swift
new file mode 100644
index 0000000..d1a503e
--- /dev/null
+++ b/Sources/PerfectNotifications/APNSTransport.swift
@@ -0,0 +1,290 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+/// The client's HTTP transport: a `URLSession` driven through delegate callbacks.
+///
+/// It is built from the caller's session's configuration (so protocol classes, proxies and
+/// TLS settings carry over), with its own delegate, because:
+///
+/// - **Redirects must not be followed.** APNs never redirects, and following one would
+/// send the provider token to another origin. corelibs Foundation ignores per-task
+/// delegates, so only a session delegate can refuse a redirect on Linux.
+/// - **Cancellation must not go through Swift task cancellation.** On Linux, cancelling a
+/// `Task` that awaits `URLSession.data(for:)` calls `URLSessionTask.cancel()` from the
+/// cancellation handler, with the task's status lock held, and `cancel()` waits for the
+/// session's work queue. If that queue is at that moment resuming the same task's
+/// continuation (which needs the status lock), both block, and every concurrency thread
+/// that touches the session follows. Here the deadline and caller cancellation resume
+/// our own continuation and then cancel the `URLSessionTask` on a Dispatch thread, with
+/// no lock held.
+/// - **The body cap is enforced as data arrives**, on both platforms.
+///
+/// Authentication challenges are forwarded to the caller's session delegate, so a custom
+/// trust evaluation (certificate pinning, a private CA in tests) keeps working.
+final class APNSTransport: Sendable {
+ private let session: URLSession
+ private let delegate: SessionDelegate
+
+ init(basedOn given: URLSession, maxBodySize: Int) {
+ delegate = SessionDelegate(forwardingTo: given, maxBodySize: maxBodySize)
+ session = URLSession(configuration: given.configuration, delegate: delegate, delegateQueue: nil)
+ }
+
+ deinit {
+ // The session retains its delegate until it is invalidated.
+ session.finishTasksAndInvalidate()
+ }
+
+ /// Sends `request`, returning the body (at most `maxBodySize` bytes) and the response.
+ ///
+ /// Returns, throws ``APNSClientError/timedOut`` at the deadline, or throws
+ /// `CancellationError` when the calling task is cancelled, without waiting for
+ /// `URLSession` to wind the transfer down.
+ func send(_ request: URLRequest, deadline seconds: TimeInterval) async throws -> (Data, HTTPURLResponse) {
+ let flight = InFlight(limit: delegate.maxBodySize)
+ return try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(Data, HTTPURLResponse), any Error>) in
+ guard flight.attach(continuation) else { return } // cancelled before we started
+ let task = session.dataTask(with: request)
+ delegate.register(flight, for: task)
+ // A timer source with a small leeway: `asyncAfter` on a global queue fired 3–6 %
+ // late on macOS.
+ let timer = DispatchSource.makeTimerSource(queue: .global())
+ timer.schedule(deadline: .now() + seconds, leeway: .milliseconds(10))
+ timer.setEventHandler { flight.finish(.failure(APNSClientError.timedOut)) }
+ timer.resume()
+ guard flight.start(task: task, timer: timer) else {
+ // Finished in the meantime (cancelled). Cancel the never-resumed task too,
+ // so the session can finish invalidating.
+ timer.cancel()
+ delegate.unregister(task)
+ DispatchQueue.global().async { task.cancel() }
+ return
+ }
+ task.resume()
+ }
+ } onCancel: {
+ // Never resume or cancel from inside the cancellation handler: it runs with
+ // the task's status lock held.
+ DispatchQueue.global().async { flight.finish(.failure(CancellationError())) }
+ }
+ }
+}
+
+// MARK: - One request
+
+private final class InFlight: @unchecked Sendable {
+ let limit: Int
+ private let lock = NSLock()
+ private var continuation: CheckedContinuation<(Data, HTTPURLResponse), any Error>?
+ private var pending: Result<(Data, HTTPURLResponse), any Error>?
+ private var task: URLSessionTask?
+ private var timer: DispatchSourceTimer?
+ /// Set while the delegate is answering a redirect; a cancel requested meanwhile waits.
+ private var answeringRedirect = false
+ private var deferredCancel: URLSessionTask?
+ private var done = false
+ private var data = Data()
+ private var response: HTTPURLResponse?
+
+ init(limit: Int) { self.limit = limit }
+
+ /// Returns `false` if the request already finished (it was cancelled first); the
+ /// continuation has then been resumed.
+ func attach(_ c: CheckedContinuation<(Data, HTTPURLResponse), any Error>) -> Bool {
+ let early: Result<(Data, HTTPURLResponse), any Error>? = lock.withLock {
+ if let pending { return pending }
+ continuation = c
+ return nil
+ }
+ if let early { c.resume(with: early); return false }
+ return true
+ }
+
+ /// Returns `false` if the request finished in the meantime.
+ func start(task: URLSessionTask, timer: DispatchSourceTimer) -> Bool {
+ lock.withLock {
+ guard !done else { return false }
+ self.task = task
+ self.timer = timer
+ return true
+ }
+ }
+
+ func received(_ response: URLResponse) -> Bool {
+ guard let http = response as? HTTPURLResponse else {
+ finish(.failure(APNSClientError.invalidResponse))
+ return false
+ }
+ if http.expectedContentLength > Int64(limit) {
+ finish(.failure(APNSClientError.responseTooLarge(limit: limit)))
+ return false
+ }
+ lock.withLock { self.response = http }
+ return true
+ }
+
+ func received(_ chunk: Data) {
+ let over: Bool = lock.withLock {
+ guard !done else { return false } // ignore anything after the end
+ data.append(chunk)
+ return data.count > limit
+ }
+ if over { finish(.failure(APNSClientError.responseTooLarge(limit: limit))) }
+ }
+
+ func completed(error: (any Error)?) {
+ if let error {
+ if let urlError = error as? URLError {
+ if urlError.code == .timedOut { finish(.failure(APNSClientError.timedOut)); return }
+ if urlError.code == .cancelled { finish(.failure(APNSClientError.network("cancelled"))); return }
+ finish(.failure(APNSClientError.network("URLError \(urlError.code.rawValue): \(urlError.localizedDescription)")))
+ } else {
+ finish(.failure(APNSClientError.network(error.localizedDescription)))
+ }
+ return
+ }
+ let result: Result<(Data, HTTPURLResponse), any Error> = lock.withLock {
+ guard let response else { return .failure(APNSClientError.invalidResponse) }
+ return .success((data, response))
+ }
+ finish(result)
+ }
+
+ /// Resolves the request once: resumes the caller, then cancels the transfer (off the
+ /// caller's thread) if it is still running.
+ func finish(_ result: Result<(Data, HTTPURLResponse), any Error>) {
+ let (c, task, timer): (CheckedContinuation<(Data, HTTPURLResponse), any Error>?, URLSessionTask?, DispatchSourceTimer?) = lock.withLock {
+ guard !done else { return (nil, nil, nil) }
+ done = true
+ data = Data()
+ guard let c = continuation else {
+ pending = result // not attached yet
+ return (nil, nil, nil)
+ }
+ continuation = nil
+ defer { self.task = nil; self.timer = nil }
+ var task = self.task
+ if answeringRedirect, case .failure = result {
+ // corelibs traps if its redirect callback is answered after the task was
+ // cancelled; cancel once the answer is in.
+ deferredCancel = task
+ task = nil
+ }
+ return (c, task, self.timer)
+ }
+ c?.resume(with: result)
+ timer?.cancel()
+ if let task, case .failure = result {
+ DispatchQueue.global().async { task.cancel() }
+ }
+ }
+
+ /// Answers a redirect callback (with `respond`) only while the request is live, so the
+ /// answer always comes before any cancel. `respond` runs outside the lock, in case the
+ /// URL loading system calls back into the delegate synchronously; a cancel requested
+ /// meanwhile is held until it returns.
+ func refuseRedirect(_ respond: () -> Void) {
+ let live = lock.withLock { () -> Bool in
+ guard !done else { return false }
+ answeringRedirect = true
+ return true
+ }
+ // When the request is already done, its cancel has been issued, and that resolves the
+ // pending redirect on both platforms; answering it too would trap corelibs.
+ guard live else { return }
+ respond()
+ let cancel: URLSessionTask? = lock.withLock {
+ answeringRedirect = false
+ defer { deferredCancel = nil }
+ return deferredCancel
+ }
+ if let cancel { DispatchQueue.global().async { cancel.cancel() } }
+ }
+}
+
+// MARK: - Session delegate
+
+private final class SessionDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable {
+ let maxBodySize: Int
+ /// The caller's session, passed to its delegate when forwarding challenges.
+ private let callerSession: URLSession
+ private let lock = NSLock()
+ private var flights: [Int: InFlight] = [:]
+
+ init(forwardingTo callerSession: URLSession, maxBodySize: Int) {
+ self.callerSession = callerSession
+ self.maxBodySize = maxBodySize
+ }
+
+ func register(_ flight: InFlight, for task: URLSessionTask) {
+ lock.withLock { flights[task.taskIdentifier] = flight }
+ }
+
+ func unregister(_ task: URLSessionTask) {
+ _ = lock.withLock { flights.removeValue(forKey: task.taskIdentifier) }
+ }
+
+ private func flight(_ task: URLSessionTask) -> InFlight? {
+ lock.withLock { flights[task.taskIdentifier] }
+ }
+
+ // MARK: Data
+
+ func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse,
+ completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void) {
+ let keep = flight(dataTask)?.received(response) ?? false
+ completionHandler(keep ? .allow : .cancel)
+ }
+
+ func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
+ flight(dataTask)?.received(data)
+ }
+
+ func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) {
+ let f = lock.withLock { flights.removeValue(forKey: task.taskIdentifier) }
+ f?.completed(error: error)
+ }
+
+ // MARK: Redirects: never followed
+
+ func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
+ newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void) {
+ if let flight = flight(task) {
+ flight.refuseRedirect { completionHandler(nil) }
+ } else {
+ // Not ours (or already completed): refuse without touching any state.
+ completionHandler(nil)
+ }
+ }
+
+ // MARK: Challenges: forwarded to the caller's delegate
+
+ #if canImport(FoundationNetworking)
+ func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge,
+ completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
+ if let forward = callerSession.delegate as? URLSessionTaskDelegate {
+ forward.urlSession(callerSession, task: task, didReceive: challenge, completionHandler: completionHandler)
+ } else if let forward = callerSession.delegate {
+ forward.urlSession(callerSession, didReceive: challenge, completionHandler: completionHandler)
+ } else {
+ completionHandler(.performDefaultHandling, nil)
+ }
+ }
+ #else
+ func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge,
+ completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
+ let forward = callerSession.delegate
+ if (forward as? URLSessionTaskDelegate)?.urlSession?(callerSession, task: task, didReceive: challenge,
+ completionHandler: completionHandler) != nil {
+ return
+ }
+ if forward?.urlSession?(callerSession, didReceive: challenge, completionHandler: completionHandler) != nil {
+ return
+ }
+ completionHandler(.performDefaultHandling, nil)
+ }
+ #endif
+}
diff --git a/Sources/PerfectNotifications/APNSValidation.swift b/Sources/PerfectNotifications/APNSValidation.swift
new file mode 100644
index 0000000..8621818
--- /dev/null
+++ b/Sources/PerfectNotifications/APNSValidation.swift
@@ -0,0 +1,83 @@
+import Foundation
+
+/// Client-side checks on everything that goes into the request line, the headers or
+/// the JWT. APNs would reject most bad values itself, but an unchecked device token or
+/// topic could change the request path or add headers, so they are refused before any
+/// request is built.
+enum APNSValidation {
+
+ /// Device tokens are hex. They are 32 bytes (64 hex digits) today; Apple says not to
+ /// hard-code the length, so up to 100 bytes (200 hex digits) is accepted.
+ static let deviceTokenHexLength = 64...200
+
+ /// `apns-collapse-id` must not exceed 64 bytes.
+ static let maxCollapseIDBytes = 64
+
+ /// The deepest JSON nesting accepted in payloads (APNs payloads are a few levels deep;
+ /// the cap keeps recursive encoding and conversion off the end of the stack).
+ static let maxJSONDepth = 64
+
+ /// Payload limits: 4 KB for regular pushes, 5 KB for VoIP.
+ static func payloadLimit(for pushType: APNSPushType) -> Int {
+ pushType == .voip ? 5120 : 4096
+ }
+
+ static func deviceToken(_ token: String) throws(APNSClientError) {
+ let bytes = token.utf8
+ guard deviceTokenHexLength.contains(bytes.count), bytes.count % 2 == 0,
+ bytes.allSatisfy(isHexDigit) else {
+ throw .invalidDeviceToken
+ }
+ }
+
+ /// A bundle ID, optionally with an APNs suffix (`.voip`, `.complication`,
+ /// `.pushkit.fileprovider`, `.push-type.liveactivity`, `.voip-ptt`, `.location-query`),
+ /// or an MDM topic: dot-separated, non-empty segments of ASCII letters, digits and `-`.
+ static func topic(_ topic: String) throws(APNSClientError) {
+ let bytes = topic.utf8
+ guard (1...255).contains(bytes.count),
+ bytes.allSatisfy({ isAlphanumeric($0) || $0 == UInt8(ascii: "-") || $0 == UInt8(ascii: ".") }),
+ !topic.split(separator: ".", omittingEmptySubsequences: false).contains(where: \.isEmpty)
+ else {
+ throw .invalidTopic
+ }
+ }
+
+ /// 1–64 printable ASCII characters, `!` (0x21) through `~` (0x7E). Apple only documents
+ /// the 64-byte limit; spaces and non-ASCII are refused to stay on the safe side.
+ static func collapseID(_ id: String) throws(APNSClientError) {
+ let bytes = id.utf8
+ guard (1...maxCollapseIDBytes).contains(bytes.count),
+ bytes.allSatisfy({ (0x21...0x7E).contains($0) }) else {
+ throw .invalidCollapseID
+ }
+ }
+
+ /// Apple key IDs and team IDs are 10 ASCII letters or digits.
+ static func isTenCharacterID(_ s: String) -> Bool {
+ s.utf8.count == 10 && s.utf8.allSatisfy(isAlphanumeric)
+ }
+
+ /// A host name or IPv4 address for ``APNSClient/Environment/custom(host:port:)``.
+ static func host(_ host: String, port: Int) throws(APNSClientError) {
+ let bytes = host.utf8
+ guard (1...253).contains(bytes.count), (1...65535).contains(port),
+ bytes.allSatisfy({ isAlphanumeric($0) || $0 == UInt8(ascii: "-") || $0 == UInt8(ascii: ".") }),
+ !host.split(separator: ".", omittingEmptySubsequences: false).contains(where: \.isEmpty)
+ else {
+ throw .invalidHost
+ }
+ }
+
+ private static func isHexDigit(_ b: UInt8) -> Bool {
+ (UInt8(ascii: "0")...UInt8(ascii: "9")).contains(b)
+ || (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(b)
+ || (UInt8(ascii: "A")...UInt8(ascii: "F")).contains(b)
+ }
+
+ private static func isAlphanumeric(_ b: UInt8) -> Bool {
+ (UInt8(ascii: "0")...UInt8(ascii: "9")).contains(b)
+ || (UInt8(ascii: "a")...UInt8(ascii: "z")).contains(b)
+ || (UInt8(ascii: "A")...UInt8(ascii: "Z")).contains(b)
+ }
+}
diff --git a/Tests/PerfectNotificationsTests/PayloadAndTokenTests.swift b/Tests/PerfectNotificationsTests/PayloadAndTokenTests.swift
new file mode 100644
index 0000000..55d5492
--- /dev/null
+++ b/Tests/PerfectNotificationsTests/PayloadAndTokenTests.swift
@@ -0,0 +1,373 @@
+import Testing
+import Foundation
+import Crypto
+@testable import PerfectNotifications
+
+/// Payload encoding, JSON values, validation and JWT minting: no transport involved.
+struct PayloadTests {
+
+ static let invalidDeviceTokens: [String] = {
+ let a64 = String(repeating: "a", count: 64)
+ var list: [String] = ["", "abc123token"]
+ list.append(String(repeating: "a", count: 63))
+ list.append(String(repeating: "a", count: 65))
+ list.append(String(repeating: "a", count: 202))
+ list.append("../../../3/device/" + a64)
+ list.append(a64 + "?x=1")
+ list.append(a64 + "#frag")
+ list.append(a64 + "/")
+ list.append("zz" + String(a64.dropFirst(2)))
+ list.append(a64 + " ")
+ list.append("\u{FF21}\u{FF22}" + String(a64.dropFirst(2)))
+ list.append(a64 + "\r\nx: y")
+ list.append(String(repeating: "\u{0661}", count: 64))
+ return list
+ }()
+
+ // MARK: Push type and priority
+
+ @Test func pushTypeRawValues() {
+ #expect(APNSPushType.alert.rawValue == "alert")
+ #expect(APNSPushType.background.rawValue == "background")
+ #expect(APNSPushType.liveActivity.rawValue == "liveactivity")
+ #expect(APNSPushType.pushToTalk.rawValue == "pushtotalk")
+ #expect(APNSPushType.fileProvider.rawValue == "fileprovider")
+ }
+
+ @Test func defaultPriorities() {
+ #expect(APNSPushType.background.defaultPriority == .background)
+ for type in APNSPushType.allCases where type != .background {
+ #expect(type.defaultPriority == .immediate)
+ }
+ #expect(APNSPriority.allCases.map(\.rawValue).sorted() == [1, 5, 10])
+ }
+
+ // MARK: Payload encoding
+
+ @Test func simpleAlertEncodesToExpectedJSON() throws {
+ let data = try APNSNotificationItem.encodePayload([
+ .alertTitle("Hello"), .alertBody("World"), .badge(3), .sound("default")
+ ])
+ #expect(String(decoding: data, as: UTF8.self)
+ == #"{"aps":{"alert":{"body":"World","title":"Hello"},"badge":3,"sound":"default"}}"#)
+ }
+
+ @Test func bodyOnlyAlertUsesStringNotDict() throws {
+ let data = try APNSNotificationItem.encodePayload([.alertBody("Just a body")])
+ #expect(String(decoding: data, as: UTF8.self) == #"{"aps":{"alert":"Just a body"}}"#)
+ }
+
+ @Test func contentAvailableAndMutableContentEncodeAsOne() throws {
+ let aps = try json(APNSNotificationItem.encodePayload([.contentAvailable, .mutableContent]))["aps"] as? [String: Any]
+ #expect(aps?["content-available"] as? Int == 1)
+ #expect(aps?["mutable-content"] as? Int == 1)
+ }
+
+ @Test func subtitleLocAndInterruptionLevelEncodeCorrectly() throws {
+ let data = try APNSNotificationItem.encodePayload([
+ .alertTitle("Title"), .alertSubtitle("Subtitle"),
+ .alertLoc("KEY", ["a", "b"]), .interruptionLevel(.timeSensitive), .relevanceScore(0.8)
+ ])
+ let aps = try #require(try json(data)["aps"] as? [String: Any])
+ let alert = try #require(aps["alert"] as? [String: Any])
+ #expect(alert["subtitle"] as? String == "Subtitle")
+ #expect(alert["loc-args"] as? [String] == ["a", "b"])
+ #expect(aps["interruption-level"] as? String == "time-sensitive")
+ #expect(aps["relevance-score"] as? Double == 0.8)
+ }
+
+ @Test func customPayloadLiteralsAppearAtTopLevel() throws {
+ let data = try APNSNotificationItem.encodePayload([
+ .alertBody("msg"),
+ .customPayload("orderId", "ORD-123"),
+ .customPayload("amount", 49),
+ .customPayload("ratio", 0.5),
+ .customPayload("refresh", true),
+ .customPayload("none", nil),
+ .customPayload("nested", ["ids": [1, 2], "ok": false]),
+ ])
+ #expect(String(decoding: data, as: UTF8.self) ==
+ #"{"amount":49,"aps":{"alert":"msg"},"nested":{"ids":[1,2],"ok":false},"none":null,"orderId":"ORD-123","ratio":0.5,"refresh":true}"#)
+ }
+
+ @Test func customPayloadCannotReplaceAps() {
+ #expect(throws: APNSClientError.reservedPayloadKey("aps")) {
+ try APNSNotification(items: [.alertBody("x"), .customPayload("aps", ["alert": "spoof"])])
+ }
+ }
+
+ @Test func nonFiniteNumbersAreRefused() {
+ #expect(throws: (any Error).self) { try APNSNotification(items: [.relevanceScore(.nan)]) }
+ #expect(throws: (any Error).self) { try APNSNotification(items: [.customPayload("x", .double(.infinity))]) }
+ }
+
+ @Test func notificationItemsAreSendableValues() {
+ // Compile-time check: the 5.x `@unchecked Sendable` is gone.
+ func requireSendable(_: T) {}
+ requireSendable(APNSNotificationItem.customPayload("k", ["a": [1, "two", nil]]))
+ #expect(APNSNotificationItem.customPayload("k", 1) == .customPayload("k", .int(1)))
+ }
+
+ @Test func badgeAndRelevanceScoreRanges() throws {
+ _ = try APNSNotification(items: [.badge(0), .relevanceScore(0), .relevanceScore(1)])
+ #expect(throws: APNSClientError.invalidPayload("badge must not be negative")) {
+ try APNSNotification(items: [.badge(-1)])
+ }
+ for score in [-0.01, 1.01, .nan, .infinity] {
+ #expect(throws: APNSClientError.invalidPayload("relevanceScore must be 0-1")) {
+ try APNSNotification(items: [.relevanceScore(score)])
+ }
+ }
+ }
+
+ @Test func rawPayloadMustBeAnObjectWithAps() throws {
+ let ok = try APNSNotification(raw: Data(#"{"aps":{"alert":"x"},"k":1}"#.utf8))
+ #expect(ok.payload.count == 27)
+ for bad in ["", "[]", "\"aps\"", #"{"aps":"x"}"#, #"{"alert":"x"}"#, "{garbage", #"{"aps":{}"#] {
+ #expect(throws: APNSClientError.self) { try APNSNotification(raw: Data(bad.utf8)) }
+ }
+ }
+
+ // MARK: Nesting depth
+
+ static func nestedArrays(_ depth: Int) -> APNSJSONValue {
+ var v: APNSJSONValue = 1
+ for _ in 0.. Void) async {
+ await withCheckedContinuation { (done: CheckedContinuation) in
+ let thread = Thread { body(); done.resume() }
+ thread.stackSize = 64 << 20
+ thread.start()
+ }
+ }
+
+ @Test func deepNestingIsRefusedWithoutCrashing() async throws {
+ // Depth 20,000 used to overflow the stack in encodePayload and convert.
+ await Self.onBigStack {
+ #expect(throws: APNSClientError.jsonNestingTooDeep(limit: 64)) {
+ try APNSNotification(items: [.customPayload("deep", Self.nestedArrays(20_000))])
+ }
+ var any: Any = 1
+ for _ in 0..<20_000 { any = [any] }
+ #expect(throws: APNSClientError.jsonNestingTooDeep(limit: 64)) { try APNSJSONValue(jsonObject: any) }
+ let raw = String(repeating: "[", count: 20_000) + String(repeating: "]", count: 20_000)
+ #expect(throws: (any Error).self) { try APNSNotification(raw: Data(#"{"aps":{},"x":\#(raw)}"#.utf8)) }
+ }
+ // The boundary: 63 levels under a top-level key (64 with the payload object) is fine.
+ _ = try APNSNotification(items: [.customPayload("ok", Self.nestedArrays(63))])
+ #expect(throws: APNSClientError.jsonNestingTooDeep(limit: 64)) {
+ try APNSNotification(items: [.customPayload("deep", Self.nestedArrays(64))])
+ }
+ var sixtyFour: Any = 1
+ for _ in 0..<64 { sixtyFour = [sixtyFour] }
+ #expect(try APNSJSONValue(jsonObject: sixtyFour) == Self.nestedArrays(64))
+ sixtyFour = [sixtyFour]
+ #expect(throws: APNSClientError.jsonNestingTooDeep(limit: 64)) { try APNSJSONValue(jsonObject: sixtyFour) }
+ }
+
+ // MARK: APNSJSONValue
+
+ @Test func unsignedValuesAboveIntMaxAreRefused() throws {
+ #expect(throws: APNSClientError.self) { try APNSJSONValue(jsonObject: UInt64.max) }
+ #expect(throws: APNSClientError.self) { try APNSJSONValue(jsonObject: NSNumber(value: UInt64.max)) }
+ #expect(throws: APNSClientError.self) { try APNSJSONValue(jsonObject: UInt(Int.max) + 1) }
+ #expect(try APNSJSONValue(jsonObject: UInt64(Int.max)) == .int(Int.max))
+ #expect(try APNSJSONValue(jsonObject: NSNumber(value: UInt64(42))) == 42)
+ // Whatever JSONSerialization makes of 2^64 - 1, it must not come out as -1.
+ let parsed = try JSONSerialization.jsonObject(with: Data("[18446744073709551615]".utf8))
+ if let value = try? APNSJSONValue(jsonObject: parsed) {
+ #expect(value != [-1])
+ }
+ }
+
+ @Test func jsonObjectConversionFromJSONSerialization() throws {
+ let parsed = try JSONSerialization.jsonObject(with: Data(
+ #"{"b":true,"f":false,"i":42,"d":1.5,"s":"x","n":null,"a":[1,"y"],"o":{"k":0}}"#.utf8))
+ let value = try APNSJSONValue(jsonObject: parsed)
+ #expect(value == [
+ "b": true, "f": false, "i": 42, "d": 1.5, "s": "x", "n": nil, "a": [1, "y"], "o": ["k": 0],
+ ])
+ }
+
+ @Test func jsonObjectConversionFromSwiftValues() throws {
+ let dict: [String: Any] = ["i": 1, "b": true, "d": 2.25, "s": "str", "arr": [1, 2] as [Int],
+ "opt": Optional.none as Any, "i64": Int64(7)]
+ #expect(try APNSJSONValue(jsonObject: dict) == [
+ "i": 1, "b": true, "d": 2.25, "s": "str", "arr": [1, 2], "opt": nil, "i64": 7,
+ ])
+ }
+
+ @Test func jsonObjectConversionRefusesNonJSON() {
+ #expect(throws: APNSClientError.self) { try APNSJSONValue(jsonObject: Date()) }
+ #expect(throws: APNSClientError.self) { try APNSJSONValue(jsonObject: [1: "int key"]) }
+ #expect(throws: APNSClientError.self) { try APNSJSONValue(jsonObject: Double.nan) }
+ }
+
+ @Test func jsonValueRoundTrips() throws {
+ let value: APNSJSONValue = ["a": [1, 2.5, "s", true, nil], "o": ["x": ["y": false]]]
+ let decoded = try JSONDecoder().decode(APNSJSONValue.self, from: JSONEncoder().encode(value))
+ #expect(decoded == value)
+ }
+
+ // MARK: Validation
+
+ @Test(arguments: invalidDeviceTokens)
+ func invalidDeviceTokensAreRefused(_ token: String) {
+ #expect(throws: APNSClientError.invalidDeviceToken) { try APNSValidation.deviceToken(token) }
+ }
+
+ @Test func validDeviceTokens() throws {
+ try APNSValidation.deviceToken(token64)
+ try APNSValidation.deviceToken(token64.uppercased())
+ try APNSValidation.deviceToken(String(repeating: "0f", count: 100))
+ }
+
+ @Test(arguments: [
+ "com.example.App", "com.example.App.voip", "com.example.App.complication",
+ "com.example.App.pushkit.fileprovider", "com.example.App.push-type.liveactivity",
+ "com.example.App.voip-ptt", "com.example.App.location-query",
+ "com.apple.mgmt.External.7F3B4D8E-1111-2222-3333-444455556666", "App",
+ ])
+ func validTopics(_ topic: String) throws {
+ try APNSValidation.topic(topic)
+ }
+
+ @Test(arguments: [
+ "", "com.example.App\r\napns-priority: 10", "com.example.App\n", "com example", "com..example",
+ ".com.example", "com.example.", "com/example", "com.exämple", "com.example_app", "a256",
+ ] as [String])
+ func invalidTopicsAreRefused(_ topic: String) {
+ let topic = topic == "a256" ? String(repeating: "a", count: 256) : topic
+ #expect(throws: APNSClientError.invalidTopic) { try APNSValidation.topic(topic) }
+ }
+
+ @Test func collapseIDLimits() throws {
+ try APNSValidation.collapseID(String(repeating: "x", count: 64))
+ try APNSValidation.collapseID("order-42:v2_(a)~!")
+ #expect(throws: APNSClientError.invalidCollapseID) { try APNSValidation.collapseID(String(repeating: "x", count: 65)) }
+ // The reviewer's cases: leading space, trailing space, bidi override + ZWJ.
+ for bad in [" lead", "trail ", "a\u{202E}b\u{200D}",
+ "", "inner space", "é-collapse", "a\r\nb: c", "a\nb", "a\u{0}b", "a\u{7F}", "a\u{85}",
+ "a\u{2028}b", "tab\there"] {
+ #expect(throws: APNSClientError.invalidCollapseID) { try APNSValidation.collapseID(bad) }
+ }
+ }
+
+ @Test func expirationHeaderValues() throws {
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ #expect(try APNSExpiration.immediate.headerValue(now: now) == 0)
+ #expect(try APNSExpiration.relative(3600).headerValue(now: now) == 1_800_003_600)
+ #expect(try APNSExpiration.absolute(1_900_000_000).headerValue(now: now) == 1_900_000_000)
+ #expect(throws: APNSClientError.invalidExpiration) { try APNSExpiration.relative(.max).headerValue(now: now) }
+ #expect(throws: APNSClientError.invalidExpiration) { try APNSExpiration.relative(-1).headerValue(now: now) }
+ #expect(throws: APNSClientError.invalidExpiration) { try APNSExpiration.absolute(-5).headerValue(now: now) }
+ }
+
+ // MARK: JWT
+
+ @Test func jwtHasExactHeaderAndClaimsAndAValidSignature() throws {
+ let key = P256.Signing.PrivateKey()
+ let clock = TestClock()
+ let creds = try credentials(key, clock: clock)
+ let jwt = try decodeJWT(creds.token(), publicKey: key.publicKey)
+ #expect(jwt.headerJSON == #"{"alg":"ES256","kid":"ABCDE12345"}"#)
+ #expect(jwt.claimsJSON == #"{"iat":1800000000,"iss":"TEAM123456"}"#)
+ #expect(jwt.signatureValid)
+ // And not with another key.
+ #expect(try !decodeJWT(creds.token(), publicKey: P256.Signing.PrivateKey().publicKey).signatureValid)
+ }
+
+ @Test func jwtPartsAreUnpaddedBase64URL() throws {
+ let creds = try APNSCredentials(keyID: keyID, teamID: teamID, key: P256.Signing.PrivateKey())
+ let parts = try creds.token().split(separator: ".", omittingEmptySubsequences: false)
+ #expect(parts.count == 3)
+ for part in parts {
+ #expect(!part.isEmpty)
+ #expect(!part.contains(where: { "+/=".contains($0) }))
+ }
+ }
+
+ @Test(arguments: [
+ "ABCDE1234", "ABCDE123456", #"ABCD","x":"1"#, "ABCDE1234\"", "ABCDE 1234", "ABCDÉ12345", "",
+ ])
+ func malformedKeyAndTeamIDsAreRefused(_ id: String) {
+ let key = P256.Signing.PrivateKey()
+ #expect(throws: APNSClientError.invalidKeyID) { try APNSCredentials(keyID: id, teamID: teamID, key: key) }
+ #expect(throws: APNSClientError.invalidTeamID) { try APNSCredentials(keyID: keyID, teamID: id, key: key) }
+ #expect(throws: APNSClientError.invalidKeyID) {
+ try APNSClient(keyID: id, teamID: teamID, pemString: key.pemRepresentation)
+ }
+ }
+
+ @Test func tokenIsCachedThenRefreshedAtFiftyMinutes() throws {
+ let clock = TestClock()
+ let creds = try credentials(clock: clock)
+ let first = try creds.token()
+ clock.advance(minutes: 49.9)
+ #expect(try creds.token() == first)
+ clock.advance(minutes: 0.2)
+ let second = try creds.token()
+ #expect(second != first)
+ #expect(try creds.token() == second)
+ }
+
+ @Test func rejectedTokenIsReplacedOnlyAfterTwentyMinutes() throws {
+ let clock = TestClock()
+ let creds = try credentials(clock: clock)
+ let first = try creds.token()
+ clock.advance(minutes: 5)
+ #expect(creds.reject(first) == false) // too soon to mint again
+ #expect(try creds.token() == first)
+ clock.advance(minutes: 15) // 20 minutes old
+ #expect(creds.reject(first) == true)
+ let second = try creds.token()
+ #expect(second != first)
+ #expect(creds.reject(first) == true) // stale token: already replaced
+ #expect(try creds.token() == second)
+ }
+
+ @Test func wallClockStepsDontAffectTokenAge() throws {
+ let clock = TestClock()
+ let creds = try credentials(clock: clock)
+ let first = try creds.token()
+ // Backwards steps (repeated, as a misbehaving VM clock might): no new mints.
+ for _ in 0..<20 { clock.stepWall(minutes: -1.1) }
+ #expect(try creds.token() == first)
+ clock.stepWall(minutes: -90)
+ #expect(try creds.token() == first)
+ // A forward wall step doesn't age the token either.
+ clock.stepWall(minutes: 600)
+ #expect(try creds.token() == first)
+ // Real (monotonic) time does.
+ clock.advance(minutes: 50)
+ #expect(try creds.token() != first)
+ }
+
+ @Test func monotonicClockIsMonotonic() {
+ let a = monotonicSeconds()
+ let b = monotonicSeconds()
+ #expect(a > 0 && b >= a)
+ }
+
+ // MARK: Redaction
+
+ @Test func credentialsNeverPrintTheKey() throws {
+ let key = P256.Signing.PrivateKey()
+ let creds = try APNSCredentials(keyID: keyID, teamID: teamID, key: key)
+ let token = try creds.token()
+ var dumped = ""
+ dump(creds, to: &dumped)
+ for text in ["\(creds)", String(reflecting: creds), dumped] {
+ #expect(!text.contains(token))
+ #expect(!text.contains(String(token.split(separator: ".")[2])))
+ #expect(!text.contains(key.rawRepresentation.base64EncodedString()))
+ #expect(text.contains(""))
+ }
+ }
+}
diff --git a/Tests/PerfectNotificationsTests/PerfectNotificationsTests.swift b/Tests/PerfectNotificationsTests/PerfectNotificationsTests.swift
index ae173db..e86d17c 100644
--- a/Tests/PerfectNotificationsTests/PerfectNotificationsTests.swift
+++ b/Tests/PerfectNotificationsTests/PerfectNotificationsTests.swift
@@ -1,384 +1,376 @@
import Testing
import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
import Crypto
@testable import PerfectNotifications
-// MARK: - URLProtocol mock
-
-final class MockURLProtocol: URLProtocol, @unchecked Sendable {
- nonisolated(unsafe) static var requestHandler: (@Sendable (URLRequest) -> (HTTPURLResponse, Data))?
- nonisolated(unsafe) static var lastRequest: URLRequest?
- nonisolated(unsafe) static var lastBody: Data?
+/// Request shape, response parsing, retries, timeouts and redaction, through a
+/// URLProtocol stub. Serialized: the stub's state is shared.
+@Suite(.serialized)
+struct ClientTests {
- override class func canInit(with request: URLRequest) -> Bool { true }
- override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+ // MARK: Request shape
- override func startLoading() {
- Self.lastBody = Self.readBody(from: request)
- Self.lastRequest = request
- guard let handler = Self.requestHandler else {
- client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
- return
- }
- let (response, data) = handler(request)
- client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
- client?.urlProtocol(self, didLoad: data)
- client?.urlProtocolDidFinishLoading(self)
+ @Test func requestShape() async throws {
+ let key = P256.Signing.PrivateKey()
+ let client = try makeClient(key: key) { _ in ok() }
+ let notification = try APNSNotification(items: [.alertBody("test")])
+ _ = try await client.send(notification, to: token64, topic: "com.example.App",
+ expiration: .absolute(1_900_000_000), collapseID: "order-42")
+ let req = try #require(MockURLProtocol.lastRequest)
+ #expect(req.url?.absoluteString == "https://api.sandbox.push.apple.com/3/device/\(token64)")
+ #expect(req.method == "POST")
+ #expect(req.body == notification.payload)
+ #expect(req.header("content-type") == "application/json")
+ #expect(req.header("apns-topic") == "com.example.App")
+ #expect(req.header("apns-push-type") == "alert")
+ #expect(req.header("apns-priority") == "10")
+ #expect(req.header("apns-expiration") == "1900000000")
+ #expect(req.header("apns-collapse-id") == "order-42")
+ let jwt = try decodeJWT(try #require(bearer(req)), publicKey: key.publicKey)
+ #expect(jwt.signatureValid)
+ #expect(jwt.header["kid"] as? String == keyID)
+ #expect(jwt.claims["iss"] as? String == teamID)
}
- override func stopLoading() {}
-
- static func readBody(from request: URLRequest) -> Data? {
- if let b = request.httpBody { return b }
- guard let stream = request.httpBodyStream else { return nil }
- stream.open(); defer { stream.close() }
- var data = Data()
- var buffer = [UInt8](repeating: 0, count: 4096)
- while stream.hasBytesAvailable {
- let n = stream.read(&buffer, maxLength: buffer.count)
- if n <= 0 { break }
- data.append(buffer, count: n)
- }
- return data
+ @Test func optionalHeadersAreOmittedByDefault() async throws {
+ let client = try makeClient { _ in ok() }
+ _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ let req = try #require(MockURLProtocol.lastRequest)
+ #expect(req.header("apns-expiration") == nil)
+ #expect(req.header("apns-collapse-id") == nil)
}
-}
-// MARK: - Helpers
-
-private func makeClient(
- handler: @escaping @Sendable (URLRequest) -> (HTTPURLResponse, Data)
-) throws -> APNSClient {
- MockURLProtocol.lastRequest = nil
- MockURLProtocol.requestHandler = handler
- let config = URLSessionConfiguration.ephemeral
- config.protocolClasses = [MockURLProtocol.self]
- let session = URLSession(configuration: config)
- let key = P256.Signing.PrivateKey()
- return try APNSClient(keyID: "TESTKEY1234", teamID: "TEAMID1234",
- pemString: key.pemRepresentation,
- environment: .sandbox, urlSession: session)
-}
+ @Test func productionAndCustomHosts() async throws {
+ var client = try makeClient(environment: .production) { _ in ok() }
+ _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ #expect(MockURLProtocol.lastRequest?.url?.absoluteString == "https://api.push.apple.com/3/device/\(token64)")
-private func okResponse(_ body: String = "{}") -> @Sendable (URLRequest) -> (HTTPURLResponse, Data) {
- { req in
- let apnsID = "test-apns-id-\(UUID().uuidString)"
- let http = HTTPURLResponse(
- url: req.url!, statusCode: 200, httpVersion: nil,
- headerFields: ["apns-id": apnsID]
- )!
- return (http, Data(body.utf8))
- }
-}
+ client = try makeClient(environment: .custom(host: "localhost", port: 8443)) { _ in ok() }
+ _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ #expect(MockURLProtocol.lastRequest?.url?.absoluteString == "https://localhost:8443/3/device/\(token64)")
-private func errorResponse(statusCode: Int, reason: String) -> @Sendable (URLRequest) -> (HTTPURLResponse, Data) {
- { req in
- let http = HTTPURLResponse(url: req.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)!
- let body = "{\"reason\":\"\(reason)\"}"
- return (http, Data(body.utf8))
+ for bad: APNSClient.Environment in [.custom(host: "evil.test/x?", port: 443), .custom(host: "a@b", port: 443),
+ .custom(host: "localhost", port: 0), .custom(host: "", port: 443)] {
+ #expect(throws: APNSClientError.invalidHost) { try makeClient(environment: bad) { _ in ok() } }
+ }
}
-}
-private func unregisteredResponse(at timestamp: Int) -> @Sendable (URLRequest) -> (HTTPURLResponse, Data) {
- { req in
- let http = HTTPURLResponse(url: req.url!, statusCode: 410, httpVersion: nil, headerFields: nil)!
- let body = "{\"reason\":\"Unregistered\",\"timestamp\":\(timestamp)}"
- return (http, Data(body.utf8))
+ @Test func backgroundPushUsesPriority5AndRefusesOthers() async throws {
+ let client = try makeClient { _ in ok() }
+ _ = try await client.sendBackground(to: token64, topic: "com.example.App",
+ customData: [.customPayload("refresh", true)])
+ let req = try #require(MockURLProtocol.lastRequest)
+ #expect(req.header("apns-push-type") == "background")
+ #expect(req.header("apns-priority") == "5")
+ let body = try json(req.body)
+ #expect((body["aps"] as? [String: Any])?["content-available"] as? Int == 1)
+ #expect(body["refresh"] as? Bool == true)
+
+ for priority in [APNSPriority.immediate, .low] {
+ await #expect(throws: APNSClientError.invalidPriority(priority, pushType: .background)) {
+ try await client.send(APNSNotification(items: [.contentAvailable]), to: token64,
+ topic: "com.example.App", pushType: .background, priority: priority)
+ }
+ }
+ _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64,
+ topic: "com.example.App", priority: .low)
+ #expect(MockURLProtocol.lastRequest?.header("apns-priority") == "1")
}
-}
-// MARK: - Tests
-
-@Suite(.serialized)
-struct PerfectNotificationsTests {
+ // MARK: Input validation never reaches the transport
- // MARK: Push type
-
- @Test func pushTypeRawValues() {
- #expect(APNSPushType.alert.rawValue == "alert")
- #expect(APNSPushType.background.rawValue == "background")
- #expect(APNSPushType.liveActivity.rawValue == "liveactivity")
- #expect(APNSPushType.pushToTalk.rawValue == "pushtotalk")
- #expect(APNSPushType.fileProvider.rawValue == "fileprovider")
+ @Test func invalidInputThrowsBeforeAnyRequest() async throws {
+ let client = try makeClient { _ in ok() }
+ let n = try APNSNotification(items: [.alertBody("x")])
+ await #expect(throws: APNSClientError.invalidDeviceToken) {
+ try await client.send(n, to: "../../../3/device/" + token64 + "?", topic: "com.example.App")
+ }
+ await #expect(throws: APNSClientError.invalidTopic) {
+ try await client.send(n, to: token64, topic: "com.example.App\r\nx-injected: 1")
+ }
+ await #expect(throws: APNSClientError.invalidCollapseID) {
+ try await client.send(n, to: token64, topic: "com.example.App", collapseID: "a\r\nb: c")
+ }
+ await #expect(throws: APNSClientError.invalidExpiration) {
+ try await client.send(n, to: token64, topic: "com.example.App", expiration: .relative(.max))
+ }
+ #expect(MockURLProtocol.requests.isEmpty)
}
- @Test func backgroundPushTypeHasPriority5() {
- #expect(APNSPushType.background.defaultPriority == 5)
- #expect(APNSPushType.alert.defaultPriority == 10)
- #expect(APNSPushType.voip.defaultPriority == 10)
- #expect(APNSPushType.liveActivity.defaultPriority == 10)
+ @Test func payloadSizeLimits() async throws {
+ let client = try makeClient { _ in ok() }
+ func payload(_ size: Int) -> APNSNotification {
+ let filler = String(repeating: "x", count: size - #"{"aps":{"alert":""}}"#.utf8.count)
+ return try! APNSNotification(raw: Data(#"{"aps":{"alert":"\#(filler)"}}"#.utf8))
+ }
+ #expect(payload(4096).payload.count == 4096)
+ _ = try await client.send(payload(4096), to: token64, topic: "com.example.App")
+ await #expect(throws: APNSClientError.payloadTooLarge(size: 4097, limit: 4096)) {
+ try await client.send(payload(4097), to: token64, topic: "com.example.App")
+ }
+ _ = try await client.send(payload(5120), to: token64, topic: "com.example.App.voip", pushType: .voip)
+ #expect(throws: APNSClientError.payloadTooLarge(size: 5121, limit: 5120)) {
+ try APNSNotification(raw: Data(repeating: UInt8(ascii: " "), count: 5121))
+ }
+ #expect(MockURLProtocol.requests.count == 2)
}
- // MARK: Payload encoding
-
- @Test func simpleAlertEncodesToExpectedJSON() throws {
- let data = try APNSNotificationItem.encodePayload([
- .alertTitle("Hello"),
- .alertBody("World"),
- .badge(3),
- .sound("default")
- ])
- let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
- let aps = json["aps"] as! [String: Any]
- let alert = aps["alert"] as! [String: Any]
- #expect(alert["title"] as? String == "Hello")
- #expect(alert["body"] as? String == "World")
- #expect(aps["badge"] as? Int == 3)
- #expect(aps["sound"] as? String == "default")
- }
+ // MARK: Response parsing
- @Test func bodyOnlyAlertUsesStringNotDict() throws {
- let data = try APNSNotificationItem.encodePayload([.alertBody("Just a body")])
- let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
- let aps = json["aps"] as! [String: Any]
- #expect(aps["alert"] as? String == "Just a body")
+ @Test func successResponse() async throws {
+ let client = try makeClient { _ in ok(apnsID: "ID-1", uniqueID: "UNIQUE-1") }
+ let result = try await client.sendAlert(to: token64, topic: "com.example.App", title: "Hey", body: "World", badge: 2)
+ #expect(result == APNSResponse(statusCode: 200, apnsID: "ID-1", apnsUniqueID: "UNIQUE-1", error: nil))
+ #expect(result.isSuccess)
+ let aps = try #require(try json(MockURLProtocol.lastRequest!.body)["aps"] as? [String: Any])
+ #expect((aps["alert"] as? [String: Any])?["title"] as? String == "Hey")
+ #expect(aps["badge"] as? Int == 2)
}
- @Test func contentAvailableAndMutableContentEncodeAsOne() throws {
- let data = try APNSNotificationItem.encodePayload([.contentAvailable, .mutableContent])
- let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
- let aps = json["aps"] as! [String: Any]
- #expect(aps["content-available"] as? Int == 1)
- #expect(aps["mutable-content"] as? Int == 1)
+ @Test func rejectionParsesReason() async throws {
+ let client = try makeClient { _ in rejected(400, "BadDeviceToken") }
+ let result = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ #expect(!result.isSuccess)
+ #expect(result.statusCode == 400)
+ #expect(result.error?.reason == "BadDeviceToken")
+ #expect(result.error?.knownReason == .badDeviceToken)
+ #expect(result.apnsID != nil)
}
- @Test func subtitleAndInterruptionLevelEncodeCorrectly() throws {
- let data = try APNSNotificationItem.encodePayload([
- .alertTitle("Title"),
- .alertSubtitle("Subtitle"),
- .interruptionLevel(.timeSensitive),
- .relevanceScore(0.8)
- ])
- let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
- let aps = json["aps"] as! [String: Any]
- let alert = aps["alert"] as! [String: Any]
- #expect(alert["subtitle"] as? String == "Subtitle")
- #expect(aps["interruption-level"] as? String == "time-sensitive")
- #expect(aps["relevance-score"] as? Double == 0.8)
+ @Test func unregisteredTimestampIsMilliseconds() async throws {
+ let ms: Int64 = 1_700_000_000_123
+ let client = try makeClient { _ in rejected(410, "Unregistered", timestamp: ms) }
+ let result = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ #expect(result.statusCode == 410)
+ #expect(result.error?.knownReason == .unregistered)
+ #expect(result.error?.timestamp == ms)
+ #expect(result.error?.invalidatedAt == Date(timeIntervalSince1970: 1_700_000_000.123))
}
- @Test func customPayloadAppearsAtTopLevel() throws {
- let data = try APNSNotificationItem.encodePayload([
- .alertBody("msg"),
- .customPayload("orderId", "ORD-123"),
- .customPayload("amount", 49)
- ])
- let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
- #expect(json["orderId"] as? String == "ORD-123")
- #expect(json["amount"] as? Int == 49)
- }
+ @Test func unknownReasonAndNonJSONBodies() async throws {
+ var client = try makeClient { _ in rejected(400, "SomethingNew") }
+ var result = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ #expect(result.error?.reason == "SomethingNew")
+ #expect(result.error?.knownReason == nil)
- // MARK: JWT structure
+ client = try makeClient { _ in .respond(status: 502, headers: [:], body: Data("bad gateway".utf8)) }
+ result = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ #expect(result.statusCode == 502)
+ #expect(result.error == nil)
+ }
- @Test func jwtHasThreeBase64UrlParts() throws {
- let key = P256.Signing.PrivateKey()
- let creds = APNSCredentials(keyID: "KEYID12345", teamID: "TEAMID1234", key: key)
- let token = try creds.token()
- let parts = token.split(separator: ".", omittingEmptySubsequences: false)
- #expect(parts.count == 3)
- // Each part should be non-empty and URL-safe base64 (no padding, no +/)
- for part in parts {
- #expect(!part.isEmpty)
- #expect(!part.contains("+"))
- #expect(!part.contains("/"))
- #expect(!part.contains("="))
+ @Test func oversizedResponseBodyIsRefused() async throws {
+ let big = Data(repeating: UInt8(ascii: "x"), count: 64 * 1024)
+ let client = try makeClient { _ in .respond(status: 400, headers: [:], body: big) }
+ await #expect(throws: APNSClientError.responseTooLarge(limit: 16 * 1024)) {
+ try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
}
}
- @Test func jwtHeaderContainsKidAndAlg() throws {
- let key = P256.Signing.PrivateKey()
- let creds = APNSCredentials(keyID: "KEYID12345", teamID: "TEAMID1234", key: key)
- let token = try creds.token()
- let headerB64 = String(token.split(separator: ".").first!)
- // Pad for standard base64 decoding
- let padded = headerB64 + String(repeating: "=", count: (4 - headerB64.count % 4) % 4)
- let urlDecoded = padded.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
- let data = Data(base64Encoded: urlDecoded)!
- let header = try JSONSerialization.jsonObject(with: data) as! [String: Any]
- #expect(header["alg"] as? String == "ES256")
- #expect(header["kid"] as? String == "KEYID12345")
+ @Test func networkFailureIsAClientError() async throws {
+ let client = try makeClient { _ in .fail(.cannotConnectToHost) }
+ do {
+ _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ Issue.record("expected a network error")
+ } catch let APNSClientError.network(message) {
+ #expect(message.contains("\(URLError.Code.cannotConnectToHost.rawValue)"))
+ #expect(!message.contains("bearer"))
+ }
}
- @Test func jwtPayloadContainsIssAndIat() throws {
- let key = P256.Signing.PrivateKey()
- let creds = APNSCredentials(keyID: "KEYID12345", teamID: "TEAMID1234", key: key)
- let beforeMint = Int(Date().timeIntervalSince1970)
- let token = try creds.token()
- let afterMint = Int(Date().timeIntervalSince1970)
-
- let payloadB64 = String(token.split(separator: ".")[1])
- let padded = payloadB64 + String(repeating: "=", count: (4 - payloadB64.count % 4) % 4)
- let urlDecoded = padded.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
- let data = Data(base64Encoded: urlDecoded)!
- let payload = try JSONSerialization.jsonObject(with: data) as! [String: Any]
- #expect(payload["iss"] as? String == "TEAMID1234")
- let iat = payload["iat"] as! Int
- #expect(iat >= beforeMint)
- #expect(iat <= afterMint)
- }
+ // MARK: Timeouts and cancellation
- @Test func jwtIsCachedOnSecondCall() throws {
- let key = P256.Signing.PrivateKey()
- let creds = APNSCredentials(keyID: "KEYID12345", teamID: "TEAMID1234", key: key)
- let first = try creds.token()
- let second = try creds.token()
- #expect(first == second)
+ @Test func hangingRequestTimesOut() async throws {
+ let client = try makeClient(timeout: 0.5) { _ in .hang }
+ let start = Date()
+ await #expect(throws: APNSClientError.timedOut) {
+ try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ }
+ let elapsed = Date().timeIntervalSince(start)
+ #expect(elapsed >= 0.4 && elapsed < 10)
}
- // MARK: Request construction
-
- @Test func sendBuildsCorrectURL() async throws {
- let client = try makeClient(handler: okResponse())
- _ = try await client.send(
- try APNSNotification(items: [.alertBody("test")]),
- to: "abc123token",
- topic: "com.example.App"
- )
- let url = MockURLProtocol.lastRequest?.url?.absoluteString
- #expect(url == "https://api.sandbox.push.apple.com/3/device/abc123token")
+ @Test func cancellationIsPrompt() async throws {
+ let client = try makeClient(timeout: 60) { _ in .hang }
+ let task = Task {
+ try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ }
+ try await Task.sleep(nanoseconds: 200_000_000)
+ let start = Date()
+ task.cancel()
+ await #expect(throws: CancellationError.self) { try await task.value }
+ #expect(Date().timeIntervalSince(start) < 5)
}
- @Test func sendIncludesRequiredHeaders() async throws {
- let client = try makeClient(handler: okResponse())
- _ = try await client.send(
- try APNSNotification(items: [.alertBody("test")]),
- to: "tokenXYZ",
- topic: "com.example.App",
- pushType: .alert
- )
- let req = MockURLProtocol.lastRequest!
- #expect(req.value(forHTTPHeaderField: "apns-topic") == "com.example.App")
- #expect(req.value(forHTTPHeaderField: "apns-push-type") == "alert")
- #expect(req.value(forHTTPHeaderField: "apns-priority") == "10")
- #expect(req.value(forHTTPHeaderField: "content-type") == "application/json")
- let auth = req.value(forHTTPHeaderField: "authorization") ?? ""
- #expect(auth.hasPrefix("bearer "))
- #expect(auth.split(separator: ".").count == 3) // valid JWT structure
+ @Test func subSecondDeadlineWithAPromptServerSucceeds() async throws {
+ // corelibs truncates URLRequest.timeoutInterval to whole seconds, so a 0.5 s value
+ // used to fail at once on Linux. The client's own deadline handles fractions.
+ let client = try makeClient(timeout: 0.5) { _ in ok() }
+ let result = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ #expect(result.isSuccess)
+ let start = Date()
+ let hung = try makeClient(timeout: 0.5) { _ in .hang }
+ await #expect(throws: APNSClientError.timedOut) {
+ try await hung.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ }
+ #expect(Date().timeIntervalSince(start) >= 0.4)
}
- @Test func backgroundPushUsesPriority5() async throws {
- let client = try makeClient(handler: okResponse())
- _ = try await client.send(
- try APNSNotification(items: [.contentAvailable]),
- to: "token",
- topic: "com.example.App",
- pushType: .background
- )
- #expect(MockURLProtocol.lastRequest?.value(forHTTPHeaderField: "apns-push-type") == "background")
- #expect(MockURLProtocol.lastRequest?.value(forHTTPHeaderField: "apns-priority") == "5")
+ @Test func urlSessionIdleTimeoutIsPastTheDeadline() async throws {
+ let client = try makeClient(timeout: 0.5) { _ in ok() }
+ _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ // Recorded by the stub from the request as sent; the deadline itself is 0.5 s.
+ #expect(MockURLProtocol.lastTimeout == 6)
}
- @Test func collapseIDHeaderIncludedWhenSet() async throws {
- let client = try makeClient(handler: okResponse())
- _ = try await client.send(
- try APNSNotification(items: [.alertBody("test")]),
- to: "token", topic: "com.example.App", collapseID: "order-42"
- )
- #expect(MockURLProtocol.lastRequest?.value(forHTTPHeaderField: "apns-collapse-id") == "order-42")
+ // MARK: Redirects
+
+ @Test func redirectStatusIsAnErrorNotASuccess() async throws {
+ for status in [301, 302, 307, 308] {
+ let client = try makeClient { _ in
+ .respond(status: status, headers: ["location": "https://evil.test/3/device/\(token64)"], body: Data())
+ }
+ await #expect(throws: APNSClientError.unexpectedRedirect(statusCode: status)) {
+ try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ }
+ #expect(MockURLProtocol.requests.count == 1)
+ #expect(MockURLProtocol.requests.allSatisfy { $0.url?.host == "api.sandbox.push.apple.com" })
+ }
}
- @Test func productionEnvironmentUsesCorrectHost() async throws {
- MockURLProtocol.requestHandler = okResponse()
- let config = URLSessionConfiguration.ephemeral
- config.protocolClasses = [MockURLProtocol.self]
- let key = P256.Signing.PrivateKey()
- let client = try APNSClient(keyID: "KID", teamID: "TID",
- pemString: key.pemRepresentation,
- environment: .production,
- urlSession: URLSession(configuration: config))
- _ = try await client.send(
- try APNSNotification(items: [.alertBody("test")]),
- to: "token", topic: "com.example.App"
- )
- let host = MockURLProtocol.lastRequest?.url?.host
- #expect(host == "api.push.apple.com")
+ @Test func responseFromAnotherOriginIsRefused() async throws {
+ let client = try makeClient { _ in .respondFrom(url: "https://evil.test/3/device/x", status: 200) }
+ await #expect(throws: APNSClientError.unexpectedRedirect(statusCode: 200)) {
+ try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ }
}
- // MARK: Response parsing
+ // MARK: Provider-token rejections
- @Test func successResponseIsSuccess() async throws {
- let client = try makeClient(handler: okResponse())
- let result = try await client.send(
- try APNSNotification(items: [.alertBody("hi")]),
- to: "token", topic: "com.example.App"
- )
+ @Test func expiredProviderTokenIsRetriedOnceWithAFreshToken() async throws {
+ let clock = TestClock()
+ let client = try makeClient(clock: clock) { i in i == 1 ? rejected(403, "ExpiredProviderToken") : ok() }
+ _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ clock.advance(minutes: 25)
+ let result = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
#expect(result.isSuccess)
- #expect(result.statusCode == 200)
- #expect(result.error == nil)
- #expect(result.apnsID != nil)
+ let tokens = MockURLProtocol.requests.map(bearer)
+ #expect(tokens.count == 3)
+ #expect(tokens[0] == tokens[1])
+ #expect(tokens[2] != tokens[1])
}
- @Test func badDeviceTokenResponseParsesReason() async throws {
- let client = try makeClient(handler: errorResponse(statusCode: 400, reason: "BadDeviceToken"))
- let result = try await client.send(
- try APNSNotification(items: [.alertBody("hi")]),
- to: "badtoken", topic: "com.example.App"
- )
- #expect(!result.isSuccess)
- #expect(result.statusCode == 400)
- #expect(result.error?.reason == "BadDeviceToken")
+ @Test func expiredProviderTokenIsNotRetriedWithinTwentyMinutes() async throws {
+ let clock = TestClock()
+ let client = try makeClient(clock: clock) { _ in rejected(403, "ExpiredProviderToken") }
+ clock.advance(minutes: 0)
+ let result = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ #expect(result.error?.knownReason == .expiredProviderToken)
+ #expect(MockURLProtocol.requests.count == 1)
+ // Still within 20 minutes: same token, no new mint.
+ clock.advance(minutes: 10)
+ _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ #expect(MockURLProtocol.requests.count == 2)
+ #expect(bearer(MockURLProtocol.requests[0]) == bearer(MockURLProtocol.requests[1]))
+ // Past 20 minutes the rejected token is replaced on the next send. That fresh
+ // token is rejected too, and it can't be replaced yet, so there is no retry.
+ clock.advance(minutes: 11)
+ let third = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ #expect(third.error?.knownReason == .expiredProviderToken)
+ let tokens = MockURLProtocol.requests.map(bearer)
+ try #require(tokens.count == 3)
+ #expect(tokens[2] != tokens[1])
}
- @Test func unregisteredResponseIncludesTimestamp() async throws {
- let ts = 1_700_000_000
- let client = try makeClient(handler: unregisteredResponse(at: ts))
- let result = try await client.send(
- try APNSNotification(items: [.alertBody("hi")]),
- to: "oldtoken", topic: "com.example.App"
- )
- #expect(result.statusCode == 410)
- #expect(result.error?.reason == "Unregistered")
- #expect(result.error?.timestamp == ts)
+ @Test func invalidProviderTokenInvalidatesWithoutRetry() async throws {
+ let clock = TestClock()
+ let client = try makeClient(clock: clock) { i in i == 0 ? rejected(403, "InvalidProviderToken") : ok() }
+ clock.advance(minutes: 0)
+ let first = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ #expect(first.error?.knownReason == .invalidProviderToken)
+ #expect(MockURLProtocol.requests.count == 1)
+ clock.advance(minutes: 21) // below the 50-minute refresh, but the token was rejected
+ _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ let tokens = MockURLProtocol.requests.map(bearer)
+ #expect(tokens[1] != tokens[0])
}
- // MARK: Convenience helpers
-
- @Test func sendAlertBuildsCorrectPayload() async throws {
- let client = try makeClient(handler: okResponse())
- _ = try await client.sendAlert(to: "token", topic: "com.example.App",
- title: "Hey", body: "World", badge: 2)
- let body = MockURLProtocol.lastBody ?? Data()
- let json = try JSONSerialization.jsonObject(with: body) as! [String: Any]
- let aps = json["aps"] as! [String: Any]
- let alert = aps["alert"] as! [String: Any]
- #expect(alert["title"] as? String == "Hey")
- #expect(alert["body"] as? String == "World")
- #expect(aps["badge"] as? Int == 2)
- #expect(MockURLProtocol.lastRequest?.value(forHTTPHeaderField: "apns-push-type") == "alert")
+ @Test func tooManyProviderTokenUpdatesKeepsTheToken() async throws {
+ let clock = TestClock()
+ let client = try makeClient(clock: clock) { i in i == 0 ? rejected(429, "TooManyProviderTokenUpdates") : ok() }
+ let first = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ #expect(first.error?.knownReason == .tooManyProviderTokenUpdates)
+ clock.advance(minutes: 30)
+ _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ let tokens = MockURLProtocol.requests.map(bearer)
+ #expect(tokens.count == 2)
+ #expect(tokens[0] == tokens[1])
}
- @Test func sendBackgroundSetsPushTypeAndContentAvailable() async throws {
- let client = try makeClient(handler: okResponse())
- _ = try await client.sendBackground(to: "token", topic: "com.example.App",
- customData: [.customPayload("refresh", true)])
- let body = MockURLProtocol.lastBody ?? Data()
- let json = try JSONSerialization.jsonObject(with: body) as! [String: Any]
- let aps = json["aps"] as! [String: Any]
- #expect(aps["content-available"] as? Int == 1)
- #expect(MockURLProtocol.lastRequest?.value(forHTTPHeaderField: "apns-push-type") == "background")
- #expect(MockURLProtocol.lastRequest?.value(forHTTPHeaderField: "apns-priority") == "5")
+ // MARK: Batch send
+
+ @Test func batchCarriesPerTokenResults() async throws {
+ let client = try makeClient { i in i == 1 ? rejected(410, "Unregistered", timestamp: 1) : (i == 2 ? .fail(.networkConnectionLost) : ok()) }
+ let other = String(repeating: "cd", count: 32)
+ let third = String(repeating: "ef", count: 32)
+ let fourth = String(repeating: "01", count: 32)
+ let results = try await client.send(APNSNotification(items: [.alertBody("x")]),
+ to: [token64, "not-a-token", other, third, fourth],
+ topic: "com.example.App")
+ #expect(results.map(\.deviceToken) == [token64, "not-a-token", other, third, fourth])
+ #expect(try results[0].result.get().isSuccess)
+ #expect(throws: APNSClientError.invalidDeviceToken) { try results[1].result.get() }
+ #expect(try results[2].result.get().error?.knownReason == .unregistered)
+ guard case .failure(.network) = results[3].result else { Issue.record("expected network failure"); return }
+ #expect(try results[4].result.get().isSuccess)
+ #expect(MockURLProtocol.requests.count == 4)
}
- // MARK: Live sandbox (gated)
+ @Test func cancellingABatchKeepsTheResultsSoFar() async throws {
+ let client = try makeClient(timeout: 60) { i in i < 2 ? ok() : .hang }
+ let tokens = ["aa", "bb", "cc", "dd"].map { String(repeating: $0, count: 32) }
+ let task = Task {
+ try await client.send(APNSNotification(items: [.alertBody("x")]), to: tokens, topic: "com.example.App")
+ }
+ try await Task.sleep(nanoseconds: 300_000_000)
+ task.cancel()
+ let results = try await task.value
+ #expect(results.map(\.deviceToken) == tokens)
+ #expect(try results[0].result.get().isSuccess)
+ #expect(try results[1].result.get().isSuccess)
+ #expect(results[2].result == .failure(.cancelled)) // in flight
+ #expect(results[3].result == .failure(.cancelled)) // never started
+ #expect(MockURLProtocol.requests.count == 3)
+ #expect(results[2] == APNSBatchResult(deviceToken: tokens[2], result: .failure(.cancelled)))
+ }
- private var liveEnabled: Bool {
- ProcessInfo.processInfo.environment["APNS_TESTS"] == "1"
+ @Test func batchSharedProblemsThrowBeforeSending() async throws {
+ let client = try makeClient { _ in ok() }
+ await #expect(throws: APNSClientError.invalidTopic) {
+ try await client.send(APNSNotification(items: [.alertBody("x")]), to: [token64, token64], topic: "bad topic")
+ }
+ #expect(MockURLProtocol.requests.isEmpty)
}
- @Test func liveSandboxSendAlert() async throws {
- guard liveEnabled,
- let keyID = ProcessInfo.processInfo.environment["APNS_KEY_ID"],
- let teamID = ProcessInfo.processInfo.environment["APNS_TEAM_ID"],
- let keyPath = ProcessInfo.processInfo.environment["APNS_KEY_PATH"],
- let token = ProcessInfo.processInfo.environment["APNS_DEVICE_TOKEN"],
- let topic = ProcessInfo.processInfo.environment["APNS_TOPIC"] else { return }
-
- let client = try APNSClient(keyID: keyID, teamID: teamID,
- privateKeyPath: keyPath, environment: .sandbox)
- let result = try await client.sendAlert(to: token, topic: topic,
- title: "Test", body: "Live sandbox test")
- if !result.isSuccess {
- Issue.record("sandbox push failed: \(result.error?.description ?? "HTTP \(result.statusCode)")")
+ // MARK: Redaction
+
+ @Test func clientNeverPrintsTheKeyOrToken() async throws {
+ let key = P256.Signing.PrivateKey()
+ let client = try makeClient(key: key) { _ in ok() }
+ _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App")
+ let token = try #require(bearer(MockURLProtocol.lastRequest))
+ var dumped = ""
+ dump(client, to: &dumped)
+ let mirrored = Mirror(reflecting: client).children.map { "\($0.label ?? ""): \($0.value)" }.joined(separator: "\n")
+ for text in ["\(client)", String(reflecting: client), dumped, mirrored] {
+ #expect(!text.contains(token))
+ #expect(!text.contains(String(token.split(separator: ".")[2])))
+ #expect(!text.contains(key.rawRepresentation.base64EncodedString()))
+ #expect(!text.contains("PRIVATE KEY"))
+ #expect(text.contains(keyID))
}
- #expect(result.apnsID != nil)
}
}
diff --git a/Tests/PerfectNotificationsTests/TestSupport.swift b/Tests/PerfectNotificationsTests/TestSupport.swift
new file mode 100644
index 0000000..79c3577
--- /dev/null
+++ b/Tests/PerfectNotificationsTests/TestSupport.swift
@@ -0,0 +1,187 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+import Crypto
+@testable import PerfectNotifications
+
+// MARK: - URLProtocol stub
+//
+// Every request made through a session from `MockURLProtocol.session()` is answered here;
+// nothing reaches the network. The suites using it are `.serialized`.
+
+enum MockReply: Sendable {
+ case respond(status: Int, headers: [String: String], body: Data)
+ /// A response that claims to come from another URL (another origin).
+ case respondFrom(url: String, status: Int)
+ case fail(URLError.Code)
+ /// Never answers (for timeout and cancellation tests).
+ case hang
+}
+
+struct RecordedRequest: Sendable {
+ let url: URL?
+ let method: String?
+ let headers: [String: String]
+ let body: Data
+ var timeout: TimeInterval = 0
+ func header(_ name: String) -> String? {
+ headers.first { $0.key.lowercased() == name.lowercased() }?.value
+ }
+}
+
+final class MockURLProtocol: URLProtocol {
+ private static let lock = NSLock()
+ nonisolated(unsafe) private static var _handler: (@Sendable (Int) -> MockReply)?
+ nonisolated(unsafe) private static var _requests: [RecordedRequest] = []
+
+ static func reset(_ handler: @escaping @Sendable (_ callIndex: Int) -> MockReply) {
+ lock.withLock { _handler = handler; _requests = [] }
+ }
+ static var requests: [RecordedRequest] { lock.withLock { _requests } }
+ static var lastRequest: RecordedRequest? { requests.last }
+ static var lastTimeout: TimeInterval? { lastRequest?.timeout }
+
+ static func session() -> URLSession {
+ let config = URLSessionConfiguration.ephemeral
+ config.protocolClasses = [MockURLProtocol.self]
+ return URLSession(configuration: config)
+ }
+
+ override class func canInit(with request: URLRequest) -> Bool { true }
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+
+ override func startLoading() {
+ let recorded = RecordedRequest(url: request.url, method: request.httpMethod,
+ headers: request.allHTTPHeaderFields ?? [:],
+ body: Self.readBody(from: request),
+ timeout: request.timeoutInterval)
+ let (index, handler) = Self.lock.withLock { () -> (Int, (@Sendable (Int) -> MockReply)?) in
+ Self._requests.append(recorded)
+ return (Self._requests.count - 1, Self._handler)
+ }
+ switch handler?(index) ?? .fail(.badServerResponse) {
+ case .respond(let status, let headers, let body):
+ let http = HTTPURLResponse(url: request.url!, statusCode: status,
+ httpVersion: "HTTP/2", headerFields: headers)!
+ client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: body)
+ client?.urlProtocolDidFinishLoading(self)
+ case .respondFrom(let url, let status):
+ let http = HTTPURLResponse(url: URL(string: url)!, statusCode: status,
+ httpVersion: "HTTP/2", headerFields: ["apns-id": "X"])!
+ client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocolDidFinishLoading(self)
+ case .fail(let code):
+ client?.urlProtocol(self, didFailWithError: URLError(code))
+ case .hang:
+ break
+ }
+ }
+
+ override func stopLoading() {}
+
+ static func readBody(from request: URLRequest) -> Data {
+ if let b = request.httpBody { return b }
+ guard let stream = request.httpBodyStream else { return Data() }
+ stream.open(); defer { stream.close() }
+ var data = Data()
+ var buffer = [UInt8](repeating: 0, count: 4096)
+ while stream.hasBytesAvailable {
+ let n = stream.read(&buffer, maxLength: buffer.count)
+ if n <= 0 { break }
+ data.append(buffer, count: n)
+ }
+ return data
+ }
+}
+
+// MARK: - Replies
+
+func ok(apnsID: String = "8F1D2A6C-5B9E-4C3A-9F00-000000000001",
+ uniqueID: String? = nil) -> MockReply {
+ var headers = ["apns-id": apnsID]
+ if let uniqueID { headers["apns-unique-id"] = uniqueID }
+ return .respond(status: 200, headers: headers, body: Data())
+}
+
+func rejected(_ status: Int, _ reason: String, timestamp: Int64? = nil) -> MockReply {
+ let body = timestamp.map { "{\"reason\":\"\(reason)\",\"timestamp\":\($0)}" } ?? "{\"reason\":\"\(reason)\"}"
+ return .respond(status: status, headers: ["apns-id": "8F1D2A6C-5B9E-4C3A-9F00-000000000002"],
+ body: Data(body.utf8))
+}
+
+// MARK: - Fixtures
+
+/// A syntactically valid 32-byte device token.
+let token64 = String(repeating: "ab", count: 32)
+let keyID = "ABCDE12345"
+let teamID = "TEAM123456"
+
+/// A settable pair of clocks for token-age tests: wall time (for `iat`) and monotonic
+/// time (for token age). `advance` moves both; `stepWall` moves only the wall clock.
+final class TestClock: @unchecked Sendable {
+ private let lock = NSLock()
+ private var wall: Date
+ private var mono: TimeInterval = 1_000
+ init(_ start: Date = Date(timeIntervalSince1970: 1_800_000_000)) { wall = start }
+ var now: Date { lock.withLock { wall } }
+ var uptime: TimeInterval { lock.withLock { mono } }
+ func advance(minutes: Double) { lock.withLock { wall += minutes * 60; mono += minutes * 60 } }
+ func stepWall(minutes: Double) { lock.withLock { wall += minutes * 60 } }
+}
+
+func credentials(_ key: P256.Signing.PrivateKey = P256.Signing.PrivateKey(), clock: TestClock) throws -> APNSCredentials {
+ try APNSCredentials(keyID: keyID, teamID: teamID, key: key, now: { clock.now }, uptime: { clock.uptime })
+}
+
+func makeClient(key: P256.Signing.PrivateKey = P256.Signing.PrivateKey(),
+ clock: TestClock? = nil,
+ environment: APNSClient.Environment = .sandbox,
+ timeout: TimeInterval = APNSClient.defaultTimeout,
+ _ handler: @escaping @Sendable (Int) -> MockReply) throws -> APNSClient {
+ MockURLProtocol.reset(handler)
+ let creds = try clock.map { try credentials(key, clock: $0) }
+ ?? APNSCredentials(keyID: keyID, teamID: teamID, key: key)
+ return try APNSClient(credentials: creds, environment: environment,
+ urlSession: MockURLProtocol.session(), timeout: timeout)
+}
+
+// MARK: - JWT helpers
+
+func base64URLDecode(_ s: Substring) -> Data? {
+ var b64 = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
+ b64 += String(repeating: "=", count: (4 - b64.count % 4) % 4)
+ return Data(base64Encoded: b64)
+}
+
+struct DecodedJWT {
+ let header: [String: Any]
+ let claims: [String: Any]
+ let headerJSON: String
+ let claimsJSON: String
+ let signatureValid: Bool
+}
+
+func decodeJWT(_ jwt: String, publicKey: P256.Signing.PublicKey) throws -> DecodedJWT {
+ let parts = jwt.split(separator: ".", omittingEmptySubsequences: false)
+ guard parts.count == 3, let h = base64URLDecode(parts[0]), let c = base64URLDecode(parts[1]),
+ let s = base64URLDecode(parts[2]) else { throw URLError(.cannotDecodeContentData) }
+ let signingInput = Data("\(parts[0]).\(parts[1])".utf8)
+ let signature = try P256.Signing.ECDSASignature(rawRepresentation: s)
+ return DecodedJWT(
+ header: try JSONSerialization.jsonObject(with: h) as? [String: Any] ?? [:],
+ claims: try JSONSerialization.jsonObject(with: c) as? [String: Any] ?? [:],
+ headerJSON: String(decoding: h, as: UTF8.self),
+ claimsJSON: String(decoding: c, as: UTF8.self),
+ signatureValid: publicKey.isValidSignature(signature, for: signingInput))
+}
+
+func bearer(_ request: RecordedRequest?) -> String? {
+ guard let auth = request?.header("authorization"), auth.hasPrefix("bearer ") else { return nil }
+ return String(auth.dropFirst("bearer ".count))
+}
+
+func json(_ data: Data) throws -> [String: Any] {
+ try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
+}