Swift
The reference for the KeyGrant library 0.8.0, the KeyGrant SDK for Swift. The rules every SDK
shares are in How every SDK behaves.
- The Swift 6 toolchain (Xcode 16 or later).
- Runs on macOS 12 or later, and Linux.
- Depends on
apple/swift-cryptoonly.
Add it
Add the package by its URL, https://github.com/: in Xcode,
File > Add Package Dependencies, or in Package.swift:
dependencies: [ .package(url: "https://github.com/keygrantdev/keygrant-swift.git", from: "0.8.0"),],targets: [ .target(name: "MyApp", dependencies: [.product(name: "KeyGrant", package: "keygrant-swift")]),] Use it
import KeyGrant
let license = try License(LicenseConfig( product: "my-product", apiBaseURL: URL(string: "https://api.keygrant.dev")!, publicJWK: publicKeys, // the {"keys": [...]} from /pubkey major: 2))
let status = await license.status()if ![.licensed, .trial, .unknown].contains(status.state) { let result = await license.activate(enteredKey) if let error = result.error { showError(error.rawValue) }} - Calls are
asyncandLicenseisSendable. - Create one
Licenseper process. Calls on it run one at a time, excepthas(_:),require(_:),upgradeURL(paymentLink:)and the twopickUpcalls, which only read and run beside any call.
LicenseConfig
Required. The product's API ID.
Required. A URL, set to https://api.keygrant.dev.
Required. The product's key set as a string: the {"keys": [...]} that /pubkey answers and the dashboard's Copy key set copies.
- A JSON list of JWKs, one JWK, or its bare
xalso work. - A lease is verified by the key it names, or, naming none, by each key in turn.
This build's major version. 0, or anything below it, is read as 1, as KeyGrant reads a 0.x version, so a 0.x build can set 0 or 1.
What the license's device list calls this machine.
false turns device hold off, so no license is held to its device. See Device management.
How long one request may take, in seconds.
The ids of the license files this build refuses, matched exactly: how an app update stops an offline license file that leaked. See License files.
- A stored file listed here reads as
.invalidwith reason.revokedFile, and importing one is refused.
A LicenseAdapters(. The default is used for any you leave out.
License(_:) throws LicenseConfigError. for an empty key
set or a key that is not an Ed25519 public key.
Calls
Returns .activated or .failed(LicenseError); read the result with isActivated and error. The cases are the activation errors, with the server's words as their raw values.
emailandcodeare what a person typed for a key only its buyer can activate (below), and arenilby default.
Returns whether the license grants the entitlement, offline from the stored lease. See Check an entitlement.
grants(status.entitlements, code)answers the same of a status already in hand.
Throws MissingEntitlement, whose entitlement is the code, when the license does not grant it.
Takes a Data, or a String for pasted text. Can throw, and returns an ImportLicenseFileResult (below).
Drops the stored offline license file. Can throw.
Returns a UseResult and never throws: ok with left, or error (Metered uses).
Emails a six-digit code to the address, for key pickup. Returns a PickUpResult.
Returns a PickUpKeysResult, whose pickedKeys are the address's keys.
Confirms the code and activates the key that fits this build. Returns a RestoreResult.
Returns a LicenseStatus (below). refresh() and startTrial() return one too.
Can throw, and returns a DeactivateResult with released.
Takes and returns a URL. See Sell an upgrade.
- It returns
nilwhen no key and activation are stored, when the license file cannot be read, or when the link is not an absolute URL.
LicenseStatus
The states and reasons, as enum cases with the same raw values, .revokedFile (revoked-file) among them.
The license key; on a status from a license file, the key the file records.
On a trial: the whole days left, and when it ends, in Unix milliseconds.
The same end, as a Date.
true for a test license.
A dictionary, empty when the lease grants nothing. Each value reads with boolValue, numberValue or stringValue.
A dictionary too, empty on every status but a .licensed one from a key's lease that names meters (below).
true when an earlier call has not finished, so the status was read from storage.
On a status from a license file (below); nil on every status not from a license file.
Errors
A case only the Swift SDK has. As in the Rust SDK, activate does not throw when the license file cannot be read or saved: it returns .failed(.storage).
Thrown by deactivate(), importLicenseFile and removeLicenseFile() while an earlier call is stuck.
Thrown by deactivate(), importLicenseFile and removeLicenseFile() when the license file cannot be read or saved (below).
Thrown by require(_:).
Thrown by License(_:) for an empty key set or a key that is not an Ed25519 public key.
Keys only their buyer can activate
When a key is locked to its buyer (Who can activate), pass what
the customer typed to activate(_:email:code:), each optional. The steps and rules are in
How every SDK behaves. Since 0.6.0.
switch await license.activate(key, email: emailField, code: codeField) {case .activated: breakcase .failed(.userEmailRequired), .failed(.userEmailMismatch): askForTheAddress() // then activate again with itcase .failed(.userCodeRequired), .failed(.userCodeInvalid): askForTheCode() // a code was just mailed to the addresscase .failed(.tooManyCodes), .failed(.tooManyAttempts): askToTryLater()case .failed(.tryAgain): askToRetry() // nothing was activatedcase .failed: showTheOtherErrors()} activate(key)sends neither, and answers a locked key.failed(.userEmailRequired).importLicenseFile(_:email:code:)passes them on to a key file's key.
The refusals are LicenseError cases, with the server's words as their raw values:
Raw value user-email-required. The key is locked to its buyer and this device is new: ask for the email the key was sold to and call activate(_:email:code:) with it.
Raw value user-email-mismatch. That is not the email the key was sold to (or it is longer than 254 UTF-16 units, and no request is made). KeyGrant never says what the right one is.
Raw value user-code-required. The email was right and KeyGrant has just emailed a code to it: ask for the code and call activate(_:email:code:) with the email and the code.
Raw value user-code-invalid. The code is wrong, expired (it lasts 10 minutes) or already used, or is not six digits (no request is made). Ask again, or call activate(_:email:code:) with the email alone for a new code.
Raw value too-many-codes. Three codes have been emailed for this key this hour. Use the last one, or wait.
Raw value too-many-attempts. Too many wrong emails or codes for this key this hour. Try again later.
Raw value try-again. The code could not be emailed just now, and nothing was activated. Try again; it is nobody's mistake.
Restoring a license without the key
For an App Store build, which may not take a typed key: pickUp(email:) emails the customer a
six-digit code, and restoreByEmail(email:code:) activates the key that fits this build. How a
pickup works is in How every SDK behaves. Since 0.8.0.
Today the Swift SDK runs on macOS and Linux, so this is for a Mac App Store build.
// 1. Email a code to the address the key was sold to (the answer is the same either way)._ = await license.pickUp(email: emailField)
// 2. The customer types the code: restore.switch await license.restoreByEmail(email: emailField, code: codeField) {case .restored: break // licensed: read status()case .failed(.pickup(let refusal)) where refusal.error == .wrongCode: askAgain()case .failed(.pickup(let refusal)) where refusal.error == .codeExpired: _ = await license.pickUp(email: emailField) // a new codecase .failed(.noKey): showNoKeyUnderThatAddress()case .failed: showTheError()} -
To let the customer choose, call
pickUp(email:code:), show each of itspickedKeysby itsmodelName, and activate the one chosen withactivate(picked.key, email: email, code: picked.activationCode). - The errors are the pickup errors, in this SDK's own names. The requests are on the endpoints page.
License files
A customer can be sent a .kglicense file: a key file, which activates its key,
or an offline license file, which licenses the app with no network. How each behaves is in
How every SDK behaves, and what each costs under
Delivered as in License models. These are not the
file the SDK stores its own state in (Storage). Since 0.5.0.
Warning
Keep every key that signed a file in publicJWK. A build without it reads such a file as
.invalid with reason .unknownKey.
licenseFileExtensionis.kglicense, for an open panel's filter or a drop handler's test.licenseFileMediaTypeisapplication/vnd.keygrant.license, andmaxLicenseFileBytesis 16,384, the most a file may be.importLicenseFile(_:email:code:)takes the file's content, never its path: aData, or aStringfor pasted text.- List the ids of leaked files in
LicenseConfig(..., revokedLicenseFiles: ["lf_8Hq2"])(Blocking a leaked file).
In SwiftUI, an open dialog and a drop look like this:
import SwiftUIimport UniformTypeIdentifiersimport KeyGrant
let licenseFileType = UTType(filenameExtension: String(licenseFileExtension.dropFirst())) ?? .data
struct LicenseScreen: View { let license: License @State private var choosing = false @State private var message = ""
var body: some View { VStack { Text(message) Button("Open license file...") { choosing = true } } .fileImporter(isPresented: $choosing, allowedContentTypes: [licenseFileType]) { result in if case .success(let url) = result { Task { await importFile(at: url) } } } // macOS 13 and later; on macOS 12, `.onDrop(of: [.fileURL], ...)` gives the same URL. .dropDestination(for: URL.self) { urls, _ in guard let url = urls.first(where: { $0.pathExtension.lowercased() == "kglicense" }) else { return false } Task { await importFile(at: url) } return true } }
func importFile(at url: URL) async { // A file the app was handed from outside its sandbox: read it while access is granted. let granted = url.startAccessingSecurityScopedResource() defer { if granted { url.stopAccessingSecurityScopedResource() } } do { // The content, never the path, and never more than a license file can be: a larger one is // refused unread past its bound. let handle = try FileHandle(forReadingFrom: url) defer { try? handle.close() } let content = try handle.read(upToCount: maxLicenseFileBytes + 1) ?? Data() switch try await license.importLicenseFile(content) { case .imported(_, let licensedTo): message = "Licensed to \(licensedTo.name ?? licensedTo.email ?? "you")" case .refused(let error): message = "This license file was not taken (\(error.rawValue))." case .notActivated(let error): message = "The key in this file did not activate (\(error.rawValue))." } } catch { // Unreadable, not saved, or a license call still running: try again. message = "Try again." } }} What importLicenseFile answers
It verifies the file offline (Importing a file). It answers one
of these, and the result's isImported says whether the file was taken:
.imported(kind, licensedTo:), wherekindis.keyor.offline;.refused(LicenseFileError), with nothing stored;.notActivated(LicenseError), for a key file whose key did not activate: whatactivate(_:)answers.
The LicenseFileError cases are:
Raw value malformed. Not a license file's text, or its token or claims are broken, or it is more than maxLicenseFileBytes.
Raw value not-a-license-file. A signed token, but not a license file (a lease, say).
Raw value bad-signature. Not signed by the key it names.
Raw value unknown-key. Signed by a key this build does not carry (publicJWK): a newer one, after the product rotated its key, which an update of the app carries. A key file's notes print its key, which can be typed meanwhile.
Raw value wrong-product. Another product's file.
Raw value revoked-file. The file's id is listed in revokedLicenseFiles.
Raw value upgrade. An offline file that does not cover this build: this build is newer than the file covers.
Raw value outdated. An offline file that does not cover this build: this build is older than the file covers.
importLicenseFilethrowsLicenseCallError.previousCallUnfinishedwhile an earlier call has not finished, and the storage's error when the state cannot be read or the file (or a key file's activation) cannot be saved.removeLicenseFile()throws the sameLicenseCallErrorin the same case, and the storage's error when the change cannot be saved.
The status of a stored file
A stored offline file answers every status() with .licensed, source .file, the key, licensedTo and the file's entitlements, and
never asks KeyGrant, refresh() included.
- Show "Licensed to Ada Lovelace" from
licensedTo. - A status from a file has no seat to deactivate: offer
removeLicenseFile()instead, which drops the stored file while a key the device holds keeps working.deactivate()leaves the file alone. - Besides
.revokedFile, a status from a file can be.invalidwith reason.upgrade,.outdated,.tamperedor.unknownKey. What each means, and which wins when the device also holds a key, is in The status of a stored file.
Metered uses
A license model can count uses of what it sells (Metered uses).
The app spends them with use(_:_:), before the work they count, and does the work only when the
spend is ok. Since 0.7.0.
let spent = await license.use("exports") // use("exports", 40) spends forty at once, whole or noneif spent.ok { runExport() // spent.left: what is left now, net of this device's unsynced spends} else { switch spent.error { case .usedUp?: offerMoreExports(left: spent.left) // the server's word: fewer left than asked (nil when it gave no number) case .offlineLimit?: askToConnect() // KeyGrant could not be reached, and this is past what this device may spend without it default: showMeterProblem(spent.error) // .noMeter, .notLicensed, .revoked, .device, .upgrade, .outdated, .storage, .invalidAmount }}
let status = await license.status() // offline, from the leasestatus.meters["exports"] // MeterStatus(left: 950, total: 1000, offline: 100, unsynced: 0, resets: false) offlineistruein the result when the device spent from its offline allowance.nis anInt. Outside 1 tomaxMeterUses(1,000,000,000) it is.invalidAmount, and a meter code that is not 1 to 32 UTF-16 units is.noMeter, both without asking.- It never throws for an outcome: every one is in the
UseResult. A call whose task is canceled still runs to its bounded answer, and a spend that was made is the customer's, so read the result even from a task you canceled. - A request waits at most 5 seconds for KeyGrant, less when
httpTimeoutis shorter. deactivate()sends the unsynced spends with the release.- How a spend works online and offline, and why a meter never takes the license away, is in How every SDK behaves.
When use does not spend
Raw value used-up. The meter has fewer uses left than asked. left says how many are left, when KeyGrant gave a number, so the app can offer fewer. It is KeyGrant's own word, not a guess.
Raw value offline-limit. KeyGrant could not be reached, and the spend is past this device's offline allowance for the meter or past what it last heard is left. left says what the device may still spend. Ask the customer to connect.
Raw value no-meter. The key has no meter with that code, or the code is empty or longer than 32 UTF-16 units.
Raw value not-licensed. No key's lease licenses this build on this device, so nothing was asked. A device trial and an offline license file have no meters.
KeyGrant's verdict on the key, this build or this device, as status() says it.
Raw value storage. The stored license (the state file) could not be read just now. Try again shortly.
Raw value invalid-amount. n is not a whole number from 1 to maxMeterUses (1,000,000,000). This is a bug in the app: nothing was asked.
LicenseStatus.meters
A dictionary by meter code, on a .licensed status from a key's lease that names meters, and empty
on every other status. Each MeterStatus has:
What is left, net of this device's unsynced spends.
The uses the key holds, for good or for this billing period.
This device's offline allowance.
Uses this device spent that KeyGrant has not heard of yet.
true for a meter that comes back each billing period.
Buying from the trial
// The Buy button, while the app holds no key.let paymentLink = URL(string: "https://buy.stripe.com/your-payment-link")!NSWorkspace.shared.open(paymentLink)
// The customer types the key from the delivery email:if await license.activate(enteredKey).isActivated { showThanks() } The checkout creates the key and delivers it, and the customer types it into the app. See Buying from the trial.
When status() returns a trial, the trial reports its launch to KeyGrant at most once a day, in a
task of its own, so the call never waits for it. The app need not call startTrial() on each
launch for Analytics to count the trial as come back
(Device trials).
Storage
The default storage is the folder every SDK shares, on macOS and Linux
(Where the license is stored), so it reads a license the
Electron SDK stored. A sandboxed Mac app's home is its container,
so the file is stored under
~/Library/Containers/<bundle id>/Data/Library/Application Support/KeyGrant.
Keeping a trial through a reinstall
The default registry stash stores nothing. On macOS, KeychainTrialStash stores the trial's start
date as a Keychain item. It is available only where the Security framework is.
- By default it uses the data protection keychain, which needs an app with an application identifier (an App Store app, or one signed with a provisioning profile). In any other app it stores nothing.
- For such an app, pass
keychain: .login. It works in every app, but can show an access prompt when a build with a different signature reads the item.
#if canImport(Security)let config = LicenseConfig( product: "my-product", apiBaseURL: URL(string: "https://api.keygrant.dev")!, publicJWK: publicKeys, adapters: LicenseAdapters(registry: KeychainTrialStash(product: "my-product")))#endif