Toggle HDR on a macOS external display with one keypress
·(edited)· / , , · reads0
AI TranslationSimplified ChineseEnglish
Key Insights
This script toggles HDR on macOS external displays with one click, eliminating manual System Settings toggling when watching HDR content. It needs no third-party tools, root privileges, or UI automation. Instead, it reads display information via CoreGraphics and calls the undocumented MonitorPanel.framework to change HDR state. The logic: try the current mode first; if unsupported and the refresh rate exceeds ~120Hz, drop to ~120Hz without changing resolution or scaling; if still unsupported, restore the original mode and report an error. It dynamically identifies the external display each run, using a state file and run lock to handle interruptions and concurrency, and records the original mode to restore when HDR is turned off. It requires Xcode or Command Line Tools; the first run compiles and caches the embedded Objective-C helper. Private APIs may break after system updates, so it checks before running.
Background
Since I have a miniLED external display, when I download content I usually pick the DV or HDR version. The MacBook's built-in screen can normally use EDR to show extra highlight brightness; but for my third-party display to properly show HDR content, I still have to turn on HDR in the Display settings first.
Strictly speaking, EDR isn't exclusive to Apple displays — compatible external monitors may support it too. I'll just describe my actual usage: turn HDR on before watching, and switch back to SDR when done.
Manual toggling isn't hard by itself: open System Settings, go to Displays, select the external display, then turn High Dynamic Range on or off. The problem is you have to walk through it every time you watch something, and again when you're done; if you stop mid-way to do something else, you switch back and forth again. Do it often enough and it gets annoying.
I've recommended Space Launcher a few times before, and it happens to be able to run scripts via hotkeys. So the most fitting approach was also straightforward: write a .command file — press once to turn HDR on, press again to turn it off.
There's another wrinkle: I don't work in a fixed location, and I don't always plug into the same display. At home it might be 4K 160 Hz; somewhere else, another 4K 144 Hz panel. Depending on the port, cable, and display mode, those high refresh rates sometimes can't run together with HDR.
So I laid down a few rules for this script:
One run of the script toggles the external display's HDR.
Don't hard-code the monitor's model, name, or the temporary displayID, because the display changes with the work location.
For now it handles only one physical external display; if I ever actually add a second screen, I'll add selection logic.
If the current 4K 160/144 Hz mode can already enable HDR, don't touch the refresh rate.
Only when the current mode can't enable HDR, and only with resolution and scaling kept unchanged, should the refresh rate be lowered to about 120 Hz.
If 120 Hz still doesn't support HDR, restore the original mode and report an error — don't keep dropping to 100 Hz or 60 Hz.
Don't install third-party display tools, no root, and no reliance on Accessibility permissions or UI automation.
That last point in particular rules out driving System Settings with UI automation.
Why you can't just use a shell command
Apple's official documentation puts the HDR toggle in System Settings → Displays and reminds us that external HDR also depends on the Mac, the HDR10 display, ports, cables, adapters, and display firmware.Apple's HDR documentation
The public CoreGraphics APIs can read quite a bit: which displays are online, which one is built in, and the current resolution and refresh rate. For example, CGGetOnlineDisplayList lists the online displays, CGDisplayIsBuiltin identifies the built-in display, and CGDisplayMode can read the current display mode.
But after going through Apple's public APIs, I couldn't find any interface that directly modifies the High Dynamic Range toggle. In other words, display info can be read publicly, but there's no ready-made public way to flip the HDR switch.
I also considered using osascript to drive System Settings. That does avoid third-party tools, but it needs Accessibility and automation permissions, and it opens windows and grabs the foreground at runtime. Worse, it depends on System Settings' UI structure and wording, which a macOS update could break at any time. I wanted the switch to complete quietly after a hotkey press, so I didn't go down that path.
What I finally used was macOS's built-in but undocumented MonitorPanel.framework. It can read the current display mode, tell whether that mode has HDR options available, and modify the HDR state. For the implementation I mainly referenced ToggleHDR.swift, which uses the same framework — at least it shows the approach is viable.
Of course, the biggest problem with private interfaces is that Apple doesn't guarantee they'll keep working. After a macOS upgrade, class names, method names, calling conventions, even the actual effect of the same method, can all change. So the script runs a check on every launch and stops right away if the interfaces don't match up, without touching display settings.
How the script handles it
The whole idea is really one sentence: if the current mode can enable HDR, turn it on; if not, try about 120 Hz; if 120 Hz fails too, restore the original. The flow below is for running without arguments, --on and --off just pin the target state.
1. Find the right display first
The script doesn't remember which display it operated on last time; it looks it up again on every run:
First use CoreGraphics to find the displays currently online;
keep only displays currently in use;
exclude the built-in display, Sidecar, and AirPlay;
reject mirrored mode;
the final candidate set must contain exactly one display.
In the end there must be exactly one external display. If there's none, or two are connected at once, the script lists the displays it found and exits. I'd rather skip the switch this time than turn HDR on the wrong display.
Deliberately, the UUID isn't used to pick the target; the target is simply determined by “the only external display right now.” Once chosen, the UUID is used to re-find that same display during the switch and to associate its own restore record. That way, when I move to a different place, the script won't apply the old display's leftover record to the new screen.
2. If HDR can be enabled directly, leave the refresh rate alone
The script first reads the current display mode currentMode, then checks hasHDRModes. What this field means is simple: MonitorPanel reports whether the current mode can use HDR.
If the current mode can use HDR, the script simply turns it on. After writing, it doesn't just trust the interface's return value — it re-reads the display state: MonitorPanel confirms resolution and scaling are unchanged, and CoreGraphics confirms the actual refresh rate hasn't moved. Only when two consecutive reads agree does the switch count as successful.
In other words, if 4K 160/144 Hz already supports HDR, the script won't drop to 120 Hz just to be safe. Conversely, if the system quietly switches display modes while enabling HDR, the script notices and doesn't treat it as a successful switch.
3. If HDR can't be enabled, try 120 Hz
The script only goes looking for 120 Hz in two cases: the current mode explicitly doesn't support HDR and the refresh rate is above about 120 Hz, or the display is currently using a variable refresh rate mode such as VRR/ProMotion.
The mode it finds must also satisfy all of the following at once:
A fixed refresh rate between 119–121 Hz;
the same logical width and height as the current mode;
the same physical pixel width and height;
the same HiDPI, scaling ratio, and interlacing attributes;
and it must be a desktop mode the system marks as user-visible.
All of these conditions must hold at the same time. Comparing only “3840 × 2160” isn't enough, because the same physical resolution can have several scaling steps underneath. I can accept a temporarily lower refresh rate, but I can't accept the desktop scaling suddenly changing.
If there are multiple candidates, sort by distance from 120 Hz and pick the closest one; on ties, prefer the higher refresh rate. If no suitable 120 Hz mode exists, fail right away rather than working all the way down to 100 Hz or 60 Hz. After all, I only want to trade a bit of refresh rate for HDR, not throw the whole display experience into disarray.
4. Restore the original refresh rate when HDR is turned off
If enabling HDR did drop the refresh rate from 160/144 Hz to about 120 Hz, the script records the display modes before and after the switch. It doesn't just save a mode number that might change after a reconnect; it also records resolution, refresh rate, HiDPI, scaling, pixel format, plus the display's UUID, vendor, model, serial, and so on.
When HDR is turned off, the script first confirms it's still the same display and that the current mode really is the ~120 Hz mode it switched to earlier; only then does it restore the original refresh rate. After a reconnect, the original mode number may have changed; in that case the script searches again using resolution, refresh rate, and scaling. It restores only if exactly one matching mode is found; if none is found, or multiple match, it gives up.
If I manually change the refresh rate or scaling while HDR is on, the script won't restore the old settings. It only turns HDR off and keeps the mode I picked manually afterward.
Why the first run needs to compile
Shell can't call an Objective-C private framework directly. To squeeze everything into a single file, toggle-external-hdr.command embeds a chunk of Objective-C helper source.
On first run, the outer zsh script calls the system's built-in xcrun clang to compile the helper, then caches the result at:
TEXT
~/Library/Caches/toggle-external-hdr/
The cache is keyed by the macOS build, CPU architecture, and a hash of the embedded source. If the system or the helper source changes, it recompiles; otherwise it just reuses the cache on later runs instead of waiting for compilation every time.
The script also checks that the cache directory and binary are owned by the current user, have proper permissions, and aren't suspicious symlinks or hard links. If a check fails it stops, and a failed compile never falls back to running an old version.
None of this requires installing third-party display software, but the machine does need Apple's Xcode or Command Line Tools.
If the script is cut short mid-way, the screen may go black for a few seconds while the display mode switches, and the display's internal ID may change along with it. There's an even trickier case: the script has already dropped the refresh rate to 120 Hz but gets interrupted before enabling HDR.
So the script writes down how far it got, in four states:
Stage
Meaning
`pending`
Original mode saved; about to switch refresh rate
`enabling`
Fallback mode reached; about to enable HDR
`active`
HDR is on; restore info is valid
`disabling`
HDR is being turned off; original mode should be restored next
On the next run, the script first checks whether the last run was left half-finished. If it can confirm the current mode is one the script changed, it resumes the recovery; if it can't confirm, it stops rather than overwrite the current settings with a mode that merely looks close. If the state is already active, but I've since changed the mode manually, the script deletes the old restore record and continues turning HDR off, so it won't force an outdated refresh rate back next time.
The script also has a run lock. If someone is already in the middle of a switch, another invocation exits immediately instead of waiting in the background. So even if I press the hotkey twice quickly, double-click the script, or run it in two terminals at once, no two processes will ever modify the same display at the same time.
Usage
The script is already set as executable. After entering the directory where the script lives, it's a good idea to check the current status first:
BASH
./toggle-external-hdr.command --status
Normally you can just run it with no arguments; you can also explicitly specify on or off:
Because the extension is .command, you can also double-click it directly in Finder. Even more convenient is binding a hotkey to it with Space Launcher. If the script loses its execute permission after being downloaded or copied, you can add it back:
BASH
chmod +x ./toggle-external-hdr.command
--status shows the display the script found, the current resolution and refresh rate, whether HDR is available, whether it's currently on, and roughly what it would do next. It won't toggle HDR or change the display mode, though the first run may still write the cache while compiling the helper.
If the previous run happened to be interrupted, the next actual execution handles the leftover state first, so --status's “next step” is only a hint, not necessarily the flow it will actually take.
In practice, it looks roughly like this:
4K 144 Hz supports HDR natively: run it twice to turn HDR on and off, and the refresh rate stays at 144 Hz the whole time.
144 Hz doesn't support HDR but ~120 Hz does: it drops to about 120 Hz when enabling, and restores the original mode when disabling.
No ~120 Hz mode, or 120 Hz still doesn't support HDR: the script reports an error and restores the original mode.
Switching to a different monitor model: it still picks the current sole external display and doesn't reuse the previous display's restore state.
Manually changing the refresh rate after HDR is on: turning HDR off again won't overwrite that manual change.
No external display, multiple external displays, or mirrored mode: it only reports the problem and doesn't touch any screen.
Summary
All the script can do is toggle HDR and display modes; it can't conjure extra bandwidth for the port and cable out of thin air. Whether 4K, 120/144/160 Hz, HDR, and color depth can all run at once still depends on the Mac, the cable or adapter, the capabilities the display reports, OSD settings, and firmware. Apple also notes that certain scaled resolutions can affect the available refresh rates or HDR modes.Apple's external display documentation
For now the script handles only one ordinary external display. I haven't covered special connection types like DisplayLink or virtual displays; if you run into such a case, run --status to see whether it found the right display before deciding whether to toggle.
Besides, MonitorPanel.framework is a private interface after all. After a major macOS upgrade, it's best to run --status once, then find a moment when you can watch the screen and manually revert settings if needed, and try a single toggle.--status working normally only means the read interface is still there; it doesn't guarantee the write behavior is exactly the same as before the upgrade.
At first I thought finding the interface behind the HDR toggle was most of the work. Once I actually wrote it, I realized the toggle is the easiest part. The harder parts are picking the right display, deciding when to drop to 120 Hz, keeping the scaling unchanged, and cleaning up the settings when it fails midway.
Once all that is handled, day-to-day use is simple, and I can confidently leave it to the script: press the hotkey before watching, press it again when done; if it can switch, it switches, and if it can't tell, it leaves everything as is.
Background
Since I have a miniLED external display, when I download content I usually pick the DV or HDR version. The MacBook's built-in screen can normally use EDR to show extra highlight brightness; but for my third-party display to properly show HDR content, I still have to turn on HDR in the Display settings first.
Strictly speaking, EDR isn't exclusive to Apple displays — compatible external monitors may support it too. I'll just describe my actual usage: turn HDR on before watching, and switch back to SDR when done.
Manual toggling isn't hard by itself: open System Settings, go to Displays, select the external display, then turn High Dynamic Range on or off. The problem is you have to walk through it every time you watch something, and again when you're done; if you stop mid-way to do something else, you switch back and forth again. Do it often enough and it gets annoying.
I've recommended Space Launcher a few times before, and it happens to be able to run scripts via hotkeys. So the most fitting approach was also straightforward: write a .command file — press once to turn HDR on, press again to turn it off.
There's another wrinkle: I don't work in a fixed location, and I don't always plug into the same display. At home it might be 4K 160 Hz; somewhere else, another 4K 144 Hz panel. Depending on the port, cable, and display mode, those high refresh rates sometimes can't run together with HDR.
So I laid down a few rules for this script:
That last point in particular rules out driving System Settings with UI automation.
Why you can't just use a shell command
Apple's official documentation puts the HDR toggle in System Settings → Displays and reminds us that external HDR also depends on the Mac, the HDR10 display, ports, cables, adapters, and display firmware.Apple's HDR documentation
The public CoreGraphics APIs can read quite a bit: which displays are online, which one is built in, and the current resolution and refresh rate. For example, CGGetOnlineDisplayList lists the online displays, CGDisplayIsBuiltin identifies the built-in display, and CGDisplayMode can read the current display mode.
But after going through Apple's public APIs, I couldn't find any interface that directly modifies the High Dynamic Range toggle. In other words, display info can be read publicly, but there's no ready-made public way to flip the HDR switch.
I also considered using osascript to drive System Settings. That does avoid third-party tools, but it needs Accessibility and automation permissions, and it opens windows and grabs the foreground at runtime. Worse, it depends on System Settings' UI structure and wording, which a macOS update could break at any time. I wanted the switch to complete quietly after a hotkey press, so I didn't go down that path.
What I finally used was macOS's built-in but undocumented MonitorPanel.framework. It can read the current display mode, tell whether that mode has HDR options available, and modify the HDR state. For the implementation I mainly referenced ToggleHDR.swift, which uses the same framework — at least it shows the approach is viable.
Of course, the biggest problem with private interfaces is that Apple doesn't guarantee they'll keep working. After a macOS upgrade, class names, method names, calling conventions, even the actual effect of the same method, can all change. So the script runs a check on every launch and stops right away if the interfaces don't match up, without touching display settings.
How the script handles it
The whole idea is really one sentence: if the current mode can enable HDR, turn it on; if not, try about 120 Hz; if 120 Hz fails too, restore the original. The flow below is for running without arguments, --on and --off just pin the target state.
1. Find the right display first
The script doesn't remember which display it operated on last time; it looks it up again on every run:
In the end there must be exactly one external display. If there's none, or two are connected at once, the script lists the displays it found and exits. I'd rather skip the switch this time than turn HDR on the wrong display.
Deliberately, the UUID isn't used to pick the target; the target is simply determined by “the only external display right now.” Once chosen, the UUID is used to re-find that same display during the switch and to associate its own restore record. That way, when I move to a different place, the script won't apply the old display's leftover record to the new screen.
2. If HDR can be enabled directly, leave the refresh rate alone
The script first reads the current display mode currentMode, then checks hasHDRModes. What this field means is simple: MonitorPanel reports whether the current mode can use HDR.
If the current mode can use HDR, the script simply turns it on. After writing, it doesn't just trust the interface's return value — it re-reads the display state: MonitorPanel confirms resolution and scaling are unchanged, and CoreGraphics confirms the actual refresh rate hasn't moved. Only when two consecutive reads agree does the switch count as successful.
In other words, if 4K 160/144 Hz already supports HDR, the script won't drop to 120 Hz just to be safe. Conversely, if the system quietly switches display modes while enabling HDR, the script notices and doesn't treat it as a successful switch.
3. If HDR can't be enabled, try 120 Hz
The script only goes looking for 120 Hz in two cases: the current mode explicitly doesn't support HDR and the refresh rate is above about 120 Hz, or the display is currently using a variable refresh rate mode such as VRR/ProMotion.
The mode it finds must also satisfy all of the following at once:
All of these conditions must hold at the same time. Comparing only “3840 × 2160” isn't enough, because the same physical resolution can have several scaling steps underneath. I can accept a temporarily lower refresh rate, but I can't accept the desktop scaling suddenly changing.
If there are multiple candidates, sort by distance from 120 Hz and pick the closest one; on ties, prefer the higher refresh rate. If no suitable 120 Hz mode exists, fail right away rather than working all the way down to 100 Hz or 60 Hz. After all, I only want to trade a bit of refresh rate for HDR, not throw the whole display experience into disarray.
4. Restore the original refresh rate when HDR is turned off
If enabling HDR did drop the refresh rate from 160/144 Hz to about 120 Hz, the script records the display modes before and after the switch. It doesn't just save a mode number that might change after a reconnect; it also records resolution, refresh rate, HiDPI, scaling, pixel format, plus the display's UUID, vendor, model, serial, and so on.
When HDR is turned off, the script first confirms it's still the same display and that the current mode really is the ~120 Hz mode it switched to earlier; only then does it restore the original refresh rate. After a reconnect, the original mode number may have changed; in that case the script searches again using resolution, refresh rate, and scaling. It restores only if exactly one matching mode is found; if none is found, or multiple match, it gives up.
If I manually change the refresh rate or scaling while HDR is on, the script won't restore the old settings. It only turns HDR off and keeps the mode I picked manually afterward.
Why the first run needs to compile
Shell can't call an Objective-C private framework directly. To squeeze everything into a single file, toggle-external-hdr.command embeds a chunk of Objective-C helper source.
On first run, the outer zsh script calls the system's built-in xcrun clang to compile the helper, then caches the result at:
The cache is keyed by the macOS build, CPU architecture, and a hash of the embedded source. If the system or the helper source changes, it recompiles; otherwise it just reuses the cache on later runs instead of waiting for compilation every time.
The script also checks that the cache directory and binary are owned by the current user, have proper permissions, and aren't suspicious symlinks or hard links. If a check fails it stops, and a failed compile never falls back to running an old version.
None of this requires installing third-party display software, but the machine does need Apple's Xcode or Command Line Tools.
If the script is cut short mid-way, the screen may go black for a few seconds while the display mode switches, and the display's internal ID may change along with it. There's an even trickier case: the script has already dropped the refresh rate to 120 Hz but gets interrupted before enabling HDR.
So the script writes down how far it got, in four states:
Stage
Meaning
`pending`
Original mode saved; about to switch refresh rate
`enabling`
Fallback mode reached; about to enable HDR
`active`
HDR is on; restore info is valid
`disabling`
HDR is being turned off; original mode should be restored next
On the next run, the script first checks whether the last run was left half-finished. If it can confirm the current mode is one the script changed, it resumes the recovery; if it can't confirm, it stops rather than overwrite the current settings with a mode that merely looks close. If the state is already active, but I've since changed the mode manually, the script deletes the old restore record and continues turning HDR off, so it won't force an outdated refresh rate back next time.
The script also has a run lock. If someone is already in the middle of a switch, another invocation exits immediately instead of waiting in the background. So even if I press the hotkey twice quickly, double-click the script, or run it in two terminals at once, no two processes will ever modify the same display at the same time.
Usage
The script is already set as executable. After entering the directory where the script lives, it's a good idea to check the current status first:
Normally you can just run it with no arguments; you can also explicitly specify on or off:
Because the extension is .command, you can also double-click it directly in Finder. Even more convenient is binding a hotkey to it with Space Launcher. If the script loses its execute permission after being downloaded or copied, you can add it back:
--status shows the display the script found, the current resolution and refresh rate, whether HDR is available, whether it's currently on, and roughly what it would do next. It won't toggle HDR or change the display mode, though the first run may still write the cache while compiling the helper.
If the previous run happened to be interrupted, the next actual execution handles the leftover state first, so --status's “next step” is only a hint, not necessarily the flow it will actually take.
In practice, it looks roughly like this:
Summary
All the script can do is toggle HDR and display modes; it can't conjure extra bandwidth for the port and cable out of thin air. Whether 4K, 120/144/160 Hz, HDR, and color depth can all run at once still depends on the Mac, the cable or adapter, the capabilities the display reports, OSD settings, and firmware. Apple also notes that certain scaled resolutions can affect the available refresh rates or HDR modes.Apple's external display documentation
For now the script handles only one ordinary external display. I haven't covered special connection types like DisplayLink or virtual displays; if you run into such a case, run --status to see whether it found the right display before deciding whether to toggle.
Besides, MonitorPanel.framework is a private interface after all. After a major macOS upgrade, it's best to run --status once, then find a moment when you can watch the screen and manually revert settings if needed, and try a single toggle.--status working normally only means the read interface is still there; it doesn't guarantee the write behavior is exactly the same as before the upgrade.
At first I thought finding the interface behind the HDR toggle was most of the work. Once I actually wrote it, I realized the toggle is the easiest part. The harder parts are picking the right display, deciding when to drop to 120 Hz, keeping the scaling unchanged, and cleaning up the settings when it fails midway.
Once all that is handled, day-to-day use is simple, and I can confidently leave it to the script: press the hotkey before watching, press it again when done; if it can switch, it switches, and if it can't tell, it leaves everything as is.