Initial FocusFixer implementation

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>
This commit is contained in:
sttlab-tech
2026-08-10 12:02:29 +02:00
commit 561a547b70
18 changed files with 1411 additions and 0 deletions
+124
View File
@@ -0,0 +1,124 @@
# 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.