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:
@@ -0,0 +1,7 @@
|
|||||||
|
.DS_Store
|
||||||
|
build/
|
||||||
|
DerivedData/
|
||||||
|
*.xcuserstate
|
||||||
|
xcuserdata/
|
||||||
|
.swiftpm/
|
||||||
|
.build/
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
let app = NSApplication.shared
|
||||||
|
let delegate = AppDelegate()
|
||||||
|
app.delegate = delegate
|
||||||
|
app.run()
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
Reference in New Issue
Block a user