Follow the dock: auto-disconnect the built-in panel, and rescue an all-dark Mac - #145
Follow the dock: auto-disconnect the built-in panel, and rescue an all-dark Mac#145ncchen99 wants to merge 2 commits into
Conversation
… Mac Opt-in and off by default, hidden on a Mac with no built-in panel (didriksg#92). When an external display arrives the built-in is disconnected; when the last one leaves it comes back. A blackout watch sits underneath: if every screen goes dark while the rule owns the built-in's state, it brings a display back rather than leaving the machine unusable. Rebuilt on main rather than replayed. The branch predated didriksg#101 and didriksg#133, so two things changed on the way: - The wake path is `reconcile`, not the `reapplyOnWake` this branch added; the guard that keeps a stale built-in disconnect from being re-applied while nothing external is up now filters `reconcile`'s pending list instead. - `usablePhysicalDisplayIDs` was the same test as main's `viewableActiveDisplays`, written before it existed. Dropped, and the existing one is now internal. The two must stay one test: a display that does not count as a screen for the count cannot count as an external for the rule. make check green.
The blackout watch decided whether anything on the desk could be believed by keeping the display IDs from the last demonstrably lit desk. macOS re-issues the ID at display sleep (2 -> 31, 34, 36, 38 on four consecutive sleeps here), so on a single-display desk the one monitor stopped being recognised the moment it slept: an ordinary display sleep read as a blackout, and each one cost an enable that failed at 10 s. Keying on the UUID instead was tried and is not the fix: the phantom left by an undock while asleep carries the same UUID as the real monitor, measured with the dock out of the machine, so the watch trusted the phantom, never armed, and the desk stayed dark until the dock went back in -- worse than the bug it fixed. Identity cannot separate a sleeping display from an absent one. didriksg#133's predicate can, so the watch reads it: phantomAwareActiveDisplayCount, now internal. A sleeping display keeps a port carrying it and stays trusted; a phantom has no port and does not. Both readers of that count are rescues, which is the bargain its doc comment states -- a wrong answer can only make a rescue fire, never hide a row or refuse a remembered disconnect. The comment now names the second one. lastHealthyDisplayIDs stays, with a narrower job: it is what still separates an external that turned up during a blackout from one that was already there, which the port signal cannot do, being a count that names no display. make check green.
aef3f42 to
b5b9b3b
Compare
|
Nice work on the auto-dock switching logic! Just wanted to flag a potential timing nuance with the newly merged PR #144 (Re-assert True Tone after wake, #131):
If a user has True Tone enabled and wakes with an external display, #144 will toggle True Tone at t = 2.5s while the built-in panel is still online (which results in no tint correction because macOS still computes against the built-in panel). By the time the built-in panel is disconnected at t = 8.5s, #144 has already finished its one-shot run. Suggestion: if CoreBrightnessService.shared.trueToneEnabled {
CoreBrightnessService.shared.reassertTrueTone()
}This guarantees True Tone is always re-evaluated against the standalone external monitor right after the built-in panel goes dark. |
|
Thank you for this — reading across two PRs at once is easy to skip, and the sequencing half of it is exactly right. I went and checked before answering, and I found something that is worth more than my answer, so let me put that first. Your timings hold. #144's re-assert sits inside the wake chain's three passes, which land at roughly 2.5 s, 6.5 s and 14.5 s after And Which means that if you are right about the mechanism, the gap is wider than this PR. #144 does not run on a dock plug (no wake, so Where I am hesitant is the premise, and I would rather say so plainly than quietly not do it. Your own #131 write-up puts the cause on the external being absent when macOS computes the tint, rather than on the built-in being present:
And the toggle that corrects it — Control Center, or Crisp's row — is one you do with the built-in online and the lid open. So a re-assert taken while the built-in is still there does seem to correct it, which is the step I cannot get past. I did chase one idea and it came to nothing, so here it is in case it saves you the same detour. So it comes down to one thing only you can check, since you have the desk that shows the effect and I have not been able to reproduce #131 here — which is also why @didriksg marked #144 "not driven". With your Mac awake and settled and the tint correct on the external, disconnect the built-in by hand from Crisp's Disconnect row. Does the tint shift?
Either way I would rather wait for that one observation than add a tint blink to every dock on a guess. One small note on the snippet, only because it would bite whoever writes it: Thanks again — genuinely useful, whichever way the measurement goes. |
|
Thanks for the thorough breakdown! Here are the exact empirical findings from measuring the white points on this setup: 1. The Dual-Display vs Single-Display RealityOn Apple Silicon macOS, when both the built-in Liquid Retina display and a 3rd-party external monitor (e.g. SANC G3Q) are online simultaneously:
2. Why the Timing Gap Breaks #144 in PracticeBecause of this behavior:
3. Regarding Where the Hook BelongsYour insight about placing the hook beside
That closes the loop cleanly for all three paths! |
|
That is a much better answer than the one I asked for — thank you for going and measuring the white point rather than arguing the point. One thing I would like to square with you before either of us builds on it, because I think it changes where this belongs rather than whether it is real. Your #131 report and this measurement seem to describe different states, and my best reading is that both are true and they are three states rather than two:
If that reading is right, then #144 does fix #131 — it takes state 1 to state 2 — and what you are asking for here is state 3, which is a better outcome than the issue originally asked for rather than a defect in the merged fix. Does that match what you see? If instead a toggle in dual-display mode leaves your external worse than untouched, then #144 has a real gap for lid-open users and that is considerably more urgent than anything in this PR. Either way I do not think this branch is the right place for it, and I would rather say why than just decline. Putting If you would rather not write that up, say so and I will open it and credit the measurements to you. And if he wants it, I am happy to do the work, here or in a separate PR, whichever he prefers. Out of interest, and useful for the issue: what did you measure with? A colorimeter reading the panel, or a software probe? He will almost certainly ask, and the answer is worth having in the thread from the start. |
|
Your three-state model is a remarkably clear and accurate summary of exactly what is happening under the hood. State 1 (wake distortion) -> State 2 (D65 neutral via #144) -> State 3 (~5001K true ambient adaptation post-disconnect). To answer your question: the white point coordinates were extracted via a macOS ColorSync software probe (reading the display profile's live adapted white point / CIE xy chromaticity from the window server). Since you already articulated the architectural considerations and the three-state model so well, please feel free to open the dedicated issue on your end! Looking forward to seeing @didriksg's thoughts on the product direction for State 3. Thanks again for the great collaboration and detailed analysis! |
|
Opened as #147, with your white points, the three-state model and the ColorSync-probe caveat stated up front so @didriksg can weigh it himself. I also wrote out the cost honestly — a tint blink on every built-in disconnect, in a service that has no colour side effects today — and listed the options rather than arguing for one, since that part is his call. One shape neither of us can reach, noted there in case you can: a clamshell user has no built-in to disconnect, so if the same D65 pinning happens with the lid closed, the hook proposed here never fires for them. Thanks for measuring rather than letting me talk you out of it — the D65 coordinates are what turned this from a disagreement into an issue worth filing. |
|
I've reached a decision on this, and it's no. Not this PR, and not the feature behind it. When #92 came up I said yes in principle to an opt-in rule. Since then the pieces it needed have shipped and I've watched what they cost: #99, #106, #132 and #133 each closed a gap the previous one opened, and this PR adds a suspect list and a second rescue against the gaps in #133. That is the honest shape of any code that decides on its own whether a display should be off, because the inputs it decides on, the online list, the active count, the port nodes, are all things macOS gets wrong for seconds at a time around sleep and wake. A wrong guess blanks a screen. I'm not going to ship a rule whose failure mode is a dark Mac, however it is guarded, and I can't test it on desks I don't have. So the line is this: Crisp changes a display's state when you click on that display, and never otherwise. Disconnect keeps it off across sleep, relaunch and reboot, which is #101, and the rescue overrides it only when it is the last screen left. I measured that tonight: the external switched off at the window server, about four seconds dark, the built-in back, record cleared. On a desk that docks daily that is one click per dock. That's the trade, and it's the one I want. I did consider the middle ground, the record surviving the rescue so the built-in goes off again when the external returns, like macOS remembering a resolution per arrangement. It's the cleanest version of this idea, and it's still one more state on the path. If it turns out more people want it once 1.6.0 has been out, that's the version I'd look at. Thank you for this work, and for #99, #101, #132 and #133, which are all in 1.6.0 and made the disconnect feature something I'd stand behind. Closing this and #92. |
Follow the dock: auto-disconnect the built-in panel, and rescue an all-dark Mac
Closes #92. Opt-in, off by default, and the row is hidden on a Mac with no built-in panel
(
isAvailablealso requirestoggle.isSupported, so the whole thing is a no-op on Intel) —the terms you set in the thread.
What it does
The rule (
SettingsService.autoBuiltinFollowsExternal, menu row "Disconnect Built-inWhile Docked"): an external is online, so the built-in comes out of the layout; the last
external goes away, so it comes back. Doing it by hand is the same two calls, so this only
removes the two clicks per dock and undock. Reconnecting the panel by hand hands it back to
the user until the next real undock — the rule automates the dock, it does not police the
panel.
The rescue: re-enables window-server-disabled displays when nothing viewable is left.
This is deliberately narrower than it was when the issue was opened, because you fixed the
thing underneath it in the meantime.
restoreIfNoActiveDisplayworks from thedisconnectedrecords, and with #132 and #133 in it now does that job well. What it still cannot cover is a
display the window server has switched off with no record behind it — a crash, a
relaunch, or a wake from a multi-day standby leaves exactly that — so this half works from
remembered display IDs instead and calls
forceEnable, which bypasses the recordbookkeeping.
It is armed by an evaluation, never by a standing timer. The only thing it polls is a desk
that is already dark, where no further reconfiguration callback need arrive to say whether it
got better, and that watch is bounded at both ends: it stops when a screen is back, when the
machine sleeps, once it has rescued, or after 30 s. Sleep is excluded outright — rescuing
into a sleeping Mac switched the built-in back on mid-sleep, so it was already lit at the
next wake with nothing left for the rule to disconnect.
Your design note, and where I did not take it
You suggested firing on the edge — once when the external count goes zero to non-zero —
rather than continuously on the condition, which would make a manual reconnect survive for
free and delete the stand-down state.
I took it for the undock (
undocked = wasDocked && !docked) and not for the dock, and I wantto be straight that this is the one place I went the other way, because two cases fall
through a pure edge:
rule does nothing at the exact moment the user asked for it.
wouldLeaveNoActiveDisplayrefuses to take thelast screen, and a refusal spends the edge; the condition simply tries again at the next
evaluation once a real external is up.
So the dock side stays a condition, and
userOwnsBuiltinis the stand-down you predicted Iwould need. It is cleared only by an undock that actually happened. That distinction cost me
a bug worth reporting: switching the external off from Crisp's own menu empties the external
list identically, and reading that as an undock is how connecting that display again took
away a panel the user had just asked for. Fixed, and it is why
userRemovedExternalexists.If you would still rather have the edge and lose those two cases, it is a small change and I
will make it.
The
softReconnectguard you asked for is in, in both places it matters:evaluate()returns early whiletoggle.isBlinking, and the blackout watch refuses to actwhile it is set, so the one-second dip a blink produces cannot read as an undock or a
blackout.
The part that took the longest: what a display can be trusted on
The blackout watch has to decide whether anything on the desk is actually showing a picture.
That decision was wrong twice before it was right, and both attempts are measured, so here
they are rather than just the answer.
Keying on
CGDirectDisplayID(what I first shipped on the branch) is wrong, becausemacOS re-issues the ID at display sleep — 2 to 31, 34, 36, 38 on four consecutive sleeps
here. On a single-external desk the one monitor therefore stopped being recognised the moment
it slept: an ordinary display sleep read as a blackout, and every one cost an
enablethatfailed at 10 000 ms.
Keying on the UUID instead is also wrong, and this one looked right for a while. The UUID
is stable across the display-sleep re-enumeration,
make checkpassed, and three displaysleeps came through clean. Then the dock pull: the phantom an undock leaves behind carries
the same UUID as the real monitor (
id=44,uuid=97AD1CC3-…F817F8, dock out of themachine). The watch trusted the phantom, never armed, and the desk stayed dark until the dock
went back in — worse than the bug it replaced. Reverted, and noted on #112 so nobody spends
an evening on it twice.
Identity cannot separate a sleeping display from an absent one. #133's predicate can, so
the watch reads it:
hasTrustedDisplay()is nowphantomAwareActiveDisplayCount() > 0. Asleeping display keeps a port carrying it and stays trusted; a phantom has no port and does
not.
lastHealthyDisplayIDsstays, with a narrower job — it is what still separates an externalthat turned up during a blackout from one that was already there, which a count cannot do,
naming no display. That is also why
suspectDisplayIDssurvives review here.One change to your code, and the promise attached to it
phantomAwareActiveDisplayCountis now internal instead of private, and its doc comment isupdated. You wrote there that it is read "by
restoreIfNoActiveDisplayand nowhere else, onpurpose". This adds a second reader, so the comment now names it and says why it is the same
kind of reader: both are rescues, and the bargain you wrote still holds — a wrong answer from
the ports can only make a rescue fire, which re-enables a display Crisp itself turned off,
and can never hide a row or refuse a remembered disconnect. The Disconnect row and the launch
re-apply still read the plain count.
Measured
Rebuilt on
29a0223rather than replayed: the branch predates #101 and #133, and its historyadds a
reapplyOnWakethatreconcilehas since replaced. The wake guard that keeps a stalebuilt-in disconnect from being re-applied while nothing external is up now filters
reconcile's pending list, andusablePhysicalDisplayIDsis gone — it was the same test asyour
viewableActiveDisplays, written before that existed, and the two must stay one test.Desk: MacBook Pro Mac17,2 (M5), macOS 26.6.1, j5create JCD552 dock, BenQ GW2780 on its
DisplayPort as the only lit screen, built-in and an Acer E241Y G0 both held disconnected.
Three display sleeps, under
caffeinate -sso the Mac could not idle-sleep inside thewait and turn it into a system-sleep test (my first attempt did exactly that and is
discarded). The ID was reissued on all three — 2 to 66, 68, 70 — the port held
hpd=High sink=1throughout, and nothing armed. NoEvery screen dark, noenable. Cycleone needed one re-poll, so #132 is visibly earning its keep.
One dock pull, the run the UUID attempt died on:
Six seconds from the wake, against 104 s and a replug on the run that opened #112. Both
records survived and the desk ended where it started. The redock line at the end is the rule
doing its job on a built-in that had no record at that moment —
reconnecthad droppedit during the rescue — which is the case this feature exists for and the one #101 alone
cannot cover.
make checkgreen.What I did not measure
The blackout watch's own arming path. In the pull above
restoreIfNoActiveDisplayreachedthe dark desk first, so the watch was never put in the position of having to arm. It reads
the same count that had just read 0 on the phantom, which was the point of the rewrite, but
staging the arming case means a built-in the window server has off with no record behind it,
and I have not found a way to produce that on demand.
Also untested here: Thunderbolt (
IOPortTransportStateCIO), which has never appeared on thisdesk, and DisplayLink-class docks, which I have no hardware for.
Why this is late
I said on #92 that I would open this once #101 landed. #101 landed on 6 September and this
did not follow, because the display-sleep bug above turned up first and then took two wrong
fixes and #133 to settle. I would rather have sent it late than sent you the UUID version.
On timing
I saw your note on #133 that this waits until 1.6.0 has been out for a couple of weeks, and
that it is not a judgement on the idea. That is the right order and I am not asking you to
change it — I am opening now only so it is sitting here rather than in my worktree when you
do have the time, and so the measurements are attached while they are fresh. No rush from my
side at all.
Thank you for the time you have put into reviewing all of this. The edge suggestion, the
softReconnectguard, the question about whetherrestoreIfNoActiveDisplaywas firing atall, and the contract you wrote on #133 each changed what this ended up being, mostly by
making it smaller. I would not have found the UUID dead end without being pushed to measure
rather than reason.