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:
@@ -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.
|
||||
Reference in New Issue
Block a user