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.
| Rule | Why |
|---|---|
| AppKit only | No SwiftUI, no Catalyst, no third-party UI frameworks. The switcher has to feel native and open instantly. |
| No telemetry | No analytics, no crash reporting, no background network traffic. The only permitted requests are GitHub Releases update checks. |
| macOS 13.0 stays the floor | Newer-OS features are gated with if #available and ship a graceful fallback. |
| The hot path stays fast | Anything 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 buildXcode 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/FuzzyMatchTestsTests 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/noMatchThey 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.
| Path | What it does |
|---|---|
Input/HotkeyTap.swift | Global CGEvent tap on its own thread; detects the ⌘Tab chord and suppresses the native switcher |
Switcher/SwitcherController.swift | The state machine — selection, letter jump, search, tab drill-in |
Switcher/SwitcherView.swift | Lays out the list / grid / window-preview layouts |
Switcher/SwitcherPanel.swift | The non-activating panel and its active-state pinning |
Catalog/AppCatalog.swift | Enumerates apps and windows over the Accessibility API |
Catalog/AppCatalogCache.swift | Incremental cache fed by AX observers and MRU bumps |
Windows/Activator.swift | Activate, raise, close, hide, quit |
Windows/MRUTracker.swift | Most-recently-used ordering |
System/PrivateAPIs.swift | All 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.
- Add the key to the
Keysenum inApp/Preferences.swift, under theSwitcher.prefix. The key string is the contract:CatalogFilterandSwitcherControllerread some keys straight offUserDefaultson a background thread, so renaming one means updating both sides. - Add the
@Publishedproperty with adidSetthat persists it. Guard the write withguard oldValue != newValue else { return }— that guard is what stopsreloadFromDefaults()rewriting every key on import. - Add the matching read to
reloadFromDefaults(), with the default value. - Document it in
App/ConfigSchemaDocs.swift. This feeds the generatedschema.json, so a key you skip here is valid but undocumented — no editor completion, no hover. - Surface it in the right settings pane. Anything fragile or new goes behind the off-by-default Experimental pane.
- 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 throughLog.*(os.Logger). - One logical change per PR. Split refactors out from behaviour changes.
- Commit messages are
type: short summary—fix:,feat:,perf:,refactor:,docs:,chore:— with the body wrapped at ~72 characters explaining why.
Reporting a bug
Open an issue with:
- macOS version (
sw_vers) - BetterCmdTab version (menu bar → About)
- Steps to reproduce — the exact key sequence, which apps were open, which display
- What you expected against what happened
- 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.