Skip to content

Follow the dock: auto-disconnect the built-in panel, and rescue an all-dark Mac - #145

Closed
ncchen99 wants to merge 2 commits into
didriksg:mainfrom
ncchen99:auto-dock-switching-v2
Closed

Follow the dock: auto-disconnect the built-in panel, and rescue an all-dark Mac#145
ncchen99 wants to merge 2 commits into
didriksg:mainfrom
ncchen99:auto-dock-switching-v2

Conversation

@ncchen99

@ncchen99 ncchen99 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

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
(isAvailable also requires toggle.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-in
While 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. restoreIfNoActiveDisplay works from the disconnected
records, 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 record
bookkeeping.

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 want
to be straight that this is the one place I went the other way, because two cases fall
through a pure edge:

  • The setting being switched on while already docked. No transition happens, so an edge
    rule does nothing at the exact moment the user asked for it.
  • A disconnect that is refused or fails. wouldLeaveNoActiveDisplay refuses to take the
    last 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 userOwnsBuiltin is the stand-down you predicted I
would 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 userRemovedExternal exists.

If you would still rather have the edge and lose those two cases, it is a small change and I
will make it.

The softReconnect guard you asked for is in, in both places it matters:
evaluate() returns early while toggle.isBlinking, and the blackout watch refuses to act
while 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, because
macOS 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 enable that
failed 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 check passed, and three display
sleeps 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 the
machine). 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 now phantomAwareActiveDisplayCount() > 0. A
sleeping display keeps a port carrying it and stays trusted; a phantom has no port and does
not.

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 a count cannot do,
naming no display. That is also why suspectDisplayIDs survives review here.

One change to your code, and the promise attached to it

phantomAwareActiveDisplayCount is now internal instead of private, and its doc comment is
updated. You wrote there that it is read "by restoreIfNoActiveDisplay and nowhere else, on
purpose". 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 29a0223 rather than replayed: the branch predates #101 and #133, and its history
adds a reapplyOnWake that reconcile has since replaced. The wake guard that keeps a stale
built-in disconnect from being re-applied while nothing external is up now filters
reconcile's pending list, and usablePhysicalDisplayIDs is gone — it was the same test as
your 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 -s so the Mac could not idle-sleep inside the
wait 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=1 throughout, and nothing armed. No Every screen dark, no enable. Cycle
one needed one re-poll, so #132 is visibly earning its keep.

One dock pull, the run the UUID attempt died on:

20:08:55      display off, id 2 -> 72, DisplayProductName goes empty
20:09:00      Sleep
20:09:30      DarkWake  USB-C_plug                     <- dock out
20:09:29.037  cap=0 effective=0, CG still lists 72[9d1/78e6]
20:09:51      Wake  RTP.keyboard
20:09:56.985  no active display, restoring from 2 record(s)
20:09:57.057  enable 1: reported success after 71 ms
20:11:47.678  Docked (1 external(s)): disconnecting the built-in panel

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 — reconnect had dropped
it during the rescue — which is the case this feature exists for and the one #101 alone
cannot cover.

make check green.

What I did not measure

The blackout watch's own arming path. In the pull above restoreIfNoActiveDisplay reached
the 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 this
desk, 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
softReconnect guard, the question about whether restoreIfNoActiveDisplay was firing at
all, 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.

… 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.
@ncchen99
ncchen99 force-pushed the auto-dock-switching-v2 branch from aef3f42 to b5b9b3b Compare September 7, 2026 13:18
@per-hap-s

Copy link
Copy Markdown

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:
In AutoDisplaySwitchService.evaluate() (or wherever toggle.disconnect(builtin) succeeds), trigger a True Tone re-assert after the built-in panel is taken down:

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.

@ncchen99

ncchen99 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

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 didWake, firing at the first that lists an external. This PR sets wakeGraceUntil = now + 6 and schedules its evaluation wakeGrace + settleDelay after that, so the built-in disconnect is at about 8.5 s. On a desk with both features on, the re-assert really does normally happen about six seconds before the panel goes away.

And reassertTrueTone() has exactly one caller in the tree — that one, gated on didWake and an external being present. CoreBrightnessService.refresh() only reads state to keep the UI honest; it never re-asserts. So nothing anywhere reacts to the display set changing.

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 fullWakePending is never set), and it does not run when someone disconnects the built-in by hand from the Disconnect row, which has been in Crisp far longer than this branch. This PR would only be automating a disconnect people already do — so the hook would want to live beside disconnect() in PhysicalDisplayToggleService, where it would help everyone, rather than only people who switch this rule on.

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:

