fix(mac): virtual display hidden from ScreenCaptureKit by the TV mirror default#126
fix(mac): virtual display hidden from ScreenCaptureKit by the TV mirror default#126peetzweg wants to merge 2 commits into
Conversation
…ay from ScreenCaptureKit (#100) On machines where macOS misclassifies the virtual display as a TV, the saved "Mirror Entire Screen" arrangement is restored asynchronously — seconds after creation — and a mirror-set secondary is not an active display: it is omitted from SCShareableContent entirely (measured). The un-mirror guard detaches it on its next tick (<=2s cadence) and SCK takes ~2s more to re-list it, so the old hard 5s wait in findSCDisplay regularly expired first: 'virtual display never appeared in SCShareableContent'. Allow 15s (independently proposed in PR #106), log the CG-level display state while waiting, and put that state into the error so the next report diagnoses itself. Also scan the ONLINE display list when breaking mirror sets: a display that mirrors another is never in the ACTIVE list, so the reverse- direction detach could not find it.
…ying the TV mirror default (#111) Root-cause attempt for the #100 misclassification, mirroring what DeskPad (same private API, never TV-classed) does differently: - advertise standard monitor timings (1024x768/800x600/640x480, when they fit under native) alongside the single device-native mode — a one-non-standard-mode EDID is the prime TV-heuristic suspect - derive the physical size from a per-kind PPI estimate instead of the phone-sized 147x68mm constant, which was wrong for every iPad - bump the product id (0x4F53 'OS' -> 0x4F44 'OD') so installs with a poisoned saved arrangement (display pinned into a mirror set, keyed on vendor/product/serial) start from a clean slate; one-time cost is macOS forgetting the display's saved position Cannot be validated on a machine that classifies the display correctly — needs the reporter's setup; the #100 guard plus the 15s SCShareableContent window keep Extend correct either way.
peetzweg
left a comment
There was a problem hiding this comment.
Commit 1 (ea3fa34, #100) does what it claims and should ship; commit 2 (3a7ea30, #111) is exactly as speculative as the PR body admits and I'd hold it. Overall verdict: approve commit 1, do not merge commit 2 yet — go with option 1 from the "Ship decision" section.
Tracing the fix against the failure mode
The reporter's arrangement was "VD set as Main, built-in mirroring it." Before this PR, ensureNotMirrored broke that by (a) detaching the VD's own mirror-of setting and (b) scanning CGGetActiveDisplayList for any other display mirroring the VD and detaching that too. But a display that is mirroring another is not active — so step (b) could never find the built-in in exactly this arrangement, silently no-op'ing on the reporter's specific setup since #109 shipped. Mac/VirtualDisplay.swift:148-150 switches that scan to CGGetOnlineDisplayList, which does include it. This is the load-bearing line of the PR: without it, the un-mirror guard only ever fixed "VD mirroring the built-in," not "built-in mirroring the VD," and the second is what the reporter has. Good catch, and it's correctly scoped — matching only CGDisplayMirrorsDisplay(other) == id means it can't touch mirror sets that don't involve the VD.
The findSCDisplay timeout bump (Mac/MacSender.swift:347-361, 5s → 15s) is arithmetically justified by the guard's own cadence (≤2s to detach + ~2s for SCK to re-list), and is inert for healthy machines since they exit the loop on first success. Combined with the online-list fix, this covers the reported failure mode.
Worth fixing
-
findSCDisplay's retry loop doesn't checkstopped(Mac/MacSender.swift:348-361). Every other retry/poll loop in this file guards onstopped(start()at 206/237,reconfigureat 309/313,connectat 473, the reconnect scheduler at 541/574/583). This loop doesn't, and it never did — but tripling the window from 5s to 15s triples how long astop()mid-setup leaves this loop spinning against a display ID thatstop()already tore down (virtualDisplay = nilat line 421) before finally throwing into a dead call chain. Not a crash, just wasted work and log noise on exactly the unhealthy-machine path this PR is meant to help diagnose. Aif stopped { throw CancellationError() }inside the loop would close it out now that the window is real enough to matter. -
The branch predates the arrangement-persistence work on
main(PR #127, merged as2324757).git merge-base pr-126 origin/mainis3f13f9c, three commits behind — beforeDisplayArrangement/restoreOrigin/manageOriginexisted. A trial merge doesn't textually conflict (different regions ofVirtualDisplay.swift/MacSender.swift), but the commit-2 message's stated cost — "every existing user's Mac forgets the display's saved position once" from the productID bump — was written against the old serial-keyed-only world. On currentmain, placement is already tracked independently viaDisplayArrangementkeyed oninfo.id, which should make that one-time forgetting non-events for anyone whose receiver reports an id. Worth rebasing before the ship decision so the risk writeup in the PR body reflects what's actually onmainnow, rather than re-deriving it after merge.
Minor
-
MacSender.swift:260:let ppi = info.kind == "iPad" ? 264.0 : 460.0defaults to iPhone PPI wheneverkindis unreported (older receivers omitdevice, sokindfalls back to the literal string"device"). That's a no-worse-than-before fallback (old code used a phone-sized constant unconditionally too), but it means the exact users on old receiver builds don't get the corrected physical size that's the point of this change — the fix silently doesn't apply to them. Not blocking, just worth knowing the fallback population isn't covered. -
The three extra advertised modes in
Mac/VirtualDisplay.swift:55-63are already flagged as a cost in #111 itself (System Settings will list them as selectable even thoughselectHiDPIMode's enforcement loop fights back every 2s) — no new finding here, just confirming the tradeoff is accepted knowingly rather than overlooked. -
Commit 2's core premise — that a single non-standard EDID mode and/or the old productID caused the TV misclassification — is, per #111 and the PR body itself, unvalidated on any machine that actually reproduces it. Shipping it changes identity (and, before a rebase, the saved-position cost) for every user to test a hypothesis that hasn't been confirmed even once. That's a reasonable experiment to keep parked behind a flag or on this branch, but not something to merge to
mainon the strength of "the maintainer's machine still extends correctly."
Second report on #100; also touches #111. Parked as draft: the two commits have very different risk profiles and we haven't decided yet how much of this to ship — see "Ship decision" below.
Why: After 0.11.0 the reporter's failure changed from "stuck mirroring" to
WiFi • Failed: virtual display never appeared in SCShareableContent. Reproduced the mechanism locally with a harness that force-mirrors the virtual display at creation (what the TV default does on the reporter's Mac):SCShareableContententirely — the display doesn't show up wrong, it vanishes from ScreenCaptureKit's world.findSCDisplay's hard 5s window → the exact error in the report.Commit 1 — robustness + diagnostics (safe, inert for healthy machines)
findSCDisplaywaits 15s instead of 5s (same direction PR Add OpenDisplay Legacy: standalone iOS 12 companion app #106 proposed). Healthy machines still exit on the first successful poll (~0.3–3s measured) — nobody waits longer unless they're already failing.inMirrorSet/mirroring/active/online/main). If this still fails for the reporter, the next screenshot diagnoses itself — worth more than knowing their macOS version.ensureNotMirrored: it scanned the active display list to find displays mirroring the VD, but a mirroring display is never active (measured) — switched to the online list. This code path only executes when a VD is already in a mirror set, i.e. never on unaffected Macs.Based on the measured timings there's a decent chance this half alone fixes the reporter.
Commit 2 — root-cause experiment (#111): monitor-like identity (speculative, affects everyone)
Mirrors what DeskPad (same private API, never TV-classified) does differently: a ladder of standard modes (1024×768/800×600/640×480 when they fit under native), PPI-derived physical size instead of the phone-sized 147×68mm constant, and a product-id bump (
"OS"→"OD") so installs with a poisoned saved mirror arrangement start from a clean slate.Costs, honestly: every existing user's Mac forgets the display's saved position once (identity change); System Settings lists three extra selectable resolutions the enforcement loop will fight; and the classification benefit is unvalidated — the maintainer's machine classifies the display correctly, so this cannot be tested without the reporter's setup.
Ship decision (open)
Options, in rough order of preference:
defaultsknob; hand the reporter a Terminal one-liner to opt in, promote once proven.Verification
Fake receiver + mirror-attacker harness: session heals to "Extending" in ~1.3s under attack; both rotation rebuilds clean; baseline extend unaffected. Verified on macOS 26.5.1 (M5 Pro) — which does not reproduce the TV classification, hence the caution above.