Menu-bar utility that fixes cross-display keyboard focus loss: a listen-only CGEvent tap locates the window under the cursor and reasserts app activation + AX focus on it, with debounce, tap auto-recovery, and an Accessibility permission gate. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
5.2 KiB
CLAUDE.md
Project instructions for Claude Code.
Project
FocusFixer — a headless macOS menu-bar utility that fixes a long-standing
multi-display bug: after clicking a window on one screen, keyboard focus can
remain on a window on another screen, so typing goes to the wrong place.
The bug is aggravated by DisplayLink virtual displays.
The app watches for mouse-down events and re-asserts keyboard focus on the window under the cursor.
Build target & tooling
- Xcode project (not SwiftPM). A real
.appbundle is mandatory: the Accessibility (TCC) grant is bound to bundle ID + code signature, and a bare SwiftPM binary loses the permission on every rebuild. - Swift 5.9+, macOS 13+ deployment target.
- AppKit + ApplicationServices. No third-party dependencies.
Commands
# Build
xcodebuild -scheme FocusFixer -configuration Debug build
# Build & run
xcodebuild -scheme FocusFixer -configuration Debug build && \
open ~/Library/Developer/Xcode/DerivedData/FocusFixer-*/Build/Products/Debug/FocusFixer.app
# Tail logs (the app logs via os.Logger, subsystem below)
log stream --predicate 'subsystem == "com.local.focusfixer"' --level debug
Architecture
Keep these responsibilities in separate files. Do not merge them.
| File | Responsibility |
|---|---|
AppDelegate.swift |
Lifecycle, activation policy, permission gate, menu-bar item |
PermissionManager.swift |
AXIsProcessTrustedWithOptions, prompt, polling until granted |
EventTapController.swift |
CGEvent tap creation, run-loop source, auto-recovery |
WindowLocator.swift |
Point → window → owner PID resolution |
FocusEnforcer.swift |
App activation + kAXRaiseAction + focused-window assignment |
Preferences.swift |
UserDefaults wrapper (enabled, excluded bundle IDs, debounce) |
Flow
EventTapControllerobserves.leftMouseDown(listen-only).WindowLocatormaps the event location to the frontmost on-screen window containing that point, viaCGWindowListCopyWindowInfo(.optionOnScreenOnly), and returnskCGWindowOwnerPID.FocusEnforceractivates that PID and, through the AX API, raises the specific window and setskAXFocusedWindowAttribute.
Hard constraints
These are the failure modes that make this kind of utility unreliable. Respect them.
- Event tap must be listen-only. Use
.listenOnlyinCGEvent.tapCreate. The app never mutates or swallows events. A passive tap that adds latency to every click is unacceptable. - Handle tap disabling. macOS disables taps that are too slow. Handle
kCGEventTapDisabledByTimeoutandkCGEventTapDisabledByUserInputin the callback and re-enable viaCGEvent.tapEnable. Without this the app dies silently after a few minutes. - Do no work on the tap callback thread. The callback captures the event location and hands off to a serial dispatch queue immediately. Any AX call on the callback thread will eventually trip the timeout above.
- Never activate an already-frontmost app. Check
NSRunningApplication.isActivefirst. Redundant activation causes focus thrashing and visible window flicker. - Never activate self. Guard against the app's own PID and against the
Dock, Spotlight, and Control Center (menu-bar and system UI windows have
kCGWindowLayer != 0— filter on layer 0 only). - Debounce. Ignore events within ~150 ms of the previous one. Drag gestures and double-clicks otherwise fire repeated activations.
- Degrade silently. If the AX call fails (sandboxed app, no AX support), log and return. Never show a dialog from the event path.
Code signing & permissions
- Bundle ID
com.local.focusfixermust stay stable. Changing it invalidates the existing Accessibility grant. - Use automatic signing with a consistent team/identity. Switching between ad-hoc and Developer ID resets TCC.
Info.plistmust setLSUIElement = true(no Dock icon). Do not callsetActivationPolicy(.accessory)as a substitute — the plist key is what keeps the app out of the app switcher from launch.- Login item registration uses
SMAppService.mainApp.register(). - If Accessibility appears revoked after a rebuild during development:
tccutil reset Accessibility com.local.focusfixer, then re-grant.
Conventions
- Code and comments in English.
- Logging via
os.Logger, subsystemcom.local.focusfixer, one category per file. Noprint(). - No force-unwrapping outside of tests. C API results (
CGWindowList*,AXUIElement*) are all optional or status-coded — check every one. - AX and CG calls are wrapped in the files listed above. Do not call them
directly from
AppDelegate.
Testing
The event tap cannot be unit tested. Test the pure logic instead:
WindowLocator: given a synthetic window-info array and a point, assert the correct PID is returned (layer filtering, z-order, empty case, point outside all windows).Preferences: exclusion-list matching.- Debounce logic: injectable clock, no
sleepin tests.
Manual test checklist for focus behaviour lives in docs/manual-tests.md.
Update it when behaviour changes.
Out of scope
Do not add without being asked: window tiling, hotkeys, Spaces switching, display arrangement management, or a preferences UI beyond the menu-bar menu.