Keyboard-driven app switcher for macOS — jump to any app, MRU-toggle between windows, cycle a window ring, all from the home row.
🎯 TL;DR: appfocus is a lightweight daemon that sits between your keyboard remapper and your window manager, giving you instant app switching, MRU toggle, and window cycling — all from the home row.
- What Makes This Special
- Architecture
- Features
- Requirements
- Installation
- Configuration
- Usage
- kanata Integration
- Running as a Service
- How It Works
- Tests
- Contributing
- License
- Acknowledgements
- ⚡ Instant switching — Jump to any app by name, launch if not running, reopen if no windows. yabai switches spaces without macOS's ~0.7s slide animation.
- 🔄 MRU toggle — Double-tap to bounce between your two most recent windows of the same app
- 🎯 Window ring cycling — Navigate next/prev through all windows of the current app
- ⌨️ kanata native — Direct TCP integration with kanata's push-msg, no shell scripts, sub-ms latency
- 🧩 Modular backends — yabai is the only backend today, but the
WindowBackendprotocol makes it straightforward to add alternatives (AeroSpace, pure Accessibility API, etc.) - 🪶 Zero dependencies — Pure Swift, no frameworks beyond AppKit, ~1500 lines total
┌── Command Sources ──┐ ┌──── Backends ─────────┐ │ │ │ │ │ ⌨️ kanata (TCP) │───┐ │ 🪟 yabai │ │ │ ├─▶ appfocusd ─┤ query & focus │ │ 💻 CLI (socket) │───┘ │ │ │ │ │ │ │ 🍎 macOS native │ └──────────────────────┘ │ │ launch & reopen │ │ │ │ State Store │ 🔍 NSWorkspace │ (per-app │ process detection │ MRU + ring) └────────────────────────┘ - Jump to any app by name (launches if not running, reopens if no windows)
- MRU toggle between two most recent windows of the same app
- Ring-based window cycling (next/prev) within an app
- kanata integration via TCP push-msg
- App name aliases (e.g.,
"Code"→"Visual Studio Code") - Per-app reopen strategies (
reopen,makeWindow,makeDocument) - Focus polling for accurate MRU tracking
- Unix socket CLI for scripting
- macOS 14+
- Xcode Command Line Tools (Swift compiler)
- yabai window manager
make && make installInstalls appfocusd and appfocus to /usr/local/bin.
nix build github:moritzketzer/appfocusAll fields are optional — defaults are sensible out of the box.
Create ~/.config/appfocus/config.json:
{ "backend": "yabai", "yabai_path": "/usr/local/bin/yabai", "aliases": { "Code": "Visual Studio Code" }, "reopen_strategies": { "Finder": "makeWindow", "Safari": "makeDocument", "*": "reopen" }, "poll_interval_ms": 1000, "kanata_enabled": true, "kanata_port": 7070 }| Field | Default | Description |
|---|---|---|
backend | "yabai" | Window backend to use |
yabai_path | "/usr/local/bin/yabai" | Path to the yabai binary |
aliases | {} | Map short names to full app names |
reopen_strategies | {"*": "reopen"} | Per-app strategy when no windows exist (reopen, makeWindow, makeDocument) |
poll_interval_ms | 1000 | How often to poll focused window for MRU tracking |
kanata_enabled | true | Whether to listen for kanata TCP push-msg |
kanata_port | 7070 | TCP port to listen on for kanata messages |
appfocus jump Safari # focus Safari (or launch it) appfocus next # cycle to next window of current app appfocus prev # cycle to previous window appfocus status # show daemon status as JSONShow kanata config example
Add to your .kbd config:
(deftemplate app-open (appname) (push-msg (concat "jump " $appname)) ) (defalias wnx (push-msg "next") ;; cycle forward wpr (push-msg "prev") ;; cycle backward saf (t! app-open "Safari") kit (t! app-open "kitty") vsc (t! app-open "Visual Studio Code") ) kanata sends a JSON {"MessagePush":{"message":"jump Safari"}} over TCP to appfocusd on the configured port (default 7070). The concat form in deftemplate produces an array format which appfocus also handles.
No shell scripts, no subprocesses — the command travels from keypress to window focus in sub-millisecond time.
Show launchd plist
Save to ~/Library/LaunchAgents/local.appfocus.plist:
<?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>Label</key> <string>local.appfocus</string> <key>ProgramArguments</key> <array> <string>/usr/local/bin/appfocusd</string> </array> <key>RunAtLoad</key> <true/> <key>KeepAlive</key> <true/> <key>ProcessType</key> <string>Interactive</string> </dict> </plist>Then load it:
launchctl load ~/Library/LaunchAgents/local.appfocus.plistappfocusd exposes two command sources: a Unix socket for CLI use (appfocus jump, appfocus next, etc.) and a TCP listener for kanata's push-msg protocol. Both funnel into shared ActivationLogic, keeping behavior identical regardless of source.
When a jump <app> command arrives, appfocusd checks whether the app is already focused. If it is, it performs an MRU toggle — switching to the previously focused window of that app. If it isn't focused but has windows, it focuses the best candidate. If no windows exist, it launches or reopens the app using the configured reopen strategy (reopen, makeWindow, or makeDocument).
Per-app state — lastFocusedId, prevFocusedId, and the window ring order — is persisted as JSON files in ~/.local/state/appfocus/. A background poll (configurable interval, default 1 s) keeps MRU data fresh even when focus changes happen outside appfocus.
The command protocol is intentionally simple: newline-delimited text over the Unix socket (jump <app>, next, prev, status). kanata uses a JSON MessagePush envelope over TCP; appfocusd unwraps it and routes to the same handler. The WindowBackend protocol abstracts all yabai calls, making it straightforward to add an AeroSpace or pure Accessibility API backend in the future.
72 unit tests covering activation logic, ring reconciliation, command parsing, state persistence, and config handling.
make testContributions welcome! Please open an issue first to discuss what you'd like to change.