# 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 `.app` bundle 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 ```bash # 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 1. `EventTapController` observes `.leftMouseDown` (listen-only). 2. `WindowLocator` maps the event location to the frontmost on-screen window containing that point, via `CGWindowListCopyWindowInfo(.optionOnScreenOnly)`, and returns `kCGWindowOwnerPID`. 3. `FocusEnforcer` activates that PID and, through the AX API, raises the specific window and sets `kAXFocusedWindowAttribute`. ## Hard constraints These are the failure modes that make this kind of utility unreliable. Respect them. - **Event tap must be listen-only.** Use `.listenOnly` in `CGEvent.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 `kCGEventTapDisabledByTimeout` and `kCGEventTapDisabledByUserInput` in the callback and re-enable via `CGEvent.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.isActive` first. 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.focusfixer` must 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.plist` must set `LSUIElement = true` (no Dock icon). Do not call `setActivationPolicy(.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`, subsystem `com.local.focusfixer`, one category per file. No `print()`. - 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 `sleep` in 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.