At the moment of wake (t = 0), the external display is still performing DP link training and is not yet online. macOS only detects the built-in screen and initializes True Tone purely using the built-in panel's profile.

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. CoreBrightnessService notes that True Tone availability "flips with the lid (clamshell hides True Tone system-wide)", so I wondered whether a Crisp disconnect of the built-in looks like clamshell to CoreBrightness, which would make a re-assert afterwards a no-op. It does not: with the built-in disconnected through Crisp on this Mac, CBTrueToneClient still reports supported=true available=true enabled=true.

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?

  • If it does, that is a real gap in main today, worth its own issue, and I will gladly carry the fix here or wherever it belongs.
  • If it does not, the ordering you spotted is harmless and this can stay as it is.

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: reassertTrueTone() already reads supported, available and enabled live from CBTrueToneClient, and returns false when it did not run. Gating it on the published trueToneEnabled would bring back exactly the stale read its doc comment warns against, so the call is best left unguarded.

Thanks again — genuinely useful, whichever way the measurement goes.

@per-hap-s

Copy link
Copy Markdown

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 Reality

On Apple Silicon macOS, when both the built-in Liquid Retina display and a 3rd-party external monitor (e.g. SANC G3Q) are online simultaneously:

  • Toggling True Tone in dual-display mode does not give the external monitor its proper adapted warm tint. macOS prioritizes the built-in panel profile as the primary color gamut reference, leaving the external display effectively pinned near standard D65 cold white:
    • Dual-Display True Tone Toggle: CIE xy (0.3125, 0.3291), CCT ~6515K (still cold).
  • When the built-in panel is disconnected (leaving the external display as the sole active screen) and True Tone is then re-asserted, macOS evaluates the full ambient light adaptation curve directly for the standalone external display:
    • Single External after True Tone Toggle: CIE xy (0.3457, 0.3585), CCT ~5001K (proper warm True Tone).

2. Why the Timing Gap Breaks #144 in Practice

Because of this behavior:

3. Regarding Where the Hook Belongs

Your insight about placing the hook beside PhysicalDisplayToggleService.disconnect() is spot on. If we trigger CoreBrightnessService.reassertTrueTone() in the completion path of disconnect() (whenever the built-in panel goes offline and leaves an external active), it naturally covers:

  1. Auto dock switching (Follow the dock: auto-disconnect the built-in panel, and rescue an all-dark Mac #145),
  2. Manual "Disconnect Built-in" clicks from the menu, and
  3. Hot-plugging into a dock while awake.

That closes the loop cleanly for all three paths!

@ncchen99

ncchen99 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

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. (0.3125, 0.3291) sitting essentially on the D65 standard illuminant is about as clear as "no adaptation happened" gets, and it does settle the part I said I could not get past. I was wrong to lean on the built-in being online as evidence that a re-assert works there.

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:

  1. Just after wake, the external is wearing the tint macOS computed for the built-in — wrong for that panel, and the "noticeable deviation" External display inherits built-in panel's True Tone calculation after wake, requires manual toggle to match external display #131 opens with.
  2. After a toggle with both displays online it lands on D65, which is neutral and reads as corrected. That is what External display inherits built-in panel's True Tone calculation after wake, requires manual toggle to match external display #131 called fixed, and it is what Re-assert True Tone after a full wake once an external is back #144 delivers.
  3. After a toggle with the built-in gone it lands at ~5001K, the real ambient adaptation, which is what you are describing now.

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 reassertTrueTone() into the completion path of disconnect() gives every user who takes the built-in out of the layout a visible tint change they did not ask for, in a service that currently has no colour side effects at all. That is a product call, and it is @didriksg's to make, not one to slip into a PR he has said he will look at after 1.6.0 has been out a couple of weeks. Your three paths are the right three, and the placement you describe is the one I would implement — I just think it wants its own issue, with your two measurements in it, so he can weigh the tint blink against the benefit with the numbers in front of him.

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.

@per-hap-s

Copy link
Copy Markdown

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!

@ncchen99

ncchen99 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

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.

@didriksg

didriksg commented Sep 7, 2026

Copy link
Copy Markdown
Owner

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.

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.

Feature request: let the built-in panel follow whether an external display is connected

3 participants