src/switcher/state/PhantomWindowDetectorSpecs.md
PhantomWindowDetector decides whether a window is a phantom — present in macOS APIs (AX returns it
as a live window with a valid CGWindowID) but not something the app means to show the user, so AltTab
shouldn't offer it as a switch target. The pixel content may be absent, black, or anything; that's the
symptom, not the definition. Producers: alpha=0 Outlook reminders (#5170/#5448), orderOut: /
show:false Electron windows (Codex/Slack #5714, Joplin #5495, Sprig #5496), WeChat/Teams/DingTalk
hidden windows (#5508). Extracted as a pure kernel from Window / Applications so the "is this a
phantom?" decision is unit-testable without CGS/AX.
A phantom is read on two orthogonal CGS axes:
cgWindowId.spaces() → CGSCopySpacesForWindows(…, .all, …)) — which Space a
window belongs to. orderOut: / setAlphaValue:0 does not un-assign it.CGSCopyWindowsWithOptionsAndTags with vs. without the .invisible1/.invisible2
bits) — the inVisibleList (excludes the invisible tags) vs inAllList (includes them) pair.They're independent, which gives two strengths of phantom:
spaceIds comes back [].inAllList but not inVisibleList; CGS still tracks it (so spaceIds is
non-empty) but tags it invisible. Non-empty spaceIds therefore does not imply visibility.There are exactly two phantom families, and the batched WindowServer query AltTab already issues states both of them outright rather than by inference:
| family | producer | the fact |
|---|---|---|
| ordered out | Electron show:false (#5714, #5495, #5496) | the ordered-in bit is clear |
| alpha 0 | Outlook reminders (#5170, #5448) | alpha == 0 |
The split is real: accessibility does NOT see the alpha-0 phantom (it lists it as an ordinary
AXStandardWindow), and only the ordered-out one is absent from kAXWindows.
What these replace is a chain of stand-ins, each of which cost a regression. "Absent from every Space" stood in for ordered-out, which is also what a live window looks like when our Space data is wrong (#5791, #5954). "CGS tagged it invisible" stood in for alpha-0, which is also what CGS says about a freshly reopened Electron window for seconds after it is on screen and focused (#5849) — a case that needed an explicit FOCUS exemption, threading the MRU into a pure kernel, and now needs none, because such a window is ordered in.
Both CGS lists stay, and neither is redundant. inAllList answers the one question the WindowServer query
cannot: whether CGS has any record of the wid. inVisibleList is kept as a CLEARING signal only — it can
exempt a window, never flag one — because the ordered-in bit is maintained by 815/816, the batched query and
the Space deltas, and a window whose Space becomes current again need not produce any of them. Dropping it
was tried and hid a fullscreen window's own tile on the summon right after switching back to its Space.
syncVerdict(s, app) — synchronous, cheap, runs on every show (Window.recomputeIsPhantom). Has
only local facts, so it can observe only the strong signal. Monotonic for the weak signal: it ORs
the strong signal onto the current s.isPhantom, so it may raise the flag but never clears it on a
non-empty Space. A weak-signal phantom keeps its Space, which this path can't see; clearing there would
clobber cgsVerdict's verdict on every show and the phantom would reappear on every summon (the #5714
bug). Exception — isTabbed clears: AX tab detection is authoritative but lands after a window is
first seen, so an inactive tab is briefly flagged phantom (empty spaceIds, not-yet-known tabbed); once
AX confirms the tab this path un-flags it (a real phantom is never part of an AXTabGroup). Without it,
the monotonic OR left inactive tabs stuck phantom and "separate window per tab" showed one per app.cgsVerdict(s, app, inVisibleList, inAllList, visibleSpaceIds) — authoritative, runs ~250ms
post-show off-main (Applications.refreshIsPhantom) with the two CGS lists. Knows both signals; owns
the full verdict, including clearing. Disambiguation order (first match wins):
inAllList → phantom (strong)inVisibleList → not a phantom (currently rendered)spaceIds ∩ visibleSpaceIds == ∅ → not a phantom (other-Space window)orderOut: on a visible Space)Mirrors PhantomWindowDetectorTests.swift 1:1. Each test starts from an all-permissive baseline window
and flips the knobs it exercises.
spaceIds → stays a phantom (the #5714
invariant: the synchronous path never clears a latched verdict).orderOut: / show:false family.spaceIds are backfilled from its active sibling): the legitimate-window exemption
beats the strong signal, or the tab disappears.