src/windowserver/ActivationFocusResolverSpecs.md
Pure decisions for MRU focus around an app activation, extracted from WindowServerEvents after two
regressions in a row (#5596). The recorded ground truth (TextEdit and iTerm, via Cmd+Tab, clicks, and
AltTab-initiated focus): on activation macOS emits 808s for the app's on-Space windows — the first is the
genuinely focused window; the rest, when there is a storm at all, are raises front-to-back; sometimes there is
no storm, just the single focus 808 (iTerm). Three rules follow:
isActive guard
(NSRunningApplication.isActive is a separate clock and can still read false at that instant).bumpFocusOnActivation, for activations that emit no 808) is the weak
signal: it races the app's internal focus update and can return the previous window (iTerm with panes).
It yields once the activation's focus 808 has spoken (focusBumped), checked at apply time since the read
is async and can land after the 808.ActivationEntry is the per-pid state: the snapshot wids (only windows the storm can raise — the adapter
excludes minimized and inactive tabs), the until expiry (0.5s; generous because the 808s queue behind
AltTab's own activation work), and focusBumped.
onFocusEvent(entry, wid, now, wasJustCreated, appIsActive) -> FocusDecision — decide one 808:
bump + the entry state to store back. Expired entry ⇒ pruned (nil) and plain rules apply. Brand-new
window ⇒ always bump. First 808 of a live activation ⇒ bump, mark focusBumped, consume the wid.
In-snapshot 808 after that ⇒ raise, swallow, consume. Otherwise ⇒ bump iff appIsActive.onActivation(snapshotWids, until, altTabTarget) -> (entry, bumpWid) — build the activation entry.
A known AltTab-initiated target (switcher selection / CLI focus) is bumped directly with focusBumped set
(raise tail swallowed, AX backstop yields) — with no 808 and a stale AX read, the freshly-focused window's
bump was otherwise lost. No target ⇒ plain entry; the first 808 or the AX backstop decides.axBackstopShouldApply(entry) -> Bool — false only when a live entry has focusBumped (the real
808 already spoke); nil/expired/pre-focus ⇒ true.Mirrors ActivationFocusResolverTests.swift 1:1.
isActive still
false (the iTerm #5596 case) → bump; focusBumped set; wid consumed.until → pruned to nil; plain isActive rule.focusBumped so the raise tail is swallowed and the AX backstop yields.focusBumped → yield (the stale-AX race, #5596).