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-crypto only.

Release notes

Add it

Add the package by its URL, https://github.com/keygrantdev/keygrant-swift.git: in Xcode, File > Add Package Dependencies, or in Package.swift:

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

Swift
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 async and License is Sendable.
  • Create one License per process. Calls on it run one at a time, except has(_:), require(_:), upgradeURL(paymentLink:) and the two pickUp calls, which only read and run beside any call.

LicenseConfig

product

Required. The product's API ID.

apiBaseURL

Required. A URL, set to https://api.keygrant.dev.

publicJWK

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 x also work.
  • A lease is verified by the key it names, or, naming none, by each key in turn.
major

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.

Default 1

deviceName

What the license's device list calls this machine.

deviceHold

false turns device hold off, so no license is held to its device. See Device management.

Default true

httpTimeout

How long one request may take, in seconds.

Default 15

revokedLicenseFiles

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 .invalid with reason .revokedFile, and importing one is refused.

Default [] ; Since 0.5.0

adapters

A LicenseAdapters(storage:registry:clock:fingerprinter:http:). The default is used for any you leave out.

License(_:) throws LicenseConfigError.invalidPublicKey for an empty key set or a key that is not an Ed25519 public key.

Calls

activate(_:email:code:)

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.

  • email and code are what a person typed for a key only its buyer can activate (below), and are nil by default.

email, code since 0.6.0

has(_:)

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.
require(_:)

Throws MissingEntitlement, whose entitlement is the code, when the license does not grant it.

importLicenseFile(_:email:code:)

Takes a Data, or a String for pasted text. Can throw, and returns an ImportLicenseFileResult (below).

Since 0.5.0

removeLicenseFile()

Drops the stored offline license file. Can throw.

Since 0.5.0

use(_:_:)

Returns a UseResult and never throws: ok with left, or error (Metered uses).

Since 0.7.0

pickUp(email:)

Emails a six-digit code to the address, for key pickup. Returns a PickUpResult.

Since 0.8.0

pickUp(email:code:)

Returns a PickUpKeysResult, whose pickedKeys are the address's keys.

Since 0.8.0

restoreByEmail(email:code:)

Confirms the code and activates the key that fits this build. Returns a RestoreResult.

Since 0.8.0

status(forceOnline:)

Returns a LicenseStatus (below). refresh() and startTrial() return one too.

deactivate()

Can throw, and returns a DeactivateResult with released.

upgradeURL(paymentLink:)

Takes and returns a URL. See Sell an upgrade.

  • It returns nil when no key and activation are stored, when the license file cannot be read, or when the link is not an absolute URL.

LicenseStatus

state, reason

The states and reasons, as enum cases with the same raw values, .revokedFile (revoked-file) among them.

key

The license key; on a status from a license file, the key the file records.

trialDaysLeft, trialEndsAt

On a trial: the whole days left, and when it ends, in Unix milliseconds.

trialEndDate

The same end, as a Date.

isTest

true for a test license.

entitlements

A dictionary, empty when the lease grants nothing. Each value reads with boolValue, numberValue or stringValue.

meters

A dictionary too, empty on every status but a .licensed one from a key's lease that names meters (below).

isStalled

true when an earlier call has not finished, so the status was read from storage.

source, licensedTo

On a status from a license file (below); nil on every status not from a license file.

Errors

LicenseError.storage

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).

LicenseCallError.previousCallUnfinished

Thrown by deactivate(), importLicenseFile and removeLicenseFile() while an earlier call is stuck.

the storage's error

Thrown by deactivate(), importLicenseFile and removeLicenseFile() when the license file cannot be read or saved (below).

MissingEntitlement

Thrown by require(_:).

LicenseConfigError.invalidPublicKey

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.

Swift
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:

userEmailRequired

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.

userEmailMismatch

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.

userCodeRequired

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.

userCodeInvalid

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.

tooManyCodes

Raw value too-many-codes. Three codes have been emailed for this key this hour. Use the last one, or wait.

tooManyAttempts

Raw value too-many-attempts. Too many wrong emails or codes for this key this hour. Try again later.

tryAgain

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.

Swift
// 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 its pickedKeys by its modelName, and activate the one chosen with activate(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.

  • licenseFileExtension is .kglicense, for an open panel's filter or a drop handler's test.
  • licenseFileMediaType is application/vnd.keygrant.license, and maxLicenseFileBytes is 16,384, the most a file may be.
  • importLicenseFile(_:email:code:) takes the file's content, never its path: a Data, or a String for 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:

Swift
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:), where kind is .key or .offline;
  • .refused(LicenseFileError), with nothing stored;
  • .notActivated(LicenseError), for a key file whose key did not activate: what activate(_:) answers.

The LicenseFileError cases are:

.malformed

Raw value malformed. Not a license file's text, or its token or claims are broken, or it is more than maxLicenseFileBytes.

.notALicenseFile

Raw value not-a-license-file. A signed token, but not a license file (a lease, say).

.badSignature

Raw value bad-signature. Not signed by the key it names.

.unknownKey

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.

.wrongProduct

Raw value wrong-product. Another product's file.

.revokedFile

Raw value revoked-file. The file's id is listed in revokedLicenseFiles.

.upgrade

Raw value upgrade. An offline file that does not cover this build: this build is newer than the file covers.

.outdated

Raw value outdated. An offline file that does not cover this build: this build is older than the file covers.

  • importLicenseFile throws LicenseCallError.previousCallUnfinished while 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 same LicenseCallError in 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 .invalid with reason .upgrade, .outdated, .tampered or .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.

Swift
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)
  • offline is true in the result when the device spent from its offline allowance.
  • n is an Int. Outside 1 to maxMeterUses (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 httpTimeout is 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

.usedUp

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.

.offlineLimit

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.

.noMeter

Raw value no-meter. The key has no meter with that code, or the code is empty or longer than 32 UTF-16 units.

.notLicensed

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.

.revoked, .upgrade, .outdated, .device

KeyGrant's verdict on the key, this build or this device, as status() says it.

.storage

Raw value storage. The stored license (the state file) could not be read just now. Try again shortly.

.invalidAmount

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:

left

What is left, net of this device's unsynced spends.

total

The uses the key holds, for good or for this billing period.

offline

This device's offline allowance.

unsynced

Uses this device spent that KeyGrant has not heard of yet.

resets

true for a meter that comes back each billing period.

Buying from the trial

Swift
// 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.
Swift
#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