SwiftUI and AppKit
LicenseManager works anywhere: it’s Sendable, its getters are synchronous, and its methods are async. For SwiftUI, LicenseObserver publishes it to your views.
LicenseObserver
A LicenseObserver is an ObservableObject on the main actor. Create it once, with your LicenseManager, and give it to your views:
@main
struct MyApp: App {
@StateObject private var license = LicenseObserver(manager: licenseManager)
var body: some Scene {
WindowGroup {
Group {
switch license.state {
case .loading: ProgressView("Checking your license…")
case .active: ContentView()
case .missingKey: ActivationView()
default: LockedView()
}
}
.environmentObject(license)
}
}
}| Property or method | What it is |
|---|---|
state | The license state, published. |
offlineState | The offline state, published when it changes between online and offline. |
latestAllowedVersion | See .versionNotAllowed, published. |
licenseKeyHint | The last 5 characters of the stored key, like B3JDF, or nil: to show which key is in use, without showing all of it. |
portalURL | The customer portal of your product. |
activate(key:) | Activates a key: setLicenseKey(_:) of the manager. |
refresh() | Validates the stored key now. |
openPortal() | Opens the customer portal in the default browser. |
manager | The LicenseManager. |
It starts with the manager’s current values, and updates them on every change, on the main actor: there’s no “window opened after a change” race.
A locked screen
struct LockedView: View {
@EnvironmentObject private var license: LicenseObserver
var body: some View {
VStack(spacing: 12) {
Text(title).font(.title2.bold())
HStack {
Button("Try again") { Task { await license.refresh() } }
Button("Manage my license") { license.openPortal() }
}
}
}
var title: String {
switch license.state {
case .expired: "Your license has expired"
case .machineLimitReached: "This license is used on too many Macs"
case .versionNotAllowed:
if case .version(let version) = license.latestAllowedVersion {
"Your license includes up to version \(version)"
} else {
"This version isn't included in your license"
}
case .offlineExpired: "Connect to the internet to check your license"
case .clockTampered: "Your Mac's date and time look wrong"
default: "Your license key is no longer valid"
}
}
}See license states for what to show in each one.
Gating features
Hiding a menu item or a button isn’t enough on its own: check the license where the paid feature runs, with the manager’s state, which you can read from any thread:
func exportPDF() throws {
guard licenseManager.state == .active else { throw AppError.licenseRequired }
// …
}For a feature that must not run before the startup validation, await licenseManager.ready() first.
No license check stops someone determined to patch your binary. The SDK stops the common cases: a shared key over its machine limit, a forged token, a clock set back.
AppKit
In an AppKit app, create the manager in your app delegate, and react to changes with states, an AsyncStream that starts with the current state:
@main
final class AppDelegate: NSObject, NSApplicationDelegate {
private var licenseTask: Task<Void, Never>?
func applicationDidFinishLaunching(_ notification: Notification) {
licenseTask = Task { @MainActor in
for await state in licenseManager.states {
switch state {
case .loading: break
case .active: showMainWindow()
case .missingKey: showActivationWindow()
default: showLockedWindow(for: state)
}
}
}
}
@IBAction func openLicensePortal(_ sender: Any?) {
NSWorkspace.shared.open(licenseManager.portalURL)
}
}onStateChange(_:) is the callback alternative: it returns a Subscription, cancelled when you call cancel() or release it. Its listener runs on the SDK’s actor: hop to the main actor before touching your UI.
Concurrency
LicenseManagerisSendable: share one instance between threads, tasks and actors.state,licenseKey,offlineState,latestAllowedVersion,portalURLandisClockTamperedare synchronous and thread-safe.ready(),setLicenseKey(_:),refresh()andfingerprint()areasync, and never block the main thread: network, Keychain and files run on the SDK’s own actor.dispose()stops the background work: renewals, retries and the clock check. Releasing the last reference to the manager does it too. Most apps keep it for their whole lifetime and never call it.