From 7ddec4474cabf548cc8ae13b8fc3a18d774abb02 Mon Sep 17 00:00:00 2001 From: Jimmy Arts Date: Thu, 12 Sep 2019 13:15:45 +0200 Subject: [PATCH] Inital commit. First working version with full PKPass model and working signing --- .gitignore | 4 + Package.resolved | 61 ++++++++ Package.swift | 28 ++++ README.md | 3 + Sources/WalletKit/Models/Pass.swift | 141 ++++++++++++++++++ Sources/WalletKit/Models/PassBarcode.swift | 19 +++ .../WalletKit/Models/PassBarcodeFormat.swift | 15 ++ Sources/WalletKit/Models/PassBeacon.swift | 19 +++ .../Models/PassCharacterEncoding.swift | 103 +++++++++++++ .../Models/PassDataDetectorType.swift | 15 ++ Sources/WalletKit/Models/PassField.swift | 34 +++++ Sources/WalletKit/Models/PassLocation.swift | 19 +++ Sources/WalletKit/Models/PassNFC.swift | 15 ++ Sources/WalletKit/Models/PassStructure.swift | 24 +++ .../WalletKit/Models/PassTextAlignment.swift | 15 ++ .../WalletKit/Models/PassTransitType.swift | 16 ++ Sources/WalletKit/Models/PassValue.swift | 36 +++++ Sources/WalletKit/WalletKit.swift | 141 ++++++++++++++++++ Tests/LinuxMain.swift | 7 + Tests/WalletKitTests/PassKitTests.swift | 15 ++ Tests/WalletKitTests/XCTestManifests.swift | 9 ++ 21 files changed, 739 insertions(+) create mode 100644 .gitignore create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/WalletKit/Models/Pass.swift create mode 100644 Sources/WalletKit/Models/PassBarcode.swift create mode 100644 Sources/WalletKit/Models/PassBarcodeFormat.swift create mode 100644 Sources/WalletKit/Models/PassBeacon.swift create mode 100644 Sources/WalletKit/Models/PassCharacterEncoding.swift create mode 100644 Sources/WalletKit/Models/PassDataDetectorType.swift create mode 100644 Sources/WalletKit/Models/PassField.swift create mode 100644 Sources/WalletKit/Models/PassLocation.swift create mode 100644 Sources/WalletKit/Models/PassNFC.swift create mode 100644 Sources/WalletKit/Models/PassStructure.swift create mode 100644 Sources/WalletKit/Models/PassTextAlignment.swift create mode 100644 Sources/WalletKit/Models/PassTransitType.swift create mode 100644 Sources/WalletKit/Models/PassValue.swift create mode 100644 Sources/WalletKit/WalletKit.swift create mode 100644 Tests/LinuxMain.swift create mode 100644 Tests/WalletKitTests/PassKitTests.swift create mode 100644 Tests/WalletKitTests/XCTestManifests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02c0875 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..289407b --- /dev/null +++ b/Package.resolved @@ -0,0 +1,61 @@ +{ + "object": { + "pins": [ + { + "package": "Core", + "repositoryURL": "https://github.com/vapor/core.git", + "state": { + "branch": null, + "revision": "c64f63cb21631010952f7abfef719d376ab6a441", + "version": "3.9.1" + } + }, + { + "package": "Crypto", + "repositoryURL": "https://github.com/vapor/crypto.git", + "state": { + "branch": null, + "revision": "df8eb7d8ae51787b3a0628aa3975e67666da936c", + "version": "3.3.3" + } + }, + { + "package": "swift-nio", + "repositoryURL": "https://github.com/apple/swift-nio.git", + "state": { + "branch": null, + "revision": "ba7970fe396e8198b84c6c1b44b38a1d4e2eb6bd", + "version": "1.14.1" + } + }, + { + "package": "swift-nio-ssl-support", + "repositoryURL": "https://github.com/apple/swift-nio-ssl-support.git", + "state": { + "branch": null, + "revision": "c02eec4e0e6d351cd092938cf44195a8e669f555", + "version": "1.0.0" + } + }, + { + "package": "swift-nio-zlib-support", + "repositoryURL": "https://github.com/apple/swift-nio-zlib-support.git", + "state": { + "branch": null, + "revision": "37760e9a52030bb9011972c5213c3350fa9d41fd", + "version": "1.0.0" + } + }, + { + "package": "ZIPFoundation", + "repositoryURL": "https://github.com/weichsel/ZIPFoundation.git", + "state": { + "branch": null, + "revision": "edbeaa39b426e54702194b0a601342322f01e400", + "version": "0.9.9" + } + } + ] + }, + "version": 1 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..ddf20b0 --- /dev/null +++ b/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version:5.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "wallet-kit", + platforms: [ + .macOS(.v10_12) + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + .package(url: "https://github.com/vapor/crypto.git", from: "3.0.0"), + .package(url: "https://github.com/weichsel/ZIPFoundation.git", .upToNextMajor(from: "0.9.0")), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages which this package depends on. + .target(name: "Run", dependencies: ["WalletKit"]), + .target( + name: "WalletKit", + dependencies: ["Crypto", "ZIPFoundation"]), + .testTarget( + name: "WalletKitTests", + dependencies: ["WalletKit"]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e2c88c --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# WalletKit + +Generate Apple Wallet passes (.pkpass) using Swift. diff --git a/Sources/WalletKit/Models/Pass.swift b/Sources/WalletKit/Models/Pass.swift new file mode 100644 index 0000000..b1ce0e4 --- /dev/null +++ b/Sources/WalletKit/Models/Pass.swift @@ -0,0 +1,141 @@ +// +// Pass.swift +// Async +// +// Created by Jimmy Arts on 12/09/2019. +// + +import Foundation + +/// See: https://developer.apple.com/library/archive/documentation/UserExperience/Reference/PassKit_Bundle/Chapters/TopLevel.html +public struct Pass: Codable { + + /// - Standard Keys + /// Information that is required for all passes. + + /// Brief description of the pass, used by the iOS accessibility technologies. + /// Don’t try to include all of the data on the pass in its description, just include enough detail to distinguish passes of the same type. + public var description: String + + /// Version of the file format. The value must be 1. + public var formatVersion: Int + /// Display name of the organization that originated and signed the pass. + public var organizationName: String + /// Pass type identifier, as issued by Apple. The value must correspond with your signing certificate. + public var passTypeIdentifier: String + /// Serial number that uniquely identifies the pass. No two passes with the same pass type identifier may have the same serial number. + public var serialNumber: String + /// Team identifier of the organization that originated and signed the pass, as issued by Apple. + public var teamIdentifier: String + + /// - Associated App Keys + /// Information about an app that is associated with a pass. + + /// A URL to be passed to the associated app when launching it. + /// The app receives this URL in the application:didFinishLaunchingWithOptions: and application:openURL:options: methods of its app delegate. + /// If this key is present, the associatedStoreIdentifiers key must also be present. + public var appLaunchURL: String? + /// A list of iTunes Store item identifiers for the associated apps. + /// Only one item in the list is used—the first item identifier for an app compatible with the current device. If the app is not installed, the link opens the App Store and shows the app. If the app is already installed, the link launches the app. + public var associatedStoreIdentifiers: [Double]? + + /// - Companion App Keys + /// Custom information about a pass provided for a companion app to use. + + /// Custom information for companion apps. This data is not displayed to the user. + /// For example, a pass for a cafe could include information about the user’s favorite drink and sandwich in a machine-readable form for the companion app to read, making it easy to place an order for “the usual” from the app. + /// Available in iOS 7.0. + public var userInfo: [String: String]? + + /// - Expiration Keys + /// Information about when a pass expires and whether it is still valid. + /// A pass is marked as expired if the current date is after the pass’s expiration date, or if the pass has been explicitly marked as voided. + + /// Date and time when the pass expires. + /// The value must be a complete date with hours and minutes, and may optionally include seconds. + /// Available in iOS 7.0. + public var expirationDate: String? + /// Indicates that the pass is void—for example, a one time use coupon that has been redeemed. The default value is false. + /// Available in iOS 7.0. + public var voided: Bool? + + /// - Relevance Keys + /// Information about where and when a pass is relevant. + + /// Beacons marking locations where the pass is relevant. + public var beacons: [PassBeacon]? + /// Locations where the pass is relevant. For example, the location of your store. + public var locations: [PassLocation]? + /// Maximum distance in meters from a relevant latitude and longitude that the pass is relevant. This number is compared to the pass’s default distance and the smaller value is used. + public var maxDistance: Double? + /// Recommended for event tickets and boarding passes; otherwise optional. + /// Date and time when the pass becomes relevant. For example, the start time of a movie. + /// The value must be a complete date with hours and minutes, and may optionally include seconds. + public var relevantDate: String? + + /// - Style Keys + /// Keys that specify the pass style + /// Provide exactly one key—the key that corresponds with the pass’s type. + + /// Information specific to a boarding pass. + public var boardingPass: PassStructure? + /// Information specific to a coupon. + public var coupon: PassStructure? + /// Information specific to an event ticket. + public var eventTicket: PassStructure? + /// Information specific to a generic pass. + public var generic: PassStructure? + /// Information specific to a store card. + public var storeCard: PassStructure? + + /// - Visual Appearance Keys + /// Keys that define the visual style and appearance of the pass. + /// With the release of iOS 9, there are two ways to display a barcode: + /// - The barcodes key (new and required for iOS 9 and later) + /// - The barcode key (for iOS 8 and earlier) + /// To support older versions of iOS, use both keys. The system automatically selects the barcodes array for iOS 9 and later and uses the barcode dictionary for iOS 8 and earlier. + + /// Information specific to the pass’s barcode. For this dictionary’s keys, see Barcode Dictionary Keys. + @available(*, deprecated, message: "Deprecated in iOS 9.0 and later; use barcodes instead.") + public var barcode: PassBarcode? + /// Information specific to the pass’s barcode. The system uses the first valid barcode dictionary in the array. Additional dictionaries can be added as fallbacks. For this dictionary’s keys, see Barcode Dictionary Keys. + /// Note: Available only in iOS 9.0 and later. + public var barcodes: [PassBarcode]? + /// Background color of the pass, specified as an CSS-style RGB triple. For example, rgb(23, 187, 82). + public var backgroundColor: String? + /// Foreground color of the pass, specified as a CSS-style RGB triple. For example, rgb(100, 10, 110). + public var foregroundColor: String? + /// Optional for event tickets and boarding passes; otherwise not allowed. Identifier used to group related passes. If a grouping identifier is specified, passes with the same style, pass type identifier, and grouping identifier are displayed as a group. Otherwise, passes are grouped automatically. + /// Use this to group passes that are tightly related, such as the boarding passes for different connections of the same trip. + /// Available in iOS 7.0. + public var groupingIdentifier: String? + /// Color of the label text, specified as a CSS-style RGB triple. For example, rgb(255, 255, 255). + /// If omitted, the label color is determined automatically. + public var labelColor: String? + /// Text displayed next to the logo on the pass. + public var logoText: String? + /// If true, the strip image is displayed without a shine effect. The default value prior to iOS 7.0 is false. + /// In iOS 7.0, a shine effect is never applied, and this key is deprecated. + public var suppressStripShine: Bool? + + /// - Web Service Keys + /// Information used to update passes using the web service. + /// If a web service URL is provided, an authentication token is required; otherwise, these keys are not allowed. + + /// The authentication token to use with the web service. The token must be 16 characters or longer. + public var authenticationToken: String? + /// The URL of a web service that conforms to the API described in PassKit Web Service Reference (https://developer.apple.com/library/archive/documentation/PassKit/Reference/PassKit_WebService/WebService.html#//apple_ref/doc/uid/TP40011988). + /// The web service must use the HTTPS protocol; the leading https:// is included in the value of this key. + /// On devices configured for development, there is UI in Settings to allow HTTP web services. + public var webServiceURL: String? + + /// - NFC-Enabled Pass Keys + /// NFC-enabled pass keys support sending reward card information as part of an Apple Pay transaction. + /// Important: NFC-enabled pass keys are only supported in passes that contain an Enhanced Passbook/NFC certificate. For more information, contact merchant support at https://developer.apple.com/contact/passkit/. + /// Passes can send reward card information to a terminal as part of an Apple Pay transaction. This feature requires a payment terminal that supports NFC-entitled passes. Specifically, the terminal must implement the Value Added Services Protocol. + /// Passes provide the required information using the nfc key. The value of this key is a dictionary containing the keys described in NFC Dictionary Keys. This functionality allows passes to act as the user’s credentials in the context of the NFC Value Added Service Protocol. It is available only for storeCard style passes. + + /// Information used for Value Added Service Protocol transactions. + /// Available in iOS 9.0. + public var nfc: PassNFC? +} diff --git a/Sources/WalletKit/Models/PassBarcode.swift b/Sources/WalletKit/Models/PassBarcode.swift new file mode 100644 index 0000000..7f049ef --- /dev/null +++ b/Sources/WalletKit/Models/PassBarcode.swift @@ -0,0 +1,19 @@ +// +// PassBarcode.swift +// Async +// +// Created by Jimmy Arts on 12/09/2019. +// + +import Foundation + +public struct PassBarcode: Codable { + /// Text displayed near the barcode. For example, a human-readable version of the barcode data in case the barcode doesn’t scan. + public var altText: String? + /// Barcode format. + public var format: PassBarcodeFormat + /// Message or payload to be displayed as a barcode. + public var message: String + /// Text encoding that is used to convert the message from the string representation to a data representation to render the barcode. The value is typically iso-8859-1, but you may use another encoding that is supported by your barcode scanning infrastructure. + public var messageEncoding: PassCharacterEncoding? +} diff --git a/Sources/WalletKit/Models/PassBarcodeFormat.swift b/Sources/WalletKit/Models/PassBarcodeFormat.swift new file mode 100644 index 0000000..8bc0b2b --- /dev/null +++ b/Sources/WalletKit/Models/PassBarcodeFormat.swift @@ -0,0 +1,15 @@ +// +// PassBarcodeFormat.swift +// Async +// +// Created by Jimmy Arts on 12/09/2019. +// + +import Foundation + +public enum PassBarcodeFormat: String, Codable { + case qr = "PKBarcodeFormatQR" + case pdf = "PKBarcodeFormatPDF417" + case aztec = "PKBarcodeFormatAztec" + case code128 = "PKBarcodeFormatCode128" +} diff --git a/Sources/WalletKit/Models/PassBeacon.swift b/Sources/WalletKit/Models/PassBeacon.swift new file mode 100644 index 0000000..6ea2b1c --- /dev/null +++ b/Sources/WalletKit/Models/PassBeacon.swift @@ -0,0 +1,19 @@ +// +// PassBeacon.swift +// Async +// +// Created by Jimmy Arts on 12/09/2019. +// + +import Foundation + +public struct PassBeacon: Codable { + /// Major identifier of a Bluetooth Low Energy location beacon. + public var major: UInt16? + /// Minor identifier of a Bluetooth Low Energy location beacon. + public var minor: UInt16? + /// Unique identifier of a Bluetooth Low Energy location beacon. + public var proximityUUID: String + /// Text displayed on the lock screen when the pass is currently relevant. For example, a description of the nearby location such as “Store nearby on 1st and Main.” + public var relevantText: String? +} diff --git a/Sources/WalletKit/Models/PassCharacterEncoding.swift b/Sources/WalletKit/Models/PassCharacterEncoding.swift new file mode 100644 index 0000000..9a0b5d9 --- /dev/null +++ b/Sources/WalletKit/Models/PassCharacterEncoding.swift @@ -0,0 +1,103 @@ +// +// PassCharacterEncoding.swift +// WalletKit +// +// Created by Jimmy Arts on 12/09/2019. +// + +import Foundation + +/// See: https://docs.lansa.com/14/en/lansa093/content/lansa/intb7_0510.htm +public enum PassCharacterEncoding: String, Codable { + case utf8 = "utf-8" + + case utf16be = "utf-16be" + case utf16le = "utf-16le" + + case ascii = "ascii" + + case iso88591 = "iso-8859-1" + case iso88592 = "iso-8859-2" + case iso88593 = "iso-8859-3" + case iso88594 = "iso-8859-4" + case iso88595 = "iso-8859-5" + case iso88596 = "iso-8859-6" + case iso88597 = "iso-8859-7" + case iso88598 = "iso-8859-8" + case iso88599 = "iso-8859-9" + case iso885913 = "iso-8859-13" + case iso885915 = "iso-8859-15" + + case windows1250 = "windows-1250" + case windows1251 = "windows-1251" + case windows1252 = "windows-1252" + case windows1253 = "windows-1253" + case windows1254 = "windows-1254" + case windows1255 = "windows-1255" + case windows1256 = "windows-1256" + case windows1257 = "windows-1257" + case windows874 = "windows-874" + case windows932 = "windows-932" + case windows936 = "windows-936" + case windows949 = "windows-949" + case windows950 = "windows-950" + + case ebcdiccpus = "ebcdic-cp-us" + case ebcdiccpdk = "ebcdic-cp-dk" + case ebcdiccpfi = "ebcdic-cp-fi" + case ebcdiccpit = "ebcdic-cp-it" + case ebcdiccpes = "ebcdic-cp-es" + case ebcdiccpgb = "ebcdic-cp-gb" + case ebcdicjpkana = "ebcdic-jp-kana" + case ebcdiccpfr = "ebcdic-cp-fr" + case ebcdiccphe = "ebcdic-cp-he" + case ebcdiccpch = "ebcdic-cp-ch" + case ebcdiccpyu = "ebcdic-cp-yu" + case ebcdiccpis = "ebcdic-cp-is" + case ebcdiccpar2 = "ebcdic-cp-ar2" + case ebcdiccpar1 = "ebcdic-cp-ar1" + case ebcdicus37euro = "ebcdic-us-37+euro" + case ebcdicde273euro = "ebcdic-de-273+euro" + case ebcdicdk227euro = "ebcdic-dk-227+euro" + case ebcdicfi278euro = "ebcdic-fi-278+euro" + case ebcdicit280euro = "ebcdic-it-280+euro" + case ebcdices284euro = "ebcdic-es-284+euro" + case ebcdicgb285euro = "ebcdic-gb-285+euro" + case ebcdicfr297euro = "ebcdic-fr-297+euro" + case ebcdicinternational500euro = "ebcdic-international-500+euro" + case ebcdicis871euro = "ebcdic-is-871+euro" + + case eucjis = "euc-jis" + case eucjp = "euc-jp" + + case iso2022jp = "iso2022-jp" + + case shiftjis = "Shift_JIS" + + case big5 = "big5" + + case gb2312 = "gb2312" + + case koi8r = "koi8-r" + + case euckr = "euc-kr" + + case ibm273 = "ibm-273" + case ibm437 = "ibm-437" + case ibm775 = "ibm-775" + case ibm850 = "ibm-850" + case ibm852 = "ibm-852" + case ibm855 = "ibm-855" + case ibm857 = "ibm-857" + case ibm860 = "ibm-860" + case ibm861 = "ibm-861" + case ibm862 = "ibm-862" + case ibm863 = "ibm-863" + case ibm864 = "ibm-864" + case ibm865 = "ibm-865" + case ibm866 = "ibm-866" + case ibm868 = "ibm-868" + case ibm869 = "ibm-869" + case ibm1026 = "ibm-1026" + case ibm1047 = "ibm-1047" +} diff --git a/Sources/WalletKit/Models/PassDataDetectorType.swift b/Sources/WalletKit/Models/PassDataDetectorType.swift new file mode 100644 index 0000000..1192eca --- /dev/null +++ b/Sources/WalletKit/Models/PassDataDetectorType.swift @@ -0,0 +1,15 @@ +// +// PassDataDetectorType.swift +// Async +// +// Created by Jimmy Arts on 12/09/2019. +// + +import Foundation + +public enum PassDataDetectorType: String, Codable { + case phoneNumber = "PKDataDetectorTypePhoneNumber" + case link = "PKDataDetectorTypeLink" + case address = "PKDataDetectorTypeAddress" + case calendarEvent = "PKDataDetectorTypeCalendarEvent" +} diff --git a/Sources/WalletKit/Models/PassField.swift b/Sources/WalletKit/Models/PassField.swift new file mode 100644 index 0000000..d9890e0 --- /dev/null +++ b/Sources/WalletKit/Models/PassField.swift @@ -0,0 +1,34 @@ +// +// PassField.swift +// Async +// +// Created by Jimmy Arts on 12/09/2019. +// + +import Foundation + +public struct PassField: Codable { + /// Attributed value of the field. + /// The value may contain HTML markup for links. Only the tag and its href attribute are supported. + /// For example, the following is key-value pair specifies a link with the text “Edit my profile”: "attributedValue": "Edit my profile" + /// This key’s value overrides the text specified by the value key. + /// Available in iOS 7.0. + public var attributedValue: PassValue? + /// Format string for the alert text that is displayed when the pass is updated. The format string must contain the escape %@, which is replaced with the field’s new value. For example, “Gate changed to %@.” + /// If you don’t specify a change message, the user isn’t notified when the field changes. + public var changeMessage: String? + /// Data detectors that are applied to the field’s value. + /// The default value is all data detectors. Provide an empty array to use no data detectors. + /// Data detectors are applied only to back fields. + public var dataDetectorTypes: [PassDataDetectorType]? + /// The key must be unique within the scope of the entire pass. For example, “departure-gate.” + public var key: String + /// Label text for the field. + public var label: String? + /// Alignment for the field’s contents. + /// The default value is natural alignment, which aligns the text appropriately based on its script direction. + /// This key is not allowed for primary fields or back fields. + public var textAligment: PassTextAlignment? + /// Value of the field, for example, 42. + public var value: PassValue? +} diff --git a/Sources/WalletKit/Models/PassLocation.swift b/Sources/WalletKit/Models/PassLocation.swift new file mode 100644 index 0000000..a9c97ff --- /dev/null +++ b/Sources/WalletKit/Models/PassLocation.swift @@ -0,0 +1,19 @@ +// +// PassLocation.swift +// Async +// +// Created by Jimmy Arts on 12/09/2019. +// + +import Foundation + +public struct PassLocation: Codable { + /// Altitude, in meters, of the location. + public var altitude: Double? + /// Latitude, in degrees, of the location. + public var latitude: Double + /// Longitude, in degrees, of the location. + public var longitude: Double + /// Text displayed on the lock screen when the pass is currently relevant. For example, a description of the nearby location such as “Store nearby on 1st and Main.” + public var relevantText: String? +} diff --git a/Sources/WalletKit/Models/PassNFC.swift b/Sources/WalletKit/Models/PassNFC.swift new file mode 100644 index 0000000..4a90178 --- /dev/null +++ b/Sources/WalletKit/Models/PassNFC.swift @@ -0,0 +1,15 @@ +// +// PassNFC.swift +// Async +// +// Created by Jimmy Arts on 12/09/2019. +// + +import Foundation + +public struct PassNFC: Codable { + /// The payload to be transmitted to the Apple Pay terminal. Must be 64 bytes or less. Messages longer than 64 bytes are truncated by the system. + public var message: String + /// The public encryption key used by the Value Added Services protocol. Use a Base64 encoded X.509 SubjectPublicKeyInfo structure containing a ECDH public key for group P256. + public var encryptionPublicKey: String? +} diff --git a/Sources/WalletKit/Models/PassStructure.swift b/Sources/WalletKit/Models/PassStructure.swift new file mode 100644 index 0000000..edbd054 --- /dev/null +++ b/Sources/WalletKit/Models/PassStructure.swift @@ -0,0 +1,24 @@ +// +// PassStructure.swift +// Async +// +// Created by Jimmy Arts on 12/09/2019. +// + +import Foundation + +public struct PassStructure: Codable { + /// Additional fields to be displayed on the front of the pass. + public var auxiliaryFields: [PassField]? + /// Fields to be on the back of the pass. + public var backFields: [PassField]? + /// Fields to be displayed in the header on the front of the pass. + /// Use header fields sparingly; unlike all other fields, they remain visible when a stack of passes are displayed. + public var headerFields: [PassField]? + /// Fields to be displayed prominently on the front of the pass. + public var primaryFields: [PassField]? + /// Fields to be displayed on the front of the pass. + public var secondaryFields: [PassField]? + /// Required for boarding passes; otherwise not allowed. Type of transit. + public var transitType: PassTransitType? +} diff --git a/Sources/WalletKit/Models/PassTextAlignment.swift b/Sources/WalletKit/Models/PassTextAlignment.swift new file mode 100644 index 0000000..c293131 --- /dev/null +++ b/Sources/WalletKit/Models/PassTextAlignment.swift @@ -0,0 +1,15 @@ +// +// PassTextAlignment.swift +// Async +// +// Created by Jimmy Arts on 12/09/2019. +// + +import Foundation + +public enum PassTextAlignment: String, Codable { + case left = "PKTextAlignmentLeft" + case center = "PKTextAlignmentCenter" + case right = "PKTextAlignmentRight" + case natural = "PKTextAlignmentNatural" +} diff --git a/Sources/WalletKit/Models/PassTransitType.swift b/Sources/WalletKit/Models/PassTransitType.swift new file mode 100644 index 0000000..37e0584 --- /dev/null +++ b/Sources/WalletKit/Models/PassTransitType.swift @@ -0,0 +1,16 @@ +// +// PassTransitType.swift +// Async +// +// Created by Jimmy Arts on 12/09/2019. +// + +import Foundation + +public enum PassTransitType: String, Codable { + case air = "PKTransitTypeAir" + case boat = "PKTransitTypeBoat" + case bus = "PKTransitTypeBus" + case generic = "PKTransitTypeGeneric" + case train = "PKTransitTypeTrain" +} diff --git a/Sources/WalletKit/Models/PassValue.swift b/Sources/WalletKit/Models/PassValue.swift new file mode 100644 index 0000000..b6c50b8 --- /dev/null +++ b/Sources/WalletKit/Models/PassValue.swift @@ -0,0 +1,36 @@ +// +// PassValue.swift +// Async +// +// Created by Jimmy Arts on 12/09/2019. +// + +import Foundation + +public enum PassValue: Codable { + case double(Double) + case string(String) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + do { + self = try .double(container.decode(Double.self)) + } catch DecodingError.typeMismatch { + do { + self = try .string(container.decode(String.self)) + } catch DecodingError.typeMismatch { + throw DecodingError.typeMismatch(PassValue.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Encoded payload not of an expected type")) + } + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .double(let double): + try container.encode(double) + case .string(let string): + try container.encode(string) + } + } +} diff --git a/Sources/WalletKit/WalletKit.swift b/Sources/WalletKit/WalletKit.swift new file mode 100644 index 0000000..1b6c7c4 --- /dev/null +++ b/Sources/WalletKit/WalletKit.swift @@ -0,0 +1,141 @@ +import Core +import Crypto +import ZIPFoundation + +enum WalletKitError: Error { + case invalidPassJSON + case cannotGenerateKey(underlying: Error) + case cannotGenerateCertificate(underlying: Error) + case cannotGenerateSignature(underlying: Error) +} + +public struct WalletKit { + + private let certificatePath: String + private let certificatePassword: String + private let wwdrPath: String + private let templateDirectoryPath: String + private let fileManager = FileManager.default + + public init(certificatePath: String, certificatePassword: String, wwdrPath: String, templateDirectoryPath: String) { + self.certificatePath = certificatePath + self.certificatePassword = certificatePassword + self.wwdrPath = wwdrPath + self.templateDirectoryPath = templateDirectoryPath + } + + public func savePass(pass: Pass, destination: String) throws { + let passData = try generatePass(pass: pass) + fileManager.createFile(atPath: destination, contents: passData, attributes: nil) + } + + public func generatePass(pass: Pass) throws -> Data { + let directory = fileManager.currentDirectoryPath + let temporaryDirectory = directory + UUID().uuidString + "/" + let passDirectory = temporaryDirectory + "pass/" + defer { + try? fileManager.removeItem(atPath: temporaryDirectory) + } + try fileManager.createDirectory(atPath: temporaryDirectory, withIntermediateDirectories: false, attributes: nil) + try fileManager.copyItem(atPath: templateDirectoryPath, toPath: passDirectory) + + let jsonEncoder = JSONEncoder() + jsonEncoder.dateEncodingStrategy = .iso8601 + let passData: Data + do { + passData = try jsonEncoder.encode(pass) + } catch { + throw WalletKitError.invalidPassJSON + } + fileManager.createFile(atPath: passDirectory + "pass.json", contents: passData, attributes: nil) + + try generateManifest(directory: passDirectory) + + try generateKey(directory: temporaryDirectory) + try generateCertificate(directory: temporaryDirectory) + + try generateSignature(directory: temporaryDirectory, passDirectory: passDirectory) + + let passURL = URL(fileURLWithPath: passDirectory, isDirectory: true) + let zipURL = URL(fileURLWithPath: temporaryDirectory + "/pass.pkpass") + try fileManager.zipItem(at: passURL, to: zipURL, shouldKeepParent: false) + return try Data(contentsOf: zipURL) + } +} + +private extension WalletKit { + + func generateManifest(directory: String) throws { + let contents = try fileManager.contentsOfDirectory(atPath: directory) + var manifest: [String: String] = [:] + try contents.forEach({ (item) in + guard let data = fileManager.contents(atPath: directory + item) else { return } + let hash = try SHA1.hash(data).hexEncodedString() + manifest[item] = hash + }) + let manifestData = try JSONSerialization.data(withJSONObject: manifest, options: .prettyPrinted) + fileManager.createFile(atPath: directory + "manifest.json", contents: manifestData, attributes: nil) + } + + func generateKey(directory: String) throws { + let keyPath = directory + "key.pem" + do { + _ = try Process.execute("openssl", + "pkcs12", + "-in", + certificatePath, + "-nocerts", + "-out", + keyPath, + "-passin", + "pass:" + certificatePassword, + "-passout", + "pass:" + certificatePassword) + } catch { + throw WalletKitError.cannotGenerateKey(underlying: error) + } + } + + func generateCertificate(directory: String) throws { + let certPath = directory + "cert.pem" + do { + _ = try Process.execute("openssl", + "pkcs12", + "-in", + certificatePath, + "-clcerts", + "-nokeys", + "-out", + certPath, + "-passin", + "pass:" + certificatePassword) + } catch { + throw WalletKitError.cannotGenerateCertificate(underlying: error) + } + } + + func generateSignature(directory: String, passDirectory: String) throws { + do { + _ = try Process.execute("openssl", + "smime", + "-sign", + "-signer", + directory + "cert.pem", + "-inkey", + directory + "key.pem", + "-certfile", + wwdrPath, + "-in", + passDirectory + "manifest.json", + "-out", + passDirectory + "signature", + "-outform", + "der", + "-binary", + "-passin", + "pass:" + certificatePassword) + } catch { + throw WalletKitError.cannotGenerateSignature(underlying: error) + } + } +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift new file mode 100644 index 0000000..a92a9b8 --- /dev/null +++ b/Tests/LinuxMain.swift @@ -0,0 +1,7 @@ +import XCTest + +import PassKitTests + +var tests = [XCTestCaseEntry]() +tests += PassKitTests.allTests() +XCTMain(tests) diff --git a/Tests/WalletKitTests/PassKitTests.swift b/Tests/WalletKitTests/PassKitTests.swift new file mode 100644 index 0000000..1841563 --- /dev/null +++ b/Tests/WalletKitTests/PassKitTests.swift @@ -0,0 +1,15 @@ +import XCTest +@testable import PassKit + +final class PassKitTests: XCTestCase { + func testExample() { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct + // results. + XCTAssertEqual(PassKit().text, "Hello, World!") + } + + static var allTests = [ + ("testExample", testExample), + ] +} diff --git a/Tests/WalletKitTests/XCTestManifests.swift b/Tests/WalletKitTests/XCTestManifests.swift new file mode 100644 index 0000000..99f1be1 --- /dev/null +++ b/Tests/WalletKitTests/XCTestManifests.swift @@ -0,0 +1,9 @@ +import XCTest + +#if !canImport(ObjectiveC) +public func allTests() -> [XCTestCaseEntry] { + return [ + testCase(PassKitTests.allTests), + ] +} +#endif