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
+7
View File
@@ -0,0 +1,7 @@
.DS_Store
build/
DerivedData/
*.xcuserstate
xcuserdata/
.swiftpm/
.build/
+124
View File
@@ -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.
+427
View File
@@ -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 = "<group>"; };
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 = "<group>"; };
14D8963F47F88A0EB59672CD /* WindowLocatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowLocatorTests.swift; sourceTree = "<group>"; };
18945640C7DCF7D2BB10B26D /* PreferencesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesTests.swift; sourceTree = "<group>"; };
18AE36788814E7B845FD6E7F /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
583FC010F2AC5DA68F9A479C /* FocusEnforcer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusEnforcer.swift; sourceTree = "<group>"; };
67290BF09C16440EE8EC5F0A /* DebounceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebounceTests.swift; sourceTree = "<group>"; };
8BA5F1D3E43F0843B442B0C3 /* PermissionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionManager.swift; sourceTree = "<group>"; };
94BEBF21BDD32FE15C4B8F0C /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
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 = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
1791F5CD1D3C0EDC80AA8EA8 /* FocusFixerTests */ = {
isa = PBXGroup;
children = (
67290BF09C16440EE8EC5F0A /* DebounceTests.swift */,
18945640C7DCF7D2BB10B26D /* PreferencesTests.swift */,
14D8963F47F88A0EB59672CD /* WindowLocatorTests.swift */,
);
path = FocusFixerTests;
sourceTree = "<group>";
};
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 = "<group>";
};
38C55F723E487F1371990734 = {
isa = PBXGroup;
children = (
211A341AA5FDBEBBB831893A /* FocusFixer */,
1791F5CD1D3C0EDC80AA8EA8 /* FocusFixerTests */,
882C44028E7037E70CA117B4 /* Products */,
);
sourceTree = "<group>";
};
882C44028E7037E70CA117B4 /* Products */ = {
isa = PBXGroup;
children = (
9D1D1A4D3772591B7BE3B668 /* FocusFixer.app */,
0E21E251CF9229938F0B628E /* FocusFixerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,118 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "2274BE8F97DC846458C50849"
BuildableName = "FocusFixer.app"
BlueprintName = "FocusFixer"
ReferencedContainer = "container:FocusFixer.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C276426D2F31753646ED8E29"
BuildableName = "FocusFixerTests.xctest"
BlueprintName = "FocusFixerTests"
ReferencedContainer = "container:FocusFixer.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "2274BE8F97DC846458C50849"
BuildableName = "FocusFixer.app"
BlueprintName = "FocusFixer"
ReferencedContainer = "container:FocusFixer.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C276426D2F31753646ED8E29"
BuildableName = "FocusFixerTests.xctest"
BlueprintName = "FocusFixerTests"
ReferencedContainer = "container:FocusFixer.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "2274BE8F97DC846458C50849"
BuildableName = "FocusFixer.app"
BlueprintName = "FocusFixer"
ReferencedContainer = "container:FocusFixer.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "2274BE8F97DC846458C50849"
BuildableName = "FocusFixer.app"
BlueprintName = "FocusFixer"
ReferencedContainer = "container:FocusFixer.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+79
View File
@@ -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
}
}
+121
View File
@@ -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<CGEvent>? {
if let refcon {
let controller = Unmanaged<EventTapController>.fromOpaque(refcon).takeUnretainedValue()
controller.handle(type: type, event: event)
}
return Unmanaged.passUnretained(event)
}
+60
View File
@@ -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)")
}
}
}
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2026.</string>
</dict>
</plist>
+47
View File
@@ -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
}
}
+54
View File
@@ -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<String> {
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
}
}
+49
View File
@@ -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)
}
}
+6
View File
@@ -0,0 +1,6 @@
import AppKit
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.run()
+40
View File
@@ -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())
}
}
+47
View File
@@ -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"))
}
}
+47
View File
@@ -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))
}
}
+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`.
+53
View File
@@ -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