├── .gitignore ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── APNS │ ├── APNS.swift │ ├── APNSJWTPayload.swift │ ├── APNSResult.swift │ ├── Errors.swift │ ├── Extensions │ ├── APNS+String.swift │ └── JWT+ES256.swift │ ├── KeyGenerator.swift │ ├── Message.swift │ ├── Payload.swift │ ├── PayloadContent.swift │ └── Profile.swift └── Tests ├── APNSTests ├── APNSTests.swift └── XCTestManifests.swift └── LinuxMain.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Console", 6 | "repositoryURL": "https://github.com/vapor/console.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "5b9796d39f201b3dd06800437abd9d774a455e57", 10 | "version": "3.0.2" 11 | } 12 | }, 13 | { 14 | "package": "Core", 15 | "repositoryURL": "https://github.com/vapor/core.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "7f56a09995bf3c8df562be456bdcda405d9d0678", 19 | "version": "3.4.1" 20 | } 21 | }, 22 | { 23 | "package": "Crypto", 24 | "repositoryURL": "https://github.com/vapor/crypto.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "4b85405430df1892ee3aa1554bdb477e96cf46ad", 28 | "version": "3.2.0" 29 | } 30 | }, 31 | { 32 | "package": "DatabaseKit", 33 | "repositoryURL": "https://github.com/vapor/database-kit.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "7a01659316b9f033fa2150d5cd5e9d3c3e46c2e3", 37 | "version": "1.3.0" 38 | } 39 | }, 40 | { 41 | "package": "HTTP", 42 | "repositoryURL": "https://github.com/vapor/http.git", 43 | "state": { 44 | "branch": null, 45 | "revision": "0fac5c7c9ab1e424621ba001d83cb59ffca99ad2", 46 | "version": "3.1.1" 47 | } 48 | }, 49 | { 50 | "package": "JWT", 51 | "repositoryURL": "https://github.com/vapor/jwt.git", 52 | "state": { 53 | "branch": null, 54 | "revision": "2e225c722bf26407c1c4bd11d341e48759f46095", 55 | "version": "3.0.0" 56 | } 57 | }, 58 | { 59 | "package": "Multipart", 60 | "repositoryURL": "https://github.com/vapor/multipart.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "e57007c23a52b68e44ebdfc70cbe882a7c4f1ec3", 64 | "version": "3.0.2" 65 | } 66 | }, 67 | { 68 | "package": "Routing", 69 | "repositoryURL": "https://github.com/vapor/routing.git", 70 | "state": { 71 | "branch": null, 72 | "revision": "3219e328491b0853b8554c5a694add344d2c6cfb", 73 | "version": "3.0.1" 74 | } 75 | }, 76 | { 77 | "package": "Service", 78 | "repositoryURL": "https://github.com/vapor/service.git", 79 | "state": { 80 | "branch": null, 81 | "revision": "281a70b69783891900be31a9e70051b6fe19e146", 82 | "version": "1.0.0" 83 | } 84 | }, 85 | { 86 | "package": "swift-nio", 87 | "repositoryURL": "https://github.com/apple/swift-nio.git", 88 | "state": { 89 | "branch": null, 90 | "revision": "cf08e673dc41dc63d34065234c8fc432e8d334c4", 91 | "version": "1.9.2" 92 | } 93 | }, 94 | { 95 | "package": "swift-nio-ssl", 96 | "repositoryURL": "https://github.com/apple/swift-nio-ssl.git", 97 | "state": { 98 | "branch": null, 99 | "revision": "6617eb0d3afcb12170594968df01ca63afb58ac5", 100 | "version": "1.2.0" 101 | } 102 | }, 103 | { 104 | "package": "swift-nio-ssl-support", 105 | "repositoryURL": "https://github.com/apple/swift-nio-ssl-support.git", 106 | "state": { 107 | "branch": null, 108 | "revision": "c02eec4e0e6d351cd092938cf44195a8e669f555", 109 | "version": "1.0.0" 110 | } 111 | }, 112 | { 113 | "package": "swift-nio-zlib-support", 114 | "repositoryURL": "https://github.com/apple/swift-nio-zlib-support.git", 115 | "state": { 116 | "branch": null, 117 | "revision": "37760e9a52030bb9011972c5213c3350fa9d41fd", 118 | "version": "1.0.0" 119 | } 120 | }, 121 | { 122 | "package": "TemplateKit", 123 | "repositoryURL": "https://github.com/vapor/template-kit.git", 124 | "state": { 125 | "branch": null, 126 | "revision": "db35b1c35aabd0f5db3abca0cfda7becfe9c43e2", 127 | "version": "1.1.0" 128 | } 129 | }, 130 | { 131 | "package": "URLEncodedForm", 132 | "repositoryURL": "https://github.com/vapor/url-encoded-form.git", 133 | "state": { 134 | "branch": null, 135 | "revision": "cbfe7ef6301557d3f2d0807a98165232ae06e1c6", 136 | "version": "1.0.4" 137 | } 138 | }, 139 | { 140 | "package": "Validation", 141 | "repositoryURL": "https://github.com/vapor/validation.git", 142 | "state": { 143 | "branch": null, 144 | "revision": "156f8adeac3440e868da3757777884efbc6ec0cc", 145 | "version": "2.1.0" 146 | } 147 | }, 148 | { 149 | "package": "Vapor", 150 | "repositoryURL": "https://github.com/vapor/vapor.git", 151 | "state": { 152 | "branch": null, 153 | "revision": "54632a6c1e7ecd9923c0d00b612de936de1c4745", 154 | "version": "3.0.8" 155 | } 156 | }, 157 | { 158 | "package": "WebSocket", 159 | "repositoryURL": "https://github.com/vapor/websocket.git", 160 | "state": { 161 | "branch": null, 162 | "revision": "141cb4d3814dc8062cb0b2f43e72801b5dfcf272", 163 | "version": "1.0.1" 164 | } 165 | } 166 | ] 167 | }, 168 | "version": 1 169 | } 170 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:4.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "APNS", 8 | products: [ 9 | .library(name: "APNS", targets: ["APNS"]), 10 | ], 11 | dependencies: [ 12 | .package(url: "https://github.com/vapor/vapor.git", from: "3.0.0"), 13 | .package(url: "https://github.com/vapor/jwt.git", from: "3.0.0"), 14 | ], 15 | targets: [ 16 | .target(name: "APNS", dependencies: ["Vapor", "JWT"]), 17 | .testTarget(name: "APNSTests", dependencies: ["APNS"]), 18 | ] 19 | ) 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!IMPORTANT] 2 | > This repo has been supersceded by https://github.com/swift-server-community/APNSwift 3 | 4 | # APNS 5 | 6 | Pure Swift/Vapor 3 APNS Library. 7 | 8 | Note: This requires HTTP/2. 9 | Vapor is supposed to get a native HTTP/2 client built in so until then, this library is unoperational 10 | -------------------------------------------------------------------------------- /Sources/APNS/APNS.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APNS.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 4/1/18. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | 11 | public final class APNS: ServiceType { 12 | 13 | var worker: Container 14 | var client: FoundationClient 15 | 16 | public static func makeService(for worker: Container) throws -> APNS { 17 | return try APNS(worker: worker) 18 | } 19 | 20 | public init(worker: Container) throws{ 21 | self.worker = worker 22 | self.client = try FoundationClient.makeService(for: worker) 23 | } 24 | 25 | /// Send the message 26 | public func send(message: Message) throws -> Future { 27 | return try self.client.send(message.generateRequest(on: self.worker)).map(to: APNSResult.self) { response in 28 | guard let body = response.http.body.data, body.count != 0 else { 29 | return APNSResult.success( 30 | apnsId: message.messageId, 31 | deviceToken: message.deviceToken 32 | ) 33 | } 34 | do { 35 | let decoder = JSONDecoder() 36 | let error = try decoder.decode(APNSError.self, from: body) 37 | return APNSResult.error( 38 | apnsId: message.messageId, 39 | deviceToken: message.deviceToken, 40 | error: error 41 | ) 42 | } catch _ { 43 | return APNSResult.error( 44 | apnsId: message.messageId, 45 | deviceToken: message.deviceToken, 46 | error: APNSError.unknown 47 | ) 48 | } 49 | } 50 | } 51 | 52 | public func sendRaw(message: Message) throws -> Future { 53 | return try self.client.send(message.generateRequest(on: self.worker)) 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /Sources/APNS/APNSJWTPayload.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APNSJWTPayload.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 4/1/18. 6 | // 7 | 8 | import Foundation 9 | import JWT 10 | 11 | struct APNSJWTPayload: JWTPayload { 12 | let iss: String 13 | let iat = IssuedAtClaim(value: Date()) 14 | let exp = ExpirationClaim(value: Date(timeInterval: 3500, since: Date())) 15 | 16 | func verify(using signer: JWTSigner) throws { 17 | try self.exp.verifyNotExpired() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/APNS/APNSResult.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Result.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 4/1/18. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum APNSResult { 11 | case success(apnsId:String, deviceToken: String) 12 | case error(apnsId:String, deviceToken: String, error: APNSError) 13 | case networkError(error: Error) 14 | } 15 | -------------------------------------------------------------------------------- /Sources/APNS/Errors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Errors.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 4/1/18. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum APNSError: String, Codable { 11 | case badCollapseId = "BadCollapseId" 12 | case badDeviceToken = "BadDeviceToken" 13 | case badExpirationDate = "BadExpirationDate" 14 | case badMessageId = "BadMessageId" 15 | case badPriority = "BadPriority" 16 | case badTopic = "BadTopic" 17 | case badPath = "BadPath" 18 | case badCertificate = "BadCertificate" 19 | case badCertificateEnvironment = "BadCertificateEnvironment" 20 | case deviceTokenNotForTopic = "DeviceTokenNotForTopic" 21 | case duplicateHeaders = "DuplicateHeaders" 22 | case idleTimeout = "IdleTimeout" 23 | case missingDeviceToken = "MissingDeviceToken" 24 | case missingTopic = "MissingTopic" 25 | case payloadEmpty = "PayloadEmpty" 26 | case payloadTooLarge = "PayloadTooLarge" 27 | case topicDisallowed = "TopicDisallowed" 28 | case expiredProviderToken = "ExpiredProviderToken" 29 | case forbidden = "Forbidden" 30 | case invalidProviderToken = "InvalidProviderToken" 31 | case missingProviderToken = "MissingProviderToken" 32 | case methodNotAllowed = "MethodNotAllowed" 33 | case unregistered = "Unregistered" 34 | case tooManyProviderTokenUpdates = "TooManyProviderTokenUpdates" 35 | case tooManyRequests = "TooManyRequests" 36 | case internalServerError = "InternalServerError" 37 | case serviceUnavailable = "ServiceUnavailable" 38 | 39 | case shutdown = "Shutdown" 40 | case unknown = "unknown" 41 | 42 | public var description: String { 43 | return self.rawValue 44 | } 45 | } 46 | 47 | public enum TokenError: Error { 48 | case invalidAuthKey 49 | case invalidTokenString 50 | case wrongTokenLength 51 | case tokenWasNotGeneratedCorrectly 52 | } 53 | 54 | public enum SimpleError: Error { 55 | case string(message: String) 56 | } 57 | 58 | public enum MessageError: Error { 59 | case unableToGenerateBody 60 | case invalidURL 61 | } 62 | 63 | public enum InitializationError: Error, CustomStringConvertible { 64 | case noAuthentication 65 | case noTopic 66 | case certificateFileDoesNotExist 67 | case keyFileDoesNotExist 68 | 69 | public var description: String { 70 | switch self { 71 | case .noAuthentication: return "APNS Authentication is required. You can either use APNS Auth Key authentication (easiest to setup and maintain) or the old fashioned certificates way" 72 | case .noTopic: return "No APNS topic provided. This is required." 73 | case .certificateFileDoesNotExist: return "Certificate file could not be found on your disk. Double check if the file exists and if the path is correct" 74 | case .keyFileDoesNotExist: return "Key file could not be found on your disk. Double check if the file exists and if the path is correct" 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/APNS/Extensions/APNS+String.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APNS+String.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 4/1/18. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | func range(from nsRange: NSRange) -> Range? { 12 | guard 13 | let from16 = utf16.index(utf16.startIndex, offsetBy: nsRange.location, limitedBy: utf16.endIndex), 14 | let to16 = utf16.index(from16, offsetBy: nsRange.length, limitedBy: utf16.endIndex), 15 | let from = String.Index(from16, within: self), 16 | let to = String.Index(to16, within: self) 17 | else { return nil } 18 | return from ..< to 19 | } 20 | 21 | func collapseWhitespace() -> String { 22 | let thecomponents = components(separatedBy: CharacterSet.whitespacesAndNewlines).filter { !$0.isEmpty } 23 | return thecomponents.joined(separator: " ") 24 | } 25 | 26 | func between(_ left: String, _ right: String) -> String? { 27 | guard 28 | let leftRange = range(of:left), let rightRange = range(of: right, options: .backwards), 29 | left != right && leftRange.upperBound != rightRange.lowerBound 30 | else { return nil } 31 | 32 | return String(self[leftRange.upperBound...index(before: rightRange.lowerBound)]) 33 | } 34 | 35 | func splitByLength(_ length: Int) -> [String] { 36 | var result = [String]() 37 | var collectedCharacters = [Character]() 38 | collectedCharacters.reserveCapacity(length) 39 | var count = 0 40 | 41 | for character in self { 42 | collectedCharacters.append(character) 43 | count += 1 44 | if (count == length) { 45 | // Reached the desired length 46 | count = 0 47 | result.append(String(collectedCharacters)) 48 | collectedCharacters.removeAll(keepingCapacity: true) 49 | } 50 | } 51 | 52 | // Append the remainder 53 | if !collectedCharacters.isEmpty { 54 | result.append(String(collectedCharacters)) 55 | } 56 | 57 | return result 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/APNS/Extensions/JWT+ES256.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JWT+ES256.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 4/1/18. 6 | // 7 | 8 | import Foundation 9 | import CNIOOpenSSL 10 | import Crypto 11 | import Bits 12 | import JWT 13 | 14 | public enum JWTError: Error { 15 | case createKey 16 | case createPublicKey 17 | case decoding 18 | case encoding 19 | case incorrectNumberOfSegments 20 | case incorrectPayloadForClaimVerification 21 | case missingAlgorithm 22 | case missingClaim(withName: String) 23 | case privateKeyRequired 24 | case signatureVerificationFailed 25 | case signing 26 | case verificationFailedForClaim(withName: String) 27 | case wrongAlgorithm 28 | case unknown(Error) 29 | } 30 | 31 | public final class ES256: JWTAlgorithm { 32 | internal let curve = NID_X9_62_prime256v1 33 | internal let key: Data 34 | 35 | public var jwtAlgorithmName: String { 36 | return "ES256" 37 | } 38 | 39 | public init(key: Data) { 40 | self.key = key 41 | } 42 | 43 | public func sign(_ plaintext: LosslessDataConvertible) throws -> Data { 44 | let digest = Bytes(try SHA256.hash(plaintext)) 45 | let ecKey = try self.newECKeyPair() 46 | 47 | guard let signature = ECDSA_do_sign(digest, Int32(digest.count), ecKey) else { 48 | throw JWTError.signing 49 | } 50 | 51 | var derEncodedSignature: UnsafeMutablePointer? = nil 52 | let derLength = i2d_ECDSA_SIG(signature, &derEncodedSignature) 53 | guard let derCopy = derEncodedSignature, derLength > 0 else { 54 | throw JWTError.signing 55 | } 56 | 57 | var derBytes = [UInt8](repeating: 0, count: Int(derLength)) 58 | for b in 0.. Bool { 65 | var signaturePointer: UnsafePointer? = UnsafePointer(Bytes(signature)) 66 | let signature = d2i_ECDSA_SIG(nil, &signaturePointer, signature.count) 67 | let digest = Bytes(try SHA256.hash(plaintext)) 68 | let ecKey = try self.newECPublicKey() 69 | let result = ECDSA_do_verify(digest, Int32(digest.count), signature, ecKey) 70 | if result == 1 { 71 | return false 72 | } 73 | return true 74 | } 75 | 76 | func newECKey() throws -> OpaquePointer { 77 | guard let ecKey = EC_KEY_new_by_curve_name(curve) else { 78 | throw JWTError.createKey 79 | } 80 | return ecKey 81 | } 82 | 83 | func newECKeyPair() throws -> OpaquePointer { 84 | var privateNum = BIGNUM() 85 | 86 | // Set private key 87 | BN_init(&privateNum) 88 | BN_bin2bn(Bytes(key), Int32(key.count), &privateNum) 89 | let ecKey = try newECKey() 90 | EC_KEY_set_private_key(ecKey, &privateNum) 91 | 92 | // Derive public key 93 | let context = BN_CTX_new() 94 | BN_CTX_start(context) 95 | 96 | let group = EC_KEY_get0_group(ecKey) 97 | let publicKey = EC_POINT_new(group) 98 | EC_POINT_mul(group, publicKey, &privateNum, nil, nil, context) 99 | EC_KEY_set_public_key(ecKey, publicKey) 100 | 101 | // Release resources 102 | EC_POINT_free(publicKey) 103 | BN_CTX_end(context) 104 | BN_CTX_free(context) 105 | BN_clear_free(&privateNum) 106 | 107 | return ecKey 108 | } 109 | 110 | func newECPublicKey() throws -> OpaquePointer { 111 | var ecKey: OpaquePointer? = try self.newECKey() 112 | var publicBytesPointer: UnsafePointer? = UnsafePointer(Bytes(self.key)) 113 | 114 | if let ecKey = o2i_ECPublicKey(&ecKey, &publicBytesPointer, self.key.count) { 115 | return ecKey 116 | } else { 117 | throw JWTError.createPublicKey 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Sources/APNS/KeyGenerator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyGenerator.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 4/1/18. 6 | // 7 | 8 | import Foundation 9 | import Crypto 10 | import CNIOOpenSSL 11 | import Bits 12 | import NIO 13 | 14 | /// Key generators 15 | internal class KeyGenerator { 16 | /// Generates a new key, value pair 17 | internal static func generate(from path: String) -> (Data, Data){ 18 | var pKey = EVP_PKEY_new() 19 | 20 | let fp = fopen(path, "r") 21 | PEM_read_PrivateKey(fp, &pKey, nil, nil) 22 | let ecKey = EVP_PKEY_get1_EC_KEY(pKey) 23 | EC_KEY_set_conv_form(ecKey, POINT_CONVERSION_UNCOMPRESSED) 24 | fclose(fp) 25 | 26 | var pub: UnsafeMutablePointer? = nil 27 | let pub_len = i2o_ECPublicKey(ecKey, &pub) 28 | var publicKey = "" 29 | if let pub = pub { 30 | var publicBytes = Bytes(repeating: 0, count: Int(pub_len)) 31 | for i in 0.. Data? { 51 | var data = Data(capacity: key.count / 2) 52 | let regex = try! NSRegularExpression(pattern: "[0-9a-f]{1,2}", options: .caseInsensitive) 53 | regex.enumerateMatches(in: key, options: [], range: NSMakeRange(0, key.count)) { match, flags, stop in 54 | let range = key.range(from: match!.range) 55 | let byteString = key[range!] 56 | var num = UInt8(byteString, radix: 16) 57 | data.append(&num!, count: 1) 58 | } 59 | return data 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/APNS/Message.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Message.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 4/1/18. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | 11 | /// Push notification delivery priority 12 | public enum Priority: Int { 13 | /// Send the push message at a time that takes into account power considerations for the device. 14 | /// Notifications with this priority might be grouped and delivered in bursts. They are 15 | /// throttled, and in some cases are not delivered. 16 | case energyEfficient = 5 17 | 18 | /// Send the push message immediately. Notifications with this priority must trigger an 19 | /// alert, sound, or badge on the target device. It is an error to use this priority for a 20 | /// push notification that contains only the content-available key. 21 | case immediately = 10 22 | } 23 | 24 | public struct Message { 25 | /// APNS Developer profile info 26 | public let profile: Profile 27 | 28 | /// APNS Message UUID 29 | public let messageId: String = UUID().uuidString 30 | 31 | /// APNS message payload 32 | public let payload: Payload 33 | 34 | /// Message delivery priority 35 | public let priority: Priority 36 | 37 | /// Multiple notifications with the same collapse identifier are displayed to the user as a 38 | /// single notification. The value of this key must not exceed 64 bytes. For more information, 39 | /// see Quality of Service, Store-and-Forward, and Coalesced Notifications. 40 | public var collapseIdentifier: String? 41 | 42 | /// 43 | public var threadIdentifier: String? 44 | 45 | /// A UNIX epoch date expressed in seconds (UTC). This header identifies the date when the 46 | /// notification is no longer valid and can be discarded. If this value is nonzero, APNs stores 47 | /// the notification and tries to deliver it at least once, repeating the attempt as needed if 48 | /// it is unable to deliver the notification the first time. If the value is 0, APNs treats the 49 | /// notification as if it expires immediately and does not store the notification or attempt to 50 | /// redeliver it. 51 | public var expirationDate: Date? 52 | 53 | /// The device token to send the message to 54 | public let deviceToken: String 55 | 56 | /// Use the development or production servers 57 | public let development: Bool 58 | 59 | /// Creates a new message 60 | public init(priority: Priority = .immediately, profile: Profile, deviceToken: String, payload: Payload, on container: Container, development: Bool = false) throws { 61 | self.profile = profile 62 | self.priority = priority 63 | self.payload = payload 64 | self.deviceToken = deviceToken 65 | self.development = development 66 | } 67 | 68 | internal func generateRequest(on container: Container) throws -> Request { 69 | let request = Request(using: container) 70 | request.http.method = .POST 71 | 72 | request.http.headers.add(name: .connection, value: "Keep-Alive") 73 | request.http.headers.add(name: HTTPHeaderName("authorization"), value: "bearer \(self.profile.token ?? "")") 74 | request.http.headers.add(name: HTTPHeaderName("apns-id"), value: self.messageId) 75 | request.http.headers.add(name: HTTPHeaderName("apns-priority"), value: "\(self.priority.rawValue)") 76 | request.http.headers.add(name: HTTPHeaderName("apns-topic"), value: self.profile.topic) 77 | 78 | if let expiration = self.expirationDate { 79 | request.http.headers.add(name: HTTPHeaderName("apns-expiration"), value: String(expiration.timeIntervalSince1970.rounded())) 80 | } 81 | if let collapseId = self.collapseIdentifier { 82 | request.http.headers.add(name: HTTPHeaderName("apns-collapse-id"), value: collapseId) 83 | } 84 | if let threadId = self.threadIdentifier { 85 | request.http.headers.add(name: HTTPHeaderName("thread-id"), value: threadId) 86 | } 87 | if self.profile.tokenExpiration <= Date() { 88 | try self.profile.generateToken() 89 | } 90 | 91 | let encoder = JSONEncoder() 92 | request.http.body = try HTTPBody(data: encoder.encode(PayloadContent(payload: self.payload))) 93 | 94 | if self.development { 95 | guard let url = URL(string: "https://api.development.push.apple.com/3/device/\(self.deviceToken)") else { 96 | throw MessageError.invalidURL 97 | } 98 | request.http.url = url 99 | } else { 100 | guard let url = URL(string: "https://api.push.apple.com/3/device/\(self.deviceToken)") else { 101 | throw MessageError.invalidURL 102 | } 103 | request.http.url = url 104 | } 105 | 106 | return request 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Sources/APNS/Payload.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Payload.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 1/1/18. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | 11 | public final class Payload: Content { 12 | 13 | /// The number to display as the badge of the app icon. 14 | public var badge: Int? 15 | 16 | /// A short string describing the purpose of the notification. Apple Watch displays this string 17 | /// as part of the notification interface. This string is displayed only briefly and should be 18 | /// crafted so that it can be understood quickly. This key was added in iOS 8.2. 19 | public var title: String? 20 | 21 | /// A secondary description of the reason for the alert. 22 | public var subtitle: String? 23 | 24 | /// The text of the alert message. Can be nil if using titleLocKey 25 | public var body: String? 26 | 27 | /// The key to a title string in the Localizable.strings file for the current localization. 28 | /// The key string can be formatted with %@ and %n$@ specifiers to take the variables specified 29 | /// in the titleLocArgs array. 30 | public var titleLocKey: String? 31 | 32 | /// Variable string values to appear in place of the format specifiers in titleLocKey. 33 | public var titleLocArgs: [String]? 34 | 35 | /// If a string is specified, the system displays an alert that includes the Close and View 36 | /// buttons. The string is used as a key to get a localized string in the current localization 37 | /// to use for the right button’s title instead of “View”. 38 | public var actionLocKey: String? 39 | 40 | /// A key to an alert-message string in a Localizable.strings file for the current localization 41 | /// (which is set by the user’s language preference). The key string can be formatted 42 | /// with %@ and %n$@ specifiers to take the variables specified in the bodyLocArgs array. 43 | public var bodyLocKey: String? 44 | 45 | /// Variable string values to appear in place of the format specifiers in bodyLocKey. 46 | public var bodyLocArgs: [String]? 47 | 48 | /// The filename of an image file in the app bundle, with or without the filename extension. 49 | /// The image is used as the launch image when users tap the action button or move the action 50 | /// slider. If this property is not specified, the system either uses the previous snapshot, 51 | /// uses the image identified by the UILaunchImageFile key in the app’s Info.plist file, 52 | /// or falls back to Default.png. 53 | public var launchImage: String? 54 | 55 | /// The name of a sound file in the app bundle or in the Library/Sounds folder of the app’s 56 | /// data container. The sound in this file is played as an alert. If the sound file doesn’t 57 | /// exist or default is specified as the value, the default alert sound is played. 58 | public var sound: String? 59 | 60 | /// a category that is used by iOS 10+ notifications 61 | public var category: String? 62 | 63 | /// Silent push notification. This automatically ignores any other push message keys 64 | /// (title, body, ect.) and only the extra key-value pairs are added to the final payload 65 | public var contentAvailable: Bool = false 66 | 67 | /// A Boolean indicating whether the payload contains content that can be modified by an 68 | /// iOS 10+ Notification Service Extension (media, encrypted content, ...) 69 | public var hasMutableContent: Bool = false 70 | 71 | /// When displaying notifications, the system visually groups notifications with the same 72 | /// thread identifier together. 73 | public var threadId: String? 74 | 75 | /// Any extra key-value pairs to add to the JSON 76 | public var extra: [String : String] = [:] 77 | 78 | /// Empty Initializer 79 | public init() { } 80 | } 81 | 82 | /// Convenience initializers 83 | extension Payload { 84 | public convenience init(message: String) { 85 | self.init() 86 | 87 | self.body = message 88 | } 89 | 90 | public convenience init(title: String, body: String) { 91 | self.init() 92 | 93 | self.title = title 94 | self.body = body 95 | } 96 | 97 | public convenience init(title: String, subtitle: String, body: String) { 98 | self.init() 99 | 100 | self.title = title 101 | self.subtitle = subtitle 102 | self.body = body 103 | } 104 | 105 | public convenience init(title: String, body: String, badge: Int) { 106 | self.init() 107 | 108 | self.title = title 109 | self.body = body 110 | self.badge = badge 111 | } 112 | 113 | /// A simple, already made, Content-Available payload 114 | public static var contentAvailable: Payload { 115 | let payload = Payload() 116 | payload.contentAvailable = true 117 | return payload 118 | } 119 | } 120 | 121 | /// Equatable 122 | extension Payload: Equatable { 123 | public static func ==(lhs: Payload, rhs: Payload) -> Bool { 124 | return lhs.badge == rhs.badge || 125 | lhs.title == rhs.title || 126 | lhs.body == rhs.body || 127 | lhs.titleLocKey == rhs.titleLocKey || 128 | (lhs.titleLocArgs != nil && 129 | rhs.titleLocArgs != nil && 130 | lhs.titleLocArgs! == rhs.titleLocArgs!) || 131 | lhs.actionLocKey == rhs.actionLocKey || 132 | lhs.bodyLocKey == rhs.bodyLocKey || 133 | (lhs.bodyLocArgs != nil && 134 | rhs.bodyLocArgs != nil && 135 | lhs.bodyLocArgs == rhs.bodyLocArgs) || 136 | lhs.launchImage == rhs.launchImage || 137 | lhs.sound == rhs.sound || 138 | lhs.contentAvailable == rhs.contentAvailable || 139 | lhs.threadId == rhs.threadId 140 | } 141 | 142 | } 143 | -------------------------------------------------------------------------------- /Sources/APNS/PayloadContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PayloadContent.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 4/6/18. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | 11 | struct Alert: Content { 12 | enum CodingKeys: String, CodingKey { 13 | case title = "title" 14 | case subtitle = "subtitle" 15 | case body = "body" 16 | 17 | case titleLocKey = "title-loc-key" 18 | case titleLocArgs = "title-loc-args" 19 | 20 | case actionLocKey = "action-loc-key" 21 | 22 | case bodyLocKey = "body-loc-key" 23 | case bodyLocArgs = "body-loc-args" 24 | 25 | case launchImage = "launch-image" 26 | } 27 | var title: String? 28 | var subtitle: String? 29 | var body: String? 30 | var titleLocKey: String? 31 | var titleLocArgs: [String]? 32 | var actionLocKey: String? 33 | var bodyLocKey: String? 34 | var bodyLocArgs: [String]? 35 | var launchImage: String? 36 | } 37 | 38 | struct APS: Content { 39 | var alert: Alert 40 | var badge: Int? 41 | var sound: String? 42 | var category: String? 43 | var contentAvailable: Bool = false 44 | var hasMutableContent: Bool = false 45 | } 46 | 47 | struct PayloadContent: Content { 48 | var aps: APS 49 | var threadId: String? 50 | var extra: [String : String] = [:] 51 | 52 | init(payload: Payload) { 53 | let alert = Alert( 54 | title: payload.title, 55 | subtitle: payload.subtitle, 56 | body: payload.body, 57 | titleLocKey: payload.titleLocKey, 58 | titleLocArgs: payload.titleLocArgs, 59 | actionLocKey: payload.actionLocKey, 60 | bodyLocKey: payload.bodyLocKey, 61 | bodyLocArgs: payload.bodyLocArgs, 62 | launchImage: payload.launchImage 63 | ) 64 | self.aps = APS( 65 | alert: alert, 66 | badge: payload.badge, 67 | sound: payload.sound, 68 | category: payload.category, 69 | contentAvailable: payload.contentAvailable, 70 | hasMutableContent: payload.hasMutableContent 71 | ) 72 | self.threadId = payload.threadId 73 | self.extra = payload.extra 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/APNS/Profile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Profile.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 4/1/18. 6 | // 7 | 8 | import Foundation 9 | import JWT 10 | 11 | public class Profile { 12 | /// The two port options for Apple's APNS 13 | public enum Port: Int { 14 | /// Default HTTPS Port 443 15 | case `default` = 443 16 | 17 | /// You can alternatively use port 2197 when communicating with APNs. You might do this, 18 | /// for example, to allow APNs traffic through your firewall but to block other HTTPS traffic. 19 | case alternative = 2197 20 | } 21 | 22 | /// The port to make the HTTP call on 23 | public var port: Port = .default 24 | 25 | /// The topic of the remote notification, which is typically the bundle ID for your app. 26 | public var topic: String 27 | 28 | /// The issuer (iss) registered claim key, whose value is your 10-character Team ID, 29 | /// obtained from your developer account 30 | public var teamId: String 31 | 32 | /// A 10-character key identifier (kid) key, obtained from your developer account. 33 | public var keyId: String 34 | 35 | /// File path to the certificate key 36 | public var keyPath: String 37 | 38 | /// Debug logging 39 | public var debugLogging: Bool = false 40 | 41 | /// Token data 42 | public var token: String? 43 | public var tokenExpiration: Date = Date() 44 | 45 | internal var privateKey: Data 46 | internal var publicKey: Data 47 | 48 | public var description: String { 49 | return """ 50 | Topic \(self.topic) 51 | \nPort \(self.port.rawValue) 52 | \nCER - Key path: \(self.keyPath) 53 | \nTOK - Key ID: \(String(describing: self.keyId)) 54 | """ 55 | } 56 | 57 | public init(topic: String, forTeam teamId: String, withKey keyId: String, keyPath: String, debugLogging: Bool = false) throws { 58 | self.teamId = teamId 59 | self.topic = topic 60 | self.keyId = keyId 61 | self.debugLogging = debugLogging 62 | self.keyPath = keyPath 63 | 64 | // Token Generation 65 | guard FileManager.default.fileExists(atPath: keyPath) else { 66 | throw InitializationError.keyFileDoesNotExist 67 | } 68 | 69 | let (priv, pub) = KeyGenerator.generate(from: keyPath) 70 | self.publicKey = pub 71 | self.privateKey = priv 72 | 73 | try self.generateToken() 74 | } 75 | 76 | internal func generateToken() throws { 77 | let JWTheaders = JWTHeader(alg: "ES256", cty: nil, crit: nil, kid: self.keyId) 78 | let payload = APNSJWTPayload(iss: self.teamId) 79 | let signer = JWTSigner(algorithm: ES256(key: self.privateKey)) 80 | let jwt = JWT(header: JWTheaders, payload: payload) 81 | let signed = try jwt.sign(using: signer) 82 | guard let token = String(bytes: signed, encoding: .utf8) else { 83 | throw TokenError.tokenWasNotGeneratedCorrectly 84 | } 85 | self.token = token 86 | self.tokenExpiration = Date(timeInterval: 3500, since: Date()) 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /Tests/APNSTests/APNSTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import APNS 3 | 4 | final class APNSTests: XCTestCase { 5 | func testExample() { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | XCTAssertEqual(APNS().text, "Hello, World!") 10 | } 11 | 12 | 13 | static var allTests = [ 14 | ("testExample", testExample), 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /Tests/APNSTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !os(macOS) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(APNSTests.allTests), 7 | ] 8 | } 9 | #endif -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import APNSTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += APNSTests.allTests() 7 | XCTMain(tests) --------------------------------------------------------------------------------