Skip to content

Commit 97a5d0c

Browse files
authored
Add iOS 18+ Broadcast Push Notifications Support (#230)
1 parent eb302f9 commit 97a5d0c

19 files changed

+2156
-5
lines changed

.github/workflows/swift.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,21 @@ on:
44
jobs:
55
focal:
66
container:
7-
image: swiftlang/swift:nightly-6.0-focal
7+
image: swift:6.2-bookworm
88
runs-on: ubuntu-latest
99
steps:
1010
- uses: actions/checkout@v1
1111
- run: swift test
1212
thread:
1313
container:
14-
image: swiftlang/swift:nightly-6.0-focal
14+
image: swift:6.2-bookworm
1515
runs-on: ubuntu-latest
1616
steps:
1717
- uses: actions/checkout@v1
1818
- run: swift test --sanitize=thread
1919
address:
2020
container:
21-
image: swiftlang/swift:nightly-6.0-focal
21+
image: swift:6.2-bookworm
2222
runs-on: ubuntu-latest
2323
steps:
2424
- uses: actions/checkout@v1

BROADCAST_CHANNELS.md

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# Broadcast Channels Support
2+
3+
This implementation adds support for iOS 18+ broadcast push notifications to APNSwift.
4+
5+
## What's Implemented
6+
7+
### Core Types (APNSCore)
8+
9+
- **APNSBroadcastEnvironment**: Production and sandbox broadcast environments
10+
- **APNSBroadcastMessageStoragePolicy**: Enum for message storage options (none or most recent)
11+
- **APNSBroadcastChannel**: Represents a broadcast channel configuration
12+
- **APNSBroadcastChannelList**: List of channel IDs
13+
- **APNSBroadcastRequest**: Generic request type for all broadcast operations
14+
- **APNSBroadcastResponse**: Generic response type
15+
- **APNSBroadcastClientProtocol**: Protocol defining broadcast operations
16+
17+
### Client (APNS)
18+
19+
- **APNSBroadcastClient**: Full implementation with HTTP method routing for:
20+
- POST /channels (create)
21+
- GET /channels (list all)
22+
- GET /channels/{id} (read)
23+
- DELETE /channels/{id} (delete)
24+
25+
### Test Infrastructure (APNSTestServer)
26+
27+
- **APNSTestServer**: Unified real SwiftNIO HTTP server that mocks both:
28+
- Apple's regular push notification API (`POST /3/device/{token}`)
29+
- Apple's broadcast channel API (`POST/GET/DELETE /channels[/{id}]`)
30+
- In-memory channel storage
31+
- Notification recording with full metadata
32+
- Proper HTTP method handling
33+
- Error responses (404, 400)
34+
- Request ID generation
35+
36+
### Tests
37+
38+
- **APNSBroadcastChannelTests**: Unit tests for encoding/decoding channels (4 tests)
39+
- **APNSBroadcastChannelListTests**: Unit tests for channel lists (3 tests)
40+
- **APNSBroadcastClientTests**: Broadcast channel integration tests (9 tests)
41+
- **APNSClientIntegrationTests**: Push notification integration tests (10 tests)
42+
- Alert, Background, VoIP, FileProvider, Complication notifications
43+
- Header validation, multiple notifications
44+
45+
## Usage Example
46+
47+
```swift
48+
import APNS
49+
import APNSCore
50+
import Crypto
51+
52+
// Create a broadcast client
53+
let client = APNSBroadcastClient(
54+
authenticationMethod: .jwt(
55+
privateKey: try P256.Signing.PrivateKey(pemRepresentation: privateKey),
56+
keyIdentifier: "YOUR_KEY_ID",
57+
teamIdentifier: "YOUR_TEAM_ID"
58+
),
59+
environment: .production, // or .development
60+
eventLoopGroupProvider: .createNew,
61+
responseDecoder: JSONDecoder(),
62+
requestEncoder: JSONEncoder()
63+
)
64+
65+
// Create a new broadcast channel
66+
let channel = APNSBroadcastChannel(messageStoragePolicy: .mostRecentMessageStored)
67+
let response = try await client.create(channel: channel, apnsRequestID: nil)
68+
let channelID = response.body.channelID!
69+
70+
// Read channel info
71+
let channelInfo = try await client.read(channelID: channelID, apnsRequestID: nil)
72+
73+
// List all channels
74+
let allChannels = try await client.readAllChannelIDs(apnsRequestID: nil)
75+
print("Channels: \\(allChannels.body.channels)")
76+
77+
// Delete a channel
78+
try await client.delete(channelID: channelID, apnsRequestID: nil)
79+
80+
// Shutdown when done
81+
try await client.shutdown()
82+
```
83+
84+
## Testing with Mock Server
85+
86+
The unified `APNSTestServer` allows you to test both broadcast channels AND regular push notifications without hitting real Apple servers:
87+
88+
```swift
89+
import APNSTestServer
90+
91+
// Start mock server on random port
92+
let server = APNSTestServer()
93+
try await server.start(port: 0)
94+
95+
// Test broadcast channels
96+
let broadcastClient = APNSBroadcastClient(
97+
authenticationMethod: .jwt(...),
98+
environment: .custom(url: "http://127.0.0.1", port: server.port),
99+
eventLoopGroupProvider: .createNew,
100+
responseDecoder: JSONDecoder(),
101+
requestEncoder: JSONEncoder()
102+
)
103+
104+
// Test regular push notifications
105+
let pushClient = APNSClient(
106+
configuration: .init(
107+
authenticationMethod: .jwt(...),
108+
environment: .custom(url: "http://127.0.0.1", port: server.port)
109+
),
110+
eventLoopGroupProvider: .createNew,
111+
responseDecoder: JSONDecoder(),
112+
requestEncoder: JSONEncoder()
113+
)
114+
115+
// Send notifications and verify
116+
let notification = APNSAlertNotification(...)
117+
try await pushClient.sendAlertNotification(notification, deviceToken: "device-token")
118+
119+
let sent = server.getSentNotifications()
120+
XCTAssertEqual(sent.count, 1)
121+
XCTAssertEqual(sent[0].pushType, "alert")
122+
123+
// Cleanup
124+
try await broadcastClient.shutdown()
125+
try await pushClient.shutdown()
126+
try await server.shutdown()
127+
```
128+
129+
## Architecture Decisions
130+
131+
1. **Kept internal access control**: The `APNSPushType.Configuration` enum remains internal to avoid breaking the public API
132+
133+
2. **String-based HTTP methods**: APNSCore uses string-based HTTP methods to avoid depending on NIOHTTP1
134+
135+
3. **Generic request/response types**: Allows type-safe operations while maintaining flexibility
136+
137+
4. **Real NIO server for testing**: The mock server uses actual SwiftNIO HTTP server components for realistic testing
138+
139+
5. **Protocol-based client**: Allows for easy mocking and testing in consumer code
140+
141+
## Running Tests
142+
143+
```bash
144+
# Run all tests
145+
swift test
146+
147+
# Run only broadcast tests
148+
swift test --filter Broadcast
149+
150+
# Run unit tests only
151+
swift test --filter APNSBroadcastChannelTests
152+
swift test --filter APNSBroadcastChannelListTests
153+
154+
# Run integration tests
155+
swift test --filter APNSBroadcastClientTests
156+
```
157+
158+
## What's Left to Do
159+
160+
1. **Documentation**: Add DocC documentation for all public APIs
161+
2. **Send notifications to channels**: Implement sending push notifications to broadcast channels (separate from channel management)
162+
3. **Error handling improvements**: Add more specific error types for broadcast operations
163+
4. **Rate limiting**: Consider adding rate limiting for test server
164+
5. **Swift 6 consideration**: Maintainer asked about making this Swift 6-only - decision pending
165+
166+
## References
167+
168+
- [Apple Push Notification service documentation](https://developer.apple.com/documentation/usernotifications)
169+
- Issue: https://github.com/swift-server-community/APNSwift/issues/205
170+
- Original WIP branch: https://github.com/eliperkins/APNSwift/tree/channels

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ let package = Package(
3939
dependencies: [
4040
.target(name: "APNSCore"),
4141
.target(name: "APNS"),
42+
.target(name: "APNSTestServer"),
4243
]
4344
),
4445
.target(

Package@swift-5.10.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ let package = Package(
3939
dependencies: [
4040
.target(name: "APNSCore"),
4141
.target(name: "APNS"),
42+
.target(name: "APNSTestServer"),
4243
]
4344
),
4445
.target(
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the APNSwift open source project
4+
//
5+
// Copyright (c) 2024 the APNSwift project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of APNSwift project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import APNSCore
16+
import AsyncHTTPClient
17+
import struct Foundation.Date
18+
import struct Foundation.UUID
19+
import NIOConcurrencyHelpers
20+
import NIOCore
21+
import NIOHTTP1
22+
import NIOSSL
23+
import NIOTLS
24+
import NIOPosix
25+
26+
/// A client for managing Apple Push Notification broadcast channels.
27+
public final class APNSBroadcastClient<Decoder: APNSJSONDecoder & Sendable, Encoder: APNSJSONEncoder & Sendable>: APNSBroadcastClientProtocol {
28+
29+
/// The broadcast environment to use.
30+
private let environment: APNSBroadcastEnvironment
31+
32+
/// The ``HTTPClient`` used by the APNS broadcast client.
33+
private let httpClient: HTTPClient
34+
35+
/// The decoder for the responses from APNs.
36+
private let responseDecoder: Decoder
37+
38+
/// The encoder for the requests to APNs.
39+
@usableFromInline
40+
/* private */ internal let requestEncoder: Encoder
41+
42+
/// The authentication token manager.
43+
private let authenticationTokenManager: APNSAuthenticationTokenManager<ContinuousClock>?
44+
45+
/// The ByteBufferAllocator
46+
@usableFromInline
47+
/* private */ internal let byteBufferAllocator: ByteBufferAllocator
48+
49+
/// Default ``HTTPHeaders`` which will be adapted for each request. This saves some allocations.
50+
private let defaultRequestHeaders: HTTPHeaders = {
51+
var headers = HTTPHeaders()
52+
headers.reserveCapacity(10)
53+
headers.add(name: "content-type", value: "application/json")
54+
headers.add(name: "user-agent", value: "APNS/swift-nio")
55+
return headers
56+
}()
57+
58+
/// Initializes a new APNSBroadcastClient.
59+
///
60+
/// The client will create an internal ``HTTPClient`` which is used to make requests to APNs broadcast API.
61+
///
62+
/// - Parameters:
63+
/// - authenticationMethod: The authentication method to use.
64+
/// - environment: The broadcast environment (production or sandbox).
65+
/// - eventLoopGroupProvider: Specify how EventLoopGroup will be created.
66+
/// - responseDecoder: The decoder for the responses from APNs.
67+
/// - requestEncoder: The encoder for the requests to APNs.
68+
/// - byteBufferAllocator: The `ByteBufferAllocator`.
69+
public init(
70+
authenticationMethod: APNSClientConfiguration.AuthenticationMethod,
71+
environment: APNSBroadcastEnvironment,
72+
eventLoopGroupProvider: NIOEventLoopGroupProvider,
73+
responseDecoder: Decoder,
74+
requestEncoder: Encoder,
75+
byteBufferAllocator: ByteBufferAllocator = .init()
76+
) {
77+
self.environment = environment
78+
self.byteBufferAllocator = byteBufferAllocator
79+
self.responseDecoder = responseDecoder
80+
self.requestEncoder = requestEncoder
81+
82+
var tlsConfiguration = TLSConfiguration.makeClientConfiguration()
83+
switch authenticationMethod.method {
84+
case .jwt(let privateKey, let teamIdentifier, let keyIdentifier):
85+
self.authenticationTokenManager = APNSAuthenticationTokenManager(
86+
privateKey: privateKey,
87+
teamIdentifier: teamIdentifier,
88+
keyIdentifier: keyIdentifier,
89+
clock: ContinuousClock()
90+
)
91+
case .tls(let privateKey, let certificateChain):
92+
self.authenticationTokenManager = nil
93+
tlsConfiguration.privateKey = privateKey
94+
tlsConfiguration.certificateChain = certificateChain
95+
}
96+
97+
var httpClientConfiguration = HTTPClient.Configuration()
98+
httpClientConfiguration.tlsConfiguration = tlsConfiguration
99+
httpClientConfiguration.httpVersion = .automatic
100+
101+
switch eventLoopGroupProvider {
102+
case .shared(let eventLoopGroup):
103+
self.httpClient = HTTPClient(
104+
eventLoopGroupProvider: .shared(eventLoopGroup),
105+
configuration: httpClientConfiguration
106+
)
107+
case .createNew:
108+
self.httpClient = HTTPClient(
109+
configuration: httpClientConfiguration
110+
)
111+
}
112+
}
113+
114+
/// Shuts down the client gracefully.
115+
public func shutdown() async throws {
116+
try await self.httpClient.shutdown()
117+
}
118+
}
119+
120+
extension APNSBroadcastClient: Sendable where Decoder: Sendable, Encoder: Sendable {}
121+
122+
// MARK: - Broadcast operations
123+
124+
extension APNSBroadcastClient {
125+
126+
public func send<Message: Encodable & Sendable, ResponseBody: Decodable & Sendable>(
127+
_ request: APNSBroadcastRequest<Message>
128+
) async throws -> APNSBroadcastResponse<ResponseBody> {
129+
var headers = self.defaultRequestHeaders
130+
131+
// Add request ID if present
132+
if let apnsRequestID = request.apnsRequestID {
133+
headers.add(name: "apns-request-id", value: apnsRequestID.uuidString.lowercased())
134+
}
135+
136+
// Authorization token
137+
if let authenticationTokenManager = self.authenticationTokenManager {
138+
let token = try await authenticationTokenManager.nextValidToken
139+
headers.add(name: "authorization", value: token)
140+
}
141+
142+
// Build the request URL
143+
let requestURL = "\(self.environment.url):\(self.environment.port)\(request.operation.path)"
144+
145+
// Create HTTP request
146+
var httpClientRequest = HTTPClientRequest(url: requestURL)
147+
httpClientRequest.method = HTTPMethod(rawValue: request.operation.httpMethod)
148+
httpClientRequest.headers = headers
149+
150+
// Add body for operations that require it (e.g., create)
151+
if let message = request.message {
152+
var byteBuffer = self.byteBufferAllocator.buffer(capacity: 0)
153+
try self.requestEncoder.encode(message, into: &byteBuffer)
154+
httpClientRequest.body = .bytes(byteBuffer)
155+
}
156+
157+
// Execute the request
158+
let response = try await self.httpClient.execute(httpClientRequest, deadline: .distantFuture)
159+
160+
// Extract request ID from response
161+
let apnsRequestID = response.headers.first(name: "apns-request-id").flatMap { UUID(uuidString: $0) }
162+
163+
// Handle successful responses
164+
if response.status == .ok || response.status == .created {
165+
let body = try await response.body.collect(upTo: 1024 * 1024) // 1MB max
166+
let responseBody = try responseDecoder.decode(ResponseBody.self, from: body)
167+
return APNSBroadcastResponse(apnsRequestID: apnsRequestID, body: responseBody)
168+
}
169+
170+
// Handle error responses
171+
let body = try await response.body.collect(upTo: 1024)
172+
let errorResponse = try responseDecoder.decode(APNSErrorResponse.self, from: body)
173+
174+
let error = APNSError(
175+
responseStatus: Int(response.status.code),
176+
apnsID: nil,
177+
apnsUniqueID: nil,
178+
apnsResponse: errorResponse,
179+
timestamp: errorResponse.timestampInSeconds.flatMap { Date(timeIntervalSince1970: $0) }
180+
)
181+
182+
throw error
183+
}
184+
}

0 commit comments

Comments
 (0)