Integration from Apple Wallet using Wallet Extensions
Overview
Apple mandates that you must integrate with Apple's Wallet Extensions before distributing an app that supports push provisioning.
These extensions enable the Apple Wallet to discover potential cards in your app to add the wallet, and provisions them from within Apple Wallet without launching your app.
To implement this, you'll need to provide two extensions:
- Non-UI extension: An extension that is queried from Apple Wallet to fetch the required card metadata, and to provision the card.
- UI Extension: An extension to allow the user to sign-in to their account within your app, if it's necessary.
The user must launch your app at least once before the extensions are recognized by Apple Wallet.
The following diagram outlines how Apple Wallet and your app interact through these extensions:
Set up your project for Apple Wallet Extensions
Create the App Extensions
Navigate to your project's file on Xcode, and click on the plus icon tagged "Add a target" In the dialog that appears, select Intents Extension as the template for your target.
Upon tapping next, you'll need to set the following values:
- Product Name: WalletExtension
- Team: Select your Apple developer organisation
- Language: Select Swift
- Starting point: Leave Messaging
- Include UI Extension: Keep it selected
- Project: Ensure your project is selected
- Embed in Application: Ensure your app is selected
After clicking Finish, you should see two new folders within your project containing the code for the Extensions.
Once the Extensions are created, please record your app, and Extensions bundle identifiers, and share them with our support team so we can add them to the allow-list for provisioning. For example:
Bundle Identifier |
---|
ABCDE12345.com.weavr.app |
ABCDE12345.com.weavr.app.WalletExtension |
ABCDE12345.com.weavr.app.WalletExtensionUI |
Note that the prefix ABCDE12345
is your team's developer ID.
Configure the App Extensions
With the Extensions created you'll need to configure them to work as Apple Wallet extensions rather than Messages extensions. To do so, you'll need to ensure they have the following as dependencies:
PassKit.framework
- For the Non-UI Extension:
- The Weavr Provisioning SDK, via the
.xcframework
file provided - Phyre's SDK, by adding the
pod 'ApplePayProvisioning', '5.0.0'
to the Non-UI Extension target - MeaWallet's SDK, via the
.xcframework
file provided
- The Weavr Provisioning SDK, via the
Notice you'll need to remove Intents.framework
and IntentsUI.framework
from the dependency list as these are added by the Messaging template.
Once the dependencies are set up, you need to ensure that you have the correct capabilities configured on each Extension. The following tables outline the capabilities needed for each Extension:
App
Capability | Set up | Reason |
---|---|---|
App groups | group.your.apps.bundle.id | To share storage between your app and extensions so they can read each other's data. |
In-App Provisioning | So the card can be provisioned from your Extensions. | |
Wallet | Allow all team pass types | So the app can check if a card is already added. |
Non-UI Extension
Capability | Set up | Reason |
---|---|---|
App groups | group.your.apps.bundle.id | To share storage between your app and extensions so they can read each other's data. |
In-App Provisioning | So the card can be provisioned from your Extensions. |
UI Extension
Capability | Set up | Reason |
---|---|---|
App groups | group.your.apps.bundle.id | To share storage between your app and extensions so they can read each other's data. |
In-App Provisioning | So the card can be provisioned from your Extensions. |
Configure the Non-UI Extension
Update the Info.plist file of the Extension as per below:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.PassKit.issuer-provisioning</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).IntentHandler</string>
</dict>
</dict>
</plist>
This informs Apple Wallet that the Extension integrates with it via the NSExtensionPointIdentifier
, and specifies which class provides the implementation; in the example this is the IntentHandler
.
In order for the IntentHandler
to be compatible, you'll need it to implement the PKIssuerProvisioningExtensionHandler
protocol.
Configure the UI Extension
Similar to the Non-UI Extension, update the Info.plist as follows:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.PassKit.issuer-provisioning.authorization</string>
</dict>
</dict>
</plist>
In this case, rather than declaring NSExtensionPrincipalClass
, you are declaring NSExtensionMainStoryboard
to be MainInterface
. As a result, when the Extension triggers, Apple launches the initial view controller found inside the relevant storyboard.
In order for the initial view controller to be compatible, you'll need it to implement the PKIssuerProvisioningExtensionAuthorizationProviding
protocol.
Configure the App Extensions in Apple Developer portal
Next you'll need to register both App Extension bundle identifiers in the Apple Developer portal Identifiers section. Create provisioning profiles accordingly, with the capabilities required for each Extension.
If you have not already done so, create a new App Group, so that the app and Extensions can share storage between them. Normally, group.team-id.bundleid
is the format used to identify a new group. You must ensure this value matches the App Groups of both the App, the Non-UI Extension, and the UI Extension.
Implement the App Extensions
Shared storage between app and Extensions
To ensure the proper functioning of the Wallet Extensions, your app and its Extensions must share access to certain data, such as authentication tokens and card information. This shared storage is essential for the provisioning flow, as it allows Extensions to access the data fetched or stored by your app.
Apple provides App Groups as a mechanism to enable shared storage between an app and its Extensions. By configuring App Groups, you can ensure that both your app and Extensions can read and write to the same storage location.
The implementation of shared storage varies from app to app, so the specific details are not be covered in this guide. However, it's recommend you consider the following steps when implementing this storage:
-
Configure Shared Storage:
- Update your storage or database layer to access storage provided by App Groups.
- Ensure that the App Group identifier (e.g.,
group.your.app.group
) is correctly set up in your app and Extensions.
-
Data Migration (if needed):
- If your app is already storing data in a different location, consider implementing a migration process to transfer it to the shared storage.
-
Reuse Domain Models and Storage Classes:
- Make your domain models and storage classes accessible to both your app and Extensions. This ensures consistency in how data is serialized and deserialized.
-
Test Shared Storage in the App:
- Verify that your app can write to and read from the shared storage without issues.
-
Test Shared Storage in Extensions:
- Confirm that your Extensions can access the data written by the app and perform their required operations.
Below are reference examples of how to use UserDefaults
and FileManager
to interact with shared storage:
// Using UserDefaults for Shared Storage
let sharedDefaults = UserDefaults(suiteName: "group.your.app.group")
sharedDefaults?.set("value", forKey: "key")
let value = sharedDefaults?.string(forKey: "key")
// Using FileManager for Shared Storage
if let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.your.app.group") {
let fileURL = containerURL.appendingPathComponent("sharedData.json")
try? "data".write(to: fileURL, atomically: true, encoding: .utf8)
let data = try? String(contentsOf: fileURL, encoding: .utf8)
}
By following these steps and leveraging shared storage, you can ensure seamless communication and data sharing between your app and its Extensions.
Implement the UI Extension
The overarching requirement of the UI Extension is that you need to have (as the initial view controller of the Storyboard) a class that implements PKIssuerProvisioningExtensionAuthorizationProviding
.
The main requirement of this protocol is to include a completionHandler
as follows:
import PassKit
import UIKit
class MyViewController: UIViewController, PKIssuerProvisioningExtensionAuthorizationProviding {
var completionHandler: ((PKIssuerProvisioningExtensionAuthorizationResult) -> Void)?
// ...
}
As part of this implementation, you'll need to:
- Display your App's login flow
- Await for the user to enter their credentials
- Sign-in the user
- Store your token in shared storage
- Download the list of cards for the user
- Store the cards in shared storage
- Call the completion handler and inform Apple Wallet of the result of the login flow - Either
.canceled
, or.authorized
.
App UI extension has a memory limit of 60 MB, developers are responsible to optimize the code and libraries to fit this requirement.
Implement the Non-UI Extension
The Non-UI Extension requires a class to be provided that implements PKIssuerProvisioningExtensionHandler
.
The Non-UI extension has a memory limit of 55 MB, developers are responsible to optimize the code and libraries to fit this requirement.
There is also a time limit to complete each extension call by Apple.
PKIssuerProvisioningExtensionHandler
consists of 4 methods, implemented in the next snippet. Part of the implementation depends on how you store data and what your domain models look like. The parts that need implementing have been extracted and highlighted with a // MARK:
tag. As a result, you can copy this code into your IntentHandler
, complete the missing parts, and start provisioning.
import PassKit
import WeavrPushProvisioning
class IntentHandler: PKIssuerProvisioningExtensionHandler {
// Determines if there is a pass available and if authentication is required before adding it.
// The completion block return value, PKIssuerProvisioningExtensionStatus, has the following properties:
// requiresAuthentication: Bool - Whether authorization is required before passes can be added.
// passEntriesAvailable: Bool - Whether at least one pass is available to add in the iPhone.
// remotePassEntriesAvailable: Bool - Whether at least one pass is available to add in the remote device (Apple Watch).
// The completion callback should be invoked within 100ms. The extension is not displayed to the user
// in Apple Wallet if you exceed this time.
override func status(completion: @escaping (PKIssuerProvisioningExtensionStatus) -> Void) {
let status = PKIssuerProvisioningExtensionStatus()
// Requires Authentication is true if there's not token, or if it is expired
let token = loadToken()
status.requiresAuthentication = token?.isExpired() ?? true
let storedCardData = loadCards()
// If one card can be found that can be added to the phone, set passEntriesAvailable to true.
status.passEntriesAvailable = storedCardData.first { card in
return WPPComponents.getCardStatusInWallet(
forCardWithLastFourDigits: card.cardNumberLastFour,
deviceType: .phone
) == .notAdded
} != nil
// If one card can be found that can be added to the phone, set remotePassEntriesAvailable to true.
status.remotePassEntriesAvailable = storedCardData.first { card in
return WPPComponents.getCardStatusInWallet(
forCardWithLastFourDigits: card.cardNumberLastFour,
deviceType: .watch
) == .notAdded
} != nil
completion(status)
}
// Finds the list of passes (cards) that can be added to an iPhone.
// The result expected by Apple is a `[PKIssuerProvisioningExtensionPassEntry]` representing
// the cards (passes in Apple's language) that are available to add to Wallet.
// PKIssuerProvisioningExtensionPaymentPassEntry has the following properties:
// art: CGImage - image representing the card displayed to the user. The image must have square corners and should not include personally identifiable information like user name or account number.
// title: String - a name for the pass that the system displays to the user when they add or select the card.
// identifier: String - The ID of the card
// addRequestConfiguration: PKAddPaymentPassRequestConfiguration - the configuration data used for setting up and displaying a view controller that lets the user add a payment pass.
// You have to avoid returning cards that are already present in the user’s Wallet, therefore you must ensure their status is `notAdded`.
// The method should return within 20 seconds or the attempt will be halted and treated as a failure.
override func passEntries() async -> [PKIssuerProvisioningExtensionPassEntry] {
guard let token = loadToken()?.rawToken else {
return []
}
return await generatePassEntries(
authenticationToken: token,
cards: loadCards(),
deviceType: .phone
)
}
// Remote pass entries has the same behaviour as passEntries, but targets cards that can be
// Added to the Apple Watch.
override func remotePassEntries() async -> [PKIssuerProvisioningExtensionPassEntry] {
guard let token = loadToken()?.rawToken else {
return []
}
return await generatePassEntries(
authenticationToken: token,
cards: loadCards(),
deviceType: .watch
)
}
// identifier: String - an internal value the issuer uses to identify the card.
// configuration: PKAddPaymentPassRequestConfiguration - the configuration the system uses to add a secure pass. This configuration is prepared in methods passEntriesWithCompletion: and remotePassEntriesWithCompletion:.
// certificates, nonce, nonceSignature - parameters are generated by Apple Pay identically to PKAddPaymentPassViewControllerDelegate methods.
// The completion handler is called by the system for the data needed to add a card to Apple Pay.
// This handler takes a parameter request of type PKAddPaymentPassRequestConfiguration that contains the card data the system needs to add a card to Apple Pay.
// The continuation handler must be called within 20 seconds or an error is displayed.
// Subsequent to timeout, the continuation handler is invalid and invocations is ignored.
override func generateAddPaymentPassRequestForPassEntryWithIdentifier(
_ identifier: String,
configuration: PKAddPaymentPassRequestConfiguration,
certificateChain certificates: [Data],
nonce: Data,
nonceSignature: Data,
completionHandler completion: @escaping (PKAddPaymentPassRequest?) -> Void) {
let authenticationToken = "authenticationToken"
WPPComponents.addPaymentPass(authenticationToken: authenticationToken, clientPaymentCardId: identifier, certificates: certificates, nonce: nonce, nonceSignature: nonceSignature, completionHandler: completion)
}
// MARK: Helper functions
// You can leave this function as is, it's just extracting common code
// between passEntries and remotePassEntries
func generatePassEntries(
authenticationToken: String,
cards: [Card],
deviceType: DeviceType
) async -> Array<PKIssuerProvisioningExtensionPassEntry> {
let entries = cards.compactMap { card -> PassEntryInfo? in
let canAddCard = .notAdded == WPPComponents.getCardStatusInWallet(
forCardWithLastFourDigits: card.cardNumberLastFour,
deviceType: deviceType
)
// Compact map removes nil from the array
guard canAddCard else {
return nil
}
return PassEntryInfo(
cardId: card.id,
cardholderName: card.cardholder,
panLastFour: card.cardNumberLastFour,
cardDescription: card.friendlyName,
cardImageURL: URL(string: card.digitalArtworkUrl)
)
}
return await WPPComponents.makePassEntriesForCardsWith(
authenticationToken: authenticationToken,
entries: entries.compactMap { $0 },
// TODO Provide your own default card. Consider handling nil more elegantly
imageFallback: UIImage(named: "default-card")!.cgImage!
)
}
// MARK: To be implemented
// TODO Replace with your own models
struct Card {
let id: String
let cardNumberLastFour: String
let cardholder: String
let friendlyName: String
let digitalArtworkUrl: String
}
// TODO Replace with your own models
struct Token {
let rawToken: String
func isExpired() -> Bool {
return false
}
}
func loadCards() -> [Card] {
// TODO implement loading cards from your shared storage
return []
}
func loadToken() -> Token? {
// TODO implement loading cards from your shared storage
return Token(rawToken: "faked token")
}
}