Point DOMAIN at the repo's GitHub Pages site (serves the gh-pages appcast.xml), enable SUEnableAutomaticChecks, and remove the in-tree appcast skeleton (feed is published out-of-tree). Ships an auto-updating build.
Reset .fork-sync-state to v11.3.0 (last-synced upstream tag) and remove the release-tail step that overwrote it with the fork release tag, which would stall upstream-sync. Update SESSION-STATE/TODO/CICD-STATUS/README for the live signed+notarized v100.1.0 release.
Set the fork Developer ID in config/local.xcconfig (Manual style + team), re-sign the vendored Sparkle helpers under it, and import the cert with an optional APPLE_P12_PASSWORD. Built, signed, and notarized (Accepted) locally before cutting.
Release build inherited upstream's Developer ID and failed on CI (exit 65, no cert). Build ad-hoc when the release gate is not signing-ready; build_app.sh now forwards xcodebuild overrides. Auto-switches to signed when the owner adds the cert.
Base strings drifted from source after the About redesign and brand sweep; regenerating via genstrings unblocks the generated-files CI gate. Dead paywall strings stay upstream-branded to keep merge surface minimal.
AXObserverAddNotification's refcon was always nil. Bake the owning identity into it at subscribe time so the AX callback gets (pid, wid) for free, instead of an element.pid()/cgWindowId() round-trip per event. Window-level subscriptions carry a real wid; app-level subscriptions carry wid 0, so focus/main/created still resolve their per-event subject window from the element. Notably this gives a reliable wid for kAXUIElementDestroyed, which previously had to query an already-dead element.
Async AX events (focused-window changed, app activated) can fire during
the switcher's show path and reorder Windows.list under the panel. The
old updateSelectedWindow short-circuited to the initial-pick path
whenever the lowest-lastFocusOrder window changed mid-show, throwing
away the user's selectedTarget and re-picking the default. Visible slot
swap plus highlight jump; if the user released keys early they landed
on the wrong window.
Extract a pure-function kernel (SelectionResolver) covering the full
selection state machine and replace updateSelectedWindow with a thin
snapshot/decide/apply wrapper. The actual fix is dropping the
focusedWindowChangedWhileShowing arm of shouldSelectFromScratch: the
list still reorders (Photoshop finishing launch is fresh state the
user wants reflected), but the existing target-by-id lookup re-resolves
the user's pick at its new index instead of being skipped.
28 new SelectionResolverTests lock down every selection path: initial
pick rules, target preservation across reorders (#5665 regressions),
target removal, search-mode flags, edge cases.
Also adds minimal Appearance and SettingsSearchIndex stubs to Mocks.swift
so the unit-test target builds. ProBadgeView and LightLabel referenced
those types without the underlying source being in the test target.
closes#5260
appcenter showed that access to debugId() from main and non-main threa could crash the app. Making debugId a string that's set once at init simplifies everything and allows concurrent access from threads
* fixes issues when the user has monitors with different DPI
* fixes issues where screenshots after a window is resized could be wrong (because of cached SCWindow having stale frame.size)
BREAKING CHANGE: Better thumbnails with ScreenCaptureKit for users on macOS >= 14
* No more WindowServer memory leaks (closes#4255)
* No more purple UI from macOS bothering users
* Fullscreen thumbnails contain top part (closes#3652)
* Thumbnails contain dialogs (e.g. Save/Print panel)
* Show thumbnails for off-screen windows
* Memory usage drastically reduced (e.g. don't save full-resolution if Preview feature is not used)
* Limitations of ScreenCaptureKit compared to private API: can't capture minimized/hidden/tabbed windows
The `sortByLevel` method in the Windows class has been updated to determine window z-order properly across all visible spaces. It now sets the `lastFocusOrder` for each window, using a map from window IDs to their z-order position. Windows not present in the list are set to have `lastFocusOrder` of `Int.max`. Afterwards, windows are sorted by their `lastFocusOrder`, and these values are ensured to be monotonic, starting from 0.
closes#351
BREAKING CHANGE: The old preferences panel has been replaced with a brand new one
Preferences are now much simpler, full of visual illustrations, and should provide a much better experience. Pick between 3 styles (thumbnails, app-icons, titles), sizes, dark/light themes, high visibility options, and more!
I am able to reproduce the issue by reinstall JetBrains AppCode. The first time I open AppCode after reinstall it, AltTab never show AppCode window. I debug the code on my Mac, I find that AltTab can get the event of app launched but never get the event of finishedLaunching. So I just remove the code runningApplication.isFinishedLaunching. After that, AltTab work well and the issue is fixed.
* user was able to set a nextWindowShortcut which contained modifiers from holdShortcut
* detection of conflicts was incorrect in some cases, mostly because holdShortcut and nextWindowShortcut should be treated as 1 shortcut
* after the user forces a shortcut to apply, resetting the existing one with the same keys, it wouldn't save to UserDefaults
* forcing the holdShortcut to replace another holdShortcut would reset it, even though holdShortcut should always be set with some value
Stacktrace:
function signature specialization <Arg[1] = Dead> of generic specialization <[__C.AXUIElementRef]> of (extension in AltTab):__C.AXUIElementRef.attribute<A>(Swift.String, A.Type) throws -> A? AXUIElement.swift:36
BREAKING CHANGE: the window thumbnails are now updated *after* the UI is shown. AltTab will first display its window, with the first 3 thumbnails up-to-date, then asynchronously update the rest of the thumbnails one-by-one. This improves the experience of users with lots of windows open.
BREAKING CHANGE: the previous keyboard support implementation was not working if any app on the system activated Secure Input and didn't turn it off. This is a major hurdle for most global shortcut apps. This update introduces a new implementation which is unaffected by Secure Input. AltTab shortcuts should now work reliably
* also fix issue where clicking a thumbnail would give alt-tab focus, removing it from the app currently focused
* also use click on a thumbnail to focus it instead of only mouse-down
NSRunningApplication.init(processIdentifier: pid) sometimes returns nil for Chrome, even though I see that the pid exist in Activity Monitor and is actually Chrome. I think the constructor is thread-unsafe, and should be called from the main thread only, thus I refactored to make that possible
BREAKING CHANGE: this rework should fix all sorts of issues when OS events happen in parallel: new windows, new apps, user shortcuts, etc. Here are example of use-cases that should work great now, without, and very quickly:
* AltTab is open and an app/window is launched/quit
* A window is minimized/deminimized, and while the animation is playing, the user invokes AltTab
* An app starts and takes a long time to boot (e.g. Gimp)
* An app becomes unresponsive, yet AltTab is unaffected and remains interactive while still processing the state of the window while its parent app finally stops being frozen
closes#348, closes#157, closes#342, closes#93
some apps start with .activationPolicy == .prohibited and then transition to != .prohibited later on. We now observe that attribute evolution, instead of hardcoding exceptions for some apps. For the initial filtering of processes, we now takes every process that is not an XPC process, as all others could spawn windows down the line
the issue with netlify building on master changes is that semantic-release creates a second commit with the updated changelog. Netlify only triggers on the first commit, with an obsolete changelog. The second commit doesn't trigger Netlify, so we must manually trigger it using their webhook. This way the changelog built is *after* semantic-release, with the latest releases
complete italian, turkish, korean using automated translation
fix punctuation issues and make punctuation language-specific instead of hardcoded to English style
* Replaced the NSCollectionView with a custom NSView. Benefit includes simpler code, and control over recycling logic.
* In cases where the user has many open windows, replaced the downscaling strategy with a scrollable view
* Added a preference: align thumbnails to the left or center
There is a risk of not showing some windows, like CopyQ, but the reward is less risk of trying to observe some buggy daemon process that will never succeed
XCode project/workspace/configurations/target/pods have been in a mess for a while. Some changes done to address that:
* remove alt-tab-macos/ which was confusing
* ci/ becomes scripts/ as these can be used locally too
* added 3 xcconfig files to set aside important parameters so we stop passing flags around or modifying the huge pbxproj
closes#93closes#24closes#117
BREAKING CHANGE: Instead of asking the OS about the state of the whole system on trigger (what we do today; hard to do fast), or asking the state of the whole system on a timer (what HyperSwitch does today; inaccurate) - instead of one of 2 approaches, v3 observes the Accessibility events such as "an app was launched", "a window was closed". This means we build a cache as we receive these events in the background, and when the user trigger the app, we can show accurate state of the windows instantly.
Of course there is no free lunch, so this approach has its own issues. However from my work on it from the past week, I'm very optimistic! The thing I'm the most excited about actually is not the perf (because on my machine even v2 is instant; I have a recent macbook and no 4k displays), but the fact that we will finally have the thumbnails in order of recently-used to least-recently-used, instead of the order of their stack (z-index) on the desktop. It's a big difference! There are many more limitations that are no longer applying also with this approach.
More context: https://github.com/lwouis/alt-tab-macos/issues/45#issuecomment-571898826
Also closes#11closes#45closes#62
BREAKING CHANGE: this brings huge changes to core parts of the codebase. It introduces the use of private APIs that hopefully are should be compatible from macOS 10.12+, but I couldn't test them. I reviewed the whole codebase to clean and improve on performance and readability
implements requested changes from Code-Review and contains also refactoring, additions & improvements
- PreferencesPanel: does not handle NSApp.activate & .deactivate anymore
- PreferencesPanel: maxThumbnailsPerRow & windowDisplayDelay are now sliders
- PreferencesPanel: Sliders do not use tickmarks anymore (to allow finer settings)
- PreferencesPanel: tweaked Sliders min/max values
- PreferencesPanel: adds Hyperlink to KeyCodes Reference as suffix for Tab key control (with additional required sub-class of NSTextField)
- PreferencesPanel: adds makeSuffix() method for code clarity
implements requested changes from Code-Review and contains also refactoring and more features and improvements
- Application Wide: changes all exceptions to NSError (required for error handling via sheet in panel)
- PreferencesPanel: adds display of current value of Sliders
- PreferencesPanel: adds validation with visualisation for TextFields (whitelist for KeyCode and ranges for maxScreenUsage & windowDisplayDelay) during editing, window open and close
- PreferencesPanel: adds error sheet (in-window modal) at all known possible errors to display error information and ask the user how to proceed
- PreferencesPanel: removes Restart button (and relaunch code in Application)
- PreferencesPanel: adds Preferences being now live
- PreferencesPanel: removes invisibleText workaround
- PreferencesPanel: adds TextField value save on each keyDown event (if valid and changed)
- PreferencesPanel: adds window activation/deactivation
- PreferencesPanel: changes label texts of some Preferences
- ...
- ux/code: adds sliders (maxScreenUsage, iconSize, fontHeight)
- ux: defined sensible min/max/step values for sliders
- code: fixes panelWidth (panel and its content did not grow correctly)
- code: removes inputsMap (NSControl.identifier is now used for that)
- code: adds makeLabelWithProvidedControl() as a helper to create labeled controls with a optional suffix
- code: removes dropdownDidChange() & textDidEndEditing() -> combined in controlDidEndEditing()
- code: adds controlDidEndEditing() as an universal .action for NSControl elements
- adds a restore of values in UI and Preferences if there is an issue while saving a Preference
- code: adds another little code clarity improvements
- ux: first pass on UI
- ux: groups available preferences by keys-, visual-, behaviour-preferences (vertical separation without labels)
- ux: adds Restart action
- code: removes unused variables
- code: PreferencesPanel is no longer a Delegate of an view child
squash of pull-request corrections and improvements
refactor: better mimic apple centering
Also use enum instead of bool to self-document the interface
docs: clarify the show-on-screen preference wording
Discussion on this SO (https://stackoverflow.com/a/864432/2249756) seem to indicate that .main is not the screen with the menubar, however in my tests, it is always that window. I think macOS changed the behavior of .main without updating the documentation
refactor: shorter method names as swift promotes + removed debug code
fix: all methods in screen should use visibleFrame not frame
refactor: (hopefully) made code intent clearer
fix: panel and collection layout now use dynamic screen variable
fix: fix vertical origin from 4c44100f196
squash of implementation for pull-request #66 and review
- adds a new Preference to use the main screen (keyboard focus) or mouse location screen
- honors that preference for thumbnail generation and display of all panels
- increases the Preferences version
- widens PreferencesPanel width and input sizing to allow 3 characters, issue #59 minimal adjustment
feat: windows on mouse screen: changes from pull request #66 code review
- corrections, improvements & refactoring
feat: windows on mouse screen: additional changes for pull request #66
- Screen.preferredScreenOverride: removed
- instead currentScreen gets provided or updated now at all required places before triggering UI summon calculations
- ThumbnailsPanel.computeThumbnails(): re-creates collectionViewLayout instead of invalidating it
- Screen.showCenteredFrontPanel(): useAppleVerticalOffset applies now only if the NSWindow frame is smaller than 2/3 of visibleFrame
metaKeyCodes is now an array and is provided with both keycodes for option, control and command. This fixes two issues:
- UI stayed open when the user did open it with the secondary metaKey keyCode (the UI got toggled by the modifierFlags check) and the user had to click the proper metaKeyCode to close it again
- PC keyboards (all of them? i observed this on a Logitech K740) send the secondary keyCode for the command and option keys so the application did not work for this setup
By listening to keyboard events from another thread, that thread is able to cancel tasks scheduled on the main UI thread. This means that if the user activates the app, and very quickly release the keys to focus the other window, that key release event may be processed before the main UI thread tasks have finished. The keyboard processing thread may then cancel the UI work resulting in the UI not showing, which is good here
description:Audit and optimize every image asset shipped with AltTab. Apply the right format per asset class (PDF for vectors, HEIC for raster) and the right post-processing (strip Figma cruft from PDFs, extract SF Symbols as minimal vector PDFs, encode raster sources to HEIC at q50 with visual review). Use whenever new assets are added, when the bundle size needs shrinking, or whenever you want a full assets audit.
---
# /assets-optimization — AltTab asset audit and optimization
## Goal
Every byte that ships in `AltTab.app/Contents/Resources/` should justify itself. Vectors stay vector, rasters compress to HEIC, and neither carries metadata, color profiles, accessibility tags, or producer signatures that AppKit doesn't use.
This skill applies a known-good pipeline to each asset class. It is opinionated about the right format and the right encoder for each kind of content.
## When to use
- A designer drops new exports into `~/Desktop/` or `resources/`.
- Someone asks "why is the bundle so big?".
- After adding a new icon, illustration, app icon variant, or menubar variant.
- Periodic audit when nothing else is broken.
## Step 1: Inventory
Run a one-shot enumeration so you know what you're working with:
-`resources/icons/app/` — the macOS app icon (`.icns` + `.iconset/`). **Don't touch** — `.icns` is required by the bundle and Apple's tooling produces near-optimal output already.
-`resources/illustrations/` — the appearance-tab preview thumbnails. Raster (screenshots inside).
For each asset, decide what category it falls into:
| Source content | Right format | Why |
|---|---|---|
| Custom vector design (Figma/Sketch/Illustrator) | **PDF** | macOS 10.13 doesn't accept SVG; PDF is the universal vector container AppKit reads natively. |
| SF Symbol (Apple system glyph) | **Font glyph** in the bundled `SF-Pro-Text-Regular.otf` subset | Render via `NSImage.fromSymbol(.foo, pointSize:)` (or as text via the `Symbols` enum). Smaller than per-icon PDFs, picks up Apple's latest glyph refinements automatically when the developer updates SF Symbols.app. |
| Photographic / screenshot-heavy | **HEIC** | HEIC at q50 beats JPEG by ~30% at the same perceptual quality. |
| Tiny pixel-precise UI sprite | **PNG @2x** | Below ~40×40px the PDF overhead exceeds the bitmap savings. PNG wins. |
| App icon (the macOS bundle one) | **`.icns`** | Required by `CFBundleIconFile`. |
If an asset is in the wrong format, flag it. If it's in the right format but unoptimized, run the matching pipeline below.
## Step 2: Vector PDFs — Figma exports
Figma's "Export → PDF" output is bloated. For each menubar/illustration/icon vector PDF that came from Figma, you can strip ~50–75% of the bytes without losing a single rendered pixel.
What Figma adds that AppKit doesn't need:
1.**Embedded ICC color profile** (`/ICCBased ...`, ~3.2 KB compressed). Replace every `[/ICCBased N R]` reference with `/DeviceRGB` (or `/DeviceGray` for monochrome). Patches needed in:
- the page `Resources/ColorSpace` dict
- every Form XObject's `Resources/ColorSpace` dict (these are streams, not plain dicts — pikepdf's `pdf.objects` will only catch them if you accept both `Dictionary` and `Stream`)
- every Image XObject's direct `/ColorSpace` key
- every Shading dict inside `Pattern` entries (Figma's color icons use 8+ patterns, each with its own `/ColorSpace N R` reference)
2.**`/Metadata`** XMP packet (~830 B) — Figma's XML manifest.
4.**`/Info` dict** — `Producer="Figma"`, `Title="Menubar 22x22@1x white"`. In Figma's exports the Info dict sometimes lives **inline inside the Catalog** rather than at the trailer level, so deleting `pdf.docinfo` isn't enough — also `del root[Name('/Info')]`.
It edits in place and prints the savings per file. After running, also pipe through `mutool clean -ggg -z` and `qpdf --object-streams=generate --recompress-flate --compression-level=9` for the final 1–2% squeeze.
Verify each file still renders by sips'ing it back to PNG and eyeballing:
Open the PNGs in Preview to confirm nothing visual changed.
## Step 3: SF Symbols via font subset
Every SF Symbol shipped in AltTab — switcher status icons, sidebar tab icons, button icons, permission/feedback icons — is rendered as a text glyph from a subsetted SF Pro Text font. There are no SF-Symbol PDFs in the bundle.
How it works: SF Symbols are glyphs in the Private Use Area of SF Pro Text. Apple's `SF-Pro-Text-Regular.otf` contains every symbol they've ever shipped. We subset it down to just the codepoints AltTab needs (currently ~36 glyphs, ~17 KB) into `resources/SF-Pro-Text-Regular.otf`, and register it via `Info.plist:ATSApplicationFontsPath = ""`. At runtime, `NSFont(name: "SF Pro Text", size:)` resolves to the bundled subset on macOS <11 (where the system font isn't installed) and to the system font on macOS 11+, with identical glyph appearance either way.
To add a new SF Symbol:
1. Open [SF Symbols.app](https://developer.apple.com/sf-symbols/), search for the symbol, press **Cmd-C** to copy the symbol character to the clipboard. (Apple's name→codepoint mapping is not exposed via public API, and the SF Pro Text font's cmap uses `uniXXXXXX.medium`-style names rather than semantic ones, so this manual lookup is the authoritative path.)
2. Paste the character into a new case on the `Symbols` enum in [src/switcher/main-window/TileFontIconView.swift](src/switcher/main-window/TileFontIconView.swift) — e.g., `case foo = "" // SF Symbol name`.
3. Paste the same character at the end of the `--text=` argument in [scripts/assets/subset_font.sh](scripts/assets/subset_font.sh).
4. Run `bash scripts/assets/subset_font.sh`. It reads `/Library/Fonts/SF-Pro-Text-Regular.otf` (installed by SF Symbols.app — a standard developer prerequisite) and writes the regenerated subset to `resources/SF-Pro-Text-Regular.otf`. Picks up Apple's latest glyph refinements automatically.
5. Use it in code: `NSImage.fromSymbol(.foo, pointSize: 14)` returns a template `NSImage`; or `TileFontIconView(symbol: .foo, ...)` for the cached-attributed-string path in the switcher hot loop.
The script runs `pyftsubset` via the project's pipenv environment. Warnings about `MERG`/`meta`/`trak` tables being dropped are normal — those tables aren't relevant to glyph rendering.
### Historical note: SF Symbols via PDF (deprecated, scripts removed)
A previous pipeline shipped each SF Symbol as a per-glyph PDF in `resources/icons/`. The pipeline lives only in git history now (`scripts/assets/export_sf_symbol_pdf.swift` was deleted alongside the migrated PDFs). The technique is worth knowing in case a future need arises (e.g., a multi-color symbol that fonts can't represent):
- The naive route — `NSImage(systemSymbolName:).draw(in:)` against a PDF `CGContext` — produces a **black rectangle**, because Quartz emits `image-mask + fill-rectangle` operators where the rectangle paints over the mask. AppKit bug at the PDF emission level; `paletteColors` config does not fix it.
- The working route was to extract the symbol's vector path directly via private selectors that have been stable across macOS 11–15:
- The CGPath lives in CUI's internal coordinate space (~2× display points, Y-down) — scale to fit the canvas and flip Y. Walk the path via `CGPath.applyWithBlock` and emit raw PDF operators (`m`, `l`, `c`, `h`, `f`). Non-zero winding fill. Fill with DeviceGray (`0 g`), **not**`NSColor.black.cgColor`, which drags in a ~3.4 KB ICC color profile.
- Final PDF wrapper: 4 objects (Catalog, Pages, Page, Content), no `/Info`, no `/Metadata`, no `/Resources/ColorSpace`. ~750–1800 bytes per icon.
Git: see commit `990c1e79` ("feat: pro improve assets") for the PDF pipeline as-it-was; subsequent commit migrated the SF-Symbol PDFs back to font glyphs.
## Step 4: Raster → HEIC at q50
For anything raster (illustration thumbnails, screenshots inside an icon, anything photographic), HEIC at quality 50 is the baseline. q50 is roughly 50% smaller than JPEG at perceptually-equivalent quality, and at the small display sizes used in this app the artifacts are invisible.
-`-Z 1000` resizes the longest edge to 1000px **preserving aspect ratio**. AltTab's illustration display is 500pt wide, so 1000px is the correct @2x ship size. Anything larger wastes bytes; anything smaller looks soft on Retina.
-`formatOptions 50` is the quality. q50 was chosen after a side-by-side comparison at q20/q35/q50/q65/q80 — q50 was the lowest setting where text in screenshots stayed legible and gradient backgrounds didn't band.
That walks all PNG/JPEG files in the source directory, resizes to longest-edge 1000px at q50 HEIC, writes outputs to `/tmp/heic-out/`.
### Visual review (mandatory)
Before swapping the new HEICs into `resources/`, **always** decode a representative sample back to PNG and visually compare against the source:
```sh
sips -s format png /tmp/heic-out/sample.heic --out /tmp/sample-decoded.png >/dev/null 2>&1
open /tmp/sample-decoded.png /Users/you/Desktop/sample.png
```
Pick the visually most demanding file from the batch — usually one with the most text or the strongest gradients. Confirm:
- No banding in flat color regions
- Text edges still crisp at native display size
- No haloing around anti-aliased edges
- Color rendition matches
If anything looks degraded, bump quality to q60 or q65 and re-batch. The user, not the script, is the final arbiter — show them the sample with sizes before committing.
### Bumping quality
If q50 isn't acceptable, the next quality steps are q60 and q65 — beyond that, returns diminish quickly. q80 is the previous default in this repo and roughly 2× the bytes of q50 for no visible improvement on AltTab's content.
## Step 5: pbxproj registration
Whenever you change the file extension of a resource (.jpg → .heic, .png → .pdf, etc.), update [alt-tab-macos.xcodeproj/project.pbxproj](alt-tab-macos.xcodeproj/project.pbxproj). The places that need patching:
3.**PBXGroup section** — the file's entry inside its parent group's `children`.
4.**PBXResourcesBuildPhase section** — the entry in the main app target's `files`.
For pure extension swaps (no new files), `sed -i '' 's|old\.ext|new.ext|g'` plus a `lastKnownFileType` substitution covers it. For new files, generate new 24-char uppercase-hex object IDs (`python3 -c "import secrets; print(secrets.token_hex(12).upper())"`) and insert in all four places.
If you removed an asset entirely (file deleted from disk), delete its 4 entries from pbxproj — otherwise the build fails with "missing file" or ships dangling references.
## Step 6: Verify
```sh
bash ai/build.sh # must show ** BUILD SUCCEEDED **
bash ai/run.sh # launch the app and visually inspect every asset
```
Walk through every UI surface that loads an asset:
- Menubar icon (default + the two alternates from Preferences → General → Menubar icon)
- Preferences sidebar — 4 tab icons (SF Symbol on macOS 11+, bundled PDF below)
- Permissions window — open by revoking a permission
description:Audit the AltTab test suite for spec/test/code consistency. For every co-located triad in `src/` (`Foo.swift` + `FooSpecs.md` + `FooTests.swift`), it cross-checks that each XCTest method has a matching scenario line in the spec and vice-versa, flags drift between a scenario's description and the test's doc-comment, and reports orphan tests, orphan scenarios, and unpaired files. Use after adding or editing any `*Tests.swift` / `*Specs.md`, before committing a test change, or to get an overall health check of the suite.
---
# /audit-specs-tests
## Project context
AltTab co-locates tests and a living spec next to the source they cover. The convention (see the plan in `.claude/plans/` and `src/pro/license/` as a worked example) is a **triad** per concept:
```
src/<feature>/
├── Foo.swift # production / kernel (app target, or app+test for kernels)
The spec is **documentation tied 1:1 to the test methods**. Its `## Test scenarios` section lists every test, grouped, as:
```markdown
### A. <Group name>
- **testAFooDoesBar** — one-line description of what it pins.
```
This skill's job is to keep the three in sync: every `func test…` ⇔ exactly one `- **test…** —` spec line, descriptions roughly agree, and no triad is missing a leg.
## What counts as a triad
Pair by base name in the **same folder**: `<Base>Tests.swift` ⇔ `<Base>Specs.md`. A `<Base>.swift` (or a kernel like `SelectionResolver.swift`, `SearchTestable.swift`) in the folder is the code under test, but a spec may legitimately cover several classes (e.g. `LicenseManagerSpecs.md` documents `Clock`/`Keychain`/`LicenseAPI` too) — so a missing same-name `.swift` is a note, not an error.
## Workflow
1.**Enumerate.** Find all test and spec files:
```sh
find src -name '*Tests.swift' | sort
find src -name '*Specs.md' | sort
```
Also list any test files still under `unit-tests/` and flag them as **not yet migrated** (the target end-state is zero test files outside `src/`).
2. **For each `*Tests.swift`, extract the test methods and their doc-comments.** Test names:
For each, capture the immediately-preceding `///` or `//` comment lines (the test's intent) and the enclosing `// MARK: -` group. Note methods wrapped in `#if DEBUG`.
3. **For each `*Specs.md`, parse the scenario index.** In the `## Test scenarios` section, collect every `### <Group>` heading and every `- **testName** — description` bullet. Build the set of spec-listed test names and their descriptions.
4. **Cross-check each triad and classify findings:**
- **Orphan test** — `func testX` exists but no `- **testX** —` line in the spec. (Most common drift; spec is stale.)
- **Orphan scenario** — spec lists `testX` but no `func testX` in the suite. (Test deleted/renamed; spec stale.)
- **Group mismatch** — a test's `// MARK:` group and its spec `###` group disagree (readability drift, not fatal).
- **Description drift** — the spec bullet and the test's doc-comment describe materially different behavior. Judge semantically, don't string-match; only flag real divergence.
- **Unpaired file** — a `*Tests.swift` with no `*Specs.md` (or vice-versa).
5. **Lighter code check (kernels only).** For decision-kernel files (`*Testable.swift`, `*Resolver.swift`), list `public`/`static` entry-point functions and `enum … Decision` cases, and flag any with no scenario referencing them — a hint that a branch is undocumented/untested. Skip this for AppKit-coupled production files (intentionally not unit-tested here).
6. **Optionally repair.** If asked, regenerate a spec's `## Test scenarios` section from the live test methods + their doc-comments (preserving the existing `### Group` order where it matches `// MARK:` sections). Never invent behavior — only restate what the test asserts. Do not edit `*Tests.swift` from this skill.
## Reporting
- A per-triad status table: `folder/Base` · #tests · #scenarios · status (✅ in sync / ⚠️ drift / ❌ unpaired).
- A punch list grouped by finding type (orphan tests, orphan scenarios, description drift, unpaired, undocumented kernel branches), each with the file + line.
- The count of test files still outside `src/` (migration progress).
- If nothing is wrong, say so plainly. Then offer to fix the drift (regenerate stale spec scenario indexes) and list exactly which files would change.
description:Measure AltTab test coverage and record it as documentation. Runs the Test scheme with coverage into a throwaway result bundle, parses the per-file/region report, refreshes the per-feature badge atop each `*Specs.md` and the root `src/coverage.md` table, then suggests the highest-value missing tests. These two markdown outputs are the ONLY stored coverage — no lcov/html/xcresult is kept, and routine test runs don't gather coverage. Use to refresh coverage docs, find untested kernel branches, or decide what to test next.
---
# /coverage-explore
## Project context
AltTab follows the **Humble Object** pattern: logic lives in dependency-free **decision kernels**
(`SelectionResolver.swift`, `SearchModeResolver.swift`, the `*Testable.swift` files) compiled into the
`unit-tests` target and tested in isolation; the AppKit layer (windows, views, event taps) is kept thin
and is deliberately *not* unit-tested. So coverage is meaningful **per kernel / per feature-source** —
those should trend to ~100%, every decision branch exercised — and an aggregate app-wide % is not a
goal.
**Coverage is documentation here, not a build artifact.** The only stored coverage is:
1. the per-feature badge (`> **Line coverage:** …`) atop each `*Specs.md`, and
2. the root **`src/coverage.md`** table.
There is intentionally **nothing else**: no lcov, no HTML, no committed `.xcresult`, and coverage is
**not** gathered on routine runs (`scripts/run_tests.sh`, CI, or Xcode) — only when this skill runs it.
(Coverage isn't usefully viewable in IntelliJ, so there's no IDE-integration artifact to maintain.)
## Workflow
1.**Measure into a throwaway bundle** (then delete it — store nothing technical):
```sh
rm -rf /tmp/altcov.xcresult
set -o pipefail && xcodebuild test \
-project alt-tab-macos.xcodeproj -scheme Test -configuration Release \
description:Translate missing l10n strings for AltTab using Claude's multilingual ability. Refreshes the source `Localizable.strings` from current Swift code, finds keys missing in each `<lang>.lproj/Localizable.strings`, translates them, then writes them back via `scripts/l10n/apply_translations.ts`.
---
# /translate-missing-l10n
## Project context
AltTab is a macOS app that helps switch between windows, similar to the Windows alt-tab experience. The strings to translate are mostly Settings UI — some tooltips, dialogs, and general user guidance.
(`en` is the source — handled automatically by the apply step.)
## Translation guidelines
- **Format specifiers must be preserved exactly**: `%@`, `%d`, `%1$@`, `%2$@`, `\n`, `\t`. The number and order of specifiers in the translation must match the source. Positional and unindexed forms are interchangeable (`%@ %@` ≡ `%1$@ %2$@`), but the count must match.
- **Use Apple's official platform terminology** for the target locale where one exists. For example, prefer the term Apple uses in macOS System Settings for that locale ("Réglages" vs "Préférences" in French) over a literal translation.
- **Match the source brevity**. Settings strings are short; the translation should be short too. Prefer concise, idiomatic phrasing over literal grammatical completeness.
- **Match macOS tone**: neutral, direct, no exclamation marks unless the source has them.
- **Comments in the source file are engineer guidance, not user-visible text**. Use them to disambiguate meaning, but never include them in the translation.
## Workflow
1.**Refresh the source.** Run:
```sh
bash scripts/l10n/extract_l10n_strings.sh
```
This regenerates `resources/l10n/Localizable.strings` from the current Swift code via `genstrings`.
2. **Parse the source.** Read `resources/l10n/Localizable.strings`. Each entry has the shape:
```
/* engineer comment */
"key" = "value";
```
Build the ordered list of `(comment, key, value)` triples. The `value` for the source is usually identical to `key`, but may include positional indices (`%1$@`, `%2$@`) when there are multiple specifiers.
3. **Compute missing keys per language.** For each of the 20 target languages, read `resources/l10n/<lang>.lproj/Localizable.strings`. Each line is `"key" = "translation";`. A key is **missing** if it's present in the source but either (a) absent from the target file, or (b) present with an empty or whitespace-only value. Treat (b) the same as (a) — produce a real translation.
4. **Stop if nothing is missing.** Report and exit.
5. **Translate.** For each language, produce a translation for each missing key, applying the guidelines above. Do not invent keys; only translate keys that appear in the source. Group your work into batches of 5–10 languages per call to keep individual outputs manageable.
6. **Apply each batch.** Write the batch to a fresh file under `/tmp` (not committed) with this shape:
- Validates format specifiers — translations whose specifier set doesn't match the source value are rejected with a clear error and **not merged**.
- Always rewrites `en.lproj/Localizable.strings` from source **keys** (each entry written as `"key" = "key";` for symmetry) — no need to include `en` in your batch. This avoids leaking genstrings-rewritten values like `%1$@` into the English file when the original source key uses plain `%@`.
- Rewrites each `<lang>.lproj/Localizable.strings` using source order: existing translations are preserved, new translations merged in, and keys no longer in the source are pruned.
- Exits with code `2` if any entries were rejected.
7. **Handle rejections.** If `apply_translations.ts` reports format-specifier mismatches, fix those translations and re-run only the affected language(s) in a follow-up batch. Do not move on while rejections are outstanding.
## Reporting
After the run, report:
- Number of languages processed.
- Total translations produced and merged.
- Any entries you intentionally left untranslated (e.g., the source was already a proper noun) — list them so the user can decide.
- Any format-specifier rejections that required manual fixes.
# Also flag if the merge left a conflict in any chokepoint file (the conflicted commit
# is HEAD; compare against its first parent HEAD~1 = the pre-merge master tip).
chokepoint_conflict=0
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -qE 'LicenseManager\.swift|ProFeature\.swift|PreferenceDefinition\.swift|ProTransitionManager\.swift|LicenseCookie\.swift|AppCenterCrashes\.swift'; then
Selected as the OLDEST upstream tag strictly newer than \`.fork-sync-state\` (§4.2).
Verify the guard.yml + ci_cd.yml PR checks are GREEN (anti-relock test + marker/conflict greps) before merge.
If checks did not appear, SYNC_BOT_TOKEN is mis-set to the default GITHUB_TOKEN — the gate is disarmed (§4.2).
Chokepoint-churn count for this merge range: ${CHURN} (nonzero => de-paywall chokepoint touched, see §0.1 trip-wire A).
NEVER close this PR to defer it — \`.fork-sync-state\` only advances on MERGE, so leaving it OPEN keeps this tag 'next'; closing it would silently skip the tag (§4.2 / §6.2)."
gh pr create \
--head "sync/${TAG}" \
--base master \
--title "sync: merge upstream ${TAG}" \
--body "$BODY" \
--label "$LABELS"
# §0.1 trip-wire A — auto-apply `chokepoint-refresh` from CI when a chokepoint file
# changed or conflicted, so the trip-wire tally never depends on a human remembering.
if [ "${CHURN:-0}" -gt 0 ] || [ "${CHOKEPOINT_CONFLICT:-0}" = "1" ]; then
- Use pure swift 5.8 code to make the app. No interface builder. No SwiftUI.
- Aim for compact code. Within methods, don't have groups of statements separated with newlines. No inline comments for simple code. Instead, split statements into sub-methods.
- Use guard closes as much as possible to separate the happy-path under them
- Organize source files into folders. Folders should group files that change together, at the same pace (e.g. one feature)
- Favor low latency and responsiveness. Reuse objects, avoid wasting memory or I/O.
# Workflow
- Copy commands from ai/build.sh and run them, to confirm compilation works after you're done with implementing a change
# License / Keychain invariant
- The app's Developer ID, TeamID, and bundle ID must remain stable across builds. Keychain items are tied to the code signature; changing any of these orphans every user's stored license key and forces mass re-activation. If a rotation is unavoidable, plan a migration first (e.g., a backup-restore handler, or `kSecAttrAccessGroup` with a stable group identifier).
- Do not introduce legacy `SecKeychain*` API or `kSecAccessControl` (biometric/PIN gating) into license code — both can trigger Keychain password prompts, which is bad UX for license activation.
FRAMEWORK_SEARCH_PATHS[config=*] = $(inherited) /System/Library/PrivateFrameworks // for SkyLight.framework
LD_RUNPATH_SEARCH_PATHS = @executable_path/../Frameworks // load embedded frameworks at runtime; Xcode's SPM machinery adds the absolute PackageFrameworks path automatically before this, which is fine in Debug and stale-but-harmless in Release on customer machines
> **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.
## 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:
`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.
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.
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.**
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 |
| ↳ 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.
-`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`**).
-`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`).
-`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 |
- **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.
**Status:** full release pipeline **proven** on the real runner — signed + notarized **v100.1.0** released (run `27643432063`, Developer ID `James Maes (2X834TJ5MA)`, `spctl`-accepted + stapled). Non-release pushes stay green via the ad-hoc build path (run `27634828800`).
This is the state after the CI-fix pass. The pass applied only **safe, owner-secret-independent**
adaptations so that **push CI is GREEN today** on the not-yet-signing-provisioned fork, while the
release tail **flips on automatically** the moment the owner adds the Apple/Sparkle secrets. No secret
values were invented; no DOMAIN fabricated; notarization is not enabled without a cert.
---
## What changed in this pass
| File | Change |
|---|---|
| `.github/workflows/ci_cd.yml` | Added a `release-gate` step that emits `signing-ready` from secret presence (`APPLE_P12_CERTIFICATE`/`APPLE_ID`/`APPLE_PASSWORD`/`APPLE_TEAM_ID`/`SPARKLE_ED_PRIVATE_KEY`). Gated codesign/notarize/appcast/semantic-release/extract-changelog/gh-release/sync-state-advance behind `push && signing-ready=='true'`. Removed the `update_readme_and_website.sh` step (queried the **upstream** lwouis API — semantic mismatch, cosmetic). |
| `scripts/update_appcast.sh` | `sign_update -s $KEY` (DEPRECATED; per `--help` "no longer supported for newly generated keys" — would reject the fork's fresh EdDSA key) → `printf '%s' "$SPARKLE_ED_PRIVATE_KEY" \| sign_update --ed-key-file -`. No `-p` (default output is the `sparkle:edSignature/length` attr pair the enclosure needs; verified). Key now off the argv. |
| `commitlint.config.js` | Added `defaultIgnores: true` + `ignores` predicates (drop `chore(sync): merge upstream …`, `Merge …`, `Revert …`) so a sync merge's dragged-in upstream messages don't fail commitlint and block the release job (PLAN §4.3). |
| `scripts/update_readme_and_website.sh` | **Removed** — hardcoded `lwouis/alt-tab-macos` API + `lwouis` contributor graph; cosmetic SVG stats; no live caller after the workflow edit. |
| `scripts/update_website.sh` | **Removed** — dispatched to upstream `lwouis/alt-tab-website`; already had no live caller. |
| `scripts/upload_symbols_to_appcenter.sh` | **Removed** — hardcoded upstream AppCenter org; telemetry is off (PLAN §3.4); no live caller. |
All three workflow YAMLs validate (`yaml.safe_load` OK); `commitlint.config.js` loads in node.
---
## (A) WORKING / green NOW, without any owner secret
These run on every push and PR and need no Apple/Sparkle/DOMAIN/bot secret. They are what makes push
CI GREEN today (vs. the prior all-14-runs-RED state — note that RED was a *missing depaywall marker*,
already fixed on this branch; this pass ensures it does not go RED again on missing secrets).
| Capability | Where | Confidence |
|---|---|---|
| **Guard A** anti-relock unit tests (`run_tests.sh`, Test scheme) | `ci_cd.yml` push+PR; `guard.yml` PR (macos-15) | High — verified GREEN in EXECUTION-STATUS §2.2 (493 exec, 16 skipped, 0 fail) |
| **Guard B** de-paywall marker + `if false` wrap grep | `ci_cd.yml` first push step + PR; `guard.yml` PR | High — marker present at `LicenseManager.swift:177` |
| **Guard C** unresolved-conflict-marker grep | `ci_cd.yml` first push step + PR; `guard.yml` PR | High |
| **Compile the full tree** (`build_app.sh`, **ad-hoc**) | `ci_cd.yml` push | Verified GREEN on CI (run 27634828800). NOTE: the bare `build_app.sh` inherits upstream's Developer ID from `config/release.xcconfig:5` and fails with exit 65 on CI; the build step now runs ad-hoc (`CODE_SIGN_IDENTITY=-`) when `release-gate` is not signing-ready, and signed otherwise. `build_app.sh` forwards xcodebuild overrides via `"$@"`. |
| **commitlint** (push range + PR base..head, with `ignores`) | `ci_cd.yml` | Med-High — `ignores` should absorb upstream merge msgs; confirm on FIRST real sync PR |
| **generated-files-up-to-date** check | `ci_cd.yml` push+PR | High |
| **semantic-release dry-run** version compute (`determine_next_version.sh`) | `ci_cd.yml` push | High — dry-run, no push, no secret |
| **Release tail SKIPS cleanly** when secrets absent (warning, not failure) | `ci_cd.yml``release-gate` | High — `signing-ready=false` path emits `::warning::` and skips |
> Note: full GREEN on the GitHub-hosted **macos-15** runner with **Xcode 26.0.1** is now CONFIRMED by a
> real run (`27634828800`, commit `6070f0cb`): gate + `run_tests.sh` + ad-hoc `build_app.sh` all pass and
> the release tail skips cleanly on absent secrets.
## (B) READY — flips ON automatically when the owner adds secret X
No further code change needed; the gate enables these the moment the secret(s) exist in the `production`
| Sparkle appcast sign + publish to gh-pages | `SPARKLE_ED_PRIVATE_KEY` (+ a real `DOMAIN`/feed host) | `update_appcast.sh` (now `--ed-key-file -`) |
| Tag push + GitHub Release | ambient `GITHUB_TOKEN` (auto) — only gated alongside the above so a release isn't cut unsigned | `npx semantic-release`, `softprops/action-gh-release` (the release-tail `.fork-sync-state` advance step was **REMOVED** 2026-06-16 — it clobbered the upstream-sync cursor with the fork release tag; the cursor must be advanced by the sync-merge flow instead — see `ci_cd.yml`) |
| Sync PR fires `guard.yml`/`ci_cd.yml` PR checks | `SYNC_BOT_TOKEN` (GitHub App token or fork PAT — NOT default `GITHUB_TOKEN`) | `upstream_sync.yml` (fail-closed if missing) |
> The release tail is **all-or-nothing**: it requires the full Apple set **and** the Sparkle key
> (`signing-ready` is the AND of all five). This is deliberate — cutting a tag/Release without a signed,
> notarized, Sparkle-signed artifact would publish an un-updatable or Gatekeeper-blocked build and burn a
> version number. If you want to test the build path before the Apple cert exists, push runs build + Guard
> A and stops cleanly.
## (C) OWNER-BLOCKED — exact action required (cannot be done by agents)
| # | Blocker | Exact owner action |
|---|---|---|
| 1 | Apple Developer ID cert | Obtain a **Developer ID Application** cert for the fork; base64 it into secret `APPLE_P12_CERTIFICATE`; set `CODE_SIGN_IDENTITY` in `config/local.xcconfig` (currently commented). Re-sign the vendored `vendor/Sparkle/Helpers/*` under the SAME ID or notarization rejects (PLAN §3.3). |
| 2 | Notarization creds | Set secrets `APPLE_ID`, `APPLE_PASSWORD` (app-specific password), `APPLE_TEAM_ID` in the `production` Environment. |
| 3 | Sparkle private key | Store `SPARKLE_ED_PRIVATE_KEY` (already generated 2026-06-15) in the `production` Environment. Confirm the `Info.plist``SUPublicEDKey` is the real public half (currently may be a placeholder). |
| 4 | `production` Environment | Create the GitHub Environment named **`production`** (matches `ci_cd.yml: environment: production`) with **no required-reviewer protection** that would stall releases; attach secrets #1–#3. |
| 5 | Real DOMAIN / appcast host | Replace `DOMAIN`/`API_DOMAIN` placeholders (`fork.invalid`) in `config/local.xcconfig` with fork-owned routable hosts; stand up `https://<DOMAIN>/appcast.xml` (gh-pages); `git rm appcast.xml` + gitignore it (PLAN §3.2). Until done, auto-update has no feed even with the key. |
| 6 | `SYNC_BOT_TOKEN` | Provision a GitHub App installation token (preferred) or fork PAT with contents + pull-requests write. Default `GITHUB_TOKEN` does NOT re-trigger PR checks → guards never run on the sync PR (`upstream_sync.yml` fails closed without it). |
| 7 | PR labels | `gh label create sync` / `conflict` / `chokepoint-refresh` (`upstream_sync.yml` fails closed if any is missing). |
| 8 | External scheduler | Hit `repository_dispatch` (type `upstream-sync`) from an external cron (immune to GitHub's 60-day in-repo `schedule:` auto-disable); add the two-trigger liveness alert (PLAN §4.2). |
| 9 | Xcode re-pin discipline | When an upstream sync bumps `ci_cd.yml`'s pinned Xcode, re-pin `guard.yml`'s `Xcode_26.0.1` line to match (EXECUTION-STATUS §3.5). |
| 10 | Bootstrap acceptance tests | (a) sync PR through the bot shows guard checks; (b) a deliberately-relocked branch goes RED on `guard.yml`; (c) fork build N→N+1 auto-updates via Sparkle (PLAN §4.2/§4.4/§6.1). |
---
## Remaining risks (need a real CI run to confirm)
- **macos-15 + Xcode_26.0.1 availability** on GitHub-hosted runners is assumed, not verified here.
- **commitlint `ignores`** is verified to load and match our synthetic subject, but the exact set of
*upstream* non-conforming messages can't be enumerated until a real `chore(sync)` merge — re-verify on
the FIRST sync PR (PLAN §4.3 says confirm on first sync).
- **`semantic-release` v15 / commitlint v8 on node-16**: kept per PLAN §4.3 (engines mismatch only warns);
unverified end-to-end on the runner since the release tail can't run without secrets.
- **`update_appcast.sh` gh-pages publish path** (orphan-branch seed, append-before-`</channel>`) is logic
the agents can't exercise without `GITHUB_TOKEN` + a real repo; verify on the first signed release.
- **`set -x` in `update_appcast.sh`** will print the `printf '%s' "$SPARKLE_ED_PRIVATE_KEY"` command in
the (private-repo) log trace; the key is no longer on the argv/process list, but consider `set +x` around
the sign line if the repo ever goes from private logs to public. (Not changed in this pass — out of scope.)
This document records what the automated agents DID, the verified build/test/gate
results, and the **single authoritative owner publish checklist** (only the human
owner can do the items at the bottom). Read the checklist top to bottom before publishing —
several items are BLOCKING (the fork will not notarize, auto-update, or gate releases
until they are done).
---
## 1. What was DONE
### 1.1 De-paywall patch (committed BEFORE this integration pass)
Landed in commit `f3d76859` (`depaywall: neutralize license gate, cookie, telemetry, and
activate handler (src/pro left intact)`). `src/pro/` is left physically present but inert.
Four neutralizations, each carrying a `depaywall` marker comment:
| # | File:line | Edit | Effect |
|---|-----------|------|--------|
| 1 | `src/pro/license/LicenseManager.swift:177` | `guard false else { return .pro }` (body below wrapped in `if false { … }`) | The single state producer `computeState()` always returns `.pro`. Opens every Pro gate in the tree at once; no per-merge UI/pbxproj edits. |
| 2 | `src/App.swift:474` | `guard false else { return }` at top of `handleCustomUrl` | The scheme-gated `<bundleid>://activate?license_key=…` handler early-returns, so it can NEVER reach `LicenseManager.shared.activate → RemoteLicenseClient` POST that exfiltrates the machine hardware UUID (`MachineFingerprint`). This is the live-exfiltration surface, killed independent of where `API_DOMAIN` points. |
| 3 | `src/pro/license/LicenseCookie.swift:6` | body wrapped in `if false { … }` | `syncLicenseCookie(state:)` never writes a `license` cookie to the fork/upstream domain. Call site in `App.swift` (hot file) untouched. |
| 4 | `src/vendors/AppCenterCrashes.swift:10` | `init()` body wrapped in `if false { … }` | `AppCenter.start(...)` never runs; no crash telemetry network calls. `App.appCenterDelegate = AppCenterCrash()` still constructs the object, which now does nothing. |
All four use `if false { … }` / `guard false` rather than a bare early `return`, because
`config/base.xcconfig:7` sets `SWIFT_TREAT_WARNINGS_AS_ERRORS=YES` and "code after return"
is a build error.
### 1.2 Anti-relock guard tests (committed BEFORE this integration pass)
Landed in commit `4506c539`. Two methods appended to the EXISTING
`src/pro/license/LicenseManagerTests.swift` (already in the `unit-tests` Sources phase — zero
-`testDepaywallStillProAfterTrialExpiry` — injects a long-past `trialStartDate` so an
*unpatched*`computeState()` would return `.trialExpired`; asserts `.pro` + `!isProLocked`
+ `isProAvailable`. This is the discriminating test — it goes RED on a re-armed paywall.
**Both pass** (see §2). They use the existing `MockClock`/`MockKeychain`/`MockLicenseAPI`
fixtures and never reference `LicenseManager.shared`, `Endpoints.*`, or `ProFeature.*` (the four
traps documented in PLAN §4.4).
### 1.3 Scaffolding (committed in THIS pass — `3dde8a91`)
`git commit -m "chore: identity overlay + sync/guard/release CI scaffolding (placeholders flagged for owner)"`
— 15 files, +681/-3891. The agents wrote these but had not committed; this pass committed them.
**Identity overlay (fork-owned, last-include-wins; zero edits to `base.xcconfig`/`project.pbxproj`):**
-`config/local.xcconfig` (NEW) — `PRODUCT_NAME=AltTabFree`, `PRODUCT_BUNDLE_IDENTIFIER=io.github.koftwentytwo.alt-tab-free`, `DOMAIN=fork.invalid`, `API_DOMAIN=fork.invalid/api`, `APPCENTER_SECRET=` (empty), `CODE_SIGN_IDENTITY` left commented (TODO). All values are PLACEHOLDERS flagged for the owner.
-`Info.plist` — `Domain`/`ApiDomain` now wired to `$(DOMAIN)`/`$(API_DOMAIN)`; `SUPublicEDKey` = `REPLACE_WITH_FORK_SUPUBLIC_EDKEY` placeholder.
-`src/api/Endpoints.swift` — reads `Domain`/`ApiDomain` from the bundle (build-time, fork-owned), so every license/feedback/appcast URL derives from the fork's own hosts, never upstream `alt-tab.app`.
-`package.json` — `license` set to `GPL-3.0`, author cleared.
| (b) no reachable POST to `alt-tab.app/api` | Both POST sites (`RemoteLicenseClient.swift:105`, `FeedbackWindow.swift:446`) target `Endpoints.apiDomain`, which is read from Info.plist `ApiDomain` (fork-owned, build-time — currently `fork.invalid/api`), NEVER upstream `alt-tab.app`. The only two literal `alt-tab.app` strings in the tree are doc comments, not live code. The license-activate POST's auto-reachable entry point (`App.swift:474``handleCustomUrl`) early-returns via `guard false`. Its only other caller (`UpgradeTab.activateLicense`) requires a human to paste a key into the upsell UI and would hit the fork's own backend, not upstream. |
Note right of UI: refreshLicenseMenuItems, syncLicenseCookie,<br/>ProTransitionManager.onLicenseStateChanged,<br/>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<br/>{license_key, fingerprint, trial_started_at}
# Plan: Republish a Fully-Free, Paywall-Free AltTab Fork
> **Status:** actionable plan derived from the deep-audit sections in `docs/audit/01-*.md … 09-*.md` and the completeness-critic verdict. Read those sections for full per-file detail; this document is the execution plan that sits on top of them.
>
> **Scope reminder (from `AGENTS.md`):** pure Swift 5.8, no SwiftUI, no Interface Builder. Compile-check after every change with `ai/build.sh`.
---
## A. Licensing & Ethics (read this first — it constrains everything below)
### What the license actually is
- AltTab is **GPL-3.0** (`LICENCE.md` — verbatim GNU GPL v3, "Copyright (C) 2007 Free Software Foundation").
- The paywall overlay (everything under `src/pro/`, plus the integration call-sites in `src/App.swift`, `src/Menubar.swift`, `src/preferences/…`) was added **upstream by the original author lwouis in a single commit**`9147a4a8 "feat: introducing alt-tab pro!"` (v11.0.0, 2026-04-05) — see audit section 09. Because it was added on top of GPL-3.0 code and links against it, the paywall code is itself a **derivative work and therefore also GPL-3.0**. There is no separately-licensed proprietary blob here.
- **Consequence:** GPL-3.0 explicitly grants you the right to modify and redistribute, *including removing the gating*. You may delete the paywall and ship the result.
### What you MUST do (GPL-3.0 obligations)
1.**Offer the complete modified source under GPL-3.0.** Keep `LICENCE.md` as-is. Publish your fork's full source (you already are — public GitHub repo).
2.**Preserve upstream copyright and attribution.** Do **not** remove copyright headers, the GPL notice, `LICENCE.md`, or `docs/contributors.md` / `docs/acknowledgments.md`. Keep a clear "based on AltTab by Louis Pontoise (lwouis/alt-tab-macos), GPL-3.0" statement in your README and About box.
3.**State your changes.** GPL §5(a): mark the files you changed and the dates. A `CHANGELOG`/`NOTICE` entry ("forked from v11.x; removed the Pro/trial/license system; all features unlocked") satisfies this.
4.**Pass the same freedoms downstream.** Your fork must remain GPL-3.0 — you cannot re-close it.
### What you MUST NOT do
- **Do not claim original authorship.** This is a fork; credit lwouis prominently.
- **Do not strip the GPL or attribution** (see above) — that would terminate your license under GPL §8.
- **Do not reuse the original's trademarks / app name / icon / branding in a confusing way.** "AltTab", the AltTab icon, and the `alt-tab.app` brand are the upstream project's identity. Copyright license ≠ trademark license. **Rename the app and replace the icon** (see Section D). Shipping a binary still called "AltTab" with the same icon but a different signer invites both trademark confusion and user-trust problems.
- **Do not impersonate the paywalled product's identity.** Do **not** reuse the upstream **Developer ID** (`Developer ID Application: Louis Pontoise (QXD7GW8FHY)`, `config/release.xcconfig:5`) — you cannot sign with it and must not try. Do **not** reuse the bundle id `com.lwouis.alt-tab-macos`, the Sparkle public key, the `alt-tab.app` license/feedback/appcast endpoints, or the AppCenter app slug. These are operationally tied to lwouis's accounts and you do not control them.
- **Do not point your auto-updater at upstream's appcast / GitHub release downloads** — that would cross-feed updates between the two products and is both a technical and an identity problem.
**Net:** removing the gating and republishing is *legal and in the spirit of GPL-3.0*. The hard requirements are (a) keep it open under GPL-3.0 with attribution, and (b) ship under a **new identity** (name, icon, bundle id, Developer ID, update feed, endpoints).
-`ProFeature.attemptUse()` becomes a pass-through (`src/pro/ProFeature.swift:74-83`).
-`PreferenceDefinition.read()` returns the stored value unconditionally (`src/preferences/PreferenceDefinition.swift:32-44`); defaults already hold the Pro values (audit 03), so features unlock with no default changes.
- Stub `ProTransitionManager.onSwitcherShown/onSwitcherDismissed/onAppLaunchComplete` to no-ops to kill nags (`src/App.swift:78,328,429`).
**Pros:** ~10–20 line diff, lowest compile risk, fastest, no Xcode-project surgery.
**Cons:** Dead paywall code ships in the binary; **live remote/telemetry references remain** — license API client, `LicenseCookie` writing a tier cookie to `alt-tab.app` on every state change (`src/App.swift:451`), `RemoteLicenseClient` POSTs (`src/pro/license/RemoteLicenseClient.swift`), machine-fingerprint code (`src/pro/license/MachineFingerprint.swift`). For a *genuine free republish* this is unacceptable: it still phones the upstream paywall backend and leaks a per-machine fingerprint. Also still ships under the upstream identity unless you separately do Section D. **Not recommended as the end state.**
Delete the entire `src/pro/` tree, remove every integration call-site, rewrite the gated preference getters to read raw values, remove the upsell UI from `App.swift` / `Menubar.swift` / `SettingsWindow.swift`, drop the license/cookie/fingerprint network code, fix the Xcode project file and the tests.
**Pros:** Truly free; no dead paywall code; no license API, no tier cookie, no machine fingerprint, no Day-X nag windows. The binary is honestly "all features, no strings."
**Cons:** Larger, riskier change touching ~35 source files plus `alt-tab-macos.xcodeproj/project.pbxproj` (~102 in-Sources entries) and several test files. Build-break risks are real and enumerated in Section C/E.
### Strategy 3 — "Rebase onto pre-paywall / current upstream" (cleanest of all, different shape)
Take **upstream `lwouis/alt-tab-macos`** at a point you want (either pre-paywall `v10.12.0` = commit `317a485b`, or current `master`*minus* the `src/pro/` commit) and cherry-pick only the genuinely-free fork features you care about.
**Pros:** You never have to extract the paywall from this snapshot at all — you start from a clean, paywall-free tree (pre-v11) or a tree where you simply never merge the Pro commit. Lowest *long-term* maintenance because you track upstream directly.
**Cons:** audit 09 confirms this repo has **zero fork-owner commits** — every commit is lwouis or `semantic-release-bot`. So there are effectively **no unique fork features to carry over**; the only delta from upstream *is* the paywall. Rebasing on `v10.12.0` loses all post-v11 fixes unless cherry-picked; rebasing on current upstream-minus-Pro is essentially Strategy 2 done via git topology instead of file deletion. It also still requires the full Section D re-identity work.
### Recommendation
**Adopt Strategy 2 (full excision) as the deliverable, but use Strategy 1 as an intermediate checkpoint.** Concretely:
1. First land Strategy 1 (force-unlock) as a *temporary* commit so you have a known-good, fully-unlocked, compiling build to A/B against.
2. Then perform the full excision on top of it, verifying the build stays green at each phase (Section C).
3. Do Section D (re-identity) in the same release.
Strategy 3 is the right call **only** if you would rather track upstream long-term and re-apply removal as a maintained patch set — given there are no unique fork features, that is a reasonable alternative, but it does not save the re-identity work and loses the newer fixes if you go to `v10.12.0`. For a one-shot "free AltTab" republish from *this* snapshot, full excision is the most honest result and keeps the current v11.x fixes.
---
## C. Step-by-Step Execution (Strategy 2, full excision)
> **Golden rule:** after **every** numbered step, run `ai/build.sh` (Debug) and do not proceed until it compiles. Deleting `src/pro/` before its consumers are rewritten is a guaranteed hard break (the completeness critic lists the exact symbols). The ordering below removes *consumers first*, then the *toolkit*, then the *files*, then the *Xcode refs*, then the *tests*.
### Phase 0 — Safety net
- Branch off `master`. Optionally land the Strategy-1 force-unlock commit first (Section B) to get a fully-unlocked baseline you can diff behavior against.
- Confirm the Debug build is green before touching anything: `ai/build.sh`.
### Phase 1 — Open the gates in place (no deletions yet)
Goal: make every feature live and stop all nags while all Pro symbols still exist, so the build stays green.
1.`src/pro/ProFeature.swift:74-83` — `attemptUse()` returns `true` unconditionally (remove the `isFreePassSessionActive` short-circuit at `:76` and the `attemptHardGatedFeature` call at `:79`).
2.`src/preferences/PreferenceDefinition.swift:32-44` — `read()` returns the stored value unconditionally (drop the free-pass and isProLocked branches).
3.`src/preferences/Preferences.swift` — verify the three gated getters (`:129` appearanceStyle, `:130` appearanceSize, `:155` shortcutStyle) and the three index-0 overrides (`:313, :319, :330`) now return Pro values; defaults already hold them (audit 03).
4.`src/preferences/settings-window/tabs/controls/ControlsTab.swift:589-592` — remove the 1-shortcut cap; `:515` remove the `index>=1` Pro badge.
**Verify:** build green; manually confirm extra shortcuts, in-switcher search, App-Icons/Titles styles, and Auto size all work (audit 06 lists these four advertised features).
### Phase 2 — Sever the App.swift wiring
Edit `src/App.swift`, removing only the Pro lines (audit 01/05 enumerate them):
6. Remove the license callback block `:448-460` (`onBeforeProUnlock`, the `onStateChanged` fan-out, and `LicenseManager.shared.initialize()`). Keep `resetPreferencesDependentComponents` (`:51-53`) — it is used elsewhere; only drop the Pro caller. Drop the `proLockStateDidChangeNotification` post (`:458`).
7. Remove the custom-URL activation handler wholesale: `application(_:open:)` + `handleCustomUrl` (`:465-490`) — it exists only for `alttab://activate`.
8. Remove the menubar action selectors/methods: `:25-27` selectors, `:111-121` (`upgradeToPro`, `openAccount`; **decide** whether to keep `supportProject` — it is a donation link, not paywall, audit 01/07).
9. Simplify first-launch: `willShowDay1WelcomeOnAppLaunch` (`:205-208`) and the deferral observer (`:210-220`) reference `Day1WelcomeLetterWindow` and `hasSeenWelcome`. Replace with an unconditional `showAndCenterSettingsWindowOnFirstLaunch`. **Risk (audit 01):** if you delete the welcome window but leave the `willClose` observer, Settings never shows on first launch — remove both together.
**Verify:** build green. `App.swift` now references no `ProTransitionManager`/`LicenseManager`/`ProPromptHost` symbols.
### Phase 3 — Strip the Menubar upsell
Edit `src/Menubar.swift` (audit 07):
10. Delete the 3 license item vars (`:8-10`), the Get-Pro / My-Account / (optionally Support) item adds (`:46-49`), the `UpgradeMenuItemView` assignment (`:47`), and the `refreshLicenseMenuItems` call (`:50`).
11. Delete `refreshLicenseMenuItems` (`:84-107`) and `toggleUpgradeMenuItem` (`:109-118`).
12. Delete the `UpgradeMenuItemView` class (`:235-390`) — its only consumer was `:47`. **Note (critic):**`updateContent(_ state: LicenseState)` (`:357`) hard-depends on the `LicenseState` enum; it dies with this class, which is what lets you delete `LicenseState` later.
13. Delete `badgeDotLayer` (`:187`) and `updateBadgeDotOverlay` (`:202-227`), and remove its call inside `loadPreferredIcon` (`:196`). This severs Menubar's dependency on `ProTransitionManager.shouldShowBadgeDot`.
14. In `menuWillOpen` (`:392-399`) drop `LicenseManager.shared.refreshState()` (`:396`) but **keep**`refreshPermissionCallout()` (`:397`) — non-pro.
15.`showPopoverFromMenubar` (`:229-232`) becomes dead once Day-X popovers go; remove it. **Keep**`menubarIconCallback` (`:155-164`) — also used by PreferencesEvents/QAMenu.
**Verify:** build green. The menu now matches the upstream non-pro item set (Show / Settings / Updates / Permissions / About / Feedback / Quit).
16. Delete the `UpgradeButton` class (`:133-214`, a `ProGradientButton` subclass reading `LicenseManager.state`/`.isLifetimeVariant`/`.customerEmail`).
17. Delete the `upgradeButton` instance (`:303`) and its state (`:308-311``upgradeContentView`/`isShowingUpgradeView`/`upgradeViewBottomConstraint`).
24. Remove `private var proBadge: ProBadgeView?` (`:93`) and `setProBadge` (`:244-258`).
Edit the tabs (audit 03):
25.`AppearanceTab.swift` — remove the `proLockStateDidChange` observer (`:414-419`), the `proGatedAppearanceStyleIndices` args (`:462, :700-703`), the `ProBadge.attach` calls (`:463, :481, :575, :688-698`), the `wrapShortcutStyleProLockIntercept` (`:585-598`) and `wrapAppearanceSizeProLockIntercept` (`:662-679`), and `refreshProLockUi` (`:705+`).
26.`ShortcutEditor.swift` — remove `proGatedIndices` args (`:429, :437, :449, :459`) and the locked intercepts (`:599-607, :705-711`).
27.`ControlsTab.swift` — remove the `proLockStateDidChange` observer (`:141`) (cap + badge already removed in Phase 1).
28.`ShortcutsWhenActiveSheet.swift` — remove the ProBadge on search / lock-search rows (`:25, :51, :54, :63-69`).
29.`LabelAndControl.swift` — remove the `proGatedIndices` param and the `isProLocked` intercept from `makeImageRadioButtons` (`:74-116`, esp. `:78, :86-92`).
30.`src/events/PreferencesEvents.swift:62-64` — delete the "bounce a Pro-valued write to the Upgrade tab" block (depends on `ProFeature.isStoredValuePro`).
**Verify:** build green. No file outside `src/pro/` now references `ProBadgeView`, `ProGradientButton`, `ProGradient`, `LicenseManager`, `LicenseState`, `ProTransitionManager`, `ProFeature`, `UpgradeTab`, or `proLockStateDidChangeNotification`. Grep to confirm:
This grep returning empty (modulo the test files handled in Phase 7) is the gate for Phase 5.
### Phase 5 — Rewrite gated preference getters to be self-contained, then prepare deletion
31.`src/preferences/Preferences.swift` — rewrite the 3 gated getters (`:129, :130, :155`) and the 3 index-0 override branches (`:313, :319, :330`) to read raw values via `CachedUserDefaults.macroPref(...)` instead of `ProGatedPreferences.*.read()`. Remove `overrideRememberedKey` + the `ProTransitionState.setInt` cleanup in `removeOverride` (`:282-298`).
32.`src/preferences/PreferencesMigrations.swift:20` — remove the `ProTransitionState.markFreshInstallIfUnknown` call (the only paywall coupling in migrations).
33. (Optional, in-place-upgrade only) If you ship under the **same** bundle id, add a one-time migration that copies any `proTransition.remembered*` indices back to the base keys before you stop reading them, else users who passed trial expiry on the paywalled build silently lose their prior Pro selections (audit 03 risk). **If you ship a new bundle id (recommended, Section D), this is moot** — clean UserDefaults domain.
**Verify:** build green. `src/pro/` is now referenced only by itself + tests.
### Phase 6 — Delete `src/pro/` and fix telemetry/endpoints
34. Delete the entire `src/pro/` directory (verified contents):
35. Delete `src/preferences/PreferenceDefinition.swift` entirely (the gate engine; getters were rewritten in Phase 5).
36. Delete the Upgrade tab `src/preferences/settings-window/tabs/UpgradeTab.swift` (it contains `ProHeroButton: ProGradientButton` at `:493`, the `usageHero`/`heroButton` plumbing `:5-6, :37-38, :160-161`, and the full `activateLicense`/`deactivateLicense`/`deactivateInstance` + `LicenseAPIError.seatLimitExceeded` UI `:322-405` — critic). Remove it from the Settings tab list/registration.
37.**Endpoints/cookie/fingerprint** (`src/api/Endpoints.swift`): remove `checkoutUrl` (`:9`), `accountUrl` (`:10`), `licenseApiBaseUrl` (`:11`). Keep `appcastUrl` and `supportUrl`; keep or repoint `feedbackUrl` (`:12`) per Section D. The `LicenseCookie`, `RemoteLicenseClient`, `MachineFingerprint` files are already gone with `src/pro/license/`.
38.**`UsageStats`** (`src/util/UsageStats.swift`, `UsageStatsTestable.swift`, `UsageStatsMessageTests.swift`): local-only, no network (audit 09). Its only *paywall* consumers were `ProConversionCopy`/`UsageStatHeroView`, now deleted. You may keep it (the neutral `App.swift` recording / AboutTab display still compile) or delete it; deleting also requires removing `UsageStatsMessageTests.swift` from the test target.
39.**QA/Debug menu**`src/debug/QAMenu.swift:170-241` (audit 05): directly instantiates every Day-X window and pokes manager flags. Remove those manual triggers/resets or the DEBUG build won't compile.
40.**DebugProfile**`src/secondary-windows/DebugProfile.swift:17` reads `LicenseManager.shared.state.debugProfileLabel` (the extension lived at `src/pro/license/LicenseState.swift:14`). Since DebugProfile feeds the (kept) Feedback report, **hand-edit** — drop the License line, do not delete the file (critic).
41.**AppCenter telemetry** (audit 08/09) — remove it (most privacy-aligned for a free build; AppCenter is also EoL):
- delete `src/vendors/AppCenterCrashes.swift` and references in `src/App.swift` (`import`/`AppCenterApplication` superclass at `App.swift:7`/start call).
- **Principal class break (critic):** `Info.plist:36-37``NSPrincipalClass = AppCenterApplication`, the `class App: AppCenterApplication` superclass, and `alt-tab-macos-Bridging-Header.h:1` import are coupled. Replace the superclass with `NSApplication` and set `NSPrincipalClass` to `NSApplication` (or your own subclass), and remove the bridging-header import — or the app won't launch *and* won't compile.
- remove `Secrets.appCenterSecret` usage (`src/api/Secrets.swift`), `Info.plist``AppCenterSecret`/forwarder keys, and the `Package.swift` AppCenter deps.
- keep `DebugProfile.make()` if Feedback stays (it is shared); only the crash-attachment call site goes away.
**Verify:** build green.
### Phase 7 — Xcode project file + tests (the mechanical part — do not skip)
42.**`alt-tab-macos.xcodeproj/project.pbxproj`** (critic): every deleted file has a `PBXBuildFile` + `PBXFileReference` + `PBXGroup` child + `PBXSourcesBuildPhase` membership (~102 in-Sources pro entries; app phase ~line 1830, test phase ~1902; `PreferenceDefinition.swift` at ~1979). Note **`Clock`, `Keychain`, `LicenseAPI`, `MachineFingerprint`, `ProTransitionManagerTestable` have TWO build-file UUIDs (app + test)** — remove **both**. Any file deleted on disk but still referenced makes the project fail to parse/link. The cleanest way to do this safely is to let Xcode reconcile, but since `AGENTS.md` says don't develop in Xcode, edit `project.pbxproj` by hand and verify with `xcodebuild -list` / `ai/build.sh` after.
43.**Tests** (critic): remove/repair test-target members that reference vanished symbols — `LicenseManagerTests`, `ProTransitionTests`, `ProTransitionManagerTestable`, `ProBadgeViewSegmentTests`, and (if you deleted UsageStats) `UsageStatsMessageTests`. In `src/_test-support/Mocks.swift` remove the `ProBadgeView` Symbols stub and the `ShortcutStylePreference` mock, and update the hardcoded bundle id (`Mocks.swift:133`, `com.lwouis.alt-tab-macos`) to the new id. `PreferencesMigrationsTests.swift:356` has a *self-contained local stub*`ProTransitionState` (won't break on deletion), but the real `PreferencesMigrations.swift:20` call you removed in Phase 5 is what mattered.
**Verify:** Test scheme passes:
```
scripts/run_tests.sh # Test scheme, Release config (audit 08)
45. Manual smoke test (Section E checklist): permissions flow, switcher show/hide, all four previously-Pro features, multiple shortcuts, Settings opens with no Upgrade tab / no Get-Pro item / no badge dot, no `alttab://` handler, no network call to `alt-tab.app/api`.
---
## D. Re-Identity & Publish
> Per `AGENTS.md`, app identity (Developer ID, TeamID, bundle id) and Keychain items are coupled to the code signature. **For the free build this invariant is effectively MOOT (audit 08/09):** once `src/pro/license/Keychain.swift` is deleted there are no stored license items to orphan, so rebranding the bundle id is safe. But the identity change itself is **mandatory** — you cannot sign with upstream's Developer ID.
### Identity
- **Bundle id:** change `com.lwouis.alt-tab-macos` → your reverse-DNS (e.g. `app.koftwentytwo.alt-tab-free`). Locations: `config/base.xcconfig:4`; unit-test bundle id in `project.pbxproj:2477/2542`; hardcoded `Mocks.swift:133`.
- **App name / `PRODUCT_NAME`:** rename away from "AltTab" (trademark — Section A). Update `config/base.xcconfig`, README, and the About box (`App.repository`, `src/App.swift:16`).
- **Icon:** replace the AltTab icon with your own (trademark + brand identity).
- **TeamID / Developer ID:** `DEVELOPMENT_TEAM = "${TEAM_ID}"` currently expands to empty; signing is driven by `CODE_SIGN_IDENTITY` (`config/release.xcconfig:5`) = the upstream `Developer ID Application: Louis Pontoise (QXD7GW8FHY)`. **Replace with your own Developer ID Application cert + TeamID.** The CI cert secret `$APPLE_P12_CERTIFICATE` (`scripts/codesign/setup_ci_master.sh`) must be your P12. Notarization Apple-ID/password/team (`ci_cd.yml:9-11`, `scripts/package_and_notarize_release.sh`) must be your account.
### Auto-update (Sparkle) — must repoint or you can't ship updates
- Feed URL `Endpoints.appcastUrl` = `https://<DOMAIN>/appcast.xml`, `DOMAIN=alt-tab.app` (`config/base.xcconfig:20`). **Repoint `DOMAIN`** to a host you control, or serve the committed `appcast.xml` from your own GitHub Pages / raw URL.
- **Generate a NEW Sparkle EdDSA keypair.** Replace `SUPublicEDKey` (`Info.plist:62`, currently `2e9SQOBoaKElchSa/4QDli/nvYkyuDNfynfzBF6vJK4=`) and the CI signing secret `$SPARKLE_ED_PRIVATE_KEY` (`update_appcast.sh:9`). Public and private **must match** or updates fail verification. You cannot reuse lwouis's key (you don't have the private half anyway).
- **Truncate/regenerate `appcast.xml`** (~3876 lines). Existing items are signed by upstream's private key and point at `github.com/lwouis/alt-tab-macos/releases/...` (`update_appcast.sh:18`). Leaving them risks cross-feeding updates between the two products — start a fresh feed for your fork's own releases.
- Re-vendor/re-sign `vendor/Sparkle/Helpers/Updater.app` + `Autoupdate` with **your** Developer ID (`copy_sparkle_helpers.sh` re-seals with `CODE_SIGN_IDENTITY`); a mismatch trips `codesign --verify --deep --strict`.
### Remove the license API + telemetry endpoints
- License API (`Endpoints.licenseApiBaseUrl`, `RemoteLicenseClient`) and the per-tier `LicenseCookie` are deleted in Phase 6 — confirm no remaining reference to `alt-tab.app/api/v1/license`.
- **AppCenter** removed in Phase 6 (Step 41). Decide explicitly: remove entirely (recommended) vs repoint to a self-owned AppCenter app (note: AppCenter is EoL, so removal is the durable choice).
- **Feedback** (`Endpoints.feedbackUrl` → `alt-tab.app/api/v1/feedback`, used `FeedbackWindow.swift:445`): its server turns the POST into a GitHub issue on **lwouis's** repo and is **not in this repo**. Either stand up your own endpoint and repoint, or remove the in-app feedback submitter. This is a *product decision*, flagged in audit 08/09.
- **Website repository-dispatch** (`update_website.sh:5` → `lwouis/alt-tab-website`, `$WEBSITE_DISPATCH_TOKEN`, `ci_cd.yml:67-69`) and the README-stats step hitting `api.github.com/repos/lwouis/alt-tab-macos` (`update_readme_and_website.sh:10`) must be removed or repointed to your repo. Update `.github/FUNDING.yml`.
### Distribution channel & notarization
- **Recommended:** GitHub Releases of *your* fork + Sparkle feed served from your domain or GitHub Pages. This mirrors the existing pipeline with minimal change once the URLs/keys above are yours.
- **Alternatively / additionally:** a Homebrew cask (`brew install --cask <your-fork>`), which still requires a notarized, stapled `.zip`/`.app`.
- **Notarization is mandatory** for Gatekeeper on modern macOS regardless of channel: sign with your Developer ID → `notarytool` submit (bundled `scripts/notarytool`, `scripts/package_and_notarize_release.sh`) with **your**`$APPLE_ID`/`$APPLE_PASSWORD`/`$APPLE_TEAM_ID` → `xcrun stapler staple` → re-zip.
- **Versioning:** releases are 100% `semantic-release` from Conventional Commits on `master` (`determine_next_version.sh` → CI-only `VERSION.txt` → `Info.plist`). `package.json``1.0.0` is irrelevant. Your fork can keep this as-is once the secrets/URLs are yours, or reset the version line for a clean v1.
**Gating prerequisite (audit 08 open question):** you need your own Apple Developer account (Developer ID Application cert + notarization credentials) and a domain (or GitHub Pages) for the appcast + your own Sparkle EdDSA keypair **before** the first signed, self-updating release is possible.
---
## E. Risks, Testing, and Phased Checklist
### Top build-break risks (from the completeness critic — fix the consumer before deleting the type)
- **`LicenseState` enum** is referenced by non-pro `Menubar.swift:357` (`updateContent` param) and `DebugProfile.swift:17` (`.debugProfileLabel`). Handle in Phase 3/6 before deleting `src/pro/license/LicenseState.swift`.
- **`LicenseManager` singleton** is read by non-obvious non-pro sites: `SettingsWindow.swift:162,164,167,196,206-207,1246` and `UpgradeTab.swift:220-405`. All removed in Phase 4/6.
- **`ProGradientButton`** is the **superclass** of `UpgradeButton` (`SettingsWindow.swift:133`) and `ProHeroButton` (`UpgradeTab.swift:493`); `Menubar.swift:247` uses `ProGradient.makeLayer()`. Delete all subclasses/usages (Phase 3/4/6) before deleting the toolkit.
- **`ProBadgeView`** referenced by `SidebarList.swift:93,254`, `SettingsWindow.swift:679`, Appearance/Controls/ShortcutsWhenActiveSheet. Remove all (Phase 4) before deleting it.
- **`ProGatedPreferences`/`PreferenceDefinition.swift`** feeds `Preferences.swift:129,130,155` getters and is a **unit-test-target member** (`project.pbxproj:1979`) — a broken getter fails *app and test* compile. Rewrite getters (Phase 5) before deleting.
- **App superclass / principal class** (`App.swift:7`, bridging header, `Info.plist:36-37`) — removing AppCenter without replacing the principal class means the app won't launch *and* won't compile (Phase 6, Step 41).
- **`Day1WelcomeLetterWindow`** referenced in non-pro `App.swift:213`; the first-launch deferral observer (`:194-218`) must be simplified to always show Settings (Phase 2, Step 9) or compile breaks.
- **`project.pbxproj` integrity** — ~102 in-Sources entries + fileRef/group entries per deleted file; dual app+test UUIDs on Clock/Keychain/LicenseAPI/MachineFingerprint/ProTransitionManagerTestable must **both** go (Phase 7).
### Behavioral / product risks
- **In-place upgrade data loss** (audit 03): if you keep the same bundle id, users past trial-expiry have downgraded stored prefs with the Pro index parked in `proTransition.remembered*` (in the `<bundleId>.license` UserDefaults suite). After removal nothing restores them. **Mitigation:** ship a new bundle id (clean domain) — recommended — or add the one-time restore migration in Phase 5, Step 33.
- **Orphaned UserDefaults / Keychain** (audit 04/05): with a new bundle id there is nothing to orphan; with the same id, old `proTransition.*` / `trialStartDate` keys are harmless once no code reads them.
- **Sparkle key mismatch** breaks updates silently for end users — verify the public/private EdDSA pair before first release.
- **Trademark/identity** (Section A): shipping as "AltTab" with the AltTab icon is the highest *non-build* risk — rename + re-icon.
### Testing matrix
- **Unit:** `scripts/run_tests.sh` (Test scheme) green after Phase 7. The pure kernels (`SelectionResolver`, `SearchModeResolver`, `WindowFilterResolver`, `WindowOrderResolver`) never touched pro code (audit 02) and should pass unchanged — a good signal that gate removal didn't perturb core logic.
- **Debug build:** `ai/build.sh` green after **every** phase (the discipline that catches the consumer-before-type breaks).
- **Release build:** `scripts/build_app.sh` green before packaging.
_Updated 2026-06-16. `master` carries the fork; the **full CI/CD publishing pipeline is LIVE** — signed + notarized **v100.1.0** released and Gatekeeper-accepted. Homebrew tap live._
## ✅ SIGNED + NOTARIZED RELEASE LIVE
The complete release pipeline ran green end-to-end (run `27643432063`, commit `2f9fac17`): cert import → **signed**`build_app.sh` (Developer ID **`James Maes (2X834TJ5MA)`**) → notarize + staple → appcast→gh-pages → semantic-release → GitHub Release. **[v100.1.0](https://github.com/KofTwentyTwo/CommandTabFree/releases/tag/v100.1.0)** is published; the downloaded asset verifies `spctl` = "accepted, source=Notarized Developer ID" and `stapler validate` passes (offline-clean, no quarantine prompt). Non-release pushes stay green via the ad-hoc `build_app.sh` path (run `27634828800`).
**How signing is wired:**`config/local.xcconfig` sets `CODE_SIGN_IDENTITY` + `CODE_SIGN_STYLE = Manual` + `TEAM_ID 2X834TJ5MA`; the vendored Sparkle helpers (`vendor/Sparkle/Helpers/*`) are committed **re-signed** under the same Developer ID; `setup_ci_master.sh` imports the cert with an optional `APPLE_P12_PASSWORD`. The release secrets (`APPLE_P12_CERTIFICATE`/`APPLE_P12_PASSWORD`/`APPLE_ID`/`APPLE_PASSWORD`/`APPLE_TEAM_ID` + `SPARKLE_ED_PRIVATE_KEY`) are set in the `production` env. Getting here also required two CI fixes earlier this session: `d1e1b296` (regenerate base `Localizable.strings` to clear the genstrings gate) and `6070f0cb` (ad-hoc build until the cert existed).
**Remaining (NOT yet done):**
- **Auto-update** (deferred by choice): `appcast.xml` is published to gh-pages, but the app's feed URL is still `fork.invalid` and `SUEnableAutomaticChecks=false`. To enable N→N+1 updates: set a real `DOMAIN` (kof22.com), serve gh-pages at `https://<DOMAIN>/appcast.xml`, flip checks on.
- **Homebrew cask**: now notarized — drop the quarantine-strip postflight and bump the cask to v100.1.0.
- **Upstream-sync**: `.fork-sync-state` reset to `v11.3.0` (the release tail no longer clobbers it — see ci_cd.yml). Before enabling upstream-sync, advance the cursor from the sync-merge flow (recording the UPSTREAM tag), and provision `SYNC_BOT_TOKEN` + an external cron. The 3 sync labels exist.
`KofTwentyTwo/CommandTabFree` is a fork of **AltTab** (`lwouis/alt-tab-macos`, GPL-3.0) that neutralizes the v11 Pro paywall so all features are free, rebranded **CommandTabFree**. Strategy: thin, merge-stable patch tracking upstream forever (`docs/PLAN-maintained-fork.md`).
## ✅ DONE
- De-paywall + anti-relock tests + frozen identity (`com.koftwentytwo.commandtabfree`) + brand sweep + ⌘⇥ icon (final) — all on **`master`** (was `depaywall-free`, fast-forward merged, HEAD `c7d2d968`, pushed to origin; source is public for GPL §6).
- **Sparkle EdDSA keypair generated.** Public key in `Info.plist` (`SUPublicEDKey`); private key in 1Password item **"CommandTabFree-Sparkle-EdDSA"** + GitHub Actions secret `SPARKLE_ED_PRIVATE_KEY` (env `production`); keychain account `commandtabfree`. `SUEnableAutomaticChecks=false` until a real appcast host exists. This fixed the launch-time "updater failed to start" alert.
- **Public release v100.0.3** (pre-release, **unsigned / not notarized**, universal x86_64+arm64) — ⌘⇥ menu-bar icon, redesigned About (KofTwentyTwo identity + AltTab attribution links), and a **full l10n rebrand** (product name → CommandTabFree across base/en + all ~20 translated languages; upstream attribution kept). Earlier v100.0.0/v100.0.1/v100.0.2 superseded.
- **Homebrew tap live:** `brew install --cask koftwentytwo/tap/commandtabfree` (`KofTwentyTwo/homebrew-tap` → `Casks/commandtabfree.rb`). Cask strips the quarantine (postflight `xattr`) — was needed for the unsigned v100.0.x; now that v100.1.0 is notarized, drop that postflight and bump the cask.
- **CI/CD wired** (GitHub Actions), **fully green on push** (run `27634828800`, commit `6070f0cb`). Passing: Guard A/B/C, commitlint, generated-files gate, version compute, `run_tests.sh`, ad-hoc `build_app.sh`. The sign/notarize/appcast/semantic-release/gh-release tail is gated on a `release-gate` step that **auto-activates** when `APPLE_*` secrets are added — secrets were added 2026-06-16 and the **full tail is now proven** (signed+notarized **v100.1.0**, run `27643432063`). Fixed this session (2026-06-16): regenerated base `Localizable.strings` to clear the genstrings gate; build ad-hoc until the owner provisions the Apple cert (was inheriting upstream's Developer ID → exit 65). Earlier this session: Guard B grep now matches the shipped `guard false else { return .pro }` patch (was checking the PLAN's never-shipped `if false`/`alt-tab-free [depaywall]` form); `update_appcast.sh` → `--ed-key-file`; commitlint sync-merge `ignores`; dropped upstream-only scripts (AppCenter/website); commit headers must be ≤72 chars. Full status: `docs/CICD-STATUS.md`.
- **Local daily driver installed:** `/Applications/CommandTabFree.app` = **Release** build (no QA menu) signed with the `Local Self-Signed` cert (stable signature → no TCC re-grant loop). Remaining: user grants Accessibility + Screen Recording on first launch.
### Build recipes (verified this session)
- **Local (stable, no QA menu):** `xcodebuild -project alt-tab-macos.xcodeproj -scheme Release -derivedDataPath DerivedData CURRENT_PROJECT_VERSION=<v> CODE_SIGN_IDENTITY="Local Self-Signed" CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM="" OTHER_CODE_SIGN_FLAGS="--timestamp=none" CODE_SIGNING_ALLOWED=YES` (needs `scripts/codesign/setup_local.sh` cert first; user runs it — keychain/admin prompt).
- **Public artifact (ad-hoc):** same recipe but `CODE_SIGN_IDENTITY="-" CODE_SIGNING_REQUIRED=NO`. Zip with `ditto -c -k --sequesterRsrc --keepParent`.
- **GOTCHA — do NOT pass `-configuration Release`.** With `-scheme Release` it splits SPM package outputs and breaks clean-build module-map generation (`build/GeneratedModuleMaps/*.modulemap not found`, then "unable to resolve module Cocoa/Sparkle/..."). Use the scheme only (matches `scripts/build_app.sh`). A failed build can corrupt `build/`; recover with `rm -rf build DerivedData` then rebuild.
## ⏳ NEXT (owner-gated — `docs/EXECUTION-STATUS.md` §3)
- **Apple Developer ID** (long pole): set `CODE_SIGN_IDENTITY` in `config/local.xcconfig`, re-sign Sparkle helpers, notarize → re-release signed/notarized, then drop the cask's quarantine postflight.
- **Real DOMAIN/appcast** (kof22.com ready): replace `fork.invalid` in `local.xcconfig`, serve `/appcast.xml`, flip `SUEnableAutomaticChecks` back on.
- **Wire CI:** `update_appcast.sh` uses the deprecated `sign_update -s` — switch to `--ed-key-file` (or `--account commandtabfree`) for the new key; create `sync`/`conflict`/`chokepoint-refresh` labels; external cron for `upstream_sync.yml`.
## Gotchas
- Bundle id FROZEN (`com.koftwentytwo.commandtabfree`); keychain/UserDefaults suites derive from it.
- Don't `git revert 9147a4a8` (mixes paywall with a 1869-file reorg).
- Fork version line starts at **v100.0.0** (avoids collision with upstream tags merged into `master`).
-`aws`: use `AWS_PROFILE=kingsrook_root_admin aws …` (the `--profile` flag fails on the local wrapper).
_Updated 2026-06-16. The **signed + notarized publishing pipeline is LIVE** — **v100.1.0** is released (Gatekeeper-accepted). Remaining items are auto-update, the Homebrew cask, and upstream-sync. Full detail: `docs/CICD-STATUS.md`, `docs/EXECUTION-STATUS.md` §3._
## ✅ Signed + notarized releases — DONE (2026-06-16)
- [x] Apple Developer ID `Developer ID Application: James Maes (2X834TJ5MA)` provisioned; cert + app-specific password in `production` secrets (`APPLE_P12_CERTIFICATE`/`APPLE_P12_PASSWORD`/`APPLE_ID`/`APPLE_PASSWORD`/`APPLE_TEAM_ID`).
- [x]`CODE_SIGN_IDENTITY` + Manual style + `TEAM_ID` set in `config/local.xcconfig`.
- [x] Vendored `vendor/Sparkle/Helpers/*` committed re-signed under the fork Developer ID.
- [x] Full release tail proven on CI (run `27643432063`): signed → notarized → stapled → released. **v100.1.0** published.
## Auto-update (deferred by choice — appcast already publishes to gh-pages)
- [ ]**Real `DOMAIN`** — replace `fork.invalid` in `config/local.xcconfig` with the fork host (kof22.com).
- [ ] Serve the gh-pages `appcast.xml` at `https://<DOMAIN>/appcast.xml` (CNAME the gh-pages site).
- [ ]`git rm` the in-tree `appcast.xml` skeleton + gitignore it (feed is published out-of-tree to gh-pages); flip `SUEnableAutomaticChecks` back on (PLAN §3.2).
## Homebrew cask (now that builds are notarized)
- [ ] Drop the quarantine-strip postflight (`xattr`) from `KofTwentyTwo/homebrew-tap``Casks/commandtabfree.rb` — no longer needed for a notarized app.
- [ ] Bump the cask to v100.1.0.
## Upstream-sync automation
- [x]`.fork-sync-state` reset to `v11.3.0`; release tail no longer clobbers the cursor with the fork tag (`ci_cd.yml`).
- [ ]**Re-wire the cursor advance** into the upstream-sync merge flow so it records the synced **UPSTREAM** tag (not the fork release tag) — `upstream_sync.yml` (PLAN §4.2).
- [ ]**`SYNC_BOT_TOKEN`** — GitHub App installation token (or fork PAT with contents + pull-requests write). Default `GITHUB_TOKEN` does NOT re-trigger PR checks, so guards never run on the sync PR.
- [ ]**External cron** — hit `repository_dispatch` (type `upstream-sync`) from an external scheduler (immune to GitHub's 60-day in-repo `schedule:` auto-disable).
| `src/App.swift:206-207` | `if case .pro = LicenseManager.shared.state { return false }` / `return !ProTransitionManager.shared.hasSeenWelcome` | `willShowDay1WelcomeOnAppLaunch()` — defers first-launch Settings if the Day-1 welcome nag will show |
| `src/App.swift:328` | `ProTransitionManager.shared.onSwitcherShown()` | inside `showUiOrCycleSelection()` — decides Day-4 tour or post-expiration free-pass |
| `src/App.swift:428` | `ProTransitionManager.shared.onAction = { ProPromptHost.shared.dispatch($0) }` | binds the prompt-action emitter to the Day-X UI host |
| `src/App.swift:429` | `ProTransitionManager.shared.onAppLaunchComplete()` | starts the trial-nag scheduler |
| `src/App.swift:448` | `LicenseManager.shared.onBeforeProUnlock = { ProTransitionManager.shared.onProUnlocked() }` | restore Pro prefs on activation |
| `src/App.swift:449-459` | `LicenseManager.shared.onStateChanged = { state in … }` | the big state-change fan-out (see below) |
| `src/Menubar.swift:202-227` | `updateBadgeDotOverlay()` — gated on `ProTransitionManager.shared.shouldShowBadgeDot` (orange nag dot on the menubar icon) |
| `src/Menubar.swift:235-390` | `UpgradeMenuItemView` — the entire gradient/shine "Get Pro" menu cell, uses `ProGradient.makeLayer()` and `App.upgradeToPro()`; `updateContent(_ state: LicenseState)` renders trial-days-remaining |
> This is the **single source of truth** for whether the user has Pro. Every Pro gate and upsell prompt in the app reads from `LicenseManager.shared`. Removing the paywall cleanly = making this subsystem report "always Pro/free-unlocked" (or stubbing it out) without breaking the public surface the rest of the app links against.
---
## 1. The `LicenseState` model
Defined in `src/pro/license/LicenseState.swift:1-21`. It is a flat 4-case `enum LicenseState: Equatable` — **there is no separate "lifetime" enum case**:
```swift
enum LicenseState: Equatable {
case trial(daysRemaining: Int) // active trial, day count baked in
case pro // valid paid license (any variant)
case proExpired // version-limited Pro past its version cutoff
case trialExpired // trial over, OR a defensively-invalidated license
}
```
Members:
- `isProAvailable: Bool` (`LicenseState.swift:7-12`) — `true` for `.trial` and `.pro`; `false` for `.proExpired` and `.trialExpired`. This is the "can the user use Pro features right now" gate.
- `debugProfileLabel: String` (`LicenseState.swift:14-20`) — `"Trial"`, `"Pro"`, or `"Free"` (both expired cases map to `"Free"`). Used only by `src/secondary-windows/DebugProfile.swift:17`.
### "Lifetime" vs "Pro" vs "version-limited" — these are *variants*, not states
The free/trial/pro/lifetime distinction the audit brief asks about is split across two mechanisms:
2. **Variant slug** (a string from the backend, persisted in Keychain account `variantId`) — sub-classifies a `.pro` license:
- **Lifetime**: `LicenseManager.lifetimeVariants: Set<String> = ["pro_lifetime"]` (`LicenseManager.swift:25`). A lifetime variant has *no version cutoff, ever*.
- **Version-limited Pro**: `LicenseManager.versionLimitedVariants: [String: String] = [:]` (`LicenseManager.swift:28`) — maps a variant slug to a max supported version string (e.g. `"variant_slug": "X.Y.Z"`). **Currently empty**, so in this build every `.pro` variant behaves as unlimited and `.proExpired` is unreachable in practice (confirmed by `testVersionLimitedPastCutoffIsProExpired` in the specs, `LicenseManagerSpecs.md:55-56`).
- **Regular Pro**: any variant slug that is neither lifetime nor version-limited.
`isLifetimeVariant` (see §6) reads the persisted `variantId` and tests membership in `lifetimeVariants`. There is **no**`.lifetime` enum case — lifetime is just `.pro` + a particular variant slug.
### State transitions (from `LicenseManagerSpecs.md:18-23`)
```
.trial(daysRemaining) → .pro (user activates a key)
.trial(daysRemaining) → .trialExpired (14 days elapse)
3. **Rollback invariant** (`LicenseManager.swift:117-126`): each write is attempted in order; if any returns a status other than `errSecSuccess`, *every previously-attempted account is removed* and the call fails with `LicenseAPIError.keychainWriteFailed(account:status:)`. Validation timestamps and email are **not** written in this path, so the user can never end up half-activated. The spec (`LicenseManagerSpecs.md:29`) calls this "the most important invariant in the file."
- `deactivate(completion:)` — `LicenseManager.swift:142-166`. Requires both `licenseKey` and `instanceId` in Keychain (else `LicenseAPIError.invalidKey`); calls `api.deactivate`, and on success **removes all three Keychain accounts**, clears `lastValidation`, `lastValidationResult`, and `customerEmail` from defaults, then recomputes via `computeTrialState()` (→ `.trial(n)` if still inside the 14-day window, else `.trialExpired`). Consumed by `UpgradeTab.swift:405`.
- `deactivateInstance(licenseKey:instanceId:completion:)` — `LicenseManager.swift:170-174`. Remote-deactivates *another machine's* seat without touching any local state. Used to reclaim a seat after a `seatLimitExceeded` error (`UpgradeTab.swift:389`).
- `revalidateWithServer()` — `LicenseManager.swift:209-232` (see §4).
---
## 3. Remote server validation — the actual API
### Base URL resolution
`Endpoints` (`src/api/Endpoints.swift:3-13`) reads two Info.plist keys, themselves fed from `config/base.xcconfig`:
| Constant | Source | Resolved value (this build) |
`Info.plist:75-78` declares `Domain → $(DOMAIN)` and `ApiDomain → $(API_DOMAIN)`. (Note `feedbackUrl` at `Endpoints.swift:12` is `https://alt-tab.app/api/v1/feedback` — a separate, non-license endpoint owned by the feedback window.)
`RemoteLicenseClient` (`src/pro/license/RemoteLicenseClient.swift`) is a `struct` conforming to the `LicenseAPI` protocol. All three calls are **POST**, `Content-Type: application/json`, `Accept: application/json`, body via `JSONSerialization`, executed on `URLSession.shared` (`RemoteLicenseClient.swift:103-128`). The file header comment (`:1-5`) describes it as "our own licensing backend at `alt-tab.app/v1/license/*`", provider-agnostic; the `LicenseManagerSpecs.md:13` calls the backend "LemonSqueezy-backed."
"trial_started_at": <int epoch seconds> // only if LicenseManager.shared.trialStartDate != nil
}
```
`trial_started_at` (`:23-25`) lets the backend report trial→paid conversion latency (backend takes MIN across machines; omitted after deactivation clears the local trial start).
`post<T>` (`RemoteLicenseClient.swift:103-128`): network error → forwarded; nil data → `noData`; decode failure logs `statusCode`+body and, if the raw JSON has a string `error` field, surfaces `apiError(message)`, else `invalidResponse(debugInfo:)`. **Note:** no HTTP status-code gating — a 4xx/5xx with a decodable body is treated as a normal response.
---
## 4. Async revalidation (background re-check)
- `scheduleAsyncRevalidationIfNeeded()` (`LicenseManager.swift:202-207`): on `initialize()`, computes `elapsed = now - defaults["lastValidation"]`; only calls `revalidateWithServer()` if `elapsed >= revalidationInterval`.
- `revalidateWithServer()` (`:209-232`): requires `licenseKey` + `instanceId` in Keychain (else returns). On `api.validate` success: refreshes `lastValidation` timestamp, stores `response.valid` into `lastValidationResult`, persists a returned `variantId` to Keychain, and sets `state = computeState()` if valid or `.trialExpired` if invalid. Network failure is swallowed (`:227-228`) — state and timestamp preserved, retried next launch. Also called directly from the QA menu "Revalidate" button (`src/debug/QAMenu.swift:221`).
---
## 5. State computation, machine fingerprint, persistence
### `computeState()` / `computeTrialState()`
- `computeState()` (`LicenseManager.swift:176-190`): if a `licenseKey` exists in Keychain — read `lastValidationResult`; if missing/false → **defensively**`.trialExpired` (`:179`); if a `variantId` exists and is in `versionLimitedVariants` and `currentAppVersion()` numerically exceeds the cutoff → `.proExpired` (`:180-186`); otherwise `.pro`. If no license key → `computeTrialState()`.
- `computeTrialState()` (`:192-200`): persists `trialStartDate` on first ever call (never reset on relaunch); `daysSince = floor((now - start)/86400)`; if `>= trialDuration` → `.trialExpired`, else `.trial(daysRemaining: trialDuration - daysSince)`.
- `trialDuration = 14` (`:17`), 0-indexed: day 0 → `.trial(14)`, day 13 → `.trial(1)`, day 14 → `.trialExpired` (`LicenseManagerSpecs.md:27`).
- `currentAppVersion: () -> String` (`:42-44`) defaults to `CFBundleShortVersionString`; overridable for tests.
### Machine fingerprint
`MachineFingerprint.get(keychain:)` (`src/pro/license/MachineFingerprint.swift:9-20`): primary source is the IOKit `IOPlatformUUID` of `IOPlatformExpertDevice` (`:11-14`). Fallback: a previously-stored value at Keychain account `"machineFingerprint"` (`:5,:16`); if neither exists, generates a random `UUID().uuidString`, persists it to that Keychain account, and returns it. Only consumer is the `activate` request body (`RemoteLicenseClient.swift:18`). `validate`/`deactivate` use the stored `instanceId` instead.
- `removeAll()` (`Keychain.swift:74-88`, DEBUG only) wipes everything under the service — used by the QA "Mock fresh install" action.
> **Code-signature coupling (from AGENTS.md):** Keychain access is tied to the app's Developer ID / TeamID / bundle id. If the free build changes the bundle id or signing identity, these items become inaccessible — but for a free build that no longer reads them, that is harmless.
**HTTP cookie** — `syncLicenseCookie(state:)` (`src/pro/license/LicenseCookie.swift:5-22`) sets a `license` cookie on `.alt-tab.app` (host from `Endpoints.website`), value `"pro"` / `"proExpired"` / `""`, `secure`, `distantFuture` expiry, in `HTTPCookieStorage.shared`. Purpose: Sparkle's appcast request can be tailored per tier. Called from the `onStateChanged` hook (`src/App.swift:451`).
---
## 6. PUBLIC SURFACE — what the rest of the app depends on
`LicenseManager.shared` (`LicenseManager.swift:7-15`) is the singleton; the wider app touches **only** the members below. Each entry lists definition site + every external call site, since this is what a free-build rewrite must keep compiling.
`true` for `.proExpired`/`.trialExpired`, `false` for `.pro`/`.trial`. **The most widely consumed gate.** Drives degradation of Pro prefs and ghosting of Pro UI:
Fired from `state.didSet` on initialize and on every transition. Wired once in `src/App.swift:449-459` to: refresh menubar license items, sync the Sparkle cookie, notify `ProTransitionManager`, refresh `UpgradeTab`/upgrade button, reset preference-dependent components, and post `proLockStateDidChangeNotification`.
Fired **before**`state` flips to `.pro` (in `activate``:132` and `mockProUser``:276`) so observers can snapshot pre-Pro selections. Wired in `src/App.swift:448` to `ProTransitionManager.shared.onProUnlocked()`.
### `initialize()` (`LicenseManager.swift:91-94`)
Sets `state = computeState()` then schedules async revalidation. Called once at startup: `src/App.swift:460`.
- `trialStartDate: Date?` (`:74-77`) — read internally by `RemoteLicenseClient.activate` (`:23`).
- `LicenseManager.trialDuration` (static, `=14`) — referenced for trial messaging.
- DEBUG-only QA helpers `mockTrialUser`/`mockTrialExpired`/`mockTrialDay`/`mockProUser` (`:234-279`) — `src/debug/QAMenu.swift:151,239` etc.
---
## 7. Tests & specs (skim)
- `LicenseManagerSpecs.md` — prose spec; the load-bearing rules are in §"Behavior & edge cases" (`:25-34`): 14-day 0-indexed trial, no grace period, all-or-nothing Keychain writes with rollback, defensive expiry, 30-day throttled revalidation, synchronous compute on initialize, callback ordering.
- `LicenseManagerTests.swift` — 1:1 with the spec's scenario list. Tests inject `MockClock` (`:475`), `MockKeychain` (`:482`), `MockLicenseAPI` (`:504-527`) and an isolated `UserDefaults(suiteName:)` per test (`:13-23`). They never set `onStateChanged`/`onBeforeProUnlock` side effects unless asserting on them. `mockProUser`-related test is `#if DEBUG`-guarded.
---
## 8. Removal notes for a fully-free build
**Goal:** every feature unlocked, no trial nag, no network, no Keychain license reads — without breaking the ~30 external call sites in §6.
Recommended strategy (lowest blast radius): **keep the `LicenseManager.shared` public surface, gut the internals.**
- Make `state` permanently `.pro` (or add an always-`true` short-circuit). Then `isProLocked` is always `false` and `isProAvailable` always `true` automatically — most consumers (§6) need no edits.
- Stub `activate`/`deactivate`/`deactivateInstance`/`revalidateWithServer` to no-op-success (or delete and remove the 2 activate call sites + Upgrade tab management). The `LicenseAPI`/`RemoteLicenseClient`/`MachineFingerprint`/`LicenseCookie`/`Keychain` files can then be deleted entirely, since nothing else references them.
- `customerEmail` → return nil; `isLifetimeVariant` → return false (or true). Their consumers are all in `SettingsWindow`/`UpgradeTab` account UI, which is being removed with the paywall anyway.
- `onStateChanged`/`onBeforeProUnlock`/`initialize()`/`refreshState()` can become no-ops but should remain as symbols until the `App.swift` wiring (`:448-460`), `Menubar`, and `SettingsWindow` callers are also cleaned up — otherwise the build breaks.
**What is safe to delete outright** (no external readers beyond the license subsystem itself + the paywall UI being removed): `Clock.swift`, `MachineFingerprint.swift`, `LicenseCookie.swift` (also drop the `onStateChanged` call to `syncLicenseCookie` in `App.swift:451`), `RemoteLicenseClient.swift`, `Keychain.swift`, `LicenseAPI.swift`, `LicenseManagerTests.swift`, `LicenseManagerSpecs.md`.
**Coupled removals to coordinate (outside this audit's scope but flagged):**
- `src/api/Endpoints.swift:11``licenseApiBaseUrl` becomes dead; `Info.plist`/`config/base.xcconfig``ApiDomain` only feeds license + feedback URLs (feedback at `Endpoints.swift:12` is independent — keep `apiDomain` if feedback stays).
- The Sparkle appcast tier cookie (`LicenseCookie.swift`) means the free build's updater no longer differentiates tiers — verify the appcast still serves the free build without the `license` cookie.
- `App.swift:473-485` URL-scheme `activate` handler and the whole `UpgradeTab` should be removed with the paywall UI.
**Risks:**
- The `private(set) var state``didSet` fires `onStateChanged` — if you hardcode `state = .pro` at init time, ensure the `onStateChanged` hook (and `ProTransitionManager`) tolerate being invoked, or unset the hook in the free build.
- Existing installs have stale Keychain items under `com.lwouis.alt-tab-macos.license` and UserDefaults under the same suite; a free build can ignore them, but leaving them is cosmetically untidy.
- Bundle id is still `com.lwouis.alt-tab-macos` (upstream's). Republishing under a new identity will change the Keychain service name anyway (moot once license reads are gone), but is a signing/identity decision per AGENTS.md.
# 05 — Trial / "Pro Transition" Nag-Escalation System
Scope: `src/pro/scheduling/` (the timed/escalating upsell machinery). This is the layer that
schedules and fires the Day1→Day35 nag prompts, computes the "hard gate" that blocks Pro features
after trial expiry, and persists which prompts have already been shown. It sits on top of the
license layer (`src/pro/license/`) and the feature-gating layer (`src/pro/ProFeature.swift`).
---
## 1. Architecture at a glance
The system is split into four cooperating pieces plus eight UI classes:
| Piece | File | Role |
|---|---|---|
| Coordinator | `ProTransitionManager.swift` | Singleton. Wires license state + persisted state + scheduler + UI emitter. Holds the public hooks App.swift calls. Owns the session-only free-pass flag. |
| Scheduler | `ProTransitionScheduler.swift` | Owns the persisted `nextScheduledDate`, the in-flight `DispatchWorkItem`, and `computeNextFireDate()`. Fires `onFire` (→ `evaluateAndShow()`). |
| Persisted state | `ProTransitionState.swift` | All `hasSeen*` flags + `remembered*` Pro indices in the `<bundleId>.license` UserDefaults suite, keys prefixed `proTransition.`. Snapshots into the pure decision struct. |
| Pure decision logic | `ProTransitionManagerTestable.swift` | No singletons / no `Date()` / no UI. `State` struct + `evaluate*` functions. The actual state machine. 100% unit-tested by `ProTransitionTests.swift`. |
| UI dispatch | `src/pro/ui/ProPromptHost.swift` | Receives `ProPromptAction` enum cases and maps them to concrete Day-X window/popover classes. |
The coordinator never references AppKit Day-X classes directly. It emits a `ProPromptAction`
(`ProTransitionManager.swift:87-98`) through `onAction`; `ProPromptHost.dispatch(_:)`
(`ProPromptHost.swift:11-38`) owns the concrete `.show()` calls.
### Time / day model
- `daysSinceTrialStart` is **0-indexed**: Day 1 = 0, Day 12 = 11, Day 15 = 14, Day 21 = 20,
Day 35 = 34, Day 49 = 48 (`ProTransitionManagerTestable.swift:11`, computed in
| **Day 12** | 11 | **[B] Heads-Up** `Day12HeadsUpPopover` | Timed, only on the single day `daysSinceTrialStart==11`, inside a time window (`Testable:71-73`). Scheduler drops the candidate once `< day13Start` (`Scheduler:71-76`). | NSPopover under menubar icon, 300×100, "Your Pro trial ends in 2 days", "Not now" + "Get Pro". Also refreshes badge (`ProPromptHost.swift:17-19`). | `hasSeenDay12` | Soft | If missed in 10:00 window, try 15:30 window; **skip entirely** on Day 13+ (no retry). |
| Days 13–14 | 12–13 | Badge dot | `shouldShowBadgeDot` = `!isPro && isTrialActive && 12 <= days <= 13` (`Testable:131-133`). Read by `Menubar.swift:205`. | Orange/red dot on menubar icon. | — | Soft | Removed on Day 15 (days≥14). |
| **Day 15 (expiry)** | 14 | License flips to `.trialExpired`; **HARD GATE arms** | On the license state change, `onLicenseStateChanged()` (`Manager:171-182`) calls `onProLockEngaged()` because `isProLocked` is now true. | Degradable Pro prefs (style, size, shortcut style) immediately downgrade to Free equivalents and the Pro value is snapshotted into `remembered*` (`ProTransitionState.onProLockEngaged`, `ProTransitionState.swift:73-79`). | — | **HARD** | — |
| **Day 15+** | ≥14 | **[D] Proactive** `Day15ProactiveWindow` | Timed, only if **no hard-gate fired yet**: `days>=14 && !hasSeenProactiveDay15 && !hasSeenFullUpgrade && isInTimeWindow` (`Testable:77`). | NSWindow 380×280, "Your 14-day Pro trial just ended", two-stat usage hero, "Get Pro" / "Maybe later". `showProactiveDay15Window()` also calls `onProLockEngaged()` (`Manager:253-257`). | `hasSeenProactiveDay15` | Soft (window) | Retry next active day until shown or [C] fires. Scheduler stops offering it once `hasSeenFullUpgrade` (`Scheduler:79`). |
| **Day 15+** | ≥14 | **[C] Full Upgrade** `Day15FullUpgradeWindow` | (a) First **switcher open** post-expiry → free-pass + [C] (`evaluateSwitcherOpen` → `.triggerFreePass`, `Testable:123-124`); or (b) first **hard-gate attempt** via the free-pass ladder (`evaluateHardGate`, `Testable:101-106`). | NSWindow 440×340, header resolved by `HardGateReason.resolved` priority (`Manager:40-73`), usage hero, "Get Pro" / "Continue with Free". `showFullUpgradeWindow` sets `hasSeenFullUpgrade=true` then `onProLockEngaged()` (`Manager:244-251`). | `hasSeenFullUpgrade`, `freePassUsed`, `hasTriggeredPostExpirationSwitcher` | **HARD** (this is the lock-in moment) | N/A — user-initiated. Once-ever. |
| **after [C]** | ≥14 | **[E] Hard-Gate Popover** `Day15HardGatePopover` | Every hard-gate attempt **after** [C] shown: `evaluateHardGate` returns `.showHardGatePopover` when `freePassUsed && hasSeenFullUpgrade` (`Testable:101-106`). | NSPopover under menubar icon 280×100, header from same `resolved` priority, "Not now" / "Get Pro". | — | **HARD** (feature is blocked, returns `false`) | Fires on **every** post-[C] hard-gate attempt forever (continues after opt-out). |
| **Day 21–34** | 20–33 | **[F] Day 21 Reminder** `Day21ReminderPopover` | Timed: `days>=20 && days<34 && !hasSeenDay21 && isInTimeWindow` (`Testable:88`). Checked **after** Day 35 so a long-inactive user skips straight to [G]. | NSPopover 300×140, "AltTab Pro is still available", `ProConversionCopy.day21Body()`, "Not now" / "Get Pro". | `hasSeenDay21` | Soft | Retry daily; skipped if Day 35 arrives first (`hasSeenDay35` guard, `Scheduler:86`). |
| **Day 35–48** | 34–47 | **[G] Day 35 Final** `Day35FinalWindow` | Timed: `days>=34 && days<48 && !hasSeenDay35 && isInTimeWindow` (`Testable:83`). Checked **before** [F] (`Testable:82`). | NSWindow 380×280, "Still interested in Pro?", usage hero, "Get Pro" / **"No thanks — don't ask again"** (→ `userOptedOut=true`, `Day35FinalWindow.swift:37-41`). | `hasSeenDay35`, `userOptedOut` | Soft | Retry daily; **give up at Day 49** (`days<48` upper bound; scheduler caps at `day49`, `Scheduler:92-99`). ⨉ sets `hasSeenDay35` but does NOT opt out. |
| **Day 49+** | ≥48 | — | Scheduling complete (`isSchedulingComplete`, `Testable:140`). | Steady state: [E] on every hard-gate attempt; degradable features silently fall back; purchase only via Preferences. | — | **HARD** persists | — |
After all proactive prompts: `evaluateTimedAction` falls through to `.refreshBadgeDot` for `days>=14`
(`Testable:93-95`), which just re-paints the (now-removed) badge.
---
## 5. Soft nags vs. the HARD GATE — exactly what gets locked
**Soft nags** = [A], [H], [B], [D], [F], [G] and the badge dot. These are pure informational
windows/popovers. They never block functionality. Each is gated once-ever by a `hasSeen*` flag.
Dismissing them does nothing except set the flag (except [G]'s opt-out link, which sets
`userOptedOut`).
**The HARD GATE** is the feature-blocking mechanism that arms at trial expiry (Day 15). Two layers:
# 06 — ProFeature Model & Marketing/Conversion Copy
Scope: `src/pro/ProFeature.swift`, `src/pro/ProFeatureCopy.swift`, `src/pro/ProConversionCopy.swift`, and the Pro-gating machinery in `src/preferences/PreferenceDefinition.swift`. Cross-references to call sites are included so a clean free-build removal can be planned without breaking compilation.
All paths are absolute under `/Users/james.maes/Git.Local/kof22/alt-tab-free`.
---
## 1. The `ProFeature` model
Defined in `src/pro/ProFeature.swift:8`. `ProFeature` is an `enum: Equatable, Hashable` that is described in its own doc comment (`src/pro/ProFeature.swift:3-7`) as the "single source of truth" for: which preferences degrade on lock, which runtime actions are hard-gated, which preference keys the click-interceptor routes to the Upgrade tab, and which copy the Day-X views show.
### 1.1 Cases (`src/pro/ProFeature.swift:9-16`)
```swift
case appIconsAndTitlesStyle // degradable preference
case autoSize // degradable preference
case searchOnReleaseShortcut // degradable preference
case extraShortcut(index: Int) // hard-gated runtime action, no stored pref
case searchInSwitcher // hard-gated runtime action, no stored pref
case lockSearchInSwitcher // hard-gated runtime action, no stored pref
```
### 1.2 `GateKind` and `gateKind` (`src/pro/ProFeature.swift:18-33`)
Three gate kinds:
- `.degradable` — silent fallback only; the stored value is downgraded on lock; never triggers the `[C]` upgrade prompt by itself.
### 1.3 `gatedPreference` — link to the backing preference (`src/pro/ProFeature.swift:35-44`)
Returns the type-erased `AnyProGatedPreference` (defined in `PreferenceDefinition.swift`) that backs a degradable feature, or `nil` for hard-gated cases:
- `attemptUse() -> Bool` (`src/pro/ProFeature.swift:74-83`): returns `true` if Pro is available or a free-pass session is active; otherwise hard-gated cases consult `ProTransitionManager.shared.attemptHardGatedFeature(self)` while degradable cases always return `true` (they are gated at preference-write time, not use time).
- `isStoredValuePro(preferenceKey:)` (`src/pro/ProFeature.swift:87-89`): static helper that delegates to `ProGatedPreferences.forPreferenceKey(...)?.isStoredValuePro()`. **Note:** the `snapshotAndDowngradeStored` / `restoreStored` / `preferenceKey` / `rememberedKey` methods named in the audit prompt no longer live on `ProFeature` — the doc comments in `PreferenceDefinition.swift:16-17` and `:99-101` explicitly state this logic was extracted out of `ProFeature` into `PreferenceDefinition`. They are documented in section 3 below at their current home.
View-layer presenter for trial-nag prompts. Reads from `UsageStats` and emits formatted upsell strings. All strings are `NSLocalizedString` and are pure upsell/nag copy.
### 2.3 Adjacent upsell copy that references this scope (out of file, in scope by linkage)
These strings live in sibling files but are wired directly to `ProFeatureCopy`/feature names and are also upsell copy slated for deletion:
- `src/pro/scheduling/Day1WelcomeLetterWindow.swift:20-26` — welcome/"now has a Pro tier" titles and the 14-day-trial body copy; `:36``"Start my 14-day trial"`; `:75-76,89-92` table headers (`"Feature"`, `"Free"`, gradient "Pro" header) and the two always-true free rows (`"Reliable, fast window switching with high-quality thumbnails"`, `"Dark Mode, live preview, window controls, trackpad gestures, and more"`).
- `src/pro/scheduling/Day15FullUpgradeWindow.swift:22-44` — supporting lines: `"AltTab Pro adds 4 features beyond the free switcher."`, `"Some Pro features have reverted to free defaults."`, plus per-reason lines (`"Extra shortcuts are a Pro feature."`, `"Search is a Pro feature."`, `"The App Icons style is a Pro feature."`, `"The Titles style is a Pro feature."`); `:59``"Get Pro"`; `:66``"Continue with Free"`.
- `src/util/UsageStatsTestable.swift:4-9` — feature display names fed into `ProConversionCopy.day12Subtitle()` via `UsageStats.usedProFeatureNames()`: `"Search"`, `"App Icons appearance"`, `"Titles appearance"`, `"Extra shortcuts"`.
The methods the prompt calls `snapshotAndDowngradeStored` / `restoreStored` / `isStoredValuePro` / `preferenceKey` / `rememberedKey` have been refactored out of `ProFeature` and now live on `PreferenceDefinition<T>` / `AnyProGatedPreference` in `src/preferences/PreferenceDefinition.swift`.
Single-source-of-truth per macro preference. Carries `key`, `default`, optional `gate`. Methods:
- `read() -> T` (`:32-44`) — reads stored value via `CachedUserDefaults.macroPref`; if locked + gated, three branches: (1) active free-pass + valid remembered index → return remembered Pro selection; (2) stored value is still Pro → return `freeEquivalent`; (3) otherwise return stored (already downgraded).
- `snapshotAndDowngrade() -> Int?` (`:51-57`) — if stored value is a Pro selection, overwrite with `freeEquivalent` via `Preferences.set(...)` and return the original index for the caller to persist under `rememberedKey`. Returns `nil` if no gate or already free.
- `restore(from rememberedIndex:)` (`:62-65`) — writes the remembered index back via `Preferences.set(key, ..., false)` (notify:false so the restore pass doesn't bounce observers to Upgrade).
- `isStoredValuePro() -> Bool` (`:69-73`) — true when the stored value is a Pro selection, regardless of lock state. This is what `ProFeature.isStoredValuePro(preferenceKey:)` and `PreferencesEvents` consult.
Holds all gated `PreferenceDefinition`s, the `all` list, and `forPreferenceKey(_:)` lookup. See section 4 for the value-level mapping.
### 3.5 Where the passes are driven (`src/pro/scheduling/ProTransitionState.swift`)
- `onProLockEngaged()` (`:73-79`) — iterates `ProGatedPreferences.all`, calls `pref.snapshotAndDowngrade()`, and persists the returned index under `pref.rememberedKey`. Idempotent.
- `onProUnlocked()` (`:85-92`) — iterates `ProGatedPreferences.all`, restores from remembered index via `pref.restoreFromIndex(idx)`, then clears the remembered key.
- `resetAll()` (DEBUG, `:125-131`) — clears the remembered keys among other transition flags.
---
## 4. Definitive list of "Pro" features and their preference mappings
### 4.1 Degradable features (have a backing preference)
| ProFeature case | Backing `ProGatedPreferences` def | preferenceKey | rememberedKey | default | freeEquivalent | isProValue (= what counts as Pro) |
- `ShortcutStylePreference` (`:100-103`): `.focusOnRelease`, `.doNothingOnRelease`, `.searchOnRelease` → Pro = `.searchOnRelease`.
### 4.2 Per-shortcut override gated preferences (no direct ProFeature case)
`ProGatedPreferences.all` (`PreferenceDefinition.swift:157-164`) also includes three per-shortcut "override 0" definitions that are NOT directly mapped from a `ProFeature` case but ARE snapshotted/restored on lock/unlock. Their registered defaults are the FREE values, so `snapshotAndDowngrade` is a no-op unless the user explicitly set a Pro override:
### 4.3 Hard-gated features (no backing preference)
| ProFeature case | Gate site | Behaviour |
|---|---|---|
| `.extraShortcut(index:)` | `src/switcher/ShortcutAction.swift:56` — gates pressing shortcut slot index ≥ 1 | `attemptUse()` → `ProTransitionManager.attemptHardGatedFeature`. Also recorded by `UsageStats.recordTrigger` under `triggersExtraShortcuts`. |
| `.searchInSwitcher` | `src/switcher/main-window/TilesView.swift:92` (`enableSearchEditing`) | gated via `SearchModeResolver.enableEditing(...canSearch:)` |
| `.lockSearchInSwitcher` | `src/switcher/main-window/TilesView.swift:79` (`lockSearchMode`) | gated via `SearchModeResolver.lock(...canLockSearch:)` |
The four marketing-level "Pro features" advertised to users (per `Day15FullUpgradeWindow.swift:23-25` "adds 4 features" and the `Day1` table `:77-80`) are: **App Icons & Titles styles, Search, Auto Size, and up to 9 keyboard shortcuts.**
---
## 5. Cross-reference: all known consumers of this scope
| `ProFeature.isStoredValuePro(preferenceKey:)` | `src/events/PreferencesEvents.swift:62` (bounces to `UpgradeTab.navigateToUpgradeTab()` when locked) |
`SearchModeResolver` itself does not depend on `ProFeature` — it is purely a pure-state resolver that receives `canSearch`/`canLockSearch` booleans, so in a free build those callers can simply pass `true` (or be inlined) without touching `SearchModeResolver`.
---
## 6. Removal notes for a clean free build
1. **`attemptUse()` must become an unconditional `true`.** The three runtime gate sites (`ShortcutAction.swift:56`, `TilesView.swift:79,92`) all call `ProFeature.*.attemptUse()`. For a fully-unlocked build, either delete the `if !... { return }` guard at `ShortcutAction.swift:53-58` entirely, and pass `canSearch: true` / `canLockSearch: true` directly at `TilesView.swift:79,92`, or stub `attemptUse()` to always return `true`. Removing `ProFeature` wholesale requires editing all three call sites.
2. **`PreferencesEvents.swift:62` bounce-to-Upgrade must be removed.** This is the only consumer of `ProFeature.isStoredValuePro(preferenceKey:)`. Deleting the `if LicenseManager.shared.isProLocked && ProFeature.isStoredValuePro(...)` block (`:62-64`) prevents settings changes from yanking users to a (deleted) Upgrade tab. Note `UpgradeTab.navigateToUpgradeTab()` is itself paywall UI removed elsewhere.
3. **Preference gating (`PreferenceDefinition.read()`) must stop downgrading.**`read()` (`PreferenceDefinition.swift:32-44`) returns `freeEquivalent` when `LicenseManager.shared.isProLocked`. With a free build `isProLocked` should be hard-wired to `false` (license layer is audited separately), which neutralises `read()`, `snapshotAndDowngrade()`, and `onProLockEngaged()` without code changes here. Cleaner removal: strip the `gate:` arguments from all six `ProGatedPreferences` definitions (`:103-153`) so every preference is ungated, then `ProGatedPreferences.all` becomes empty and the snapshot/restore passes in `ProTransitionState` become no-ops. Confirm `Preferences.swift` getters still resolve through `PreferenceDefinition.read()` (referenced by the doc comment at `:99-100`) before stripping.
4. **`rememberedKey` migration risk.** Existing installs may have `proTransition.rememberedAppearanceStyle` / `...Size` / `...Style` (and the three `*Override` variants) persisted from a prior lock. If gating is removed without an unlock pass, a user's pre-lock Pro selection stays downgraded in `appearanceStyle`/`appearanceSize`/`shortcutStyle`. Consider running `onProUnlocked()` (`ProTransitionState.swift:85-92`) once on upgrade-to-free-build, or document that users may need to re-select their preferred style/size after migrating. The remembered keys are namespaced `proTransition.<key>` in UserDefaults.
5. **Copy deletion is self-contained.**`ProFeatureCopy.swift` and `ProConversionCopy.swift` can be deleted in full once `ProFeature.copy` (`ProFeature.swift:48-56`), `Day1WelcomeLetterWindow.swift:77-80`, `Day12HeadsUpPopover.swift:21`, and `Day21ReminderPopover.swift:18` are removed. The feature-name strings in `UsageStatsTestable.swift:4-9` and the `UsageStats.usedProFeatureNames()`/`usedProFeaturesSessionCount` telemetry feed only the conversion copy — they can be deleted with the nag system. `Day15FullUpgradeWindow.swift` supporting lines (`:22-44`) and `HardGateReason` are tied to the hard-gate ladder and are removed with that subsystem (covered by other audit sections).
6. **`UsageStats` trigger recording** (`src/util/UsageStats.swift:8-15`) exists only to power conversion copy and the Day-X nags. It has no functional effect on switching; safe to remove along with the prompt system, but it is harmless if left (writes to a separate `.usage` UserDefaults suite).
Releases are fully automated from Conventional Commits on `master`; there is **no manual version
bump**. `package.json``version` (`1.0.0`) is irrelevant — the app version is computed by semantic-release.
### Commit linting → version derivation
- `commitlint.config.js` extends `@commitlint/config-conventional`. CI enforces it across the pushed range: `npx commitlint --from "$GITHUB_EVENT_BEFORE" --to "$GITHUB_EVENT_AFTER"` (`ci_cd.yml:48`).
- `release.config.js` configures `@semantic-release/commit-analyzer` (angular preset) with extra `releaseRules` so `perf/docs/style/refactor/test/chore/ci` all force at least a **patch** bump (`release.config.js:6-14`). `feat` → minor, breaking → major (defaults).
- `scripts/determine_next_version.sh:5-8` runs `npx semantic-release --dry-run`, greps "The next release version is …", and writes the bare version (e.g. `11.3.0`) to `VERSION.txt` (the `$VERSION_FILE`, `ci_cd.yml:18`). `VERSION.txt` is **not committed** — it is a CI-only scratch file consumed by later steps.
### Version injection into the app
`scripts/replace_environment_variables_in_app.sh:7-11` writes a fresh `config/local.xcconfig`:
```
CURRENT_PROJECT_VERSION = <contents of VERSION.txt>
APPCENTER_SECRET = $APPCENTER_SECRET
```
`Info.plist` consumes `$(CURRENT_PROJECT_VERSION)` for both `CFBundleShortVersionString` and
`CFBundleVersion` (`Info.plist:26-29`), and `$(APPCENTER_SECRET)` for `AppCenterSecret` (`Info.plist:73-74`).
### Changelog, tag & GitHub Release
- `npx semantic-release` (`ci_cd.yml:59`) runs the full plugin chain in `release.config.js`: generates release notes → updates `changelog.md` (`@semantic-release/changelog`) → commits `changelog.md`, `appcast.xml`, `README.md` back to the repo (`@semantic-release/git`), and creates the git tag.
- `scripts/extract_latest_changelog.sh` writes `tag_name=v<version>` and the top changelog block to `$GITHUB_OUTPUT` (`:6-9`).
- `softprops/action-gh-release@v3` (`ci_cd.yml:62-66`) publishes the GitHub Release with that tag/body and attaches `DerivedData/Build/Products/Release/*.zip`.
- `changelog.md` is already populated with upstream history; entries link to `github.com/lwouis/alt-tab-macos/...` (e.g. `changelog.md:1`). The most recent committed release is **11.3.0** (matches `git log`).
---
## 3. Code signing & notarization
### Signing
- Release is signed by Xcode using `CODE_SIGN_IDENTITY = Developer ID Application: Louis Pontoise (QXD7GW8FHY)` (`release.xcconfig:5`) with hardened runtime + secure timestamp (`base.xcconfig:12`, `release.xcconfig:7`).
- The signing cert reaches CI via a base64 P12 secret: `scripts/codesign/setup_ci_master.sh:8` decodes `$APPLE_P12_CERTIFICATE` to `codesign.p12`, then `import_certificate_into_new_keychain.sh` creates/unlocks a dedicated `alt-tab-macos.keychain`, imports the P12 for `/usr/bin/codesign`, and runs `security set-key-partition-list` (`import_certificate_into_new_keychain.sh:12-19`).
- `setup_ci_pr.sh` (used on PRs, not wired into the current `master`-only workflow) generates a self-signed cert instead — PRs build but cannot notarize.
### Entitlements
`alt_tab_macos.entitlements`: `app-sandbox = false` (it needs Accessibility/Screen Recording) and
`com.apple.security.cs.disable-library-validation = true` (to load the embedded Sparkle/AppCenter
frameworks under hardened runtime).
### Sparkle framework re-sealing (build phase)
`scripts/copy_sparkle_helpers.sh` is a "Copy Sparkle Helpers" build phase that runs after Embed
Frameworks and before Xcode's final code sign. It copies the prebuilt `Updater.app` + `Autoupdate`
into `Sparkle.framework/Versions/A/`, mirrors SPM-generated resources, rewrites the framework
`CFBundleIdentifier` to `org.sparkle-project.Sparkle`, then **re-signs** the framework with
`EXPANDED_CODE_SIGN_IDENTITY`/`CODE_SIGN_IDENTITY` (final lines of the script). The `Updater.app`/
`Autoupdate` helpers are pre-signed with the maintainer's Developer ID at vendor time
2. Submit to Apple: `scripts/notarytool submit --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" <zip> --wait --timeout 15m` (`:14-19`). Exits non-zero unless status is `Accepted` (`:22`).
3. `xcrun stapler staple AltTab.app` (`:25`) then re-zips the stapled app over the same name (`:26`). That stapled zip is the GitHub Release asset and the Sparkle download.
The app's Developer ID, TeamID, and bundle ID must stay stable across builds: Keychain items
(license keys) are tied to the code signature. Changing any one orphans every user's stored
license. **For the free fork this is the reason §7 matters** — but since the fork is *removing*
the license/Keychain layer entirely, the invariant's coupling concern goes away once the `src/pro`
license code is gone (no Keychain license to orphan). The identity still must change because the
upstream Developer ID cannot be used.
---
## 4. Auto-update (Sparkle)
The app embeds **Sparkle 2.9.1** (`vendor/Sparkle/UPSTREAM`), vendored as source via SPM plus
prebuilt helper apps.
### Wiring in code
- `src/App.swift:407-413` builds an `SPUStandardUpdaterController(startingUpdater: false, updaterDelegate: App.sparkleDelegate!, ...)` and calls `startUpdater()` 30s after launch.
- `src/vendors/SparkleDelegate.swift:26-28` supplies the feed URL: `feedURLString → Endpoints.appcastUrl`.
- `src/api/Endpoints.swift`: `website = "https://\(domain)"` where `domain` is read from the `Domain` Info.plist key (`Endpoints.swift:4,6`); `appcastUrl = "\(website)/appcast.xml"` (`:7`). With `DOMAIN = alt-tab.app` that resolves to **`https://alt-tab.app/appcast.xml`** **[upstream feed — see §7]**.
- `feedParameters` (`SparkleDelegate.swift:30-38`) appends `version`, `macos`, `arch`, `lang` query params to the feed request.
- **Tier-aware appcast:**`src/pro/license/LicenseCookie.swift` writes a `license` cookie (`pro`/`proExpired`/empty) on the `alt-tab.app` domain so the appcast server can tailor responses per license tier (`LicenseCookie.swift:3,9-21`). This is paywall-coupled and goes away with `src/pro` removal.
- Update preferences are toggled in `src/events/UserDefaultsEvents.swift`, `src/events/PreferencesEvents.swift:85-86`, and `src/preferences/settings-window/tabs/GeneralTab.swift:100-101`. `Info.plist:63-66` sets `SUEnableAutomaticChecks = true`, `SUScheduledCheckInterval = 604800` (weekly).
### EdDSA signing
- The **public** EdDSA key is baked into `Info.plist:61-62`: `SUPublicEDKey = 2e9SQOBoaKElchSa/4QDli/nvYkyuDNfynfzBF6vJK4=`**[upstream key — see §7]**.
- The **private** key lives only in CI as `$SPARKLE_ED_PRIVATE_KEY`. `scripts/update_appcast.sh:9` signs each zip with `vendor/Sparkle/bin/sign_update -s $SPARKLE_ED_PRIVATE_KEY <zip>` and embeds the `sparkle:edSignature` + `length` into a new `<item>`.
### Appcast file (`appcast.xml`)
- RSS 2.0 with Sparkle namespace; `<title>alt-tab-macos</title>`. 3876 lines, newest item first (currently 11.3.0).
- `scripts/update_appcast.sh:11-26` builds an `ITEM.txt` block and `sed`-inserts it right after the `</language>` tag. Each `<enclosure url=...>` points at **`https://github.com/lwouis/alt-tab-macos/releases/download/v<version>/AltTab-<version>.zip`** (`update_appcast.sh:18`) **[upstream download host — see §7]**, and the release-notes link is **`https://alt-tab.app/changelog-bare`** (`:16`) **[upstream — see §7]**.
- `appcast.xml` is committed back to the repo by semantic-release (`release.config.js:21-26`). The *file* is what Sparkle fetches from `alt-tab.app/appcast.xml`, so the hosting website serves this committed file.
### Distribution surface summary
The download artifacts live on **GitHub Releases of `lwouis/alt-tab-macos`** (per the appcast
enclosure URLs and the `update_readme_and_website.sh` API calls), and the appcast/release-notes are
served from the **`alt-tab.app`** website. The website itself is a *separate* repo
- The app subclasses `AppCenterApplication` (`Info.plist:36-37``NSPrincipalClass`; `src/App.swift:7`). `src/vendors/AppCenterCrashes.swift:20` calls `AppCenter.start(withAppSecret: Secrets.appCenterSecret, services: [Crashes.self])`. The secret is read from the `AppCenterSecret` Info.plist key (`src/api/Secrets.swift:6`), injected from `$APPCENTER_SECRET` (`replace_environment_variables_in_app.sh:9`).
- Network is gated off unless a crash is actually sent (`AppCenterCrashes.swift:13-14, 49`).
- AppCenter is vendored (4.3.0 + PLCrashReporter 1.11.1, `vendor/scripts/update_appcenter.sh`).
- **dSYM upload:**`scripts/upload_symbols_to_appcenter.sh` zips and uploads `AltTab.app.dSYM` and `Sparkle.framework.dSYM` to `https://api.appcenter.ms/v0.1/apps/alt-tab-macos/alt-tab-macos` (`:4-7`) using `$APPCENTER_TOKEN`. **[upstream AppCenter account — see §7]**
> NOTE: Microsoft App Center is being retired (Microsoft announced March 31 2025 EoL). Even
> independent of the fork, this crash-reporting path is dead-ending and is a candidate for removal,
| 4 | **Notarization Apple account** | `ci_cd.yml:9-11`; `package_and_notarize_release.sh:15-18` | `$APPLE_ID` / `$APPLE_PASSWORD` / `$APPLE_TEAM_ID` | **C** — fork's Apple ID + app-specific password + TeamID. |
| 5 | **Sparkle public EdDSA key** | `Info.plist:62` | `2e9SQOBoaKElchSa/4QDli/nvYkyuDNfynfzBF6vJK4=` | **C** — generate a NEW EdDSA keypair (`generate_keys`); put public half here. |
| 6 | **Sparkle private EdDSA key** | `ci_cd.yml:13`; `update_appcast.sh:9` | `$SPARKLE_ED_PRIVATE_KEY` | **C** — fork's new private key. Must match #5 or updates won't verify. |
| 18 | **Vendored Developer-ID-signed Sparkle helpers** | `vendor/Sparkle/Helpers/Updater.app`, `Autoupdate` (re-signed by `copy_sparkle_helpers.sh`) | signed with maintainer Developer ID at vendor time | **C** — re-vendor/re-sign with fork's Developer ID, else `--deep --strict` verify may flag mismatched seals once the app id changes. |
### Notes for the removal plan
- Items **8, 11, 14** (license API, license cookie, AppCenter) are *removals* that should fall out
naturally when `src/pro/` and the AppCenter vendor wiring are stripped — they are not just
re-pointing.
- Items **1, 2, 3, 4, 5, 6** are the hard prerequisites to ship a notarized, self-updating fork:
without a new Developer ID + a new Sparkle EdDSA keypair the fork can neither notarize nor sign
its own updates. The `AGENTS.md` Keychain-orphaning concern is **moot** for the free fork because
the license/Keychain code is being removed — there is no stored license to orphan.
- The `appcast.xml` already in the repo contains ~3876 lines of upstream EdDSA-signed enclosures
pointing at upstream releases. A clean fork should **truncate/regenerate** it (old entries are
signed by the upstream private key #6 and reference upstream downloads #9), otherwise existing
AltTab users' Sparkle could see fork updates or vice-versa.
- `DEVELOPMENT_TEAM = "${TEAM_ID}"` (undefined) is harmless today but should be set to the fork's
TeamID (or left empty with explicit `CODE_SIGN_IDENTITY`) for clarity.
(crash reporting), feedback POST, Sparkle appcast — first-party, not paywall, but tied to
`alt-tab.app` and the lwouis signing identity.
---
## 7. Branding / identity strings that tie the binary to the paywalled product
Everything below is still the **upstream lwouis identity** — none of it has been rebranded
for "alt-tab-free" yet. Each is a string a clean free build must change or remove.
| What | Value | Location |
|---|---|---|
| App name | `AltTab` | `config/base.xcconfig:3` (`PRODUCT_NAME`), surfaced via `App.name``src/App.swift:13` |
| Bundle id | `com.lwouis.alt-tab-macos` | `config/base.xcconfig:4`; also hardcoded in `src/_test-support/Mocks.swift:133`; consumed by `UsageStats.swift:2` and the URL scheme `Info.plist:46-58` |
| Source-code / repo URL | `https://github.com/lwouis/alt-tab-macos` | `src/App.swift:16` (`App.repository`); shown as "Source code" link in `AboutTab.swift:19` |
| Account URL | `alt-tab.app/my-account` | `Endpoints.accountUrl``Endpoints.swift:10`; opened by `App.openAccount()`/`UpgradeTab.swift:273`; menubar "My Account" `Menubar.swift:48` |
| License API base | `https://alt-tab.app/api/v1/license` | `Endpoints.licenseApiBaseUrl``Endpoints.swift:11` (paywall) |
You can [suggest an enhancement or discuss an issue on github](https://github.com/lwouis/alt-tab-macos/issues), or use the feedback form in the app.
## Technical overview
This document gives a technical overview of the project, for newcomers who want to contribute.
## Building the project
This project has minimal dependency on Xcode-only features (e.g. InterfaceBuilder, Playgrounds). You can build it by doing:
* `scripts/codesign/setup_local.sh` to generate a local self-signed certificate, to avoid having to re-check the `System Preferences > Security & Privacy` permissions on every build
* Either open `alt-tab-macos.xcodeproj` with Xcode, or use the CLI: `xcodebuild -project alt-tab-macos.xcodeproj -scheme Debug` to build the .app with the `Debug` build configuration
## Mac development
Mac development ecosystem is pretty terrible in general. They keep piling on the tech stacks on top of each other, so you have C APIs, ObjC APIs, Swift APIs, Interface builder, Playgrounds, Swift UI, Mac Catalyst. All these are bridging with each other with a bunch of macros, SDKs glue, compiler flags, compatibility mode, XCode legacy build system, etc. For alt-tab, we are on Swift 5.0. Note that swift just recently started being stable, but overall any change of version breaks a lot of stuff. Swift itself is the mainstream language with the worst governance I’ve seen in modern times.
Regarding SDKs, it’s very different from other (better) ecosystems like Java. Here the SDK is bundled with XCode, and XCode is bundled with the OS. This means that from a machine running let’s say macOS 10.10, you have access to only a specific range of XCode versions (you can’t run the latest for instance), and these give you access to a specific range of SDKs (i.e. Swift + objc + c + bridges + compiler + toolchain + etc)
Documentation is abysmal. Very simple things are not documented at all, and good information is hard to find. Compared to other ecosystem I’ve worked on in the past like Android, nodejs, Java, rust, this is really a bad spot. You can truly tell Apple doesn’t care about supporting third-parties. They are in such a good position that people will struggle and just push through to deliver on their ecosystem because it is so valuable, and because they don’t have to care, they don’t. They could pay an intern to update the docs over the summer for instance, just to give you context of the lack of care we are talking about here.
Dependencies in this project are vendored under `vendor/` and consumed as local Swift Package Manager packages. Each dependency has an `update_*.sh` script under `vendor/scripts/` that re-fetches it from upstream, strips unused files, and regenerates its `Package.swift`. There is no remote dependency resolution at build time — `git clone && xcodebuild` is the full bootstrap.
OS APIs are quite limited for the kind of low-level, system-wide app AltTab is. This means often we just don’t have an API to do something. For instance, there is no API to ask the OS “how many Spaces does the user have?” or “Can you focus the window on Space 2?”. There are however, retro-engineered private APIs which you can call. These are not documented at all, not guaranteed to be there in future macOS releases, and prevent us from releasing AltTab on the Mac AppStore. We have tried our best to [document the ones we are using](https://github.com/lwouis/alt-tab-macos/blob/master/src/api-wrappers/private-apis/README.md), as well as [the ones we investigated](https://github.com/lwouis/alt-tab-macos/blob/master/src/experimentations/PrivateApis.swift) in the past.
## Project architecture
To mitigate the issues listed above, we took some measures.
We minimize reliance on XCode, InterfaceBuilder, Playground, and other GUI tools. You can’t cut the dependency completely though as only XCode can build macOS apps. Currently, the project has these files:
* 1 xib (InterfaceBuilder UI file, describing the menubar items like “Edit” or “Format”)
* `alt-tab-macos.xcodeproj` file describing AltTab itself. It contains some settings for the app
* `Alt-tab-macos.entitlements` and Info.plist which are static files describing some app config for XCode
* `vendor/` holds vendored open-source libraries (Sparkle, ShortcutRecorder, AppCenter) as local SPM packages. `vendor/scripts/update_*.sh` refresh them from upstream.
* Some `.xcconfig` files in `config/` which contain XCode settings that people typically change using XCode UI, but that I want to be version controlled
The project directory is organized in the following way:
| Path | Role |
|------|-------|
| `config/` | XCode build settings |
| `docs/` | supporting material to document the project |
| `resources/` | files that are shipped inside the final `.app` (e.g. icons) |
| `scripts/` | bash scripts useful for CI and local workflows |
| `src/` | Swift source code |
| `src/api-wrappers` | Wrapping some unfriendly APIs (usually C-APIs) |
| `src/logic` | Business logic (i.e. "models") |
| `src/ui` | UI code (e.g. sublasses of NSView or NSCollectionView) |
Other folders/files are either tooling or auto-generated (e.g. `DerivedData/` is generated by Xcode builds).
## QA
**alt-tab-macos** is deeply integrated with the OS and other apps. Thus doing end-to-end automated QA would be a nightmare. For the time being QA is done manually.
In an attempt to not have too many regressions, this documents will list OS interactions. This should be useful as some of them are very exotic and not many people know about them.
## List of use-cases
### Which windows to list, and be able to focus
* Minimized windows
* Windows from hidden apps
* Windows of fullscreen apps
* Windows of fullscreen apps with split-screen
* Windows merged into 1 as tabs (e.g. Finder "Merge All Windows", drag-and-drop a window onto an existing window, etc)
* Windows on multiple monitors
* Windows on multiple Spaces
* Should not show: dialogs, pop-overs, context menus (e.g. Outlook meeting reminder, iStats Pro menus)
### App is summoned during an OS animation
* The UI should only appear after the animation completes for:
* Space transition
* an app going fullscreen
* The UI should not show at all (i.e. ignore the shortcut) if Mission Control is open
* The UI should show instantly during:
* Window minimizing/de-minimizing
* Window maximizing (i.e. double-click the titlebar)
* An app is launching/quitting
### Thumbnail layout corner-cases
* Very small windows (i.e. smaller than the thumbnail min size)
* Very wide/tall windows
* Should show the app name for windows without a title
* Long titles should be truncated
* Many windows are opened
* There is no open window
* AltTab should appear on top of all windows, dialogs, pop-overs, the Dock, etc
### OS events to handle while AltTab’s UI is shown
* An app is launching/quitting
* A new window opens
* An existing window is closed
### Drag-and-drop on top of the thumbnails
* Drag-and-dropping a URL onto a window thumbnail should open it with that window’s app
* Drag-and-dropping a file onto a window thumbnail should open it with that window’s app
### System Preferences
* General > Appearance > "Dark": switches to Dark Mode
* General > Accent color > "Graphite": traffic lights on thumbnails should be gray
* Accessibility > Display > Reduce transparency: AltTab background should be a solid color
* General > Show scroll bars > "Always": regenerates all scrollbars
* Mission Control > "Displays have separate Spaces": changes Spaces behavior on multi-displays setups
### Spaces
* Spaces get created/destroyed
* A window is moved to another space by drag-and-dropping on the Spaces thumbnails at the top of the Mission Control UI
* A window is moved to another space by dragging it on the side of the current Space, and waiting for a Space transition, then dropping it
* A window is moved to another space by destroying the Space it is in
* An app is assigned to a specific space or all spaces by clicking its Dock icon > Options > Assign to
### Shortcuts
* The hold "key" can be multiple modifiers (e.g. `⌥⇧`)
* Shortcuts should have priority over system shortcuts such as `cmd+tab`, so the user can replace these
* The "select next window" shortcut can be modifiers, modifiers+key, or just key; it can also contain the same modifiers as the hold "key"
* All shortcuts, except the hold key, can be disabled by the user
* Shortcuts can include the `escape` and `delete` key; these should not stop recording shortcuts
* [Secure Input](https://github.com/lwouis/alt-tab-macos/issues/157#issuecomment-659170293) can prevent AltTab from listening to the keyboard
* Some shortcuts should only work when AltTab is open
* These shortcuts should active whether the hold shortcut is held or not
* Shortcuts should work with capslock active or inactive
* Shortcuts should repeat if kept pressed
* Repeat rate and initial delay should match the values set in `System Preference` > `Keyboard`
* when navigating left/right/up/down, the repeating behavior should stop when hitting the last window in the list. The user can then manually do the shortcut once more to cycle to the other side; it then repeats again
* The shortcut sets 1 and 2 should not interact with each other (e.g. opening AltTab with one, then using the other to navigate)
* Shortcuts can focus the window on release, or be pressing a key or using the mouse
* Keyboards from other countries have different layout which impact shortcuts
* e.g. the default ``` ⌥` ``` shortcut should become `⌥<` on a Spanish ISO keyboard
### Localization
* Right-to-left languages (e.g. arabic) have the whole layout reversed
* For the main window, even navigation is reversed
* For preferences and feedback windows, all layout is reversed
* Text length can vary per languages which can create layout issues
### Misc
* AltTab is launched after some apps/windows are already opened
* Displays/mouses/trackpads/keyboards get connected/disconnected while AltTab is used
<textx="330"y="158"font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', Helvetica, Arial, sans-serif"font-weight="400"font-size="22"fill="#8888a8">See every window. Switch in an instant.</text>
Loaded 100 of 1248 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.