src/pro/license/LicenseManagerSpecs.md
Line coverage:
LicenseManager.swift91% · refreshed 2026-05-27 by/coverage-explore
LicenseManager is the single source of truth for whether the user has AltTab Pro. It computes a LicenseState and notifies observers when it changes. The state drives every Pro gate in the app (search, lock-search, extra shortcuts, App Icons / Titles styles, Auto size, search-on-release) and the Pro-transition prompts.
It is built from three injected collaborators so the logic is testable without real I/O — the tests pass in mocks (MockClock, MockKeychain, MockLicenseAPI, all defined inline at the bottom of LicenseManagerTests.swift):
Clock — current time. Lets tests fast-forward the trial without sleeping.Keychain — secure storage for the license key, instance id, and variant id. Tied to the app's code signature (see the License/Keychain invariant in AGENTS.md).LicenseAPI — the LemonSqueezy-backed activate / validate / deactivate calls.UserDefaults — non-secret bookkeeping: trialStartDate, lastValidation (timestamp), lastValidationResult (Bool)..trial(daysRemaining) → .pro (user activates a key)
.trial(daysRemaining) → .trialExpired (14 days elapse)
.pro → .trialExpired (revalidation fails / license invalidated, still in/after trial window)
.pro → .proExpired (version-limited variant past its cutoff)
.trial(14); day 13 → .trial(1); day 14 → .trialExpired. The trial start is persisted on first launch and never reset on relaunch.isProLocked is true the instant the trial expires — degradable Pro prefs downgrade immediately (this pairs with ProTransitionManager.onProLockEngaged())..trial — never a half-activated .pro. This is the most important invariant in the file.lastValidationResult missing or false resolves to .trialExpired, not .pro.initialize() dispatches an async revalidation only if lastValidation is older than the interval (~30 days). Within the interval it's skipped (no network call). Network failure preserves state and the old timestamp; a valid result refreshes the timestamp and variant; an invalid result flips to .trialExpired.initialize() from defaults+keychain; async revalidation may then update it on the main queue.onStateChanged fires on initialize and on every transition; onBeforeProUnlock fires before the state flips to .pro (so observers can snapshot pre-Pro state).mockProUser() is #if DEBUG only (a QA-menu helper). CI runs -configuration Release, which strips DEBUG, so its test is guarded by #if DEBUG too.Mirrors LicenseManagerTests.swift 1:1. Each test uses an isolated UserDefaults(suiteName:) (fresh per test, torn down after) so runs don't leak into each other.
.trial(14), trialStartDate set to now.trialStartDate → .trial(11), not a fresh trial..trial(7)..trial(1)..trialExpired..trialExpired (no underflow / negative days).lastValidationResult == true → .pro.lastValidationResult == false → .trialExpired.lastValidationResult never set → defensively .trialExpired.versionLimitedVariants dict, any variant resolves to .pro..pro; key/instance/variant written to keychain, lastValidation* set, customerEmail captured..trial, nothing written to keychain.customerEmail nil, no variant written.keychainWriteFailed surfaced, state stays .trial, nothing left in keychain, no validation/email written..trial..pro state intact..trial(11), keychain cleared..trialExpired..pro, keychain intact.lastValidation recent → no API validate call.lastValidation 31 days old, valid result → one validate call, stays .pro, timestamp refreshed..trialExpired, lastValidationResult set false..pro, timestamp untouched.onStateChanged fires once on initialize with the computed .trial state..pro, ≥2 total..trial → not locked..trialExpired → locked immediately (no grace period)..trialExpired and locked..pro state, confirming it runs before the flip.mockProUser() fires the hook and flips to .pro.