Skip to content

Fix bug: focus can land on a different window of the same app - #2213

Open
dmellok wants to merge 1 commit into
nikitabobko:mainfrom
dmellok:fix-focus-wrong-window
Open

Fix bug: focus can land on a different window of the same app#2213
dmellok wants to merge 1 commit into
nikitabobko:mainfrom
dmellok:fix-focus-wrong-window

Conversation

@dmellok

@dmellok dmellok commented Aug 7, 2026

Copy link
Copy Markdown

The bug

focus on one window of a multi-window app can focus a different window of that app. I hit it constantly with two Safari windows; it also pulled the focused workspace along when the window macOS picked was one AeroSpace had parked off-screen on an inactive workspace.

Conditions, which is why it doesn't reproduce for everyone:

  • Displays have separate Spaces off, or a single monitor
  • an app with more than one window
  • keyboard-driven focus only — a mouse click focuses natively and never enters this path

Cause

MacApp.nativeFocus has a fast path that skips the per-window AX raise and calls nsApp.activate() alone:

if (!NSScreen.screensHaveSeparateSpaces || monitors.count == 1) &&
    (lastNativeFocusedWindowId == windowId || windowsCount == 1)

activate() focuses whichever window macOS currently holds as the app's main one. lastNativeFocusedWindowId records what AeroSpace last observed, and the app's main window drifts from that on its own — a background window taking a link, an app reordering its own windows — so the condition isn't evidence that the requested window is the one activate() will bring.

The general path has a residual race too: the AX raise says which window should be main, and activating the app makes macOS restore whichever window it holds as main.

The fix

Two parts:

  1. The fast path now requires windowsCount == 1, where activate() can only mean the requested window. It still covers the case it was added for (apps slow to answer AX requests, which are overwhelmingly single-window).
  2. The result is verified rather than assumed: 150ms after asking, check the app's focused window and ask once more if it isn't the one requested. Bounded to a single retry, and skipped when the app is no longer frontmost so that focus moving to another app is left alone.

Measurements

Two Safari windows, focusing a different app between each attempt:

failures
before 1 in 8
after part 1 only 1 in 8
after both 0 in 24

swift test passes (382 tests).

Happy to split this into two commits, drop the retry and keep only the fast-path fix, or move the discussion to Discussions first if you'd prefer that — just say which.

nativeFocus could skip the per-window AX raise and call nsApp.activate()
alone, on the evidence that the last window AeroSpace observed as focused
already matched the requested one. That evidence doesn't hold: activate()
focuses whichever window macOS currently holds as the app's main one,
while the observation records only what AeroSpace last saw. The two drift
apart on their own - a background window taking a link, an app reordering
its own windows - so 'focus' on one window of a multi-window app could
activate another.

The shortcut now requires the app to have exactly one window, where
activate() can only mean that window. It still covers the case it was
added for: apps slow to answer AX requests, which are overwhelmingly
single-window. lastNativeFocusedWindowId goes with it - the shortcut was
its only reader.

That leaves a race in the general path. The AX raise says which window
should be main, and activating the app makes macOS restore whichever
window it still holds as main; when macOS wins, focus lands on a sibling
window - observed landing on a window parked off-screen on an inactive
workspace, which then pulled the focused workspace along with it. The
result is checked instead: 150ms after asking, look at the app's focused
window and ask once more if it isn't the one requested. The retry is
bounded to one attempt, and is skipped when the app is no longer frontmost
so that focus moving to another app is left alone.

Only reachable when 'Displays have separate Spaces' is off or there is a
single monitor, which is why it doesn't reproduce for everyone. Only
keyboard-driven focus is affected: a mouse click focuses natively and
never enters this path.

Measured with two Safari windows, focusing another app between attempts:
1 failure in 8 before, 0 in 24 after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dmellok
dmellok force-pushed the fix-focus-wrong-window branch from b77da48 to 02528c2 Compare August 7, 2026 02:15
@dmellok

dmellok commented Aug 7, 2026

Copy link
Copy Markdown
Author

Force-pushed a fix for the CI failure. Periphery caught two things:

  • raiseAxWindow slipped in from my own fork and doesn't belong in this patch — removed.
  • lastNativeFocusedWindowId became assign-only, since the shortcut this changes was its only reader. Removed the property and its assignment in updateFocusCache too, which I should have done in the first place.

All five jobs pass now.

@nikitabobko nikitabobko added the not-actionable By default, all PRs have this label. If the PR makes sense, the label will be removed. label Aug 10, 2026
vitorebatista added a commit to vitorebatista/AeroSpace-edge that referenced this pull request Aug 27, 2026
…te sync state

Sync state moves to upstream main @ c548c7f / 59 open PRs, reviewed 2026-08-27.
Adds the newly ported upstream PRs (2244, 2232, 2211, 2228, 2225) to the
already-backported list, and records this cycle's skips: focus-follows-mouse PRs
(nikitabobko#2238/nikitabobko#2245, feature absent in the fork), nikitabobko#2220 (duplicate of the already-ported
nikitabobko#2024), nikitabobko#2213 (deletes lastNativeFocusedWindowId, which fork PR #59 depends on),
nikitabobko#2206 (competing implementation of nikitabobko#2225), and six deferred feature PRs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBcwRe3CktWZGGcHJHP4ds
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

not-actionable By default, all PRs have this label. If the PR makes sense, the label will be removed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants