# alt-tab-free — System Graphs
Visual companion to the paywall-removal audit (`docs/audit/01..09`). Five Mermaid diagrams:
overall architecture, license activation/state flow, the Day1→Day35 trial-nag escalation, the
feature-gating downgrade/restore mechanism, and the removal map (delete vs edit vs keep).
All `file:line` references are against branch `master`, commit `9fadf36b`. Diagrams use only simple
ASCII node labels so they render in any Mermaid viewer.
---
## 1. Overall application architecture & subsystem relationships
```mermaid
flowchart TD
main["src/main.swift
CLI fast-path, signal/exception handlers
(PAYWALL-FREE)"]
main -->|App.shared.run| app["src/App.swift
App : AppCenterApplication
NSApplicationDelegate"]
app -->|phase A| phaseA["applicationDidFinishLaunching
App.swift:435-463
logging, Preferences, license wiring,
permission-poll timer"]
phaseA -->|polls until granted| perms["src/macos/SystemPermissions
Accessibility + Screen Recording"]
perms -->|"continueAppLaunchAfterPermissionsAreGranted (SystemPermissions.swift:77)"| phaseB["phase B
App.swift:383-431
threads, panels, menubar,
hooks, Sparkle"]
phaseB --> switcher["Switcher UI
src/switcher/
TilesPanel, PreviewPanel, Windows"]
phaseB --> prefs["Preferences + Settings
src/preferences/"]
phaseB --> events["Event hooks
src/events/, KeyboardEvents,
AccessibilityEvents, RunningApps"]
phaseB --> macos["src/macos/
AXCallScheduler, LoginItem
(PAYWALL-FREE)"]
phaseB --> bg["BackgroundWork
run-loop threads + queues
(PAYWALL-FREE)"]
phaseB --> menubar["src/Menubar.swift
status item + menu"]
app -. license callbacks .-> pro["src/pro/ PAYWALL OVERLAY"]
menubar -. Get Pro / badge dot .-> pro
switcher -. ProFeature.attemptUse .-> pro
prefs -. PreferenceDefinition gating .-> pro
subgraph pro["src/pro/ PAYWALL OVERLAY"]
lic["license/
LicenseManager, RemoteLicenseClient,
Keychain, MachineFingerprint"]
sched["scheduling/
ProTransitionManager + Scheduler,
Day1..Day35 windows"]
feat["ProFeature.swift
capability registry"]
ui["ui/
ProPromptHost, ProBadgeView,
ProGradientButton, UsageStatHeroView"]
feat --> sched
sched --> ui
lic --> sched
end
api["src/api/Endpoints.swift"] -.-> lic
sparkle["Sparkle auto-update
SparkleDelegate"] --> phaseB
appcenter["AppCenter crash reporting
src/vendors/"] -.-> app
```
The app boots from a plain top-level `src/main.swift` (no `@main`), which calls `App.shared.run()`.
`App.swift` is the spine and uses a deliberate two-phase launch: phase A wires logging, preferences,
the license callbacks, and a permission-poll timer; only once Accessibility + Screen-Recording
permissions pass does `SystemPermissions` call back into phase B, which stands up every subsystem and
installs the macOS hooks. The switcher, events, `src/macos/`, and `BackgroundWork` are entirely
paywall-free; the paywall (`src/pro/`) is grafted on through callbacks wired in `App.swift` and
`Menubar.swift`, plus a handful of gate call-sites in the switcher and preferences. The dotted arrows
are the only couplings a removal must sever.
---
## 2. License activation + state flow
```mermaid
sequenceDiagram
actor User
participant App as App.swift
participant LM as LicenseManager.shared
participant API as RemoteLicenseClient
participant Remote as alt-tab.app/api/v1/license
participant KC as Keychain + UserDefaults
participant UI as Menubar / Settings / Gating
Note over App,LM: App launch (phase A)
App->>LM: initialize() (App.swift:460)
LM->>LM: computeState()
seeds trialStartDate on first launch
LM-->>UI: onStateChanged(state) fan-out (App.swift:449-459)
Note right of UI: refreshLicenseMenuItems, syncLicenseCookie,
ProTransitionManager.onLicenseStateChanged,
UpgradeTab.refreshStatus, proLockStateDidChange
Note over User,App: Activation via alt-tab://activate?license_key=...
User->>App: open URL (App.swift:473-485)
App->>LM: activate(licenseKey)
LM->>API: api.activate(key)
API->>Remote: POST /activate
{license_key, fingerprint, trial_started_at}
Remote-->>API: {activated, instance_id, variant_id, customer_email}
API-->>LM: ActivateResult
alt activated == true
LM->>KC: all-or-nothing write
licenseKey, instanceId, variantId
(rollback on any failure)
LM->>KC: lastValidation, lastValidationResult=true, customerEmail
LM->>LM: onBeforeProUnlock() then state = .pro
LM-->>UI: onStateChanged(.pro) (gates open)
else invalid_key / seat_limit_exceeded / rejected
LM-->>UI: LicenseAPIError (state untouched)
end
Note over LM,Remote: Background revalidation throttled to 30 days
LM->>API: revalidateWithServer() (if elapsed >= 30d)
API->>Remote: POST /validate {license_key, instance_id}
Remote-->>LM: {valid} -> .pro or .trialExpired
```
`LicenseManager.shared` is the single source of truth for entitlement and computes a flat 4-case
`LicenseState` (`.trial`, `.pro`, `.proExpired`, `.trialExpired`). At launch `initialize()` computes
state (seeding a 14-day `trialStartDate` on first run) and fans out through `onStateChanged`.
Activation arrives via the `alt-tab://activate` URL scheme (or the Settings Upgrade tab), POSTs to
`alt-tab.app/api/v1/license/activate` with the machine fingerprint, and on success performs an
all-or-nothing Keychain write (with rollback) before flipping state to `.pro`. A throttled 30-day
background revalidation re-checks the license. The whole remote/Keychain layer feeds exactly one
downstream signal the rest of the app consumes: `isProLocked` / `isProAvailable`.
---
## 3. The Day1 -> Day35 trial-nag escalation
```mermaid
stateDiagram-v2
[*] --> Day1
note right of Day1
SOFT NAGS = informational, never block.
Each fires once, gated by a hasSeen* flag.
Prompts only fire 10:00-11:30 or 15:30-17:00.
end note
Day1: Day 1 - [A] Welcome Letter (SOFT)
Day1: Fires immediately on first launch; blocks others until seen
Day4: Day 4 - [H] Tour Popover (SOFT)
Day4: Only if switcher opened on Day 4 exactly
Silent: Days 5-11 silent trial
Day12: Day 12 - [B] Heads-Up Popover (SOFT)
Day12: "Trial ends in 2 days"; skipped on Day 13+
Badge: Days 13-14 - menubar badge dot (SOFT)
Expiry: Day 15 - trial expires -> HARD GATE ARMS
Expiry: onProLockEngaged downgrades degradable prefs to Free,
snapshots Pro values into remembered*
Proactive: Day 15+ - [D] Proactive Window (SOFT)
Proactive: Only if no hard gate fired yet
FreePass: First post-expiry switcher open -> one-shot Free Pass
FullUpgrade: [C] Full Upgrade Window (HARD - lock-in)
FullUpgrade: Sets hasSeenFullUpgrade; once-ever
HardGate: [E] Hard-Gate Popover (HARD)
HardGate: Every blocked feature attempt after [C], forever
Day21: Day 21-34 - [F] Reminder Popover (SOFT)
Day35: Day 35-48 - [G] Final Window (SOFT)
Day35: Has "No thanks - don't ask again" opt-out
Steady: Day 49+ - steady state
Steady: [E] on every hard-gate attempt; degradable prefs fall back
Day1 --> Day4
Day4 --> Silent
Silent --> Day12
Day12 --> Badge
Badge --> Expiry
Expiry --> Proactive
Expiry --> FreePass
FreePass --> FullUpgrade
Proactive --> FullUpgrade
FullUpgrade --> HardGate
Expiry --> Day21
Day21 --> Day35
Day35 --> Steady
HardGate --> Steady
Steady --> [*]
FullUpgrade --> Purchased
Purchased: Purchase -> .pro
Purchased: Scheduler cancelled, all windows dismissed, prefs restored
Purchased --> [*]
```
The trial-nag system (`src/pro/scheduling/`) escalates upsell prompts across a Day1→Day35 timeline,
driven by a pure decision struct (`ProTransitionManagerTestable`) and a persisted scheduler. The
**soft nags** ([A] Welcome, [H] Day4 Tour, [B] Day12 Heads-Up, [D] Day15 Proactive, [F] Day21,
[G] Day35, plus the Days13-14 badge dot) are purely informational and never block functionality. The
**hard gate** arms at Day 15 expiry: degradable preferences are immediately downgraded to their free
equivalents, and the three hard-gated features (extra shortcut slot, search, lock-search) are blocked
at use-time through a free-pass ladder that grants one last full session, then shows [C] Full Upgrade
once, then [E] Hard-Gate popover forever. Only a purchase clears the gate (cancelling the scheduler,
dismissing windows, and restoring remembered Pro prefs).
---
## 4. The feature-gating mechanism (downgrade on lock / restore on unlock)
```mermaid
flowchart TD
state["LicenseManager.state"] --> locked{"isProLocked?
LicenseManager.swift:67-72
(true for proExpired/trialExpired)"}
locked -->|"becomes true (lock)"| engage["ProTransitionState.onProLockEngaged()
ProTransitionState.swift:73-79"]
engage --> snap["For each of 6 ProGatedPreferences:
snapshot Pro index -> remembered* key,
overwrite stored value with freeEquivalent"]
snap --> stored1["UserDefaults stored = Free value
(style=thumbnails, size=medium,
shortcutStyle=doNothingOnRelease)"]
locked -->|"becomes false (unlock/purchase)"| unlock["ProTransitionState.onProUnlocked()
ProTransitionState.swift:85-92"]
unlock --> restore["Restore remembered* Pro index
-> stored value (notify:false),
clear remembered key"]
restore --> stored2["UserDefaults stored = Pro value"]
stored1 --> read["PreferenceDefinition.read()
PreferenceDefinition.swift:32-44"]
stored2 --> read
read --> freepass{"isFreePassSessionActive?"}
freepass -->|yes| pro["return remembered Pro value
(one-session preview)"]
freepass -->|"no + locked + stored still Pro"| free["return freeEquivalent"]
freepass -->|no| asis["return stored value"]
hard["Hard-gated features
extraShortcut, search, lockSearch"] --> attempt["ProFeature.attemptUse()
ProFeature.swift:74-83"]
attempt --> avail{"isProAvailable?"}
avail -->|yes| allow["return true (run)"]
avail -->|"no (expired)"| ladder["ProTransitionManager.attemptHardGatedFeature
free-pass -> [C] -> [E] ladder"]
pro --> getters["Preferences.effective* getters
(appearanceStyle/Size, shortcutStyle)"]
free --> getters
asis --> getters
getters --> switcher["TilesView / ShortcutAction render"]
```
The master gate is `LicenseManager.isProLocked` (true once trial or Pro expires). On the
false→true transition, `onProLockEngaged()` snapshots each of the six gated preferences' Pro index
into a `remembered*` key and overwrites the stored value with its free equivalent; on unlock,
`onProUnlocked()` restores the remembered Pro value (with `notify:false` to avoid bouncing). The
hot-path getter `PreferenceDefinition.read()` returns the remembered Pro value during an active
free-pass session, the free equivalent while locked-and-still-Pro, or the stored value otherwise.
Hard-gated runtime features have no backing preference and are gated at use-time by
`ProFeature.attemptUse()`. Crucially, the registered defaults already contain the Pro values, so
forcing `isProLocked = false` auto-unlocks every degradable feature without any default changes.
---
## 5. The REMOVAL MAP — delete vs edit vs keep
```mermaid
flowchart TB
subgraph DELETE["DELETE WHOLESALE - the paywall tree"]
direction TB
d1["src/pro/license/*
LicenseManager, RemoteLicenseClient,
Keychain, MachineFingerprint, LicenseCookie,
LicenseAPI, Clock"]
d2["src/pro/scheduling/*
ProTransitionManager + Scheduler + State + Testable,
all Day1..Day35 windows/popovers"]
d3["src/pro/ProFeature.swift
ProFeatureCopy.swift, ProConversionCopy.swift"]
d4["src/pro/ui/*
ProPromptHost, ProGradientButton,
UsageStatHeroView (ProBadgeView/ProGradient
only AFTER Settings consumers gone)"]
d5["src/preferences/PreferenceDefinition.swift
(entire gating engine)"]
d6["Tests: LicenseManagerTests, ProTransitionTests,
ProBadgeViewSegmentTests, UsageStatsMessageTests"]
end
subgraph EDIT["EDIT IN PLACE - sever wiring, keep file"]
direction TB
e1["src/App.swift
remove license block 448-460, onAction/onAppLaunchComplete
428-429, switcher hooks 78/328, custom-URL handler 465-490,
Day1Welcome first-launch entanglement 189-233"]
e2["src/Menubar.swift
remove Get Pro/My Account items 46-50,
UpgradeMenuItemView 235-390, refreshLicenseMenuItems 84-118,
badge dot 202-227, refreshState in menuWillOpen 396"]
e3["src/preferences/Preferences.swift
rewrite gated getters 129/130/155 +
override getters 313/319/330 to read raw values"]
e4["src/switcher/ShortcutAction.swift:56
src/switcher/main-window/TilesView.swift:79,92
remove attemptUse gates"]
e5["src/events/PreferencesEvents.swift:62-64
remove isProLocked/isStoredValuePro bounce"]
e6["Settings tabs: AppearanceTab, ControlsTab,
ShortcutEditor, ShortcutsWhenActiveSheet,
LabelAndControl - strip proGatedIndices + ProBadge"]
e7["SettingsWindow.swift - remove UpgradeButton 133-214,
showUpgradeView/hideUpgradeView, refreshUpgradeButton,
windowDidBecomeKey license refresh; SidebarList setProBadge"]
e8["UpgradeTab.swift - DELETE tab; remove navigateToUpgradeTab callers"]
e9["PreferencesMigrations.swift:20
remove markFreshInstallIfUnknown call"]
e10["DebugProfile.swift:17 - drop .debugProfileLabel line
(keep file; feeds feedback)"]
e11["QAMenu.swift:170-241
remove DEBUG Day-X triggers/resets"]
e12["alt-tab-macos-Bridging-Header.h + Info.plist
NSPrincipalClass + alttab:// URL scheme
(coordinate with AppCenter/identity decision)"]
e13["alt-tab-macos.xcodeproj/project.pbxproj
remove ~102 in-Sources entries + fileRef/group
for every deleted src/pro file (app + test UUIDs)"]
end
subgraph KEEP["KEEP UNTOUCHED - paywall-free"]
direction TB
k1["src/main.swift (entry point)"]
k2["src/macos/* (SystemPermissions,
AXCallScheduler, LoginItem)"]
k3["BackgroundWork (threads/queues)"]
k4["src/switcher kernels (SelectionResolver,
WindowFilterResolver, etc.) + Windows/Window"]
k5["src/events hooks (Keyboard, Accessibility,
RunningApplications) minus the few gate lines"]
k6["CachedUserDefaults / index / macroPref plumbing"]
k7["Sparkle wiring (repoint feed/key separately)"]
end
DELETE -. "deleting these breaks compile until EDIT done" .-> EDIT
EDIT -. "must compile against" .-> KEEP
classDef del fill:#ffd6d6,stroke:#c0392b,color:#7b1f1f;
classDef edt fill:#fff3cd,stroke:#d4a017,color:#7a5c00;
classDef kp fill:#d6f5d6,stroke:#27ae60,color:#145a32;
class d1,d2,d3,d4,d5,d6 del;
class e1,e2,e3,e4,e5,e6,e7,e8,e9,e10,e11,e12,e13 edt;
class k1,k2,k3,k4,k5,k6,k7 kp;
```
This is the money diagram for the removal plan. **Red = delete wholesale**: the entire `src/pro/`
tree (license, scheduling, ProFeature, copy, UI primitives) plus `PreferenceDefinition.swift` and the
paywall test files. **Yellow = edit in place**: files that survive but must have their paywall wiring
severed — most are surgical (a handful of lines in `App.swift`, the switcher gates, the preference
getters), but `Menubar.swift`, `SettingsWindow.swift`, and the Settings tabs need real surgery to
strip the upsell UI, and `project.pbxproj` needs every deleted file's build-file/group/sources
entries removed or the project won't even parse. **Green = keep untouched**: the entry point,
`src/macos/`, `BackgroundWork`, the switcher kernels, the event hooks, and the `CachedUserDefaults`
plumbing are all verified paywall-free. The ordering constraint is critical: the deletes break the
build at compile time, so the edits (severing every consumer of `LicenseManager`, `LicenseState`,
`ProFeature`, `ProGatedPreferences`, `ProBadgeView`, and `ProGradientButton`) must land in the same
change set. The lowest-risk first step before any deletion is to force `isProLocked = false` /
`isProAvailable = true`, which turns every gate into a pass-through and lets defaults auto-unlock.