Skip to content

Keylight.manager

import SwiftUI
import KeylightSDK
@MainActor
enum Licensing {
static let manager = try! Keylight.manager(
sdkKey: "sdk_live_...", // from your Keylight dashboard
tenantId: "acme", // assigned at signup
productId: "widget", // set in the dashboard
keyPrefix: "ACME", // 4-char prefix from the dashboard
trustedPublicKeyBase64: "...", // shown once in the dashboard
trialDurationDays: 14,
branding: BrandingConfig(
appName: "Widget",
purchaseURL: URL(string: "https://acme.example.com/buy")!,
supportEmail: "support@acme.example.com",
tintColor: .orange
)
)
}

The factory builds the KeylightConfiguration, the KeylightProvider (with the Keylight origin hardcoded), and the @MainActor LicenseManager in one call. The sdkKey authenticates your app against your Keylight account - rotate it from the dashboard if it’s ever compromised.

Keep your Licensing.manager reference internal (the default) or private within your app module. Do not re-export it as public:

// ✅ good - only your app's own code can touch it
@MainActor
enum Licensing {
static let manager = try! Keylight.manager(...)
}
// ❌ bad - anything linking your module can call activate/deactivate
public let sharedLicenseManager = try! Keylight.manager(...)

This matters when your app loads plugins, embeds scripting, or is itself a framework other modules link against. A public manager lets any linked code call manager.activate(key:) with a forged key or swap state to .licensed. For a standalone app with no plugin surface it is mostly theoretical, but the rule is free to follow.

License and trial state is persisted in a device-bound encrypted file by default (storage: .encryptedFile() — the default since SDK 0.6.0, settable from the factory since 0.8.0). The blob is sealed with a key derived from the device identity, your tenantId, and productId, and the Keychain is left untouched — so macOS shows no Keychain permission prompt on first launch.

The factory above uses this default, which is the right choice for almost every new app. To opt into a different backend, pass storage: to the factory:

static let manager = try! Keylight.manager(
// …same parameters as above…
storage: .encryptedFile(keychainMirror: true) // file authoritative + Keychain recovery copy
// or: storage: .keychain // legacy: Keychain authoritative, file as fallback
)

It backs the encrypted file with a composite store — the file stays authoritative and the Keychain is a synchronized recovery copy:

  • Writes go to both the file and the Keychain.
  • Reads try the file first; on a miss they fall back to the Keychain, and if the value is found there they heal the file from it (copy it back). The next read hits the file.

That read-fallback-and-heal is what turns it into a one-time migration path, not just a backup.

The .keychain backend keeps the Keychain authoritative (pre-0.8.0 behavior) with the encrypted file as a crash-recovery fallback. Prefer .encryptedFile(keychainMirror: true) for migrations — it keeps the popup-free file as the source of truth while still recovering Keychain state.

@main
struct WidgetApp: App {
@StateObject private var manager = Licensing.manager
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(manager)
.task { await manager.checkOnLaunch() }
.onChange(of: scenePhase) { phase in
if phase == .active {
Task { await manager.refreshIfNeeded() }
}
}
}
}
}
struct ContentView: View {
@EnvironmentObject var manager: LicenseManager
var body: some View {
if manager.isEntitled {
ProFeaturesView()
} else {
UpgradePromptView()
}
}
}

Build your own upgrade/activation UI, or drop in the bundled LicensePromptView(manager:).

Trial length and free tier come from your dashboard

Section titled “Trial length and free tier come from your dashboard”

The Trial length and Free tier settings on your app’s Access card are the ones that apply. The values you pass to Keylight.manager(...) are the seed: what a brand-new install uses before it has ever reached the server.

Read what is actually in force:

let days = await manager.effectiveTrialDurationDays()
let freeTier = await manager.effectiveFreeTierEnabled()

Each resolves server value → your compiled-in value → 0.

The settings ride on calls the SDK already makes — validate on every licensed install, and the keyless beacon on every unlicensed one. There are no new network calls at launch. Change the length in the dashboard and installs pick it up on their next check.

fetchConfig() is there if you want an explicit refresh at a moment of your choosing — a settings pane, a “check for updates” button:

await manager.fetchConfig() // nothing calls this for you

Dropping it would make first launch depend on the network. It is the floor a new install runs on before its first successful check.

A dashboard value of 0 means trials are off and survives a relaunch. It is not the same as never having configured one, which leaves your compiled-in value in charge. If you cache these yourself, keep “absent” and “zero” distinct or turning trials off will read as never having set them.

The trial clock is stamped at first launch even when no trial is on offer, so enabling trials afterwards gives existing installs a start date to measure from.

Read the consequence before you do it on a shipped app: the window is measured from that original stamp, not from the day you switched it on. An install older than the length you set gets a trial that has already expired. Turning on a 14-day trial hands nothing to anyone who installed more than 14 days ago. If you want existing installs to get a window, set a length that covers their age.

Verify that the settings really came from your dashboard

Section titled “Verify that the settings really came from your dashboard”

Keylight signs the trial length and free-tier flag with your tenant key, on every route that delivers them. The SDK checks that signature when you ask it to:

let manager = try Keylight.manager(
sdkKey: "", tenantId: "", productId: "", keyPrefix: "",
trustedPublicKeyBase64: "", trialDurationDays: 14, branding: branding,
requireSignedConfig: true
)

Leave it off until your app has a trial length set in the dashboard. That is what makes Keylight sign an app’s settings; with the switch on, an unsigned app rejects every legitimate response and stays on the value you compiled in. Once it is on, settings that do not verify against trustedPublicKeyBase64 are never cached. The check runs at the one place settings are merged, so validate, the keyless beacon and fetchConfig() all go through it.

Verification trusts only the key you compile in; nothing is fetched at runtime. Rotating to a new key id leaves installs that do not know it on their last known settings until they update — they freeze, they do not break.

Payment webhooks can lag the checkout redirect by a few seconds, so one validate the moment your customer returns can still see the old entitlements. refreshAfterUpgrade polls briefly and returns as soon as the entitlements or state change:

// When the customer comes back from checkout:
let changed = await manager.refreshAfterUpgrade(timeout: 30, pollInterval: 2) // seconds

true as soon as something changed, false on timeout or when there is no stored license (which makes no network call). The same call exists in every SDK; only the units differ.

Anywhere in your view tree, read manager.isEntitled (which is true for both .trial and .licensed states) to decide whether to expose paid functionality. For state-specific UI (e.g. showing remaining trial days), switch on manager.state directly.

LicenseManager posts Notification.Name.keylightLicenseDidChange after any state transition. Subscribe if you need to invalidate caches or refresh remote data when entitlement changes.

NotificationCenter.default.addObserver(
forName: .keylightLicenseDidChange,
object: nil,
queue: .main
) { _ in
// react to entitlement change
}

Activation and deactivation are driven by LicensePromptView, which calls manager.activate(key:) and manager.deactivate() under the hood. You can also call them directly if you build your own paywall UI.