From 561a547b702ab1da43fd7defc795c40b8da246f6 Mon Sep 17 00:00:00 2001 From: sttlab-tech Date: Mon, 10 Aug 2026 12:02:29 +0200 Subject: [PATCH] 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 --- .gitignore | 7 + CLAUDE.md | 124 +++++ FocusFixer.xcodeproj/project.pbxproj | 427 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcschemes/FocusFixer.xcscheme | 118 +++++ FocusFixer/AppDelegate.swift | 79 ++++ FocusFixer/EventTapController.swift | 121 +++++ FocusFixer/FocusEnforcer.swift | 60 +++ FocusFixer/Info.plist | 28 ++ FocusFixer/PermissionManager.swift | 47 ++ FocusFixer/Preferences.swift | 54 +++ FocusFixer/WindowLocator.swift | 49 ++ FocusFixer/main.swift | 6 + FocusFixerTests/DebounceTests.swift | 40 ++ FocusFixerTests/PreferencesTests.swift | 47 ++ FocusFixerTests/WindowLocatorTests.swift | 47 ++ docs/manual-tests.md | 97 ++++ project.yml | 53 +++ 18 files changed, 1411 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 FocusFixer.xcodeproj/project.pbxproj create mode 100644 FocusFixer.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 FocusFixer.xcodeproj/xcshareddata/xcschemes/FocusFixer.xcscheme create mode 100644 FocusFixer/AppDelegate.swift create mode 100644 FocusFixer/EventTapController.swift create mode 100644 FocusFixer/FocusEnforcer.swift create mode 100644 FocusFixer/Info.plist create mode 100644 FocusFixer/PermissionManager.swift create mode 100644 FocusFixer/Preferences.swift create mode 100644 FocusFixer/WindowLocator.swift create mode 100644 FocusFixer/main.swift create mode 100644 FocusFixerTests/DebounceTests.swift create mode 100644 FocusFixerTests/PreferencesTests.swift create mode 100644 FocusFixerTests/WindowLocatorTests.swift create mode 100644 docs/manual-tests.md create mode 100644 project.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85a2f74 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +build/ +DerivedData/ +*.xcuserstate +xcuserdata/ +.swiftpm/ +.build/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b0270cc --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/FocusFixer.xcodeproj/project.pbxproj b/FocusFixer.xcodeproj/project.pbxproj new file mode 100644 index 0000000..46af480 --- /dev/null +++ b/FocusFixer.xcodeproj/project.pbxproj @@ -0,0 +1,427 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 02861A191097E26F4BBFFC3C /* EventTapController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F6A6781660931E8DE13CBA8 /* EventTapController.swift */; }; + 1855F520A8A8D2091F6D1915 /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BA5F1D3E43F0843B442B0C3 /* PermissionManager.swift */; }; + 26C71677B1D8CB4CD657154D /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94BEBF21BDD32FE15C4B8F0C /* main.swift */; }; + 3A6CBD1179B0870EF6A4DF5C /* FocusEnforcer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 583FC010F2AC5DA68F9A479C /* FocusEnforcer.swift */; }; + 67D53FB027B5450292EA0E19 /* PreferencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18945640C7DCF7D2BB10B26D /* PreferencesTests.swift */; }; + ADF3776153FD8C769680597F /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AE36788814E7B845FD6E7F /* Preferences.swift */; }; + AED74188139B448C5DF52328 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13F720922E8DFF9B027834B8 /* AppDelegate.swift */; }; + D7F8F852A5B6FF5E887C13CD /* WindowLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07D7F8D2C883918C1D8125C2 /* WindowLocator.swift */; }; + DEE81AEB5FD750511EB331C3 /* DebounceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67290BF09C16440EE8EC5F0A /* DebounceTests.swift */; }; + FED57F4C597F7DA62B540A21 /* WindowLocatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D8963F47F88A0EB59672CD /* WindowLocatorTests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 9E463F6F6CA86E27D10D16C4 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = E406203B385B50B976761426 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 2274BE8F97DC846458C50849; + remoteInfo = FocusFixer; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 07D7F8D2C883918C1D8125C2 /* WindowLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowLocator.swift; sourceTree = ""; }; + 0E21E251CF9229938F0B628E /* FocusFixerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FocusFixerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 13F720922E8DFF9B027834B8 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 14D8963F47F88A0EB59672CD /* WindowLocatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowLocatorTests.swift; sourceTree = ""; }; + 18945640C7DCF7D2BB10B26D /* PreferencesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesTests.swift; sourceTree = ""; }; + 18AE36788814E7B845FD6E7F /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; + 583FC010F2AC5DA68F9A479C /* FocusEnforcer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusEnforcer.swift; sourceTree = ""; }; + 67290BF09C16440EE8EC5F0A /* DebounceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebounceTests.swift; sourceTree = ""; }; + 8BA5F1D3E43F0843B442B0C3 /* PermissionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionManager.swift; sourceTree = ""; }; + 94BEBF21BDD32FE15C4B8F0C /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + 9D1D1A4D3772591B7BE3B668 /* FocusFixer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FocusFixer.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 9F6A6781660931E8DE13CBA8 /* EventTapController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventTapController.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 1791F5CD1D3C0EDC80AA8EA8 /* FocusFixerTests */ = { + isa = PBXGroup; + children = ( + 67290BF09C16440EE8EC5F0A /* DebounceTests.swift */, + 18945640C7DCF7D2BB10B26D /* PreferencesTests.swift */, + 14D8963F47F88A0EB59672CD /* WindowLocatorTests.swift */, + ); + path = FocusFixerTests; + sourceTree = ""; + }; + 211A341AA5FDBEBBB831893A /* FocusFixer */ = { + isa = PBXGroup; + children = ( + 13F720922E8DFF9B027834B8 /* AppDelegate.swift */, + 9F6A6781660931E8DE13CBA8 /* EventTapController.swift */, + 583FC010F2AC5DA68F9A479C /* FocusEnforcer.swift */, + 94BEBF21BDD32FE15C4B8F0C /* main.swift */, + 8BA5F1D3E43F0843B442B0C3 /* PermissionManager.swift */, + 18AE36788814E7B845FD6E7F /* Preferences.swift */, + 07D7F8D2C883918C1D8125C2 /* WindowLocator.swift */, + ); + path = FocusFixer; + sourceTree = ""; + }; + 38C55F723E487F1371990734 = { + isa = PBXGroup; + children = ( + 211A341AA5FDBEBBB831893A /* FocusFixer */, + 1791F5CD1D3C0EDC80AA8EA8 /* FocusFixerTests */, + 882C44028E7037E70CA117B4 /* Products */, + ); + sourceTree = ""; + }; + 882C44028E7037E70CA117B4 /* Products */ = { + isa = PBXGroup; + children = ( + 9D1D1A4D3772591B7BE3B668 /* FocusFixer.app */, + 0E21E251CF9229938F0B628E /* FocusFixerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 2274BE8F97DC846458C50849 /* FocusFixer */ = { + isa = PBXNativeTarget; + buildConfigurationList = 336271D4A81B9CB911993090 /* Build configuration list for PBXNativeTarget "FocusFixer" */; + buildPhases = ( + F95ABA416F370B29F1A5C6EA /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = FocusFixer; + packageProductDependencies = ( + ); + productName = FocusFixer; + productReference = 9D1D1A4D3772591B7BE3B668 /* FocusFixer.app */; + productType = "com.apple.product-type.application"; + }; + C276426D2F31753646ED8E29 /* FocusFixerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 16F48D487CFBC1464F087BCE /* Build configuration list for PBXNativeTarget "FocusFixerTests" */; + buildPhases = ( + E421552178F3632A174E9DF2 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + FA4181D70BA95B3FFC2C1233 /* PBXTargetDependency */, + ); + name = FocusFixerTests; + packageProductDependencies = ( + ); + productName = FocusFixerTests; + productReference = 0E21E251CF9229938F0B628E /* FocusFixerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + E406203B385B50B976761426 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 2274BE8F97DC846458C50849 = { + ProvisioningStyle = Automatic; + }; + C276426D2F31753646ED8E29 = { + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 7D0C38B716F9F4A97F3ACC0F /* Build configuration list for PBXProject "FocusFixer" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 38C55F723E487F1371990734; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 882C44028E7037E70CA117B4 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 2274BE8F97DC846458C50849 /* FocusFixer */, + C276426D2F31753646ED8E29 /* FocusFixerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + E421552178F3632A174E9DF2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DEE81AEB5FD750511EB331C3 /* DebounceTests.swift in Sources */, + 67D53FB027B5450292EA0E19 /* PreferencesTests.swift in Sources */, + FED57F4C597F7DA62B540A21 /* WindowLocatorTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F95ABA416F370B29F1A5C6EA /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AED74188139B448C5DF52328 /* AppDelegate.swift in Sources */, + 02861A191097E26F4BBFFC3C /* EventTapController.swift in Sources */, + 3A6CBD1179B0870EF6A4DF5C /* FocusEnforcer.swift in Sources */, + 1855F520A8A8D2091F6D1915 /* PermissionManager.swift in Sources */, + ADF3776153FD8C769680597F /* Preferences.swift in Sources */, + D7F8F852A5B6FF5E887C13CD /* WindowLocator.swift in Sources */, + 26C71677B1D8CB4CD657154D /* main.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + FA4181D70BA95B3FFC2C1233 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 2274BE8F97DC846458C50849 /* FocusFixer */; + targetProxy = 9E463F6F6CA86E27D10D16C4 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 2DD07A9740EDCB8CF35DFCC7 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = FocusFixer/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.0; + PRODUCT_BUNDLE_IDENTIFIER = com.local.focusfixer; + PRODUCT_NAME = FocusFixer; + SDKROOT = macosx; + }; + name = Debug; + }; + 4C55CEB7A8359D1CD8B32D5A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.0; + SDKROOT = macosx; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/FocusFixer.app/Contents/MacOS/FocusFixer"; + }; + name = Debug; + }; + 5AEBCBD9302CD9E837EB33A1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_HARDENED_RUNTIME = NO; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.9; + }; + name = Release; + }; + 70FD6E93579D7CC65D70BD03 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.0; + SDKROOT = macosx; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/FocusFixer.app/Contents/MacOS/FocusFixer"; + }; + name = Release; + }; + 83E1BF6F7A218D06F8AB5DBA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_HARDENED_RUNTIME = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.9; + }; + name = Debug; + }; + 8828802B6A800297D33E0596 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = FocusFixer/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.0; + PRODUCT_BUNDLE_IDENTIFIER = com.local.focusfixer; + PRODUCT_NAME = FocusFixer; + SDKROOT = macosx; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 16F48D487CFBC1464F087BCE /* Build configuration list for PBXNativeTarget "FocusFixerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4C55CEB7A8359D1CD8B32D5A /* Debug */, + 70FD6E93579D7CC65D70BD03 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 336271D4A81B9CB911993090 /* Build configuration list for PBXNativeTarget "FocusFixer" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2DD07A9740EDCB8CF35DFCC7 /* Debug */, + 8828802B6A800297D33E0596 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 7D0C38B716F9F4A97F3ACC0F /* Build configuration list for PBXProject "FocusFixer" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83E1BF6F7A218D06F8AB5DBA /* Debug */, + 5AEBCBD9302CD9E837EB33A1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = E406203B385B50B976761426 /* Project object */; +} diff --git a/FocusFixer.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/FocusFixer.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/FocusFixer.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/FocusFixer.xcodeproj/xcshareddata/xcschemes/FocusFixer.xcscheme b/FocusFixer.xcodeproj/xcshareddata/xcschemes/FocusFixer.xcscheme new file mode 100644 index 0000000..2f0a738 --- /dev/null +++ b/FocusFixer.xcodeproj/xcshareddata/xcschemes/FocusFixer.xcscheme @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FocusFixer/AppDelegate.swift b/FocusFixer/AppDelegate.swift new file mode 100644 index 0000000..737882d --- /dev/null +++ b/FocusFixer/AppDelegate.swift @@ -0,0 +1,79 @@ +import AppKit +import ServiceManagement +import os + +/// App lifecycle, the Accessibility permission gate, and the menu-bar item. +/// Wires WindowLocator + FocusEnforcer to the events EventTapController +/// reports; owns no event-tap or AX logic itself. +final class AppDelegate: NSObject, NSApplicationDelegate { + private let logger = Logger(subsystem: "com.local.focusfixer", category: "AppDelegate") + + private let preferences = Preferences.shared + private let permissionManager = PermissionManager() + private let eventTapController = EventTapController() + private let focusEnforcer = FocusEnforcer() + + private var statusItem: NSStatusItem? + + func applicationDidFinishLaunching(_ notification: Notification) { + setUpStatusItem() + permissionManager.waitUntilTrusted { [weak self] in + self?.startEventTap() + } + } + + private func startEventTap() { + eventTapController.start { [weak self] point in + self?.handleMouseDown(at: point) + } + } + + private func handleMouseDown(at point: CGPoint) { + guard preferences.isEnabled else { return } + guard let pid = WindowLocator.ownerPID(at: point) else { return } + focusEnforcer.enforceFocus(forWindowOwnedBy: pid) + } + + // MARK: - Menu bar + + private func setUpStatusItem() { + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + item.button?.image = NSImage(systemSymbolName: "scope", accessibilityDescription: "FocusFixer") + + let menu = NSMenu() + + let enabledItem = NSMenuItem(title: "Enabled", action: #selector(toggleEnabled(_:)), keyEquivalent: "") + enabledItem.target = self + enabledItem.state = preferences.isEnabled ? .on : .off + menu.addItem(enabledItem) + + let loginItem = NSMenuItem(title: "Launch at Login", action: #selector(toggleLaunchAtLogin(_:)), keyEquivalent: "") + loginItem.target = self + loginItem.state = SMAppService.mainApp.status == .enabled ? .on : .off + menu.addItem(loginItem) + + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Quit FocusFixer", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) + + item.menu = menu + statusItem = item + } + + @objc private func toggleEnabled(_ sender: NSMenuItem) { + preferences.isEnabled.toggle() + sender.state = preferences.isEnabled ? .on : .off + } + + @objc private func toggleLaunchAtLogin(_ sender: NSMenuItem) { + do { + if SMAppService.mainApp.status == .enabled { + try SMAppService.mainApp.unregister() + } else { + try SMAppService.mainApp.register() + } + } catch { + logger.error("Failed to toggle login item: \(error.localizedDescription, privacy: .public)") + } + sender.state = SMAppService.mainApp.status == .enabled ? .on : .off + } +} diff --git a/FocusFixer/EventTapController.swift b/FocusFixer/EventTapController.swift new file mode 100644 index 0000000..8ed4a9a --- /dev/null +++ b/FocusFixer/EventTapController.swift @@ -0,0 +1,121 @@ +import CoreGraphics +import Foundation +import os + +/// Suppresses events that arrive within `interval` of the previous accepted +/// event. Clock is injectable so debounce timing can be unit tested without +/// sleeping. +struct Debouncer { + private let interval: TimeInterval + private let now: () -> TimeInterval + private var lastAcceptedTime: TimeInterval? + + init(interval: TimeInterval, clock: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) { + self.interval = interval + self.now = clock + } + + mutating func shouldProcess() -> Bool { + let current = now() + if let last = lastAcceptedTime, current - last < interval { + return false + } + lastAcceptedTime = current + return true + } +} + +/// Owns the low-level CGEvent tap: creation, run-loop wiring, and recovery +/// when macOS disables the tap for being too slow. The tap is listen-only +/// and never mutates or swallows events. All work beyond capturing the +/// event location happens off the callback thread. +final class EventTapController { + private let logger = Logger(subsystem: "com.local.focusfixer", category: "EventTapController") + private let handoffQueue = DispatchQueue(label: "com.local.focusfixer.eventtap.handoff") + private let preferences: Preferences + + private var eventTap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + private var debouncer: Debouncer + private var onMouseDown: ((CGPoint) -> Void)? + + init(preferences: Preferences = .shared) { + self.preferences = preferences + self.debouncer = Debouncer(interval: preferences.debounceInterval) + } + + /// Starts listening for left mouse-down events. `onMouseDown` is + /// invoked on `handoffQueue`, after debouncing, with the click location + /// in global screen coordinates. + func start(onMouseDown: @escaping (CGPoint) -> Void) { + self.onMouseDown = onMouseDown + + let eventMask: CGEventMask = 1 << CGEventType.leftMouseDown.rawValue + let selfPtr = Unmanaged.passUnretained(self).toOpaque() + + guard let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .listenOnly, + eventsOfInterest: eventMask, + callback: eventTapCallback, + userInfo: selfPtr + ) else { + logger.error("Failed to create event tap — Accessibility permission likely missing") + return + } + + eventTap = tap + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + runLoopSource = source + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + logger.info("Event tap started") + } + + func stop() { + if let tap = eventTap { + CGEvent.tapEnable(tap: tap, enable: false) + } + if let source = runLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) + } + eventTap = nil + runLoopSource = nil + logger.info("Event tap stopped") + } + + fileprivate func handle(type: CGEventType, event: CGEvent) { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + logger.error("Event tap disabled — re-enabling") + if let tap = eventTap { + CGEvent.tapEnable(tap: tap, enable: true) + } + return + } + + guard type == .leftMouseDown else { return } + let location = event.location + + handoffQueue.async { [weak self] in + guard let self else { return } + guard self.debouncer.shouldProcess() else { return } + self.onMouseDown?(location) + } + } +} + +/// Non-capturing C callback required by CGEvent.tapCreate. Recovers the +/// controller instance from `refcon` rather than capturing `self`. +private func eventTapCallback( + proxy: CGEventTapProxy, + type: CGEventType, + event: CGEvent, + refcon: UnsafeMutableRawPointer? +) -> Unmanaged? { + if let refcon { + let controller = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + controller.handle(type: type, event: event) + } + return Unmanaged.passUnretained(event) +} diff --git a/FocusFixer/FocusEnforcer.swift b/FocusFixer/FocusEnforcer.swift new file mode 100644 index 0000000..3980de0 --- /dev/null +++ b/FocusFixer/FocusEnforcer.swift @@ -0,0 +1,60 @@ +import AppKit +import ApplicationServices +import os + +/// Activates the app that owns a window and reasserts keyboard focus on +/// that specific window via the Accessibility API. +final class FocusEnforcer { + private let logger = Logger(subsystem: "com.local.focusfixer", category: "FocusEnforcer") + private let preferences: Preferences + + init(preferences: Preferences = .shared) { + self.preferences = preferences + } + + /// Activates the owning app and raises/focuses its window, unless it is + /// this app, already frontmost, or user-excluded. + func enforceFocus(forWindowOwnedBy pid: pid_t) { + guard pid != ProcessInfo.processInfo.processIdentifier else { + return + } + guard let app = NSRunningApplication(processIdentifier: pid) else { + logger.debug("No running application for pid \(pid, privacy: .public)") + return + } + if let bundleID = app.bundleIdentifier, preferences.isExcluded(bundleID: bundleID) { + return + } + guard !app.isActive else { + return + } + + if !app.activate(options: []) { + logger.debug("Activation returned false for pid \(pid, privacy: .public)") + } + raiseFocusedWindow(pid: pid) + } + + private func raiseFocusedWindow(pid: pid_t) { + let axApp = AXUIElementCreateApplication(pid) + var windowRef: CFTypeRef? + let copyResult = AXUIElementCopyAttributeValue(axApp, kAXFocusedWindowAttribute as CFString, &windowRef) + guard copyResult == .success, let windowRef else { + logger.debug("Could not resolve focused window via AX for pid \(pid, privacy: .public): \(copyResult.rawValue, privacy: .public)") + return + } + // AXUIElementCopyAttributeValue guarantees an AXUIElement on success; the + // Swift compiler can't express that constraint, hence the forced cast. + let axWindow = windowRef as! AXUIElement + + let raiseResult = AXUIElementPerformAction(axWindow, kAXRaiseAction as CFString) + if raiseResult != .success { + logger.debug("Raise action failed for pid \(pid, privacy: .public): \(raiseResult.rawValue, privacy: .public)") + } + + let focusResult = AXUIElementSetAttributeValue(axApp, kAXFocusedWindowAttribute as CFString, axWindow) + if focusResult != .success { + logger.debug("Setting focused window failed for pid \(pid, privacy: .public): \(focusResult.rawValue, privacy: .public)") + } + } +} diff --git a/FocusFixer/Info.plist b/FocusFixer/Info.plist new file mode 100644 index 0000000..8755319 --- /dev/null +++ b/FocusFixer/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + LSUIElement + + NSHumanReadableCopyright + Copyright © 2026. + + diff --git a/FocusFixer/PermissionManager.swift b/FocusFixer/PermissionManager.swift new file mode 100644 index 0000000..b534157 --- /dev/null +++ b/FocusFixer/PermissionManager.swift @@ -0,0 +1,47 @@ +import ApplicationServices +import Foundation +import os + +/// Requests and polls for the Accessibility (TCC) grant FocusFixer needs +/// to observe events and drive the AX API. +final class PermissionManager { + private let logger = Logger(subsystem: "com.local.focusfixer", category: "PermissionManager") + private var pollTimer: Timer? + + var isTrusted: Bool { + AXIsProcessTrusted() + } + + /// Shows the system Accessibility prompt (if not already trusted) and + /// polls until the grant appears, then calls `onGranted` on the main + /// queue. Safe to call repeatedly; a prior poll is cancelled first. + func waitUntilTrusted(pollInterval: TimeInterval = 1.0, onGranted: @escaping () -> Void) { + stopPolling() + + if isTrusted { + onGranted() + return + } + + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + logger.info("Accessibility permission not granted yet; prompting and polling") + + pollTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] timer in + guard let self else { + timer.invalidate() + return + } + guard self.isTrusted else { return } + timer.invalidate() + self.pollTimer = nil + self.logger.info("Accessibility permission granted") + onGranted() + } + } + + func stopPolling() { + pollTimer?.invalidate() + pollTimer = nil + } +} diff --git a/FocusFixer/Preferences.swift b/FocusFixer/Preferences.swift new file mode 100644 index 0000000..22ff91e --- /dev/null +++ b/FocusFixer/Preferences.swift @@ -0,0 +1,54 @@ +import Foundation + +/// UserDefaults-backed settings for FocusFixer. +final class Preferences { + static let shared = Preferences() + + private enum Key { + static let enabled = "enabled" + static let excludedBundleIDs = "excludedBundleIDs" + static let debounceIntervalMS = "debounceIntervalMS" + } + + private static let defaultDebounceIntervalMS = 150 + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + defaults.register(defaults: [ + Key.enabled: true, + Key.excludedBundleIDs: [String](), + Key.debounceIntervalMS: Self.defaultDebounceIntervalMS + ]) + } + + var isEnabled: Bool { + get { defaults.bool(forKey: Key.enabled) } + set { defaults.set(newValue, forKey: Key.enabled) } + } + + var excludedBundleIDs: Set { + get { Set(defaults.stringArray(forKey: Key.excludedBundleIDs) ?? []) } + set { defaults.set(Array(newValue), forKey: Key.excludedBundleIDs) } + } + + var debounceInterval: TimeInterval { + get { TimeInterval(defaults.integer(forKey: Key.debounceIntervalMS)) / 1000.0 } + set { defaults.set(Int(newValue * 1000), forKey: Key.debounceIntervalMS) } + } + + func isExcluded(bundleID: String) -> Bool { + excludedBundleIDs.contains(bundleID) + } + + func setExcluded(_ excluded: Bool, bundleID: String) { + var ids = excludedBundleIDs + if excluded { + ids.insert(bundleID) + } else { + ids.remove(bundleID) + } + excludedBundleIDs = ids + } +} diff --git a/FocusFixer/WindowLocator.swift b/FocusFixer/WindowLocator.swift new file mode 100644 index 0000000..1712b02 --- /dev/null +++ b/FocusFixer/WindowLocator.swift @@ -0,0 +1,49 @@ +import CoreGraphics +import os + +/// A single on-screen window as reported by the window server, reduced to +/// the fields FocusFixer needs. Kept separate from the raw CGWindowList +/// dictionary so the resolution logic below can be unit tested with +/// synthetic data instead of the real window server. +struct WindowInfo: Equatable { + let ownerPID: pid_t + let layer: Int + let bounds: CGRect +} + +enum WindowLocator { + private static let logger = Logger(subsystem: "com.local.focusfixer", category: "WindowLocator") + + /// Resolves the owning process ID of the frontmost on-screen window + /// under `point`, using the live window server state. + static func ownerPID(at point: CGPoint) -> pid_t? { + guard let list = CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID) as? [[String: Any]] else { + logger.error("CGWindowListCopyWindowInfo returned nil") + return nil + } + return ownerPID(at: point, in: list.compactMap(parse)) + } + + /// Pure resolution logic: `windows` is assumed to be in front-to-back + /// z-order, matching CGWindowListCopyWindowInfo's contract. Only + /// layer-0 windows (ordinary app windows, excluding menu bar, Dock, + /// Spotlight, and Control Center) are considered. + static func ownerPID(at point: CGPoint, in windows: [WindowInfo]) -> pid_t? { + for window in windows where window.layer == 0 && window.bounds.contains(point) { + return window.ownerPID + } + return nil + } + + private static func parse(_ dict: [String: Any]) -> WindowInfo? { + guard + let pid = dict[kCGWindowOwnerPID as String] as? pid_t, + let layer = dict[kCGWindowLayer as String] as? Int, + let boundsDict = dict[kCGWindowBounds as String] as? [String: Any], + let bounds = CGRect(dictionaryRepresentation: boundsDict as CFDictionary) + else { + return nil + } + return WindowInfo(ownerPID: pid, layer: layer, bounds: bounds) + } +} diff --git a/FocusFixer/main.swift b/FocusFixer/main.swift new file mode 100644 index 0000000..b57e54a --- /dev/null +++ b/FocusFixer/main.swift @@ -0,0 +1,6 @@ +import AppKit + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +app.run() diff --git a/FocusFixerTests/DebounceTests.swift b/FocusFixerTests/DebounceTests.swift new file mode 100644 index 0000000..039e5a9 --- /dev/null +++ b/FocusFixerTests/DebounceTests.swift @@ -0,0 +1,40 @@ +import XCTest +@testable import FocusFixer + +final class DebounceTests: XCTestCase { + func testFirstEventIsAlwaysProcessed() { + var clockValue: TimeInterval = 0 + var debouncer = Debouncer(interval: 0.15, clock: { clockValue }) + XCTAssertTrue(debouncer.shouldProcess()) + } + + func testEventWithinIntervalIsSuppressed() { + var clockValue: TimeInterval = 0 + var debouncer = Debouncer(interval: 0.15, clock: { clockValue }) + XCTAssertTrue(debouncer.shouldProcess()) + + clockValue += 0.05 + XCTAssertFalse(debouncer.shouldProcess()) + } + + func testEventAfterIntervalIsProcessed() { + var clockValue: TimeInterval = 0 + var debouncer = Debouncer(interval: 0.15, clock: { clockValue }) + XCTAssertTrue(debouncer.shouldProcess()) + + clockValue += 0.2 + XCTAssertTrue(debouncer.shouldProcess()) + } + + func testAcceptedEventResetsTheWindow() { + var clockValue: TimeInterval = 0 + var debouncer = Debouncer(interval: 0.15, clock: { clockValue }) + XCTAssertTrue(debouncer.shouldProcess()) + + clockValue += 0.2 + XCTAssertTrue(debouncer.shouldProcess()) + + clockValue += 0.05 + XCTAssertFalse(debouncer.shouldProcess()) + } +} diff --git a/FocusFixerTests/PreferencesTests.swift b/FocusFixerTests/PreferencesTests.swift new file mode 100644 index 0000000..f4c7ee0 --- /dev/null +++ b/FocusFixerTests/PreferencesTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import FocusFixer + +final class PreferencesTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + private var preferences: Preferences! + + override func setUp() { + super.setUp() + suiteName = "com.local.focusfixer.tests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + preferences = Preferences(defaults: defaults) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + preferences = nil + defaults = nil + suiteName = nil + super.tearDown() + } + + func testDefaultsToEnabled() { + XCTAssertTrue(preferences.isEnabled) + } + + func testDefaultDebounceIntervalIs150ms() { + XCTAssertEqual(preferences.debounceInterval, 0.15, accuracy: 0.0001) + } + + func testExclusionListMatching() { + XCTAssertFalse(preferences.isExcluded(bundleID: "com.apple.finder")) + + preferences.setExcluded(true, bundleID: "com.apple.finder") + XCTAssertTrue(preferences.isExcluded(bundleID: "com.apple.finder")) + + preferences.setExcluded(false, bundleID: "com.apple.finder") + XCTAssertFalse(preferences.isExcluded(bundleID: "com.apple.finder")) + } + + func testExclusionListRequiresExactMatch() { + preferences.setExcluded(true, bundleID: "com.local.focusfixer.helper") + XCTAssertFalse(preferences.isExcluded(bundleID: "com.local.focusfixer")) + XCTAssertTrue(preferences.isExcluded(bundleID: "com.local.focusfixer.helper")) + } +} diff --git a/FocusFixerTests/WindowLocatorTests.swift b/FocusFixerTests/WindowLocatorTests.swift new file mode 100644 index 0000000..333f301 --- /dev/null +++ b/FocusFixerTests/WindowLocatorTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import FocusFixer + +final class WindowLocatorTests: XCTestCase { + func testReturnsOwnerPIDWhenPointInsideWindow() { + let windows = [ + WindowInfo(ownerPID: 111, layer: 0, bounds: CGRect(x: 0, y: 0, width: 100, height: 100)) + ] + XCTAssertEqual(WindowLocator.ownerPID(at: CGPoint(x: 50, y: 50), in: windows), 111) + } + + func testIgnoresNonZeroLayerWindows() { + let windows = [ + WindowInfo(ownerPID: 999, layer: 25, bounds: CGRect(x: 0, y: 0, width: 100, height: 100)) + ] + XCTAssertNil(WindowLocator.ownerPID(at: CGPoint(x: 50, y: 50), in: windows)) + } + + func testFallsThroughToNextWindowWhenTopWindowIsWrongLayer() { + let windows = [ + WindowInfo(ownerPID: 1, layer: 25, bounds: CGRect(x: 0, y: 0, width: 100, height: 100)), + WindowInfo(ownerPID: 2, layer: 0, bounds: CGRect(x: 0, y: 0, width: 100, height: 100)) + ] + XCTAssertEqual(WindowLocator.ownerPID(at: CGPoint(x: 50, y: 50), in: windows), 2) + } + + func testReturnsFrontmostWindowInZOrder() { + // Overlapping layer-0 windows: front-to-back order (as returned by + // CGWindowListCopyWindowInfo) determines which owner wins. + let windows = [ + WindowInfo(ownerPID: 1, layer: 0, bounds: CGRect(x: 0, y: 0, width: 200, height: 200)), + WindowInfo(ownerPID: 2, layer: 0, bounds: CGRect(x: 0, y: 0, width: 200, height: 200)) + ] + XCTAssertEqual(WindowLocator.ownerPID(at: CGPoint(x: 50, y: 50), in: windows), 1) + } + + func testEmptyWindowListReturnsNil() { + XCTAssertNil(WindowLocator.ownerPID(at: CGPoint(x: 50, y: 50), in: [])) + } + + func testPointOutsideAllWindowsReturnsNil() { + let windows = [ + WindowInfo(ownerPID: 1, layer: 0, bounds: CGRect(x: 0, y: 0, width: 100, height: 100)) + ] + XCTAssertNil(WindowLocator.ownerPID(at: CGPoint(x: 500, y: 500), in: windows)) + } +} diff --git a/docs/manual-tests.md b/docs/manual-tests.md new file mode 100644 index 0000000..24f260c --- /dev/null +++ b/docs/manual-tests.md @@ -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`. diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..d1bd195 --- /dev/null +++ b/project.yml @@ -0,0 +1,53 @@ +name: FocusFixer +options: + minimumXcodeGenVersion: "2.38.0" +configs: + Debug: debug + Release: release +settings: + base: + SWIFT_VERSION: "5.9" + MACOSX_DEPLOYMENT_TARGET: "13.0" + CODE_SIGN_STYLE: Automatic + ENABLE_HARDENED_RUNTIME: NO +targets: + FocusFixer: + type: application + platform: macOS + deploymentTarget: "13.0" + sources: + - path: FocusFixer + excludes: + - Info.plist + settings: + base: + PRODUCT_NAME: FocusFixer + PRODUCT_BUNDLE_IDENTIFIER: com.local.focusfixer + INFOPLIST_FILE: FocusFixer/Info.plist + GENERATE_INFOPLIST_FILE: NO + FocusFixerTests: + type: bundle.unit-test + platform: macOS + deploymentTarget: "13.0" + sources: + - path: FocusFixerTests + dependencies: + - target: FocusFixer + settings: + base: + GENERATE_INFOPLIST_FILE: YES + BUNDLE_LOADER: "$(TEST_HOST)" + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/FocusFixer.app/Contents/MacOS/FocusFixer" +schemes: + FocusFixer: + build: + targets: + FocusFixer: all + FocusFixerTests: [test] + run: + config: Debug + test: + config: Debug + gatherCoverageData: false + targets: + - FocusFixerTests