Compare commits

..
3 Commits
1249 changed files with 52 additions and 143855 deletions

No files matched your search

-193
View File
@@ -1,193 +0,0 @@
---
name: assets-optimization
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:
```sh
find resources -type f \( -iname '*.pdf' -o -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.heic' -o -iname '*.svg' -o -iname '*.icns' \) \
-exec ls -la {} \; | awk '{printf "%8d %s\n", $5, $9}' | sort -k2
```
Group what you see by directory. For AltTab the relevant buckets are:
- `resources/icons/menubar/` — small template icons shown in the macOS menubar.
- `resources/icons/tabs/` — Preferences sidebar icons (template, sized ~13pt).
- `resources/icons/permission-window/` — first-launch permission window icons (~32pt).
- `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.
3. **`/StructTreeRoot`, `/ParentTree`, `/StructElem`** — accessibility tags ("Document" / "Part" structural roles). AppKit's PDF renderer ignores them.
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')]`.
5. **`/Lang`, `/MarkInfo`, `/Annots`, `/StructParents`, `/Tabs`** — empty or trivial page-level entries.
6. **`/ProcSet [/PDF]`** — deprecated since PDF 1.4.
Use the script:
```sh
python3 scripts/assets/optimize_figma_pdf.py resources/icons/menubar/*.pdf
```
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:
```sh
for f in resources/icons/menubar/*.pdf; do
sips -s format png "$f" --out "/tmp/$(basename $f .pdf).png" -Z 300 >/dev/null 2>&1
done
```
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:
`NSImage(systemSymbolName:).representations[0] (NSSymbolImageRep) → .perform("vectorGlyph") (CUINamedVectorGlyph) → .perform("CGPath") (real CGPath)`.
- 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.
Pipeline (built-in to macOS via `sips`):
```sh
sips -Z 1000 -s format heic -s formatOptions 50 input.png --out output.heic
```
- `-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.
Use the script for batch conversion:
```sh
bash scripts/assets/encode_heic.sh ~/Desktop 1000 50
```
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:
1. **PBXBuildFile section** — comment + the comment inside `fileRef = ... /* name.ext */`.
2. **PBXFileReference section** — comment, `lastKnownFileType` (e.g. `image.pdf`, `image.heic`, `image.png`), and `path = "name.ext"`.
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
- Preferences → Appearance — illustration thumbnails change per show/hide row
Compare `git diff --stat` before committing. Asset replacements should net negative on bundle size.
## Reporting
After the run, report:
- File-by-file before/after sizes for everything that changed.
- Total bundle delta in KB.
- Any files left untouched and why (e.g., `app.icns` — bundle-required format).
- The encoder settings used (especially HEIC quality if not q50, so the next person knows).
- Anything the visual review revealed (e.g., "had to bump to q60 for `thumbnails_dark` because gradient banding at q50").
-65
View File
@@ -1,65 +0,0 @@
---
name: audit-specs-tests
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)
├── FooSpecs.md # the living spec (not compiled)
└── FooTests.swift # XCTest (unit-tests target only)
```
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:
```sh
grep -nE '^\s*func test[A-Za-z0-9_]+\(' src/<path>/FooTests.swift
```
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.
-63
View File
@@ -1,63 +0,0 @@
---
name: coverage-explore
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 \
-enableCodeCoverage YES -resultBundlePath /tmp/altcov.xcresult | scripts/xcbeautify
```
If the build is red, stop and report — coverage on a failing suite is meaningless.
2. **Parse** `xcrun xccov view --report --json /tmp/altcov.xcresult`. Each `targets[].files[]` has
`path`, `lineCoverage`, `coveredLines`, `executableLines`, `functions[]`. For line-by-line counts of
one file while investigating a gap: `xcrun xccov view --file <abs-path> /tmp/altcov.xcresult`.
3. **Refresh `src/coverage.md`.** Rewrite the table with every `src/` file (excluding `*Tests.swift`)
that the test target compiles, sorted worst-coverage-first, as `| NN% (cov/exec) | \`path\` |`.
Keep the header note explaining the Humble-Object scope and that this + the badges are the only
stored coverage. Update the "updated" date.
4. **Offer to refresh the per-spec badges.** For each `<Base>Specs.md` whose `<Base>.swift` (or kernel)
is in the report, propose updating its `> **Line coverage:** …` line with the new % + today's date.
Ask first; only apply the ones the user accepts. Keep honest notes where a file is intentionally
partial (e.g. `PreferencesMigrations.swift`).
5. **Rank gaps & suggest tests.** For kernels below 100%, list the uncovered functions/regions, map each
to the behavior it represents (read the source), and write ready-to-paste additions: a
`- **testGroupScenario** — …` spec line + a short XCTest stub in that suite's builder style. Don't add
tests automatically unless asked.
6. **Clean up:** `rm -rf /tmp/altcov.xcresult`. Leave no technical coverage files in the tree.
## Reporting
- Headline: a small ascending table of kernel `lineCoverage`, and the delta vs the previous `src/coverage.md`.
- Ranked gap list: file → uncovered function/branch → the behavior it represents.
- Suggested scenarios (spec lines + test stubs) for the top gaps.
- State exactly what was rewritten (`src/coverage.md`, which badges). Flag files that are partial *by
design* (AppKit glue, excluded migrations) so a low number isn't mistaken for a problem.
@@ -1,75 +0,0 @@
---
name: translate-missing-l10n
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.
## Target languages (20)
`de, ja, fr, es, zh-CN, pt-BR, nl, ko, it, pl, ar, zh-HK, vi, tr, sv, th, zh-TW, he, id, ru`
(`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.
- **Do not translate proper nouns**: "AltTab", "macOS", "Mission Control", "Spaces", "Dock".
- **Do not translate modifier key names**: "Cmd", "Option", "Alt", "Shift", "Ctrl", "Control", "Fn", "Command".
- **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:
```json
{
"fr": { "About %@": "À propos de %@", "Quit": "Quitter" },
"ja": { "About %@": "%@について" }
}
```
Then run:
```sh
npx ts-node scripts/l10n/apply_translations.ts /tmp/batch-NN.json
```
The helper:
- 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.
-1
View File
@@ -1 +0,0 @@
v11.3.0
-31
View File
@@ -1,31 +0,0 @@
# Gitea CI on the shared KofTwentyTwo/ci-workflows macOS workflow (grogu). The .github workflows keep
# running on the GitHub mirror (release, notarization, appcast); this one is build only and unsigned.
#
# Guard B the de-paywall marker and the if-false wrap are still in computeState() (docs/PLAN-maintained-fork.md §4.4)
# Guard C no unresolved merge conflict markers in the tree
# build the Release scheme, unsigned (a compile check at macOS 12.0: grogu has only Xcode 27)
# Guard A the Test scheme is compiled (build-for-testing) but not run: the grogu runner has no GUI session for
# testmanagerd. Run scripts/run_tests.sh locally before merging (docs/contributing.md).
name: build
on:
push:
branches: ['**']
jobs:
macos:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/macos.yaml@v1
with:
commands: |
F=src/pro/license/LicenseManager.swift
[ -f "$F" ] || { echo "::error::Guard B: $F not found; chokepoint moved? Refresh the de-paywall patch (§6.2)."; exit 1; }
grep -q 'depaywall' "$F" || { echo "::error::PAYWALL RELOCKED: 'depaywall' marker missing from $F (§6.2)."; exit 1; }
grep -Eq 'guard[[:space:]]+false[[:space:]]+else' "$F" || { echo "::error::PAYWALL RELOCKED: 'if false { … }' wrap missing from $F (§6.2)."; exit 1; }
echo "Guard B OK"
if git -c grep.lineNumber=false grep -nE '^(<<<<<<<|=======|>>>>>>>)' -- . ':(exclude)docs/'; then
echo "::error::UNRESOLVED CONFLICT MARKERS present; resolve before merge (§4.4 Guard C)."; exit 1
fi
echo "Guard C OK"
# grogu has only Xcode 27.2, which rejects the app's 10.13 deployment target (the shipping build is the GitHub
# mirror's, on Xcode 26.0.1). This compile check builds at 12.0 with the same deprecation downgrade as
# config/test-base.xcconfig; every other Swift warning stays an error.
xcodebuild -quiet -project alt-tab-macos.xcodeproj -scheme Release -derivedDataPath DerivedData CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO MACOSX_DEPLOYMENT_TARGET=12.0 SWIFT_TREAT_WARNINGS_AS_ERRORS=NO 'OTHER_SWIFT_FLAGS=$(inherited) -warnings-as-errors -Wwarning DeprecatedDeclaration' build
xcodebuild -quiet -project alt-tab-macos.xcodeproj -scheme Test -configuration Release -derivedDataPath DerivedData build-for-testing
-9
View File
@@ -1,9 +0,0 @@
# alt-tab-free [depaywall]: upstream's FUNDING.yml routed the GitHub "Sponsor" button on the fork's
# repo to lwouis (github: lwouis / patreon: alt_tab_macos / ko_fi: alt_tab / a lwouis PayPal button) —
# i.e. every donation would have gone to the author the fork just de-paywalled (PLAN §3.5). It has
# been emptied so NO sponsor button renders and no donation routes to the upstream author.
#
# OWNER-OVERRIDABLE-BEFORE-PUBLISH: to accept sponsorships for THIS fork, uncomment and fill in your
# own handles (do NOT re-add lwouis's). Otherwise leave this file empty / delete it.
# github: <fork-owner-handle>
# custom: ["https://<fork-domain>/donate"]
-29
View File
@@ -1,29 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is. What happened, and what you think should have happened.
**Screenshots / video**
Add screenshots to help explain your problem. You can also record your screen to show the bug in action. That is *really* helpful!
**Steps to reproduce the bug**
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Your environment**
* AltTab version:
* macOS version:
* Other relevant info: e.g. using external keyboard, multiple monitors, etc
@@ -1,17 +0,0 @@
---
name: Feature suggestion
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature suggestion related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
-10
View File
@@ -1,10 +0,0 @@
---
name: Other
about: Share something that's not a bug report, and not a feature suggestion
title: ''
labels: ''
assignees: ''
---
-4
View File
@@ -1,4 +0,0 @@
Thank you for opening a PR! Please make sure that:
* The title and description of the PR convey what the change is about, by mentioning the ticket it addresses for instance.
* The commits messages [follow the convention](https://www.conventionalcommits.org/en/v1.0.0-beta.4/#summary), so your PR can be merged.
-226
View File
@@ -1,226 +0,0 @@
# alt-tab-free: fork-owned CI/CD workflow (diverged from upstream lwouis/alt-tab-macos).
# Upstream's ci_cd.yml fires `on: push: branches: [master]` ONLY and is the sole workflow,
# so this fork ADDS a `pull_request:` trigger to gate the sync PR pre-merge (see PLAN §4.2),
# gates every release/push-only step under `if: github.event_name == 'push'`, and wires the
# anti-relock + conflict-marker greps as the FIRST push-job steps so a relocked or conflicted
# tree aborts BEFORE update_appcast.sh (Sparkle sign), semantic-release (tag push), and the
# gh-release. This file is in the §6.2 "keep ours" recurring-conflict set — on an upstream
# ci_cd.yml change, reconcile manually and re-confirm the `pull_request:` trigger survives.
on:
push:
branches:
- master
# alt-tab-free [depaywall]: PR trigger so the sync PR runs the guards pre-merge (PLAN §4.2).
# A separate fork-owned .github/workflows/guard.yml ALSO runs the guards on pull_request, so a
# careless upstream-conflict resolution that strips this trigger cannot silently disarm the gate.
pull_request:
branches:
- master
# alt-tab-free [depaywall]: GitHub defaults GITHUB_TOKEN to read-only since 2023; semantic-release
# (@semantic-release/git) pushes commits+tags to master and softprops/action-gh-release creates the
# release — both need contents:write, else they 403 and the appcast enclosure 404s for auto-update
# (PLAN §4.3).
permissions:
contents: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPCENTER_SECRET: ${{ secrets.APPCENTER_SECRET }}
APPCENTER_TOKEN: ${{ secrets.APPCENTER_TOKEN }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_P12_CERTIFICATE: ${{ secrets.APPLE_P12_CERTIFICATE }}
# alt-tab-free [depaywall]: optional .p12 export password (empty if exported password-less).
# Not part of the release-gate AND — the cert import tolerates an empty password.
APPLE_P12_PASSWORD: ${{ secrets.APPLE_P12_PASSWORD }}
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
GITHUB_EVENT_BEFORE: ${{ github.event.before }}
GITHUB_EVENT_AFTER: ${{ github.event.after }}
BUILD_DIR: ${{ github.workspace }}
XCODE_BUILD_PATH: DerivedData/Build/Products/Release
VERSION_FILE: VERSION.txt
# alt-tab-free [depaywall]: must equal PRODUCT_NAME in config/local.xcconfig
# (PLAN §3.1/§4.3) or the build/package/appcast scripts mismatch the artifact.
APP_NAME: CommandTabFree
jobs:
build:
runs-on: macos-15 # all options: https://github.com/actions/runner-images/tree/main
environment: production
steps:
# - env:
# GITHUB_CONTEXT: ${{ toJSON(github) }}
# run: echo "$GITHUB_CONTEXT"
- id: pre-checkout
env:
COMMITS: ${{ toJson(github.event.commits) }}
run: |
if [[ "${{ github.event_name }}" == "push" ]]; then
COUNT=$(( $(echo "$COMMITS" | jq length) + 30 ))
else
COUNT=1
fi
echo "fetch-depth=$COUNT" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v6
with:
fetch-depth: ${{ steps.pre-checkout.outputs.fetch-depth }}
# alt-tab-free [depaywall]: Guard B + Guard C run FIRST on the push job (before run_tests.sh,
# and therefore before update_appcast.sh's Sparkle sign + semantic-release's tag push) so a
# relock or a still-conflicted tree aborts the job BEFORE any irreversible side-effect (PLAN
# §4.4 "Where the guards run"). They are near-instant target-independent greps, so running them
# first is free. They also run on the PR path for the fastest red signal. A second copy sits
# just before action-gh-release as belt-and-suspenders.
- name: anti-relock — verify de-paywall marker present (Guard B)
run: |
grep -q 'depaywall' src/pro/license/LicenseManager.swift \
|| { echo "PAYWALL RELOCKED: depaywall marker missing from computeState()"; exit 1; }
# The marker proves the patch is present; pair with the if-false wrap the patch introduces.
# A bare `return .pro` grep is NOT load-bearing (it matches the unpatched line 187 too).
grep -Eq 'guard[[:space:]]+false[[:space:]]+else' src/pro/license/LicenseManager.swift \
|| { echo "PAYWALL RELOCKED: 'guard false else' (forced .pro) missing from computeState()"; exit 1; }
- name: anti-conflict — no unresolved merge markers (Guard C)
run: |
if git -c grep.lineNumber=false grep -nE '^(<<<<<<<|=======|>>>>>>>)' -- . ':(exclude)docs/'; then
echo "UNRESOLVED CONFLICT MARKERS present — resolve before merge/release"; exit 1
fi
# alt-tab-free [depaywall]: release-readiness gate (PLAN §3.3/§4.3 owner-secret checklist).
# The fork ships unsigned/ad-hoc until the OWNER provisions the Apple Developer ID cert and the
# Sparkle key. We do NOT hard-fail when those secrets are absent — that would make every push
# RED on a fork that is correctly built but not yet signing-provisioned. Instead we compute a
# boolean from secret presence and SKIP the sign/notarize/appcast/release tail cleanly when it
# is false. The build + Guard A (run_tests.sh) still run on push, so push CI is GREEN today and
# the release tail FLIPS ON automatically the moment the owner adds the secrets. Secrets are not
# available in step-level `if:` directly, so map them to env and emit an output here.
- name: release-gate — are signing/notarization secrets present?
id: release-gate
env:
HAS_P12: ${{ secrets.APPLE_P12_CERTIFICATE != '' }}
HAS_APPLE_ID: ${{ secrets.APPLE_ID != '' }}
HAS_APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD != '' }}
HAS_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID != '' }}
HAS_SPARKLE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY != '' }}
run: |
set -euo pipefail
if [ "$HAS_P12" = "true" ] && [ "$HAS_APPLE_ID" = "true" ] \
&& [ "$HAS_APPLE_PASSWORD" = "true" ] && [ "$HAS_APPLE_TEAM_ID" = "true" ] \
&& [ "$HAS_SPARKLE_KEY" = "true" ]; then
echo "signing-ready=true" >> "$GITHUB_OUTPUT"
echo "All signing/notarization/Sparkle secrets present — release tail WILL run on push."
else
echo "signing-ready=false" >> "$GITHUB_OUTPUT"
echo "::warning::Signing/notarization/Sparkle secrets absent — release tail (codesign/notarize/appcast/semantic-release/gh-release) is SKIPPED. Build + Guard A still run. Provision APPLE_P12_CERTIFICATE / APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID / SPARKLE_ED_PRIVATE_KEY in the 'production' Environment to enable releases (PLAN §3.3/§3.4)."
fi
- uses: actions/setup-node@v6
with:
node-version: 16 # PLAN §4.3: keep node-16; the package.json engines mismatch only warns
cache: 'npm'
- run: sudo xcode-select -s /Applications/Xcode_26.0.1.app/Contents/Developer
- run: scripts/print_env.sh
- run: npm ci
# alt-tab-free [depaywall]: commitlint is gated to the push path. On push it lints the whole
# pushed range, which drags in upstream's non-conforming messages on a `chore(sync)` merge —
# this is handled by an `ignores` predicate in commitlint.config.js (PLAN §4.3) that drops the
# synthetic merge commit and upstream's own messages; do NOT break commitlint on --no-ff merges.
# $GITHUB_EVENT_BEFORE/AFTER are push-context (empty on pull_request), so a PR-safe variant runs
# below on the PR path instead.
- run: npx commitlint --from "$GITHUB_EVENT_BEFORE" --to "$GITHUB_EVENT_AFTER" --verbose
if: github.event_name == 'push'
# alt-tab-free [depaywall]: PR-safe commitlint — lints base..head (valid on pull_request),
# paired with the commitlint.config.js `ignores` predicate (PLAN §4.2/§4.3).
- run: npx commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.sha }}" --verbose
if: github.event_name == 'pull_request'
# Runs on both push and PR: catches an un-regenerated l10n/generated-file diff (PLAN §4.2/§4.5).
- run: scripts/ensure_generated_files_are_up_to_date.sh
# alt-tab-free [depaywall]: release-path — a semantic-release dry-run against a PR head is
# meaningless and can mis-bump, so gate to push (PLAN §4.2).
- run: scripts/determine_next_version.sh
if: github.event_name == 'push'
# alt-tab-free [depaywall]: decouple the release apparatus from "every signing-ready push".
# determine_next_version.sh writes an EMPTY VERSION.txt when no commit since the last tag is
# release-worthy (semantic-release dry-run finds no bump). Gate the sign/notarize/release steps
# on this so a docs/ci/chore push still runs guards + commitlint + run_tests (Release-config
# compile check) but does NOT sign-build, notarize, or cut a release — no [skip ci] needed (§4.3).
- name: release-decision — did semantic-release compute a version?
id: release-decision
if: github.event_name == 'push'
run: |
if [ -n "$(tr -d '[:space:]' < "$VERSION_FILE" 2>/dev/null)" ]; then
echo "will-release=true" >> "$GITHUB_OUTPUT"
echo "Release-worthy: semantic-release will cut v$(cat "$VERSION_FILE")."
else
echo "will-release=false" >> "$GITHUB_OUTPUT"
echo "::notice::No release-worthy commit — build/test only; notarize+release tail skipped."
fi
# Inject the release version only when releasing; an empty CURRENT_PROJECT_VERSION would crash
# App.version (see config/local.xcconfig). Non-release builds use the committed 0.0.0 default.
- run: scripts/replace_environment_variables_in_app.sh
if: github.event_name == 'push' && steps.release-decision.outputs.will-release == 'true'
# codesign needs APPLE_P12_CERTIFICATE — gated on signing-ready so a not-yet-provisioned fork
# does not RED on the missing-cert import (PLAN §3.3/§4.3).
- run: scripts/codesign/setup_ci_master.sh
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
# Guard A — the anti-relock unit test (appended to LicenseManagerTests.swift). Runs on both
# push and PR (no secrets needed); on the push path it sits before the sign/tag steps below
# so a relock fails BEFORE any irreversible side-effect (PLAN §4.4). This is what keeps push
# CI GREEN today on the signing-unprovisioned fork.
- run: scripts/run_tests.sh
# alt-tab-free [depaywall]: build the Release product to prove the tree compiles. The fork
# inherits upstream's Developer ID via config/release.xcconfig (CODE_SIGN_IDENTITY), which is
# absent on CI until the owner provisions the Apple cert — so when the release gate is NOT
# signing-ready we build AD-HOC (CODE_SIGN_IDENTITY=-, signing not required, no timestamp) to
# prove the tree compiles without a cert. When the owner adds the secrets AND sets the fork's
# Developer ID in config/local.xcconfig, this builds signed for the release tail (PLAN §3.3/§4.3).
- name: build app (signed — owner cert provisioned)
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
run: scripts/build_app.sh
- name: build app (ad-hoc — until signing-provisioned)
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready != 'true'
run: scripts/build_app.sh CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO OTHER_CODE_SIGN_FLAGS=--timestamp=none
# --- Release tail: requires the owner's Apple Developer ID + Sparkle secrets. Skipped cleanly
# (NOT failed) when they are absent, so push CI stays GREEN until the owner provisions them. ---
- run: scripts/package_and_notarize_release.sh
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
# alt-tab-free [depaywall]: upload_symbols_to_appcenter.sh REMOVED — telemetry is off (PLAN §3.4).
- run: scripts/update_appcast.sh
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
# alt-tab-free [depaywall]: update_readme_and_website.sh REMOVED — it queried the UPSTREAM
# lwouis/alt-tab-macos GitHub API for stars/downloads and rewrote docs/contributors.md from the
# UPSTREAM contributor graph (semantic mismatch for the fork) and dispatched the lwouis website.
# Cosmetic only; dropped per PLAN §4.0/§4.3 (alongside update_website.sh, already removed).
- run: npx semantic-release
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
- id: set_release_info
run: scripts/extract_latest_changelog.sh
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
# alt-tab-free [depaywall]: belt-and-suspenders copy of Guard B + Guard C immediately before the
# release is created. The load-bearing copy is at the top of the job (pre-tag-push); this second
# copy guards the gh-release object itself (PLAN §4.4).
- name: anti-relock + anti-conflict re-check (belt-and-suspenders)
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
run: |
grep -q 'depaywall' src/pro/license/LicenseManager.swift \
|| { echo "PAYWALL RELOCKED: depaywall marker missing from computeState()"; exit 1; }
grep -Eq 'guard[[:space:]]+false[[:space:]]+else' src/pro/license/LicenseManager.swift \
|| { echo "PAYWALL RELOCKED: 'guard false else' (forced .pro) missing from computeState()"; exit 1; }
if git -c grep.lineNumber=false grep -nE '^(<<<<<<<|=======|>>>>>>>)' -- . ':(exclude)docs/'; then
echo "UNRESOLVED CONFLICT MARKERS present — resolve before release"; exit 1
fi
# alt-tab-free [depaywall]: point the release at THIS fork's repo via the ambient GITHUB_TOKEN
# (release.config.js has no @semantic-release/github plugin, so softprops is the sole release
# creator — PLAN §4.3). repository defaults to ${{ github.repository }} = the fork.
- uses: softprops/action-gh-release@v3
if: github.event_name == 'push' && steps.release-gate.outputs.signing-ready == 'true' && steps.release-decision.outputs.will-release == 'true'
with:
repository: ${{ github.repository }}
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ steps.set_release_info.outputs.tag_name }}
body: ${{ steps.set_release_info.outputs.body }}
files: ${{ env.XCODE_BUILD_PATH }}/*.zip
# alt-tab-free [depaywall]: the release tail intentionally does NOT advance .fork-sync-state.
# That file is the UPSTREAM-sync cursor — the last-synced UPSTREAM tag (e.g. v11.3.0), read by
# upstream_sync.yml to pick "the oldest upstream tag strictly newer than it." The fork versions
# independently (semantic-release cuts v100.x), so a prior revision's step that wrote the FORK
# release tag here set the cursor to v100.x — after which no upstream tag is ever "newer" and
# upstream-sync would silently never fire. The cursor must instead be advanced by the
# upstream-sync merge flow, recording the UPSTREAM tag it synced (to be wired with
# upstream_sync.yml — PLAN §4.2). Until then .fork-sync-state stays at the last real upstream tag.
# alt-tab-free [depaywall]: update_website.sh (lwouis website dispatch) REMOVED (PLAN §3.4/§4.2).
-94
View File
@@ -1,94 +0,0 @@
# alt-tab-free — anti-relock guard workflow (docs/PLAN-maintained-fork.md §4.4)
#
# A SEPARATE, fork-owned workflow that runs the anti-relock guards on every PR,
# INDEPENDENTLY of ci_cd.yml. Rationale (§4.4): ci_cd.yml is the file most likely to take an
# upstream conflict, and a careless "keep ours/theirs" resolution could silently strip its
# pull_request: trigger or its release-step gating — at which point the in-ci_cd.yml guards
# would stop gating PRs with NO error. A guard workflow that does NOT inherit ci_cd.yml's
# trigger survives that. This is the reviewer's primary green/red signal on a sync PR.
#
# Three guards, each fails the job LOUDLY:
# A — the Test scheme (the mock-injected anti-relock unit tests in LicenseManagerTests.swift)
# B — a target-independent grep that the de-paywall flip is still present in computeState()
# C — a tree-wide grep that no unresolved git conflict markers survive into a merge/release
#
# Defense-in-depth note: Guards B + C are ALSO wired into ci_cd.yml as the FIRST push-job
# steps (CI agent owns that) so a relock/conflict-laden tree aborts the release BEFORE the tag
# is pushed or the binary signed, even if this PR gate is ever missing/misconfigured (§4.4).
name: guard
on:
pull_request:
branches: [master]
permissions:
contents: read
jobs:
# Guards B + C are near-instant greps; run them on cheap ubuntu so a relock or a still-
# conflicted tree fails FAST, before the expensive macOS Guard-A job even starts (§4.5).
grep-guards:
name: anti-relock greps (B + C)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
# GUARD B — verify the de-paywall flip is still present at the single state producer.
# §4.4 pitfall: a bare `return .pro` grep MATCHES THE UNPATCHED FILE (computeState() already
# contains `return .pro` in the keychain-valid branch on a fully relocked tree), so it is NOT
# load-bearing. The load-bearing signals are (1) the `depaywall` marker comment, which exists
# ONLY because the fork added it, AND (2) the `if false { … }` wrap that the patch introduces
# around the original (now dead) body. Both are absent on an unpatched/relocked upstream file.
- name: Guard B — de-paywall marker + if-false wrap present in computeState()
run: |
set -euo pipefail
F=src/pro/license/LicenseManager.swift
if [ ! -f "$F" ]; then
echo "::error::Guard B: $F not found — chokepoint moved? Refresh the de-paywall patch (§6.2)."
exit 1
fi
grep -q 'depaywall' "$F" || {
echo "::error::PAYWALL RELOCKED: 'depaywall' marker missing from $F — the de-paywall flip was lost in a merge. Refresh the patch (§6.2)."
exit 1
}
grep -Eq 'guard[[:space:]]+false[[:space:]]+else' "$F" || {
echo "::error::PAYWALL RELOCKED: 'if false { … }' wrap missing from $F — the de-paywall body-suppress was lost. Refresh the patch (§6.2)."
exit 1
}
echo "Guard B OK: de-paywall marker + if-false wrap present in $F."
# GUARD C — no unresolved git conflict markers anywhere in the tree. The §4.2 sync cron
# deliberately commits a conflicted tree (markers + --no-verify) for a human to resolve;
# nothing else guarantees the human removed EVERY marker before merge. A markers-laden
# commit merged to master becomes a permanent ancestor of the published tag (un-compilable
# source), contradicting §5(1). Exclude docs/ so this plan's own literal marker examples
# don't trip the grep.
- name: Guard C — no unresolved merge conflict markers
run: |
set -euo pipefail
if git -c grep.lineNumber=false grep -nE '^(<<<<<<<|=======|>>>>>>>)' -- . ':(exclude)docs/'; then
echo "::error::UNRESOLVED CONFLICT MARKERS present — resolve before merge/release (§4.4 Guard C)."
exit 1
fi
echo "Guard C OK: no unresolved conflict markers."
# GUARD A — the behavioral anti-relock unit tests (mock-injected LicenseManager) via the Test
# scheme. §4.4: this MUST run on macos-15 with the pinned Xcode — run_tests.sh is `xcodebuild
# test`, which cannot run on ubuntu. If left on ubuntu, Guard A silently degrades to greps-only
# and the behavioral relock test never executes (the exact silent-disarming class this plan
# guards against). Bootstrap assertion (§4.4): on the deliberately-relocked acceptance PR,
# confirm THIS job produced a real test result that is RED on the relock — not just the greps.
unit-tests:
name: anti-relock unit tests (A)
runs-on: macos-15 # MUST match ci_cd.yml — `xcodebuild test` needs the pinned Xcode
steps:
- uses: actions/checkout@v6
# Pin the same Xcode as ci_cd.yml (§4.5 bootstrap invariant — re-pin on every upstream bump).
- run: sudo xcode-select -s /Applications/Xcode_26.0.1.app/Contents/Developer
# run_tests.sh runs the `Test` scheme (-configuration Release), which includes the
# appended anti-relock tests in src/pro/license/LicenseManagerTests.swift:
# testDepaywallProNeverLocked — load-bearing `state == .pro` assert
# testDepaywallStillProAfterTrialExpiry — discriminating (expired-trial) assert
# A relock makes computeState() fall back to computeTrialState() and these go RED.
- name: Guard A — run the Test scheme (anti-relock unit tests)
run: scripts/run_tests.sh
-226
View File
@@ -1,226 +0,0 @@
# alt-tab-free — upstream sync workflow (docs/PLAN-maintained-fork.md §4.2)
#
# Polls lwouis/alt-tab-macos for new release tags and opens a reviewable sync PR.
# Selects the OLDEST upstream tag strictly newer than the last-merged tag recorded in
# `.fork-sync-state` (NOT "newest tag minus --is-ancestor"). This is the §4.2
# never-drop-commits invariant: `.fork-sync-state` advances ONLY when a sync PR merges to
# master (a ci_cd.yml push-job step — owned by the CI agent), so a sync PR that is
# closed-without-merge or abandoned can never silently skip its tag — that tag stays "next"
# until it is actually merged. This subsumes the old `merge-base --is-ancestor` guard.
#
# A release is cut only when a human merges the PR to master (ci_cd.yml on: push). This
# workflow never releases; it only opens the PR for review.
name: upstream-sync
on:
# §4.2 cron-durability note: the in-repo `schedule:` SILENTLY auto-disables after 60 days
# of repo inactivity (no error, no notification). It is kept here as belt-and-suspenders.
# The RECOMMENDED PRIMARY trigger is an EXTERNAL scheduler hitting `repository_dispatch`
# carrying the SYNC_BOT_TOKEN (immune to the 60-day rule, and composes with the
# workflow-re-trigger token). See ownerActions / §4.2.
schedule:
- cron: '0 7 * * *' # daily; cheap, idempotent
workflow_dispatch: {}
repository_dispatch:
types: [upstream-sync] # external-scheduler entry point (§4.2 primary durability path)
# GITHUB_TOKEN is read-only by default; opening a PR + pushing a branch need these.
permissions:
contents: write
pull-requests: write
jobs:
sync:
runs-on: ubuntu-latest # pure git, no Xcode
env:
# CRITICAL (§4.2 BLOCKING) — do NOT use ${{ github.token }} here. By GitHub's documented
# recursion-prevention rule, a branch push / PR authored with the default GITHUB_TOKEN
# does NOT trigger other workflows — so guard.yml and ci_cd.yml's pull_request: checks
# would NEVER fire on the sync PR and the entire pre-merge gate would silently never run.
# SYNC_BOT_TOKEN MUST be a fork-owned credential that re-triggers workflows: a GitHub App
# installation token (preferred — least-privilege, short-lived) or a fork-owned PAT.
GH_TOKEN: ${{ secrets.SYNC_BOT_TOKEN }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
# Persist the fork-owned credential (NOT the default GITHUB_TOKEN) as the push
# credential, so the branch push below re-triggers guard.yml / ci_cd.yml PR checks.
token: ${{ secrets.SYNC_BOT_TOKEN }}
- name: Fail closed if SYNC_BOT_TOKEN is missing
run: |
# §4.2: without the workflow-re-triggering token the pre-merge gate is silently
# disarmed. Fail loudly here rather than open a check-less PR.
if [ -z "${SYNC_BOT_TOKEN:-}" ]; then
echo "::error::SYNC_BOT_TOKEN is not set. Refusing to open a check-less sync PR (§4.2)."
echo "Provision a GitHub App installation token (preferred) or fork-owned PAT as SYNC_BOT_TOKEN."
exit 1
fi
env:
SYNC_BOT_TOKEN: ${{ secrets.SYNC_BOT_TOKEN }}
- name: Add upstream + fetch tags
run: |
set -euo pipefail
git remote add upstream https://github.com/lwouis/alt-tab-macos.git || true
git fetch upstream --tags --force
- name: Verify sync labels exist (§4.2 — fail closed, never treat empty as "no open sync")
run: |
set -euo pipefail
# Guard 2 below and the §0.1 trip-wire query `sync` / `conflict` / `chokepoint-refresh`.
# `gh pr list --label <missing>` returns an EMPTY LIST, not an error, for a label that
# was never created — so on a fresh repo Guard 2 would silently no-op. Fail closed if a
# required label is absent; the bootstrap (§6.1) creates them with `gh label create`.
missing=0
for L in sync conflict chokepoint-refresh; do
if ! gh label list --json name --jq '.[].name' | grep -qxF "$L"; then
echo "::error::Required label '$L' is missing. Create it at bootstrap (§6.1): gh label create $L"
missing=1
fi
done
[ "$missing" -eq 0 ] || exit 1
- name: Select oldest upstream tag strictly newer than .fork-sync-state
id: tag
run: |
set -euo pipefail
# State-file-driven selection (§4.2): NOT "newest tag minus --is-ancestor".
last_synced="$(tr -d '[:space:]' < .fork-sync-state)"
if [ -z "$last_synced" ]; then
echo "::error::.fork-sync-state is empty; seed it with the last-merged upstream tag (e.g. v11.3.0)."
exit 1
fi
echo "Last synced (from .fork-sync-state): $last_synced"
# UPSTREAM-ONLY enumeration: `git tag -l 'v*'` would also match the FORK's OWN
# semantic-release tags (the fork cuts vX.Y.Z every sync — §4.3), so list tags from
# the upstream remote explicitly. Sort by version; keep only tags strictly newer than
# the recorded one; take the OLDEST of those (resolve-in-order — §4.2).
all_newer="$(git ls-remote --tags --refs upstream 'v*' \
| awk -F/ '{print $NF}' \
| sort -V \
| awk -v last="$last_synced" '
$0 == last { seen = 1; next }
seen { print }
')"
# Fallback for the case where last_synced is not itself present upstream: take every
# tag that sorts strictly after it.
if [ -z "$all_newer" ]; then
all_newer="$(git ls-remote --tags --refs upstream 'v*' \
| awk -F/ '{print $NF}' \
| sort -V \
| awk -v last="$last_synced" '{ if ($0 != last) print $0 }' \
| while read -r t; do
# keep only tags that sort AFTER last_synced
if [ "$(printf '%s\n%s\n' "$last" "$t" | sort -V | tail -1)" = "$t" ] && [ "$t" != "$last" ]; then
echo "$t"
fi
done)"
fi
next="$(printf '%s\n' "$all_newer" | sed '/^$/d' | head -1)"
if [ -z "$next" ]; then
echo "Up to date: no upstream tag newer than $last_synced."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Next tag to sync (oldest strictly newer): $next"
# Guard — a prior sync is still OPEN (resolve-in-order, §4.2): do NOT open a new PR
# while any sync/* PR is unmerged. A backlog pauses syncing until the human merges it;
# this is the never-drop-commits behavior. The liveness alert keys off the stalled flag.
# `gh pr list --label sync` fails closed because the label existence was verified above.
open_sync="$(gh pr list --state open --label sync --json number,headRefName,createdAt \
--jq 'sort_by(.createdAt) | .[0] // empty')"
if [ -n "$open_sync" ]; then
echo "A prior sync PR is still open (resolve-in-order): $open_sync"
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "stalled=true" >> "$GITHUB_OUTPUT" # liveness alert keys off this (§4.2)
exit 0
fi
echo "latest=$next" >> "$GITHUB_OUTPUT"
- name: Merge onto a sync branch (real merge commit, conventional message)
if: steps.tag.outputs.skip != 'true'
id: merge
run: |
set -euo pipefail
TAG='${{ steps.tag.outputs.latest }}'
git config user.name "fork-sync-bot"
git config user.email "fork-sync-bot@users.noreply.github.com"
git switch -c "sync/${TAG}"
# NO --no-commit: let git author a REAL merge commit with a CONVENTIONAL message so
# commitlint / @semantic-release/commit-analyzer stay happy (§4.3). On conflict, commit
# the conflicted tree (markers included, --no-verify) so the PR shows the markers for a
# human to resolve; Guard C (guard.yml + ci_cd.yml) then blocks it from ever shipping.
if git merge --no-ff -m "chore(sync): merge upstream ${TAG}" "${TAG}"; then
conflicts=false
else
git add -A
git commit --no-verify \
-m "chore(sync): merge upstream ${TAG} (CONFLICTS — resolve before merge)"
conflicts=true
fi
echo "conflicts=${conflicts}" >> "$GITHUB_OUTPUT"
# §0.1 trip-wire A — surface chokepoint churn as a per-sync signal. A nonzero count
# means an upstream change touched a de-paywall chokepoint file in this merge range
# (last-synced tag .. new tag); the PR is labeled `chokepoint-refresh` (below) so the
# trip-wire is a single query, not a memory exercise.
CHOKEPOINTS="src/pro/license/LicenseManager.swift src/pro/ProFeature.swift src/preferences/PreferenceDefinition.swift src/pro/scheduling/ProTransitionManager.swift src/pro/license/LicenseCookie.swift src/vendors/AppCenterCrashes.swift"
LAST_SYNCED="$(tr -d '[:space:]' < .fork-sync-state)"
churn="$(git rev-list --count "${LAST_SYNCED}..${TAG}" -- $CHOKEPOINTS 2>/dev/null || echo 0)"
# 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
if [ "${conflicts:-false}" = "true" ]; then
chokepoint_conflict=1
fi
fi
echo "churn=${churn}" >> "$GITHUB_OUTPUT"
echo "chokepoint_conflict=${chokepoint_conflict}" >> "$GITHUB_OUTPUT"
git push origin "HEAD:sync/${TAG}"
- name: Open PR
if: steps.tag.outputs.skip != 'true'
run: |
set -euo pipefail
TAG='${{ steps.tag.outputs.latest }}'
CHURN='${{ steps.merge.outputs.churn }}'
CONFLICTS='${{ steps.merge.outputs.conflicts }}'
CHOKEPOINT_CONFLICT='${{ steps.merge.outputs.chokepoint_conflict }}'
# Label fail-closed (§4.2): conflict -> `sync,conflict`, else `sync`.
if [ "$CONFLICTS" = "true" ]; then
LABELS="sync,conflict"
else
LABELS="sync"
fi
BODY="Automated upstream sync to \`${TAG}\`.
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
gh pr edit "sync/${TAG}" --add-label chokepoint-refresh || true
fi
-25
View File
@@ -1,25 +0,0 @@
/.idea/
xcuserdata/
/.vscode/
/buildServer.json
WorkspaceSettings.xcsettings
IDEWorkspaceChecks.plist
/node_modules/
/DerivedData/
/build/
/vendor/*/.build/
/vendor/*/build/
/vendor/*/.swiftpm/
/vendor/*/Package.resolved
.DS_Store
codesign.conf
codesign.crt
codesign.key
codesign.p12
/.claude/*
!/.claude/skills/
/scripts/l10n/missing-translations.json
/ai/output/
# alt-tab-free [depaywall]: the Sparkle feed is published OUT OF TREE to the gh-pages branch by
# scripts/update_appcast.sh; the in-tree appcast.xml is removed (PLAN §3.3).
/appcast.xml
-6
View File
@@ -1,6 +0,0 @@
project: alt-tab-macos.xcodeproj
schemes:
- Release
index_exclude:
- vendor/**
- src/api-wrappers/private-apis/**
-10
View File
@@ -1,10 +0,0 @@
# rules: https://github.com/nicklockwood/SwiftFormat/blob/main/Rules.md
--swiftversion 5.8
--maxwidth 110
--nowrapoperators .,==,!=,<,>,>=,<=
--wrapternary before-operators
--disable andOperator
--disable wrapConditionalBodies
--disable wrapLoopBodies
--disable wrapMultilineStatementBraces
-10
View File
@@ -1,10 +0,0 @@
# **/*.swift: Matches all .swift files in the current directory and all its subdirectories.
# src/**/*.swift: Matches all .swift files in the src directory and all its subdirectories.
# !node_modules/**: Excludes all files in the node_modules directory and all its subdirectories.
Generated/**
**/PrivateApis.swift
# We will open a new branch to deal with code format issues at the next release.
**/*
-14
View File
@@ -1,14 +0,0 @@
# macOS development
- Don't use xcode directly to develop
- 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.
-1
View File
@@ -1 +0,0 @@
@include ./AGENTS.md
-84
View File
@@ -1,84 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- ===== Bundle identity (required by macOS / App Store) ===== -->
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleIconFile</key>
<string>app.icns</string>
<key>NSHumanReadableCopyright</key>
<string>GPL-3.0 licence</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<!-- ===== Versioning (substituted from xcconfig; CI overrides via config/local.xcconfig) ===== -->
<key>CFBundleShortVersionString</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<!-- ===== Launch behavior (read by LaunchServices before app starts) ===== -->
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>LSUIElement</key>
<string>true</string>
<key>NSPrincipalClass</key>
<string>AppCenterApplication</string>
<key>NSSupportsAutomaticTermination</key>
<string>false</string>
<key>NSSupportsSuddenTermination</key>
<string>false</string>
<key>ATSApplicationFontsPath</key>
<string></string>
<!-- ===== URL scheme registration (LaunchServices dispatches $(PRODUCT_BUNDLE_IDENTIFIER):// to us) ===== -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLName</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleURLSchemes</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<!-- ===== Sparkle (auto-update framework — reads these directly from the bundle) ===== -->
<key>SUPublicEDKey</key>
<!-- Fork EdDSA public key for Sparkle update verification. The matching private key
is held as CI secret SPARKLE_ED_PRIVATE_KEY (see docs/EXECUTION-STATUS.md §3.3).
Auto-checks stay off (below) until DOMAIN serves a real appcast host. -->
<string>1YfoCJDk/T8GQx1Gpf3O4ApFgDkBiN6oe/z/Kfnxs/M=</string>
<key>SUEnableAutomaticChecks</key>
<!-- alt-tab-free: enabled 2026-06-16 — DOMAIN serves the appcast on GitHub Pages (gh-pages). -->
<string>true</string>
<key>SUScheduledCheckInterval</key>
<string>604800</string>
<!-- ===== AppCenter (crash reporter — read by AppCenterApplication superclass at init) ===== -->
<key>AppCenterApplicationForwarderEnabled</key>
<string>0</string>
<!-- ===== Build-time secrets (substituted from xcconfig; override per-dev via config/local.xcconfig) ===== -->
<key>AppCenterSecret</key>
<string>$(APPCENTER_SECRET)</string>
<key>Domain</key>
<string>$(DOMAIN)</string>
<key>ApiDomain</key>
<string>$(API_DOMAIN)</string>
</dict>
</plist>
-674
View File
@@ -1,674 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
-34
View File
@@ -1,34 +0,0 @@
# NOTICE — Statement of Changes (GPL-3.0 §5(a))
This file carries the prominent notice, required by GNU GPL v3.0 §5(a), that this work
has been modified, and gives the relevant date. It is a **living document**: whenever a
sync from upstream changes this fork's modification surface, update the change list and
the date below (see `docs/PLAN-maintained-fork.md` §6.2).
**Forked from:** [lwouis/alt-tab-macos](https://github.com/lwouis/alt-tab-macos) v11.3.0,
© Louis Pontoise, licensed GPL-3.0.
**Modified by:** the CommandTabFree fork. This fork neutralizes the AltTab Pro paywall so that
all features are free.
**Date of modification:** 2026-06-10.
**License:** CommandTabFree is released under the **GNU General Public License v3.0**, the same
license as upstream. The full license text is in [`LICENCE.md`](LICENCE.md), which is kept
byte-for-byte identical to upstream and is never edited.
## Changes made in this fork
- **`LicenseManager.computeState`** (`src/pro/license/LicenseManager.swift`) — forced to
return `.pro`, opening every Pro feature gate. This is the single live license-state
producer, so this one change unlocks all paywalled features.
- **`LicenseCookie.syncLicenseCookie`** (`src/pro/license/LicenseCookie.swift`) — made a
no-op, so no license cookie is written to the upstream domain.
- **`AppCenterCrash.init`** (`src/vendors/AppCenterCrashes.swift`) — telemetry disabled;
AppCenter is never started.
- **`App.handleCustomUrl`** (`src/App.swift`) — the `activate` deep-link handler is
disabled, so a crafted `…://activate?license_key=…` URL can no longer POST the machine
hardware fingerprint to a license backend.
The upstream copyright, contributor credits (`docs/contributors.md`), and third-party
acknowledgments (`docs/acknowledgments.md`) are retained unchanged.
-17
View File
@@ -1,17 +0,0 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true
[dev-packages]
[packages]
fonttools = "==4.8.1"
googletrans = "==3.0.0"
[requires]
python_version = "3.6"
[scripts]
font2png = "scripts/convert_font_to_png.sh"
translate = "python scripts/l10n/google_translate_all_terms_locally.py resources/l10n --retry_translate"
Generated
-127
View File
@@ -1,127 +0,0 @@
{
"_meta": {
"hash": {
"sha256": "3646221352b85667ac21f1b528dc8b29f56ff1e24cb97e7eaba79b5040c78e21"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.6"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"certifi": {
"hashes": [
"sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407",
"sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"
],
"markers": "python_version >= '3.7'",
"version": "==2025.8.3"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"fonttools": {
"hashes": [
"sha256:596c0e8399c650dbf1048b7ca6fc7b12bd79a7db0981f3b87743de8c2b7512de",
"sha256:6d241d319ea9eae4c4ee27cbd1db8656dbd496dd9aa884401debfab3a4c7d82b"
],
"index": "pypi",
"markers": "python_version >= '3.6'",
"version": "==4.8.1"
},
"googletrans": {
"hashes": [
"sha256:44caeea42d91ff6ead5c2d49db2b88de66c45c2fe874c8ec03eb9b4ceb3a533d"
],
"index": "pypi",
"version": "==3.0.0"
},
"h11": {
"hashes": [
"sha256:33d4bca7be0fa039f4e84d50ab00531047e53d6ee8ffbc83501ea602c169cae1",
"sha256:4bc6d6a1238b7615b266ada57e0618568066f57dd6fa967d1290ec9309b2f2f1"
],
"version": "==0.9.0"
},
"h2": {
"hashes": [
"sha256:61e0f6601fa709f35cdb730863b4e5ec7ad449792add80d1410d4174ed139af5",
"sha256:875f41ebd6f2c44781259005b157faed1a5031df3ae5aa7bcb4628a6c0782f14"
],
"version": "==3.2.0"
},
"hpack": {
"hashes": [
"sha256:0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89",
"sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2"
],
"version": "==3.0.0"
},
"hstspreload": {
"hashes": [
"sha256:02bd5b0885b62f0d5239ca6c6876ffcad8d9c08dc97cee54292598141a16e9dc",
"sha256:346552a807b3a1762376de8ecce097544e7fcd64fb64231b4652da52f86fa6f1"
],
"markers": "python_version >= '3.6'",
"version": "==2025.1.1"
},
"httpcore": {
"hashes": [
"sha256:9850fe97a166a794d7e920590d5ec49a05488884c9fc8b5dba8561effab0c2a0",
"sha256:ecc5949310d9dae4de64648a4ce529f86df1f232ce23dcfefe737c24d21dfbe9"
],
"markers": "python_version >= '3.6'",
"version": "==0.9.1"
},
"httpx": {
"hashes": [
"sha256:32d930858eab677bc29a742aaa4f096de259f1c78c68a90ad11f5c3c04f08335",
"sha256:3642bd13e90b80ba8a243a730275eb10a4c26ec96f5fc16b87e458d4ab21efae"
],
"markers": "python_version >= '3.6'",
"version": "==0.13.3"
},
"hyperframe": {
"hashes": [
"sha256:5187962cb16dcc078f23cb5a4b110098d546c3f41ff2d4038a9896893bbd0b40",
"sha256:a9f5c17f2cc3c719b917c4f33ed1c61bd1f8dfac4b1bd23b7c80b3400971b41f"
],
"version": "==5.2.0"
},
"idna": {
"hashes": [
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.10"
},
"rfc3986": {
"hashes": [
"sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835",
"sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"
],
"version": "==1.5.0"
},
"sniffio": {
"hashes": [
"sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2",
"sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"
],
"markers": "python_version >= '3.7'",
"version": "==1.3.1"
}
},
"develop": {}
}
-62
View File
@@ -1,62 +0,0 @@
# CommandTabFree
**CommandTabFree is a fork of [AltTab](https://github.com/lwouis/alt-tab-macos)
(github.com/lwouis/alt-tab-macos), © Louis Pontoise, licensed GPL-3.0.**
This fork neutralizes the AltTab Pro paywall — **all features are free.** It is
**not affiliated with, sponsored by, or endorsed by** the original author. "AltTab"
is used here only to credit the upstream project (nominative fair use); CommandTabFree
is a separate, independently-distributed build.
CommandTabFree is itself released under the **GNU General Public License v3.0** — the same
license as upstream. See [`LICENCE.md`](LICENCE.md) for the full text and
[`NOTICE.md`](NOTICE.md) for the GPL §5(a) statement of changes made in this fork.
## Install
```sh
brew install --cask koftwentytwo/tap/commandtabfree
```
CommandTabFree is **signed and notarized** (as of v100.1.0), so it launches cleanly through
Gatekeeper — no right-click-**Open** workaround needed. Install via the Homebrew cask above, or
download the **`.dmg`** (or `.zip`) from the
[Releases](https://github.com/KofTwentyTwo/CommandTabFree/releases) page and drag CommandTabFree
to Applications. On first launch, grant **Accessibility** and **Screen Recording** in System
Settings. Universal binary (Apple Silicon + Intel), macOS 10.13+.
## Based on AltTab by Louis Pontoise
This software is built on AltTab (`lwouis/alt-tab-macos`) by Louis Pontoise and its
contributors. All of upstream's copyright, attribution, contributor credits
([`docs/contributors.md`](docs/contributors.md)), and third-party acknowledgments
([`docs/acknowledgments.md`](docs/acknowledgments.md)) are retained unchanged. The
only functional change this fork makes is removing the Pro paywall so every feature
is available for free; see [`NOTICE.md`](NOTICE.md) for the precise change list.
## Corresponding source (GPL-3.0 §6)
The complete corresponding source for every released binary is this repository at the
matching release tag:
```
https://github.com/KofTwentyTwo/CommandTabFree
```
For a given release `vX.Y.Z`, the source is the repository tree at that tag, including
the committed build configuration. Only signing/notarization secrets (the Developer ID
certificate, the Sparkle EdDSA private key, and Apple notarization credentials) are
deliberately not conveyed; GPL-3.0 does not require these to be distributed.
---
<!--
Below: upstream credit. CommandTabFree is a fork of AltTab by Louis Pontoise.
This links to the original project for attribution, not as a download CTA for this fork.
-->
<div align="center">
CommandTabFree is a free, GPL-3.0 fork of <a href="https://github.com/lwouis/alt-tab-macos">AltTab</a> by Louis Pontoise.
</div>
-7
View File
@@ -1,7 +0,0 @@
#!/bin/bash
xcodebuild \
-project alt-tab-macos.xcodeproj \
-scheme Debug \
-configuration Debug \
-derivedDataPath DerivedData
-16
View File
@@ -1,16 +0,0 @@
#!/bin/bash
profileFile="/tmp/profile_$(date +%Y%m%d_%H%M%S)"
xcrun xctrace record \
--instrument 'Time Profiler' \
--time-limit 20s \
--no-prompt --quiet \
--output "$profileFile".trace \
--launch -- \
DerivedData/Build/Products/Debug/AltTab.app --benchmark showUi 3
xcrun xctrace export \
--input "$profileFile".trace \
--xpath '/trace-toc/run[@number="1"]/data/table[@schema="time-profile"]' \
--output "$profileFile".xml
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
DerivedData/Build/Products/Debug/AltTab.app/Contents/MacOS/AltTab --logs=debug --benchmark showUi 3
-2
View File
@@ -1,2 +0,0 @@
#import "AppCenterApplication.h"
#import "ObjCExceptionCatcher.h"
File diff suppressed because it is too large. Load diff
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -1,99 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D04BA5E73DAF232ADE11C97A"
BuildableName = "CommandTabFree.app"
BlueprintName = "alt-tab-macos"
ReferencedContainer = "container:alt-tab-macos.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF0C873EC188A7EBBCF4310D"
BuildableName = "unit-tests.xctest"
BlueprintName = "unit-tests"
ReferencedContainer = "container:alt-tab-macos.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "NO"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D04BA5E73DAF232ADE11C97A"
BuildableName = "CommandTabFree.app"
BlueprintName = "alt-tab-macos"
ReferencedContainer = "container:alt-tab-macos.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
<CommandLineArgument
argument = "--logs=debug"
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
<LocationScenarioReference
identifier = "com.apple.dt.IDEFoundation.CurrentLocationScenarioIdentifier"
referenceType = "1">
</LocationScenarioReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Debug"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D04BA5E73DAF232ADE11C97A"
BuildableName = "CommandTabFree.app"
BlueprintName = "alt-tab-macos"
ReferencedContainer = "container:alt-tab-macos.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Debug"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -1,73 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1130"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D04BA5E73DAF232ADE11C97A"
BuildableName = "CommandTabFree.app"
BlueprintName = "alt-tab-macos"
ReferencedContainer = "container:alt-tab-macos.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
disableMainThreadChecker = "YES"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D04BA5E73DAF232ADE11C97A"
BuildableName = "CommandTabFree.app"
BlueprintName = "alt-tab-macos"
ReferencedContainer = "container:alt-tab-macos.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<LocationScenarioReference
identifier = "com.apple.dt.IDEFoundation.CurrentLocationScenarioIdentifier"
referenceType = "1">
</LocationScenarioReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Release">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -1,84 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF0C873EC188A7EBBCF4310D"
BuildableName = "unit-tests.xctest"
BlueprintName = "unit-tests"
ReferencedContainer = "container:alt-tab-macos.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "NO">
<EnvironmentVariables>
<EnvironmentVariable
key = ""
value = ""
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF0C873EC188A7EBBCF4310D"
BuildableName = "unit-tests.xctest"
BlueprintName = "unit-tests"
ReferencedContainer = "container:alt-tab-macos.xcodeproj">
</BuildableReference>
<LocationScenarioReference
identifier = "com.apple.dt.IDEFoundation.CurrentLocationScenarioIdentifier"
referenceType = "1">
</LocationScenarioReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<LocationScenarioReference
identifier = "com.apple.dt.IDEFoundation.CurrentLocationScenarioIdentifier"
referenceType = "1">
</LocationScenarioReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+52
View File
@@ -0,0 +1,52 @@
<?xml version="1.0" standalone="yes"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>alt-tab-free</title>
<language>en</language>
<item>
<title>Version 100.1.0</title>
<pubDate>Tue, 16 Jun 2026 19:48:09 +0000</pubDate>
<description>Corresponding source (GPL-3.0): https://github.com/KofTwentyTwo/CommandTabFree/archive/refs/tags/v100.1.0.tar.gz</description>
<sparkle:minimumSystemVersion>10.13</sparkle:minimumSystemVersion>
<sparkle:releaseNotesLink>https://github.com/KofTwentyTwo/CommandTabFree/releases/tag/v100.1.0</sparkle:releaseNotesLink>
<enclosure
url="https://github.com/KofTwentyTwo/CommandTabFree/releases/download/v100.1.0/CommandTabFree-100.1.0.zip"
sparkle:version="100.1.0"
sparkle:shortVersionString="100.1.0"
sparkle:edSignature="5QC/UsEOhct3HiXfsC2tqWSibsegSzwmYQ+WoFnzNzM8PBqs8lPUyDQtzIMQ763NLjdxN1kO5ycf65GmhwG0BA==" length="8384035"
type="application/octet-stream"/>
</item>
<item>
<title>Version 100.2.0</title>
<pubDate>Tue, 16 Jun 2026 21:05:39 +0000</pubDate>
<description>Corresponding source (GPL-3.0): https://github.com/KofTwentyTwo/CommandTabFree/archive/refs/tags/v100.2.0.tar.gz</description>
<sparkle:minimumSystemVersion>10.13</sparkle:minimumSystemVersion>
<sparkle:releaseNotesLink>https://github.com/KofTwentyTwo/CommandTabFree/releases/tag/v100.2.0</sparkle:releaseNotesLink>
<enclosure
url="https://github.com/KofTwentyTwo/CommandTabFree/releases/download/v100.2.0/CommandTabFree-100.2.0.zip"
sparkle:version="100.2.0"
sparkle:shortVersionString="100.2.0"
sparkle:edSignature="1mswtH3y+i+xzIxCFCQos37Z+aePvGInt0+5FIDFi+p8LU4QRIA6ro3g9WGS3MhYATyOtQ/Mbgg8BzpGlbrMCw==" length="8384111"
type="application/octet-stream"/>
</item>
<item>
<title>Version 100.2.1</title>
<pubDate>Tue, 16 Jun 2026 21:21:13 +0000</pubDate>
<description>Corresponding source (GPL-3.0): https://github.com/KofTwentyTwo/CommandTabFree/archive/refs/tags/v100.2.1.tar.gz</description>
<sparkle:minimumSystemVersion>10.13</sparkle:minimumSystemVersion>
<sparkle:releaseNotesLink>https://github.com/KofTwentyTwo/CommandTabFree/releases/tag/v100.2.1</sparkle:releaseNotesLink>
<enclosure
url="https://github.com/KofTwentyTwo/CommandTabFree/releases/download/v100.2.1/CommandTabFree-100.2.1.zip"
sparkle:version="100.2.1"
sparkle:shortVersionString="100.2.1"
sparkle:edSignature="uS6lohQf8BGHLuC+I7uCKQv/jg4357tK3COkt92wiiyfUJpSiRXKM/9ghARBWKzEZ+oAu6gaIw5A2jhrbHjfCQ==" length="8384034"
type="application/octet-stream"/>
</item>
</channel>
</rss>
-3714
View File
File diff suppressed because it is too large. Load diff
-15
View File
@@ -1,15 +0,0 @@
// alt-tab-free [depaywall]: commitlint config with an `ignores` predicate so a `chore(sync): merge
// upstream` PR (and the upstream commits it drags in through the merge's second parent) does NOT fail
// commitlint and block the release job (PLAN §4.3). ci_cd.yml lints the WHOLE pushed range on push and
// base..head on a PR, both of which include upstream's own (non-conventional) messages on a sync merge.
// `defaultIgnores: true` keeps config-conventional's built-in "Merge …" ignore; the predicates below
// drop the synthetic merge subject and any "Merge …" / "Revert …" line we don't author.
module.exports = {
extends: ['@commitlint/config-conventional'],
defaultIgnores: true,
ignores: [
(msg) => /^chore\(sync\): merge upstream /.test(msg), // our synthetic merge commit (§4.2)
(msg) => /^Merge /.test(msg), // belt-and-suspenders (merge commits)
(msg) => /^Revert /.test(msg), // upstream revert subjects
],
}
-21
View File
@@ -1,21 +0,0 @@
// docs: https://help.apple.com/xcode/mac/11.4/#/itcaec37c2a6
PRODUCT_NAME = AltTab
PRODUCT_BUNDLE_IDENTIFIER = com.lwouis.alt-tab-macos
MACOSX_DEPLOYMENT_TARGET = 10.13
SWIFT_VERSION = 5.8 // xcode 14.2 translates this to 5.0; xcode 16.2 as 5.8
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES
INFOPLIST_FILE = Info.plist
CODE_SIGN_ENTITLEMENTS = alt_tab_macos.entitlements
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
ENABLE_HARDENED_RUNTIME = YES // for notarization
APPLY_RULES_IN_COPY_FILES = YES
INFOPLIST_OUTPUT_FORMAT = binary
PLIST_FILE_OUTPUT_FORMAT = binary
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES
SWIFT_OBJC_BRIDGING_HEADER = alt-tab-macos-Bridging-Header.h
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES
DEAD_CODE_STRIPPING = YES
DOMAIN = alt-tab.app
API_DOMAIN = alt-tab.app/api
-13
View File
@@ -1,13 +0,0 @@
// docs: https://help.apple.com/xcode/mac/11.4/#/itcaec37c2a6
#include "base.xcconfig"
CODE_SIGN_IDENTITY = Local Self-Signed
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG // for macro `#if DEBUG`
OTHER_SWIFT_FLAGS = -driver-time-compilation -Xfrontend -warn-long-function-bodies=250 -Xfrontend -warn-long-expression-type-checking=250 // find slow compilation areas
OTHER_CODE_SIGN_FLAGS = --timestamp=none
SWIFT_COMPILATION_MODE = incremental
ENABLE_DEBUG_DYLIB = NO // skip the debug dylib loader (we don't use SwiftUI Previews); silences [PreviewsAgentExecutorLibrary] startup chatter
// Per-developer overrides (CODE_SIGN_IDENTITY, APPCENTER_SECRET, DOMAIN, API_DOMAIN, …).
#include? "local.xcconfig"
-54
View File
@@ -1,54 +0,0 @@
// =============================================================================
// alt-tab-free — fork identity overlay (committed; see docs/PLAN-maintained-fork.md §3.1)
// =============================================================================
//
// This file is #include?'d LAST by config/release.xcconfig and config/debug.xcconfig,
// so "last assignment wins": every value below overrides config/base.xcconfig WITHOUT
// editing base.xcconfig (which upstream churns). Net pbxproj/base.xcconfig edits: zero.
//
// >>> OWNER MUST SET REAL VALUES BEFORE PUBLISH <<<
// Every placeholder below is OWNER-OVERRIDABLE-BEFORE-PUBLISH and is clearly marked.
// Until these are real, the fork will NOT notarize, auto-update, or serve a working
// feedback/appcast endpoint. See ownerActionsNeeded in the scaffolding report.
//
// KEYCHAIN-SUITE INVARIANT (BLOCKING): PRODUCT_BUNDLE_IDENTIFIER below derives the
// keychain + UserDefaults suite names (LicenseManager.swift:4-5 read App.bundleIdentifier).
// PICK THE FORK'S BUNDLE ID ONCE AND FREEZE IT. Any later change orphans every user's
// stored prefs/state. See §3.1 OWNER DECISION (a)=fresh id / (b)=reuse+migrate.
// =============================================================================
// --- App identity (overrides base.xcconfig :3 / :4) ---
// DECIDED 2026-06-10 (KofTwentyTwo). The bundle id is FROZEN — never change it
// (keychain + UserDefaults suite names derive from it; changing it orphans every user's state).
PRODUCT_NAME = CommandTabFree
PRODUCT_BUNDLE_IDENTIFIER = com.koftwentytwo.commandtabfree
// Local dev default for the build version. Info.plist's CFBundleVersion/CFBundleShortVersionString
// are $(CURRENT_PROJECT_VERSION); CI's replace_environment_variables_in_app.sh APPENDS the real
// release value (which wins by xcconfig last-assignment). Without this, a plain local build leaves
// the version empty and App.version (src/App.swift:14, `as! String`) crashes on launch.
CURRENT_PROJECT_VERSION = 0.0.0
// --- Hosts (overrides base.xcconfig :20-21) ---
// DOMAIN serves the Sparkle appcast at https://<DOMAIN>/appcast.xml. Set 2026-06-16 to the repo's
// GitHub Pages site (gh-pages branch, published by scripts/update_appcast.sh). A kof22.com custom
// domain can be added later — GitHub redirects the github.io URL, so shipped builds keep updating.
// (/support still 404s here — harmless until a support page exists.)
DOMAIN = koftwentytwo.github.io/CommandTabFree
// API_DOMAIN drives the user-feedback POST (FeedbackWindow) — left NON-routable: feedback is deferred
// (§3.4). The activate fingerprint POST is closed separately at the handleCustomUrl handler.
API_DOMAIN = fork.invalid/api
// --- Code signing (overrides release.xcconfig :5) ---
// Fork Developer ID — signed, notarizable builds (PLAN §3.3 / §4.6). Set 2026-06-16.
// The vendored Sparkle helpers (vendor/Sparkle/Helpers/*) are re-signed under this SAME
// identity (a committed change) — required or notarization rejects the nested code.
// Manual style + TEAM_ID so the headless CI runner signs without an Xcode-managed account.
CODE_SIGN_IDENTITY = Developer ID Application: James Maes (2X834TJ5MA)
CODE_SIGN_STYLE = Manual
TEAM_ID = 2X834TJ5MA
// --- Telemetry off (overrides base.xcconfig; AppCenter no-ops on empty secret) ---
// Intentionally empty: telemetry is disabled (reinforced by Edit 3, §2).
// CI must NOT re-append an APPCENTER_SECRET line (see scripts/replace_environment_variables_in_app.sh).
APPCENTER_SECRET =
-21
View File
@@ -1,21 +0,0 @@
// docs: https://help.apple.com/xcode/mac/11.4/#/itcaec37c2a6
#include "base.xcconfig"
CODE_SIGN_IDENTITY = Developer ID Application: Louis Pontoise (QXD7GW8FHY)
CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO // for notarization
OTHER_CODE_SIGN_FLAGS = --timestamp --deep --options runtime // for notarization
SWIFT_COMPILATION_MODE = wholemodule
SWIFT_OPTIMIZATION_LEVEL = -O
GCC_OPTIMIZATION_LEVEL = s // optimize for size; Xcode default for Release but explicit
LLVM_LTO = YES_THIN // ThinLTO: cross-module DCE + inlining across all static-linked .o (host + AppCenter + ShortcutRecorder)
STRIP_SWIFT_SYMBOLS = YES // explicit; Xcode default for Release
DEBUG_INFORMATION_FORMAT = dwarf-with-dsym
CLANG_DEBUG_INFORMATION_LEVEL = line-tables-only // shrinks dSYM for clang/Obj-C; no Swift equivalent ships in Xcode
DEPLOYMENT_POSTPROCESSING = YES
COPY_PHASE_STRIP = YES
STRIP_INSTALLED_PRODUCT = YES
STRIP_STYLE = all
// Per-developer overrides (CODE_SIGN_IDENTITY, APPCENTER_SECRET, DOMAIN, API_DOMAIN, …).
#include? "local.xcconfig"
-16
View File
@@ -1,16 +0,0 @@
// docs: https://help.apple.com/xcode/mac/11.4/#/itcaec37c2a6
SWIFT_VERSION = 5.8
SWIFT_TREAT_WARNINGS_AS_ERRORS = NO // replaced by the explicit flags below so the deprecation group can be downgraded after them
// Raising the unit-test target to 12.0 turns uses of APIs deprecated in macOS 11/12 (kept for the 10.13 app) into warnings; only that
// group is downgraded here (after -warnings-as-errors, which would otherwise win); every other warning stays an error.
OTHER_SWIFT_FLAGS = $(inherited) -warnings-as-errors -Wwarning DeprecatedDeclaration
MACOSX_DEPLOYMENT_TARGET = 12.0 // unit-tests only: Xcode 27 supports 12.0+; the shipping app keeps 10.13 in base.xcconfig
CODE_SIGNING_REQUIRED = NO // no need for codesign
CODE_SIGNING_ALLOWED = NO // no need for codesign
ONLY_ACTIVE_ARCH = YES // only need to run on the host machine
ENABLE_BITCODE = NO
GCC_GENERATE_TEST_COVERAGE_FILES = NO
GCC_INSTRUMENT_PROGRAM_FLOW_ARCS = NO
TEST_ATTACHMENT_LOGGING = NO
DEAD_CODE_STRIPPING = YES
-7
View File
@@ -1,7 +0,0 @@
// docs: https://help.apple.com/xcode/mac/11.4/#/itcaec37c2a6
#include "test-base.xcconfig"
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG // for macro `#if DEBUG`
OTHER_SWIFT_FLAGS = -driver-time-compilation -Xfrontend -warn-long-function-bodies=250 -Xfrontend -warn-long-expression-type-checking=250 // find slow compilation areas
SWIFT_COMPILATION_MODE = incremental
-6
View File
@@ -1,6 +0,0 @@
// docs: https://help.apple.com/xcode/mac/11.4/#/itcaec37c2a6
#include "test-base.xcconfig"
DEBUG_INFORMATION_FORMAT = none // we don't need dSYM files
GCC_GENERATE_DEBUGGING_SYMBOLS = NO // we don't need dSYM files
-227
View File
@@ -1,227 +0,0 @@
# alt-tab-free — Master Paywall Audit
> **Purpose.** This is the authoritative, read-this-first overview of the codebase, the paywall grafted onto it, and the complete surface that must be removed to ship a fully-free build. It summarizes nine deep-dive sections under [`docs/audit/`](audit/) and the completeness review; follow the links for line-by-line detail.
>
> **Audit date / commit basis:** repo at `master` (HEAD `9fadf36b`, v11.3.0). All `file:line` references are against this snapshot.
>
> **Status legend:** **KEEP** = legitimate app, must survive · **CUT** = paywall, remove · **REWRITE** = touched by both, hand-edit · **FLAG** = owner decision.
---
## 1. What this project is, and the licensing position
**alt-tab-free** ([github.com/KofTwentyTwo/CommandTabFree](https://github.com/KofTwentyTwo/CommandTabFree)) is a re-host of the popular open-source macOS window switcher **AltTab** ([github.com/lwouis/alt-tab-macos](https://github.com/lwouis/alt-tab-macos)). It is a pure Swift 5.8 AppKit application — no SwiftUI, no Interface Builder (per [`AGENTS.md`](../AGENTS.md)).
### It is not a diverged fork — it is upstream + a paywall commit
Git history shows every recent commit is authored by upstream author `lwouis` (34/40) or `semantic-release-bot` (6/40); **zero commits by the alt-tab-free owner**. The entire paywall was introduced upstream in a single commit `9147a4a8` *"feat: introducing alt-tab pro!"* (v11.0.0, 2026-04-05), a 1869-file change that also did a large repo-wide folder reorg. `src/pro/` did not exist before it and has only ever been touched by 3 commits. All branding is still upstream's: bundle id `com.lwouis.alt-tab-macos`, name AltTab, domain `alt-tab.app`, repo `lwouis/alt-tab-macos`, Sparkle `SUPublicEDKey`, AppCenter secret. (See [`09-telemetry-and-upstream.md`](audit/09-telemetry-and-upstream.md).)
### Licensing: republishing a free build is permitted, with one obligation
The project is **GPL-3.0** ([`LICENCE.md`](../LICENCE.md):1 — *"GNU GENERAL PUBLIC LICENSE Version 3"*). Under GPL-3 you may modify and redistribute, including a build with all paywall code removed. **The obligation:** you must offer the complete corresponding source of your modified version under GPL-3 to your users (typically by publishing the fork's repo). The GPL-3 copyright string in `Info.plist:20-21` is correct and must stay. The paywall itself was GPL-3 code upstream, so its removal carries no additional license burden.
> **Strategic alternative (owner decision).** Because upstream is alive and GPL-3, rebasing the free build on the **pre-paywall upstream tag v10.12.0** (commit `317a485b`) yields the same unlocked feature set with no paywall to extract, at the cost of losing post-v11 fixes unless cherry-picked. The rest of this document assumes **in-place removal** from the current v11.x snapshot.
---
## 2. High-level architecture (the legitimate app)
This is the app that survives removal. Deep dive: [`01-core-architecture.md`](audit/01-core-architecture.md), [`02-switcher-ui.md`](audit/02-switcher-ui.md).
### Startup (two-phase, permission-gated)
- Entry point is top-level code in [`src/main.swift`](../src/main.swift) (**not** `@main`/`@NSApplicationMain`): CLI fast-path → signal/exception handlers → `App.shared.run()` (`main.swift:23`). `main.swift` is paywall-free.
- `App` (`src/App.swift:7`) subclasses `AppCenterApplication` (this is *why* removing AppCenter touches the principal class — see §5) and is its own `NSApplicationDelegate`.
- **Phase A** — `applicationDidFinishLaunching` (`App.swift:435-463`): wires logging, Preferences, the license callbacks, and starts a permission-polling timer.
- **Phase B** — `App.continueAppLaunchAfterPermissionsAreGranted` (`App.swift:383-431`): invoked **once** from `SystemPermissions.checkPermissionsPreStartup` (`SystemPermissions.swift:77`) only after Accessibility + Screen-Recording permissions pass. It creates the background threads/queues, switcher panels, menubar, main menu, and installs every macOS hook.
- The **phase-A → phase-B handoff is the critical launch path and is paywall-free.**
### macOS integration hooks (all paywall-free)
- Keyboard: `cghid` CGEvent tap + Carbon `RegisterEventHotKey` (`KeyboardEvents.swift:104-152`).
- Windows: AX observers with refcon-encoded `(pid,wid)` identity (`AccessibilityEvents.swift`), dispatched through a bounded retry executor (`AXCallScheduler.swift`).
- App lifecycle: KVO on `NSWorkspace.runningApplications` (`RunningApplicationsEvents.swift:6-10`).
- Threading owned by `BackgroundWork` (dedicated CFRunLoop threads + bounded operation queues, ~45-thread budget). **`src/macos/` and `BackgroundWork` contain zero License/Pro references** (grep-verified).
### Switcher UI
Event-driven: each `Window` subscribes to AX notifications → `Windows.findOrCreate` (`Windows.swift:390`), gated by `WindowDiscriminator.isActualWindow`, into the canonical `Windows.list`. Pure unit-tested kernels (`SelectionResolver`, `SearchModeResolver`, `WindowFilterResolver`, `WindowOrderResolver`) are separated from imperative wrappers and **never call pro code** — gating is passed in as a `Bool`. Rendering is a static `TilesView` engine with a 20-`TileView` recycled pool (`TilesView.swift:35`); focus uses private SkyLight APIs (`Window.swift:245-288`).
### Preferences
Everything stored as strings in `UserDefaults.standard`, macro (enum) prefs as stringified `CaseIterable` index, read through `CachedUserDefaults`, written through `Preferences.set` firing one `PreferencesEvents.preferenceChanged` hook. The **core preferences plumbing is paywall-independent** — only the feature-gating layer on top is paywall (see §3).
### Build / release / update
Raw `xcodebuild` over `alt-tab-macos.xcodeproj` (3 schemes), settings layered via `config/*.xcconfig`. Releases are fully automated by `semantic-release` on push to master. Signed with upstream Developer ID *Louis Pontoise (QXD7GW8FHY)*, notarized via bundled `notarytool`. Auto-update via embedded **Sparkle 2.9.1**, feed `https://alt-tab.app/appcast.xml`, EdDSA-signed. Crash reporting via Microsoft **AppCenter** (Crashes module only; opt-in; EoL). Deep dive: [`08-build-release-distribution.md`](audit/08-build-release-distribution.md), [`09-telemetry-and-upstream.md`](audit/09-telemetry-and-upstream.md).
---
## 3. The paywall, anatomized
The paywall lives **almost entirely under [`src/pro/`](../src/pro/)** and is grafted onto the spine at a small, well-defined set of call sites. Four subsystems:
### 3a. License subsystem — `src/pro/license/` ([`04-license-subsystem.md`](audit/04-license-subsystem.md))
`LicenseManager.shared` is the **single source of truth** for entitlement. It computes a flat 4-case `LicenseState` enum — `.trial(daysRemaining:)`, `.pro`, `.proExpired`, `.trialExpired` (`LicenseState.swift:14-19`); there is no `.lifetime` case (lifetime is `.pro` + variant slug `"pro_lifetime"`, `LicenseManager.swift:25`). A **14-day trial** starts on first launch (`LicenseManager.swift:17,192-200`).
- The master gate **`isProLocked`** (`LicenseManager.swift:67-72`) is `true` for `.proExpired`/`.trialExpired`, `false` for `.pro`/`.trial`. Every settings intercept, the write-bounce, the shortcut cap, and `ProFeature.attemptUse()` consult it.
- `activate(_:)` (`LicenseManager.swift:104-140`) does all-or-nothing Keychain writes with rollback, then flips `state = .pro`. `RemoteLicenseClient` (`RemoteLicenseClient.swift`) POSTs activate/validate/deactivate to **`https://alt-tab.app/api/v1/license`** (`Endpoints.swift:11`), sending a machine fingerprint (`MachineFingerprint.swift:9-20`, IOPlatformUUID) + trial-start timestamp. Revalidation is throttled to 30 days.
- Keychain service: **`com.lwouis.alt-tab-macos.license`** (`LicenseManager.swift:4`); a parallel UserDefaults suite of the same name holds `trialStartDate`, `lastValidation`, `customerEmail`.
- `LicenseCookie.swift:5-22` writes a per-tier `license` cookie on `.alt-tab.app` so the Sparkle appcast can be tier-tailored.
### 3b. Trial-nag "Pro Transition" scheduler — `src/pro/scheduling/` ([`05-trial-nag-scheduling.md`](audit/05-trial-nag-scheduling.md))
A self-contained Day1→Day35 upsell-escalation engine. Coordinator `ProTransitionManager` (singleton) + timed `ProTransitionScheduler` (persisted `DispatchWorkItem`) + persisted `ProTransitionState` (all `hasSeen*` flags + `remembered*` Pro indices, in the `.license` UserDefaults suite) + pure, 100%-unit-tested `ProTransitionManagerTestable` state machine. The coordinator emits abstract `ProPromptAction` cases through `onAction`; `ProPromptHost` maps each to a concrete Day-X window/popover so the coordinator stays AppKit-free.
- **Soft nags** (never block): `[A]` Day1 Welcome (fires immediately on first launch), `[H]` Day4 Tour, `[B]` Day12 Heads-Up, `[D]` Day15 Proactive, `[F]` Day21, `[G]` Day35, plus a Days13-14 menubar badge dot.
- **Hard gate arms at Day15 expiry:** `onProLockEngaged()` (`ProTransitionState.swift:73-79`) snapshots degradable Pro prefs to `remembered*` and downgrades stored values to free equivalents; the three hard-gated features then route through a free-pass ladder (`ProTransitionManager.swift:226-242`): first post-expiry attempt = `.freePass` (allow once, queue `[C]` Full Upgrade) → `.showFullUpgrade` → `.showHardGatePopover` `[E]` forever.
- Prompts only fire 10:00-11:30 or 15:30-17:00; give-up at Day49.
### 3c. Feature gating — `ProFeature.swift` + `PreferenceDefinition.swift` ([`03-preferences-and-gating.md`](audit/03-preferences-and-gating.md), [`06-profeature-and-copy.md`](audit/06-profeature-and-copy.md))
Two complementary mechanisms:
1. **Degradable preferences** — declared as 6 `ProGatedPreferences` definitions (`PreferenceDefinition.swift:103-164`): 3 globals (`appearanceStyle`, `appearanceSize`, `shortcutStyle`) + their 3 shortcut-0 overrides. Each carries a `freeEquivalent`, a `rememberedKey`, and an `isProValue` predicate. `PreferenceDefinition.read()` (`:32-44`) is the hot-path getter; while locked it returns the free equivalent. Pro getters in `Preferences.swift` route through these (`:129,130,155,313,319,330`).
2. **Hard-gated runtime actions** — no backing preference, gated at use-time via `ProFeature.attemptUse()` (`ProFeature.swift:74-83`): `extraShortcut(index:)`, `searchInSwitcher`, `lockSearchInSwitcher`. Call sites: `ShortcutAction.swift:56`, `TilesView.swift:79,92`.
Write-side enforcement bounces any setter that lands a Pro value into a gated key while locked, to the Upgrade tab (`PreferencesEvents.swift:62-64`). Free users are hard-capped at **1 shortcut** (`ControlsTab.swift:589-592`). The registered defaults already hold the Pro values (e.g. `appearanceSize=.auto`), so **removing the gate auto-unlocks everything with no default changes.**
### 3d. Upsell UI — `src/pro/ui/` + Settings/Menubar entanglement ([`07-pro-ui-and-menubar.md`](audit/07-pro-ui-and-menubar.md))
Reusable AppKit upsell primitives (none contain license logic; they only render): `ProPromptHost` (dispatch hub), `ProPromptPopover`/`ProPromptWindow` (shared chrome), `ProPromptHeader`, `ProGradientButton` (gradient buy CTA), `UsageStatHeroView` (a "your usage so far" stat block), and `ProBadgeView.swift` (misleadingly named — holds 5 types incl. the `ProGradient` utility enum, `NotAdvisedButton`, and the "Pro" pill). These are also consumed by **Settings** (the persistent Get-Pro gradient pill `UpgradeButton`, the in-window Upgrade view, `ProBadgeView` badges on gated rows, and the "Pro" search-index keyword), which is why `src/pro/ui/` can only be deleted after Settings consumers are cut.
---
## 4. The complete set of Pro-gated features (what becomes free)
There are **two degradable groups + three hard-gated actions**, marketed as four headline features. After removal, all are unconditionally available.
| Feature (marketing copy) | Kind | Free (locked) value | Pro value | Gate mechanism | Definition |
|---|---|---|---|---|---|
| **App Icons & Titles styles** (`appearanceStyle`) | Degradable + hard-gated | `thumbnails` (0) | `appIcons` (1) / `titles` (2) | `read()` downgrade + first-summon `[C]` | `PreferenceDefinition.swift:103-109`; `Preferences.swift:129` |
| **Auto Size** (`appearanceSize`) | Degradable | `medium` (1) | `auto` (3) | `read()` downgrade only | `PreferenceDefinition.swift:111-117`; `Preferences.swift:130` |
| **Search on release** (`shortcutStyle`) | Degradable + hard-gated | `doNothingOnRelease`/`focusOnRelease` | `searchOnRelease` (2) | `read()` downgrade + first-summon `[C]` | `PreferenceDefinition.swift:119-125`; `Preferences.swift:155` |
| ↳ same three as **shortcut-0 overrides** | Degradable | free equivalents | Pro values | `read()` downgrade | `PreferenceDefinition.swift:130-164`; `Preferences.swift:313,319,330` |
| **Up to 9 keyboard shortcuts** (extra slots ≥ 2) | Hard-gated | capped at **1** shortcut | up to 9 | `ProFeature.attemptUse()` + `addShortcutSlot` cap | `ProFeature.swift:14`; `ShortcutAction.swift:56`; `ControlsTab.swift:589-592` |
| **Search — find a window by typing** | Hard-gated | blocked | available | `ProFeature.attemptUse()` | `ProFeature.swift:15`; `TilesView.swift:92` |
| **Lock-search in switcher** | Hard-gated | blocked | available | `ProFeature.attemptUse()` | `ProFeature.swift:16`; `TilesView.swift:79` |
Marketing copy: `ProFeatureCopy.swift` (4 strings); the comparison table that advertises them is `Day1WelcomeLetterWindow.swift:77-80`. Note **preview-selected-window is NOT actually Pro-gated** (`Preferences.effectivePreviewSelectedWindow` reads the raw bool, gated only by Screen-Recording permission) — leave it alone.
---
## 5. The complete removal surface
**Removal strategy (consensus across all sections):** *delete `src/pro/` as a unit, then fix the consumers that reference it.* The lowest-risk staged approach is to first force `LicenseManager.isProLocked = false` / `isProAvailable = true` (every gate becomes a pass-through with no other edits), then progressively excise. The list below — grouped by file — is the full set of edits required for a clean build, incorporating the completeness review's additional items and build-break risks. **Bold flags** mark items the deep sections under-weighted.
### `src/pro/` — delete wholesale (CUT)
- `src/pro/license/` — `LicenseManager`, `LicenseState`, `LicenseAPI`, `RemoteLicenseClient`, `LicenseCookie`, `Keychain`, `MachineFingerprint`, `Clock`, `LicenseManagerSpecs.md`, `LicenseManagerTests.swift`.
- `src/pro/scheduling/` — `ProTransitionManager`, `ProTransitionScheduler`, `ProTransitionState`, `ProTransitionManagerTestable`, all `Day*.swift` windows/popovers, `ProTransitionTests.swift`, `ProTransitionSpecs.md`.
- `src/pro/ui/` — `ProPromptHost`, `ProPromptPopover`, `ProPromptWindow`, `ProPromptHeader`, `ProGradientButton`, `UsageStatHeroView`, `ProBadgeView.swift` (all 5 types). **Delete only AFTER the Settings consumers below are removed.**
- `src/pro/` root — `ProFeature.swift`, `ProFeatureCopy.swift`, `ProConversionCopy.swift`, `UsageStats.swift` / `UsageStatsTestable.swift` (local-only; feeds only upsell copy).
### `src/App.swift` (REWRITE — the spine wiring)
- Selectors `upgradeToProAction` / `openAccountAction` / `supportProjectAction` (`:25-27`) and methods `upgradeToPro` / `openAccount` / `supportProject` (`:111-121`) — remove (Menubar-only consumers).
- `hideUi` `onSwitcherDismissed()` (`:78`) and `showUiOrCycleSelection` `onSwitcherShown()` (`:328`) — remove the single Pro line each (switcher hot path).
- First-launch welcome entanglement (`:189-233`): `willShowDay1WelcomeOnAppLaunch` (`:205-208`) + deferral observer referencing `Day1WelcomeLetterWindow` (`:194-218`). **Simplify to always call `showAndCenterSettingsWindowOnFirstLaunch`; the welcome window and its `willCloseNotification` observer must be removed together or Settings never shows on first launch.**
- Phase-B Pro wiring `onAction` / `onAppLaunchComplete` (`:428-429`) — remove (all other phase-B wiring stays).
- License callback block (`:448-460`): `onBeforeProUnlock`, the `onStateChanged` fan-out (`refreshLicenseMenuItems`, `syncLicenseCookie`, `onLicenseStateChanged`, `UpgradeTab.refreshStatus`, `refreshUpgradeButton`, `proLockStateDidChange` post), `LicenseManager.initialize()`. The cleanest 13-line cut point. Keep `resetPreferencesDependentComponents` (`:51-53`) — it's used elsewhere; just drop the caller.
- Custom-URL handler `application(_:open:)` + `handleCustomUrl` (`:465-490`) — remove wholesale (exists only for the `<bundleid>://activate` deep link; the registered scheme is `$(PRODUCT_BUNDLE_IDENTIFIER)` = `com.lwouis.alt-tab-macos://`, NOT a literal `alt-tab://`). **Also drop the URL scheme from `Info.plist` (`CFBundleURLSchemes`, :53-56).**
### `src/Menubar.swift` (REWRITE)
- License item vars (`:8-10`), the Get Pro / My Account / Support adds + view assignment (`:46-50`), `refreshLicenseMenuItems` (`:84-107`), `toggleUpgradeMenuItem` (`:109-118`).
- `UpgradeMenuItemView` gradient pill incl. trial "days remaining" copy (`:235-390`; copy at `:370-375`) — delete; only consumer is `:47`.
- Badge dot: `badgeDotLayer` (`:187`), `updateBadgeDotOverlay` (`:202-227`) gated on `ProTransitionManager.shouldShowBadgeDot` (`:205`), and its call inside `loadPreferredIcon` (`:196`).
- `menuWillOpen` (`:392-399`): drop `LicenseManager.shared.refreshState()` (`:396`); **keep `refreshPermissionCallout()` (`:397`).**
- **`updateContent(_ state: LicenseState)` (`:357`) hard-depends on the `LicenseState` enum type — goes away with `UpgradeMenuItemView`. Keep `menubarIconCallback` (`:155-164`), used by non-pro callers.**
### `src/preferences/` (REWRITE)
- `PreferenceDefinition.swift` — delete entirely; **it is also a unit-test-target member (`project.pbxproj:1979`), so a broken getter fails app AND test compile.**
- `Preferences.swift` — rewrite the 3 gated getters (`:129,130,155`) and 3 index-0 override branches (`:313,319,330`) to read raw values; remove `overrideRememberedKey` + `ProTransitionState` cleanup in `removeOverride` (`:282-298`).
- `PreferencesEvents.swift:62-64` — delete the `isProLocked && isStoredValuePro` bounce.
- `PreferencesMigrations.swift:20` — remove the `ProTransitionState.markFreshInstallIfUnknown` call (the only paywall coupling in migrations; **the real symbol, distinct from the self-contained stub in `PreferencesMigrationsTests.swift:356`**).
- `LabelAndControl.swift` — strip `proGatedIndices` param + intercept (`:74-116`, esp. `:86-92`).
- `AppearanceTab.swift` — `proLockStateDidChange` observer (`:414-419`), `proGatedAppearanceStyleIndices` (`:462,700-703`), `ProBadge.attach` (`:463,481,575,688-698`), `wrapShortcutStyleProLockIntercept` (`:585-598`), `wrapAppearanceSizeProLockIntercept` (`:662-679`), `refreshProLockUi` (`:705+`).
- `ShortcutEditor.swift` — `proGatedIndices` (`:429,437,449,459`), segmented + radios locked intercepts (`:599-607,705-711`).
- `ControlsTab.swift` — `proLockStateDidChange` observer (`:141`), `row.setProBadge` for index≥1 (`:515`), the `addShortcutSlot` shortcut cap (`:589-592`).
- `ShortcutsWhenActiveSheet.swift` — `ProBadge` on search/lock-search rows (`:25,51,54,63-69`).
### `src/preferences/settings-window/SettingsWindow.swift` — **the most license-entangled non-pro file outside Menubar/UpgradeTab (REWRITE — under-weighted by §7)**
- `UpgradeButton` class (`:133-214`, a `ProGradientButton` subclass reading `LicenseManager.state`/`.isLifetimeVariant`/`.customerEmail`/`.trial`/`.proExpired`); instance `:303`; state `:308-311`; `setupUpgradeButton` + `upgradeButtonClicked` (`:401,507-526`).
- `showUpgradeView` / `hideUpgradeView` embedding `UpgradeTab.initTab` (`:1066,1122-1161`); `refreshUpgradeButton` (`:1163-1165`, called from `App.swift:454` and `UpgradeTab:214`).
- `windowDidBecomeKey` → `LicenseManager.refreshState` + `UpgradeTab.refreshStatus` + shine animation (`:1243-1254`); `UpgradeTab.cleanup` in `windowWillClose` (`:1266`).
- Settings-search registers `ProBadgeView → "Pro"` (`:679-680`); `refreshEmailTooltip` + `hasSecondLine` read `.pro`/`customerEmail` (`:196,206-207`).
### Other Settings / secondary windows (REWRITE)
- `SidebarList.swift` — `private var proBadge: ProBadgeView?` (`:93`) and `setProBadge` instantiating `ProBadgeView` (`:244-258`).
- `UpgradeTab.swift` — delete the whole tab; **`ProHeroButton: ProGradientButton` (`:493`) + `usageHero`/`heroButton`/`makeHeroButton` (`:5-6,37-38,160-161`); `activateLicense`/`deactivateLicense`/`deactivateInstance` + `LicenseAPIError.seatLimitExceeded` seat-management UI (`:322-405`) — full activation UI, §4 listed only `LicenseManager`/`RemoteLicenseClient`.**
- `DebugProfile.swift:17` — `LicenseManager.shared.state.debugProfileLabel` (extension at `LicenseState.swift:14`). **This feeds `FeedbackWindow`, which is being kept — hand-edit (drop the License line), do not delete the file.**
### `src/debug/QAMenu.swift` (REWRITE)
- The DEBUG manual triggers/resets (`:170-241`) directly instantiate every Day-X window and poke `LicenseManager`/manager flags — must be removed/updated in lockstep or the **DEBUG build won't compile.**
### Build / distribution / telemetry (REWRITE / FLAG — see [`08`](audit/08-build-release-distribution.md), [`09`](audit/09-telemetry-and-upstream.md))
- `Endpoints.swift` — `licenseApiBaseUrl` (`:11`), `checkoutUrl` (`:9`), `accountUrl` (`:10`) become dead. `feedbackUrl` (`:12`) and `supportUrl` (`:8`) are not paywall — owner decides.
- **Identity (mandatory regardless of removal):** the upstream Developer ID cannot be used. Change bundle id (`config/base.xcconfig:4`; also hardcoded `Mocks.swift:133` and unit-test ids `project.pbxproj:2477/2542`), signing cert (`release.xcconfig:5`), generate a NEW Sparkle EdDSA keypair (`Info.plist:62 SUPublicEDKey` + CI `$SPARKLE_ED_PRIVATE_KEY`), repoint feed/download/website URLs (`base.xcconfig:20-21`, `update_appcast.sh:16,18`, `App.repository` `App.swift:16`), truncate the upstream-signed `appcast.xml`.
- **AppCenter removal touches the app's principal class:** `App` subclasses `AppCenterApplication` (`App.swift:7`), `Info.plist:36-37 NSPrincipalClass=AppCenterApplication`, `alt-tab-macos-Bridging-Header.h:1`. Removing AppCenter requires choosing a replacement principal class / reverting to `NSApplication`, or **the app won't launch and won't compile.** Also: `src/vendors/AppCenterCrashes.swift`, `Secrets.appCenterSecret`, `scripts/upload_symbols_to_appcenter.sh`, `ci_cd.yml:56`.
- `.github/FUNDING.yml` and the website repository-dispatch (`update_website.sh:5 → lwouis/alt-tab-website`) point at upstream — repoint or remove.
### `alt-tab-macos.xcodeproj/project.pbxproj` — **mechanical but build-fatal (REWRITE)**
Every `src/pro` file (and `Endpoints`/`Secrets`/`AppCenter`) has a `PBXBuildFile` + `PBXFileReference` + `PBXGroup` child + `PBXSourcesBuildPhase` membership (~102 in-Sources pro entries; app phase ~1830, test phase ~1902; incl. `PreferenceDefinition.swift:1979`). **`Clock`/`Keychain`/`LicenseAPI`/`MachineFingerprint`/`ProTransitionManagerTestable` each have TWO build-file UUIDs (app + test) — both must be removed.** Any file deleted on disk but left referenced breaks at project-load/link.
### Test target (REWRITE)
`LicenseManagerTests`, `ProTransitionTests`, `ProTransitionManagerTestable`, `UsageStatsMessageTests`, `ProBadgeViewSegmentTests`, and `_test-support/Mocks.swift` (ProBadgeView stub + `ShortcutStylePreference` mock + bundle id `:133`) all break if pro symbols vanish — update/remove in lockstep.
### Build-break risk summary (the hard dependencies)
| Symbol being deleted | Non-pro / under-weighted consumers that break first |
|---|---|
| `LicenseState` enum | `Menubar.swift:357` (`updateContent` param), `DebugProfile.swift:17` (`.debugProfileLabel`) |
| `LicenseManager.shared` | `SettingsWindow.swift:162,164,167,196,206-207,1246`; `UpgradeTab.swift:220-405` |
| `ProGradientButton` (superclass) | `UpgradeButton` (`SettingsWindow.swift:133`), `ProHeroButton` (`UpgradeTab.swift:493`), `ProGradient.makeLayer` (`Menubar.swift:247`) |
| `ProBadgeView` | `SidebarList.swift:93,254`, `SettingsWindow.swift:679`, Appearance/Controls/ShortcutsWhenActiveSheet tabs |
| `ProGatedPreferences` / `PreferenceDefinition` | `Preferences.swift:129,130,155`; also a test-target member → fails app + test compile |
| `AppCenterApplication` | `App.swift:7` superclass + Bridging-Header + `Info.plist` NSPrincipalClass |
| `Day1WelcomeLetterWindow` | `App.swift:213` first-launch deferral observer |
| `ProTransitionState.markFreshInstallIfUnknown` | `PreferencesMigrations.swift:20` (real symbol) |
---
## 6. Notable findings, risks, and open questions
### Findings
- **The paywall is well-isolated.** Outside `src/pro/`, the entire spine coupling is ~13 lines in `App.swift` (`:448-460`) plus discrete blocks in `Menubar`, `Settings`, and the gating layer. The launch path, switcher kernels, macOS hooks, threading, and core preferences are all paywall-free.
- **Defaults already hold Pro values.** Removing the gate auto-unlocks features with no default changes (`appearanceSize=.auto`, etc.).
- **`.proExpired` is effectively dead code** in this build: `versionLimitedVariants` is empty (`LicenseManager.swift:28`), so no `.pro` variant ever expires by version.
- **No third-party analytics SDK.** Only telemetry is AppCenter Crashes (opt-in, EoL); `UsageStats` is local-only and never networked.
### Risks
- **In-place upgrade data loss.** Users who passed trial expiry on the paywalled build have stored prefs already downgraded to free equivalents, with the original Pro index parked in `proTransition.remembered*` keys (in the `.license` UserDefaults suite). After removal, `read()` no longer restores them → silent loss of their prior Pro selection. A **new bundle id** (clean UserDefaults domain) sidesteps this; otherwise add a one-time migration copying `remembered*` indices back to base keys before deleting `proTransition.*`.
- **`state.didSet` fires `onStateChanged`.** If you hardcode `state = .pro` at init, ensure the hook tolerates being invoked (or unset it) — otherwise it can re-enter paywall code you intend to delete.
- **Keychain/signing invariant is MOOT for a free build.** Removing `src/pro` removes all Keychain license usage, so changing bundle id / Developer ID has nothing to orphan — but the **identity change is still mandatory** because the upstream Developer ID cannot be used. (Per [`AGENTS.md`](../AGENTS.md), do not rotate identity while licensing is retained.)
- **Do NOT `git revert 9147a4a8`.** It mixes the paywall with a 1869-file folder reorg; removal must be by deleting `src/pro/` and untangling the wiring, then confirming the build links (`ai/build.sh`).
### Open questions (owner decisions)
1. **New bundle id vs in-place?** Determines whether the `remembered*` migration above is needed.
2. **Keep "Support this project"** (donation link, upstream precedent) and the feedback feature? Both are non-paywall but currently route through paywall-adjacent code / upstream endpoints (`Endpoints.supportUrl`, `Endpoints.feedbackUrl` → `alt-tab.app`, whose server side is not in this repo).
3. **Auto-update story.** A fork cannot ship updates against the upstream `SUPublicEDKey` / `alt-tab.app/appcast.xml` without owning that domain and lwouis's private key. Stand up own host + EdDSA key, or strip Sparkle.
4. **Keep crash diagnostics?** Remove AppCenter entirely (most privacy-aligned, but requires a replacement principal class) vs repoint to a self-owned AppCenter app (EoL).
5. **Rebase on pre-paywall v10.12.0** vs in-place removal — both yield the same unlocked feature set.
---
*Deep-dive sections: [`01-core-architecture`](audit/01-core-architecture.md) · [`02-switcher-ui`](audit/02-switcher-ui.md) · [`03-preferences-and-gating`](audit/03-preferences-and-gating.md) · [`04-license-subsystem`](audit/04-license-subsystem.md) · [`05-trial-nag-scheduling`](audit/05-trial-nag-scheduling.md) · [`06-profeature-and-copy`](audit/06-profeature-and-copy.md) · [`07-pro-ui-and-menubar`](audit/07-pro-ui-and-menubar.md) · [`08-build-release-distribution`](audit/08-build-release-distribution.md) · [`09-telemetry-and-upstream`](audit/09-telemetry-and-upstream.md)*
-98
View File
@@ -1,98 +0,0 @@
# CI/CD readiness — CommandTabFree (fork of lwouis/alt-tab-macos)
**Date:** 2026-06-16 **Branch:** `master` **Repo:** `KofTwentyTwo/CommandTabFree` (public)
**Status:** publishing pipeline + Sparkle auto-update **LIVE** — signed + notarized **v100.2.0** released (Developer ID `James Maes (2X834TJ5MA)`, `spctl`-accepted + stapled), feed served on GitHub Pages (`koftwentytwo.github.io/CommandTabFree/appcast.xml`). The release apparatus (cert-import → sign-build → notarize → appcast → semantic-release → gh-release) is gated on a `will-release` signal (empty `VERSION.txt` ⇒ no release), so docs/ci pushes build+test without cutting a release or needing `[skip ci]`.
**Authoritative design:** `docs/PLAN-maintained-fork.md` §4; owner checklist `docs/EXECUTION-STATUS.md` §3.4/§3.5.
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`
Environment.
| Capability | Enabling secret(s) | Gated step(s) |
|---|---|---|
| Code-sign keychain import | `APPLE_P12_CERTIFICATE` | `setup_ci_master.sh` |
| Notarize + staple | `APPLE_ID` + `APPLE_PASSWORD` + `APPLE_TEAM_ID` | `package_and_notarize_release.sh` |
| 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.)
-295
View File
@@ -1,295 +0,0 @@
# Execution status — depaywall-free integration
**Branch:** `depaywall-free`
**Upstream sync point:** lwouis/alt-tab-macos `v11.3.0` (recorded in `.fork-sync-state`)
**Date:** 2026-06-10
**Authoritative plan:** `docs/PLAN-maintained-fork.md`. Audit: `docs/AUDIT.md`.
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
pbxproj edits), per PLAN §4.4 Guard A:
- `testDepaywallProNeverLocked` — fresh mock-injected `LicenseManager`; asserts `state == .pro`.
- `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.
**GPL attribution docs:** `README.md` (modified), `NOTICE.md` (NEW) — GPL §5(a) statement of
changes; currently references placeholder identity (`AltTabFree`, the placeholder bundle id, the
placeholder GitHub URL) and upstream `v11.3.0` / `2026-06-10`.
**Upstream-sync + guard CI:** `.github/workflows/upstream_sync.yml` (NEW), `.github/workflows/guard.yml`
(NEW), `.fork-sync-state` (NEW, `v11.3.0`). `guard.yml` runs Guard A (anti-relock test on `macos-15`,
Xcode pinned to `26.0.1`), Guard B (depaywall marker grep), Guard C (conflict-marker grep) on
`pull_request`.
**Release pipeline:** `.github/workflows/ci_cd.yml` (re-architected: `pull_request` trigger added,
release-side steps gated, Guard B/C greps inserted FIRST in the push job),
`scripts/update_appcast.sh`, `scripts/replace_environment_variables_in_app.sh`, `release.config.js`,
`appcast.xml` (trimmed), `.github/FUNDING.yml`, `.gitignore`.
All three workflow YAMLs validate (`yaml.safe_load` OK).
---
## 2. Verified results
### 2.1 Build — full tree compiles (PASS)
`bash ai/build.sh` (Debug) FAILS in this environment with exactly one error:
```
error: No certificate matching 'Local Self-Signed' found … (target 'alt-tab-macos')
```
This is `config/debug.xcconfig:5` (`CODE_SIGN_IDENTITY = Local Self-Signed`) requiring a
self-signed cert that is **not present in this CI/dev keychain** (`security find-identity -v
-p codesigning` → `0 valid identities found`). It is a pre-existing ENVIRONMENT prerequisite,
unrelated to the depaywall patch or scaffolding (the scaffolding touches no Debug signing config).
Re-running with signing disabled isolates compilation:
```
xcodebuild -project alt-tab-macos.xcodeproj -scheme Debug -configuration Debug \
-derivedDataPath DerivedData \
CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO
→ ** BUILD SUCCEEDED ** (exit 0; 0 errors; 218 Swift compile actions)
```
**The full source tree compiles clean after all scaffolding.** No scaffolding file needed a fix,
so the commit was not amended. The default `ai/build.sh` will succeed once the owner provisions a
signing identity (see checklist).
### 2.2 Tests — GREEN (anti-relock guards PASS; 16 legacy paywall tests skipped — §2.3)
`xcodebuild test -scheme Test -configuration Debug` (signing disabled by `config/test-base.xcconfig`):
- **477 tests pass.**
- The two anti-relock guards **PASS**:
- `testDepaywallProNeverLocked` — passed (0.001s)
- `testDepaywallStillProAfterTrialExpiry` — passed (0.001s)
- **16 legacy paywall-behavior tests in `LicenseManagerTests` were RED** (they assert the now-dead
trial/expiry/lock/activate behavior). **RESOLVED in commit `1a914c88`** — each is now `XCTSkip`-marked
with an `alt-tab-free [depaywall]` reason. Zero failures outside `LicenseManagerTests`; zero attributable
to scaffolding.
**Overall Test scheme result: GREEN** (`** TEST SUCCEEDED **`) — 493 executed, **16 skipped, 0 failures**;
both `testDepaywall*` anti-relock guards RUN and PASS.
### 2.3 RESOLVED (commit `1a914c88`) — 16 legacy paywall tests contradicted the patch
> **RESOLVED 2026-06-10:** all 16 are now `XCTSkip`-marked (`alt-tab-free [depaywall]`), so the `Test`
> scheme is GREEN and the two `testDepaywall*` guards remain the active anti-relock signal. The analysis
> below is retained for the record.
The depaywall patch forces `computeState()` to always return `.pro`. Sixteen pre-existing tests in
`src/pro/license/LicenseManagerTests.swift` still assert the now-dead trial/expiry/lock behavior and
therefore fail. They were introduced-as-failing by the depaywall patch commit (`f3d76859`), NOT by
this integration pass's scaffolding. The failing tests:
```
testFirstLaunchStartsTrial testTrialMidway
testSecondLaunchPreservesTrialStart testTrialLastDay
testTrialExpiresOnDay14 testTrialExpiresWellPastDuration
testPreviouslyInvalidatedLicenseIsTrialExpired testLicenseWithoutValidationResultIsTrialExpired
testIsProLockedTrueAfterTrialExpiry testIsProLockedTrueWhenKeychainInvalidated
testOnStateChangedFiresOnInitialize testOnBeforeProUnlockFiresBeforeStateFlipsToPro
testActivateFailurePreservesState testActivateSeatLimitExceededSurfacesInstances
testActivateFailsAndRollsBackIfKeychainWriteFails testActivateRollsBackPartialKeychainWritesOnLaterFailure
```
Root cause (two clusters, both downstream of the forced `.pro`):
1. **initialize-path tests** assert `.trial(...)` / `.trialExpired` after `initialize()`, which now
always yields `.pro`.
2. **activate-failure-path tests** assert state stays `.trial` after a rejected/rolled-back
`activate`, but the rollback re-reads `state = computeState()` → `.pro`.
**Why this is BLOCKING and was NOT silently "fixed" here:** PLAN §4.4 makes `run_tests.sh` (the whole
`Test` scheme) the Guard A release gate, wired into both `ci_cd.yml` (push job) and `guard.yml`
(PR). A permanently-RED Test scheme means **Guard A can never go green → no release can ever ship,
and the genuine anti-relock signal is drowned out by 16 expected failures.** Neutralizing 16 tests is
a substantive source change to a hot test file (re-conflicts on every upstream sync that touches it)
and is outside the "fix the offending *scaffolding* file" / "do NOT deviate from strategy" mandate of
this integration pass, so it is flagged for an explicit owner/patch decision rather than done
unilaterally. **Recommended disposition** (consistent with the depaywall strategy — neutralize, don't
delete): wrap each failing assertion's body in `if false { … }` with an `alt-tab-free [depaywall]`
marker and replace it with an `XCTAssertEqual(manager.state, .pro)` assertion, exactly mirroring how
the production code was neutralized. This keeps the merge surface minimal and the test file's
membership/pbxproj untouched. (Alternatively, delete the 16 methods — simpler diff, but loses the
documentation of the dead behavior.)
### 2.4 Gate proofs — the paywall is dead (verified by grep + reachability)
| Plan requirement | Proof |
|---|---|
| (a) `computeState()` returns `.pro` with `depaywall` marker | `LicenseManager.swift:177` — `guard false else { return .pro } // depaywall: …` |
| (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. |
| (c) `syncLicenseCookie` + `AppCenterCrash.init` wrapped in `if false` | `LicenseCookie.swift:6` — `if false { // depaywall: …`; `AppCenterCrashes.swift:10` — `if false { // depaywall: …` |
---
## 3. Owner publish checklist — only you can do these
These require human judgment, secrets, or external accounts. Items marked **BLOCKING** must be done
or the fork will not notarize, auto-update, gate releases, or stop phoning home. Grouped, with the
per-component owner actions gathered from the scaffolding agents merged in.
### 3.0 ✅ DONE (commit `1a914c88`) — Test scheme is GREEN
- [x] **Resolved the 16 RED legacy paywall tests** in `src/pro/license/LicenseManagerTests.swift` —
each is now `XCTSkip`-marked (`if true { throw XCTSkip("alt-tab-free [depaywall]: …") }`; the bare
`throw` form is rejected by `SWIFT_TREAT_WARNINGS_AS_ERRORS`). The `Test` scheme is now GREEN
(493 executed, 16 skipped, 0 failures); the two `testDepaywall*` anti-relock guards still RUN and
PASS. No owner action remains here.
### 3.1 Brand / identity / trademark (BLOCKING for a clean publish)
- [x] **Fork name + brand sweep + l10n (DONE 2026-06-15)** — name is **CommandTabFree** (trademark-distinct);
brand sweep done; l10n rebranded across base/en + all ~20 language tables, upstream attribution kept.
- [x] **`PRODUCT_NAME = CommandTabFree` (DONE)** in `config/local.xcconfig`. Verify `ci_cd.yml` `APP_NAME`
matches when CI is wired.
- [x] **Fork bundle id FROZEN (DONE)** — `com.koftwentytwo.commandtabfree` (option (a) fresh id) in `config/local.xcconfig`.
- [x] **`README.md` + `NOTICE.md` (DONE)** — final product name, real fork GitHub URL; README now includes the brew install.
- [ ] **At release time, set `NOTICE.md`'s upstream version + modification date** to the actual sync
point (currently `v11.3.0` / `2026-06-10`). Keep `NOTICE.md` current per PLAN §6.2 on every future
sync that changes the fork's modification surface. **Note:** `NOTICE.md` lists
"App.handleCustomUrl (activate disabled)" as a change — the §2 Edit-2 neutralization HAS landed
(`App.swift:474`), so that line is accurate; if you ever opt NOT to neutralize it, remove that line.
- [x] **App icon DONE (owner-approved 2026-06-15)** — the ⌘⇥ glyph (app + menu bar) is the chosen mark, not lwouis's mark. No replacement planned.
- [ ] **Add `config/local.xcconfig` and `package.json` to the PLAN §6.2 "keep ours" recurring-conflict
set** so a future merge can't re-introduce upstream identity or the MIT license string.
### 3.2 Hosts / domain (BLOCKING for update + feedback)
- [ ] **Set `DOMAIN` and `API_DOMAIN` in `config/local.xcconfig`** to fork-owned, routable hosts
(currently `fork.invalid` / `fork.invalid/api` placeholders). `DOMAIN` must serve
`https://<DOMAIN>/appcast.xml` and `/support`; `API_DOMAIN` drives the feedback POST. Decide the
§3.4 feedback strategy. The `activate` fingerprint POST is closed independently at the handler
(§2 Edit 2), so `API_DOMAIN` may be routable for feedback WITHOUT re-arming exfiltration.
- [ ] **Stand up the appcast/feed host:** point `DOMAIN` at the gh-pages site that serves
`/appcast.xml` (or CNAME it). `Endpoints.appcastUrl = https://$DOMAIN/appcast.xml`.
- [ ] **Remove `appcast.xml` from the tree** (`git rm appcast.xml`) and add `/appcast.xml` to
`.gitignore` — the feed is served out-of-tree from gh-pages. (Coordinate: `.gitignore` is owned by
the identity overlay.)
### 3.3 Code signing + Sparkle keys (BLOCKING for notarization + auto-update)
- [ ] **Obtain an Apple Developer ID Application certificate** for the fork and set
`CODE_SIGN_IDENTITY` in `config/local.xcconfig` (currently a commented TODO). Required for
notarization/Gatekeeper. Also re-sign the vendored Sparkle helpers under the SAME Developer ID
(PLAN §3.3 / §4.6) or notarization rejects. (This is also what makes the default `ai/build.sh`
stop failing on the missing `Local Self-Signed` cert.)
- [x] **Sparkle EdDSA keypair generated (2026-06-15).** PUBLIC key wired into `Info.plist`
(`SUPublicEDKey`); PRIVATE key stored in 1Password "CommandTabFree-Sparkle-EdDSA" + GitHub
Actions secret `SPARKLE_ED_PRIVATE_KEY` (env `production`); keychain account `commandtabfree`.
`SUEnableAutomaticChecks` set false until DOMAIN serves a real appcast.
**TODO at CI-wiring time:** `update_appcast.sh` still calls the deprecated `sign_update -s` —
switch it to `--ed-key-file` (or `--account commandtabfree`); the `-s` path rejects this key format.
### 3.4 GitHub Actions secrets + Environment (BLOCKING for release)
- [ ] **Create the GitHub Environment named `production`** (matches `ci_cd.yml` `environment:`) with NO
required-reviewer protection that would stall releases.
- [ ] **Set these secrets (repo + the `production` Environment):** `APPLE_P12_CERTIFICATE` (fork's
Developer ID), `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID`, `SPARKLE_ED_PRIVATE_KEY` (fork's NEW
EdDSA private key), and `SYNC_BOT_TOKEN`.
- [ ] **Provision `SYNC_BOT_TOKEN` (BLOCKING, PLAN §4.2):** a GitHub App installation token (preferred)
or a fork-owned PAT with contents + pull-requests write. **The default `GITHUB_TOKEN` will NOT
work** — a PR it authors fires no `pull_request` checks, so `guard.yml` / `ci_cd.yml` never run on
the sync PR. Add it to the §4.6 credential-lifecycle bucket (it expires; can push to master if
leaked).
### 3.5 Upstream-sync wiring (BLOCKING for the perpetual-republish promise)
- [ ] **Create the three PR labels at bootstrap (§6.1):** `gh label create sync`,
`gh label create conflict`, `gh label create chokepoint-refresh`. `upstream_sync.yml` fails closed
if any is missing.
- [ ] **Set up the EXTERNAL scheduler (§4.2 recommended primary)** hitting `repository_dispatch` (event
type `upstream-sync`) carrying `SYNC_BOT_TOKEN` — immune to GitHub's 60-day auto-disable of in-repo
cron. The in-repo cron is belt-and-suspenders only.
- [ ] **`.fork-sync-state` writer (contract owned by `ci_cd.yml`):** add the push-job step that advances
`.fork-sync-state` to the merged tag on merge to master — without it the cron re-opens the same tag
forever. (This file is the contract; the writer lives in `ci_cd.yml`.)
- [ ] **Add the two-trigger liveness alert (§4.2):** notify if no `sync/*` PR in ~45 days OR oldest open
`sync/*` PR > ~14 days (the workflow emits a `stalled` output flag for this).
- [ ] **Re-pin `guard.yml`'s Xcode line** (currently `Xcode_26.0.1`) whenever an upstream sync bumps
`ci_cd.yml`'s pinned Xcode (§4.5 / §6.2).
### 3.6 Commit/changelog plumbing (needed for CI to pass on sync PRs)
- [ ] **Add the `commitlint.config.js` `ignores` predicate (§4.3)** to drop the synthetic
`chore(sync): merge upstream` commit and upstream's non-conforming messages — `ci_cd.yml`'s
commitlint steps depend on it.
- [ ] **Inject the GPL §6(d) corresponding-source pointer into the GitHub Release body**
(`scripts/extract_latest_changelog.sh`). `update_appcast.sh` already covers the Sparkle channel;
the GitHub Release channel still needs it (PLAN §5(1)).
- [ ] **Consider a source tarball release asset and/or a takedown-contingency mirror** so GPL §6 source
survives a repo takedown (PLAN §5(1) conveyance durability).
### 3.7 Bootstrap acceptance tests (do before declaring "published")
- [ ] **§4.2 first-sync acceptance:** open a sync PR THROUGH the bot and confirm `guard.yml` +
`ci_cd.yml` `pull_request` checks actually appear and run. A check-less bot PR means
`SYNC_BOT_TOKEN` is mis-set.
- [ ] **§4.4 relock acceptance:** push a branch reverting the `computeState() → .pro` flip and confirm
`guard.yml` goes RED (Guard A test on `macos-15`, and/or the Guard B marker grep) before merge.
- [ ] **§6.1 fork-to-fork auto-update acceptance:** confirm a user on fork build N actually receives
build N+1 via Sparkle — enclosure URL resolves to the fork's release, the EdDSA signature verifies
against the embedded fork public key, `SUVersion` ordering is monotonic, and Gatekeeper is the only
first-run friction (PLAN §3.6.1).
---
## 4. Commit log on `depaywall-free`
```
3dde8a91 chore: identity overlay + sync/guard/release CI scaffolding (placeholders flagged for owner) [this pass]
4506c539 test: anti-relock guards (pro never locked, incl. post-trial-expiry)
f3d76859 depaywall: neutralize license gate, cookie, telemetry, and activate handler (src/pro left intact)
e243fb4b docs: audit + republish/maintained-fork plans
9fadf36b chore(release): 11.3.0 [skip ci] (upstream sync point)
```
-291
View File
@@ -1,291 +0,0 @@
# alt-tab-free — System Graphs
Visual companion to the paywall-removal audit (`docs/audit/01..09`). Five Mermaid diagrams:
overall architecture, license activation/state flow, the Day1→Day35 trial-nag escalation, the
feature-gating downgrade/restore mechanism, and the removal map (delete vs edit vs keep).
All `file:line` references are against branch `master`, commit `9fadf36b`. Diagrams use only simple
ASCII node labels so they render in any Mermaid viewer.
---
## 1. Overall application architecture & subsystem relationships
```mermaid
flowchart TD
main["src/main.swift<br/>CLI fast-path, signal/exception handlers<br/>(PAYWALL-FREE)"]
main -->|App.shared.run| app["src/App.swift<br/>App : AppCenterApplication<br/>NSApplicationDelegate"]
app -->|phase A| phaseA["applicationDidFinishLaunching<br/>App.swift:435-463<br/>logging, Preferences, license wiring,<br/>permission-poll timer"]
phaseA -->|polls until granted| perms["src/macos/SystemPermissions<br/>Accessibility + Screen Recording"]
perms -->|"continueAppLaunchAfterPermissionsAreGranted (SystemPermissions.swift:77)"| phaseB["phase B<br/>App.swift:383-431<br/>threads, panels, menubar,<br/>hooks, Sparkle"]
phaseB --> switcher["Switcher UI<br/>src/switcher/<br/>TilesPanel, PreviewPanel, Windows"]
phaseB --> prefs["Preferences + Settings<br/>src/preferences/"]
phaseB --> events["Event hooks<br/>src/events/, KeyboardEvents,<br/>AccessibilityEvents, RunningApps"]
phaseB --> macos["src/macos/<br/>AXCallScheduler, LoginItem<br/>(PAYWALL-FREE)"]
phaseB --> bg["BackgroundWork<br/>run-loop threads + queues<br/>(PAYWALL-FREE)"]
phaseB --> menubar["src/Menubar.swift<br/>status item + menu"]
app -. license callbacks .-> pro["src/pro/ PAYWALL OVERLAY"]
menubar -. Get Pro / badge dot .-> pro
switcher -. ProFeature.attemptUse .-> pro
prefs -. PreferenceDefinition gating .-> pro
subgraph pro["src/pro/ PAYWALL OVERLAY"]
lic["license/<br/>LicenseManager, RemoteLicenseClient,<br/>Keychain, MachineFingerprint"]
sched["scheduling/<br/>ProTransitionManager + Scheduler,<br/>Day1..Day35 windows"]
feat["ProFeature.swift<br/>capability registry"]
ui["ui/<br/>ProPromptHost, ProBadgeView,<br/>ProGradientButton, UsageStatHeroView"]
feat --> sched
sched --> ui
lic --> sched
end
api["src/api/Endpoints.swift"] -.-> lic
sparkle["Sparkle auto-update<br/>SparkleDelegate"] --> phaseB
appcenter["AppCenter crash reporting<br/>src/vendors/"] -.-> app
```
The app boots from a plain top-level `src/main.swift` (no `@main`), which calls `App.shared.run()`.
`App.swift` is the spine and uses a deliberate two-phase launch: phase A wires logging, preferences,
the license callbacks, and a permission-poll timer; only once Accessibility + Screen-Recording
permissions pass does `SystemPermissions` call back into phase B, which stands up every subsystem and
installs the macOS hooks. The switcher, events, `src/macos/`, and `BackgroundWork` are entirely
paywall-free; the paywall (`src/pro/`) is grafted on through callbacks wired in `App.swift` and
`Menubar.swift`, plus a handful of gate call-sites in the switcher and preferences. The dotted arrows
are the only couplings a removal must sever.
---
## 2. License activation + state flow
```mermaid
sequenceDiagram
actor User
participant App as App.swift
participant LM as LicenseManager.shared
participant API as RemoteLicenseClient
participant Remote as alt-tab.app/api/v1/license
participant KC as Keychain + UserDefaults
participant UI as Menubar / Settings / Gating
Note over App,LM: App launch (phase A)
App->>LM: initialize() (App.swift:460)
LM->>LM: computeState()<br/>seeds trialStartDate on first launch
LM-->>UI: onStateChanged(state) fan-out (App.swift:449-459)
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}
Remote-->>API: {activated, instance_id, variant_id, customer_email}
API-->>LM: ActivateResult
alt activated == true
LM->>KC: all-or-nothing write<br/>licenseKey, instanceId, variantId<br/>(rollback on any failure)
LM->>KC: lastValidation, lastValidationResult=true, customerEmail
LM->>LM: onBeforeProUnlock() then state = .pro
LM-->>UI: onStateChanged(.pro) (gates open)
else invalid_key / seat_limit_exceeded / rejected
LM-->>UI: LicenseAPIError (state untouched)
end
Note over LM,Remote: Background revalidation throttled to 30 days
LM->>API: revalidateWithServer() (if elapsed >= 30d)
API->>Remote: POST /validate {license_key, instance_id}
Remote-->>LM: {valid} -> .pro or .trialExpired
```
`LicenseManager.shared` is the single source of truth for entitlement and computes a flat 4-case
`LicenseState` (`.trial`, `.pro`, `.proExpired`, `.trialExpired`). At launch `initialize()` computes
state (seeding a 14-day `trialStartDate` on first run) and fans out through `onStateChanged`.
Activation arrives via the `alt-tab://activate` URL scheme (or the Settings Upgrade tab), POSTs to
`alt-tab.app/api/v1/license/activate` with the machine fingerprint, and on success performs an
all-or-nothing Keychain write (with rollback) before flipping state to `.pro`. A throttled 30-day
background revalidation re-checks the license. The whole remote/Keychain layer feeds exactly one
downstream signal the rest of the app consumes: `isProLocked` / `isProAvailable`.
---
## 3. The Day1 -> Day35 trial-nag escalation
```mermaid
stateDiagram-v2
[*] --> Day1
note right of Day1
SOFT NAGS = informational, never block.
Each fires once, gated by a hasSeen* flag.
Prompts only fire 10:00-11:30 or 15:30-17:00.
end note
Day1: Day 1 - [A] Welcome Letter (SOFT)
Day1: Fires immediately on first launch; blocks others until seen
Day4: Day 4 - [H] Tour Popover (SOFT)
Day4: Only if switcher opened on Day 4 exactly
Silent: Days 5-11 silent trial
Day12: Day 12 - [B] Heads-Up Popover (SOFT)
Day12: "Trial ends in 2 days"; skipped on Day 13+
Badge: Days 13-14 - menubar badge dot (SOFT)
Expiry: Day 15 - trial expires -> HARD GATE ARMS
Expiry: onProLockEngaged downgrades degradable prefs to Free,<br/>snapshots Pro values into remembered*
Proactive: Day 15+ - [D] Proactive Window (SOFT)
Proactive: Only if no hard gate fired yet
FreePass: First post-expiry switcher open -> one-shot Free Pass
FullUpgrade: [C] Full Upgrade Window (HARD - lock-in)
FullUpgrade: Sets hasSeenFullUpgrade; once-ever
HardGate: [E] Hard-Gate Popover (HARD)
HardGate: Every blocked feature attempt after [C], forever
Day21: Day 21-34 - [F] Reminder Popover (SOFT)
Day35: Day 35-48 - [G] Final Window (SOFT)
Day35: Has "No thanks - don't ask again" opt-out
Steady: Day 49+ - steady state
Steady: [E] on every hard-gate attempt; degradable prefs fall back
Day1 --> Day4
Day4 --> Silent
Silent --> Day12
Day12 --> Badge
Badge --> Expiry
Expiry --> Proactive
Expiry --> FreePass
FreePass --> FullUpgrade
Proactive --> FullUpgrade
FullUpgrade --> HardGate
Expiry --> Day21
Day21 --> Day35
Day35 --> Steady
HardGate --> Steady
Steady --> [*]
FullUpgrade --> Purchased
Purchased: Purchase -> .pro
Purchased: Scheduler cancelled, all windows dismissed, prefs restored
Purchased --> [*]
```
The trial-nag system (`src/pro/scheduling/`) escalates upsell prompts across a Day1→Day35 timeline,
driven by a pure decision struct (`ProTransitionManagerTestable`) and a persisted scheduler. The
**soft nags** ([A] Welcome, [H] Day4 Tour, [B] Day12 Heads-Up, [D] Day15 Proactive, [F] Day21,
[G] Day35, plus the Days13-14 badge dot) are purely informational and never block functionality. The
**hard gate** arms at Day 15 expiry: degradable preferences are immediately downgraded to their free
equivalents, and the three hard-gated features (extra shortcut slot, search, lock-search) are blocked
at use-time through a free-pass ladder that grants one last full session, then shows [C] Full Upgrade
once, then [E] Hard-Gate popover forever. Only a purchase clears the gate (cancelling the scheduler,
dismissing windows, and restoring remembered Pro prefs).
---
## 4. The feature-gating mechanism (downgrade on lock / restore on unlock)
```mermaid
flowchart TD
state["LicenseManager.state"] --> locked{"isProLocked?<br/>LicenseManager.swift:67-72<br/>(true for proExpired/trialExpired)"}
locked -->|"becomes true (lock)"| engage["ProTransitionState.onProLockEngaged()<br/>ProTransitionState.swift:73-79"]
engage --> snap["For each of 6 ProGatedPreferences:<br/>snapshot Pro index -> remembered* key,<br/>overwrite stored value with freeEquivalent"]
snap --> stored1["UserDefaults stored = Free value<br/>(style=thumbnails, size=medium,<br/>shortcutStyle=doNothingOnRelease)"]
locked -->|"becomes false (unlock/purchase)"| unlock["ProTransitionState.onProUnlocked()<br/>ProTransitionState.swift:85-92"]
unlock --> restore["Restore remembered* Pro index<br/>-> stored value (notify:false),<br/>clear remembered key"]
restore --> stored2["UserDefaults stored = Pro value"]
stored1 --> read["PreferenceDefinition.read()<br/>PreferenceDefinition.swift:32-44"]
stored2 --> read
read --> freepass{"isFreePassSessionActive?"}
freepass -->|yes| pro["return remembered Pro value<br/>(one-session preview)"]
freepass -->|"no + locked + stored still Pro"| free["return freeEquivalent"]
freepass -->|no| asis["return stored value"]
hard["Hard-gated features<br/>extraShortcut, search, lockSearch"] --> attempt["ProFeature.attemptUse()<br/>ProFeature.swift:74-83"]
attempt --> avail{"isProAvailable?"}
avail -->|yes| allow["return true (run)"]
avail -->|"no (expired)"| ladder["ProTransitionManager.attemptHardGatedFeature<br/>free-pass -> [C] -> [E] ladder"]
pro --> getters["Preferences.effective* getters<br/>(appearanceStyle/Size, shortcutStyle)"]
free --> getters
asis --> getters
getters --> switcher["TilesView / ShortcutAction render"]
```
The master gate is `LicenseManager.isProLocked` (true once trial or Pro expires). On the
false→true transition, `onProLockEngaged()` snapshots each of the six gated preferences' Pro index
into a `remembered*` key and overwrites the stored value with its free equivalent; on unlock,
`onProUnlocked()` restores the remembered Pro value (with `notify:false` to avoid bouncing). The
hot-path getter `PreferenceDefinition.read()` returns the remembered Pro value during an active
free-pass session, the free equivalent while locked-and-still-Pro, or the stored value otherwise.
Hard-gated runtime features have no backing preference and are gated at use-time by
`ProFeature.attemptUse()`. Crucially, the registered defaults already contain the Pro values, so
forcing `isProLocked = false` auto-unlocks every degradable feature without any default changes.
---
## 5. The REMOVAL MAP — delete vs edit vs keep
```mermaid
flowchart TB
subgraph DELETE["DELETE WHOLESALE - the paywall tree"]
direction TB
d1["src/pro/license/*<br/>LicenseManager, RemoteLicenseClient,<br/>Keychain, MachineFingerprint, LicenseCookie,<br/>LicenseAPI, Clock"]
d2["src/pro/scheduling/*<br/>ProTransitionManager + Scheduler + State + Testable,<br/>all Day1..Day35 windows/popovers"]
d3["src/pro/ProFeature.swift<br/>ProFeatureCopy.swift, ProConversionCopy.swift"]
d4["src/pro/ui/*<br/>ProPromptHost, ProGradientButton,<br/>UsageStatHeroView (ProBadgeView/ProGradient<br/>only AFTER Settings consumers gone)"]
d5["src/preferences/PreferenceDefinition.swift<br/>(entire gating engine)"]
d6["Tests: LicenseManagerTests, ProTransitionTests,<br/>ProBadgeViewSegmentTests, UsageStatsMessageTests"]
end
subgraph EDIT["EDIT IN PLACE - sever wiring, keep file"]
direction TB
e1["src/App.swift<br/>remove license block 448-460, onAction/onAppLaunchComplete<br/>428-429, switcher hooks 78/328, custom-URL handler 465-490,<br/>Day1Welcome first-launch entanglement 189-233"]
e2["src/Menubar.swift<br/>remove Get Pro/My Account items 46-50,<br/>UpgradeMenuItemView 235-390, refreshLicenseMenuItems 84-118,<br/>badge dot 202-227, refreshState in menuWillOpen 396"]
e3["src/preferences/Preferences.swift<br/>rewrite gated getters 129/130/155 +<br/>override getters 313/319/330 to read raw values"]
e4["src/switcher/ShortcutAction.swift:56<br/>src/switcher/main-window/TilesView.swift:79,92<br/>remove attemptUse gates"]
e5["src/events/PreferencesEvents.swift:62-64<br/>remove isProLocked/isStoredValuePro bounce"]
e6["Settings tabs: AppearanceTab, ControlsTab,<br/>ShortcutEditor, ShortcutsWhenActiveSheet,<br/>LabelAndControl - strip proGatedIndices + ProBadge"]
e7["SettingsWindow.swift - remove UpgradeButton 133-214,<br/>showUpgradeView/hideUpgradeView, refreshUpgradeButton,<br/>windowDidBecomeKey license refresh; SidebarList setProBadge"]
e8["UpgradeTab.swift - DELETE tab; remove navigateToUpgradeTab callers"]
e9["PreferencesMigrations.swift:20<br/>remove markFreshInstallIfUnknown call"]
e10["DebugProfile.swift:17 - drop .debugProfileLabel line<br/>(keep file; feeds feedback)"]
e11["QAMenu.swift:170-241<br/>remove DEBUG Day-X triggers/resets"]
e12["alt-tab-macos-Bridging-Header.h + Info.plist<br/>NSPrincipalClass + alttab:// URL scheme<br/>(coordinate with AppCenter/identity decision)"]
e13["alt-tab-macos.xcodeproj/project.pbxproj<br/>remove ~102 in-Sources entries + fileRef/group<br/>for every deleted src/pro file (app + test UUIDs)"]
end
subgraph KEEP["KEEP UNTOUCHED - paywall-free"]
direction TB
k1["src/main.swift (entry point)"]
k2["src/macos/* (SystemPermissions,<br/>AXCallScheduler, LoginItem)"]
k3["BackgroundWork (threads/queues)"]
k4["src/switcher kernels (SelectionResolver,<br/>WindowFilterResolver, etc.) + Windows/Window"]
k5["src/events hooks (Keyboard, Accessibility,<br/>RunningApplications) minus the few gate lines"]
k6["CachedUserDefaults / index / macroPref plumbing"]
k7["Sparkle wiring (repoint feed/key separately)"]
end
DELETE -. "deleting these breaks compile until EDIT done" .-> EDIT
EDIT -. "must compile against" .-> KEEP
classDef del fill:#ffd6d6,stroke:#c0392b,color:#7b1f1f;
classDef edt fill:#fff3cd,stroke:#d4a017,color:#7a5c00;
classDef kp fill:#d6f5d6,stroke:#27ae60,color:#145a32;
class d1,d2,d3,d4,d5,d6 del;
class e1,e2,e3,e4,e5,e6,e7,e8,e9,e10,e11,e12,e13 edt;
class k1,k2,k3,k4,k5,k6,k7 kp;
```
This is the money diagram for the removal plan. **Red = delete wholesale**: the entire `src/pro/`
tree (license, scheduling, ProFeature, copy, UI primitives) plus `PreferenceDefinition.swift` and the
paywall test files. **Yellow = edit in place**: files that survive but must have their paywall wiring
severed — most are surgical (a handful of lines in `App.swift`, the switcher gates, the preference
getters), but `Menubar.swift`, `SettingsWindow.swift`, and the Settings tabs need real surgery to
strip the upsell UI, and `project.pbxproj` needs every deleted file's build-file/group/sources
entries removed or the project won't even parse. **Green = keep untouched**: the entry point,
`src/macos/`, `BackgroundWork`, the switcher kernels, the event hooks, and the `CachedUserDefaults`
plumbing are all verified paywall-free. The ordering constraint is critical: the deletes break the
build at compile time, so the edits (severing every consumer of `LicenseManager`, `LicenseState`,
`ProFeature`, `ProGatedPreferences`, `ProBadgeView`, and `ProGradientButton`) must land in the same
change set. The lowest-risk first step before any deletion is to force `isProLocked = false` /
`isProAvailable = true`, which turns every gate into a pass-through and lets defaults auto-unlock.
File diff suppressed because it is too large. Load diff
-300
View File
@@ -1,300 +0,0 @@
# 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).
---
## B. Removal Strategies & Recommendation
### Strategy 1 — "Surgical force-unlock" (smallest diff)
Make the gate always open and neutralize the nag scheduler, leaving the paywall code physically present but inert.
- `LicenseManager.isProLocked` → always `false`; `isProAvailable` → always `true` (audit 03/04: gate lives at `src/pro/license/LicenseManager.swift:67-72`). Hardcode `state = .pro`.
- `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.**
### Strategy 2 — "Full excision" (delete `src/pro/` and untangle) **← RECOMMENDED**
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):
5. Remove the trial-nag hooks: `:78` (`onSwitcherDismissed`), `:328` (`onSwitcherShown`), `:428` (`onAction = ProPromptHost.shared.dispatch`), `:429` (`onAppLaunchComplete`).
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).
### Phase 4 — Strip Settings-window license entanglement (the critic's biggest miss)
Edit `src/preferences/settings-window/SettingsWindow.swift` (completeness critic):
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`).
18. Delete `setupUpgradeButton` + constraints + `upgradeButtonClicked` (`:401, :503, :507-526`).
19. Delete `showUpgradeView`/`hideUpgradeView` (`:1066, :1122-1161`) which embed `UpgradeTab.initTab`.
20. Delete `refreshUpgradeButton` (`:1163-1165`) — was called from `App.swift:454` (removed in Phase 2) and `UpgradeTab:214`.
21. In `windowDidBecomeKey` (`:1243-1254`) remove `LicenseManager.refreshState` + `UpgradeTab.refreshStatus` + `playShineAnimation`; in `windowWillClose` remove `UpgradeTab.cleanup` (`:1266`).
22. Remove `refreshEmailTooltip` / `hasSecondLine` license reads (`:196, :206-207`).
23. Remove the settings-search "Pro" registration (`:679-680`, registers `ProBadgeView` → "Pro" string).
Edit `src/preferences/settings-window/SidebarList.swift` (critic):
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:
```
grep -rn -e ProBadgeView -e ProGradient -e LicenseManager -e LicenseState \
-e ProTransitionManager -e ProFeature -e UpgradeTab -e proLockStateDidChange \
--include=*.swift src | grep -v '^src/pro/'
```
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):
- `src/pro/license/` — `Clock.swift`, `Keychain.swift`, `LicenseAPI.swift`, `LicenseCookie.swift`, `LicenseManager.swift`, `LicenseManagerSpecs.md`, `LicenseManagerTests.swift`, `LicenseState.swift`, `MachineFingerprint.swift`, `RemoteLicenseClient.swift`
- `src/pro/scheduling/` — all `Day*.swift` (Day1/4/12/15×3/21/35), `ProTransitionManager.swift`, `ProTransitionManagerTestable.swift`, `ProTransitionScheduler.swift`, `ProTransitionState.swift`, `ProTransitionTests.swift`, `ProTransitionSpecs.md`
- `src/pro/ui/` — `ProBadgeView.swift`, `ProBadgeViewSegmentTests.swift`, `ProBadgeViewSegmentSpecs.md`, `ProGradientButton.swift`, `ProPromptHeader.swift`, `ProPromptHost.swift`, `ProPromptPopover.swift`, `ProPromptWindow.swift`, `UsageStatHeroView.swift`
- `src/pro/ProFeature.swift`, `src/pro/ProConversionCopy.swift`, `src/pro/ProFeatureCopy.swift`
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)
```
### Phase 8 — Final compile + behavior pass
44. `ai/build.sh` (Debug) green; `scripts/build_app.sh` (Release scheme) green; `scripts/run_tests.sh` green.
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.
- **Manual smoke (post-Phase 8):**
1. Fresh launch → Accessibility + Screen-Recording permission flow → switcher panels appear (phase-A → phase-B handoff, audit 01).
2. Alt-Tab show/hide/cycle; selection + hover; window focus.
3. All four previously-Pro features work: App-Icons/Titles appearance style, Auto size, in-switcher search, lock-search, and **>1 keyboard shortcut**.
4. Settings: no Upgrade tab, no Get-Pro menu item, no trial subtitle, no orange badge dot, no Pro badges on Appearance/Controls rows.
5. No `alttab://` URL handler registered (and removed from `Info.plist` URL scheme).
6. Network check (e.g. Little Snitch / `nettop`): **no** request to `alt-tab.app/api/v1/license` and **no** tier cookie; appcast points at *your* host.
7. Auto-update: a test appcast item signed with your EdDSA key updates successfully.
### Phased checklist
- [ ] Phase 0 — branch; baseline build green; (optional) land Strategy-1 force-unlock checkpoint.
- [ ] Phase 1 — gates open in place (`ProFeature`, `PreferenceDefinition.read`, shortcut cap); build green.
- [ ] Phase 2 — `App.swift` wiring severed (hooks, license block, custom-URL, selectors, first-launch); build green.
- [ ] Phase 3 — `Menubar.swift` upsell stripped (items, `UpgradeMenuItemView`, badge dot, `refreshState`); build green.
- [ ] Phase 4 — `SettingsWindow`/`SidebarList`/tabs/`PreferencesEvents` license + ProBadge plumbing removed; grep gate empty; build green.
- [ ] Phase 5 — `Preferences.swift` getters read raw values; `PreferencesMigrations:20` removed; (optional) restore migration; build green.
- [ ] Phase 6 — delete `src/pro/` + `PreferenceDefinition.swift` + `UpgradeTab.swift`; fix `Endpoints`, `DebugProfile`, `QAMenu`, AppCenter/principal class; build green.
- [ ] Phase 7 — `project.pbxproj` entries removed (incl. dual app+test UUIDs); tests/Mocks repaired; `scripts/run_tests.sh` green.
- [ ] Phase 8 — Release build green; manual smoke pass.
- [ ] Re-identity (D) — new bundle id, name, icon, Developer ID, Sparkle EdDSA keypair + repointed feed, removed license/AppCenter, repointed/removed feedback + website dispatch + FUNDING.
- [ ] Publish — signed, notarized, stapled `.app`/`.zip` via your GitHub Releases + your appcast (and/or Homebrew cask).
- [ ] Compliance (A) — `LICENCE.md` intact, attribution + "based on lwouis/alt-tab-macos, GPL-3.0" in README/About, changes noted, fork remains GPL-3.0.
---
### Appendix — reference: audit sections
| # | File | Topic |
|---|------|-------|
| 01 | `docs/audit/01-core-architecture.md` | App lifecycle, App.swift/Menubar.swift spine touchpoints |
| 02 | `docs/audit/02-switcher-ui.md` | Switcher gates (`ProFeature.attemptUse` call sites) |
| 03 | `docs/audit/03-preferences-and-gating.md` | `PreferenceDefinition`/`ProGatedPreferences`, `isProLocked` |
| 04 | `docs/audit/04-license-subsystem.md` | `LicenseManager`/`RemoteLicenseClient`/Keychain |
| 05 | `docs/audit/05-trial-nag-scheduling.md` | `ProTransitionManager` Day1→Day35 scheduler |
| 06 | `docs/audit/06-profeature-and-copy.md` | `ProFeature` cases + marketing copy |
| 07 | `docs/audit/07-pro-ui-and-menubar.md` | `src/pro/ui/` toolkit + Menubar integration |
| 08 | `docs/audit/08-build-release-distribution.md` | xcconfig, signing, Sparkle, CI |
| 09 | `docs/audit/09-telemetry-and-upstream.md` | AppCenter, network surfaces, upstream relationship |
-44
View File
@@ -1,44 +0,0 @@
# SESSION-STATE — CommandTabFree (de-paywalled AltTab fork)
_Updated 2026-06-16. `master` carries the fork; the **full CI/CD publishing pipeline + auto-update are LIVE** — signed + notarized **v100.2.0** released (Gatekeeper-accepted), Sparkle feed served on GitHub Pages. 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).
**Auto-update is LIVE (shipped in v100.2.0):** `DOMAIN = koftwentytwo.github.io/CommandTabFree`, `SUEnableAutomaticChecks = true`, the in-tree `appcast.xml` skeleton removed + gitignored. GitHub Pages serves `https://koftwentytwo.github.io/CommandTabFree/appcast.xml` (gh-pages branch, published by `update_appcast.sh`); the feed carries v100.1.0 + v100.2.0 enclosures. A kof22.com custom domain can be added later (GitHub redirects the github.io URL — no broken builds). **Homebrew cask** bumped to v100.2.0 + quarantine-strip postflight dropped (`KofTwentyTwo/homebrew-tap`). **Release-gate decoupled:** the sign/notarize/release apparatus is gated on a `will-release` signal (empty `VERSION.txt` ⇒ no release), so docs/ci pushes build+test without cutting a release or needing `[skip ci]` (ci_cd.yml).
**Remaining (owner-gated, NOT automatable):**
- **Upstream-sync**: provision `SYNC_BOT_TOKEN` + an external `repository_dispatch` cron; re-wire the `.fork-sync-state` cursor advance into the sync-merge flow (recording the UPSTREAM tag). `.fork-sync-state` = `v11.3.0`; the 3 sync labels exist.
- **(Optional)** kof22.com custom domain for the appcast (one DNS CNAME → `koftwentytwo.github.io`).
**Repo facts:** GitHub = `KofTwentyTwo/CommandTabFree` (local dir `~/Git.Local/kof22/alt-tab-free`, SSH remote). Latest release **v100.2.0** (signed + notarized + auto-updating). ⌘⇥ icon FINAL. Owner-gated checklist: `docs/TODO.md`.
## What this is
`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).
-23
View File
@@ -1,23 +0,0 @@
# TODO — CommandTabFree
_Updated 2026-06-16. The **publishing pipeline + auto-update are LIVE** — **v100.2.0** released (signed, notarized, Gatekeeper-accepted, auto-updating). The only substantial remaining work is upstream-sync automation. Full detail: `docs/CICD-STATUS.md`, `docs/EXECUTION-STATUS.md` §3._
## ✅ Done this session (2026-06-16)
- [x] **Signed + notarized releases** — Developer ID `James Maes (2X834TJ5MA)`; cert + creds in `production` secrets; `CODE_SIGN_IDENTITY`/Manual/`TEAM_ID` in `config/local.xcconfig`; vendored Sparkle helpers re-signed. Full tail proven (runs `27643432063` v100.1.0, `27647918163` v100.2.0).
- [x] **Auto-update** — `DOMAIN = koftwentytwo.github.io/CommandTabFree`, `SUEnableAutomaticChecks = true`, in-tree `appcast.xml` removed + gitignored. GitHub Pages serves the gh-pages feed; v100.2.0 ships auto-updating.
- [x] **Homebrew cask** — bumped to v100.2.0 + quarantine-strip postflight dropped (`KofTwentyTwo/homebrew-tap`).
- [x] **Release-gate decouple** — sign/notarize/release apparatus gated on a `will-release` signal (empty `VERSION.txt` ⇒ no release); non-release pushes build+test without cutting a release or needing `[skip ci]`.
- [x] genstrings gate fixed (`d1e1b296`); ad-hoc-until-cert build (`6070f0cb`); `.fork-sync-state` reset to `v11.3.0` + release tail no longer clobbers it; 3 sync labels created.
## Remaining — Upstream-sync automation (owner-gated)
- [ ] **`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).
- [ ] **Re-wire the `.fork-sync-state` 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).
## Optional
- [ ] kof22.com custom domain for the appcast (one DNS CNAME → `koftwentytwo.github.io`; GitHub redirects the github.io URL, so existing builds keep updating).
## Bootstrap acceptance tests (after upstream-sync is wired)
- [ ] Sync PR through the bot shows guard checks running.
- [ ] A deliberately-relocked branch goes RED on `guard.yml`.
- [x] Fork build N→N+1 auto-updates via Sparkle — infra live (feed served + v100.2.0 auto-update-enabled); end-to-end N→N+1 confirmed on the next release.
-12
View File
@@ -1,12 +0,0 @@
## Acknowledgments
These third-party libraries are used:
**ShortcutRecorder**
[Website](https://github.com/Kentzo/ShortcutRecorder) - [CC 4.0 License](https://github.com/Kentzo/ShortcutRecorder/blob/master/LICENSE.txt)
**Sparkle**
[Website](https://github.com/sparkle-project/Sparkle) - [License](https://github.com/sparkle-project/Sparkle/blob/master/LICENSE)
**AppCenter**
[Website](https://github.com/microsoft/appcenter-sdk-apple) - [Custom](https://github.com/microsoft/appcenter-sdk-apple/blob/develop/LICENSE)
-403
View File
@@ -1,403 +0,0 @@
# 01 — Core Application Architecture & Lifecycle
> Audit scope: the app entry point, `App.swift` (`NSApplicationDelegate`), and `src/macos/`
> (permissions, accessibility, login item, AX scheduling). This is the spine the rest of the
> paywall-removal audit hangs on. **Read-only audit — no source was modified.**
All `file:line` references are against the tree as audited (branch `master`, commit `9fadf36b`).
---
## 1. Entry point & process bootstrap
The app has **no `@main` / `@NSApplicationMain`**. It uses a top-level `main.swift`
(`src/main.swift`) as the executable entry, per the AGENTS.md "pure Swift, no Interface Builder"
constraint.
Boot order in `src/main.swift`:
1. `src/main.swift:4-6` — CLI fast-path. If launched with a recognized CLI command
(`CliClient.detectCommand()`), it forwards the command to the already-running instance via a
Mach message port and the process exits early (never becomes the GUI app).
2. `src/main.swift:11-15` — installs POSIX signal handlers for `SIGTERM` / `SIGTRAP` that call
`emergencyExit(...)`.
3. `src/main.swift:19-21` — installs `NSSetUncaughtExceptionHandler` (Obj-C exceptions) → also
`emergencyExit(...)`.
4. `src/main.swift:23` — `App.shared.run()` enters the AppKit run loop.
`emergencyExit` (`src/main.swift:33-39`) re-enables the native ⌘-Tab
(`setNativeCommandTabEnabled(true)`), prints a stack trace, drains in-flight screen captures
(`makeSureAllCapturesAreFinished()`, `src/main.swift:41-51`, which sets `App.isTerminating = true`
and spins up to 5 s waiting on `ActiveWindowCaptures.value()`), then `exit(0)`.
**Paywall touchpoint in entry path:** none. `main.swift` is clean.
---
## 2. The `App` class
`App` (`src/App.swift:7`) subclasses `AppCenterApplication` (a Microsoft AppCenter `NSApplication`
subclass, used for crash reporting) and sets itself as its own delegate
(`src/App.swift:41-44`, `delegate = self`). It conforms to `NSApplicationDelegate` in the extension
at `src/App.swift:434-507`.
It is a hybrid singleton: instance methods satisfy the `NSApplicationDelegate` protocol, but the
bulk of the app's coordination logic lives in `static` methods/properties on `App` (e.g.
`App.showUi`, `App.hideUi`, `App.refreshUi`, `App.continueAppLaunchAfterPermissionsAreGranted`).
`App.shared` (`src/App.swift:24`) downcasts `NSApplication.shared`.
Notable static state:
- `App.isTerminating` (`src/App.swift:28`) — read by the capture-drain loop.
- `App.activity` (`src/App.swift:9-10`) — a `ProcessInfo.beginActivity` token preventing App Nap.
- `App.sparkleDelegate` / `App.updaterController` (`src/App.swift:35-36`) — Sparkle auto-update.
- Selectors `supportProjectAction` / `upgradeToProAction` / `openAccountAction`
(`src/App.swift:25-27`) — wired into the menubar (see §7).
---
## 3. Startup sequence (the two-phase launch)
Launch is **deliberately two-phase**, gated on macOS permissions. This split is structurally
important for the removal plan because the paywall is initialized in **phase A** and the rest of the
app only boots in **phase B** after permissions pass.
### Phase A — `applicationDidFinishLaunching` (`src/App.swift:435-463`)
Runs immediately on launch, before any permission is confirmed:
1. `:436` `AppCenterCrash()` — crash reporting.
2. `:437` `disableRelaunchOnLogin()`.
3. `:438-439` `Logger.initialize()` + version log.
4. `:444` (release only) `MoveToApplicationsFolder.promptIfNeeded()`.
5. `:446` `AXUIElement.setGlobalTimeout()` — sets the global 1 s AX messaging timeout.
6. `:447` `Preferences.initialize()` — loads `UserDefaults`, runs migrations.
7. **`:448-460` License/Pro wiring (PAYWALL).** Wires three `LicenseManager.shared` callbacks and
calls `LicenseManager.shared.initialize()`. Detailed in §6.
8. `:461` `BackgroundWork.preStart()` — creates the *minimal* early queues needed for permission
polling (`permissionsCheckOnTimerQueue`, `permissionsSystemCallsQueue`, `screenshotsQueue`;
`src/util/BackgroundWork.swift:23-30`).
9. `:462` `SystemPermissions.ensurePermissionsAreGranted()` — starts the permission timer (see §5).
Phase A does **not** create the switcher panel, menubar, or any event taps. If permissions are
missing, the app sits in the permission-window loop and never proceeds to phase B.
### Phase B — `App.continueAppLaunchAfterPermissionsAreGranted` (`src/App.swift:383-431`)
Called **once**, from `SystemPermissions.checkPermissionsPreStartup()`
(`src/macos/SystemPermissions.swift:77`), the moment both Accessibility and Screen-Recording
permissions are confirmed. This is the real subsystem-wiring sequence:
| Line | Call | Subsystem |
|------|------|-----------|
| `:385` | `BackgroundWork.start()` | full set of background threads/queues (§4) |
| `:386` | `NSScreen.updatePreferred()` | display state |
| `:387` | `Appearance.update()` | switcher theming |
| `:388-389` | `TilesPanel.updateMaxPossible…Size()` | layout precompute |
| `:390` | `Menubar.initialize()` | menubar status item (**touches license**, §7) |
| `:391` | `MainMenu.create()` | app main menu |
| `:392-393` | `TilesPanel()` / `PreviewPanel()` | switcher windows |
| `:394-395` | `Spaces.refresh()` / `Screens.refresh()` | spaces & displays state |
| `:396-405` | `*.observe()` for Spaces, Screens, SystemAppearance, SystemScrollerStyle, InputSource, Cursor, Trackpad, Cli; plus `Applications.initialDiscovery()`; `KeyboardEvents.addEventHandlers()` | event observers / taps (§8) |
| `:406-413` | `SparkleDelegate()` + `SPUStandardUpdaterController`, updater started 30 s later | auto-update |
| `:414` | `PreferencesEvents.initialize()` | applies side-effects of every preference |
| `:415` | `BenchmarkRunner.startIfNeeded()` | dev benchmark |
| `:416-420` | first-launch Settings window (`showSettingsWindowOnFirstLaunchIfNeeded`) | UI (**license-aware**, §6) |
| `:421-426` | `#if DEBUG` QA menu / debug graph | dev-only |
| `:427` | `UsageStats.prune()` | usage telemetry |
| **`:428-429`** | **`ProTransitionManager.shared.onAction = …` + `onAppLaunchComplete()`** | **PAYWALL** (§6) |
`Applications.initialDiscovery()` (`src/App.swift:401`) is also where the NSWorkspace
running-applications observer gets installed: it calls `RunningApplicationsEvents.observe()`
internally (`src/switcher/state/Applications.swift:20`).
---
## 4. Background threading model (`BackgroundWork`)
`src/util/BackgroundWork.swift`. The app is heavily multithreaded because most macOS hooks
(CGEvent taps, AX observers, Mach ports) require their own run loops, and AX calls can block for the
1 s timeout.
- **Dedicated run-loop threads** (`BackgroundThreadWithRunLoop`, `:106-137`):
`accessibilityEventsThread`, `keyboardAndMouseAndTrackpadEventsThread`, `missionControlThread`,
`cliEventsThread`. Each runs a `CFRunLoopRun()` with a dummy source to stay alive
(`:131-136`). Created in `start()` (`:43-49`).
- **Operation queues** (`LabeledOperationQueue`, `:140-160`): `repeatingKeyQueue`,
`screenshotsQueue`, `accessibilityCommandsQueue`, `focusOrderQueue`, `crashReportsQueue`,
`permissionsCheckOnTimerQueue`, `permissionsSystemCallsQueue`.
- `preStart()` (`:23-30`) creates only the three queues needed before permissions; `start()`
(`:32-50`) creates the rest in phase B.
- Thread budget is policed: `addPotentialThreadCount` asserts the total stays ≤ 45 against the
~64-thread process limit (`:59-63`).
The separate `AXCallScheduler` (`src/macos/AXCallScheduler.swift`) owns three more bounded pools
for *outgoing* AX queries (`axQueryFirstTryQueue` 10, `axQueryScanQueue` 6, `axQueryRetryQueue` 8;
`:43-45`). It is a per-key serializing executor with retry/backoff for unresponsive apps
(`schedule(...)`, `:51-71`; `attemptBlock`, `:113-169`). It does **no** throttling/coalescing —
that is the caller's job. **No paywall coupling.**
**Paywall touchpoint in threading:** none. `BackgroundWork` and `AXCallScheduler` are clean.
---
## 5. How permissions are requested (`src/macos/SystemPermissions.swift`)
`SystemPermissions` drives a `DispatchSourceTimer` on `permissionsCheckOnTimerQueue`.
- `ensurePermissionsAreGranted()` (`:22-27`) creates the timer with handler
`checkPermissionsOnTimer` and fires it immediately.
- **Pre-startup** (`checkPermissionsPreStartup`, `:70-84`): if both
`AccessibilityPermission.status` and `ScreenRecordingPermission.status` are not-`.notGranted`,
it flips `preStartupPermissionsPassed = true`, closes the permission window, switches to the
infrequent timer, starts the distributed-notification revoke listener, and calls
**`App.continueAppLaunchAfterPermissionsAreGranted()`** (`:77`). Otherwise it shows the
permissions window (`:81`).
- **Post-startup** (`checkPermissionsPostStartup`, `:86-91`): if Accessibility is revoked while
running, it restarts the app (`App.restart()`).
- **Revoke detection** (`startListeningForDistributedRevoke`, `:29-43`): observes the undocumented
`com.apple.accessibility.api` distributed notification; on revoke → `App.restart()`. A sparse
60 s backstop timer covers cases where the notification doesn't fire (`setInfrequentTimer`,
`:96-103`).
- **Accessibility detection** (`AccessibilityPermission`, `:116-128`): `AXIsProcessTrustedWithOptions`
with `kAXTrustedCheckOptionPrompt: false`.
- **Screen-recording detection** (`ScreenRecordingPermission`, `:130-217`): via
`SCShareableContent.getExcludingDesktopWindows` (macOS 12.3+) or `CGDisplayStream` probing,
each wrapped in a 6 s timeout (`runWithTimeout`, `:202-217`). Honors
`Preferences.screenRecordingPermissionSkipped` (`:141`).
The permission UI window (`PermissionsWindow`) lives outside this scope
(`src/secondary-windows/permission-window/`).
**Paywall touchpoint in permissions:** none. `SystemPermissions.swift` is clean.
---
## 6. PAYWALL — every place the spine touches License / Pro
This section is the deliverable's core. Below is **every** reference in the spine (`App.swift`,
`Menubar.swift`, `main.swift`, `src/macos/`) to `LicenseManager`, `ProTransitionManager`,
`ProPromptHost`, checkout, and `UpgradeTab`.
### 6.1 `App.swift` — license wiring & callbacks
| `file:line` | Code | Purpose |
|-------------|------|---------|
| `src/App.swift:26` | `static var upgradeToProAction: Selector { #selector(App.upgradeToPro) }` | selector exposed to menubar "Get Pro" |
| `src/App.swift:27` | `static var openAccountAction: Selector { #selector(App.openAccount) }` | selector for menubar "My Account" |
| `src/App.swift:78` | `ProTransitionManager.shared.onSwitcherDismissed()` | inside `hideUi()` — ends free-pass session, queues post-dismiss upsell windows |
| `src/App.swift:115-117` | `@objc static func upgradeToPro() { ProTransitionManager.openCheckout() }` | opens checkout URL |
| `src/App.swift:119-121` | `@objc static func openAccount() { UpgradeTab.openAccountPage() }` | opens account page |
| `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/App.swift:460` | `LicenseManager.shared.initialize()` | computes initial license/trial state; **first thing that touches Keychain/UserDefaults trial bookkeeping** |
| `src/App.swift:480` | `UpgradeTab.showAutoActivating(licenseKey)` | custom-URL `alttab://activate` handler |
| `src/App.swift:481-489` | `LicenseManager.shared.activate(licenseKey) { … }` (+ `UpgradeTab.showAutoActivation{Success,Failed}`) | license activation via custom URL |
The `onStateChanged` closure (`src/App.swift:449-459`) does six things on every license-state
transition:
1. `Menubar.refreshLicenseMenuItems()` (`:450`)
2. `syncLicenseCookie(state:)` (`:451`) — defined in `src/pro/license/LicenseCookie.swift:5`,
writes a Sparkle HTTP cookie so the appcast can serve license-aware updates.
3. `ProTransitionManager.shared.onLicenseStateChanged()` (`:452`)
4. `UpgradeTab.refreshStatus()` (`:453`)
5. `SettingsWindow.shared?.refreshUpgradeButton()` (`:454`)
6. `App.resetPreferencesDependentComponents()` if `TilesPanel.shared != nil` (`:455`), then posts
`ProTransitionManager.proLockStateDidChangeNotification` (`:458`).
### 6.2 `App.swift` custom-URL scheme — license activation
`application(_:open:)` (`src/App.swift:465-471`) routes `alttab://` URLs to `handleCustomUrl`
(`:473-490`), which handles **only** `host == "activate"` with a `license_key` query item, calling
`LicenseManager.shared.activate(...)`. **The entire custom-URL handler exists solely for license
activation** — there is no other URL scheme use in the spine.
### 6.3 `Menubar.swift` — license-driven menu items & badge
`Menubar` is the other spine file with deep paywall coupling (it's created in phase B at
`src/App.swift:390`):
| `file:line` | Purpose |
|-------------|---------|
| `src/Menubar.swift:46-47` | builds the "Get Pro" item with a custom `UpgradeMenuItemView` |
| `src/Menubar.swift:48` | "My Account" item (`App.openAccountAction`) |
| `src/Menubar.swift:49` | "Support this project" item |
| `src/Menubar.swift:50` | calls `refreshLicenseMenuItems()` during build |
| `src/Menubar.swift:84-107` | `refreshLicenseMenuItems()` — switches visibility of Get-Pro/Account/Support items on `LicenseManager.shared.state` |
| `src/Menubar.swift:109-118` | `toggleUpgradeMenuItem()` — inserts/removes the Get-Pro item |
| `src/Menubar.swift:156-158` | comment referencing `LicenseManager.initialize()` ordering vs `statusItem` nil |
| `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 |
| `src/Menubar.swift:392-399` | `MenubarMenuDelegate.menuWillOpen` → `LicenseManager.shared.refreshState()` |
### 6.4 `src/macos/` — license touchpoints
**None.** `SystemPermissions.swift`, `BackgroundWork.swift`, `AXCallScheduler.swift`,
`LoginItem.swift` contain no License/Pro references (verified by grep). The `src/macos/` layer is
paywall-free.
### 6.5 The contract the spine depends on (for the removal plan)
`App.swift` depends on this public surface of the Pro module:
- `LicenseManager.shared` (singleton, `src/pro/license/LicenseManager.swift:7-15`), with:
`.state: LicenseState` (`:51`), `.initialize()` (`:91`), `.refreshState()` (`:99`),
`.activate(_:completion:)` (`:104`), `.onStateChanged` (`:38`), `.onBeforeProUnlock` (`:49`),
`.isProLocked` (`:67`), `.isProAvailable` (`:62`), `.daysSinceTrialStart` (`:79`),
`.customerEmail` (`:55`).
- `LicenseState` enum (`src/pro/license/LicenseState.swift:1-5`): `.trial(daysRemaining:)`,
`.pro`, `.proExpired`, `.trialExpired`.
- `ProTransitionManager.shared` (`src/pro/scheduling/ProTransitionManager.swift:105`), with the
lifecycle hooks `onSwitcherShown/Dismissed`, `onAppLaunchComplete`, `onLicenseStateChanged`,
`onProUnlocked`, `onAction`, `hasSeenWelcome`, `shouldShowBadgeDot`, the static
`openCheckout()` (`:307`) and `proLockStateDidChangeNotification` (`:110`).
- `ProPromptHost.shared.dispatch(_:)` (`src/pro/ui/ProPromptHost.swift:9-11`).
- `UpgradeTab` static methods (`src/preferences/settings-window/tabs/UpgradeTab.swift`).
- `syncLicenseCookie(state:)` (`src/pro/license/LicenseCookie.swift:5`).
The **runtime feature gate** itself is `ProFeature.attemptUse()`
(`src/pro/ProFeature.swift:74-83`): returns `true` if `isProAvailable`, else if a free-pass
session is active, else routes hard-gated features through
`ProTransitionManager.shared.attemptHardGatedFeature(...)`. Call sites for the gate live outside
this scope (TilesView, ShortcutAction, SearchModeResolver) — see §9.
---
## 7. App-menu / window plumbing (license-adjacent)
`App` exposes several `@objc` window/show helpers wired into the menubar and main menu:
`showSettingsWindow` (`:137`), `showAboutWindow` (`:151`), `showFeedbackPanel` (`:123`),
`showDebugWindow` (`:132`), `showPermissionsWindow` (`:235`), `checkForUpdatesNow` (`:103`),
`checkPermissions` (`:107`), `supportProject` (`:111`). Of these, only `supportProject`,
`upgradeToPro`, `openAccount` are paywall-adjacent.
First-launch Settings logic (`showSettingsWindowOnFirstLaunchIfNeeded`, `:189-233`) is
**entangled with the Day-1 welcome nag**: it checks `willShowDay1WelcomeOnAppLaunch()`
(`:205-208`, reads `LicenseManager.shared.state` and `ProTransitionManager.shared.hasSeenWelcome`)
and, if the welcome window will appear, defers showing Settings until a `Day1WelcomeLetterWindow`
`willCloseNotification` fires (`:210-220`).
---
## 8. How AltTab hooks into macOS (event taps, AX observers, NSWorkspace)
The hooks are installed in phase B and live mostly under `src/events/`. Summary of the kernel of
macOS integration the rest of the audit will reference:
- **CGEvent tap (keyboard)** — `KeyboardEvents.addCgEventTap()`
(`src/events/KeyboardEvents.swift:132-152`): a `.cghidEventTap` / `.headInsertEventTap` /
`.defaultTap` tap for `.flagsChanged` + `.keyDown`, added to
`BackgroundWork.keyboardAndMouseAndTrackpadEventsThread`'s run loop. If `tapCreate` returns nil
(permission lost) → `App.restart()` (`:150`). Also a local `NSEvent` monitor
(`:123-130`). The tap's `.keyDown` branch only absorbs Esc, and only when a shortcut binds it
and the switcher is active (issue #5585 workaround, `:33-44`).
- **Carbon hotkeys** — `RegisterEventHotKey` for the global switcher shortcuts
(`registerHotKeyIfNeeded`, `:104-120`; handlers installed in `addGlobalHandlerIfNeeded`,
`:154-173`). `toggleGlobalShortcuts` (`:64-75`) registers/unregisters them when an app exception
disables AltTab.
- **Accessibility (AX) observers** — `AccessibilityEvents.axObserverCallback`
(`src/events/AccessibilityEvents.swift:6-21`). Subscriptions bake `(pid, wid)` into the
`refcon` (`subscriptionRefcon`, `:25-28`). Focus/activation use an IPC-free fast lane
(`updateFocusOrderFastLane`, `:38-56`) on `BackgroundWork.focusOrderQueue`; everything else is
scheduled through `AXCallScheduler.shared` and dispatched to `handleEventApp` / `handleEventWindow`.
These feed the window inventory (`Windows`, `Applications`) and drive
`App.refreshOpenUiAfterExternalEvent` / `App.checkIfShortcutsShouldBeDisabled`.
- **NSWorkspace** — `RunningApplicationsEvents.observe()`
(`src/events/RunningApplicationsEvents.swift:6-10`): KVO on
`NSWorkspace.shared.runningApplications` (not the launch/terminate notifications, which miss
non-GUI apps), feeding `Applications.addRunningApplications` / `removeRunningApplications`.
- **Other observers** wired at `src/App.swift:396-405`: Spaces, Screens, system appearance,
scroller style, input source, cursor, trackpad, CLI (Mach message port).
- **Login item** — `src/macos/LoginItem.swift` writes/removes a launchd `LaunchAgents` plist
keyed by `App.bundleIdentifier`; reconciled from `Preferences.startAtLogin` via
`PreferencesEvents`.
None of the macOS hook installation is paywall-coupled. The only license interaction at runtime
is via `ProFeature.attemptUse()` invoked from feature call sites (§9), not from the tap/observer
plumbing itself.
---
## 9. Runtime feature gates reachable from the spine (out-of-scope files, noted for the plan)
The spine wires the managers; the actual *gating* happens when features run. For continuity with
later audit sections, the `ProFeature.attemptUse()` (`src/pro/ProFeature.swift:74`) call sites are:
- `src/switcher/main-window/TilesView.swift:79` (`lockSearchInSwitcher`), `:92` (`searchInSwitcher`)
- `src/switcher/ShortcutAction.swift:56` (`extraShortcut(index:)`)
- preference read interception: `src/preferences/PreferenceDefinition.swift:34-35`
(`isProLocked` + `isFreePassSessionActive` swap stored Pro values for Free)
And the broader (non-spine) paywall coupling surface — settings UI badges, upgrade tab, and the
preference setter that bounces to the Upgrade tab — is concentrated in `src/preferences/...`,
`src/Menubar.swift`, `src/events/PreferencesEvents.swift:62-63`, and `src/debug/QAMenu.swift`
(DEBUG-only). These are covered by later audit sections; they are listed here only so the spine's
fan-out is traceable.
---
## 10. Removal-plan notes (spine only)
**Goal:** every `ProFeature.attemptUse()` should behave as if `isProAvailable == true`, and all
trial-nag / upsell windows and license activation should be gone, without breaking the two-phase
launch.
1. **`App.continueAppLaunchAfterPermissionsAreGranted` must keep its non-Pro work.** Only
`src/App.swift:428-429` (the `onAction` binding + `onAppLaunchComplete`) is Pro. Removing those
two lines stops the trial-nag scheduler. Everything else in this method (queues, panels,
observers, Sparkle, first-launch Settings) must stay.
2. **`applicationDidFinishLaunching` license block (`src/App.swift:448-460`)** is the cleanest cut
point. If the Pro module is deleted wholesale, all 13 lines (`:448-459` callback wiring +
`:460` `initialize()`) go. **Risk:** `LicenseManager.shared.initialize()` is currently the
place that seeds `trialStartDate` (`src/pro/license/LicenseManager.swift:193-194`); removing it
is fine for a free build (nothing reads trial state once gating is unconditional), but any
leftover reader of `LicenseManager.shared.state` will crash if the singleton is deleted.
3. **`onStateChanged` fan-out (`src/App.swift:449-459`)** has one non-Pro side effect worth noting:
`App.resetPreferencesDependentComponents()` (`:455`) and the
`proLockStateDidChangeNotification` post (`:458`). Both become dead once Pro is removed, but
`resetPreferencesDependentComponents()` itself (`src/App.swift:51-53`) is a legitimate function
used elsewhere — keep the function, drop this caller.
4. **`hideUi` / `showUiOrCycleSelection`** each have exactly one Pro line
(`src/App.swift:78` and `:328`). Remove the `ProTransitionManager.shared.onSwitcher*` calls;
the surrounding session logic is independent.
5. **`upgradeToPro` / `openAccount` / `supportProject`** (`src/App.swift:111-121`) and their
selectors (`:25-27`) are only referenced by the menubar. Removing the corresponding
`Menubar` items (`src/Menubar.swift:46-50`, the entire `UpgradeMenuItemView`
`:235-390`, `refreshLicenseMenuItems` `:84-118`, badge dot `:202-227`, and the
`MenubarMenuDelegate.menuWillOpen` license refresh `:392-399`) lets these selectors be deleted.
`supportProject` (`Endpoints.supportUrl`) is a donation link, not a paywall — keep or drop per
the owner's call.
6. **Custom-URL handler (`src/App.swift:465-490`).** The entire `application(_:open:)` +
`handleCustomUrl` exists only for license activation. It can be removed entirely (and the
`alttab://` URL scheme dropped from `Info.plist` — out of this scope but flagged).
7. **First-launch welcome entanglement (`src/App.swift:189-233`).** `willShowDay1WelcomeOnAppLaunch`
(`:205-208`) and the deferral observer (`:210-220`) reference Pro. With the welcome nag gone,
simplify `showSettingsWindowOnFirstLaunchIfNeeded` to always call
`showAndCenterSettingsWindowOnFirstLaunch()` directly. **Risk:** if you delete
`Day1WelcomeLetterWindow` but leave the `willCloseNotification` observer, the observer never
fires and Settings never shows on first launch — remove the observer path together with the nag.
8. **Keychain / signing invariant (from AGENTS.md).** A free build that *keeps* `LicenseManager`
alive (e.g. to avoid touching many call sites) must preserve `App.bundleIdentifier`, TeamID, and
Developer ID, because `LicenseManager.keychainService` is `"\(App.bundleIdentifier).license"`
(`src/pro/license/LicenseManager.swift:4`). A clean free build that deletes the Pro module
entirely removes all Keychain usage and sidesteps this — preferred. Either way, do **not**
rotate bundle ID without the migration AGENTS.md describes.
9. **`src/macos/` is safe.** No file under `src/macos/` references the paywall; the permission
loop, AX scheduler, and login item need no changes. The phase-A→phase-B handoff
(`SystemPermissions.swift:77` → `App.continueAppLaunchAfterPermissionsAreGranted`) is the
critical path and is paywall-free.
---
### Appendix — files read for this section
`src/main.swift`, `src/App.swift`, `src/Menubar.swift`, `src/macos/SystemPermissions.swift`,
`src/macos/AXCallScheduler.swift`, `src/macos/LoginItem.swift`, `src/util/BackgroundWork.swift`,
`src/events/KeyboardEvents.swift`, `src/events/AccessibilityEvents.swift`,
`src/events/RunningApplicationsEvents.swift`, `src/switcher/SwitcherSession.swift`,
`src/pro/scheduling/ProTransitionManager.swift`, `src/pro/license/LicenseManager.swift`,
`src/pro/license/LicenseState.swift`, `src/pro/ProFeature.swift`,
`src/pro/ui/ProPromptHost.swift` (surface only), `src/pro/license/LicenseCookie.swift` (ref only).
-523
View File
@@ -1,523 +0,0 @@
# Audit 02 — Switcher UI (the product itself)
Scope: `src/switcher/` and `src/events/`. This section covers how windows are
enumerated and modeled, how the switcher panel is rendered/updated, how
cycling/selection/focus works, the keyboard/shortcut event flow, and how all of
this consults preferences. Pro-gated behavior that surfaces in this subsystem is
called out explicitly with `file:line` references and summarized at the end.
All paths are relative to the repo root `/Users/james.maes/Git.Local/kof22/alt-tab-free`.
---
## 1. Window enumeration and modeling
### 1.1 The data model
The product is built around two model layers:
- **Live AppKit-coupled objects** — `Window` (`src/switcher/state/Window.swift`)
and `Application` (`src/switcher/state/Application.swift`). These hold
`AXUIElement` handles, AX observers, thumbnails, icons, etc.
- **Pure value records** — `WindowState` (`src/switcher/state/WindowState.swift`)
and `ApplicationState`. These are `Equatable` structs containing only the
"facts" (`isMinimized`, `isFullscreen`, `spaceIds`, `title`, `lastFocusOrder`,
…) and are passed into the unit-testable decision "kernels"
(`WindowFilterResolver`, `WindowOrderResolver`, `SelectionResolver`,
`ExceptionMatcher`, `SearchModeResolver`).
`Window` is `@dynamicMemberLookup` over `WindowState`
(`src/switcher/state/Window.swift:3`, subscript at `:43-46`), so `window.title`,
`window.isMinimized`, etc. forward to `window.state`. This is purely a
convenience and has no paywall coupling.
Per-window fields of interest (`Window.swift:19-38`): `state`, `cgWindowId`,
`thumbnail: CALayerContents?`, `icon` (delegates to `application.icon`),
`shouldShowTheUser`, `tabbedSiblingWids`, `position`/`size`, `screenId`,
`axUiElement`, `application`, `axObserver`, `rowIndex`, search-result caches
(`swAppResults`, `swTitleResults`, `swBestSimilarity`, `lastSearchQuery`).
### 1.2 How windows are discovered
Discovery is **event-driven**, not poll-driven, and is rooted in `src/events/`:
1. **Accessibility (AX) notifications** drive everything. Each `Window` subscribes
to a fixed set of AX notifications on creation
(`Window.swift:5-12`: destroyed/title-changed/miniaturized/deminiaturized/
resized/moved) via `observeEvents()` (`Window.swift:134-150`). The callback is
`AccessibilityEvents.axObserverCallback`
(`src/events/AccessibilityEvents.swift:6-21`).
2. The callback decodes the owning `(pid, wid)` from a packed `refcon`
(`AccessibilityEvents.swift:25-33`) and routes through `AxEventRouting`
(`src/events/AxEventRouting.swift`) to either app-level (`handleEventApp`,
`AccessibilityEvents.swift:95-107`) or window-level (`handleEventWindow`,
`:141-175`) handling.
3. `handleEventWindow` reads AX attributes (title, subrole, role, size, position,
fullscreen, minimized, children for tab detection) and calls
`Windows.findOrCreate(...)` (`AccessibilityEvents.swift:151`).
4. `Windows.findOrCreate` (`src/switcher/state/Windows.swift:390-400`) either
updates an existing window (matched by `cgWindowId` via `byWindowId` or by
`isEqualRobust`) or, gated by `WindowDiscriminator.isActualWindow(...)`, creates
a new `Window` and calls `appendWindow`.
`WindowDiscriminator` (`src/switcher/state/WindowDiscriminator.swift`) is the
"is this a real, user-switchable window?" filter: it rejects `wid == 0`, missing
size, and windows smaller than 100x50 (`:8-19`), then accepts based on subrole
(`kAXStandardWindowSubrole`/`kAXDialogSubrole`) plus a large set of
app-specific special cases (Adobe, Steam, Firefox, VLC, JetBrains, Keynote,
Books, Crossover/Wine, scrcpy, AutoCAD, etc., `:20-44` and helpers). No paywall
logic here.
`appendWindow` (`Windows.swift:402-411`) appends to the canonical
`Windows.list`, registers it in `byWindowId`, assigns an initial
`lastFocusOrder`, and grows the recycled `TileView` pool if needed
(`TilesView.recycledViews`).
### 1.3 Spaces / screens / tab groups
- `Window.updateSpacesAndScreen` (`Window.swift:301-307`) populates `spaceIds`,
`spaceIndexes`, `isOnAllSpaces`, and `screenId` from CGS, with a workaround for
inactive tabs (`:309-320`).
- `recomputeIsInvisible()` (`Window.swift:124-126`) flags "ghost" windows
(no Space, not tabbed/minimized/hidden).
- Tab groups are tracked via `TabGroup` (`src/switcher/state/TabGroup.swift`) and
`tabbedSiblingWids`.
### 1.4 Window lifecycle / removal
`Windows.removeWindows` (`Windows.swift:413-483`) is the teardown path. Of note
(all memory-hygiene, no paywall): it releases pooled `TileView` IOSurfaces
(`:419-425`), clears `PreviewPanel` if it was showing a removed window
(`:427-431`), detaches the per-window AX observer runloop source via
`releaseAxObserver()` (`Window.swift:101-108`, `Windows.swift:473`), strips
`AXCallScheduler`/throttler entries, and re-shifts `lastFocusOrder`. It ends with
`App.refreshOpenUiAfterExternalEvent([], windowRemoved: true)`.
### 1.5 Per-show refresh
`Windows.updatesBeforeShowing()` (`Windows.swift:63-82`) runs each time the
switcher is summoned: refreshes Spaces, snapshots per-shortcut filter preferences
once (`WindowFilters.snapshot()`, `Windows.swift:507-519`), recomputes
`shouldShowTheUser` per window via `WindowFilterResolver.shouldShow(...)`
(`Windows.swift:105-125`), applies "only main windows" grouping
(`refreshWhichWindowsToShowTheUser`, `:89-103`, gated on
`Preferences.onlyShowMainWindows()`), and sorts. It returns `false` (and the show
aborts) if Mission Control is showing all/front windows.
---
## 2. The switcher panel: rendering and updating
### 2.1 Panel objects
- **`TilesPanel`** (`src/switcher/main-window/TilesPanel.swift`) — the main
`NSPanel`. `.nonactivatingPanel`, floating, `level = .popUpMenu`, joins all
Spaces, clear background, vibrant dark/light appearance
(`TilesPanel.swift:11-32`, appearance at `:34-37`). Its content view is
`TilesView.contentView`. Singleton `TilesPanel.shared`. Created in
`App.swift:392` (`_ = TilesPanel()`).
- **`PreviewPanel`** (`src/switcher/PreviewPanel.swift`) — a separate `NSPanel`
that shows a full-resolution preview of the *selected* window, ordered just
below `TilesPanel` (`PreviewPanel.swift:46-53`). Created in `App.swift:393`.
**This is the "preview selected window" feature** (see §6).
### 2.2 `TilesView` — the layout/render engine
`TilesView` (`src/switcher/main-window/TilesView.swift`) is a static class that
owns the scroll view, search field, "No Window" label, the recycled tile pool,
and the layout math. Key flow:
- `initialize()` (`:29-38`) sets up the search field, the no-window label, the
background effect view, and pre-allocates **20** `TileView` objects into
`recycledViews`.
- **Cell recycling**: `TileView`s are reused across shows. `recycledViews[i]` is
bound to `Windows.list[i]` during layout. Tiles past the visible/list count get
their image references released and `window_ = nil`
(`:514-519`, `:455`).
- `updateItemsAndLayout(_:)` (`:386-412`) is the entry point called by
`TilesPanel.updateContents` (`TilesPanel.swift:39-49`). It computes max width,
optionally resolves "auto" size, lays out tiles into rows
(`layoutTileViews`, `:471-529`), positions parent views
(`layoutParentViews`, `:549-601`), centers rows, and re-highlights the
selection.
- `TileView` (`src/switcher/main-window/TileView.swift`) renders a single cell:
thumbnail layer, app-icon layer, title label, status icons, dock label badge,
windowless-app indicator. The chosen content depends on the **appearance
style** (`thumbnails` / `appIcons` / `titles`) read via
`Preferences.effectiveAppearanceStyle(...)` (e.g. `TileView.swift:52, 64, 82`).
- Companion views: `TileOverView` (hover/window-controls overlay + hit-testing),
`TileUnderLayer` (highlight rectangle), `StatusIconsView`, `TileTitleView`,
`TileFontIconView`, `TilesPanelBackgroundView` (all under
`src/switcher/main-window/`).
### 2.3 Show / hide / refresh orchestration (in `src/App.swift`)
The switcher's lifecycle methods live in `App.swift`, not in `src/switcher/`, but
they are the orchestration glue:
- `App.showUiOrCycleSelection(shortcutIndex, forceDoNothingOnRelease)`
(`App.swift:307-351`) — creates the `SwitcherSession` if absent, records usage
(`UsageStats.recordTrigger`, see §6), and on the first summon (or a
different shortcut index) rebuilds: updates preferred screen, sorts by level on
the very first summon, hides the panel (`alpha = 0`) to mask the rebuild,
**notifies the trial/upsell scheduler** via
`ProTransitionManager.shared.onSwitcherShown()` (`App.swift:328`, see §6),
starts the search session, runs `updatesBeforeShowing`, picks the initial
selection, then displays after `Preferences.windowDisplayDelay`. If the same
shortcut is held, it cycles instead (`:347-350`).
- `App.buildUiAndShowPanel()` (`App.swift:353-369`) — updates Appearance, swaps
the background effect view, refreshes UI, calls `TilesPanel.shared.show()`,
previews the selected window, kicks off thumbnail capture.
- `App.refreshUi(preserveScrollPosition)` (`App.swift:293-305`) — the in-session
redraw: `Windows.updateSelectedWindow()` → `TilesPanel.updateContents(...)` →
VoiceOver → `WindowThumbnails.previewSelectedIfNeeded()` →
`Applications.refreshBadgesAsync()`. Heavily guarded by
`SwitcherSession.isActive` between each step (race-safety).
- `App.refreshOpenUiAfterExternalEvent(...)` (`App.swift:284-291`) — throttled
redraw triggered by AX events (window created/moved/focused/destroyed).
- `App.hideUi(keepPreview)` (`App.swift:63-79`) — tears down the session:
`SwitcherSession.current = nil`, `UsageStats.resetSession()`, end search,
toggle context-menu/cursor/trackpad event handling off, order the panel out,
and **notify the scheduler** via
`ProTransitionManager.shared.onSwitcherDismissed()` (`App.swift:78`, see §6).
### 2.4 `SwitcherSession` — per-invocation state
`SwitcherSession` (`src/switcher/SwitcherSession.swift`) holds everything scoped
to one show: `shortcutIndex`, `isFirstSummon`, `forceDoNothingOnRelease`,
`selectedIndex`, `hoveredIndex`, `selectedTarget` (window id for stable
re-selection), `searchQuery`. `SwitcherSession.current != nil` is the canonical
"panel is conceptually shown" flag (`isActive`, `:11`).
`SwitcherSession.activeShortcutIndex` (`:14`) is read by essentially every
per-shortcut effective-preference lookup in the UI.
---
## 3. Cycling, selection, hover, and focus
### 3.1 Selection state
Selection is stored on `SwitcherSession` (`selectedIndex`, `hoveredIndex`,
`selectedTarget`). The canonical list `Windows.list` is index-aligned with
`TilesView.recycledViews`.
### 3.2 Selection decisions (pure kernel)
`SelectionResolver` (`src/switcher/state/SelectionResolver.swift`) is a pure,
unit-tested kernel. `Windows` projects `list` into `[SelectionWindow]`
(`Windows.selectionSnapshot`, `Windows.swift:185-193`), builds `SelectionInputs`
(`makeSelectionInputs`, `:195-204`), and:
- On show: `setInitialSelectedAndHoveredWindowIndex()` (`Windows.swift:163-172`)
→ `SelectionResolver.initialPickIndex(...)`.
- On every refresh: `updateSelectedWindow()` (`Windows.swift:174-182`) →
`SelectionResolver.decide(...)` → `applySelectionDecision(...)` (`:206-223`).
`makeSelectionInputs` reads `Preferences.windowOrder[shortcutIndex]` to set
`useLastFocusedRule` (`Windows.swift:200-201`).
### 3.3 Cycling
- `App.cycleSelection(direction, allowWrap)` (`App.swift:248-256`) dispatches:
up/down → `TilesView.navigateUpOrDown(...)`; left/right →
`Windows.cycleSelectedWindowIndex(step, allowWrap)`.
- `Windows.cycleSelectedWindowIndex` (`Windows.swift:272-284`) and
`selectedWindowIndexAfterCycling` (`:286-297`) skip non-displayed windows and
handle wrap-around suppression on key-repeat.
- `TilesView.navigateUpOrDown` (`TilesView.swift:367-384`) and `nextRow`
(`:341-365`) handle row-based vertical navigation, respecting RTL layout.
- `updateSelectedAndHoveredWindowIndex(newIndex, fromMouse)`
(`Windows.swift:238-270`) is the single mutation point. It distinguishes hover
(mouse) from focus (keyboard or mouse-with-`mouseHoverEnabled`), updates the
highlight (`TilesView.highlight`), scrolls the focused tile into view, triggers
the window preview (`WindowThumbnails.previewSelectedIfNeeded()`), and notifies
VoiceOver. Mouse hover honors `Preferences.mouseHoverEnabled` (`:255`).
### 3.4 Highlight rendering
`TilesView.highlight(index)` (`TilesView.swift:324-339`) draws the per-tile
highlight and updates `TileUnderLayer` with the focused and (distinct) hovered
tile frames.
### 3.5 Focus / activation
When the user commits (key-up in focus-on-release, or the focus shortcut):
- `App.focusTarget()` (`App.swift:96-101`) → `App.focusSelectedWindow(...)`
(`:263-275`) → `Window.focus()` (`Window.swift:245-275`).
- `Window.focus()` handles three cases: AltTab's own windows, windowless apps
(launch/activate), and normal windows (private SkyLight
`_SLPSSetFrontProcessWithOptions` + `makeKeyWindow` byte-poking ported from
Hammerspoon, `Window.swift:277-288`, then `axUiElement.focusWindow()`).
- Optional cursor-follows-focus via `App.moveCursorToSelectedWindow`
(`App.swift:277-282`), gated on `Preferences.cursorFollowFocus`.
Window-management actions on the selected window are wired in `ShortcutActions`
(`src/switcher/ShortcutAction.swift:28-32`): `close()`, `minDemin()`,
`toggleFullscreen()`, `application.quit()`, `application.hideOrShow()` (all in
`Window.swift`/`Application.swift`). None of these are paywalled.
---
## 4. Keyboard / shortcut event flow
### 4.1 Registration
`KeyboardEvents` (`src/events/KeyboardEvents.swift`) installs:
- **Carbon global hotkeys** (`RegisterEventHotKey`) for the `nextWindowShortcut*`
and `holdShortcut*` slots (`:104-120`, IDs from
`KeyboardEventsTestable.globalShortcutsIds`,
`src/events/KeyboardEventsTestable.swift:4-9`).
- A **local NSEvent monitor** for keyDown/keyUp (`:123-130`).
- A **CGEvent tap** on `.cghidEventTap` for `.flagsChanged` and `.keyDown`
(`:132-152`). The `.keyDown` path only absorbs **Esc** (and only when a
shortcut binds Esc and the switcher is active) to beat the macOS 26 Game
Overlay hook on `⌘⎋` (`:33-44`, `anyShortcutUsesEscape` at `:20`).
Registration is driven from `ControlsTab`
(`src/preferences/.../ControlsTab.swift:781` calls
`KeyboardEvents.addGlobalShortcut`). `addEventHandlers()` is called once at
startup (`App.swift:402`).
### 4.2 Matching and dispatch
All paths funnel into `handleKeyboardEvent(...)`
(`src/events/KeyboardEventsTestable.swift:13-24`):
1. If the search field is being edited and the panel is key, the keyDown is first
offered to `TilesView.handleSearchEditingKeyDown` (`:14-19`).
2. `triggerMatchingShortcuts(...)` (`:49-66`) iterates `ControlsTab.shortcuts`,
and for each `ATShortcut` calls `matches(...)` then `shouldTrigger()`, and if
both pass, `executeAction(isARepeat)`.
`ATShortcut` (`src/switcher/ATShortcut.swift`) encapsulates one bound shortcut:
`matches(...)` (`:21-41`) does global-id and modifier/keycode matching;
`modifiersMatch(...)` (`:43-61`) handles the hold-modifier semantics and the
"base key without hold modifiers when panel open" case;
`shouldTrigger()` (`:63-80`) gates by scope (global/local), trigger phase
(down/up), session shortcut index, and — for the key-up path —
`Preferences.effectiveShortcutStyle(...) == .focusOnRelease`;
`redundantSafetyMeasures()` (`:90-110`) is a safety net for dropped/out-of-order
key-up events.
### 4.3 Actions
`ShortcutActions.execute(id)` (`src/switcher/ShortcutAction.swift:49-70`) maps an
id to behavior:
- A static table `ShortcutActions.all` (`:9-41`) maps named ids to closures
(`focusWindowShortcut`, `previousWindowShortcut`, arrow/vim cycling,
`cancelShortcut`, `closeWindowShortcut`, `minDeminWindowShortcut`,
`toggleFullscreenWindowShortcut`, `quitAppShortcut`, `hideShowAppShortcut`,
`searchShortcut`, `lockSearchShortcut`).
- Prefix-matched ids: `holdShortcut*` → `App.focusTarget()`;
`nextWindowShortcut*` → `App.showUiOrCycleSelection(index, false)`.
- **A Pro gate is applied at the top** for `holdShortcut`/`nextWindowShortcut`
slots with index ≥ 1 (`ShortcutAction.swift:50-58`, see §6).
### 4.4 Key repeat
`KeyRepeatTimer` (`src/switcher/KeyRepeatTimer.swift`) implements artificial
key-repeat for next/previous-window cycling when the bound shortcut has no
keycode (modifier-only). It reads the system `KeyRepeat`/`InitialKeyRepeat`
defaults (`:41-42`) and polls hardware modifier state to detect release
(`holdModifierIsReleased`, `:62-70`). No paywall coupling.
### 4.5 Other event monitors (`src/events/`)
- `CursorEvents` (`src/events/CursorEvents.swift`) — `.cgSessionEventTap` for
mouse down/up/moved; handles click-to-focus, click-outside-to-dismiss
(`:88`), middle-click-to-close (`:120-129`), and hover with a "deadzone" to
ignore tiny trackpad jitter (`:178-189`). Toggled on/off with the session.
- `ScrollwheelEvents` (`src/events/ScrollwheelEvents.swift`) — blocks continuous
(trackpad) scroll while letting discrete (mouse) scroll through.
- `TrackpadEvents` (`src/events/TrackpadEvents.swift`) — gesture-based cycling.
- `ContextMenuEvents` (`:ContextMenuEvents.swift`) — right-click menu on tiles.
- Plus environment monitors that trigger refreshes: `SpacesEvents`,
`ScreensEvents`, `DockEvents`, `SleepWakeEvents`, `InputSourceEvents`,
`SystemAppearanceEvents`, `SystemScrollerStyleEvents`,
`RunningApplicationsEvents`, `WindowCaptureEvents` (thumbnail capture),
`PreferencesEvents`, `UserDefaultsEvents`, `CliEvents`, `AxEventRouting`.
**`PreferencesEvents`** contains one paywall touchpoint
(`src/events/PreferencesEvents.swift:62`, see §6).
---
## 5. Search
Search is a first-class part of the switcher UI and is **Pro-gated** (see §6).
- `Search` (`src/switcher/Search.swift`) computes per-window match/relevance
using the `SearchTestable` fuzzy matcher, caching results on the `Window`
(`swBestSimilarity`, `lastSearchQuery`).
- `Windows.updateSearchQuery(...)` (`Windows.swift:14-35`) updates the session
query and the "select best match / restore default" flags, then re-sorts.
- `Windows.shouldDisplay(window)` (`Windows.swift:10-12`) is
`shouldShowTheUser && Search.matches(window, query)`. This single predicate is
consulted everywhere (layout, cycling, selection snapshot, viewport).
- `SearchModeResolver` (`src/switcher/state/SearchModeResolver.swift`) is the pure
search-mode state machine (`off`/`editing`/`locked`). Its `enableEditing` and
`lock` decisions take a `canSearch` / `canLockSearch` `Bool`; the caller
(`TilesView`) computes that bool from `ProFeature.*.attemptUse()` (`:68-86`).
- `TilesView` translates those decisions into AppKit side effects
(`enableSearchEditing`, `lockSearchMode`, `disableSearchMode`,
`TilesView.swift:59-128`).
- Sorting respects per-shortcut order/group preferences via `WindowOrderResolver`
(`src/switcher/state/WindowOrderResolver.swift`, `OrderSortType` =
`recentlyFocused`/`recentlyCreated`/`alphabetical`/`space`) and search
relevance (`Windows.sort`, `Windows.swift:322-343`).
---
## 6. Pro / paywall touchpoints surfacing in this subsystem
The switcher subsystem itself is mostly free of paywall logic — the gating lives
in `src/pro/` (`ProFeature`, `ProTransitionManager`, `LicenseManager`,
`ProGatedPreferences`) — but it is *invoked* from a small number of well-defined
sites in scope. The pattern is consistent: pure kernels never gate; the
imperative wrapper calls `ProFeature.<case>.attemptUse()` (or reads an
"effective" preference that silently downgrades when Pro is locked) at the real
use moment.
`ProFeature.attemptUse()` (`src/pro/ProFeature.swift:74-83`) returns `true` when
Pro/trial is available or a "free pass session" is active; otherwise it consults
the Day1→Day35 nag ladder for hard-gated features. For a fully-free build, every
one of these should resolve to "always allowed."
### Hard gates invoked from the switcher
1. **Extra shortcut slots (index ≥ 1)** — `ShortcutAction.swift:50-58`
(`ShortcutActions.execute`): pressing any `holdShortcut`/`nextWindowShortcut`
slot past the first calls `ProFeature.extraShortcut(index:).attemptUse()` and
`return`s (does nothing) if denied. Removing this gate makes all configured
shortcut variants always work.
2. **Search in the switcher** — `TilesView.swift:92`
(`enableSearchEditing`): `ProFeature.searchInSwitcher.attemptUse()` is passed
as `canSearch` to `SearchModeResolver.enableEditing`. If denied, the kernel
returns `.proGateBlocked(.search)` and editing never starts.
3. **Lock search results** — `TilesView.swift:79` (`lockSearchMode`):
`ProFeature.lockSearchInSwitcher.attemptUse()` passed as `canLockSearch` to
`SearchModeResolver.lock`.
### Degradable preferences read via "effective" getters
These do not call `attemptUse()` directly; instead the switcher reads
`Preferences.effective*` getters, and the *stored* value is silently downgraded
when Pro is locked (via `ProGatedPreferences`). The switcher consumes them at:
4. **Appearance style** (`appIcons`/`titles` are Pro;
`ProFeature.appIconsAndTitlesStyle`) — read via
`Preferences.effectiveAppearanceStyle(...)`. Surfaces in the switcher at, e.g.,
`TilesView.swift:388, 563, 614`, `TilesPanel.swift:101, 128`,
`TileView.swift:52, 64, 82`. The Pro-aware read path for index 0 is
`ProGatedPreferences.appearanceStyleOverride0.read()`
(`Preferences.swift:311-315`).
5. **Auto size** (`ProFeature.autoSize`) — read via
`Preferences.effectiveAppearanceSize(...)` at `TilesView.swift:388, 391`
(and `TilesPanel`/`Appearance`). Pro-aware read at
`Preferences.swift:317-321`.
6. **Search-on-release shortcut style** (`ProFeature.searchOnReleaseShortcut`) —
read via `Preferences.effectiveShortcutStyle(...)` at `ATShortcut.swift:70,
96`, `App.swift:329`, `ShortcutAction.swift:22`. Pro-aware read at
`Preferences.swift:328-332`. (`focusOnRelease` itself is free; the
`searchOnRelease` value is the Pro one.)
### Preview selected window
7. **`PreviewPanel`** (`src/switcher/PreviewPanel.swift`) and
`WindowThumbnails.previewSelectedIfNeeded()`
(`src/switcher/state/WindowThumbnails.swift:6-19`) implement the "preview the
selected window full-size" feature, driven by
`Preferences.effectivePreviewSelectedWindow(...)`
(`Preferences.swift:334-337`). **As written, this preference is NOT in
`ProFeature` / `ProGatedPreferences`** — `effectivePreviewSelectedWindow`
reads the stored bool directly with no Pro-aware override. So in the current
code preview-selected-window is effectively a free feature gated only by the
Screen-Recording permission. Worth confirming against `src/pro/` during
removal, but it does not appear to need changes for a free build.
### Trial/upsell scheduler hooks
8. The switcher show/hide lifecycle notifies the trial-nag scheduler:
`ProTransitionManager.shared.onSwitcherShown()` (`App.swift:328`) and
`onSwitcherDismissed()` (`App.swift:78`). These are the hooks the Day1→Day35
nag system uses to decide when to interrupt the user with upsell UI. For a free
build these calls should be removed (or made no-ops).
### Usage analytics
9. `UsageStats.recordTrigger(...)` (`App.swift:315`) and
`UsageStats.recordSearchIfFirst()` (`TilesView.swift:183`), reset via
`UsageStats.resetSession()` (`App.swift:67`). `UsageStats`
(`src/util/UsageStats.swift`) records trigger/search counts (including
`triggersExtraShortcuts`, `triggersAppIcons`, etc.) into a private
`UserDefaults` suite. This feeds the upsell messaging ("you used X N times").
Not strictly a gate, but part of the paywall apparatus; safe to strip or stub
for a free build.
> Note: `ProFeature.searchOnReleaseShortcut` is mapped to the "extra shortcuts"
> marketing copy and grouped under keyboard shortcuts
> (`ProFeature.swift:52`), which is why the trial copy lumps them together.
---
## 7. Preferences interaction summary
The switcher reads preferences in two modes:
- **Per-shortcut "effective" reads**, keyed on
`SwitcherSession.activeShortcutIndex`. These are the ones that can be Pro-gated:
`effectiveAppearanceStyle`, `effectiveAppearanceSize`, `effectiveShortcutStyle`,
`effectivePreviewSelectedWindow`, `effectiveAppearanceTheme`
(`Preferences.swift:311-337`). Window-filter preferences are snapshotted once per
show in `WindowFilters.snapshot()` (`Windows.swift:507-519`) for performance.
- **Plain reads** scattered throughout: `Preferences.onlyShowMainWindows()`,
`windowOrder`, `mouseHoverEnabled`, `cursorFollowFocus`, `windowDisplayDelay`,
`fadeOutAnimation`, `previewFadeInAnimation`, `captureWindowsInBackground`, the
`exceptions` list (consumed by `ExceptionMatcher`), etc.
`Appearance` (`src/switcher/Appearance.swift`) centralizes all derived layout
constants (paddings, font, theme, max-width-on-screen) and is recomputed via
`Appearance.update()` on each show.
---
## 8. Removal notes for a clean free build
To free everything in this subsystem without breaking the build, the cleanest
approach is to make the gates at the call sites always pass rather than ripping
`src/pro/` out from under the switcher first:
- **Make `ProFeature.attemptUse()` always return `true`.** This single change
neutralizes the three hard gates invoked from scope
(`ShortcutAction.swift:56`, `TilesView.swift:79, 92`) with no signature changes.
- **Make the `Preferences.effective*` getters read the raw stored value** (i.e.
bypass `ProGatedPreferences.*Override0.read()` downgrades at
`Preferences.swift:313, 319, 330`) so `appIcons`/`titles`/`auto`/`searchOnRelease`
are honored regardless of license state. Equivalently, make
`ProGatedPreferences.read()` return the stored Pro value unconditionally.
- **Stub `ProTransitionManager.shared.onSwitcherShown()` /
`onSwitcherDismissed()`** (`App.swift:78, 328`) to no-ops, or delete the calls,
to kill the trial-nag interrupts triggered by the switcher lifecycle.
- **`UsageStats` calls** (`App.swift:67, 315`; `TilesView.swift:183`) can be left
(harmless) or stubbed; they only feed upsell copy.
- **`PreviewPanel` / preview-selected-window needs no change** for the free build
per current code (not actually Pro-gated; see §6.7) — but verify against
`src/pro/` in the licensing-focused audit section.
Risks / things to verify in the dedicated `src/pro/` audit:
- `SearchModeResolver` and `SelectionResolver` are pure and have unit tests; do
**not** move gating into them — keep gates at the `TilesView`/`ShortcutAction`
call sites so the kernels' tests stay valid.
- Removing `ProTransitionManager`/`LicenseManager` types outright will break these
call sites at compile time; either keep thin stubs or update all call sites in
the same change. The keyboard/selection/render paths have no other Pro coupling,
so once the four/five sites above are neutralized the switcher is fully free.
- `ProFeatureCopy` / marketing strings are referenced only by `src/pro/` UI, not by
the switcher, so they are out of scope here.
-368
View File
@@ -1,368 +0,0 @@
# 03 — Preferences System & Feature-Gating Mechanism
Audit scope: the preferences system (`src/preferences/`) and the Pro feature-gating mechanism
that sits on top of it. This is the heart of how the paywall degrades and hides features. This
document is the authoritative reference for the removal plan; every gated preference is enumerated
with its exact key, Pro values, and free equivalent.
---
## 1. How the preferences system works (no-paywall baseline)
### 1.1 Storage and defaults
- `Preferences.defaultValues` (`src/preferences/Preferences.swift:6-78`) is the single dictionary of
every default, registered into `UserDefaults.standard` via `registerDefaults()`
(`Preferences.swift:179-181`). All values are stored as **strings** (e.g. `"true"`, `"100"`, or a
stringified enum index like `AppearanceStylePreference.thumbnails.indexAsString`).
- Macro (enum-backed) preferences store the **enum case index** as a string. The index is derived by
the `CaseIterable where Self: Equatable` extension `index` / `indexAsString`
(`src/macos/api-wrappers/HelperExtensions.swift:330-337`).
- Reads go through `CachedUserDefaults` (`Preferences.swift:432-523`), a thread-safe cache keyed by
preference name. `macroPref(key, allCases)` reads the stored int and maps it to `allCases[index]`,
resetting to the default if the value can't be parsed (`getThenConvertOrReset`, lines 503-518).
- Writes go through `Preferences.set` / `setShortcut` / `remove` (`Preferences.swift:191-228`). Each
write invalidates the cache and, unless `notify: false` is passed, fires
`PreferencesEvents.preferenceChanged(key)` (`Preferences.swift:216-218`). **This notify hook is the
single chokepoint the paywall hijacks to bounce setters to the Upgrade tab — see §3.2.**
### 1.2 Per-shortcut model and overrides
- Most behavioral prefs are per-shortcut, indexed by `indexToName(baseName, index)`
(`Preferences.swift:421-423`): index 0 has no suffix (`appearanceSizeOverride`), index 1 gets
suffix `2` (`appearanceSizeOverride2`), etc. (`nameToIndex` is the inverse, lines 425-429.)
- `maxShortcutCount = 9`, `minShortcutCount = 1` (`Preferences.swift:160-161`). `shortcutCount`
clamps the stored count into that range (lines 162-164).
- Appearance **overrides** let any shortcut deviate from the global appearance. The 5 override base
names are `appearanceStyleOverride`, `appearanceSizeOverride`, `appearanceThemeOverride`,
`shortcutStyleOverride`, `previewFocusedWindowOverride` (`Preferences.swift:258-261`), each mapped
back to its global key by `overrideToGlobalKey` (lines 264-270).
- `hasOverride(baseName, index)` (lines 275-277) consults `Preferences.all` (the cached
`persistentDomain` snapshot, lines 238-244), which **excludes registered defaults**. So an
untouched override reports `false` even though its key has a registered default. The override
defaults are deliberately set to the **free-tier value** (`Preferences.swift:67-75`) so that
`snapshotAndDowngrade` is a no-op for unset overrides.
- `effectiveAppearanceStyle/Size/Theme/ShortcutStyle/PreviewSelectedWindow(index)` (lines 311-337)
resolve the value a given shortcut actually uses: the override if set, else the global. **For
index 0, the three Pro-gated overrides route through `ProGatedPreferences.*Override0.read()`**
(lines 313, 319, 330) — meaning index-0 overrides are degraded exactly like the globals.
### 1.3 Migrations
`PreferencesMigrations` (`src/preferences/PreferencesMigrations.swift`) runs once per version bump on
launch via `Preferences.initialize()` (`Preferences.swift:168-172`). It is largely paywall-agnostic
historical data migration. **The only paywall coupling** is one line in `migratePreferences()`:
- `PreferencesMigrations.swift:20` — `ProTransitionState.markFreshInstallIfUnknown(existingVersion == nil)`.
This is the *only* place that can still observe a nil `preferencesVersion`, so it persists the
fresh-install signal the trial scheduler later reads. For a free build this single call must be
removed along with `ProTransitionState`.
No other migration references Pro/license/trial state.
---
## 2. The Pro-gating mechanism (the heart of the paywall)
The gating mechanism has two complementary halves:
1. **Degradable preferences** — stored values that get *silently downgraded* to a free equivalent
when Pro locks, snapshotted for later restore, and *intercepted at write-time* in Settings.
2. **Hard-gated runtime actions** — features with no stored preference, gated at use-time (extra
shortcuts, in-switcher search, lock-search).
### 2.1 `isProLocked` — the master gate
`LicenseManager.shared.isProLocked` (`src/pro/license/LicenseManager.swift:67-72`) is the single
boolean every gate consults:
```swift
var isProLocked: Bool {
switch state {
case .pro, .trial: return false
case .proExpired, .trialExpired: return true
}
}
```
So a user is "locked" once the 14-day trial (`trialDuration = 14`, line 17) expires or a Pro license
becomes invalid. `isProAvailable` is the inverse-ish (`pro` or `trial`).
For a fully-free build, the cleanest semantic equivalent is **`isProLocked == false` always** (and
`isProAvailable == true` always). Every gate below then becomes a no-op pass-through.
### 2.2 The `PreferenceDefinition` / `PreferenceGate` engine
`src/preferences/PreferenceDefinition.swift` is the single source of truth for degradable prefs.
- `PreferenceGate<T>` (lines 7-11) holds three things:
- `freeEquivalent: T` — the value returned/written when locked.
- `rememberedKey: String` — the `ProTransitionState` key under which the original Pro index is
snapshotted for restore + "ghost UI".
- `isProValue: (T) -> Bool` — predicate identifying which stored values count as "Pro".
- `PreferenceDefinition<T>` (lines 18-74) wraps `key`, `default`, and an optional `gate`, and exposes
the read/downgrade/restore operations:
- **`read()`** (lines 32-44): the hot-path getter. If there's no gate or `!isProLocked`, returns
the stored value verbatim. When **locked**, three branches:
1. If a **free-pass session** is active and a remembered Pro index exists → returns the
remembered Pro value (so the switcher renders the user's Pro choice "one last time").
2. Else if the stored value is still a Pro value (transient, before
`onProLockEngaged()` has downgraded it) → returns `freeEquivalent`.
3. Else → returns stored (already downgraded).
- **`snapshotAndDowngrade()`** (lines 51-57): if stored is a Pro value, overwrites it with
`freeEquivalent` via `Preferences.set` (which **fires the notify hook**), and returns the
original index for the caller to remember. No-op if no gate or already free.
- **`restore(from:)`** (lines 62-65): writes the remembered index back with `notify: false`.
- **`isStoredValuePro()`** (lines 69-73): true iff stored is currently a Pro value, regardless of
lock state — used by the notify hook (§3.2).
- `AnyProGatedPreference` (lines 79-85) is the type-erased view (Int/Bool returns only) that the
lock/unlock passes iterate over. `PreferenceDefinition.erased` (lines 87-96) produces it.
### 2.3 `ProGatedPreferences` — the registry of gated prefs
`ProGatedPreferences` (`PreferenceDefinition.swift:102-170`) declares **six** gated
`PreferenceDefinition`s (3 globals + 3 index-0 overrides). `ProGatedPreferences.all` (lines 157-164)
is the list iterated on lock/unlock. `forPreferenceKey(_:)` (lines 167-169) is the lookup the notify
hook uses. **This enum is the precise enumeration source for the table in §4.**
### 2.4 `ProFeature` — capability registry
`src/pro/ProFeature.swift` enumerates every gated capability (degradable + hard-gated):
- Cases (lines 9-16): `.appIconsAndTitlesStyle`, `.autoSize`, `.searchOnReleaseShortcut`
(degradable) and `.extraShortcut(index:)`, `.searchInSwitcher`, `.lockSearchInSwitcher`
(hard-gated).
- `gateKind` (lines 27-33): `.degradable`, `.hardGated`, or `.degradableAndHardGated`.
- `gatedPreference` (lines 37-44): maps the 3 degradable features to their
`ProGatedPreferences.*.erased` definition.
- `attemptUse()` (lines 74-83): the runtime gate. Returns `true` if `isProAvailable` or a free-pass
session is active; otherwise hard-gated features defer to
`ProTransitionManager.attemptHardGatedFeature`, and degradable features always return `true`
(they're gated at write-time, not use-time).
- `isStoredValuePro(preferenceKey:)` (lines 87-89): delegates to the registry; used by the notify hook.
### 2.5 Lock / unlock passes — downgrade & restore
`ProTransitionState` (`src/pro/scheduling/ProTransitionState.swift`) owns the snapshot/restore logic
and the `remembered*` indices (persisted in the `<bundleId>.license` UserDefaults suite under keys
prefixed `proTransition.`):
- **`onProLockEngaged()`** (lines 73-79): iterates `ProGatedPreferences.all`, calls
`snapshotAndDowngrade()` on each, and stores the returned Pro index under the pref's
`rememberedKey`. Triggered when the license locks via `ProTransitionManager.onLicenseStateChanged`
→ `onProLockEngaged` (`ProTransitionManager.swift:179-181, 259-262`), and also from the Day 15
Full-Upgrade / Proactive paths (lines 248-249, 256). Idempotent (re-entry no-ops because stored is
already free).
- **`onProUnlocked()`** (lines 85-92): iterates the registry, restores each remembered index via
`restoreFromIndex` (with `notify: false`), then clears the remembered key. Triggered on activation
via `LicenseManager.onBeforeProUnlock` (wired in `App.swift`) and `ProTransitionManager.onProUnlocked`.
- `rememberedAppearanceStyle/Size/ShortcutStyle` + the three `*Override` variants (lines 36-62) are
typed accessors over those `proTransition.remembered*` keys.
### 2.6 Free-pass session & ghost UI
- `ProTransitionManager.isFreePassSessionActive` (`ProTransitionManager.swift:135-142`) is a
session-only flag, true between a free-pass grant and switcher dismissal. While active,
`PreferenceDefinition.read()` returns the **remembered Pro value** (branch 1 above), so the
switcher shows the user's Pro selection one last time before `[C]` upsell fires. Flipping the flag
triggers `App.resetPreferencesDependentComponents()` so `TilesView` re-renders.
- The remembered indices also drive the "ghost outline" in Settings (the Pro selection is shown
badged/dimmed even though the live value is the free equivalent).
---
## 3. How gating reaches the UI
### 3.1 Settings controls — write-time interception + badges
`LabelAndControl.makeImageRadioButtons` (`src/preferences/settings-window/LabelAndControl.swift:74-116`)
takes a `proGatedIndices: Set<Int>` parameter. On click, **if `isProLocked` and the clicked index is
gated** (lines 86-92): it snaps the radio group back to the stored value and calls
`UpgradeTab.navigateToUpgradeTab()` instead of writing. This is the generic interception primitive.
`AppearanceTab` (`src/preferences/settings-window/tabs/appearance/AppearanceTab.swift`) wires the three
global gated controls:
- **Appearance Style** radios (lines 458-463): `proGatedIndices: proGatedAppearanceStyleIndices()`
which is "everything but `.thumbnails`" (lines 700-703 → indices `{1, 2}` = appIcons, titles).
`addProBadgesToStyleButtons` (lines 688-698) attaches a `ProBadgeView` to every non-thumbnails
card.
- **Appearance Size** segmented (lines 474-490): `wrapAppearanceSizeProLockIntercept` (lines 662-679)
intercepts the `.auto` segment (last index) when locked — snaps back to stored, calls
`navigateToUpgradeTab()`. `addProBadgeToAutoSegment` (lines 681-686) badges the Auto segment.
- **After-keys-released / shortcutStyle** segmented (lines 566-583):
`wrapShortcutStyleProLockIntercept` (lines 585-598) intercepts the `.searchOnRelease` segment.
`addProBadgeToShortcutStyleSegment` (lines 606-611) badges the Search segment.
- `refreshProLockUi()` (lines 705+) re-syncs these three controls when
`ProTransitionManager.proLockStateDidChangeNotification` fires (observer registered lines 414-419),
so the live Settings window updates on lock/unlock without a reopen.
The per-shortcut override panes mirror this exactly in `ShortcutEditor.swift`
(`src/preferences/settings-window/tabs/controls/ShortcutEditor.swift`):
- `ShortcutOverrideSegmented` / `ShortcutOverrideRadios` carry their own `proGatedIndices` (lines
540, 648). The override `AppearancePane` (lines 425-465) gates: style → `{appIcons, titles}` (line
429), size → `{.auto}` (line 437), theme → `[]` (line 449, **not gated**), shortcutStyle →
`{.searchOnRelease}` (line 459). Preview toggle is **not** gated.
- The write-time intercept lives in `handleClick` for both controls (lines 596-621 for segmented,
702-717 for radios): if a gated index is clicked while locked, snap back + `navigateToUpgradeTab()`.
### 3.2 The notify-hook bounce (write-side enforcement)
`PreferencesEvents.preferenceChanged(_:)` (`src/events/PreferencesEvents.swift:53-75`) contains the
critical line **62-64**:
```swift
if LicenseManager.shared.isProLocked && ProFeature.isStoredValuePro(preferenceKey: key) {
UpgradeTab.navigateToUpgradeTab()
}
```
Any write that lands a Pro value into a gated key while locked bounces the user to Upgrade. This is
why `onProUnlocked` restores with `notify: false` (otherwise restoring a Pro value during a
still-locked window would yank the user to Upgrade). For the free build, removing this branch is
mandatory.
### 3.3 Extra-shortcut sidebar gating (`ControlsTab`)
- `ControlsTab` badges every shortcut row at index ≥ 1 as Pro (`row.setProBadge(index >= 1)`,
`src/preferences/settings-window/tabs/controls/ControlsTab.swift:515`).
- `addShortcutSlot()` (lines 586-598): if `currentCount >= 1 && isProLocked`, it refuses to add the
slot and calls `navigateToUpgradeTab()` (lines 589-592). **So free users are hard-capped at exactly
1 shortcut.**
### 3.4 Runtime hard-gates (switcher)
- Extra shortcut trigger: `ShortcutAction.swift:56` — `if !ProFeature.extraShortcut(index: index).attemptUse() { return }`.
- In-switcher search: `TilesView.swift:92` gates editing on `ProFeature.searchInSwitcher.attemptUse()`.
- Lock-search: `TilesView.swift:79` gates on `ProFeature.lockSearchInSwitcher.attemptUse()`.
- `searchInSwitcher` / `lockSearchInSwitcher` map to copy "Search"; `searchOnReleaseShortcut` is the
Settings-side degradable twin of the same capability.
---
## 4. ENUMERATION — every Pro-gated preference (exact)
There are **6 degradable preference definitions** (3 globals + 3 index-0 overrides) plus runtime
hard-gates with no backing preference. Indices below are the `CaseIterable` order (and thus the
stored string value).
### 4.1 Degradable preferences (downgraded on lock, snapshot/restore)
Source: `ProGatedPreferences` (`PreferenceDefinition.swift:103-153`).
| # | Preference key | Type / enum | `isProValue` (Pro values) | Pro index value(s) | Free equivalent (value written on lock) | Free index | rememberedKey | Default |
|---|----------------|-------------|---------------------------|--------------------|------------------------------------------|-----------|---------------|---------|
| 1 | `appearanceStyle` | `AppearanceStylePreference` | `$0 != .thumbnails` | `.appIcons` (1), `.titles` (2) | `.thumbnails` | 0 | `rememberedAppearanceStyle` | `.thumbnails` |
| 2 | `appearanceSize` | `AppearanceSizePreference` | `$0 == .auto` | `.auto` (3) | `.medium` | 1 | `rememberedAppearanceSize` | `.auto` |
| 3 | `shortcutStyle` | `ShortcutStylePreference` | `$0 == .searchOnRelease` | `.searchOnRelease` (2) | `.doNothingOnRelease` | 1 | `rememberedShortcutStyle` | `.focusOnRelease` |
| 4 | `appearanceStyleOverride` (shortcut 0 only) | `AppearanceStylePreference` | `$0 != .thumbnails` | `.appIcons` (1), `.titles` (2) | `.thumbnails` | 0 | `rememberedAppearanceStyleOverride` | `.thumbnails` |
| 5 | `appearanceSizeOverride` (shortcut 0 only) | `AppearanceSizePreference` | `$0 == .auto` | `.auto` (3) | `.medium` | 1 | `rememberedAppearanceSizeOverride` | `.medium` |
| 6 | `shortcutStyleOverride` (shortcut 0 only) | `ShortcutStylePreference` | `$0 == .searchOnRelease` | `.searchOnRelease` (2) | `.doNothingOnRelease` | 1 | `rememberedShortcutStyleOverride` | `.doNothingOnRelease` |
Notes:
- Enum orders: `AppearanceStylePreference = [thumbnails(0), appIcons(1), titles(2)]`
(`MacroPreferences.swift:284-287`); `AppearanceSizePreference = [small(0), medium(1), large(2),
auto(3)]` (lines 308-312); `ShortcutStylePreference = [focusOnRelease(0), doNothingOnRelease(1),
searchOnRelease(2)]` (lines 100-103).
- **Note the asymmetry for `appearanceSize`/`shortcutStyle`**: the *registered default* is the Pro
value (`.auto` / `.focusOnRelease` for the global), but the *free equivalent written on lock* is
`.medium` / `.doNothingOnRelease`. (For `appearanceStyle` the default `.thumbnails` is already
free.) Override-key defaults are intentionally the **free** value so unset overrides never get
snapshotted (`Preferences.swift:67-75`, `PreferenceDefinition.swift:130-153`).
- Index-0 overrides (4-6) are the only override indices snapshotted, because indices ≥ 1 are
unreachable while locked (extra shortcuts are hard-gated). `Preferences.removeOverride` also clears
the matching remembered key for these three (`Preferences.swift:282-298`).
- The corresponding index-1..8 override keys (`appearanceStyleOverride2..9`, etc.) and the two
**non-gated** overrides (`appearanceThemeOverride`, `previewFocusedWindowOverride`) are NOT gated.
### 4.2 Hard-gated runtime features (no backing preference)
Source: `ProFeature` (`ProFeature.swift:13-16`), enforcement sites in §3.3–3.4.
| Feature | Gate kind | Enforcement | Free-tier effect when locked |
|---------|-----------|-------------|------------------------------|
| Extra shortcuts (`shortcutCount` > 1, slots index ≥ 1) | hardGated | `ControlsTab.addShortcutSlot` (ControlsTab.swift:589-592) + `ShortcutAction.swift:56` | Capped at 1 shortcut; extra-shortcut chord no-ops |
| In-switcher search (`searchInSwitcher`) | hardGated | `TilesView.swift:92` via `attemptUse()` | Typing-to-search disabled in switcher |
| Lock-search (`lockSearchInSwitcher`) | hardGated | `TilesView.swift:79` via `attemptUse()` | Lock-search disabled |
`searchOnReleaseShortcut` is `degradableAndHardGated` (`ProFeature.swift:30`): it is the
Settings-side degradable form (#3/#6 above) **and** its first post-expiration switcher summon can
trigger the `[C]` upsell.
---
## 5. What must change for a clean free build
The intent is: **all features unlocked, all paywall/trial/license/upsell removed**, without breaking
the build. The preferences subsystem must keep working; only the gating layered on top is removed.
### 5.1 Minimal, low-risk approach (recommended first pass)
Neuter the master gate so every check becomes a pass-through, leaving the preferences plumbing intact:
- Make `LicenseManager.shared.isProLocked` always return `false` and `isProAvailable` always `true`.
Every Settings intercept, the notify-hook bounce, the sidebar shortcut cap, and `attemptUse()`
immediately become no-ops, and `PreferenceDefinition.read()` returns stored values verbatim.
- This is the smallest change that unlocks everything. Downside: dead paywall code (badges,
`ProTransitionState`, Day-X UI, `UpgradeTab`) remains compiled in.
### 5.2 Full removal (the actual goal — requires care)
Touchpoints that must be removed/rewritten in this scope:
1. **`PreferenceDefinition.swift` (entire file)** — `PreferenceGate`, `PreferenceDefinition`,
`AnyProGatedPreference`, `ProGatedPreferences`. The getters in `Preferences.swift` that call
`ProGatedPreferences.*.read()` (lines 129, 130, 155) and the index-0 override branches in
`effectiveAppearanceStyle/Size/ShortcutStyle` (lines 313, 319, 330) must be rewritten to read
the raw value directly, e.g. `CachedUserDefaults.macroPref("appearanceStyle", AppearanceStylePreference.allCases)`.
2. **`PreferencesEvents.swift:62-64`** — delete the `isProLocked && isStoredValuePro` bounce block
(and the `ProFeature` import dependency it implies).
3. **`Preferences.swift`** — remove `overrideRememberedKey` (lines 291-298) and the
`ProTransitionState.setInt(...)` cleanup inside `removeOverride` (lines 284-286).
4. **`PreferencesMigrations.swift:20`** — remove the
`ProTransitionState.markFreshInstallIfUnknown(...)` call.
5. **`LabelAndControl.swift:74-116`** — drop the `proGatedIndices` parameter and the
`isProLocked && proGatedIndices.contains(index)` intercept (lines 86-92).
6. **`AppearanceTab.swift`** — remove `proGatedAppearanceStyleIndices`, all `ProBadgeView` attach
calls, both `wrap*ProLockIntercept` methods, `refreshProLockUi` and its
`proLockStateDidChangeNotification` observer, and the `autoSegment/shortcutStyleSegment` overlay
refs.
7. **`ShortcutEditor.swift`** — drop `proGatedIndices`/`attachBadge`/`refreshBadge` plumbing and the
locked-intercept branches in both `ShortcutOverrideSegmented.handleClick` (lines 599-607) and
`ShortcutOverrideRadios.handleClick` (lines 705-711).
8. **`ControlsTab.swift`** — remove `row.setProBadge(index >= 1)` (line 515) and the
`isProLocked` cap in `addShortcutSlot` (lines 589-592), plus the
`proLockStateDidChangeNotification` observer (line 141).
9. **Switcher hard-gates** (out of strict scope but coupled) — `ShortcutAction.swift:56`,
`TilesView.swift:79,92` call `ProFeature.*.attemptUse()`; these calls must be removed or replaced
with `true`.
10. The defaults in `Preferences.defaultValues` need no value change to unlock features (defaults
already include the Pro values, e.g. `appearanceSize = .auto`), but the **override defaults**
that were deliberately set to the free value (`appearanceSizeOverride = .medium`,
`shortcutStyleOverride = .doNothingOnRelease`, `Preferences.swift:72,74`) can stay as-is — they
only mattered for the snapshot no-op trick, which is being removed.
### 5.3 Risks / gotchas
- **`index`/`indexAsString`/`macroPref` cache plumbing is paywall-independent** and must be
preserved — it is core preferences infrastructure, not paywall code.
- **Already-locked users have downgraded stored values.** A user who ran the paywalled build past
trial expiry has `appearanceStyle`/`appearanceSize`/`shortcutStyle` already overwritten to free
equivalents in UserDefaults, with the original Pro index sitting in the `proTransition.remembered*`
keys (in the `<bundleId>.license` suite). After removal, `read()` no longer restores those — the
user keeps the free value and silently loses their old Pro selection. A free build with a **new
bundle ID** (per AGENTS.md, identity is coupled to the signature) gets a clean UserDefaults domain,
so this only affects in-place upgrades of the same bundle ID. Consider a one-time migration that
copies any `proTransition.remembered*` index back into its base key before deleting the
`proTransition.*` state — otherwise it's a silent (minor) data loss.
- **`screenRecordingDependentFeatures`** (`Preferences.swift:346-355`) iterates
`effectiveAppearanceStyle`/`effectivePreviewSelectedWindow` across all shortcuts; once those stop
routing through `read()`, behavior is unchanged but verify the permission callout still resolves
correctly when more than 1 shortcut is now reachable.
- Removing `ProGatedPreferences` will break `ProFeature.gatedPreference` and
`ProTransitionState.remembered*` accessors (compile errors) — these live in `src/pro/` and should
be deleted as a unit alongside the rest of the paywall (covered by the pro-subsystem audit).
- Test files reference these symbols: `PreferencesMigrationsTests.swift`,
`LicenseManagerTests.swift`, `ProTransitionTests.swift`, `OnActionExtensionTests.swift`, and the
`_test-support/Mocks.swift` `ShortcutStylePreference` mock — they will need updating/removal.
-282
View File
@@ -1,282 +0,0 @@
# Audit 04 — License / Activation Subsystem
> Scope: `src/pro/license/` — `LicenseManager.swift`, `LicenseState.swift`, `LicenseAPI.swift`, `RemoteLicenseClient.swift`, `LicenseCookie.swift`, `Keychain.swift`, `MachineFingerprint.swift`, `Clock.swift` (plus `LicenseManagerSpecs.md` & `LicenseManagerTests.swift`). Cross-references `src/api/Endpoints.swift`, `Info.plist`, `config/base.xcconfig`, and the app-side consumers.
>
> 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:
1. **State** (`.trial` / `.pro` / `.proExpired` / `.trialExpired`) — the runtime entitlement.
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)
.pro → .trialExpired (revalidation fails / license invalidated)
.pro → .proExpired (version-limited variant past its cutoff — dead code while the dict is empty)
```
---
## 2. The activation flow — `activate(_ licenseKey:)`
`LicenseManager.activate(_:completion:)` — `LicenseManager.swift:104-140`.
1. Calls `api.activate(licenseKey)` (network).
2. On `.success(response: ActivateResult)`, hops to the **main queue** and builds an all-or-nothing batch of Keychain writes:
- `licenseKey` → account `"licenseKey"` (`keychainKeyAccount`).
- `response.instanceId` → account `"instanceId"` (`keychainInstanceAccount`).
- `response.variantId` (if non-nil) → account `"variantId"` (`keychainVariantAccount`).
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."
4. On a fully successful write batch:
- `defaults["lastValidation"] = clock.now.timeIntervalSince1970` (`LicenseManager.swift:127`).
- `defaults["lastValidationResult"] = true` (`:128`).
- If `response.customerEmail` is non-nil/non-empty → `defaults[customerEmailKey] = email` (`:129-131`).
- Calls `onBeforeProUnlock()` (`:132`) **before** flipping state.
- Sets `state = .pro` (`:133`), which triggers `didSet → onStateChanged?(state)`.
- `completion(.success(()))`.
5. On `.failure(error)` from the API, state is untouched and the error is forwarded (`:135-137`).
The completion runs on the main thread in all branches (the whole result handler is wrapped in `DispatchQueue.main.async`).
App-side activation entrypoints:
- **URL scheme** `alt-tab://activate?license_key=...` → `App.handleCustomUrl` → `LicenseManager.shared.activate(...)` (`src/App.swift:473-485`).
- **Settings → Upgrade tab** manual key entry (`src/preferences/settings-window/tabs/UpgradeTab.swift:325`).
### Sibling network operations
- `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) |
|---|---|---|
| `Endpoints.domain` | Info.plist `Domain` ← xcconfig `DOMAIN` | `alt-tab.app` (`config/base.xcconfig:20`) |
| `Endpoints.apiDomain` | Info.plist `ApiDomain` ← xcconfig `API_DOMAIN` | `alt-tab.app/api` (`config/base.xcconfig:21`) |
| `Endpoints.website` | `"https://\(domain)"` | `https://alt-tab.app` |
| `Endpoints.licenseApiBaseUrl` | `"https://\(apiDomain)/v1/license"` | **`https://alt-tab.app/api/v1/license`** |
`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.)
`LicenseManager.shared` injects `RemoteLicenseClient(baseUrl: Endpoints.licenseApiBaseUrl, keychain: keychain)` (`LicenseManager.swift:12`).
### Endpoints, methods, request/response shapes
`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."
**POST `{baseUrl}/activate`** (`RemoteLicenseClient.swift:15-60`)
Request body:
```json
{
"license_key": "<key>",
"fingerprint": "<MachineFingerprint.get>",
"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).
Response DTO `ActivateResponse` (`:132-139`): `{ activated: Bool, instance_id: String?, variant_id: String?, customer_email: String?, error: String?, instances: [InstanceDTO]? }`.
Mapping (`:28-58`):
- `activated == true` but missing `instance_id` → `invalidResponse(debugInfo: "missing instance_id")`.
- `activated == true` → `ActivateResult(instanceId, variantId, customerEmail)`.
- `activated == false` + `error == "invalid_key"` → `LicenseAPIError.invalidKey`.
- `error == "seat_limit_exceeded"` → `LicenseAPIError.seatLimitExceeded(instances:)` (maps `instances` DTOs).
- any other non-nil `error` → `activationRejected(reason)`; nil error → `activationRejected("unknown")`.
**POST `{baseUrl}/validate`** (`RemoteLicenseClient.swift:62-78`)
Request body: `{ "license_key": "<key>", "instance_id": "<id>" }`.
Response DTO `ValidateResponse` (`:141-144`): `{ valid: Bool, variant_id: String? }` → `ValidateResult(valid, variantId)`.
**POST `{baseUrl}/deactivate`** (`RemoteLicenseClient.swift:80-101`)
Request body: `{ "license_key": "<key>", "instance_id": "<id>" }`.
Response DTO `DeactivateResponse` (`:146-149`): `{ deactivated: Bool, error: String? }`. `deactivated == false` → `LicenseAPIError.deactivationRejected`.
`InstanceDTO` (`:151-159`): `{ id: String, machineName: String?, lastSeenAt: Int }` → maps to the public `ActiveInstance` struct.
### `LicenseAPI` protocol & error model
`src/pro/license/LicenseAPI.swift`:
- `protocol LicenseAPI` (`:4-8`) — the three async methods. This is the seam tests mock (`MockLicenseAPI`, `LicenseManagerTests.swift:504-527`).
- Result structs: `ActivateResult` (`:10-14`), `ValidateResult` (`:16-19`), `ActiveInstance` (`:21-25`).
- `enum LicenseAPIError: LocalizedError` (`:27-58`) — `invalidKey`, `activationRejected(String)`, `seatLimitExceeded(instances:)`, `deactivationRejected`, `noData`, `invalidResponse(debugInfo:)`, `apiError(String)`, `keychainWriteFailed(account:status:)`. Provides localized `errorDescription` and an internal `debugInfo`.
### Decode-failure handling
`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`.
- `revalidationInterval = 30 * 24 * 60 * 60` (30 days) — `LicenseManager.swift:18` (private static).
- `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.
### Persistence map (Keychain + UserDefaults + cookie)
**Keychain** — `SystemKeychain` (`src/pro/license/Keychain.swift:10-89`), generic-password items (`kSecClassGenericPassword`), accessibility `kSecAttrAccessibleWhenUnlocked` on write (`Keychain.swift:45`). `setValue` deletes-then-adds (`:39-48`). All items share one service:
- **Service:** `LicenseManager.keychainService = "\(App.bundleIdentifier).license"` (`LicenseManager.swift:4`) → resolves to **`com.lwouis.alt-tab-macos.license`** (`App.bundleIdentifier` = `Bundle.main.bundleIdentifier!`, `src/App.swift:11`; bundle id `com.lwouis.alt-tab-macos`, `config/base.xcconfig:4`).
- Accounts under that service:
| Account constant | Account string | Written by |
|---|---|---|
| `keychainKeyAccount` | `"licenseKey"` | activate (`LicenseManager.swift:19`) |
| `keychainInstanceAccount` | `"instanceId"` | activate (`:20`) |
| `keychainVariantAccount` | `"variantId"` | activate / revalidate (`:21`) |
| (MachineFingerprint) | `"machineFingerprint"` | fingerprint fallback (`MachineFingerprint.swift:5`) |
- `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.
**UserDefaults** — suite `LicenseManager.defaultsSuiteName = "\(App.bundleIdentifier).license"` (`LicenseManager.swift:5`) → `com.lwouis.alt-tab-macos.license`. Non-secret bookkeeping keys:
| Key | Type | Set by | Read by |
|---|---|---|---|
| `"trialStartDate"` | Double (epoch) | `computeTrialState` first launch (`:194`) | `trialStartDate`, `daysSinceTrialStart`, trial math |
| `"lastValidation"` | Double (epoch) | activate / revalidate (`:127`,`:217`) | `scheduleAsyncRevalidationIfNeeded` |
| `"lastValidationResult"` | Bool | activate / revalidate (`:128`,`:218`) | `computeState` defensive check |
| `customerEmailKey` = `"customerEmail"` | String | activate (`:130`) | `customerEmail` getter |
**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.
### `state: LicenseState` — `private(set)` stored, `didSet → onStateChanged?(state)` (`LicenseManager.swift:51-53`)
The core read. External readers:
- `src/App.swift:206` — `if case .pro = ... { return false }` (gates some behavior).
- `src/Menubar.swift:86` — menubar Pro/trial display.
- `src/preferences/settings-window/SettingsWindow.swift:162,196,206` — account UI.
- `src/preferences/settings-window/tabs/UpgradeTab.swift:220` — upgrade tab rendering.
- `src/pro/scheduling/ProTransitionManager.swift:172,299` — trial-nag scheduling.
- `src/secondary-windows/DebugProfile.swift:17` — `.debugProfileLabel`.
### `isProLocked: Bool` — computed (`LicenseManager.swift:67-72`)
`true` for `.proExpired`/`.trialExpired`, `false` for `.pro`/`.trial`. **The most widely consumed gate.** Drives degradation of Pro prefs and ghosting of Pro UI:
- `src/pro/ProFeature.swift:65` (`var isLocked`).
- `src/events/PreferencesEvents.swift:62`.
- `src/preferences/PreferenceDefinition.swift:34`.
- `src/preferences/settings-window/LabelAndControl.swift:86`.
- `src/preferences/settings-window/tabs/appearance/AppearanceTab.swift:589,667`.
- `src/preferences/settings-window/tabs/controls/ControlsTab.swift:589`.
- `src/preferences/settings-window/tabs/controls/ShortcutEditor.swift:599,705`.
- `src/pro/scheduling/ProTransitionManager.swift:179`.
### `isProAvailable: Bool` — computed, delegates to `state.isProAvailable` (`LicenseManager.swift:62`)
- `src/pro/ProFeature.swift:63,75`.
### `customerEmail: String?` — computed from `defaults[customerEmailKey]` (`LicenseManager.swift:55`)
- `src/preferences/settings-window/SettingsWindow.swift:167,196,207`.
- `src/preferences/settings-window/tabs/UpgradeTab.swift:227`.
### `isLifetimeVariant: Bool` — computed from Keychain `variantId` ∈ `lifetimeVariants` (`LicenseManager.swift:57-60`)
- `src/preferences/settings-window/SettingsWindow.swift:164`.
- `src/preferences/settings-window/tabs/UpgradeTab.swift:228`.
### `onStateChanged: ((LicenseState) -> Void)?` (`LicenseManager.swift:38`)
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`.
### `onBeforeProUnlock: () -> Void` (`LicenseManager.swift:49`, default no-op)
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`.
### `refreshState()` (`LicenseManager.swift:99-102`)
Recomputes `state` and reassigns only if changed (so the baked-in trial day count tracks the clock). Called before UI surfaces read the day count:
- `src/Menubar.swift:396`.
- `src/preferences/settings-window/SettingsWindow.swift:1246`.
### `activate(_:completion:)` (`LicenseManager.swift:104-140`) — see §2
- `src/App.swift:481`, `src/preferences/settings-window/tabs/UpgradeTab.swift:325`.
### Other public members consumed externally
- `deactivate(completion:)` — `UpgradeTab.swift:405`.
- `deactivateInstance(licenseKey:instanceId:completion:)` — `UpgradeTab.swift:389`.
- `revalidateWithServer()` — `QAMenu.swift:221` (DEBUG).
- `daysSinceTrialStart: Int` (`:79-82`) — `ProTransitionManager.swift:300`.
- `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.
-244
View File
@@ -1,244 +0,0 @@
# 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
`LicenseManager.daysSinceTrialStart`, `LicenseManager.swift:79-82`:
`Int(clock.now.timeIntervalSince(start) / 86400)`).
- `trialStartDate` is stored in the license UserDefaults suite (`LicenseManager.swift:74-77`).
- Time-of-day windows: prompts only fire between **10:00–11:30** or **15:30–17:00**
(`ProTransitionManagerTestable.isInTimeWindow`, `ProTransitionManagerTestable.swift:148-152`:
600–690 min or 930–1020 min). The window check is snapshotted in
`ProTransitionState.snapshot(...)` (`ProTransitionState.swift:97-118`).
---
## 2. What triggers the system (entry points)
There are exactly **three** kinds of trigger, all routed through `ProTransitionManager`:
### A. App launch / timed scheduler fire
- `App.swift:429` calls `ProTransitionManager.shared.onAppLaunchComplete()` at end of launch
(after `onAction` is wired at `App.swift:428`).
- `ProTransitionManager.onAppLaunchComplete()` (`ProTransitionManager.swift:167-169`) delegates to
`scheduler.onAppLaunchComplete()` (`ProTransitionScheduler.swift:24-30`): if a persisted
`nextScheduledDate` is in the past, fire immediately; then `scheduleNext()`.
- The scheduler arms a single `DispatchWorkItem` (`ProTransitionScheduler.swift:40-55`) that calls
`onFire` → `evaluateAndShow()` (`ProTransitionManager.swift:121, 271-293`) → consults
`evaluateTimedAction(...)` and emits the matching prompt action, then re-arms `scheduleNext()`.
### B. Switcher shown / dismissed
- `App.showUiOrCycleSelection()` calls `ProTransitionManager.shared.onSwitcherShown()` at the start
of a **fresh** summon, not on a cycle (`App.swift:328`, inside the `isFirstSummon` branch).
- `App.hideUi()` calls `ProTransitionManager.shared.onSwitcherDismissed()` (`App.swift:78`).
- `onSwitcherShown()` (`ProTransitionManager.swift:205-223`) consults `evaluateSwitcherOpen(...)`
and queues a `pendingDismissAction` (Day 4 tour, or post-expiration free-pass + [C]).
- `onSwitcherDismissed()` (`ProTransitionManager.swift:187-201`) ends any active free-pass session
and, 1 second later (so the focused window is back in front), runs the queued action.
### C. Hard-gated feature attempt (use-time)
- `ProFeature.attemptUse()` (`ProFeature.swift:74-83`) is the gate. For the hard-gated features
(`.extraShortcut`, `.searchInSwitcher`, `.lockSearchInSwitcher`) it calls
`ProTransitionManager.shared.attemptHardGatedFeature(self)` (`ProFeature.swift:79`).
- `attemptHardGatedFeature(_:)` (`ProTransitionManager.swift:226-242`) consults `evaluateHardGate`
and returns whether the action may proceed; side effects are the free-pass / [C] / [E] ladder.
---
## 3. Public hooks called from App.swift (the integration surface)
These are the exact call sites a clean removal must sever (all in `src/App.swift` unless noted):
| Call site | Hook | Purpose |
|---|---|---|
| `App.swift:78` | `onSwitcherDismissed()` | Ends free-pass session, fires deferred prompt. |
| `App.swift:116` | `ProTransitionManager.openCheckout()` | Opens LemonSqueezy checkout URL. |
| `App.swift:207` | `hasSeenWelcome` (read) | Gates first-launch behavior (`!hasSeenWelcome`). |
| `App.swift:328` | `onSwitcherShown()` | Queues Day 4 tour / post-expiry free-pass. |
| `App.swift:428` | `onAction = { ProPromptHost.shared.dispatch($0) }` | Wires the emitter to the UI host. |
| `App.swift:429` | `onAppLaunchComplete()` | Kicks off the scheduler. |
| `App.swift:448` | `onProUnlocked()` (via `LicenseManager.onBeforeProUnlock`) | Restores remembered Pro prefs on purchase. |
| `App.swift:452` | `onLicenseStateChanged()` | Cancels scheduler + dismisses windows on Pro; engages lock on expiry. |
| `App.swift:458` | `proLockStateDidChangeNotification` (post) | Refreshes Settings ghost UI. |
| `Menubar.swift:205` | `shouldShowBadgeDot` (read) | Days 13–14 menubar badge dot. |
| `PreferenceDefinition.swift:35` | `isFreePassSessionActive` (read) | Swaps stored→remembered Pro value during free-pass. |
| `ProFeature.swift:76,79` | `isFreePassSessionActive`, `attemptHardGatedFeature(_:)` | Hard-gate ladder. |
| `QAMenu.swift:179-234` | many | DEBUG-only manual triggers / resets. |
| `AppearanceTab.swift:416`, `ControlsTab.swift:141` | `proLockStateDidChangeNotification` (observe) | Live Settings ghost-UI refresh. |
---
## 4. The full trial timeline (day-by-day)
All prompt selection lives in `ProTransitionManagerTestable.evaluateTimedAction(_:)`
(`ProTransitionManagerTestable.swift:63-98`), `evaluateSwitcherOpen(_:)` (lines 114-127), and
`evaluateHardGate(_:)` (lines 101-106). Letters [A]–[H] match the spec / `ProPromptAction` enum.
| Day (1-idx) | `daysSinceTrialStart` | Prompt | Trigger | What fires | Gate flag | Soft / Hard | Retry / miss behavior |
|---|---|---|---|---|---|---|---|
| **Day 1** | 0 | **[A] Welcome Letter** `Day1WelcomeLetterWindow` | Timed: launch / scheduler. `computeNextFireDate` returns `Date()` immediately while `!hasSeenWelcome` (`ProTransitionScheduler.swift:64-66`). `evaluateTimedAction` returns `.showWelcome` and blocks all other actions until seen (`Testable:67-69`). | NSWindow 560×520, Free-vs-Pro comparison table, "Start my 14-day trial" button. Copy varies fresh-install vs upgrade via `ProTransitionState.isFreshInstall` (`Day1WelcomeLetterWindow.swift:14-26`). | `hasSeenWelcome` | Soft | Always shows. |
| **Day 4** | 3 | **[H] Day 4 Tour** `Day4TourPopover` | **Switcher open** only. `evaluateSwitcherOpen` returns `.showDay4Tour` iff `isTrialActive && daysSinceTrialStart==3 && !hasSeenDay4Tour` (`Testable:116-119`). Deferred to 1s post-dismiss (`Manager:208-210, 193-199`). | NSPopover under menubar icon, 280×110, lists App Icons/Titles, extra shortcuts, Search; "Try them in Settings". | `hasSeenDay4Tour` | Soft | No retry — only fires if switcher is opened on Day 4 exactly. |
| Days 5–11 | 4–10 | — | — | Silent trial. | — | — | — |
| **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:
1. **Degradable prefs downgrade immediately** (`onProLockEngaged`, `ProTransitionState.swift:73-79`).
On the `isProLocked` transition, every entry in `ProGatedPreferences.all` is snapshotted and
downgraded: appearance style → `.thumbnails`, size → `.medium`, shortcut style →
`.doNothingOnRelease`. The original Pro index is saved to `remembered*` (drives Settings "ghost"
UI + the one-last-session free-pass read). `PreferenceDefinition.read()` (`PreferenceDefinition.swift:32-44`)
returns the Free equivalent once locked — unless a free-pass session is active, in which case it
returns the remembered Pro value (`PreferenceDefinition.swift:35-39`).
2. **Hard-gated features are blocked at use-time** via the **free-pass ladder**
(`evaluateHardGate`, `Testable:101-106`):
- `isPro || isTrialActive` → `.allow` (run normally).
- `!freePassUsed` → `.freePass`: allow the action **once**, mark `freePassUsed=true`, open a
free-pass session, and queue [C] for after dismissal (`Manager:230-234`).
- `!hasSeenFullUpgrade` → `.showFullUpgrade`: block the feature (return `false`), show [C]
inline (`Manager:235-237`).
- else → `.showHardGatePopover`: block the feature, show [E] (`Manager:238-240`). Fires forever.
Hard-gated features (`ProFeature.attemptUse`, `ProFeature.swift:77-82`): `.extraShortcut`
(shortcut slot ≥ 2), `.searchInSwitcher`, `.lockSearchInSwitcher`. The degradable-only set
(`.appIconsAndTitlesStyle`, `.autoSize`, `.searchOnReleaseShortcut`) always returns `true` here
because it is gated at preference-write time, not use-time.
**The defining difference:** soft nags return control to the user immediately. After [C] has fired,
the hard gate **permanently blocks** the three hard-gated features (returns `false`, action does
not execute) and shows [E] each time — even after opt-out, even past Day 49. Only purchase clears it.
---
## 6. Free-pass sessions
A free-pass session is a **session-only** window (`isFreePassSessionActive`,
`ProTransitionManager.swift:135-142`) during which the user gets their Pro experience back for one
last switcher summon. It is opened in two places:
- `onSwitcherShown()` → `.triggerFreePass` (first post-expiry summon): sets `freePassUsed=true`,
`hasTriggeredPostExpirationSwitcher=true`, `isFreePassSessionActive=true`, builds a
`HardGateReason.proPreferences(...)` from `remembered*`, and queues [C] (`Manager:211-219`).
- `attemptHardGatedFeature()` → `.freePass`: sets `freePassUsed=true`, `isFreePassSessionActive=true`,
queues [C] for the attempted feature (`Manager:230-234`).
While active:
- `PreferenceDefinition.read()` returns the **remembered Pro value** instead of the downgraded
stored value (`PreferenceDefinition.swift:35-39`), so the switcher renders Pro for that one session.
- `ProFeature.attemptUse()` returns `true` for *all* features without re-consuming the pass
(`ProFeature.swift:76`), so search/lock-search/extra-shortcut chords work inside the session.
- Setting the flag triggers `App.resetPreferencesDependentComponents()` (when `TilesPanel` exists)
so `TilesView` re-renders against the new `read()` (`Manager:136-142`).
The session ends in `onSwitcherDismissed()` (`Manager:189`), which sets
`isFreePassSessionActive=false`; the deferred [C] window then appears 1s later (`Manager:193-199`).
---
## 7. How the schedule is computed and persisted
`ProTransitionScheduler.computeNextFireDate()` (`ProTransitionScheduler.swift:58-103`):
- Bails (returns `nil`) if Pro, or `userOptedOut && hasSeenDay35`, or no `trialStartDate`.
- If `!hasSeenWelcome` → returns `Date()` (fire [A] now).
- Otherwise builds a candidate list from the unshown prompts (Day 12, Day 15 proactive, Day 21,
Day 35), each via `nextTimeWindow(onOrAfterDay:trialStart:)` (`Scheduler:106-123`) which finds the
next 10:00 or 15:30 slot on/after the target trial day (scanning up to 60 days). Returns the
**earliest** candidate.
- Day 12 candidate is dropped once past `day13Start` (`Scheduler:71-76`); Day 35 candidate must be
before `day49` (`Scheduler:92-99`).
`scheduleNext()` (`Scheduler:40-55`) cancels the prior `DispatchWorkItem`, persists the chosen date
to UserDefaults key `proTransition.nextScheduledDate` (`Scheduler:14, 46`), and arms a new
`DispatchWorkItem` via `asyncAfter`. On fire it calls `onFire()` then re-schedules.
**Persistence:** all flags + the next-fire date live in the `<bundleId>.license` UserDefaults suite,
keys prefixed `proTransition.` (`ProTransitionState.swift:9, 136-154`; `Scheduler:14`). Note the
schedule and the nag state are co-located in the **license** suite (shared with `trialStartDate`),
not standard defaults. The pure-decision `isSchedulingComplete` (`Testable:136-145`) mirrors the
scheduler's bail conditions for testing.
`cancel()` (`Scheduler:33-37`) removes `nextScheduledDate` and the work item — called on purchase
via `onLicenseStateChanged()` (`Manager:172-176`), which also emits `.dismissAllProWindows` +
`.refreshBadge`.
---
## 8. Removal notes (for the free build)
Removing the nag system cleanly is largely about severing the App.swift hooks in §3 and deleting the
`src/pro/scheduling/` directory. Key dependencies and risks:
- **`src/pro/scheduling/` is self-contained UI/logic** but depends on: `ProFeature` /
`ProGatedPreferences` (feature gating), `LicenseManager` (state, `trialStartDate`,
`daysSinceTrialStart`, `clock`), `UsageStats`, `ProConversionCopy` / `ProFeatureCopy`,
`ProPromptWindow`/`ProPromptPopover`/`ProPromptHeader`/`UsageStatHeroView`/`NotAdvisedButton`
(shared Pro UI in `src/pro/ui/`), and `Endpoints.checkoutUrl`. Those are part of the broader
paywall and are covered by other audit sections; this directory is a *consumer* of them.
- **App.swift hooks to remove/neutralize** (all load-bearing): `App.swift:78, 116, 207, 328, 428,
429, 448, 452, 458`. The `onSwitcherShown`/`onSwitcherDismissed` calls are in the switcher hot
path; the `pendingDismissAction` 1s-deferred dispatch must be dropped with them.
- **`ProFeature.attemptUse()` (`ProFeature.swift:74-83`)** calls into `attemptHardGatedFeature`.
For a fully-unlocked build this must unconditionally return `true` (delete the
`ProTransitionManager` call and the `isFreePassSessionActive` short-circuit at `ProFeature.swift:76`).
- **`PreferenceDefinition.read()` (`PreferenceDefinition.swift:32-44`)** references
`isFreePassSessionActive` and `ProTransitionState.int(...)`. With no lock these branches are dead;
removal must keep `read()` returning the stored value unconditionally.
- **`Menubar.swift:205`** reads `shouldShowBadgeDot`; remove the badge-dot call.
- **Settings observers** (`AppearanceTab.swift:416`, `ControlsTab.swift:141`) subscribe to
`proLockStateDidChangeNotification`; with no lock these never fire but the symbol must still
resolve or be removed alongside the manager.
- **`ProGatedPreferences.all`** is iterated by `onProLockEngaged`/`onProUnlocked`. If those prefs
become permanently unlocked, the snapshot/restore logic is dead code; ensure no stored value is
ever forced to the Free equivalent on launch.
- **Risk — orphaned UserDefaults:** existing installs carry `proTransition.*` keys in the
`<bundleId>.license` suite (and `trialStartDate`). A free build that ignores them is safe, but if
any remaining read of `isProLocked` survives, a previously-expired user could still see a degraded
pref. Confirm `isProLocked` always evaluates to `false` in the free build.
- **DEBUG-only QAMenu** (`QAMenu.swift:170-241`) directly instantiates every Day-X window and pokes
manager flags; it must be removed/updated in lockstep or it won't compile.
- **Tests:** `ProTransitionTests.swift` (729 lines) and `ProTransitionManagerTestable.swift` are
pure-logic and will need to be deleted with the system; nothing outside scheduling imports them.
-215
View File
@@ -1,215 +0,0 @@
# 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.
- `.hardGated` — use-time free-pass → `[C]` ladder; no stored preference.
- `.degradableAndHardGated` — both: silent downgrade AND the first post-expiration switcher summon triggers `[C]`.
Mapping in `gateKind` (`src/pro/ProFeature.swift:27-33`):
- `.autoSize` → `.degradable`
- `.appIconsAndTitlesStyle`, `.searchOnReleaseShortcut` → `.degradableAndHardGated`
- `.extraShortcut`, `.searchInSwitcher`, `.lockSearchInSwitcher` → `.hardGated`
### 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:
- `.appIconsAndTitlesStyle` → `ProGatedPreferences.appearanceStyle.erased`
- `.autoSize` → `ProGatedPreferences.appearanceSize.erased`
- `.searchOnReleaseShortcut` → `ProGatedPreferences.shortcutStyle.erased`
- `.extraShortcut`, `.searchInSwitcher`, `.lockSearchInSwitcher` → `nil`
### 1.4 `copy` — per-case marketing line (`src/pro/ProFeature.swift:46-56`)
Maps each case to a `ProFeatureCopy` string. Note multiple cases share a line:
- `.appIconsAndTitlesStyle` → `ProFeatureCopy.appIconsAndTitles`
- `.autoSize` → `ProFeatureCopy.autoSize`
- `.searchOnReleaseShortcut` → `ProFeatureCopy.extraShortcuts` ("grouped under keyboard shortcuts")
- `.extraShortcut` → `ProFeatureCopy.extraShortcuts`
- `.searchInSwitcher`, `.lockSearchInSwitcher` → `ProFeatureCopy.search`
### 1.5 `degradable` list (`src/pro/ProFeature.swift:59`)
```swift
static let degradable: [ProFeature] = [.appIconsAndTitlesStyle, .autoSize, .searchOnReleaseShortcut]
```
### 1.6 Availability & runtime gating
- `isAvailable` (`src/pro/ProFeature.swift:63`) → `LicenseManager.shared.isProAvailable`
- `isLocked` (`src/pro/ProFeature.swift:65`) → `LicenseManager.shared.isProLocked`
- `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.
---
## 2. Marketing copy strings (these get deleted)
### 2.1 `ProFeatureCopy` (`src/pro/ProFeatureCopy.swift:3-8`)
The canonical feature-list strings. Every one of these is a `NSLocalizedString`:
| Constant | String |
|---|---|
| `appIconsAndTitles` | `"App Icons & Titles styles"` |
| `search` | `"Search — find a window by typing"` |
| `autoSize` | `"Auto Size — larger previews with few windows, more at once with many"` |
| `extraShortcuts` | `"Up to 9 keyboard shortcuts"` |
Consumers (all upsell UI, to be removed/neutralised):
- `src/pro/scheduling/Day1WelcomeLetterWindow.swift:77-80` — the Free-vs-Pro comparison table rows.
- `src/pro/ProFeature.swift:50-54` — the `copy` accessor.
### 2.2 `ProConversionCopy` (`src/pro/ProConversionCopy.swift`)
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.
`day12Subtitle()` (`src/pro/ProConversionCopy.swift:11-24`):
- empty usage: `"On Day 15, Pro features revert to the free version."`
- 1–2 used: `"You've been using %@.\nThese will switch back to defaults on Day 15."`
- 3+ used: `"You've been using %d Pro features.\nThey'll switch back to defaults on Day 15."`
`day21Body()` (`src/pro/ProConversionCopy.swift:26-44`):
- both counts > 0: `"You've used AltTab %@ times — %@ of those used Pro features."`
- triggers only: `"You've used AltTab %@ times."`
- neither: `"Pro is still available whenever you're ready."`
Consumers:
- `src/pro/scheduling/Day12HeadsUpPopover.swift:21` — `ProConversionCopy.day12Subtitle()`
- `src/pro/scheduling/Day21ReminderPopover.swift:18` — `ProConversionCopy.day21Body()`
### 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"`.
---
## 3. Snapshot / downgrade / restore machinery (now in `PreferenceDefinition.swift`)
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`.
### 3.1 `PreferenceGate<T>` (`src/preferences/PreferenceDefinition.swift:7-11`)
Per-preference gate policy carrying:
- `freeEquivalent: T` — the value to fall back to when locked.
- `rememberedKey: String` — UserDefaults sub-key under which the Pro value index is snapshotted.
- `isProValue: (T) -> Bool` — predicate identifying which stored values count as "Pro".
### 3.2 `PreferenceDefinition<T>` (`src/preferences/PreferenceDefinition.swift:18-74`)
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.
### 3.3 `AnyProGatedPreference` (`src/preferences/PreferenceDefinition.swift:79-96`)
Type-erased descriptor exposing concrete-typed ops only: `preferenceKey`, `rememberedKey`, `snapshotAndDowngrade: () -> Int?`, `restoreFromIndex: (Int) -> Void`, `isStoredValuePro: () -> Bool`. Produced by `PreferenceDefinition.erased` (`:88-95`). This is what `ProFeature.gatedPreference` returns.
### 3.4 `ProGatedPreferences` registry (`src/preferences/PreferenceDefinition.swift:102-170`)
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) |
|---|---|---|---|---|---|---|
| `.appIconsAndTitlesStyle` | `appearanceStyle` (`PreferenceDefinition.swift:103-109`) | `appearanceStyle` | `rememberedAppearanceStyle` | `.thumbnails` | `.thumbnails` | `$0 != .thumbnails` (i.e. `.appIcons` or `.titles`) |
| `.autoSize` | `appearanceSize` (`:111-117`) | `appearanceSize` | `rememberedAppearanceSize` | `.auto` | `.medium` | `$0 == .auto` |
| `.searchOnReleaseShortcut` | `shortcutStyle` (`:119-125`) | `shortcutStyle` | `rememberedShortcutStyle` | `.focusOnRelease` | `.doNothingOnRelease` | `$0 == .searchOnRelease` |
Underlying enums (`src/preferences/MacroPreferences.swift`):
- `AppearanceStylePreference` (`:284-287`): `.thumbnails`, `.appIcons`, `.titles` → Pro = `.appIcons`/`.titles`.
- `AppearanceSizePreference` (`:308-312`): `.small`, `.medium`, `.large`, `.auto` → Pro = `.auto`.
- `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:
| Def | preferenceKey | rememberedKey | default | freeEquivalent | isProValue |
|---|---|---|---|---|---|
| `appearanceStyleOverride0` (`:131-137`) | `appearanceStyleOverride` | `rememberedAppearanceStyleOverride` | `.thumbnails` | `.thumbnails` | `$0 != .thumbnails` |
| `appearanceSizeOverride0` (`:139-145`) | `appearanceSizeOverride` | `rememberedAppearanceSizeOverride` | `.medium` | `.medium` | `$0 == .auto` |
| `shortcutStyleOverride0` (`:147-153`) | `shortcutStyleOverride` | `rememberedShortcutStyleOverride` | `.doNothingOnRelease` | `.doNothingOnRelease` | `$0 == .searchOnRelease` |
### 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
| Symbol | Consumer site |
|---|---|
| `ProFeature.extraShortcut(index:).attemptUse()` | `src/switcher/ShortcutAction.swift:56` |
| `ProFeature.lockSearchInSwitcher.attemptUse()` | `src/switcher/main-window/TilesView.swift:79` |
| `ProFeature.searchInSwitcher.attemptUse()` | `src/switcher/main-window/TilesView.swift:92` |
| `ProFeature.isStoredValuePro(preferenceKey:)` | `src/events/PreferencesEvents.swift:62` (bounces to `UpgradeTab.navigateToUpgradeTab()` when locked) |
| `ProFeatureCopy.*` (4 strings) | `src/pro/scheduling/Day1WelcomeLetterWindow.swift:77-80`; `src/pro/ProFeature.swift:50-54` |
| `ProConversionCopy.day12Subtitle()` | `src/pro/scheduling/Day12HeadsUpPopover.swift:21` |
| `ProConversionCopy.day21Body()` | `src/pro/scheduling/Day21ReminderPopover.swift:18` |
| `ProGatedPreferences.all` / `forPreferenceKey` | `src/pro/scheduling/ProTransitionState.swift:74,86,129`; `src/preferences/Preferences.swift`; `src/pro/ProFeature.swift:39-41,88` |
| `SearchModeResolver` gating note | `src/switcher/state/SearchModeResolver.swift:9` |
`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).
-332
View File
@@ -1,332 +0,0 @@
# 07 — Pro Upsell UI Overlay & Menu-bar Integration
**Scope:** `src/pro/ui/` (the shared upsell UI toolkit) and `src/Menubar.swift` (the
`NSStatusItem` menu and its license/trial/badge integration).
This section documents every upsell-specific UI component, how `ProPromptHost.dispatch`
routes the timed Day-X prompts, every menu-bar item that is license / trial / upsell
related, and what the menu bar should look like once the paywall is removed.
> Cross-references: the Day-X window/popover classes live in `src/pro/scheduling/`
> (audited separately). The coordinator that *emits* prompt actions is
> `ProTransitionManager` (`src/pro/scheduling/ProTransitionManager.swift`). License state
> lives in `src/pro/license/`. This section only covers the *UI receivers* of those
> emissions and the menu-bar surface.
---
## 1. The `src/pro/ui/` toolkit
`src/pro/ui/` is a small library of reusable AppKit building blocks. None of these classes
contain license/trial *logic* themselves — they are pure presentation primitives consumed
by the Day-X scheduling windows (in `src/pro/scheduling/`) and by the Settings "Upgrade"
tab (`src/preferences/settings-window/tabs/UpgradeTab.swift`). They exist *only* to render
the paywall/upsell, so all of them are deletion candidates for a fully-free build, but
several are referenced from non-pro code and must be untangled (see §1.8).
### 1.1 `ProPromptHost.swift` — the dispatch hub
`/Users/james.maes/Git.Local/kof22/alt-tab-free/src/pro/ui/ProPromptHost.swift`
A singleton (`ProPromptHost.shared`, line 9) whose sole job is to translate the abstract
`ProPromptAction` enum into concrete Day-X UI calls. It is wired at launch in
`src/App.swift:428`:
```swift
ProTransitionManager.shared.onAction = { ProPromptHost.shared.dispatch($0) }
```
`dispatch(_:)` (lines 11–38) is a single `switch` over `ProPromptAction`
(defined in `src/pro/scheduling/ProTransitionManager.swift:87-98`). The complete routing
table:
| `ProPromptAction` case | Source line | UI call made | Day label |
|---|---|---|---|
| `.showWelcome` | `ProPromptHost.swift:13-14` | `Day1WelcomeLetterWindow.show()` | [A] Day 1 |
| `.showDay4Tour` | `:15-16` | `Day4TourPopover.show()` | [H] Day 4 |
| `.showDay12HeadsUp` | `:17-19` | `Day12HeadsUpPopover.show()` **then** `Menubar.menubarIconCallback(nil)` | [B] Day 12 |
| `.showDay15Proactive` | `:20-21` | `Day15ProactiveWindow.show()` | [D] Day 15 |
| `.showDay15FullUpgrade(reason)` | `:22-23` | `Day15FullUpgradeWindow.show(for: reason)` | [C] Day 15 |
| `.showDay15HardGatePopover(reason)` | `:24-25` | `Day15HardGatePopover.show(for: reason)` | [E] Day 15 hard gate |
| `.showDay21Reminder` | `:26-27` | `Day21ReminderPopover.show()` | [F] Day 21 |
| `.showDay35Final` | `:28-29` | `Day35FinalWindow.show()` | [G] Day 35 |
| `.dismissAllProWindows` | `:30-34` | closes the four singleton windows (Day1/Day15Full/Day15Proactive/Day35) | on upgrade-to-pro |
| `.refreshBadge` | `:35-36` | `Menubar.menubarIconCallback(nil)` | menu-bar badge dot refresh |
Two cases reach back into the menu bar: `.showDay12HeadsUp` (line 19) and `.refreshBadge`
(line 36) both call `Menubar.menubarIconCallback(nil)` to repaint the orange badge dot
(see §2.5). The window-class APIs the host calls were verified to exist:
`show()` / `show(for:)` static methods and `shared?` optionals on the window subclasses.
### 1.2 `ProPromptPopover.swift` — popover plumbing
`/Users/james.maes/Git.Local/kof22/alt-tab-free/src/pro/ui/ProPromptPopover.swift`
An `enum` namespace (no instances) with two static helpers used by the four menu-bar-anchored
Day-X popovers ([B] Day 12, [E] Day 15 hard gate, [F] Day 21, [H] Day 4):
- `make(contentSize:)` (lines 10–15) builds a `.transient` `NSPopover`.
- `present(_:content:)` (lines 18–25) sets the content VC, activates the app, then anchors
the popover under the menu-bar icon via `Menubar.showPopoverFromMenubar(popover)`
(line 23) — its only coupling to `Menubar`.
### 1.3 `ProPromptWindow.swift` — base window chrome
`/Users/james.maes/Git.Local/kof22/alt-tab-free/src/pro/ui/ProPromptWindow.swift`
`NSWindow` subclass (lines 7–26) providing the shared chrome for the four non-modal Day-X
*windows* ([A] Welcome, [C] Full Upgrade, [D] Proactive, [G] Final): hidden titlebar,
hidden traffic-light buttons, `hidesOnDeactivate = false`, `isReleasedWhenClosed = false`,
and `Esc`→`close()` (line 23-25). Title is hard-coded `"AltTab Pro"` (line 12).
### 1.4 `ProPromptHeader.swift` — branded window header
`/Users/james.maes/Git.Local/kof22/alt-tab-free/src/pro/ui/ProPromptHeader.swift`
`NSStackView` subclass (lines 10–64) drawing an app-icon + title row at the top of every
Day-X window. Notable: it auto-replaces the last `"Pro"` substring in the title with a
gradient text attachment from `ProGradient.makeProTextAttachment` (lines 55–63). Consumed by
Day1/Day15Full/Day15Proactive/Day35 windows (see §1.8).
### 1.5 `ProGradientButton.swift` — the gradient CTA button
`/Users/james.maes/Git.Local/kof22/alt-tab-free/src/pro/ui/ProGradientButton.swift`
`NSButton` subclass (lines 3–100) rendering the pink→blue gradient "buy" button used as the
primary CTA across the upsell. Custom layer-based gradient fill (line 8, using
`ProGradient.makeLayer`), drop shadow, and a hover "shine" sweep animation
(`playShineAnimation`, lines 74–99). Subclassed by Settings: `UpgradeButton`
(`SettingsWindow.swift:133`) and `ProHeroButton` (`UpgradeTab.swift:493`).
### 1.6 `UsageStatHeroView.swift` — "your usage so far" stat block
`/Users/james.maes/Git.Local/kof22/alt-tab-free/src/pro/ui/UsageStatHeroView.swift`
`NSStackView` subclass (lines 22–140) rendering a guilt/value-framing hero block driven by
`UsageStats.triggerCount` and `UsageStats.usedProFeaturesSessionCount` (read at init and on
`refresh()`, lines 64–65). Three render modes (both counts >0 → two columns; only triggers
→ one column; both zero → omitted). The "Pro feature uses" column number is drawn as a
gradient image (lines 84-88, 112-117). Lead-in copy is `"YOUR USAGE SO FAR"` (line 69).
Consumed by Day15Full, Day15Proactive, Day35 windows and the Upgrade tab (see §1.8).
### 1.7 `ProBadgeView.swift` — the "Pro" pill + gradient utilities (largest file)
`/Users/james.maes/Git.Local/kof22/alt-tab-free/src/pro/ui/ProBadgeView.swift`
This 458-line file actually contains **five** distinct types, only the last of which is the
badge view proper:
1. **`enum ProGradient`** (lines 3–119) — the gradient color/geometry definitions
(lines 4–13) plus rendering helpers: `makeLayer`, `makeGradientTextImage`,
`makeProTextAttachment`, `makeGradientTextAttachment` (used for menu rows showing
"Get Pro" per the line 70 comment), `makeFullProBadgeImage` (renders the badge into an
`NSImage` for `NSMenuItem.image` / closed `NSPopUpButton` face, lines 90–101), and
`drawGradientFill`. This enum is the shared dependency of basically every other upsell
surface (the gradient menu pill, the Pro segment overlays in Settings, etc.).
2. **`class ProDropdownItemView`** (lines 124–187) — a custom `NSMenuItem.view` that draws a
title + full Pro pill in a dropdown row, with its own highlight handling. (Used by
Settings dropdowns, **not** the menu bar.)
3. **`class NotAdvisedButton`** (lines 190–200) — the small gray "discouraged action" link
button ("Not now", "Maybe later", "Continue with Free", "No thanks…"). Consumed by all
Day-X dismiss links (see §1.8).
4. **`class DynamicColorImageView`** (lines 205–213) — state-aware tinted image view used by
the Pro-segment overlay in Settings.
5. **`class ProBadgeView`** (lines 215–458) — the actual "Pro" pill. Includes static helpers
`attach(to:segmentIndex:…)` (lines 257–318) and `refreshSelection(…)` (lines 323–327)
that overlay a Pro badge on a `NSSegmentedControl` segment in Settings. Registers `"Pro"`
with the Settings search index at init (line 344: `SettingsSearchIndex.registerString`).
> Note: despite the filename, the menu-bar code does **not** instantiate `ProBadgeView`
> directly. Menubar uses `ProGradient.makeLayer` (indirectly, via `UpgradeMenuItemView`)
> and the orange dot is a hand-rolled `CALayer`, not a `ProBadgeView`.
### 1.8 Consumers of the toolkit (untangle map for removal)
Confirmed via grep across `src/`. These are the call sites that will break if `src/pro/ui/`
is deleted wholesale:
| Toolkit type | Consumed by | File:line |
|---|---|---|
| `ProGradientButton` | `UpgradeButton` (Settings) | `preferences/settings-window/SettingsWindow.swift:133` |
| `ProGradientButton` | `ProHeroButton` (Upgrade tab) | `preferences/settings-window/tabs/UpgradeTab.swift:493` |
| `UsageStatHeroView` | Upgrade tab hero | `preferences/settings-window/tabs/UpgradeTab.swift:5,37` |
| `UsageStatHeroView` | Day15Full / Day15Proactive / Day35 | `pro/scheduling/Day15FullUpgradeWindow.swift:56`, `Day15ProactiveWindow.swift:38`, `Day35FinalWindow.swift:27` |
| `ProPromptHeader` | Day1 / Day15Full / Day15Proactive / Day35 | `pro/scheduling/Day1WelcomeLetterWindow.swift:22`, `Day15FullUpgradeWindow.swift:53`, `Day15ProactiveWindow.swift:34`, `Day35FinalWindow.swift:23` |
| `NotAdvisedButton` | Day12 / Day15Full / Day15HardGate / Day15Proactive / Day21 / Day35 | `pro/scheduling/Day12HeadsUpPopover.swift:28`, `Day15FullUpgradeWindow.swift:66`, `Day15HardGatePopover.swift:28`, `Day15ProactiveWindow.swift:48`, `Day21ReminderPopover.swift:34`, `Day35FinalWindow.swift:37` |
| `ProGradient` / `ProBadgeView.attach` | Settings tabs (Appearance/Controls Pro segments, search index) | `SettingsSearchIndex.swift`, `AppearanceTab.swift`, `ControlsTab.swift`, `LabelAndControl.swift` (per earlier grep on `ProTransitionManager`/badge usage) |
Because every Day-X scheduling class is being removed in the same effort, the
`src/pro/ui/` types whose *only* surviving consumers are the Settings Upgrade tab are
`ProGradientButton`, `UsageStatHeroView`, and the `ProGradient`/`ProBadgeView` segment
helpers. Those will be resolved when the Upgrade tab and Pro-segment overlays are removed.
---
## 2. `src/Menubar.swift` — the `NSStatusItem` menu
`/Users/james.maes/Git.Local/kof22/alt-tab-free/src/Menubar.swift`
`Menubar` is the always-present `NSStatusItem`. Most of it (Show / Settings / Check for
updates / Check permissions / About / Send feedback / Quit, the menu-bar icon preference,
removal-from-menubar observation, the Screen-Recording `PermissionCallout`) is **not**
paywall-related and must survive. The paywall touch-points are isolated to the three
license-driven menu items, the `refreshLicenseMenuItems` state machine, the
`UpgradeMenuItemView`, and the badge-dot overlay.
### 2.1 Stored upsell menu items
Lines 8–10 declare three license-driven items as static vars:
```swift
private static var upgradeToProMenuItem: NSMenuItem! // line 8
private static var supportProjectMenuItem: NSMenuItem! // line 9
private static var myAccountMenuItem: NSMenuItem! // line 10
```
### 2.2 Menu construction (`initialize()`, lines 27–66)
The non-pro items are added at lines 37–45 (Show, Settings, Check for updates, Check
permissions, About, **Debug tools**, Send feedback). The three pro items are added
immediately after:
- **Line 46:** `upgradeToProMenuItem = addMenuItem("Get Pro", App.upgradeToProAction, "", "star.fill", …)`
— the "Get Pro" upsell entry. Its action `App.upgradeToProAction`
(`App.swift:26`) → `@objc App.upgradeToPro()` (`App.swift:115-117`) →
`ProTransitionManager.openCheckout()` → opens `Endpoints.checkoutUrl`
(`ProTransitionManager.swift:307-308`; URL `…/pricing`, `Endpoints.swift:9`).
- **Line 47:** `upgradeToProMenuItem.view = UpgradeMenuItemView()` — replaces the row with the
custom gradient view (§2.4).
- **Line 48:** `myAccountMenuItem = addMenuItem("My Account", App.openAccountAction, …)` —
action `App.openAccountAction` (`App.swift:27`) → `App.openAccount()`
(`App.swift:119-121`) → `UpgradeTab.openAccountPage()` → opens `Endpoints.accountUrl`
(`…/my-account`, `Endpoints.swift:10`).
- **Line 49:** `supportProjectMenuItem = addMenuItem("Support this project", App.supportProjectAction, …, .red, …)`
— heart icon, action `App.supportProjectAction` (`App.swift:25`) →
`App.supportProject()` (`App.swift:111-113`) → opens `Endpoints.supportUrl`
(`…/support`, `Endpoints.swift:8`).
- **Line 50:** `refreshLicenseMenuItems()` — runs the state machine to show/hide these three.
> Upstream AltTab (the GPL parent) historically shipped a "Support this project" /
> donate item; "Get Pro" and "My Account" are the fork's proprietary additions. Whether to
> keep "Support this project" in the free build is a product decision (see §3), but it is
> currently gated by the same `refreshLicenseMenuItems` state machine.
### 2.3 `refreshLicenseMenuItems()` state machine (lines 84–107)
Reads `LicenseManager.shared.state` (`LicenseState`, defined
`pro/license/LicenseState.swift:1-21`) and shows/hides the three items per state:
| `LicenseState` | "Get Pro" (upgrade) | "Support this project" | "My Account" |
|---|---|---|---|
| `.trial(daysRemaining)` | shown | hidden | hidden |
| `.pro` | **removed** (`toggleUpgradeMenuItem(false)`) | hidden | shown |
| `.proExpired` | shown | shown | shown |
| `.trialExpired` | shown | shown | hidden |
- `.pro` early-returns at line 105 *before* calling `updateContent`, so the gradient view's
text is only refreshed for non-pro states (line 106).
- `toggleUpgradeMenuItem(_:)` (lines 109–118) inserts/removes `upgradeToProMenuItem`
relative to `supportProjectMenuItem`'s index — the "Get Pro" row is fully removed from the
menu (not just `isHidden`) for Pro users, because `NSMenuItem.isHidden` is unreliable with
custom views (same pattern as the permission callout, comment at line 135).
`refreshLicenseMenuItems()` is invoked from:
- `Menubar.initialize()` line 50 (initial build).
- `App.swift:450` inside `LicenseManager.shared.onStateChanged` (license state transitions).
### 2.4 `UpgradeMenuItemView` (lines 235–390)
Custom `NSView` set as `upgradeToProMenuItem.view`. Renders the gradient "Get Pro" pill in
the menu with a two-line label and a `star.fill` icon (lines 269–275, tinted white). Uses
`ProGradient.makeLayer()` (line 247) for the backdrop and has its own hover "shine"
animation (`playShineAnimation`, lines 328–355) mirroring `ProGradientButton`.
- `updateContent(_ state:)` (lines 357–382) builds the two-line attributed string. The
**trial day count** appears here: line 370-371,
`String(format: "Trial: %d days remaining", daysRemaining)` for `.trial`; line 372-373
`"License doesn't cover this version"` for `.proExpired`; else `"Trial expired"`
(line 375). Second line is always `"Get Pro"` (line 380).
- `mouseUp` (lines 384–389) cancels menu tracking and calls `App.upgradeToPro()`
(opens checkout).
### 2.5 The orange badge dot overlay (lines 187–227)
A hand-rolled 7pt orange `CALayer` (`systemOrange`, line 221) anchored to the bottom-right
of the menu-bar icon, signalling "the trial is ending / there's an upsell to see."
- `badgeDotLayer` static var (line 187).
- `updateBadgeDotOverlay()` (lines 202–227): removes any existing dot, then
**guards on `ProTransitionManager.shared.shouldShowBadgeDot`** (line 205). If false (or
no button), it stops — so the dot only appears when the transition manager says so
(`shouldShowBadgeDot` defined `ProTransitionManager.swift:161-162`, delegating to
`ProTransitionManagerTestable.shouldShowBadgeDot`). Otherwise it builds the orange dot
layer.
- Invoked only from `loadPreferredIcon()` (line 196), which runs inside
`applyMenubarIconPreferences()` → `menubarIconCallback(_:)` (lines 155–164). That callback
is the entry point `ProPromptHost` calls (lines 19, 36) to repaint the dot, and is also
called from `PreferencesEvents.swift:79` and `QAMenu.swift:240` (non-pro icon-pref
changes also repaint, harmlessly).
### 2.6 `MenubarMenuDelegate` (lines 392–399)
`menuWillOpen` calls `LicenseManager.shared.refreshState()` (line 396) so the trial-day
subtitle in `UpgradeMenuItemView` reflects the current clock at open time, then
`Menubar.refreshPermissionCallout()` (line 397, non-pro). The `refreshState()` call is the
license/trial coupling here; the permission-callout refresh is independent and must stay.
### 2.7 Non-pro menu-bar code (must survive untouched)
For clarity on what is *not* in scope for removal: `addMenuItem` helper (14–25), the
QA-menu middle-click monitor (DEBUG only, 68–82), `refreshPermissionCallout` /
`togglePermissionCallout` / `PermissionCallout` (120–144, 401–445), `statusItemOnClick`
(146–153), the icon-preference + removal-observer machinery (166–197 minus the badge-dot
call at 196), and `showPopoverFromMenubar` (229–232). Note `showPopoverFromMenubar` is used
*only* by the Day-X popovers via `ProPromptPopover.present`, so after the paywall is removed
it becomes dead code and can be deleted too.
---
## 3. What the menu bar should look like with the paywall gone
After removing the trial/license/upsell system, the `NSStatusItem` menu should reduce to
the upstream-style set of items:
```
Show
─────────
Settings…
Check for updates…
Check permissions…
─────────
About AltTab
Debug tools (DEBUG builds only — see note)
Send feedback…
Support this project (product decision — see below)
─────────
Quit AltTab
```
Concrete `Menubar.swift` changes required for a clean free build:
- **Remove the three license-driven items and their wiring:**
- Delete the three static vars at lines 8–10.
- Delete line 46 (`Get Pro`), line 47 (`.view = UpgradeMenuItemView()`), line 48
(`My Account`), and the `refreshLicenseMenuItems()` call at line 50. ("Support this
project" at line 49 is the only one of the four that has upstream precedent — keep or
drop per product decision; if kept, add it unconditionally and stop hiding it.)
- **Delete `refreshLicenseMenuItems()` (84–107) and `toggleUpgradeMenuItem(_:)` (109–118)**
entirely — both reference `LicenseManager`/`LicenseState`.
- **Delete the `UpgradeMenuItemView` class (235–390)** — it is the gradient pill, the trial
day-count renderer, and the checkout `mouseUp`. Its only consumer is line 47.
- **Delete the badge-dot overlay** (`badgeDotLayer` line 187; `updateBadgeDotOverlay()`
202–227) and remove its call at line 196 inside `loadPreferredIcon()`. With it gone,
`loadPreferredIcon` no longer depends on `ProTransitionManager`.
- **Simplify `MenubarMenuDelegate.menuWillOpen` (392–399)** — drop the
`LicenseManager.shared.refreshState()` call at line 396; keep
`refreshPermissionCallout()` (line 397).
- **`menubarIconCallback(_:)` (155–164)** can stay (it is also the icon-preference entry
point), but its callers in `ProPromptHost.swift:19,36` disappear with the host. The
`applyMenubarIconPreferences`/`loadPreferredIcon` path remains valid for the
PreferencesEvents/QAMenu callers.
- **`showPopoverFromMenubar` (229–232)** becomes dead code (only the Day-X popovers used it)
and can be removed.
In `src/App.swift`: remove `supportProjectAction`/`upgradeToProAction`/`openAccountAction`
(25–27) and the `@objc supportProject/upgradeToPro/openAccount` methods (111–121) if their
endpoints (`pricing`, `my-account`, `support`) are also dropped; remove the
`ProTransitionManager.shared.onAction = …` wiring (line 428) and the
`Menubar.refreshLicenseMenuItems()` call inside `onStateChanged` (line 450).
The entire `src/pro/ui/` directory can be deleted **once** its non-pro consumers in
`src/preferences/settings-window/` (the Upgrade tab, the Pro-segment overlays via
`ProBadgeView.attach` / `ProGradient`, and the search-index `"Pro"` registration at
`ProBadgeView.swift:344`) are removed in the Settings audit. Until then,
`ProGradient`, `ProGradientButton`, `UsageStatHeroView`, and the `ProBadgeView` segment
helpers are still referenced and would break the build if deleted prematurely.
> **Note on "Debug tools":** it is added unconditionally at `Menubar.swift:44` in this fork
> (not behind `#if DEBUG`), unlike the QA-menu monitor. Leaving it is orthogonal to the
> paywall; flag it for the product owner but it is not a removal blocker.
-259
View File
@@ -1,259 +0,0 @@
# 08 — Build, Signing, Auto-Update & Publishing Pipeline
How `alt-tab-free` (a fork of `lwouis/alt-tab-macos`) is actually built, versioned, signed,
notarized, shipped, and auto-updated. This section is written for the republish-as-free goal:
every place the pipeline still points at the **original upstream / paywall vendor** (`lwouis`,
`alt-tab.app`, AppCenter, the upstream Developer ID and Sparkle key) is enumerated at the end.
---
## 1. How to build locally
There is no Makefile / wrapper — builds are raw `xcodebuild` invocations over
`alt-tab-macos.xcodeproj`. There are exactly **three schemes** (`alt-tab-macos.xcodeproj/xcshareddata/xcschemes/`):
`Debug.xcscheme`, `Release.xcscheme`, `Test.xcscheme`.
### Debug build (everyday dev loop)
`ai/build.sh:3-7`:
```
xcodebuild -project alt-tab-macos.xcodeproj -scheme Debug -configuration Debug -derivedDataPath DerivedData
```
- Per `AGENTS.md` the sanctioned workflow is "copy commands from `ai/build.sh` and run them" — never drive Xcode interactively.
- Run the built app: `ai/run.sh:3` runs `DerivedData/Build/Products/Debug/AltTab.app/Contents/MacOS/AltTab --logs=debug --benchmark showUi 3`.
- Profile: `ai/profile.sh` records a 20s Time Profiler trace via `xcrun xctrace` and exports it to XML.
### Release build (what CI ships)
`scripts/build_app.sh:5`:
```
xcodebuild -project alt-tab-macos.xcodeproj -scheme Release -derivedDataPath DerivedData | scripts/xcbeautify
```
Release products land in `DerivedData/Build/Products/Release` (`XCODE_BUILD_PATH` env, `ci_cd.yml:17`).
### Build configuration (xcconfig layering)
Build settings live in `config/*.xcconfig`, attached to the project via `baseConfigurationReference`
entries in `alt-tab-macos.xcodeproj/project.pbxproj` (lines ~2429–2562). The layering:
- `config/base.xcconfig` — shared identity & build settings. Key values:
- `PRODUCT_NAME = AltTab` (`base.xcconfig:3`)
- `PRODUCT_BUNDLE_IDENTIFIER = com.lwouis.alt-tab-macos` (`base.xcconfig:4`) **[upstream identity — see §7]**
- `MACOSX_DEPLOYMENT_TARGET = 10.13` (`base.xcconfig:5`); `SWIFT_VERSION = 5.8` (`:6`)
- `INFOPLIST_FILE = Info.plist`, `CODE_SIGN_ENTITLEMENTS = alt_tab_macos.entitlements` (`:8-9`)
- `ENABLE_HARDENED_RUNTIME = YES` (`:12`, required for notarization)
- `DOMAIN = alt-tab.app` and `API_DOMAIN = alt-tab.app/api` (`:20-21`) **[upstream domains — see §7]**
- `config/debug.xcconfig` — `#include "base.xcconfig"`, then `CODE_SIGN_IDENTITY = Local Self-Signed` (`:5`), `SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG` (`:6`), `OTHER_CODE_SIGN_FLAGS = --timestamp=none` (`:8`). Ends with `#include? "local.xcconfig"` (`:13`).
- `config/release.xcconfig` — `#include "base.xcconfig"`, then `CODE_SIGN_IDENTITY = Developer ID Application: Louis Pontoise (QXD7GW8FHY)` (`:5`) **[upstream Developer ID — see §7]**, `OTHER_CODE_SIGN_FLAGS = --timestamp --deep --options runtime` (`:7`, for notarization), wholemodule + `-O` + ThinLTO + full stripping (`:8-18`). Ends with `#include? "local.xcconfig"` (`:21`).
- `config/test-base.xcconfig` / `test-debug.xcconfig` / `test-release.xcconfig` — for the Test scheme; signing disabled (`CODE_SIGNING_REQUIRED = NO`, `CODE_SIGNING_ALLOWED = NO`, `test-base.xcconfig:6-7`), `ONLY_ACTIVE_ARCH = YES`.
`config/local.xcconfig` is **not committed** (referenced via `#include?`, so optional). It is the
per-developer / CI override channel for `CODE_SIGN_IDENTITY`, `APPCENTER_SECRET`, `CURRENT_PROJECT_VERSION`,
`DOMAIN`, `API_DOMAIN`. The pbxproj has a stale `local.xcconfig` file reference (`project.pbxproj:458`)
but it is *not* set as a `baseConfigurationReference` — the `#include?` in debug/release is what loads it.
### `DEVELOPMENT_TEAM` is empty at build time
In `project.pbxproj` the app target sets `DEVELOPMENT_TEAM = "${TEAM_ID}"` (lines 2462, 2520, 2554),
but **`TEAM_ID` is never defined** anywhere (not in any xcconfig, not in CI env). It therefore
expands to empty, so signing is driven purely by `CODE_SIGN_IDENTITY`. The CI's `APPLE_TEAM_ID`
secret is used *only* by `notarytool` (`package_and_notarize_release.sh:17`), not by `xcodebuild`.
The Debug target explicitly sets `DEVELOPMENT_TEAM = ""` (`project.pbxproj:2562`). Net effect:
Release is signed with whatever identity `release.xcconfig` (or a `local.xcconfig` override) names.
### Local Release signing without the upstream cert
`scripts/codesign/setup_local.sh` generates a throwaway self-signed cert and imports it into the
main keychain (calls `generate_selfsigned_certificate.sh` → `import_certificate_into_main_keychain.sh`).
This lets a developer who lacks the `Developer ID Application: Louis Pontoise` cert still produce a
signed-but-not-notarizable Release (typically combined with a `local.xcconfig` overriding
`CODE_SIGN_IDENTITY`).
### Tooling prerequisites
- **Xcode 26.0.1** pinned in CI (`ci_cd.yml:45`: `sudo xcode-select -s /Applications/Xcode_26.0.1.app/...`).
- **Node** (16 in CI, `package.json` `engines` says `>=18`) for `commitlint`, `semantic-release`, `swiftformat.js`, `ts-node`.
- **Python 3.6** (`Pipfile`) only for asset/l10n tooling (`fonttools`, `googletrans`), not the app build.
- Bundled binaries in `scripts/`: `xcbeautify` (6.5 MB), `notarytool` (17 MB), `assets/createicns`; and `vendor/Sparkle/bin/sign_update`.
---
## 2. Versioning & release cutting (semantic-release)
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
(`vendor/scripts/update_sparkle.sh`).
### Notarization (`scripts/package_and_notarize_release.sh`)
1. `ditto -c -k --keepParent AltTab.app AltTab-<version>.zip` (`:11`).
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.
### Keychain / identity invariant (from `AGENTS.md`)
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
(`lwouis/alt-tab-website`, see §6).
---
## 5. Crash reporting & symbols (Microsoft AppCenter)
- 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,
> not just re-pointing.
---
## 6. CI/CD workflow (`.github/workflows/ci_cd.yml`)
Single workflow, single `build` job, triggered only on **push to `master`** (`ci_cd.yml:1-4`),
runs on `macos-15`, environment `production` (`:22-23`). Step order (`:38-69`):
1. `actions/checkout@v6` with computed `fetch-depth` (commits + 30, `:28-40`) — needs history for commitlint/semantic-release.
2. `actions/setup-node@v6` (node 16) + `npm ci` (`:41-47`).
3. `npx commitlint --from <before> --to <after>` (`:48`).
4. `scripts/ensure_generated_files_are_up_to_date.sh` (`:49`) — runs l10n extraction and fails if it produced a diff (`git diff-files --exit-code`).
5. `scripts/determine_next_version.sh` (`:50`) → `VERSION.txt`.
6. `scripts/replace_environment_variables_in_app.sh` (`:51`) → `config/local.xcconfig`.
7. `scripts/codesign/setup_ci_master.sh` (`:52`) — import Developer ID P12.
8. `scripts/run_tests.sh` (`:53`) — `xcodebuild test -scheme Test -configuration Release`.
9. `scripts/build_app.sh` (`:54`) — Release build.
10. `scripts/package_and_notarize_release.sh` (`:55`) — zip + notarize + staple.
11. `scripts/upload_symbols_to_appcenter.sh` (`:56`) — dSYMs → AppCenter.
12. `scripts/update_appcast.sh` (`:57`) — sign zip + prepend appcast item.
13. `scripts/update_readme_and_website.sh` (`:58`) — refresh README stats SVG + contributors.
14. `npx semantic-release` (`:59`) — changelog/tag/commit-back.
15. `extract_latest_changelog.sh` (`:60-61`) → release notes.
16. `softprops/action-gh-release@v3` (`:62-66`) — publish GitHub Release + attach `*.zip`.
17. `scripts/update_website.sh` (`:67-69`) — `gh api repos/lwouis/alt-tab-website/dispatches -f event_type=update-website`, authed with `$WEBSITE_DISPATCH_TOKEN` (`update_website.sh:5`). **[upstream website repo — see §7]**
### CI secrets / env (`ci_cd.yml:5-19`)
`GITHUB_TOKEN`, `APPCENTER_SECRET`, `APPCENTER_TOKEN`, `APPLE_ID`, `APPLE_PASSWORD`,
`APPLE_TEAM_ID`, `APPLE_P12_CERTIFICATE` (base64 Developer ID P12), `SPARKLE_ED_PRIVATE_KEY`,
`WEBSITE_DISPATCH_TOKEN`. Plus computed `BUILD_DIR`, `XCODE_BUILD_PATH=DerivedData/Build/Products/Release`,
`VERSION_FILE=VERSION.txt`, `APP_NAME=AltTab`.
`scripts/update_readme_and_website.sh` hits `https://api.github.com/repos/lwouis/alt-tab-macos/...`
for contributors / star / download counts (`:10`) — another hardcoded upstream slug.
`.github/FUNDING.yml` points at upstream donation channels (`github: lwouis`, `patreon: alt_tab_macos`,
`ko_fi: alt_tab`, a PayPal button id). Cosmetic, but fork-specific.
---
## 7. CRITICAL — identities / URLs / secrets a fork MUST change or remove
Everything below still targets the **original upstream / paywall vendor**. A republished free fork
must change (C) or remove (R) each item. Build will not *break* if these are left as-is (it will
build and sign with whatever cert is present), but the app would phone home to upstream, fail to
update from the fork's own releases, and attempt to notarize/sign under the upstream account.
| # | What | Where (file:line) | Current value | Action |
|---|------|-------------------|---------------|--------|
| 1 | **Bundle ID** | `config/base.xcconfig:4` (+ unit-tests id `project.pbxproj:2477,2542`; mocks `src/_test-support/Mocks.swift:133`) | `com.lwouis.alt-tab-macos` | **C** — set fork's reverse-DNS id (e.g. `com.koftwentytwo.alt-tab-free`). |
| 2 | **Developer ID signing cert** | `config/release.xcconfig:5` | `Developer ID Application: Louis Pontoise (QXD7GW8FHY)` | **C** — fork's own Developer ID Application cert + TeamID. |
| 3 | **Signing P12 secret** | `ci_cd.yml:12` / `setup_ci_master.sh` | `$APPLE_P12_CERTIFICATE` | **C** — replace with fork's exported Developer ID P12. |
| 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. |
| 7 | **Appcast feed domain** | `config/base.xcconfig:20` (`DOMAIN`) → `Endpoints.swift:6-7` | `alt-tab.app` → `https://alt-tab.app/appcast.xml` | **C** — fork's domain, OR repoint feed at fork's GitHub-hosted appcast. |
| 8 | **API domain (license + feedback)** | `config/base.xcconfig:21` (`API_DOMAIN`) → `Endpoints.swift:5,11-12` | `alt-tab.app/api` → `https://alt-tab.app/api/v1/license`, `.../v1/feedback` | **R** — license API is paywall; remove with `src/pro`. Feedback endpoint must be repointed or removed. |
| 9 | **Appcast download URL** | `update_appcast.sh:18` | `https://github.com/lwouis/alt-tab-macos/releases/download/v<v>/...` | **C** — fork's GitHub releases URL (`KofTwentyTwo/CommandTabFree`). |
| 10 | **Appcast release-notes link** | `update_appcast.sh:16`; existing items in `appcast.xml` | `https://alt-tab.app/changelog-bare` | **C/R** — fork URL or drop the link. |
| 11 | **License tier cookie** | `src/pro/license/LicenseCookie.swift` | cookie on `alt-tab.app` | **R** — remove with paywall. |
| 12 | **Website dispatch repo + token** | `update_website.sh:5`; `ci_cd.yml:69` | `lwouis/alt-tab-website` + `$WEBSITE_DISPATCH_TOKEN` | **R/C** — fork has no upstream website; remove this CI step or repoint. |
| 13 | **README stats / contributors API** | `update_readme_and_website.sh:10` | `api.github.com/repos/lwouis/alt-tab-macos` | **C/R** — fork slug, or drop the step. |
| 14 | **AppCenter app + secret** | `update_symbols`→`upload_symbols_to_appcenter.sh:4-7`; `ci_cd.yml:7-8`; `Info.plist:73`; `Secrets.swift:6` | `apps/alt-tab-macos/alt-tab-macos`, `$APPCENTER_SECRET`, `$APPCENTER_TOKEN` | **R** — AppCenter is EoL; remove crash-reporting + the upload step (or repoint to a fork-owned analytics-free build). |
| 15 | **`App.repository`** | `src/App.swift:16` | `https://github.com/lwouis/alt-tab-macos` | **C** — fork repo URL (used in About/links). |
| 16 | **GitHub Release target** | implicit via `GITHUB_TOKEN` repo + appcast URLs | upstream releases | **C** — fork publishes to its own repo; ensure appcast URLs (#9) match. |
| 17 | **FUNDING.yml** | `.github/FUNDING.yml` | upstream Patreon/Ko-fi/PayPal/github sponsor | **C/R** — fork's funding or remove. |
| 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.
-390
View File
@@ -1,390 +0,0 @@
# 09 — Telemetry / Analytics & Relationship to Upstream
Audit scope: every network/telemetry surface in the app, the fork's divergence from
upstream AltTab (`lwouis/alt-tab-macos`), and every branding/identity string that ties
this binary to the paywalled "AltTab Pro" product. Read-only analysis; no source was
modified. All paths are relative to the repo root unless noted.
---
## 1. Executive summary
- There is **no third-party analytics or product-tracking SDK** in this app. A broad
sweep for Mixpanel / Amplitude / Sentry / Firebase / Crashlytics / Segment / PostHog /
Datadog / Heap / Matomo / Plausible / Google Analytics found **nothing** in shipped
source, `Info.plist`, or xcconfig.
- The only vendored telemetry SDK is **Microsoft AppCenter**, and only its **Crashes**
module is used — there is no `import AppCenterAnalytics` anywhere. Crash reporting is
**opt-in / ask-by-default** and is deliberately wired so AppCenter makes **no network
request at all** unless the user agrees to send a specific crash report.
- The app makes network calls from exactly **four** places, all to first-party
`alt-tab.app` infrastructure or to AppCenter:
1. AppCenter crash uploads (opt-in).
2. The license backend (`alt-tab.app/api/v1/license/*`) — part of the paywall.
3. The feedback form (`alt-tab.app/api/v1/feedback`) — posts to a server that opens a
public GitHub issue.
4. Sparkle auto-update (`alt-tab.app/appcast.xml`), which sends a small system profile
and a per-license-tier cookie.
- **This repo is a near-verbatim copy of upstream `lwouis/alt-tab-macos`, not a
diverged fork.** Every one of the last 40 commits is authored by `lwouis` or
`semantic-release-bot`; there are **no fork-specific commits**. The entire paywall was
introduced **upstream** by lwouis in a single commit, `9147a4a8 "feat: introducing
alt-tab pro!"` (v11.0.0, 2026-04-05). The "alt-tab-free" repo has only re-hosted the
code under `KofTwentyTwo/CommandTabFree` — **no code-level rebrand has happened yet**:
bundle id, app name, domain, GitHub URL, and SUPublicEDKey are all still the upstream
`com.lwouis.alt-tab-macos` / `alt-tab.app` / `lwouis/alt-tab-macos` values.
This has a major strategic implication discussed in §6: because upstream is alive,
GPL-3.0, and is the source of both the features *and* the paywall, the cleanest path may
be to **rebase a free build on a pre-11.0.0 upstream tag** rather than surgically
extracting the paywall from this snapshot.
---
## 2. Telemetry / crash reporting (AppCenter)
### 2.1 What is vendored
- `vendor/AppCenter/` — Microsoft AppCenter SDK, version pinned in
`vendor/AppCenter/UPSTREAM`:
- `VERSION=appcenter-4.3.0 + PLCrashReporter-1.11.1`
- `SOURCE=https://github.com/microsoft/appcenter-sdk-apple.git`
- `DATE=2026-05-12`
- `vendor/AppCenter/Package.swift` declares **only two** products/targets:
`AppCenter` and `AppCenterCrashes` (plus the `PLCrashReporter` binary target). The
**Analytics** module is not vendored at all (`Package.swift:8-9`). So even if someone
wanted product analytics, the SDK pieces are not present.
### 2.2 The wrapper — `src/vendors/AppCenterCrashes.swift`
This is the single integration point. Key behaviors:
- `src/vendors/AppCenterCrashes.swift:6` — `static let secret = Secrets.appCenterSecret`
reads the AppCenter app secret from `Info.plist` (see §5).
- `src/vendors/AppCenterCrashes.swift:14` —
`AppCenter.networkRequestsAllowed = false` is set **before** `AppCenter.start`,
specifically (per the inline comment) so that "appcenter makes network call just from
AppCenter.start; we only want networking when sending reports."
- `src/vendors/AppCenterCrashes.swift:18-20` — wires `Crashes.delegate = self`, a
`userConfirmationHandler`, then `AppCenter.start(withAppSecret:services:[Crashes.self])`.
Only the `Crashes` service is registered.
- `src/vendors/AppCenterCrashes.swift:31-54` — `confirmationHandler` defers a modal
`NSAlert` and only allows the network channel to flush
(`AppCenter.networkRequestsAllowed = shouldSend`,
`Crashes.notify(with: shouldSend ? .send : .dontSend)`) if the user agrees.
- `src/vendors/AppCenterCrashes.swift:56-77` — `checkIfShouldSend()` reads
`Preferences.crashPolicy` (`.ask` shows the dialog; `.always` auto-sends; otherwise
don't send). The user-facing copy is "AltTab crashed last time you used it…"
(`:61-62`) and a "Remember my choice" checkbox.
- `src/vendors/AppCenterCrashes.swift:94-96` — on a crash upload, the SDK attaches a
**debug profile** (`DebugProfile.make()`) as `debug-profile.md`. See §2.4 for what's
in it.
- `src/vendors/AppCenterCrashes.swift:99` & `:103` — after each send succeeds/fails,
`AppCenter.networkRequestsAllowed` is forced back to `false`, so the channel goes
silent again between reports.
### 2.3 Instantiation and plist hooks
- `src/App.swift:4` `import AppCenterCrashes`; `src/App.swift:33`
`private static var appCenterDelegate: AppCenterCrash?`;
`src/App.swift:436` `App.appCenterDelegate = AppCenterCrash()` — created at the very
top of `applicationDidFinishLaunching`.
- `Info.plist:36-37` — `NSPrincipalClass = AppCenterApplication` (the AppCenter
exception-catching `NSApplication` subclass).
- `Info.plist:69-70` — `AppCenterApplicationForwarderEnabled = 0`.
- `Info.plist:73-74` — `AppCenterSecret = $(APPCENTER_SECRET)` (substituted from
xcconfig at build time).
### 2.4 What leaves the device on a crash — `DebugProfile`
`src/secondary-windows/DebugProfile.swift` builds the attachment. Notable fields
(`DebugProfile.swift:15-34`): app name+version, macOS version, **License state label**
(`LicenseManager.shared.state.debugProfileLabel`), OS architecture
(`Sysctl.run("hw.machine")`), **hardware model** (`Sysctl.run("hw.model")`), and
hardware-utilization data. This same profile is also attached to feedback submissions
(§4). It does **not** appear to include the user's name/email or the machine fingerprint,
but it does include the license tier and hardware model — worth a privacy note.
### 2.5 Recommendation for a privacy-respecting free build
Two viable options, in order of how clean they are:
1. **Remove AppCenter entirely.** Delete `vendor/AppCenter/`, `src/vendors/AppCenterCrashes.swift`,
the `App.swift` instantiation (`:4`, `:33`, `:436`), the `Info.plist` keys
(`NSPrincipalClass` -> revert to plain `NSApplication`; drop
`AppCenterApplicationForwarderEnabled`, `AppCenterSecret`), and the
`Secrets.appCenterSecret` accessor. Remove the crash-policy UI/preference if nothing
else uses it. This is the most defensible "free + private" posture and removes a
~Microsoft-hosted network dependency and the `com.lwouis` AppCenter app secret. Note
`DebugProfile.make()` is also used by the feedback path, so keep `DebugProfile.swift`
if feedback is retained; only the crash-attachment call site goes away.
2. **Keep AppCenter but neuter the default.** The current design is already opt-in
(`networkRequestsAllowed=false` until consent), so a minimal change is to ship with
`crashPolicy` defaulting to ask/never and point the secret at a self-owned AppCenter
app. This keeps crash diagnostics but still ships a Microsoft SDK; less aligned with a
"fully free, no strings" rebrand.
The grafted **upsell** uses of usage data (see §3) are independent of AppCenter and must
be removed regardless.
---
## 3. `UsageStats` — local-only behavioral counters (NOT telemetry, but paywall fuel)
`src/util/UsageStats.swift` records how often the switcher is triggered and which
"pro" features were used. **This is local-only**: it writes to a private `UserDefaults`
suite `"\(App.bundleIdentifier).usage"` (`UsageStats.swift:2`) and **is never sent over
the network** (no URLSession reference anywhere in the file; data is pruned after 365
days at `:59-69`). So it is not a telemetry concern per se.
However, it exists **to drive the paywall**: the recorded counts feed the Day1->Day35
trial-nag prompts and the upgrade UI:
- `src/pro/ProConversionCopy.swift:12,27,28` — reads `usedProFeatureNames()`,
`triggerCount`, `usedProFeaturesSessionCount` to compose upsell copy.
- `src/pro/ui/UsageStatHeroView.swift:64-86` — renders trigger/pro-use counts in the
upgrade hero.
- `src/pro/scheduling/Day15FullUpgradeWindow.swift:22`,
`Day15ProactiveWindow.swift:21` — branch on `usedProFeaturesSessionCount`.
- `src/App.swift:315` `UsageStats.recordTrigger`, `:427` `UsageStats.prune`,
`:67` `resetSession`.
- `src/preferences/settings-window/tabs/AboutTab.swift:148-152` — also shows week/month/
year trigger counts in the About tab (a benign stat display).
Removal note: when the `src/pro/` tree is removed, `UsageStats` callers in `src/pro/*`
disappear. The neutral callers (`App.swift` recording, `AboutTab` display) can stay or be
removed; nothing about `UsageStats` requires the paywall. It does **not** make network
calls, so it is not a privacy blocker on its own.
---
## 4. Other network surfaces (outside AppCenter)
### 4.1 License backend — `src/pro/license/RemoteLicenseClient.swift` (PAYWALL)
- POSTs to `Endpoints.licenseApiBaseUrl` = `https://alt-tab.app/api/v1/license`
(`src/api/Endpoints.swift:11`), endpoints `activate` / `validate` / `deactivate`
(`RemoteLicenseClient.swift:104`, network at `:109` `URLSession.shared.dataTask`,
`:127` `.resume()`).
- The activation body sends a **machine fingerprint** and the local trial-start timestamp
(`RemoteLicenseClient.swift:16-25`). The fingerprint is the IOKit
`IOPlatformUUID` with a keychain fallback (`src/pro/license/MachineFingerprint.swift:9-20`).
This is a stable per-machine identifier transmitted to the backend — a privacy-relevant
data flow that exists **only** to support the paywall.
- Removal note: this entire client and all of `src/pro/license/` goes away in a free
build. It is the most clearly paywall-coupled network call.
### 4.2 License-tier cookie for Sparkle — `src/pro/license/LicenseCookie.swift` (PAYWALL)
- `syncLicenseCookie(state:)` writes a `license` cookie (value `pro` / `proExpired` /
empty) on the `alt-tab.app` domain so "Sparkle's appcast request can be tailored per
tier" (`LicenseCookie.swift:3-21`). Called from `src/App.swift:451` on every license
state change.
- This means the **auto-update check itself transmits the user's license tier** to the
server via cookie. In a free build with no tiers, this should be deleted; the appcast
becomes a single feed for everyone.
### 4.3 Feedback form — `src/secondary-windows/FeedbackWindow.swift`
- POSTs to `Endpoints.feedbackUrl` = `https://alt-tab.app/api/v1/feedback`
(`Endpoints.swift:12`; request built `FeedbackWindow.swift:444-456`, sent `:390`/`:412`).
- Body includes the user's title/body, a `kind`, and **the `DebugProfile`**
(`FeedbackWindow.swift:449-454`). The UI explicitly warns "Your feedback will be
submitted as a public GitHub issue" with "A debug profile (versions, settings,
hardware) is attached" (`:380-381`).
- This is **first-party and consent-gated**, not analytics. It is not paywall code, but
it is tied to the `alt-tab.app` backend and the upstream GitHub repo. A free rebrand
must repoint or remove this (the backend that turns the POST into a GitHub issue is not
in this repo and presumably belongs to lwouis). Note: a prior upstream commit
(`da9a46bf "feat: remove the need to add email in feedback form"`) already minimized PII
here.
### 4.4 Sparkle auto-update — `src/vendors/SparkleDelegate.swift`
- Feed URL = `Endpoints.appcastUrl` = `https://alt-tab.app/appcast.xml`
(`SparkleDelegate.swift:26-28`, `Endpoints.swift:7`).
- `feedParameters` sends a small system profile on each check: app version, macOS
version, CPU arch (`Sysctl.run("hw.machine")`), and preferred language
(`SparkleDelegate.swift:30-38`). Combined with the license cookie (§4.2), update checks
currently carry version + OS + arch + lang + license tier.
- `Info.plist:61-66` — `SUPublicEDKey` (the upstream lwouis signing key),
`SUEnableAutomaticChecks=true`, weekly interval (604800s).
- Removal note: a fork **cannot** ship updates against `SUPublicEDKey` /
`alt-tab.app/appcast.xml` unless it controls that domain and private signing key. A free
republish must either point the appcast at its own host with its own EdDSA key, or strip
auto-update. The license-tier cookie should be removed regardless.
---
## 5. Build-time identity/secrets wiring
- `src/api/Secrets.swift:6` — `appCenterSecret` from `Info.plist` key `AppCenterSecret`.
- `src/api/Endpoints.swift` — all URLs derive from `Domain` (`alt-tab.app`) and
`ApiDomain` (`alt-tab.app/api`) plist keys (`Endpoints.swift:4-5`), themselves from
`config/base.xcconfig:20-21` (`DOMAIN`, `API_DOMAIN`).
- `config/base.xcconfig:3-4` — `PRODUCT_NAME = AltTab`,
`PRODUCT_BUNDLE_IDENTIFIER = com.lwouis.alt-tab-macos`.
- `Info.plist:73-78` — `AppCenterSecret`, `Domain`, `ApiDomain` substituted from xcconfig.
- `alt_tab_macos.entitlements` — app-sandbox **off**, library validation disabled. No
app-group / keychain-access-group is declared here (per AGENTS.md the keychain items are
tied to the code signature / bundle id, so the bundle id is load-bearing for stored
license keys — relevant only if you keep licensing, which a free build would not).
---
## 6. Fork vs upstream — git characterization
### 6.1 Commands run and what they showed
```
git log --oneline -40 # last 40 commits: ALL authored by lwouis / semantic-release-bot
git log --oneline -- src/pro # only 3 commits ever touch src/pro
git log --oneline --diff-filter=A -- src/pro | tail -5 # src/pro first appears in 9147a4a8
git log -40 --format='%an' | sort | uniq -c # 34 lwouis, 6 semantic-release-bot
git rev-list --count HEAD # 1850 commits total
git remote -v # origin = github.com:KofTwentyTwo/CommandTabFree.git
```
Findings:
- **The paywall was born in one commit, upstream.** `src/pro/` has only ever been touched
by three commits, and it was **added** in `9147a4a8 "feat: introducing alt-tab pro!"`,
authored by `lwouis <lwouis@gmail.com>` on **2026-04-05**, shipped as **v11.0.0**
(`1f2b5a51 "chore(release): 11.0.0 [skip ci]"`). The commit immediately before the pro
era (`317a485b`) is v10.12.0. Confirmed `src/pro/` did not exist before 9147a4a8
(`git log 9147a4a8~1 -- src/pro` is empty).
- **This is not a diverged fork.** The author of every recent commit — including bug
fixes after the pro launch (`f3fe535a`, `cddc89dd`, `e0b1778d`, etc.) — is `lwouis`.
There are **zero commits by KofTwentyTwo / the current owner**. The "alt-tab-free" repo
is a mirror of upstream `lwouis/alt-tab-macos` re-hosted under a new origin
(`git@github.com:KofTwentyTwo/CommandTabFree.git`), with the codebase otherwise unchanged.
The README still markets it as "AltTab Pro" and links `https://alt-tab.app/`
(`README.md:1`).
### 6.2 What `9147a4a8` actually changed
It was a **1,869-file** commit — far more than just adding `src/pro/`. It simultaneously
introduced the paywall *and* performed a large repo-wide reorganization (many files were
moved into new folder structures: `macos/api-wrappers/`, `switcher/main-window/`,
`switcher/state/`, `kit/`, `preferences/settings-window/tabs/...`, etc.). So the v11.0.0
boundary is both "Pro launches" and "big refactor," which makes a literal
`git revert 9147a4a8` impractical.
**Files added under `src/pro/` in that commit (the paywall proper):**
- `src/pro/license/` — `Clock.swift`, `Keychain.swift`, `LicenseAPI.swift`,
`LicenseCookie.swift`, `LicenseManager.swift`, `LicenseState.swift`,
`MachineFingerprint.swift`, `RemoteLicenseClient.swift`.
- `src/pro/scheduling/` — `ProTransitionManager.swift`, `ProTransitionScheduler.swift`,
`ProTransitionState.swift`, and the trial-nag windows/popovers
`Day1WelcomeLetterWindow`, `Day4TourPopover`, `Day12HeadsUpPopover`,
`Day15FullUpgradeWindow`, `Day15HardGatePopover`, `Day15ProactiveWindow`,
`Day21ReminderPopover`, `Day35FinalWindow`.
- `src/pro/ui/` — `ProBadgeView`, `ProGradientButton`, `ProPromptHeader`,
`ProPromptHost`, `ProPromptPopover`, `ProPromptWindow`, `UsageStatHeroView`.
- `src/pro/` root — `ProConversionCopy.swift`, `ProFeature.swift`, `ProFeatureCopy.swift`.
(Subsequent commit `60cca89b` added co-located `*Specs.md` / `*Tests.swift` under
`src/pro`, the only other src/pro churn.)
### 6.3 Upstream vs fork-added top-level areas
Because this repo *is* upstream-as-of-v11.x, the right framing is **upstream AltTab core
vs the v11.0.0 paywall graft**, not "fork-added" (there is no fork-added code):
- **Upstream AltTab core (keep):** `src/switcher/`, `src/events/`, `src/macos/`,
`src/preferences/` (minus pro-gating hooks), `src/kit/`, `src/util/`,
`src/experimentations/`, `src/secondary-windows/` (feedback/debug), `App.swift`,
`main.swift`, `MainMenu.swift`, `Menubar.swift`, and the `vendor/Sparkle` +
`vendor/ShortcutRecorder` deps. These predate and are independent of the paywall.
- **Paywall graft (remove):** the entire `src/pro/` tree, plus the call sites that hook it
into the core — e.g. `App.swift:428-429` (`ProTransitionManager` wiring), `:448-459`
(`LicenseManager` callbacks, `syncLicenseCookie`), `Menubar.swift:46/48/49` ("Get Pro",
"My Account", "Support this project"), `App.swift:111-120`
(`supportProject`/`upgradeToPro`/`openAccount` selectors), `UpgradeTab`, and the
`src/pro/license/` network/keychain/fingerprint machinery (`src/api/Endpoints.swift`
license/account/checkout URLs, `LicenseCookie`). These are detailed in the
paywall-specific audit sections; this section flags them only as the fork/upstream
boundary.
- **Telemetry/diagnostics (decide):** `vendor/AppCenter` + `src/vendors/AppCenterCrashes.swift`
(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` |
| Website domain | `alt-tab.app` | `config/base.xcconfig:20` (`DOMAIN`), `Info.plist:75-76` |
| API domain | `alt-tab.app/api` | `config/base.xcconfig:21` (`API_DOMAIN`), `Info.plist:77-78` |
| Website link | `https://alt-tab.app` | `Endpoints.website` `src/api/Endpoints.swift:6`; "Website" link `AboutTab.swift:18` |
| Support/donate URL | `alt-tab.app/support` | `Endpoints.supportUrl` `Endpoints.swift:8`; opened by `App.supportProject()` `App.swift:111-112`; menubar "Support this project" `Menubar.swift:49` |
| Checkout/pricing URL | `alt-tab.app/pricing` | `Endpoints.checkoutUrl` `Endpoints.swift:9` (paywall) |
| 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) |
| Feedback URL | `https://alt-tab.app/api/v1/feedback` | `Endpoints.feedbackUrl` `Endpoints.swift:12` |
| Appcast URL | `https://alt-tab.app/appcast.xml` | `Endpoints.appcastUrl` `Endpoints.swift:7`; `SparkleDelegate.swift:27` |
| Sparkle signing key | `SUPublicEDKey = 2e9SQOBoaKElchSa/4QDli/nvYkyuDNfynfzBF6vJK4=` | `Info.plist:62` (the upstream EdDSA key; cannot sign updates without lwouis's private key) |
| AppCenter app secret | `$(APPCENTER_SECRET)` -> lwouis's AppCenter app | `Info.plist:73-74`, `config/*.xcconfig`, `Secrets.swift:6` |
| Menubar upsell text | "Get Pro" | `Menubar.swift:46`, `:380` |
| Crash dialog copy | "AltTab crashed last time you used it…" | `AppCenterCrashes.swift:61-62` |
| Copyright | "GPL-3.0 licence" | `Info.plist:20-21` (license — keep, it's correct) |
| README marketing | "AltTab Pro — … — Get AltTab", links `https://alt-tab.app/` | `README.md:1` |
Code identifiers named `AltTab*` (e.g. `AltTabKey`, image asset names) and references to
`lwouis/alt-tab-macos` GitHub **issue numbers** inside code comments
(`src/macos/api-wrappers/MissionControl.swift:32`, `src/switcher/state/Applications.swift:109-110`,
`src/switcher/state/WindowThumbnails.swift:35`, etc.) are benign provenance/attribution and
can stay; they don't gate behavior. The `changelog.md` is entirely upstream-linked but is a
doc artifact, not shipped behavior.
**Keychain/identity caveat (from AGENTS.md):** the bundle id `com.lwouis.alt-tab-macos`
is coupled to Developer ID / TeamID and to keychain items used by the license code.
For a free build that *removes* licensing, the keychain coupling stops mattering (no
stored license keys to orphan), so rebranding the bundle id to a KofTwentyTwo identifier
is safe and recommended. If any licensing were retained, a keychain migration would be
required first.
---
## 8. Rebase-on-upstream vs clean-this-fork
Because the audit shows this repo is **upstream code re-hosted, with the paywall authored
upstream**, the owner has a real choice:
**Option A — Rebase the free build on a pre-Pro upstream tag.**
Upstream `lwouis/alt-tab-macos` is alive and GPL-3.0. v10.12.0 (`317a485b`) is the last
release **before** the Pro paywall (`9147a4a8` / v11.0.0). A free build could branch from
the pre-11.0.0 state and then cherry-pick only the *non-paywall* bug fixes that landed in
the 11.x line. Pros: no paywall code to extract, no `src/pro/` entanglement, no license
cookie/fingerprint surfaces, no need to reverse the 1,869-file Pro reorg. Cons: loses any
genuinely useful non-paywall improvements made *after* v11.0.0 unless individually
cherry-picked; the v11 refactor is intermixed with the paywall, so picking later fixes
that depend on the new folder layout is harder.
**Option B — Clean this snapshot (remove `src/pro/` + hooks in place).**
Keep the current v11.x codebase (with its newer fixes/refactor) and surgically remove the
paywall: delete `src/pro/`, the App.swift/Menubar/UpgradeTab hooks, the license/account/
checkout endpoints, the license cookie, and decide on AppCenter. Pros: keeps the latest
window-switching improvements. Cons: more invasive, must verify the build still links and
that nothing in the core silently depends on `LicenseManager`/`ProTransitionManager`
(the App.swift wiring at `:428-459` is the main coupling to untangle).
GPL-3.0 permits either path (the fork must stay GPL-3.0 and preserve `LICENCE.md` and the
`NSHumanReadableCopyright` notice). Given that the goal is "all features unlocked," and the
paywall gates **existing** AltTab features rather than adding new ones, **both options yield
the same feature set**; the decision is really "do we want the post-v11 fixes badly enough
to do the harder in-place removal (B), or do we prefer the clean slate of pre-paywall
upstream (A)?" A pragmatic hybrid is to start from this snapshot (B) for the newer fixes
but use upstream's v10.12.0 as a reference for what the core looked like before the graft.
Either way, the telemetry/branding cleanup in §2, §4, §5, and §7 is identical and must be
done on top of whichever base is chosen — most importantly: drop the license-tier cookie,
repoint or remove the appcast + Sparkle signing key, decide AppCenter's fate, and rebrand
the bundle id / name / URLs off the `lwouis` / `alt-tab.app` identity.
-157
View File
@@ -1,157 +0,0 @@
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
### Tests (run locally before merging)
The Gitea CI (`.gitea/workflows/build.yaml`) runs Guards B and C (the de-paywall markers and conflict markers), builds the `Release` scheme unsigned and compiles the `Test` scheme (`build-for-testing`), but does not run the tests: its macOS runner has no GUI session, so `xcodebuild test` cannot reach `testmanagerd`. Run them, including the anti-relock tests in `src/pro/license/LicenseManagerTests.swift` (Guard A, `docs/PLAN-maintained-fork.md` §4.4), locally before merging, especially an upstream sync:
* `scripts/run_tests.sh` (the `Test` scheme, `-configuration Release`)
## 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
* Display > Resolution > Scaled: changes DPI and rescale AltTab
* 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
* Sudden Termination
-7
View File
@@ -1,7 +0,0 @@
## [Developed the app](https://github.com/lwouis/alt-tab-macos/graphs/contributors)
[adamnemecek](https://github.com/adamnemecek), [AfzalivE](https://github.com/AfzalivE), [akx](https://github.com/akx), [Allsochen](https://github.com/Allsochen), [ayroblu](https://github.com/ayroblu), [britalmeida](https://github.com/britalmeida), [Calinou](https://github.com/Calinou), [damonpam](https://github.com/damonpam), [decodism](https://github.com/decodism), [delaneyb](https://github.com/delaneyb), [gingerr](https://github.com/gingerr), [GrzegorzKazana](https://github.com/GrzegorzKazana), [hughlilly](https://github.com/hughlilly), [karbassi](https://github.com/karbassi), [L1cardo](https://github.com/L1cardo), [lwouis](https://github.com/lwouis), [metacodes](https://github.com/metacodes), [mnin](https://github.com/mnin), [MrPaschenko](https://github.com/MrPaschenko), [nella17](https://github.com/nella17), [notlmn](https://github.com/notlmn), [phungtuanhoang1996](https://github.com/phungtuanhoang1996), [rbnis](https://github.com/rbnis), [Rjevski](https://github.com/Rjevski), [ShlomoCode](https://github.com/ShlomoCode), [skolj](https://github.com/skolj), [stopdesign](https://github.com/stopdesign), [xanathar](https://github.com/xanathar), [zacharee](https://github.com/zacharee)
## Localized the app
73, A, a774 nn, Aamirsuleman, Aarni Koskela, Abdulelah, Abdur-Rahman Bilal, Aden Aziz, Admin, Aindriú Mac Giolla Eoin, Ajaykumarmkaju, Aku, Albert Abdilim, Aleksander Bang-Larsen, Alex, Alexander Kilian, Alexey Eremeev, Ali, Ali Gokmen, Ali. tas103, Allen Guan, Alonso Chen, Ameng, Anders, Andreas Wagner, Andrej Koman, Andrew Vader, antoon334, Anurag Roy, anushree b, Arman, Artem Stankov, Arthur, Ash, blanorama, bluefirex, BO the Man, bovirus, Bufo, BV Joshi, caduellery, Caner İlhan, catcore, changanmoon, Christian Keilmann, Chun Fei Lung, codecantata, codingalone, Cong Tri, Dan, Dan84, Darius Rosendahl, Darko, David R, Denis, Dékány Martin, dibas-np, Didier Deschrijver, Dzhuneyt, edu_sombra, EDUARDO, Eimantas, Eliezer Shpigelman, Emir H. KAYNAK, EP45, Eric WANTZ, Ersagun Kuruca, EUKA, Eukarya, Eyelly wu, fil, Filipe, Frangarciasalomon, Frank, Friendship1, Fukuda, Ganesh Sawant, Gezimos, Giacomo Volpi, Giang, Github, Gkostov, Grzegorz Kazana, Guillaume, Gumpanath Puttarassu, hann-solo, Haoshuai Xu, Hao豪, Heramb Lonkar, Hjörtur Hjartarson, Hokuto Kato, Huandngoc, Huynh Sang, Ialiendeg, Ida Bomholt Dyrholm Jacobsen, Igor Aradski, igorpocta, Indexerrowaty, Inês Almeida, isametry, Ismatulla, Isthereanybody, Jaeyong Sung, Jakob, Jakob Licina, Jakub Bartušek, Jesus Angel Villafuerte, jiho4, Jisan, Joao Vitor Casarin, Jord Nijhuis, Jui Yuan Liu, Jules Beckers, Julian Nowaczyk, Julio, justcontributor, Kagurazaka Tsuki, kal, kant, Kawarimidoll+git, kazuki sakamoto, Kevinsevinche, KillianG, Kinyagulovrr, Klara, Kostiantyn Ko, Kristófer Fannar Björnsson, Kushnee5, Kuuchi, Lakshman Kolappan, Lars, Lasse Mattila, Lcwhhh, Leo, Lester, Logan Boissy, Loic. peron, Loïc, LostInCompilation, Lumaxis, lwouis, M, Malo, Maplevantablack, Marc Pla, Marcus Billman, Marekscholle, Marko McLion, Martin Mitka, Martin. mitka, Masayoshi Motojima, Masih, Matheus, Max, Maximilian Falk, MaximilianFreitag, Mehmet Ali Ertorer, Meriç Bağlayan, Michael, Milos M, Mohammad Al Zouabi, mohma, Mohsin Rahman, Mr. axel. bock, mrtbts, MuDraconis, Muzhenstudent, Mwolfinspace, Nathancodes, NIKHIL S, Nikola Rajić, Nils Fahldieck, Nilton Souza, Nmolham, Nuriddin Islamov, Ori, Paul Pichaureau, Paul Przybyszewski, Paul. pichaureau, Pehovorka, Pendolino, Petar Shomov, Peterkim0620, Petr Kolář, Petter Johan Olsen, Piotr Budny, Quentin. douarre, Rajesh Kaswala, rajuneon, Rami Elsawy, Raphaël, Rasmus, Raymonf, raz, Razvan, rbnis, rei fujise, Roccobot, Roccobot, Rodrigo Schneider, Ron Nuss, rPaasch, Ryan Nardi, sa-ha, Sai Khant Zay Lynn Yaung, Sami Suleiman, Samimkel00, Saransh, sawtooth, Sebastian, Selcuk Dursun, Sergey, Serhii Alekseev, Seyedparsa Mirtaheri, Shameem Reza, SheNeVmerla, Shivam Bansal, shlomo, Sippawit Thammawiset, Sjur Moshagen, Sjur.n. moshagen, Stan Smits, star-affinity, Stefan, Stefan Lange, Svetoslav Stefanov, Tamilselvan0x0, Thebennue, Thomas Wölk, Thorsten, Thorsten, Tomoa Nozawa, Trevor, Tsilimpotis Dimitrios, Umutakkaya1996, Vadym, Vijayant Saini, Vincent Orback, Vlad, Webmaster, Wesley Matos, Whatsmine-HaoshuaiXu, White Vogel, Wilhelm Wolfgang Gärtner, Yongsung Yoon, Yorben, Yossi Zahn, ysaito, Yukai, yumechan, Yusuf Caliskan, Zamaialexander, Zuhaib Syed, Τσιλιμπότης Δημ., Андрій Кирбаба, Ивайло, Пустой, 林清, 橙梓
Binary file not shown.

Before

Width:  |  Height:  |  Size: 507 KiB

-13
View File
@@ -1,13 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="298" height="64" fill="none" viewBox="0 0 298 64">
<defs>
<linearGradient id="a" x1=".850001" x2="62.62" y1="62.72" y2="1.81" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9419"/>
<stop offset=".43" stop-color="#FF021D"/>
<stop offset=".99" stop-color="#E600FF"/>
</linearGradient>
</defs>
<path fill="#000" d="M86.4844 40.5858c0 .8464-.1792 1.5933-.5377 2.2505-.3585.6573-.8564 1.1651-1.5137 1.5236-.6572.3585-1.3941.5378-2.2406.5378H78v6.1044h5.0787c1.912 0 3.6248-.4282 5.1484-1.2846 1.5236-.8564 2.7186-2.0415 3.585-3.5452.8663-1.5037 1.3045-3.1966 1.3045-5.0886V21.0178h-6.6322v19.568Zm17.8556-1.8224h13.891v-5.6065H104.34v-6.3633h15.355v-5.7758H97.8766v29.9743h22.2464v-5.7757H104.34v-6.453Zm17.865-11.8005h8.882v24.0193h6.633V26.9629h8.842v-5.9451h-24.367v5.9551l.01-.01Zm47.022 9.0022c-.517-.2788-1.085-.4879-1.673-.6472.449-.1295.877-.2888 1.275-.488 1.096-.5676 1.962-1.3643 2.579-2.39.618-1.0257.936-2.2007.936-3.5351 0-1.5237-.418-2.8879-1.244-4.0929-.827-1.195-1.992-2.131-3.486-2.8082-1.494-.6672-3.206-1.0058-5.118-1.0058h-13.315v29.9743h13.574c2.011 0 3.804-.3485 5.387-1.0556 1.573-.707 2.798-1.6829 3.675-2.9476.866-1.2547 1.304-2.6887 1.304-4.302 0-1.4837-.338-2.8082-1.026-3.9833-.687-1.175-1.633-2.0812-2.858-2.7285l-.01.0099Zm-13.603-9.9184h5.886c.816 0 1.533.1494 2.161.4382.627.2888 1.115.707 1.464 1.2547.348.5378.527 1.1751.527 1.9021 0 .7269-.179 1.414-.527 1.9817-.349.5676-.837.9958-1.464 1.3045-.628.3087-1.345.4581-2.161.4581h-5.886v-7.3492.0099Zm10.138 18.134c-.378.5676-.916 1.0058-1.603 1.3145-.697.3087-1.484.4581-2.39.4581h-6.145v-7.6878h6.145c.886 0 1.673.1693 2.37.4979.687.3286 1.235.7867 1.613 1.3842.378.5975.578 1.2747.578 2.0414 0 .7668-.19 1.4241-.568 1.9917Zm29.596-5.3077c1.663-.7967 2.947-1.922 3.864-3.3659.916-1.444 1.374-3.117 1.374-5.0289 0-1.912-.448-3.5253-1.344-4.9592-.897-1.434-2.171-2.5394-3.814-3.3261-1.644-.7867-3.546-1.1751-5.717-1.1751h-13.124v29.9743h6.642V40.0779h4.322l6.084 10.9142h7.578l-6.851-11.7208c.339-.1195.677-.249.996-.3983h-.01Zm-2.151-6.1244c-.369.6274-.896 1.1154-1.583 1.444-.688.3386-1.494.5079-2.42.5079h-5.975v-8.2953h5.975c.926 0 1.732.1693 2.42.4979.687.3287 1.214.8166 1.583 1.434.368.6174.558 1.3544.558 2.1908 0 .8365-.19 1.5734-.558 2.2008v.0199Zm20.594-11.7308-10.706 29.9743h6.742l2.121-6.6122h11.114l2.27 6.6122h6.612L220.99 21.0178h-7.189Zm-.339 18.3431 3.445-10.5756.409-1.922.408 1.922 3.685 10.5756h-7.947Zm20.693 11.6312h6.851V21.0178h-6.851v29.9743Zm31.02-9.6993-12.896-20.275h-6.463v29.9743h6.055V30.7172l12.826 20.2749h6.533V21.0178h-6.055v20.275Zm31.528-3.3559c-.647-1.2448-1.564-2.2904-2.729-3.1369-1.165-.8464-2.509-1.4041-4.023-1.6929l-5.098-1.0456c-.797-.1892-1.434-.5178-1.902-.9958-.469-.478-.708-1.0755-.708-1.7825 0-.6473.17-1.205.518-1.683.339-.478.827-.8464 1.444-1.1153.618-.2689 1.335-.3983 2.151-.3983.817 0 1.554.1394 2.181.4182.627.2788 1.115.6672 1.464 1.1751s.528 1.0755.528 1.7228h6.642c-.04-1.7427-.528-3.2863-1.444-4.6207-.916-1.3443-2.201-2.3899-3.834-3.1468-1.633-.7568-3.505-1.1352-5.597-1.1352-2.091 0-3.943.3884-5.566 1.1751-1.623.7867-2.898 1.8721-3.804 3.2663-.906 1.3941-1.364 2.9775-1.364 4.76 0 1.444.288 2.7485.876 3.9036.587 1.1652 1.414 2.1311 2.479 2.8979 1.076.7668 2.311 1.3045 3.725 1.6033l5.397 1.1153c.886.2091 1.584.5975 2.101 1.1551.518.5577.767 1.2448.767 2.0813 0 .6672-.189 1.2747-.567 1.8025-.379.5277-.907.936-1.584 1.2248-.677.2888-1.474.4282-2.39.4282-.916 0-1.782-.1593-2.529-.478-.747-.3186-1.325-.7767-1.733-1.3742-.418-.5875-.617-1.2747-.617-2.0414h-6.642c.029 1.8721.527 3.5152 1.513 4.9492.976 1.424 2.32 2.5394 4.033 3.336 1.713.7967 3.675 1.195 5.886 1.195 2.21 0 4.202-.4083 5.915-1.2249 1.723-.8165 3.057-1.9418 4.023-3.3758.966-1.434 1.444-3.0572 1.444-4.8696 0-1.4838-.329-2.848-.976-4.1028l.02.01Z"/>
<path fill="url(#a)" d="M20.34 3.66 3.66 20.34C1.32 22.68 0 25.86 0 29.18V59c0 2.76 2.24 5 5 5h29.82c3.32 0 6.49-1.32 8.84-3.66l16.68-16.68c2.34-2.34 3.66-5.52 3.66-8.84V5c0-2.76-2.24-5-5-5H29.18c-3.32 0-6.49 1.32-8.84 3.66Z"/>
<path fill="#000" d="M48 16H8v40h40V16Z"/>
<path fill="#fff" d="M30 47H13v4h17v-4Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

-178
View File
@@ -1,178 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
AUTOGENERATED by scripts/build_readme_svg.py — do not edit by hand.
Stat values are refreshed by scripts/update_readme_and_website.sh.
-->
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="1040" viewBox="0 0 900 1040">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#13132a"/>
<stop offset="40%" stop-color="#0f0f24"/>
<stop offset="100%" stop-color="#0a0a1a"/>
</linearGradient>
<radialGradient id="glow1" cx="50%" cy="6%" r="40%">
<stop offset="0%" stop-color="#4488ff" stop-opacity="0.10"/>
<stop offset="100%" stop-color="#0a0a1a" stop-opacity="0"/>
</radialGradient>
<radialGradient id="glow2" cx="50%" cy="40%" r="30%">
<stop offset="0%" stop-color="#8F47EB" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0a0a1a" stop-opacity="0"/>
</radialGradient>
<linearGradient id="proGrad"
x1="0" y1="48.541" x2="35.267" y2="0"
gradientUnits="userSpaceOnUse"
spreadMethod="repeat">
<stop offset="0" stop-color="#FF4488"/>
<stop offset="0.333" stop-color="#4488ff"/>
<stop offset="0.667" stop-color="#66CCFF"/>
<stop offset="1" stop-color="#FF4488"/>
<animateTransform attributeName="gradientTransform" type="translate"
values="0,0; -102.107,0" dur="8s" repeatCount="indefinite"/>
</linearGradient>
<linearGradient id="ctaGrad"
x1="0" y1="121.353" x2="88.167" y2="0"
gradientUnits="userSpaceOnUse"
spreadMethod="repeat">
<stop offset="0" stop-color="#FF4488"/>
<stop offset="0.333" stop-color="#4488ff"/>
<stop offset="0.667" stop-color="#66CCFF"/>
<stop offset="1" stop-color="#FF4488"/>
<animateTransform attributeName="gradientTransform" type="translate"
values="0,0; -255.267,0" dur="14s" repeatCount="indefinite"/>
</linearGradient>
<filter id="ctaShadow" x="-10%" y="-30%" width="120%" height="160%">
<feDropShadow dx="0" dy="1" stdDeviation="1" flood-color="#000000" flood-opacity="0.25"/>
</filter>
<linearGradient id="iconCardA" x1="50%" x2="50%" y1="0%" y2="100%">
<stop stop-color="#d619ac"/>
<stop offset="1" stop-color="#d0224c"/>
</linearGradient>
<linearGradient id="iconCardB" x1="50%" x2="50%" y1="0%" y2="100%">
<stop stop-color="#1ddfdf"/>
<stop offset="1" stop-color="#1d8cdc"/>
</linearGradient>
<linearGradient id="iconCardC" x1="50%" x2="50%" y1="0%" y2="100%">
<stop stop-color="#161386"/>
<stop offset="1" stop-color="#0a093d"/>
</linearGradient>
<clipPath id="screenshotClip">
<rect x="40" y="455" width="820" height="461" rx="14"/>
</clipPath>
</defs>
<rect width="900" height="1040" fill="url(#bg)"/>
<rect width="900" height="1040" fill="url(#glow1)"/>
<rect width="900" height="1040" fill="url(#glow2)"/>
<g fill="#ffffff">
<circle cx="60" cy="40" r="0.9" opacity="0.55"/>
<circle cx="135" cy="80" r="0.6" opacity="0.35"/>
<circle cx="200" cy="30" r="1.1" opacity="0.45"/>
<circle cx="42" cy="160" r="0.5" opacity="0.3"/>
<circle cx="170" cy="180" r="1" opacity="0.5"/>
<circle cx="50" cy="280" r="0.6" opacity="0.4"/>
<circle cx="120" cy="340" r="0.7" opacity="0.45"/>
<circle cx="40" cy="430" r="0.8" opacity="0.4"/>
<circle cx="160" cy="380" r="0.5" opacity="0.3"/>
<circle cx="80" cy="430" r="0.9" opacity="0.5"/>
<circle cx="700" cy="35" r="0.6" opacity="0.4"/>
<circle cx="780" cy="80" r="1" opacity="0.5"/>
<circle cx="840" cy="40" r="0.7" opacity="0.45"/>
<circle cx="860" cy="170" r="0.6" opacity="0.35"/>
<circle cx="730" cy="220" r="0.9" opacity="0.5"/>
<circle cx="820" cy="320" r="0.5" opacity="0.3"/>
<circle cx="870" cy="430" r="0.7" opacity="0.45"/>
<circle cx="730" cy="380" r="0.5" opacity="0.3"/>
<circle cx="820" cy="430" r="0.6" opacity="0.35"/>
<circle cx="450" cy="22" r="0.5" opacity="0.3"/>
<circle cx="60" cy="940" r="0.8" opacity="0.45"/>
<circle cx="120" cy="1000" r="0.5" opacity="0.3"/>
<circle cx="180" cy="980" r="0.7" opacity="0.4"/>
<circle cx="780" cy="930" r="0.6" opacity="0.4"/>
<circle cx="840" cy="1000" r="0.7" opacity="0.45"/>
<circle cx="730" cy="980" r="0.5" opacity="0.3"/>
<circle cx="40" cy="1010" r="0.6" opacity="0.35"/>
<circle cx="860" cy="940" r="0.5" opacity="0.3"/>
</g>
<g transform="translate(166 50)">
<svg width="144" height="144" viewBox="0 0 55 55">
<g><rect width="43.171" height="36.695" x="1.843" y="15.476" fill="url(#iconCardA)" rx="1.125" transform="rotate(-9 1.843 15.476)"/><rect width="43.171" height="36.695" x="5.771" y="9.315" fill="url(#iconCardB)" rx="1.125" transform="rotate(-9 5.771 9.315)"/><path fill="url(#iconCardC)" d="M6.481 13.787 43.49 7.926a1.125 1.125 0 0 1 1.287.935l4.856 30.658-37.009 5.862a1.125 1.125 0 0 1-1.287-.935z"/><path fill="#fff" fill-rule="evenodd" d="m43.728 21.934-9.955 8.01-.639-4.033-6.662 1.055-.714-4.51L32.42 21.4l-.635-4.009z" clip-rule="evenodd"/><path fill="#fff" fill-rule="evenodd" d="m12.892 34.939 11.943 4.542-.639-4.033 6.663-1.055-.714-4.51-6.663 1.055-.635-4.01z" clip-rule="evenodd"/><circle cx="12.121" cy="18.181" r="1.923" fill="#fff" transform="rotate(-9 12.121 18.18)"/><circle cx="17.55" cy="17.322" r="1.923" fill="#fff" transform="rotate(-9 17.55 17.322)"/><circle cx="23.036" cy="16.452" r="1.923" fill="#fff" transform="rotate(-9 23.036 16.452)"/></g>
</svg>
</g>
<text x="330" y="120" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', Helvetica, Arial, sans-serif" font-weight="700" font-size="56" fill="#ffffff" letter-spacing="-1.5">AltTab <tspan fill="url(#proGrad)">Pro</tspan></text>
<text x="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>
<g transform="translate(170 220)">
<g transform="translate(80 0)" opacity="0.85">
<svg width="140" height="140" viewBox="0 0 120 120">
<g fill="none" stroke="#7EB6FF" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 16v10 M18 22l4 4 4-4" stroke-width="2" opacity=".6"/>
<path d="M45 8v8 M42 13l3 3 3-3" stroke-width="1.5" opacity=".35"/>
<path d="M78 10v8 M75 15l3 3 3-3" stroke-width="1.5" opacity=".4"/>
<path d="M100 18v10 M96 24l4 4 4-4" stroke-width="2" opacity=".55"/>
<path d="M12 52v8 M9 57l3 3 3-3" stroke-width="1.5" opacity=".3"/>
<path d="M60 30v14 M54 39l6 5 6-5" stroke-width="2.5" opacity=".7"/>
<path d="M108 50v8 M105 55l3 3 3-3" stroke-width="1.5" opacity=".3"/>
<path d="M30 86v10 M26 92l4 4 4-4" stroke-width="2" opacity=".55"/>
<path d="M58 96v8 M55 101l3 3 3-3" stroke-width="1.5" opacity=".45"/>
<path d="M90 88v10 M86 94l4 4 4-4" stroke-width="2" opacity=".5"/>
</g>
<g fill="#7EB6FF">
<circle cx="38" cy="52" r="1" opacity=".3"/>
<circle cx="82" cy="48" r="1" opacity=".3"/>
<circle cx="50" cy="72" r=".8" opacity=".25"/>
<circle cx="72" cy="78" r=".8" opacity=".25"/>
</g>
</svg>
</g>
<text x="150" y="78" text-anchor="middle" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', Helvetica, Arial, sans-serif" font-weight="700" font-size="42" fill="#ffffff" letter-spacing="-0.5"><!--downloads-->7.4M</text>
<text x="150" y="115" text-anchor="middle" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', Helvetica, Arial, sans-serif" font-weight="500" font-size="14" fill="#8888a8" letter-spacing="2.4">DOWNLOADS</text>
</g>
<g transform="translate(450 220)">
<g transform="translate(80 0)" opacity="0.85">
<svg width="140" height="140" viewBox="0 0 120 120">
<g fill="#F5D97E">
<path d="m22 28 2-6 2 6 6 2-6 2-2 6-2-6-6-2Z" opacity=".7"/>
<path d="m90 20 1.5-5 1.5 5 5 1.5-5 1.5-1.5 5-1.5-5-5-1.5Z" opacity=".5"/>
<path d="m15 70 1.5-5 1.5 5 5 1.5-5 1.5-1.5 5-1.5-5-5-1.5Z" opacity=".45"/>
<path d="m98 72 2-6 2 6 6 2-6 2-2 6-2-6-6-2Z" opacity=".6"/>
<path d="m50 10 1-3 1 3 3 1-3 1-1 3-1-3-3-1Z" opacity=".35"/>
<path d="m70 8 1-3 1 3 3 1-3 1-1 3-1-3-3-1Zm-40 92 1.5-5 1.5 5 5 1.5-5 1.5-1.5 5-1.5-5-5-1.5Z" opacity=".45"/>
<path d="m85 98 1.5-5 1.5 5 5 1.5-5 1.5-1.5 5-1.5-5-5-1.5Z" opacity=".5"/>
<circle cx="40" cy="18" r="1" opacity=".3"/>
<circle cx="80" cy="15" r=".8" opacity=".25"/>
<circle cx="12" cy="50" r=".8" opacity=".25"/>
<circle cx="108" cy="50" r="1" opacity=".3"/>
<circle cx="60" cy="105" r=".8" opacity=".25"/>
</g>
</svg>
</g>
<text x="150" y="78" text-anchor="middle" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', Helvetica, Arial, sans-serif" font-weight="700" font-size="42" fill="#ffffff" letter-spacing="-0.5"><!--stars-->15K</text>
<g transform="translate(150 110)">
<g transform="translate(-79 -7) scale(0.143)" opacity="0.7">
<path fill="#8888a8" fill-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a47 47 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0" clip-rule="evenodd"/>
</g>
<text x="-58" y="5" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', Helvetica, Arial, sans-serif" font-weight="500" font-size="14" fill="#8888a8" letter-spacing="2.4">GITHUB STARS</text>
</g>
</g>
<g transform="translate(372 365)">
<rect width="156" height="56" rx="28" fill="url(#ctaGrad)"/>
<text x="78" y="36" text-anchor="middle" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', Helvetica, Arial, sans-serif" font-weight="700" font-size="22" fill="#ffffff" filter="url(#ctaShadow)">Get AltTab</text>
</g>
<image href="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAQDAwQDAwQEBAQFBQQFBwsHBwYGBw4KCggLEA4RERAOEA8SFBoWEhMYEw8QFh8XGBsbHR0dERYgIh8cIhocHRz/2wBDAQUFBQcGBw0HBw0cEhASHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBz/wAARCAP1BwgDASIAAhEBAxEB/8QAHQAAAQQDAQEAAAAAAAAAAAAAAAIDBAUBBgcICf/EAGkQAAEDAwICBQYGCgoNCQcCBwECAwQABRESIQYxBxNBUWEUIjJxgZEVQlKhsdEIFiMzU2JykpPBFyQ2Q1RzgpSy0hg0RFVWV2N0g6Kz4fAlJjU3RWR1lfEnRoSjtMLTCRnD4kdlhaQo/8QAGwEAAwEBAQEBAAAAAAAAAAAAAAECAwQFBgf/xABEEQACAgEEAAMEBQkGBQQDAQAAAQIRAwQSITETQVEFFCIyFWFxgdEjM3KRoaKxwfA0QlJT0uE1Q1RjwgYkRGIWJfGC/9oADAMBAAIRAxEAPwD2RRRRXzHZ6oVV8NcSWni62quVlmtzbeHnGBIazoWttWlek/GAUCMjY4rxh9l5048c8E9JjFisV4k2q3xYTLqUR9P3dS9eVK1JOR5oAHLY1tP2DnG1y4ni8QxrtxU6+m3IYbh2VSG20MNY3cQkJGBkacJ2GMkbjHU9M44/EbMfG+PbR6+orHjWO/5vXXMbWKzWM15U6NpXDtzjWnibiLjS7wekZ7iJ2FLiR7k48sPeULQIZh6lJ6rqwkE6AQnzsjGz/AHFXFXCDln6qZbZnD944o4giKtfkig+FoXKeSsPa9yVNYxp5Kxud63eFeRn4h6lzRXlbg7pz47vPDVyuL8jh3rZHDyrxGXNkRo7cV/WEpSUIkKX1B1FOp3QoKbwdjtaq6XOLX+Fl+ST467zGviLfKYkxorMtDRj9boZzIMZ53dKshYwgkadQoWml6oPFT4PSVZ5ZrT+i3iz7eeAbJf/ACsTPLWSTITEVFDikqKSQ2pSindPyiDzBIIrb6wkqbRSdCs1jnXPOleSpDPC0OVIVE4euF6Zj3eSl9TGhnq3FNoLiSClK30soO+4Vp+NWmx+LW+G5/ENosQkwHl3GHHaaLyLlG61xLqlNsanEJZcLLIWpLiwhOtCsEqIVUMVq7Dcd1zQDtXn6P0rcV3jhGTdocu0Q3LbabvOeL0UOpkOwpSmUnKXMJQtKCVaVKxnzVdpsr10j8T8PvXC2LlQJksu2gtSWoqWhHRMU6FIKFuhBOWSlClLTu4kEKI85vDIe47dRWqdHHEFx4h4YEq6tsJmtSpEYqZW2oLS26pKVHq1rQlWB5yQo6VAg45VtoOSazap0NciaK0npjKkdGnEC21JS51KEpKpCmEnLqAQXE7oBBwVDcDfsrUo9wncOC0W6G1w3w87fbuYzsuNcnLm2htEdbox1iW8OqLZSEnbAKsk7VcMe5WS5Uzse3fRXk2TxLcWOju5SIc2H8KfA/GMtu9REHrUlm4ox1KislCF6s7E4wjBGkZ9R2hS12uIpctEtamkqU+hISlwkbqABIwfWac8e1ApbifRQk5NaR0mOOWm32riZt9xpvh2c3MlJDhQ2uIctSCsfGCG3FOgd7Y5VnFW6KZu/OiuHMcSS7db08apiJuD13+FrxEZypxfkzUbEZtrnpC2mkKICTutXM80WvpW4igW6Zc76uCu125i33GTIbDGoxny4l7DTT7pS2nCHErUclOpO5STWnhMndZ3SiuXdHfHHEPEt9XZrubezcbXGdfukeOg5SXnEqhBOTkZY1atjlQ2Ixg9QB51Eo7XTHdmaPbRXnGxcVXaNG4o4ZTdnV3Hiy9XJuwvOLHWRtMtxiUEauaWEN9cAexeNgKcIbhN0ejqK832TpJvlo6Ore9Bu0IosPCdsuiIk5suyryt1K8p1agQVBoISUhR6xe420q2y79Jt+sy79DZiMzLjw3HuF0mshggvxkt64TacKJStZWAVAKB8ndwBkY0eB+oRmdkoxXDYPSDxwq03Bt0W6RLQYDjDyVw233kva+tbaZElTerDepsrWnVrI84oJV03gPiBfE3CkG5uSRJdWXGnHREMbK23FIUOrKlYIUkjZRBxkEgis5Q2q7GpWbLRQDzrTulB12Bwk5eGpPk/wABSWLo6vfdhlwKfSccwWetGO8ioXLG5UbjiivPNin3K48QwbTInzltcc3BjiaKorWOoiMuFTjII9BGluGCnPnF9fjiU1xnxKxYr0qxSrJBjWW03C6hnyErS68ibJSkE9YNKCGjqIBJJyCOVavH9ZDmd8rI2zXAOM+MLney4DerZFhQ+K7HARaw3+2XUrfiulZc1ggnrSQkAp0JOc582e90hcWy3I7lruFndl3eddraxbPJipdvVGQ+WXXVasqGphKVpISMvpwRpBWlhbVpgpUdvorjPDPS1ceJ77AYhqhKtd/fbk2l5DepSoLSCJZUc4JDidII5BxPPG/ZQazcHHsceTNFFcpm3Ti1rjDjlqxWq03GIhuKVmfdXIxaUY5JCUpZcBGMHORueXaXCO60Ozq1Fec+HukjiWFbOGYVrUw5EhWWyvOLlLYJliQAFqW66+lYyElKVJSvK851ejUqwcWXqNMg2CHfbZa48+ZxPLclz2Q+rVHuZS2hOpaRsHVEjfZBxjG2i07I3HoKitc4Gv8AI4n4LsN6ktNNv3GE1IWloko1KTklJO5SeYzvgithB51jJU6LCiiikMTRRRQAUUUUAFFFFABRRRQAUUUUAFFFFNIAooooAKKKKYBRRRQAUUUUEt0FFFFBIUUUUDCiiigAooooAKKKKACiiigTYUUUUCCiiigAooopoAooopgFFFFLsTYUUUUCCiiimAUUUUAFFFFABRRRQDYUUUUIQUUUVSAKKKKXQBRRRQAUUUUCboKKKKBBRRRQAUUUUAFFFFAm6CiiigQUUUUDCiiigAooooAKKKKACiiiglsKKKKCRNFFFUURrhcI1rhuy5bnVsNDKlYJ7cAADckkgADck1V/bdbvwV0Hrtcn/wDHWeLtrLq7pcT/AOparR7zxpwpBusu2cLG2Xziq3PMA2sXbqFAqccxhayUladbh0AE7gHspxTYje4vFNulTGIiVSW35BKWhIiPMhxQBUUgrQATgE4znAPcausVyTgXini7iy2cPyuMeGBYJwuxS0kP6/KW/J3sOdWd2/USc8663mlkW10AYoIwKzXBvspuIuMrZ0fyY3DFrli3vNKXd7wy62hUKKD56UalA61DbIBIB23Ioxw3y22I7hFmMTozMmK62/GeQFtvNqCkOJPakjmPGngc1onRHxBJ4n4EtU9zh02CEWUNwYSntavJkpAbURpTpyNwO7B2zgb2ntqWtraGZoorhfTvx/IsD1tsspcWBZZ9wiRpzlyKQ3NhuOhL+gqBQpAQVpcQrCkpPWDYbuEXKVIDs9ru8K9Q/LLfKakxusW1rbVnStCilaFDsUlQIIO4IqbXKlcQ2Ru6xFdH0q03ObKfaTc40F/rxJbDRCC66gqDLgQ2dDrgIX1fVqUNSVJ6ontqpxoDChVTeb7b+H2EO3CSGg4rS2gJUtbhxnCUJBUdhk4G1Wyq510o8PSrjHRdo5Q41BYUh6OSAtSNQVlOdlZICSg41ZTvtgxCNujPLKSg3DsvXePuG2tB+FmVIUNZWlKlJaTkjLhA+5jII87GMHOMVsiQlaErQoKQoZBB2IrzzC4Y4gXItS1W9tpU15fV6nUpVGktpQFsylEkqdw0rz2wQpIWnlgq73Yrf8D2aDbus63yRlDPWadOvSMZxk4rfPhWJ15mWDNknfiRolgDB32rV+MekThXo+ZjO8UX6DaUyyoR0yXMKe04zpSNyBkZPIZGedbZXhP7Ndjhe5cYtLVxg3E4ig29DDtpeivuJKCVqQUOISUpUoKyQrnhJqMGNZJ7ZG57PVxVYkXuPZF3q3Iu8lvrmYSpKQ+42eSkozkj/gVc6a+abXD1s6RelwmV0mQ4V9lvxFRJibU6G3HA0gN6F6tKVDSjTqOCVAA719KUjIqtRi8Kqd2A1NmMW2HIlynUtRo7anXXFnCUJSMkn2VCY4igSLbIuHWOsRIySp5cphbBbSE6iSFgHGnfNR+OHFtcG38ohpmq8he/a6kFQd8xXmkJ3OeWx7a1O8RLhE6HeLk3Rt7y1dvmqV5Q8XFlHVLCCRqUEnQE5SCQDnckmsIq2Bpp+zE6MM5TJvS0H0VptbuFeIrH9mJ0Yfh73/5W5Xz6h63m2m221LdUEBKE81EgAAeNOOhTLzjK0KS60ooWlXNKgcEHxBBHsr2fo/HV8k7j6BJ+zD6MFOJR197Ge34Ldrtlqu8O922JcrdIbkwJrKX2HmzlLjahlKh6xvXyRYX+2Wvyq+nfQUn/ANi3R/8A+CRP9mK49Vpo4opxGnZv+dq1+18Y2K9SocaBcmn35sZcxhCQQVtIWG1q3G2FqAwd857qvztXMuiXh+A1EF6MARbkoFspGsaElKMp0qUrtQD2blRxlRJ4YQUrbGUkj7KXo0jcQLsy7pNLrclURySILnk6XArQfumMadWRq5dvKuyV8nr7apyp95nJjOqiJmylF3HmgB9YJz3ZFfV9pJDLecegnl+SK7dbovddvDW71Jx5FO6fQ1OmxrXAlT5jyWYkVtTrriuSEpBJJ9QBqHH4gt8m0O3UPluAyhbjjr7am+rSjJUVBQBAwM0cShz7Xbr1SUrc8mc0pUVgKVpOASgFQ9aQT3VzhxKWugri5SWVICrfcVFTiCnrSUL88DJGD3pwk9gHKuOMbNKKJX2X/RkhR/bN5Un4q02t3Ch3jIpP9mD0Y/wi9f8AlbteKolo4YejMl7iB9tfVoylS42x0jI9Kpn2ucKsBp1+9yeqUcgLXHSHADhQBLine truncated
<g transform="translate(372 950)">
<rect width="156" height="56" rx="28" fill="url(#ctaGrad)"/>
<text x="78" y="36" text-anchor="middle" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', Helvetica, Arial, sans-serif" font-weight="700" font-size="22" fill="#ffffff" filter="url(#ctaShadow)">Get AltTab</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

-35
View File
@@ -1,35 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="160" viewBox="0 0 900 160">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0f0f24"/>
<stop offset="100%" stop-color="#0a0a1a"/>
</linearGradient>
<linearGradient id="jbA" x1=".850001" x2="62.62" y1="62.72" y2="1.81" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9419"/>
<stop offset=".43" stop-color="#FF021D"/>
<stop offset=".99" stop-color="#E600FF"/>
</linearGradient>
</defs>
<rect width="900" height="160" fill="url(#bg)"/>
<g fill="#ffffff">
<circle cx="80" cy="40" r="0.6" opacity="0.4"/>
<circle cx="160" cy="120" r="0.7" opacity="0.45"/>
<circle cx="50" cy="90" r="0.5" opacity="0.3"/>
<circle cx="820" cy="40" r="0.6" opacity="0.4"/>
<circle cx="750" cy="120" r="0.7" opacity="0.45"/>
<circle cx="860" cy="100" r="0.5" opacity="0.3"/>
</g>
<text x="450" y="48" text-anchor="middle" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', Helvetica, Arial, sans-serif" font-weight="500" font-size="13" fill="#8888a8" letter-spacing="2.4">SPONSORED BY</text>
<g transform="translate(351 70)">
<svg width="198" height="42" viewBox="0 0 298 64">
<path fill="#ffffff" d="M86.4844 40.5858c0 .8464-.1792 1.5933-.5377 2.2505-.3585.6573-.8564 1.1651-1.5137 1.5236-.6572.3585-1.3941.5378-2.2406.5378H78v6.1044h5.0787c1.912 0 3.6248-.4282 5.1484-1.2846 1.5236-.8564 2.7186-2.0415 3.585-3.5452.8663-1.5037 1.3045-3.1966 1.3045-5.0886V21.0178h-6.6322v19.568Zm17.8556-1.8224h13.891v-5.6065H104.34v-6.3633h15.355v-5.7758H97.8766v29.9743h22.2464v-5.7757H104.34v-6.453Zm17.865-11.8005h8.882v24.0193h6.633V26.9629h8.842v-5.9451h-24.367v5.9551l.01-.01Zm47.022 9.0022c-.517-.2788-1.085-.4879-1.673-.6472.449-.1295.877-.2888 1.275-.488 1.096-.5676 1.962-1.3643 2.579-2.39.618-1.0257.936-2.2007.936-3.5351 0-1.5237-.418-2.8879-1.244-4.0929-.827-1.195-1.992-2.131-3.486-2.8082-1.494-.6672-3.206-1.0058-5.118-1.0058h-13.315v29.9743h13.574c2.011 0 3.804-.3485 5.387-1.0556 1.573-.707 2.798-1.6829 3.675-2.9476.866-1.2547 1.304-2.6887 1.304-4.302 0-1.4837-.338-2.8082-1.026-3.9833-.687-1.175-1.633-2.0812-2.858-2.7285l-.01.0099Zm-13.603-9.9184h5.886c.816 0 1.533.1494 2.161.4382.627.2888 1.115.707 1.464 1.2547.348.5378.527 1.1751.527 1.9021 0 .7269-.179 1.414-.527 1.9817-.349.5676-.837.9958-1.464 1.3045-.628.3087-1.345.4581-2.161.4581h-5.886v-7.3492.0099Zm10.138 18.134c-.378.5676-.916 1.0058-1.603 1.3145-.697.3087-1.484.4581-2.39.4581h-6.145v-7.6878h6.145c.886 0 1.673.1693 2.37.4979.687.3286 1.235.7867 1.613 1.3842.378.5975.578 1.2747.578 2.0414 0 .7668-.19 1.4241-.568 1.9917Zm29.596-5.3077c1.663-.7967 2.947-1.922 3.864-3.3659.916-1.444 1.374-3.117 1.374-5.0289 0-1.912-.448-3.5253-1.344-4.9592-.897-1.434-2.171-2.5394-3.814-3.3261-1.644-.7867-3.546-1.1751-5.717-1.1751h-13.124v29.9743h6.642V40.0779h4.322l6.084 10.9142h7.578l-6.851-11.7208c.339-.1195.677-.249.996-.3983h-.01Zm-2.151-6.1244c-.369.6274-.896 1.1154-1.583 1.444-.688.3386-1.494.5079-2.42.5079h-5.975v-8.2953h5.975c.926 0 1.732.1693 2.42.4979.687.3287 1.214.8166 1.583 1.434.368.6174.558 1.3544.558 2.1908 0 .8365-.19 1.5734-.558 2.2008v.0199Zm20.594-11.7308-10.706 29.9743h6.742l2.121-6.6122h11.114l2.27 6.6122h6.612L220.99 21.0178h-7.189Zm-.339 18.3431 3.445-10.5756.409-1.922.408 1.922 3.685 10.5756h-7.947Zm20.693 11.6312h6.851V21.0178h-6.851v29.9743Zm31.02-9.6993-12.896-20.275h-6.463v29.9743h6.055V30.7172l12.826 20.2749h6.533V21.0178h-6.055v20.275Zm31.528-3.3559c-.647-1.2448-1.564-2.2904-2.729-3.1369-1.165-.8464-2.509-1.4041-4.023-1.6929l-5.098-1.0456c-.797-.1892-1.434-.5178-1.902-.9958-.469-.478-.708-1.0755-.708-1.7825 0-.6473.17-1.205.518-1.683.339-.478.827-.8464 1.444-1.1153.618-.2689 1.335-.3983 2.151-.3983.817 0 1.554.1394 2.181.4182.627.2788 1.115.6672 1.464 1.1751s.528 1.0755.528 1.7228h6.642c-.04-1.7427-.528-3.2863-1.444-4.6207-.916-1.3443-2.201-2.3899-3.834-3.1468-1.633-.7568-3.505-1.1352-5.597-1.1352-2.091 0-3.943.3884-5.566 1.1751-1.623.7867-2.898 1.8721-3.804 3.2663-.906 1.3941-1.364 2.9775-1.364 4.76 0 1.444.288 2.7485.876 3.9036.587 1.1652 1.414 2.1311 2.479 2.8979 1.076.7668 2.311 1.3045 3.725 1.6033l5.397 1.1153c.886.2091 1.584.5975 2.101 1.1551.518.5577.767 1.2448.767 2.0813 0 .6672-.189 1.2747-.567 1.8025-.379.5277-.907.936-1.584 1.2248-.677.2888-1.474.4282-2.39.4282-.916 0-1.782-.1593-2.529-.478-.747-.3186-1.325-.7767-1.733-1.3742-.418-.5875-.617-1.2747-.617-2.0414h-6.642c.029 1.8721.527 3.5152 1.513 4.9492.976 1.424 2.32 2.5394 4.033 3.336 1.713.7967 3.675 1.195 5.886 1.195 2.21 0 4.202-.4083 5.915-1.2249 1.723-.8165 3.057-1.9418 4.023-3.3758.966-1.434 1.444-3.0572 1.444-4.8696 0-1.4838-.329-2.848-.976-4.1028l.02.01Z"/>
<path fill="url(#jbA)" d="M20.34 3.66 3.66 20.34C1.32 22.68 0 25.86 0 29.18V59c0 2.76 2.24 5 5 5h29.82c3.32 0 6.49-1.32 8.84-3.66l16.68-16.68c2.34-2.34 3.66-5.52 3.66-8.84V5c0-2.76-2.24-5-5-5H29.18c-3.32 0-6.49 1.32-8.84 3.66Z"/>
<path fill="#0a0a1a" d="M48 16H8v40h40V16Z"/>
<path fill="#fff" d="M30 47H13v4h17v-4Z"/>
</svg>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.0 KiB

-11568
View File
File diff suppressed because it is too large. Load diff
-42
View File
@@ -1,42 +0,0 @@
{
"private": true,
"name": "alt-tab-macos",
"version": "1.0.0",
"author": "",
"license": "GPL-3.0",
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.swift": [
"node scripts/swiftformat.js --lint"
]
},
"scripts": {
"format": "node scripts/swiftformat.js",
"format:check": "node scripts/swiftformat.js --lint"
},
"devDependencies": {
"@commitlint/cli": "^8.1.0",
"@commitlint/config-conventional": "^8.1.0",
"@semantic-release/changelog": "^3.0.4",
"@semantic-release/git": "^7.0.16",
"fontkit": "^1.8.0",
"glob": "^10.0.0",
"husky": "^3.0.4",
"lint-staged": "^13.0.0",
"marked": "^0.8.0",
"semantic-release": "^15.13.24"
},
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"@types/node": "^22.10.2",
"ts-node": "^10.9.2",
"typescript": "^5.7.2"
}
}
-31
View File
@@ -1,31 +0,0 @@
module.exports = {
plugins: [
['@semantic-release/commit-analyzer', {
'preset': 'angular',
// having version increments and builds for these commit types is valuable
'releaseRules': [
{'type': 'perf', 'release': 'patch'},
{'type': 'docs', 'release': 'patch'},
{'type': 'style', 'release': 'patch'},
{'type': 'refactor', 'release': 'patch'},
{'type': 'test', 'release': 'patch'},
{'type': 'chore', 'release': 'patch'},
{'type': 'ci', 'release': 'patch'},
],
}],
'@semantic-release/release-notes-generator',
['@semantic-release/changelog', {
'changelogFile': 'changelog.md',
}],
['@semantic-release/git', {
// alt-tab-free [depaywall]: 'appcast.xml' REMOVED from assets (PLAN §3.3/§4.3). The fork
// publishes the Sparkle feed OUT OF TREE (gh-pages) via scripts/update_appcast.sh, and
// appcast.xml is removed from the tree. If it stayed here, semantic-release would try to
// commit the now-untracked file back every release.
'assets': [
'changelog.md',
'README.md',
],
}],
],
}
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 1249 files, more files were not shown because too many files have changed in this diff. Show more