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
+97
View File
@@ -0,0 +1,97 @@
# Manual test checklist — focus behaviour
The event tap and Accessibility side-effects can't be unit tested. Run this
checklist by hand after any change to `EventTapController`, `WindowLocator`,
or `FocusEnforcer`, and whenever behaviour changes update it alongside the
code.
Prerequisites: FocusFixer built and running as a `.app` bundle (not via
`swift run`), Accessibility permission granted in System Settings →
Privacy & Security → Accessibility.
## Basic cross-display focus
1. Open a text editor window on display A and a terminal window on display B.
2. Click into the terminal on display B, then click the text editor on
display A.
3. Type immediately after the click. Confirm keystrokes land in the text
editor, not the terminal.
4. Repeat in the opposite direction (A → B).
## DisplayLink virtual display
1. Connect a DisplayLink virtual display (or enable one if already paired).
2. Repeat the "Basic cross-display focus" steps with one of the two windows
on the virtual display.
3. Confirm focus follows clicks with no perceptible lag and no dropped
keystrokes.
## No focus thrashing on the active app
1. Click repeatedly inside the already-frontmost window (same app, same
display).
2. Confirm no visible activation flicker and no window re-raise animation —
`NSRunningApplication.isActive` should short-circuit these clicks.
## Debounce
1. Rapidly double-click and drag-select across two different windows.
2. Confirm only one focus reassertion happens per interaction, not one per
raw mouse-down.
## Excluded apps
1. Add an app's bundle ID to the excluded list (`excludedBundleIDs` in
`UserDefaults` for `com.local.focusfixer`).
2. Click into that app's window from another display.
3. Confirm FocusFixer does not activate or raise it.
## System UI is left alone
1. Click the Dock, a Spotlight search result, and Control Center.
2. Confirm none of these trigger FocusFixer activation/raise logic (check
logs — see below — for absence of unexpected activation entries).
## Self-activation guard
1. Click the FocusFixer menu-bar icon.
2. Confirm FocusFixer never tries to activate/raise itself (it has no
normal windows, but verify no AX errors logged for its own PID).
## Tap recovery
1. Trigger sustained system load (e.g. a busy `yes > /dev/null` loop or a
heavy build) to risk `kCGEventTapDisabledByTimeout`.
2. Continue clicking across displays during the load.
3. Confirm focus reassertion keeps working — tail the logs (below) for a
"re-enabling" message, which indicates recovery kicked in.
## Permission revoked mid-session
1. While FocusFixer is running, run:
`tccutil reset Accessibility com.local.focusfixer`
2. Click across displays; confirm FocusFixer degrades silently (no dialog,
no crash) and logs the AX failures.
3. Re-grant Accessibility permission in System Settings and confirm
FocusFixer resumes without a relaunch (permission polling should pick it
back up if the app is still waiting; otherwise relaunch and confirm the
prompt reappears correctly).
## Toggling from the menu
1. Use the menu-bar "Enabled" checkbox to disable FocusFixer.
2. Confirm clicks across displays no longer reassert focus.
3. Re-enable and confirm behaviour resumes.
4. Toggle "Launch at Login", quit and relog (or use
`sfltool` / System Settings → General → Login Items) to confirm the
registration took effect.
## Logs
```bash
log stream --predicate 'subsystem == "com.local.focusfixer"' --level debug
```
Watch for repeated `tapDisabledByTimeout`/`tapDisabledByUserInput` messages
(indicates the tap callback is doing too much work) and AX error codes
logged by `FocusEnforcer`.