Skip to content

Commit 07a1a81

Browse files
committed
Add broadcast push send support
1 parent 1f9d864 commit 07a1a81

8 files changed

Lines changed: 678 additions & 12 deletions

File tree

Sources/APNS/APNSBroadcastClient.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,16 @@ public final class APNSBroadcastClient<Decoder: APNSJSONDecoder & Sendable, Enco
7575
/// - responseDecoder: The decoder for the responses from APNs.
7676
/// - requestEncoder: The encoder for the requests to APNs.
7777
/// - byteBufferAllocator: The `ByteBufferAllocator`.
78+
/// - proxy: Upstream proxy, defaults to no proxy.
7879
public init(
7980
authenticationMethod: APNSClientConfiguration.AuthenticationMethod,
8081
environment: APNSBroadcastEnvironment,
8182
bundleID: String,
8283
eventLoopGroupProvider: NIOEventLoopGroupProvider,
8384
responseDecoder: Decoder,
8485
requestEncoder: Encoder,
85-
byteBufferAllocator: ByteBufferAllocator = .init()
86+
byteBufferAllocator: ByteBufferAllocator = .init(),
87+
proxy: HTTPClient.Configuration.Proxy? = nil
8688
) {
8789
self.environment = environment
8890
self.bundleID = bundleID
@@ -108,6 +110,7 @@ public final class APNSBroadcastClient<Decoder: APNSJSONDecoder & Sendable, Enco
108110
var httpClientConfiguration = HTTPClient.Configuration()
109111
httpClientConfiguration.tlsConfiguration = tlsConfiguration
110112
httpClientConfiguration.httpVersion = .automatic
113+
httpClientConfiguration.proxy = proxy
111114

112115
switch eventLoopGroupProvider {
113116
case .shared(let eventLoopGroup):

Sources/APNS/APNSClient.swift

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,4 +200,91 @@ extension APNSClient {
200200

201201
throw error
202202
}
203+
204+
/// Publishes a broadcast push notification.
205+
///
206+
/// Broadcast pushes are sent to the regular device-push host (``APNSCore/APNSEnvironment``), not the
207+
/// channel-management host, and are Live Activities only.
208+
///
209+
/// - Parameter request: The broadcast send request.
210+
public func sendBroadcast<Message: APNSCore.APNSMessage>(
211+
_ request: APNSCore.APNSBroadcastSendRequest<Message>
212+
) async throws -> APNSCore.APNSBroadcastSendResponse {
213+
var headers = self.defaultRequestHeaders
214+
215+
// Broadcast headers (apns-channel-id, apns-push-type, apns-expiration, apns-priority, apns-request-id)
216+
for (name, value) in request.headers {
217+
headers.add(name: name, value: value)
218+
}
219+
220+
// Authorization token
221+
if let authenticationTokenManager = self.authenticationTokenManager {
222+
let token = try await authenticationTokenManager.nextValidToken
223+
headers.add(name: "authorization", value: token)
224+
}
225+
226+
let requestURL = self.configuration.environment.broadcastSendURL(bundleID: request.bundleID)
227+
var byteBuffer = self.byteBufferAllocator.buffer(capacity: 0)
228+
229+
try self.requestEncoder.encode(request.message, into: &byteBuffer)
230+
231+
var httpClientRequest = HTTPClientRequest(url: requestURL)
232+
httpClientRequest.method = .POST
233+
httpClientRequest.headers = headers
234+
httpClientRequest.body = .bytes(byteBuffer)
235+
236+
let response = try await self.httpClient.execute(httpClientRequest, deadline: .distantFuture)
237+
238+
let apnsRequestID = response.headers.first(name: "apns-request-id").flatMap { UUID(uuidString: $0) }
239+
let apnsUniqueID = response.headers.first(name: "apns-unique-id").flatMap { UUID(uuidString: $0) }
240+
241+
if response.status == .ok {
242+
return APNSBroadcastSendResponse(apnsRequestID: apnsRequestID, apnsUniqueID: apnsUniqueID)
243+
}
244+
245+
let body = try await response.body.collect(upTo: 1024)
246+
let errorResponse = try responseDecoder.decode(APNSErrorResponse.self, from: body)
247+
248+
let error = APNSError(
249+
responseStatus: Int(response.status.code),
250+
apnsID: nil,
251+
apnsUniqueID: apnsUniqueID,
252+
apnsResponse: errorResponse,
253+
timestamp: errorResponse.timestampInSeconds.flatMap { Date(timeIntervalSince1970: $0) }
254+
)
255+
256+
throw error
257+
}
258+
}
259+
260+
// MARK: - Broadcast convenience
261+
262+
extension APNSClient {
263+
264+
/// Publishes a broadcast Live Activity update (or end) notification.
265+
///
266+
/// - Important: Broadcast is Live Activities only and cannot be used to *start* an activity.
267+
///
268+
/// - Parameters:
269+
/// - notification: The Live Activity notification to broadcast.
270+
/// - channelID: The base64-encoded channel ID to publish the broadcast on.
271+
/// - bundleID: The app's bundle identifier used in the API path.
272+
/// - apnsRequestID: An optional request ID for tracking.
273+
@discardableResult
274+
public func sendBroadcastLiveActivityNotification<ContentState: Encodable & Sendable>(
275+
_ notification: APNSCore.APNSLiveActivityNotification<ContentState>,
276+
channelID: String,
277+
bundleID: String,
278+
apnsRequestID: UUID? = nil
279+
) async throws -> APNSCore.APNSBroadcastSendResponse {
280+
let request = APNSCore.APNSBroadcastSendRequest(
281+
message: notification,
282+
channelID: channelID,
283+
bundleID: bundleID,
284+
expiration: notification.expiration,
285+
priority: notification.priority,
286+
apnsRequestID: apnsRequestID
287+
)
288+
return try await sendBroadcast(request)
289+
}
203290
}

Sources/APNSCore/APNSEnvironment.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ public struct APNSEnvironment: Sendable {
4141
public var absoluteURL: String {
4242
"\(url):\(port)/3/device"
4343
}
44-
45-
44+
45+
/// The fully constructed URL for publishing a broadcast push notification.
46+
///
47+
/// Broadcast pushes are sent to the regular device-push host, not the channel-management host.
48+
///
49+
/// - Parameter bundleID: The app's bundle identifier.
50+
public func broadcastSendURL(bundleID: String) -> String {
51+
"\(url):\(port)/4/broadcasts/apps/\(bundleID)"
52+
}
4653
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the APNSwift open source project
4+
//
5+
// Copyright (c) 2025 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+
#if canImport(FoundationEssentials)
16+
import struct FoundationEssentials.UUID
17+
#else
18+
import struct Foundation.UUID
19+
#endif
20+
21+
/// Represents a request to publish a broadcast push notification.
22+
///
23+
/// Broadcast pushes are sent to the regular device-push host (``APNSEnvironment``), not the
24+
/// channel-management host, at `POST /4/broadcasts/apps/<bundle ID>`. They are Live Activities only
25+
/// and cannot be used to start an activity.
26+
///
27+
/// See: https://developer.apple.com/documentation/usernotifications/sending-broadcast-push-notification-requests-to-apns
28+
public struct APNSBroadcastSendRequest<Message: APNSMessage>: Sendable {
29+
/// The message payload to broadcast.
30+
public let message: Message
31+
32+
/// The base64-encoded channel ID to publish the broadcast on.
33+
public let channelID: String
34+
35+
/// The app's bundle identifier used in the API path.
36+
public let bundleID: String
37+
38+
/// The date when the notification is no longer valid and can be discarded.
39+
public let expiration: APNSNotificationExpiration
40+
41+
/// The priority of the notification.
42+
public let priority: APNSPriority
43+
44+
/// An optional request ID for tracking. If you omit this, APNs creates a new UUID and returns it in the response.
45+
public let apnsRequestID: UUID?
46+
47+
/// Creates a broadcast send request.
48+
///
49+
/// - Parameters:
50+
/// - message: The message payload to broadcast.
51+
/// - channelID: The base64-encoded channel ID to publish the broadcast on.
52+
/// - bundleID: The app's bundle identifier used in the API path.
53+
/// - expiration: The date when the notification is no longer valid and can be discarded.
54+
/// - priority: The priority of the notification.
55+
/// - apnsRequestID: An optional request ID for tracking.
56+
public init(
57+
message: Message,
58+
channelID: String,
59+
bundleID: String,
60+
expiration: APNSNotificationExpiration,
61+
priority: APNSPriority,
62+
apnsRequestID: UUID? = nil
63+
) {
64+
self.message = message
65+
self.channelID = channelID
66+
self.bundleID = bundleID
67+
self.expiration = expiration
68+
self.priority = priority
69+
self.apnsRequestID = apnsRequestID
70+
}
71+
72+
/// The HTTP headers required (and optional) by APNs for a broadcast send request.
73+
///
74+
/// - Note: Unlike a regular device push, `apns-expiration` and `apns-priority` are always emitted
75+
/// since Apple requires both headers on broadcast requests.
76+
public var headers: [(String, String)] {
77+
var computedHeaders: [(String, String)] = []
78+
79+
/// Channel ID
80+
computedHeaders.append(("apns-channel-id", channelID))
81+
82+
/// Push type — broadcast only supports `liveactivity`.
83+
computedHeaders.append(("apns-push-type", "liveactivity"))
84+
85+
/// Expiration — required by Apple, so `.none` is sent as `0`.
86+
computedHeaders.append(("apns-expiration", "\(expiration.expiration ?? 0)"))
87+
88+
/// Priority — required by Apple.
89+
computedHeaders.append(("apns-priority", "\(priority.rawValue)"))
90+
91+
/// Request ID
92+
if let apnsRequestID {
93+
computedHeaders.append(("apns-request-id", apnsRequestID.uuidString.lowercased()))
94+
}
95+
96+
return computedHeaders
97+
}
98+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the APNSwift open source project
4+
//
5+
// Copyright (c) 2025 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+
#if canImport(FoundationEssentials)
16+
import struct FoundationEssentials.UUID
17+
#else
18+
import struct Foundation.UUID
19+
#endif
20+
21+
/// Represents a response from publishing a broadcast push notification.
22+
public struct APNSBroadcastSendResponse: Sendable, Hashable {
23+
/// The request ID returned by APNs, either echoing the one sent in the request or a newly generated one.
24+
public let apnsRequestID: UUID?
25+
26+
/// A unique ID for the broadcast notification, as determined by the APNs servers.
27+
public let apnsUniqueID: UUID?
28+
29+
public init(apnsRequestID: UUID?, apnsUniqueID: UUID?) {
30+
self.apnsRequestID = apnsRequestID
31+
self.apnsUniqueID = apnsUniqueID
32+
}
33+
}

0 commit comments

Comments
 (0)