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. Full investigation: PhantomWindowDetection.swift.
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.syncVerdict(s, app) — synchronous, cheap, runs on every show (Window.recomputeIsPhantom). Has
only local facts, so it can observe only the strong signal. Assert-only: it ORs the strong signal
onto the current s.isPhantom, so it may raise the flag but never clears it. A weak-signal phantom
keeps its Space, which this path can't see; clearing here would clobber cgsVerdict's verdict on every
show and the phantom would reappear on every summon (the #5714 bug).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 an authoritative verdict).