Skip to Content
macOS (Swift)Working offline

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

  1. The SDK waits for the first clock check. When the clock was tampered with, the state is .clockTampered, whatever the token.
  2. It reads the stored token, and verifies it like an online one: signature, product, key, fingerprint, exp and nbf. No token, or a token that fails, gives .offlineExpired.
  3. It checks the token’s mv includes the app’s version. When it doesn’t, the state is .versionNotAllowed. See versions offline.
  4. 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:

OfflineBanner.swift
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.") } } }
ValueWhen
.onlineThe 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 .versionNotAllowed from the token, with the same backoff.

The backoff goes back to 5 minutes after the next successful validation.

SituationNext tries
1 week token, renewal point at day 4 16h, offline since day 2Day 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 tokenNothing 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:

mvAllows
null, or no mvEvery 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.