Skip to content

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.

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.

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.

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

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.

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.

Something went wrong: clock rollback detected, the stored license can’t be validated, or Keylight refused the license. Paywall shown; no entitlement.

.trialdaysLeft > 0.licensedlease fresh + verified.expiredtrial ran out or lease expired.freeTierno license, freeTierEnabled.limitedfallback lease, no entitlements.invalidclock rollback, rejectionactivatedeactivatetrial days run outlease expiredserver rejectionrefresh 6h / 24h before expiryfallback ONcached fallbacktrial ended, freeTierEnabledtrial ended, no free tier

LicenseManager.checkOnLaunch() resolves state with a most-specific-wins ordering. The table below covers every case; .expired is never silently swallowed.

SituationResolves 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 fallbackAccess off resolves to .expired even 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 turn fallbackAccess on.
  • If product config has never been fetched (e.g. first launch while offline), the SDK falls back to .expired rather than assuming a free tier exists.

Once .licensed, the SDK’s refreshIfNeeded() re-validates in the background. It’s debounced and staleness-gated:

RuleInterval
Debounce (minimum gap between refreshes)5 minutes
Refresh if last refresh was older than6 hours
Refresh eagerly if lease expires within24 hours
Pre-schedule a refresh this long before lease expires1 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.

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.

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