561a547b70
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>
125 lines
5.2 KiB
Markdown
125 lines
5.2 KiB
Markdown
# 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.
|