License lifecycle
A license has six possible states from the app’s perspective:
public enum LicenseState: Equatable, Sendable { case trial(daysLeft: Int) case licensed case limited case freeTier case expired case invalid}Every UI decision - show the paywall, unlock a feature, nag the user about an expiring key - should be driven off this state. LicenseManager.state is @Published on the main actor, so SwiftUI can observe it directly.
States
Section titled “States”.trial(daysLeft:)
Section titled “.trial(daysLeft:)”The app has never been activated and the trial clock is still running. daysLeft ticks down to 0, then the state becomes .expired.
isEntitled returns true while daysLeft > 0. Use this to let trial users exercise paid features.
.licensed
Section titled “.licensed”A license key has been activated and a recent Ed25519 lease confirms it. The SDK re-verifies the lease on every app launch; while the lease is fresh the app stays licensed without a network.
isEntitled is true.
.limited
Section titled “.limited”A former paying customer whose license expired, where the key type has fallback access enabled. The server issues a signed lease with status: "fallback" and an empty entitlements list — no paid features leak through.
isEntitled is false. Your app decides what .limited means in practice — typically a read-only or data-export mode so customers never lose access to their data.
This state is distinct from .expired: a customer in .limited once had a paid license and the developer has explicitly opted into keeping them in a reduced mode rather than cutting them off entirely. Show messaging that reflects this — something like “Your subscription has lapsed. Renew to unlock full access.”
.freeTier
Section titled “.freeTier”A keyless user on the app’s free plan. There is no license and no lease — the SDK resolves this state locally when the product has freeTierEnabled on and no trial is running.
isEntitled is false. Your app renders the free experience.
This is distinct from .limited: .freeTier means the user has never paid, while .limited means they are a lapsed customer. You can show different messaging even if the feature set looks the same — for example, a “Get started” prompt vs a “Renew your subscription” prompt.
.expired
Section titled “.expired”Either the trial clock ran out (no stored license) or the server returned an expired lease for a stored license and the key type does not have fallback access enabled. Paywall shown; no entitlement.
.invalid
Section titled “.invalid”Something went wrong: clock rollback detected, the stored license can’t be validated, or Keylight refused the license. Paywall shown; no entitlement.
Transitions
Section titled “Transitions”Resolution at launch
Section titled “Resolution at launch”LicenseManager.checkOnLaunch() resolves state with a most-specific-wins ordering. The table below covers every case; .expired is never silently swallowed.
| Situation | Resolves to |
|---|---|
| Clock manipulated (monotonic check) | .invalid |
Stored license, fresh cached lease, status active | .licensed |
Stored license, cached lease status fallback | .limited |
Stored license expired → server issues active lease | .licensed |
Stored license expired → server issues fallback lease (key type fallback ON) | .limited |
| Stored license expired, key type fallback OFF | .expired |
| Explicit server rejection / clock issue at re-validate | .invalid |
| No stored license, trial running | .trial(daysLeft:) |
No stored license, trial ended/not-started, freeTierEnabled ON | .freeTier |
No stored license, trial ended/not-started, freeTierEnabled OFF | .expired |
A few deliberate decisions worth noting:
- An expired paid key with
fallbackAccessoff resolves to.expiredeven if the app has a free tier enabled. Turning fallback off is an explicit “this tier gets cut off” choice. Developers who want lapsed customers in the free tier can turnfallbackAccesson. - If product config has never been fetched (e.g. first launch while offline), the SDK falls back to
.expiredrather than assuming a free tier exists.
Refresh cadence
Section titled “Refresh cadence”Once .licensed, the SDK’s refreshIfNeeded() re-validates in the background. It’s debounced and staleness-gated:
| Rule | Interval |
|---|---|
| Debounce (minimum gap between refreshes) | 5 minutes |
| Refresh if last refresh was older than | 6 hours |
| Refresh eagerly if lease expires within | 24 hours |
| Pre-schedule a refresh this long before lease expires | 1 hour |
Net effect: foregrounding the app many times in quick succession won’t spam the network, but a lease nearing expiry is refreshed before the user notices.
Transient failures don’t boot the user. If the network is flaky mid-session the state stays .licensed and the next refresh retries. Only an explicit server rejection during refresh flips to .invalid.
Deactivation
Section titled “Deactivation”LicenseManager.deactivate() calls /deactivate, frees the instance slot, and then re-evaluates the trial timer. Depending on the trial state, you land in .trial(daysLeft) or .expired.
Notification hook
Section titled “Notification hook”Every state transition also posts Notification.Name.keylightLicenseDidChange on NotificationCenter.default, with the posting LicenseManager as the object. Useful if you need to react from a non-SwiftUI layer (AppKit, Sparkle, analytics).
Related
Section titled “Related”- Offline leases - how the lease verification actually works.
- Ed25519 leases - the wire format and signing rules.
- Keylight.manager - calling
checkOnLaunchandrefreshIfNeededfrom your app.