import Foundation import PushMessageDefinitions // Use crypto replacement on Linux #if canImport(CryptoKit) import CryptoKit #else import Crypto #endif // Import network types on Linux #if canImport(FoundationNetworking) import FoundationNetworking #endif /** A client to interact with a push server. */ public final class PushClient { /** The bas url to reach the push server */ public let server: URL /** The application id of the service for which this client is used. */ public let application: ApplicationId private static let encoder = JSONEncoder() private static let decoder = JSONDecoder() /** Create a new client. - Parameter server: The base url of the push server. - Parameter application: The id of the application */ public init(server: URL, application: ApplicationId) { self.server = server self.application = application } /** Register the device with the push server. - Parameter token: The APNs token of the device - Parameter name: The optional device name for easier identification - Returns: The new authentication token associated with the device - Note: Depending on the application, the device must be approved by an administrator. */ public func register(token: PushToken, name: String? = nil) async -> AuthenticationToken? { let device = DeviceRegistration( pushToken: token, application: application, name: name ?? "") return await post(.registerNewDevice, body: device) } /** Get the list of all devices in the same application. - Parameter pushToken: The APNs token of the registered device. - Parameter authToken: The authentication token of the device. - Returns: The list of registered and approved devices in the application */ public func getDeviceList(pushToken: PushToken, authToken: AuthenticationToken) async -> [DeviceRegistration]? { let device = DeviceAuthentication(pushToken: pushToken, authentication: authToken) guard let data = await post(.listDevicesInApplication, body: device) else { return nil } do { return try PushClient.decoder.decode([DeviceRegistration].self, from: data) } catch { return nil } } /** Get a list of unapproved devices for an administrator key. - Parameter masterKey: The master key of the administrator - Returns: A list of registered devices which need to be approved or rejected. */ public func getUnapprovedDevices(masterKey: String) async -> [DeviceRegistration]? { let hash = hash(masterKey) let request = AdminRequest(keyHash: hash, application: application) guard let data = await post(.listUnapprovedDevices, body: request) else { return nil } do { return try PushClient.decoder.decode([DeviceRegistration].self, from: data) } catch { return nil } } /** Approve a device using the administrator key. - Parameter pushToken: The push token of the approved device. - Parameter masterKey: The administrator master key. - Returns: `true`, if the device has been approved */ public func approve(pushToken: PushToken, with masterKey: String) async -> Bool { let hash = hash(masterKey) let device = DeviceDecision(pushToken: pushToken, masterKeyHash: hash) return await post(.approveDevice, body: device) != nil } /** Reject a device using the administrator key. - Parameter pushToken: The push token of the rejected device. - Parameter masterKey: The administrator master key. - Returns: `true`, if the device has been rejected */ public func reject(pushToken: PushToken, with masterKey: String) async -> Bool { let hash = hash(masterKey) let device = DeviceDecision(pushToken: pushToken, masterKeyHash: hash) return await post(.rejectDevice, body: device) != nil } /** Check if the device has been approved. - Parameter token: The APNs token of the device. - Parameter authToken: The authentication token of the device. - Returns: `true`, if the device is approved, `false` on error */ public func isConfirmed(token: PushToken, authentication: AuthenticationToken) async -> Bool { let device = DeviceAuthentication(pushToken: token, authentication: authentication) return await post(.isDeviceApproved, body: device) != nil } /** Send a push notification. - Parameter message: The push message to send - Returns: `true` if the message was sent */ public func send(push message: AuthenticatedPushMessage) async -> Bool { await post(.sendPushNotification, body: message) != nil } private func post(_ route: Route, body: T) async -> Data? where T: Encodable { let bodyData = try! PushClient.encoder.encode(body) return await post(route, bodyData: bodyData) } private func post(_ route: Route, bodyData: Data) async -> Data? { var request = URLRequest(url: server.appendingPathComponent(route.rawValue)) request.httpBody = bodyData request.httpMethod = "POST" return await post(request) } private func post(_ request: URLRequest) async -> Data? { do { let (data, response) : (Data, URLResponse) = try await data(for: request) guard let httpResponse = response as? HTTPURLResponse else { return nil } guard httpResponse.statusCode == 200 else { print("Failed with code: \(httpResponse.statusCode)") return nil } return data } catch { print("Failed with error: \(error)") return nil } } private func data(for request: URLRequest) async throws -> (Data, URLResponse) { #if !canImport(FoundationNetworking) if #available(iOS 15.0, macOS 12.0, *) { return try await URLSession.shared.data(for: request) } #endif return try await URLSession.shared.dataRequest(request) } private func hash(_ masterKey: String) -> Data { Data(SHA256.hash(data: masterKey.data(using: .utf8)!)) } } private extension URLSession { func dataRequest(_ request: URLRequest) async throws -> (Data, URLResponse) { try await withCheckedThrowingContinuation { continuation in dataTask(with: request) { data, response, error in if let error = error { print("Failed with error: \(error)") continuation.resume(throwing: error) return } guard let data = data, let response = response else { continuation.resume(throwing: URLError(.unknown)) return } continuation.resume(returning: (data, response)) }.resume() } } }