Working offline on macOS
When a validation can’t get a definitive answer from LicenseManager, the SDK falls back on the stored token, the one it got at the last successful validation. While it’s valid, the app stays .active, and the customer notices nothing.
It’s the case when:
- the computer is offline, or LicenseManager can’t be reached,
- the request takes more than 10 seconds,
- LicenseManager answers
plan_limit_exceeded,validation_error,internal_error, or an error the SDK doesn’t know, - the answer isn’t JSON, or its token fails verification.
When LicenseManager can’t be reached, or answers 408, 429 or 5xx, the SDK first retries the request, for up to 30 seconds during refresh() or a startup without a valid stored token, 5 minutes in the background, and only then falls back.
When the customer activates a key, there’s no fallback: setLicenseKey(_:) throws. The fallback only applies to a key already activated.
The fallback
- The SDK waits for the first clock check. When the clock was tampered with, the state is
.clockTampered, whatever the token. - It reads the stored token, and verifies it like an online one: signature, product, key, fingerprint,
expandnbf. No token, or a token that fails, gives.offlineExpired. - It checks the token’s
mvincludes the app’s version. When it doesn’t, the state is.versionNotAllowed. See versions offline. - Otherwise, the state is
.active.
The stored token is never deleted by the fallback: only a locking answer from LicenseManager deletes it.
Offline state
offlineState tells whether the last validation reached LicenseManager, and how long the app can keep running without it. A LicenseObserver publishes it too, so SwiftUI views update when it changes:
struct OfflineBanner: View {
@EnvironmentObject private var license: LicenseObserver
var body: some View {
if case .offline(let validUntil?, let remaining) = license.offlineState, remaining < 2 * 86_400 {
Text("Offline: connect to the internet before \(validUntil.formatted()) to keep using Acme Photo.")
}
}
}| Value | When |
|---|---|
.online | The last validation got a definitive answer: a token, or a locking error. Also during .loading, and in .missingKey. |
.offline(validUntil: Date, remaining: TimeInterval) | .active on the stored token. validUntil is its expiration, remaining the seconds left, computed when it’s read. |
.offline(validUntil: nil, remaining: 0) | Locked offline: .offlineExpired, .clockTampered, or .versionNotAllowed from the token. |
LicenseObserver updates offlineState when it switches between online and offline, but remaining is only computed then: read licenseManager.offlineState again, or use validUntil, to show a countdown.
Most customers never need to know. Only show a banner when little time is left, like 2 days, so the customer has time to get online.
Retries
While offline, the SDK tries again on its own:
- In
.active, at the renewal point if it’s still ahead. Once it has passed, with a backoff: after 5 minutes, then 10, 20, 40, and every hour after that. Never later than the token’s expiration: the last try happens right when it expires. - In
.offlineExpired,.clockTampered, and.versionNotAllowedfrom the token, with the same backoff.
The backoff goes back to 5 minutes after the next successful validation.
| Situation | Next tries |
|---|---|
| 1 week token, renewal point at day 4 16h, offline since day 2 | Day 4 16h, then +5 min, +10 min, +20 min, +40 min, then every hour until day 7, when the token expires: .offlineExpired, still every hour. |
| Launched offline on day 1, with a 1 week token | Nothing until day 4 16h: the token has plenty of time left. |
| Launched offline on day 6, with a 1 week token | +5 min, +10 min, +20 min, +40 min, then every hour. |
The SDK doesn’t watch the network: when the Mac comes back online, it’s noticed at the next try. To notice it right away, call refresh() when the network comes back, with NWPathMonitor:
import Network
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { path in
if path.status == .satisfied, case .offline = licenseManager.offlineState {
Task { await licenseManager.refresh() }
}
}
monitor.start(queue: .global())Versions offline
Online, LicenseManager decides which versions the license includes. Offline, the SDK checks the token’s mv claim, the version hard-coded in the license when it was issued:
mv | Allows |
|---|---|
null, or no mv | Every version. |
"2" | Every 2.x.x, and every older version. |
"2.3" | Every 2.3.x, and every older version. |
Only the major and minor numbers are compared: patch numbers and suffixes are ignored, so 2.3.9 and 2.3.0-beta.1 are both 2.3. A leading v is accepted. A version that can’t be parsed, like nightly, is allowed: LicenseManager will tell at the next validation.
When the version isn’t allowed, the state is .versionNotAllowed, and latestAllowedVersion is .notApplicable: the token knows the max version, not which releases exist. Unlike a refusal from LicenseManager:
- the stored token is kept: going back to an allowed version works offline right away,
- the SDK keeps retrying, since LicenseManager has the final word.
A license with time-limited updates has mv: null until its lock date. A token issued before that date allows every version offline, even ones released after it. The next online validation decides.
Edge cases
An expired license, launched offline. If the app was .expired at its last session, its token was deleted: the state is .offlineExpired. If the license expired while the app was offline, the stored token expired with it: .offlineExpired too.
A license deactivated or expired from the dashboard, used offline. The stored token doesn’t know about it, and the app keeps running until it expires: at most the license’s max offline duration. See what a token doesn’t know.
A token copied from another computer. Its fp doesn’t match this computer’s fingerprint: it fails verification, and the state is .offlineExpired.
The fingerprint changed, like when the stored install id was deleted. The stored token holds the old fingerprint and fails: .offlineExpired offline. Online, the computer is validated as a new machine.
LicenseManager is down. Every running app falls back on its token. Apps with a valid token keep working; only first activations fail.
Your organization is over its plan. Validations answer plan_limit_exceeded, handled like LicenseManager being down: apps keep running until their tokens expire, then become .offlineExpired. See plans and billing.