BetterCmdTab

Contributing

Build BetterCmdTab from source, run the tests, and get a pull request merged.

Issues and pull requests are both welcome — bug reports, feature ideas and code changes alike. This page mirrors CONTRIBUTING.md in the repository; that file is the source of truth if the two ever disagree.

Ground rules

These four are not negotiable — a PR that breaks one will be asked to change, however good the feature is.

RuleWhy
AppKit onlyNo SwiftUI, no Catalyst, no third-party UI frameworks. The switcher has to feel native and open instantly.
No telemetryNo analytics, no crash reporting, no background network traffic. The only permitted requests are GitHub Releases update checks.
macOS 13.0 stays the floorNewer-OS features are gated with if #available and ship a graceful fallback.
The hot path stays fastAnything running on ⌘Tab is off the main thread, or measured.

Building

git clone https://github.com/rokartur/BetterCmdTab.git
cd BetterCmdTab
xcodebuild -scheme "BetterCmdTab Debug" -configuration Debug build

Xcode 16+ and the macOS 26 SDK are required. The Liquid Glass code paths are gated to macOS 26 — building against an older SDK still works, the app just falls back to NSVisualEffectView at runtime.

Run the app once and grant Accessibility before assuming your change is broken. The switcher does not boot without it, so ⌘Tab simply does nothing — see Quick start.

Running tests

# whole suite
xcodebuild -scheme "BetterCmdTab Debug" -destination 'platform=macOS' test

# one suite
xcodebuild -scheme "BetterCmdTab Debug" -destination 'platform=macOS' \
  test -only-testing:BetterCmdTabTests/FuzzyMatchTests

Tests use Swift Testing (import Testing, @Suite / @Test), not XCTest. There are no testXxx() methods, so select a single case by its Swift function name:

xcodebuild -scheme "BetterCmdTab Debug" -destination 'platform=macOS' \
  test -only-testing:BetterCmdTabTests/FuzzyMatchTests/noMatch

They cover pure logic only — switcher metrics, row labels, catalog filtering, fuzzy match, updater parsing, Liquid Glass selection, settings portability. UI behaviour is verified by hand: the switcher needs a live WindowServer plus Accessibility, so that surface fails headless and is not part of the unit run.

Where things live

The codebase is small. These are the files worth reading first.

PathWhat it does
Input/HotkeyTap.swiftGlobal CGEvent tap on its own thread; detects the ⌘Tab chord and suppresses the native switcher
Switcher/SwitcherController.swiftThe state machine — selection, letter jump, search, tab drill-in
Switcher/SwitcherView.swiftLays out the list / grid / window-preview layouts
Switcher/SwitcherPanel.swiftThe non-activating panel and its active-state pinning
Catalog/AppCatalog.swiftEnumerates apps and windows over the Accessibility API
Catalog/AppCatalogCache.swiftIncremental cache fed by AX observers and MRU bumps
Windows/Activator.swiftActivate, raise, close, hide, quit
Windows/MRUTracker.swiftMost-recently-used ordering
System/PrivateAPIs.swiftAll private CGS / SkyLight glue, isolated in one file for review
Settings/The native AppKit settings window, one controller per pane

Adding a preference

Preferences are a contract, not just a variable — several pieces have to move together.

  1. Add the key to the Keys enum in App/Preferences.swift, under the Switcher. prefix. The key string is the contract: CatalogFilter and SwitcherController read some keys straight off UserDefaults on a background thread, so renaming one means updating both sides.
  2. Add the @Published property with a didSet that persists it. Guard the write with guard oldValue != newValue else { return } — that guard is what stops reloadFromDefaults() rewriting every key on import.
  3. Add the matching read to reloadFromDefaults(), with the default value.
  4. Document it in App/ConfigSchemaDocs.swift. This feeds the generated schema.json, so a key you skip here is valid but undocumented — no editor completion, no hover.
  5. Surface it in the right settings pane. Anything fragile or new goes behind the off-by-default Experimental pane.
  6. If it has pure-logic behaviour, add a test.

New keys are picked up by export/import and the config file automatically — both walk the whole Switcher.* namespace rather than a hand-written list.

The config reference on this site is generated from a copy of that same schema.json. After adding a preference, refresh it so the docs don't lag — see docs/README.md.

Pull request checklist

  • Builds clean, with no new warnings.
  • Existing tests still pass; new pure-logic behaviour ships with at least one test.
  • No commented-out code, no dead branches, no leftover print — log through Log.* (os.Logger).
  • One logical change per PR. Split refactors out from behaviour changes.
  • Commit messages are type: short summaryfix:, feat:, perf:, refactor:, docs:, chore: — with the body wrapped at ~72 characters explaining why.

Reporting a bug

Open an issue with:

  1. macOS version (sw_vers)
  2. BetterCmdTab version (menu bar → About)
  3. Steps to reproduce — the exact key sequence, which apps were open, which display
  4. What you expected against what happened
  5. A short screen recording if it is visual (focus flicker, layout, glass rendering)

For a crash, attach the .ips from ~/Library/Logs/DiagnosticReports/.

Requesting a feature

Describe the workflow you want, not the implementation. "I want to filter the switcher by app category" is more useful than "add a category dropdown" — the implementation can be debated, but the underlying need is what drives the design.

Security

If you find a vulnerability — anything letting a third-party app read switcher state, intercept hotkeys, or escalate through the Accessibility permission BetterCmdTab holds — please open a private security advisory rather than a public issue.

License

BetterCmdTab is GPL v3. By submitting a contribution you agree that your work is licensed under it too.

On this page