Files
sttlab-tech 561a547b70 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>
2026-08-10 12:02:29 +02:00

5.2 KiB

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

# 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.