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