228 lines
28 KiB
Markdown
228 lines
28 KiB
Markdown
# alt-tab-free — Master Paywall Audit
|
|
|
|
> **Purpose.** This is the authoritative, read-this-first overview of the codebase, the paywall grafted onto it, and the complete surface that must be removed to ship a fully-free build. It summarizes nine deep-dive sections under [`docs/audit/`](audit/) and the completeness review; follow the links for line-by-line detail.
|
|
>
|
|
> **Audit date / commit basis:** repo at `master` (HEAD `9fadf36b`, v11.3.0). All `file:line` references are against this snapshot.
|
|
>
|
|
> **Status legend:** **KEEP** = legitimate app, must survive · **CUT** = paywall, remove · **REWRITE** = touched by both, hand-edit · **FLAG** = owner decision.
|
|
|
|
---
|
|
|
|
## 1. What this project is, and the licensing position
|
|
|
|
**alt-tab-free** ([github.com/KofTwentyTwo/CommandTabFree](https://github.com/KofTwentyTwo/CommandTabFree)) is a re-host of the popular open-source macOS window switcher **AltTab** ([github.com/lwouis/alt-tab-macos](https://github.com/lwouis/alt-tab-macos)). It is a pure Swift 5.8 AppKit application — no SwiftUI, no Interface Builder (per [`AGENTS.md`](../AGENTS.md)).
|
|
|
|
### It is not a diverged fork — it is upstream + a paywall commit
|
|
|
|
Git history shows every recent commit is authored by upstream author `lwouis` (34/40) or `semantic-release-bot` (6/40); **zero commits by the alt-tab-free owner**. The entire paywall was introduced upstream in a single commit `9147a4a8` *"feat: introducing alt-tab pro!"* (v11.0.0, 2026-04-05), a 1869-file change that also did a large repo-wide folder reorg. `src/pro/` did not exist before it and has only ever been touched by 3 commits. All branding is still upstream's: bundle id `com.lwouis.alt-tab-macos`, name AltTab, domain `alt-tab.app`, repo `lwouis/alt-tab-macos`, Sparkle `SUPublicEDKey`, AppCenter secret. (See [`09-telemetry-and-upstream.md`](audit/09-telemetry-and-upstream.md).)
|
|
|
|
### Licensing: republishing a free build is permitted, with one obligation
|
|
|
|
The project is **GPL-3.0** ([`LICENCE.md`](../LICENCE.md):1 — *"GNU GENERAL PUBLIC LICENSE Version 3"*). Under GPL-3 you may modify and redistribute, including a build with all paywall code removed. **The obligation:** you must offer the complete corresponding source of your modified version under GPL-3 to your users (typically by publishing the fork's repo). The GPL-3 copyright string in `Info.plist:20-21` is correct and must stay. The paywall itself was GPL-3 code upstream, so its removal carries no additional license burden.
|
|
|
|
> **Strategic alternative (owner decision).** Because upstream is alive and GPL-3, rebasing the free build on the **pre-paywall upstream tag v10.12.0** (commit `317a485b`) yields the same unlocked feature set with no paywall to extract, at the cost of losing post-v11 fixes unless cherry-picked. The rest of this document assumes **in-place removal** from the current v11.x snapshot.
|
|
|
|
---
|
|
|
|
## 2. High-level architecture (the legitimate app)
|
|
|
|
This is the app that survives removal. Deep dive: [`01-core-architecture.md`](audit/01-core-architecture.md), [`02-switcher-ui.md`](audit/02-switcher-ui.md).
|
|
|
|
### Startup (two-phase, permission-gated)
|
|
|
|
- Entry point is top-level code in [`src/main.swift`](../src/main.swift) (**not** `@main`/`@NSApplicationMain`): CLI fast-path → signal/exception handlers → `App.shared.run()` (`main.swift:23`). `main.swift` is paywall-free.
|
|
- `App` (`src/App.swift:7`) subclasses `AppCenterApplication` (this is *why* removing AppCenter touches the principal class — see §5) and is its own `NSApplicationDelegate`.
|
|
- **Phase A** — `applicationDidFinishLaunching` (`App.swift:435-463`): wires logging, Preferences, the license callbacks, and starts a permission-polling timer.
|
|
- **Phase B** — `App.continueAppLaunchAfterPermissionsAreGranted` (`App.swift:383-431`): invoked **once** from `SystemPermissions.checkPermissionsPreStartup` (`SystemPermissions.swift:77`) only after Accessibility + Screen-Recording permissions pass. It creates the background threads/queues, switcher panels, menubar, main menu, and installs every macOS hook.
|
|
- The **phase-A → phase-B handoff is the critical launch path and is paywall-free.**
|
|
|
|
### macOS integration hooks (all paywall-free)
|
|
|
|
- Keyboard: `cghid` CGEvent tap + Carbon `RegisterEventHotKey` (`KeyboardEvents.swift:104-152`).
|
|
- Windows: AX observers with refcon-encoded `(pid,wid)` identity (`AccessibilityEvents.swift`), dispatched through a bounded retry executor (`AXCallScheduler.swift`).
|
|
- App lifecycle: KVO on `NSWorkspace.runningApplications` (`RunningApplicationsEvents.swift:6-10`).
|
|
- Threading owned by `BackgroundWork` (dedicated CFRunLoop threads + bounded operation queues, ~45-thread budget). **`src/macos/` and `BackgroundWork` contain zero License/Pro references** (grep-verified).
|
|
|
|
### Switcher UI
|
|
|
|
Event-driven: each `Window` subscribes to AX notifications → `Windows.findOrCreate` (`Windows.swift:390`), gated by `WindowDiscriminator.isActualWindow`, into the canonical `Windows.list`. Pure unit-tested kernels (`SelectionResolver`, `SearchModeResolver`, `WindowFilterResolver`, `WindowOrderResolver`) are separated from imperative wrappers and **never call pro code** — gating is passed in as a `Bool`. Rendering is a static `TilesView` engine with a 20-`TileView` recycled pool (`TilesView.swift:35`); focus uses private SkyLight APIs (`Window.swift:245-288`).
|
|
|
|
### Preferences
|
|
|
|
Everything stored as strings in `UserDefaults.standard`, macro (enum) prefs as stringified `CaseIterable` index, read through `CachedUserDefaults`, written through `Preferences.set` firing one `PreferencesEvents.preferenceChanged` hook. The **core preferences plumbing is paywall-independent** — only the feature-gating layer on top is paywall (see §3).
|
|
|
|
### Build / release / update
|
|
|
|
Raw `xcodebuild` over `alt-tab-macos.xcodeproj` (3 schemes), settings layered via `config/*.xcconfig`. Releases are fully automated by `semantic-release` on push to master. Signed with upstream Developer ID *Louis Pontoise (QXD7GW8FHY)*, notarized via bundled `notarytool`. Auto-update via embedded **Sparkle 2.9.1**, feed `https://alt-tab.app/appcast.xml`, EdDSA-signed. Crash reporting via Microsoft **AppCenter** (Crashes module only; opt-in; EoL). Deep dive: [`08-build-release-distribution.md`](audit/08-build-release-distribution.md), [`09-telemetry-and-upstream.md`](audit/09-telemetry-and-upstream.md).
|
|
|
|
---
|
|
|
|
## 3. The paywall, anatomized
|
|
|
|
The paywall lives **almost entirely under [`src/pro/`](../src/pro/)** and is grafted onto the spine at a small, well-defined set of call sites. Four subsystems:
|
|
|
|
### 3a. License subsystem — `src/pro/license/` ([`04-license-subsystem.md`](audit/04-license-subsystem.md))
|
|
|
|
`LicenseManager.shared` is the **single source of truth** for entitlement. It computes a flat 4-case `LicenseState` enum — `.trial(daysRemaining:)`, `.pro`, `.proExpired`, `.trialExpired` (`LicenseState.swift:14-19`); there is no `.lifetime` case (lifetime is `.pro` + variant slug `"pro_lifetime"`, `LicenseManager.swift:25`). A **14-day trial** starts on first launch (`LicenseManager.swift:17,192-200`).
|
|
|
|
- The master gate **`isProLocked`** (`LicenseManager.swift:67-72`) is `true` for `.proExpired`/`.trialExpired`, `false` for `.pro`/`.trial`. Every settings intercept, the write-bounce, the shortcut cap, and `ProFeature.attemptUse()` consult it.
|
|
- `activate(_:)` (`LicenseManager.swift:104-140`) does all-or-nothing Keychain writes with rollback, then flips `state = .pro`. `RemoteLicenseClient` (`RemoteLicenseClient.swift`) POSTs activate/validate/deactivate to **`https://alt-tab.app/api/v1/license`** (`Endpoints.swift:11`), sending a machine fingerprint (`MachineFingerprint.swift:9-20`, IOPlatformUUID) + trial-start timestamp. Revalidation is throttled to 30 days.
|
|
- Keychain service: **`com.lwouis.alt-tab-macos.license`** (`LicenseManager.swift:4`); a parallel UserDefaults suite of the same name holds `trialStartDate`, `lastValidation`, `customerEmail`.
|
|
- `LicenseCookie.swift:5-22` writes a per-tier `license` cookie on `.alt-tab.app` so the Sparkle appcast can be tier-tailored.
|
|
|
|
### 3b. Trial-nag "Pro Transition" scheduler — `src/pro/scheduling/` ([`05-trial-nag-scheduling.md`](audit/05-trial-nag-scheduling.md))
|
|
|
|
A self-contained Day1→Day35 upsell-escalation engine. Coordinator `ProTransitionManager` (singleton) + timed `ProTransitionScheduler` (persisted `DispatchWorkItem`) + persisted `ProTransitionState` (all `hasSeen*` flags + `remembered*` Pro indices, in the `.license` UserDefaults suite) + pure, 100%-unit-tested `ProTransitionManagerTestable` state machine. The coordinator emits abstract `ProPromptAction` cases through `onAction`; `ProPromptHost` maps each to a concrete Day-X window/popover so the coordinator stays AppKit-free.
|
|
|
|
- **Soft nags** (never block): `[A]` Day1 Welcome (fires immediately on first launch), `[H]` Day4 Tour, `[B]` Day12 Heads-Up, `[D]` Day15 Proactive, `[F]` Day21, `[G]` Day35, plus a Days13-14 menubar badge dot.
|
|
- **Hard gate arms at Day15 expiry:** `onProLockEngaged()` (`ProTransitionState.swift:73-79`) snapshots degradable Pro prefs to `remembered*` and downgrades stored values to free equivalents; the three hard-gated features then route through a free-pass ladder (`ProTransitionManager.swift:226-242`): first post-expiry attempt = `.freePass` (allow once, queue `[C]` Full Upgrade) → `.showFullUpgrade` → `.showHardGatePopover` `[E]` forever.
|
|
- Prompts only fire 10:00-11:30 or 15:30-17:00; give-up at Day49.
|
|
|
|
### 3c. Feature gating — `ProFeature.swift` + `PreferenceDefinition.swift` ([`03-preferences-and-gating.md`](audit/03-preferences-and-gating.md), [`06-profeature-and-copy.md`](audit/06-profeature-and-copy.md))
|
|
|
|
Two complementary mechanisms:
|
|
|
|
1. **Degradable preferences** — declared as 6 `ProGatedPreferences` definitions (`PreferenceDefinition.swift:103-164`): 3 globals (`appearanceStyle`, `appearanceSize`, `shortcutStyle`) + their 3 shortcut-0 overrides. Each carries a `freeEquivalent`, a `rememberedKey`, and an `isProValue` predicate. `PreferenceDefinition.read()` (`:32-44`) is the hot-path getter; while locked it returns the free equivalent. Pro getters in `Preferences.swift` route through these (`:129,130,155,313,319,330`).
|
|
2. **Hard-gated runtime actions** — no backing preference, gated at use-time via `ProFeature.attemptUse()` (`ProFeature.swift:74-83`): `extraShortcut(index:)`, `searchInSwitcher`, `lockSearchInSwitcher`. Call sites: `ShortcutAction.swift:56`, `TilesView.swift:79,92`.
|
|
|
|
Write-side enforcement bounces any setter that lands a Pro value into a gated key while locked, to the Upgrade tab (`PreferencesEvents.swift:62-64`). Free users are hard-capped at **1 shortcut** (`ControlsTab.swift:589-592`). The registered defaults already hold the Pro values (e.g. `appearanceSize=.auto`), so **removing the gate auto-unlocks everything with no default changes.**
|
|
|
|
### 3d. Upsell UI — `src/pro/ui/` + Settings/Menubar entanglement ([`07-pro-ui-and-menubar.md`](audit/07-pro-ui-and-menubar.md))
|
|
|
|
Reusable AppKit upsell primitives (none contain license logic; they only render): `ProPromptHost` (dispatch hub), `ProPromptPopover`/`ProPromptWindow` (shared chrome), `ProPromptHeader`, `ProGradientButton` (gradient buy CTA), `UsageStatHeroView` (a "your usage so far" stat block), and `ProBadgeView.swift` (misleadingly named — holds 5 types incl. the `ProGradient` utility enum, `NotAdvisedButton`, and the "Pro" pill). These are also consumed by **Settings** (the persistent Get-Pro gradient pill `UpgradeButton`, the in-window Upgrade view, `ProBadgeView` badges on gated rows, and the "Pro" search-index keyword), which is why `src/pro/ui/` can only be deleted after Settings consumers are cut.
|
|
|
|
---
|
|
|
|
## 4. The complete set of Pro-gated features (what becomes free)
|
|
|
|
There are **two degradable groups + three hard-gated actions**, marketed as four headline features. After removal, all are unconditionally available.
|
|
|
|
| Feature (marketing copy) | Kind | Free (locked) value | Pro value | Gate mechanism | Definition |
|
|
|---|---|---|---|---|---|
|
|
| **App Icons & Titles styles** (`appearanceStyle`) | Degradable + hard-gated | `thumbnails` (0) | `appIcons` (1) / `titles` (2) | `read()` downgrade + first-summon `[C]` | `PreferenceDefinition.swift:103-109`; `Preferences.swift:129` |
|
|
| **Auto Size** (`appearanceSize`) | Degradable | `medium` (1) | `auto` (3) | `read()` downgrade only | `PreferenceDefinition.swift:111-117`; `Preferences.swift:130` |
|
|
| **Search on release** (`shortcutStyle`) | Degradable + hard-gated | `doNothingOnRelease`/`focusOnRelease` | `searchOnRelease` (2) | `read()` downgrade + first-summon `[C]` | `PreferenceDefinition.swift:119-125`; `Preferences.swift:155` |
|
|
| ↳ same three as **shortcut-0 overrides** | Degradable | free equivalents | Pro values | `read()` downgrade | `PreferenceDefinition.swift:130-164`; `Preferences.swift:313,319,330` |
|
|
| **Up to 9 keyboard shortcuts** (extra slots ≥ 2) | Hard-gated | capped at **1** shortcut | up to 9 | `ProFeature.attemptUse()` + `addShortcutSlot` cap | `ProFeature.swift:14`; `ShortcutAction.swift:56`; `ControlsTab.swift:589-592` |
|
|
| **Search — find a window by typing** | Hard-gated | blocked | available | `ProFeature.attemptUse()` | `ProFeature.swift:15`; `TilesView.swift:92` |
|
|
| **Lock-search in switcher** | Hard-gated | blocked | available | `ProFeature.attemptUse()` | `ProFeature.swift:16`; `TilesView.swift:79` |
|
|
|
|
Marketing copy: `ProFeatureCopy.swift` (4 strings); the comparison table that advertises them is `Day1WelcomeLetterWindow.swift:77-80`. Note **preview-selected-window is NOT actually Pro-gated** (`Preferences.effectivePreviewSelectedWindow` reads the raw bool, gated only by Screen-Recording permission) — leave it alone.
|
|
|
|
---
|
|
|
|
## 5. The complete removal surface
|
|
|
|
**Removal strategy (consensus across all sections):** *delete `src/pro/` as a unit, then fix the consumers that reference it.* The lowest-risk staged approach is to first force `LicenseManager.isProLocked = false` / `isProAvailable = true` (every gate becomes a pass-through with no other edits), then progressively excise. The list below — grouped by file — is the full set of edits required for a clean build, incorporating the completeness review's additional items and build-break risks. **Bold flags** mark items the deep sections under-weighted.
|
|
|
|
### `src/pro/` — delete wholesale (CUT)
|
|
|
|
- `src/pro/license/` — `LicenseManager`, `LicenseState`, `LicenseAPI`, `RemoteLicenseClient`, `LicenseCookie`, `Keychain`, `MachineFingerprint`, `Clock`, `LicenseManagerSpecs.md`, `LicenseManagerTests.swift`.
|
|
- `src/pro/scheduling/` — `ProTransitionManager`, `ProTransitionScheduler`, `ProTransitionState`, `ProTransitionManagerTestable`, all `Day*.swift` windows/popovers, `ProTransitionTests.swift`, `ProTransitionSpecs.md`.
|
|
- `src/pro/ui/` — `ProPromptHost`, `ProPromptPopover`, `ProPromptWindow`, `ProPromptHeader`, `ProGradientButton`, `UsageStatHeroView`, `ProBadgeView.swift` (all 5 types). **Delete only AFTER the Settings consumers below are removed.**
|
|
- `src/pro/` root — `ProFeature.swift`, `ProFeatureCopy.swift`, `ProConversionCopy.swift`, `UsageStats.swift` / `UsageStatsTestable.swift` (local-only; feeds only upsell copy).
|
|
|
|
### `src/App.swift` (REWRITE — the spine wiring)
|
|
|
|
- Selectors `upgradeToProAction` / `openAccountAction` / `supportProjectAction` (`:25-27`) and methods `upgradeToPro` / `openAccount` / `supportProject` (`:111-121`) — remove (Menubar-only consumers).
|
|
- `hideUi` `onSwitcherDismissed()` (`:78`) and `showUiOrCycleSelection` `onSwitcherShown()` (`:328`) — remove the single Pro line each (switcher hot path).
|
|
- First-launch welcome entanglement (`:189-233`): `willShowDay1WelcomeOnAppLaunch` (`:205-208`) + deferral observer referencing `Day1WelcomeLetterWindow` (`:194-218`). **Simplify to always call `showAndCenterSettingsWindowOnFirstLaunch`; the welcome window and its `willCloseNotification` observer must be removed together or Settings never shows on first launch.**
|
|
- Phase-B Pro wiring `onAction` / `onAppLaunchComplete` (`:428-429`) — remove (all other phase-B wiring stays).
|
|
- License callback block (`:448-460`): `onBeforeProUnlock`, the `onStateChanged` fan-out (`refreshLicenseMenuItems`, `syncLicenseCookie`, `onLicenseStateChanged`, `UpgradeTab.refreshStatus`, `refreshUpgradeButton`, `proLockStateDidChange` post), `LicenseManager.initialize()`. The cleanest 13-line cut point. Keep `resetPreferencesDependentComponents` (`:51-53`) — it's used elsewhere; just drop the caller.
|
|
- Custom-URL handler `application(_:open:)` + `handleCustomUrl` (`:465-490`) — remove wholesale (exists only for the `<bundleid>://activate` deep link; the registered scheme is `$(PRODUCT_BUNDLE_IDENTIFIER)` = `com.lwouis.alt-tab-macos://`, NOT a literal `alt-tab://`). **Also drop the URL scheme from `Info.plist` (`CFBundleURLSchemes`, :53-56).**
|
|
|
|
### `src/Menubar.swift` (REWRITE)
|
|
|
|
- License item vars (`:8-10`), the Get Pro / My Account / Support adds + view assignment (`:46-50`), `refreshLicenseMenuItems` (`:84-107`), `toggleUpgradeMenuItem` (`:109-118`).
|
|
- `UpgradeMenuItemView` gradient pill incl. trial "days remaining" copy (`:235-390`; copy at `:370-375`) — delete; only consumer is `:47`.
|
|
- Badge dot: `badgeDotLayer` (`:187`), `updateBadgeDotOverlay` (`:202-227`) gated on `ProTransitionManager.shouldShowBadgeDot` (`:205`), and its call inside `loadPreferredIcon` (`:196`).
|
|
- `menuWillOpen` (`:392-399`): drop `LicenseManager.shared.refreshState()` (`:396`); **keep `refreshPermissionCallout()` (`:397`).**
|
|
- **`updateContent(_ state: LicenseState)` (`:357`) hard-depends on the `LicenseState` enum type — goes away with `UpgradeMenuItemView`. Keep `menubarIconCallback` (`:155-164`), used by non-pro callers.**
|
|
|
|
### `src/preferences/` (REWRITE)
|
|
|
|
- `PreferenceDefinition.swift` — delete entirely; **it is also a unit-test-target member (`project.pbxproj:1979`), so a broken getter fails app AND test compile.**
|
|
- `Preferences.swift` — rewrite the 3 gated getters (`:129,130,155`) and 3 index-0 override branches (`:313,319,330`) to read raw values; remove `overrideRememberedKey` + `ProTransitionState` cleanup in `removeOverride` (`:282-298`).
|
|
- `PreferencesEvents.swift:62-64` — delete the `isProLocked && isStoredValuePro` bounce.
|
|
- `PreferencesMigrations.swift:20` — remove the `ProTransitionState.markFreshInstallIfUnknown` call (the only paywall coupling in migrations; **the real symbol, distinct from the self-contained stub in `PreferencesMigrationsTests.swift:356`**).
|
|
- `LabelAndControl.swift` — strip `proGatedIndices` param + intercept (`:74-116`, esp. `:86-92`).
|
|
- `AppearanceTab.swift` — `proLockStateDidChange` observer (`:414-419`), `proGatedAppearanceStyleIndices` (`:462,700-703`), `ProBadge.attach` (`:463,481,575,688-698`), `wrapShortcutStyleProLockIntercept` (`:585-598`), `wrapAppearanceSizeProLockIntercept` (`:662-679`), `refreshProLockUi` (`:705+`).
|
|
- `ShortcutEditor.swift` — `proGatedIndices` (`:429,437,449,459`), segmented + radios locked intercepts (`:599-607,705-711`).
|
|
- `ControlsTab.swift` — `proLockStateDidChange` observer (`:141`), `row.setProBadge` for index≥1 (`:515`), the `addShortcutSlot` shortcut cap (`:589-592`).
|
|
- `ShortcutsWhenActiveSheet.swift` — `ProBadge` on search/lock-search rows (`:25,51,54,63-69`).
|
|
|
|
### `src/preferences/settings-window/SettingsWindow.swift` — **the most license-entangled non-pro file outside Menubar/UpgradeTab (REWRITE — under-weighted by §7)**
|
|
|
|
- `UpgradeButton` class (`:133-214`, a `ProGradientButton` subclass reading `LicenseManager.state`/`.isLifetimeVariant`/`.customerEmail`/`.trial`/`.proExpired`); instance `:303`; state `:308-311`; `setupUpgradeButton` + `upgradeButtonClicked` (`:401,507-526`).
|
|
- `showUpgradeView` / `hideUpgradeView` embedding `UpgradeTab.initTab` (`:1066,1122-1161`); `refreshUpgradeButton` (`:1163-1165`, called from `App.swift:454` and `UpgradeTab:214`).
|
|
- `windowDidBecomeKey` → `LicenseManager.refreshState` + `UpgradeTab.refreshStatus` + shine animation (`:1243-1254`); `UpgradeTab.cleanup` in `windowWillClose` (`:1266`).
|
|
- Settings-search registers `ProBadgeView → "Pro"` (`:679-680`); `refreshEmailTooltip` + `hasSecondLine` read `.pro`/`customerEmail` (`:196,206-207`).
|
|
|
|
### Other Settings / secondary windows (REWRITE)
|
|
|
|
- `SidebarList.swift` — `private var proBadge: ProBadgeView?` (`:93`) and `setProBadge` instantiating `ProBadgeView` (`:244-258`).
|
|
- `UpgradeTab.swift` — delete the whole tab; **`ProHeroButton: ProGradientButton` (`:493`) + `usageHero`/`heroButton`/`makeHeroButton` (`:5-6,37-38,160-161`); `activateLicense`/`deactivateLicense`/`deactivateInstance` + `LicenseAPIError.seatLimitExceeded` seat-management UI (`:322-405`) — full activation UI, §4 listed only `LicenseManager`/`RemoteLicenseClient`.**
|
|
- `DebugProfile.swift:17` — `LicenseManager.shared.state.debugProfileLabel` (extension at `LicenseState.swift:14`). **This feeds `FeedbackWindow`, which is being kept — hand-edit (drop the License line), do not delete the file.**
|
|
|
|
### `src/debug/QAMenu.swift` (REWRITE)
|
|
|
|
- The DEBUG manual triggers/resets (`:170-241`) directly instantiate every Day-X window and poke `LicenseManager`/manager flags — must be removed/updated in lockstep or the **DEBUG build won't compile.**
|
|
|
|
### Build / distribution / telemetry (REWRITE / FLAG — see [`08`](audit/08-build-release-distribution.md), [`09`](audit/09-telemetry-and-upstream.md))
|
|
|
|
- `Endpoints.swift` — `licenseApiBaseUrl` (`:11`), `checkoutUrl` (`:9`), `accountUrl` (`:10`) become dead. `feedbackUrl` (`:12`) and `supportUrl` (`:8`) are not paywall — owner decides.
|
|
- **Identity (mandatory regardless of removal):** the upstream Developer ID cannot be used. Change bundle id (`config/base.xcconfig:4`; also hardcoded `Mocks.swift:133` and unit-test ids `project.pbxproj:2477/2542`), signing cert (`release.xcconfig:5`), generate a NEW Sparkle EdDSA keypair (`Info.plist:62 SUPublicEDKey` + CI `$SPARKLE_ED_PRIVATE_KEY`), repoint feed/download/website URLs (`base.xcconfig:20-21`, `update_appcast.sh:16,18`, `App.repository` `App.swift:16`), truncate the upstream-signed `appcast.xml`.
|
|
- **AppCenter removal touches the app's principal class:** `App` subclasses `AppCenterApplication` (`App.swift:7`), `Info.plist:36-37 NSPrincipalClass=AppCenterApplication`, `alt-tab-macos-Bridging-Header.h:1`. Removing AppCenter requires choosing a replacement principal class / reverting to `NSApplication`, or **the app won't launch and won't compile.** Also: `src/vendors/AppCenterCrashes.swift`, `Secrets.appCenterSecret`, `scripts/upload_symbols_to_appcenter.sh`, `ci_cd.yml:56`.
|
|
- `.github/FUNDING.yml` and the website repository-dispatch (`update_website.sh:5 → lwouis/alt-tab-website`) point at upstream — repoint or remove.
|
|
|
|
### `alt-tab-macos.xcodeproj/project.pbxproj` — **mechanical but build-fatal (REWRITE)**
|
|
|
|
Every `src/pro` file (and `Endpoints`/`Secrets`/`AppCenter`) has a `PBXBuildFile` + `PBXFileReference` + `PBXGroup` child + `PBXSourcesBuildPhase` membership (~102 in-Sources pro entries; app phase ~1830, test phase ~1902; incl. `PreferenceDefinition.swift:1979`). **`Clock`/`Keychain`/`LicenseAPI`/`MachineFingerprint`/`ProTransitionManagerTestable` each have TWO build-file UUIDs (app + test) — both must be removed.** Any file deleted on disk but left referenced breaks at project-load/link.
|
|
|
|
### Test target (REWRITE)
|
|
|
|
`LicenseManagerTests`, `ProTransitionTests`, `ProTransitionManagerTestable`, `UsageStatsMessageTests`, `ProBadgeViewSegmentTests`, and `_test-support/Mocks.swift` (ProBadgeView stub + `ShortcutStylePreference` mock + bundle id `:133`) all break if pro symbols vanish — update/remove in lockstep.
|
|
|
|
### Build-break risk summary (the hard dependencies)
|
|
|
|
| Symbol being deleted | Non-pro / under-weighted consumers that break first |
|
|
|---|---|
|
|
| `LicenseState` enum | `Menubar.swift:357` (`updateContent` param), `DebugProfile.swift:17` (`.debugProfileLabel`) |
|
|
| `LicenseManager.shared` | `SettingsWindow.swift:162,164,167,196,206-207,1246`; `UpgradeTab.swift:220-405` |
|
|
| `ProGradientButton` (superclass) | `UpgradeButton` (`SettingsWindow.swift:133`), `ProHeroButton` (`UpgradeTab.swift:493`), `ProGradient.makeLayer` (`Menubar.swift:247`) |
|
|
| `ProBadgeView` | `SidebarList.swift:93,254`, `SettingsWindow.swift:679`, Appearance/Controls/ShortcutsWhenActiveSheet tabs |
|
|
| `ProGatedPreferences` / `PreferenceDefinition` | `Preferences.swift:129,130,155`; also a test-target member → fails app + test compile |
|
|
| `AppCenterApplication` | `App.swift:7` superclass + Bridging-Header + `Info.plist` NSPrincipalClass |
|
|
| `Day1WelcomeLetterWindow` | `App.swift:213` first-launch deferral observer |
|
|
| `ProTransitionState.markFreshInstallIfUnknown` | `PreferencesMigrations.swift:20` (real symbol) |
|
|
|
|
---
|
|
|
|
## 6. Notable findings, risks, and open questions
|
|
|
|
### Findings
|
|
|
|
- **The paywall is well-isolated.** Outside `src/pro/`, the entire spine coupling is ~13 lines in `App.swift` (`:448-460`) plus discrete blocks in `Menubar`, `Settings`, and the gating layer. The launch path, switcher kernels, macOS hooks, threading, and core preferences are all paywall-free.
|
|
- **Defaults already hold Pro values.** Removing the gate auto-unlocks features with no default changes (`appearanceSize=.auto`, etc.).
|
|
- **`.proExpired` is effectively dead code** in this build: `versionLimitedVariants` is empty (`LicenseManager.swift:28`), so no `.pro` variant ever expires by version.
|
|
- **No third-party analytics SDK.** Only telemetry is AppCenter Crashes (opt-in, EoL); `UsageStats` is local-only and never networked.
|
|
|
|
### Risks
|
|
|
|
- **In-place upgrade data loss.** Users who passed trial expiry on the paywalled build have stored prefs already downgraded to free equivalents, with the original Pro index parked in `proTransition.remembered*` keys (in the `.license` UserDefaults suite). After removal, `read()` no longer restores them → silent loss of their prior Pro selection. A **new bundle id** (clean UserDefaults domain) sidesteps this; otherwise add a one-time migration copying `remembered*` indices back to base keys before deleting `proTransition.*`.
|
|
- **`state.didSet` fires `onStateChanged`.** If you hardcode `state = .pro` at init, ensure the hook tolerates being invoked (or unset it) — otherwise it can re-enter paywall code you intend to delete.
|
|
- **Keychain/signing invariant is MOOT for a free build.** Removing `src/pro` removes all Keychain license usage, so changing bundle id / Developer ID has nothing to orphan — but the **identity change is still mandatory** because the upstream Developer ID cannot be used. (Per [`AGENTS.md`](../AGENTS.md), do not rotate identity while licensing is retained.)
|
|
- **Do NOT `git revert 9147a4a8`.** It mixes the paywall with a 1869-file folder reorg; removal must be by deleting `src/pro/` and untangling the wiring, then confirming the build links (`ai/build.sh`).
|
|
|
|
### Open questions (owner decisions)
|
|
|
|
1. **New bundle id vs in-place?** Determines whether the `remembered*` migration above is needed.
|
|
2. **Keep "Support this project"** (donation link, upstream precedent) and the feedback feature? Both are non-paywall but currently route through paywall-adjacent code / upstream endpoints (`Endpoints.supportUrl`, `Endpoints.feedbackUrl` → `alt-tab.app`, whose server side is not in this repo).
|
|
3. **Auto-update story.** A fork cannot ship updates against the upstream `SUPublicEDKey` / `alt-tab.app/appcast.xml` without owning that domain and lwouis's private key. Stand up own host + EdDSA key, or strip Sparkle.
|
|
4. **Keep crash diagnostics?** Remove AppCenter entirely (most privacy-aligned, but requires a replacement principal class) vs repoint to a self-owned AppCenter app (EoL).
|
|
5. **Rebase on pre-paywall v10.12.0** vs in-place removal — both yield the same unlocked feature set.
|
|
|
|
---
|
|
|
|
*Deep-dive sections: [`01-core-architecture`](audit/01-core-architecture.md) · [`02-switcher-ui`](audit/02-switcher-ui.md) · [`03-preferences-and-gating`](audit/03-preferences-and-gating.md) · [`04-license-subsystem`](audit/04-license-subsystem.md) · [`05-trial-nag-scheduling`](audit/05-trial-nag-scheduling.md) · [`06-profeature-and-copy`](audit/06-profeature-and-copy.md) · [`07-pro-ui-and-menubar`](audit/07-pro-ui-and-menubar.md) · [`08-build-release-distribution`](audit/08-build-release-distribution.md) · [`09-telemetry-and-upstream`](audit/09-telemetry-and-upstream.md)*
|