Skip to content

fix(mac): virtual display hidden from ScreenCaptureKit by the TV mirror default#126

Draft
peetzweg wants to merge 2 commits into
mainfrom
fix/100-vd-missing-from-scshareablecontent
Draft

fix(mac): virtual display hidden from ScreenCaptureKit by the TV mirror default#126
peetzweg wants to merge 2 commits into
mainfrom
fix/100-vd-missing-from-scshareablecontent

Conversation

@peetzweg

@peetzweg peetzweg commented Jul 10, 2026

Copy link
Copy Markdown
Owner

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):

  1. A mirror-set secondary is not an active display and is omitted from SCShareableContent entirely — the display doesn't show up wrong, it vanishes from ScreenCaptureKit's world.
  2. macOS restores the saved TV "Mirror Entire Screen" arrangement asynchronously, seconds after creation. The fix(mac): force extend-mode virtual display out of system mirror sets #109 guard detaches it on its next tick (≤2s cadence), SCK takes ~2s more to re-list the display.
  3. That round trip regularly overruns findSCDisplay's hard 5s window → the exact error in the report.

Commit 1 — robustness + diagnostics (safe, inert for healthy machines)

  • findSCDisplay waits 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.
  • While waiting, log the CG-level display state, and embed it in the timeout error (inMirrorSet/mirroring/active/online/main). If this still fails for the reporter, the next screenshot diagnoses itself — worth more than knowing their macOS version.
  • Bug fix in 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:

  1. Merge commit 1 only; park commit 2 on this branch, linked from Stop macOS classifying the virtual display as a TV (root cause of #100 mirror default) #111, until the reporter's data (macOS version + the new self-diagnosing error text from a commit-1 build) proves classification is still the blocker.
  2. Commit 1 + commit 2 gated behind a hidden defaults knob; hand the reporter a Terminal one-liner to opt in, promote once proven.
  3. Ship both as-is.

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.

peetzweg added 2 commits July 10, 2026 15:58
…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 peetzweg marked this pull request as draft July 10, 2026 14:10

@peetzweg peetzweg left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 check stopped (Mac/MacSender.swift:348-361). Every other retry/poll loop in this file guards on stopped (start() at 206/237, reconfigure at 309/313, connect at 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 a stop() mid-setup leaves this loop spinning against a display ID that stop() already tore down (virtualDisplay = nil at 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. A if 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 as 2324757). git merge-base pr-126 origin/main is 3f13f9c, three commits behind — before DisplayArrangement/restoreOrigin/manageOrigin existed. A trial merge doesn't textually conflict (different regions of VirtualDisplay.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 current main, placement is already tracked independently via DisplayArrangement keyed on info.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 on main now, rather than re-deriving it after merge.

Minor

  • MacSender.swift:260: let ppi = info.kind == "iPad" ? 264.0 : 460.0 defaults to iPhone PPI whenever kind is unreported (older receivers omit device, so kind falls 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-63 are already flagged as a cost in #111 itself (System Settings will list them as selectable even though selectHiDPIMode'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 main on the strength of "the maintainer's machine still extends correctly."

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant