该脚本用于一键切换macOS外接显示器的HDR,解决观看HDR片源时需手动在系统设置中开关的麻烦。它不依赖第三方工具、root或UI自动化,而是用CoreGraphics读取显示器信息,并调用未公开的MonitorPanel.framework修改HDR状态。逻辑是:优先使用当前模式直接开HDR;若不支持且刷新率高于约120Hz,则在不改分辨率、缩放的前提下降到约120Hz;仍不支持则恢复原模式并报错。脚本每次动态识别唯一外接屏,通过状态文件和运行锁处理中断与并发,并记录原模式以便关闭HDR时恢复。它需要Xcode或Command Line Tools,首次运行会编译内嵌的Objective-C helper,并缓存结果。私有接口可能在系统升级后失效,因此运行前会做检查。
需求
因为手上有一台 miniLED 外接显示器,所以下载片源时,我一般都会优先选 DV 或 HDR 版本。MacBook 内屏平时可以直接利用 EDR 显示额外的高光亮度;而我这台第三方显示器想正常显示 HDR 片源,还是得先在“显示器”设置里打开 HDR。
严格来说,EDR 并不是苹果显示器独占,兼容的外接屏同样可能支持。这里只说我的实际使用情况:看片前开 HDR,结束后再切回 SDR。
手动切换本来不难:打开“系统设置”,进入“显示器”,选中外接屏,再打开或关闭“高动态范围”。问题是每次看片都要走一遍,看完还得再走一遍;中途如果停下来做别的事情,也要来回切。次数一多,就有点烦了。
我之前推荐过几次 Space Launcher,它正好可以用快捷键运行脚本。于是最合适的做法也很直接:写一个 .command 文件,按一下打开 HDR,再按一下关掉。
还有一个麻烦:我的工作地点不固定,接的也不总是同一台显示器。在家里可能是 4K 160 Hz,换个地方又可能是另一台 4K 144 Hz。受接口、线缆和显示模式影响,这些高刷新率有时不能和 HDR 同时开启。
所以我给这个脚本定了几条规矩:
尤其是最后一点,基本排除了用 UI 自动化去操作“系统设置”的方案。
为什么不能直接写一条 Shell 命令
Apple 的官方说明把 HDR 开关放在“系统设置 → 显示器”中,并提醒外接 HDR 还取决于 Mac、HDR10 显示器、端口、线缆、转换器和显示器固件。Apple 的 HDR 说明
公开的 CoreGraphics API 能读到不少信息:有哪些显示器在线、哪块是内建屏、当前分辨率和刷新率是多少。比如,CGGetOnlineDisplayList 用来列出在线显示器,CGDisplayIsBuiltin 用来判断内建屏,CGDisplayMode 则能读出当前显示模式。
但我把 Apple 的公开 API 翻了一圈,没有找到能直接修改“高动态范围”这个开关的接口。也就是说,显示器信息可以公开读取,HDR 开关却没有现成的公开写法。
我也考虑过用 osascript 驱动“系统设置”。这样确实不用安装第三方工具,但需要辅助功能和自动化权限,运行时还会打开窗口、抢占前台。更麻烦的是,它依赖系统设置的界面结构和文字,macOS 一升级就可能失效。我要的是按下快捷键后安静地完成切换,所以没有走这条路。
最后用到的是 macOS 自带、但没有公开文档的 MonitorPanel.framework。它可以读取当前显示模式,判断这个模式有没有可用的 HDR 选项,也能修改 HDR 状态。实现时我主要参考了 ToggleHDR.swift,它用的也是这套框架,至少说明方向可行。
当然,私有接口最大的麻烦就是 Apple 不保证它以后还能用。macOS 升级后,类名、方法名、调用方式,甚至同一个方法的实际效果都有可能变化。所以脚本每次启动都会先做检查,发现接口对不上就直接停下,不去碰显示设置。
脚本是怎么处理的
整个思路其实就一句话:当前模式能开 HDR 就直接开;开不了再试约 120 Hz;120 Hz 也不行就恢复原样。下面是无参数运行时的流程,--on 和 --off 只是把目标状态固定下来。
1. 先找对显示器
脚本不会记住“上次操作的是哪块屏”,而是每次运行都重新找一遍:
最后必须只剩下一块外接屏。没有外接屏,或者同时接了两块,脚本都会列出当前找到的显示器然后退出。宁可这次不切,也不能把 HDR 开到另一块屏上。
这里故意不用 UUID 来挑目标,目标只由“当前唯一的外接屏”决定。选中之后,UUID 才用来在切换过程中重新找到同一块屏,并关联这块屏自己的恢复记录。这样换到别的地方时,脚本不会因为上一块显示器留过记录,就把旧设置套到新屏上。
2. 能直接开 HDR,就不碰刷新率
脚本先读取当前显示模式 currentMode,再看 hasHDRModes。这个字段的意思很简单:MonitorPanel 有没有报告“当前模式可以使用 HDR”。
如果当前模式可以用 HDR,脚本就直接打开它。写完之后不会只相信接口的返回值,而是重新读取显示器状态:用 MonitorPanel 确认分辨率和缩放没变,再用 CoreGraphics 确认实际刷新率没变。连续两次读到的结果都一致,才算切换成功。
换句话说,如果 4K 160/144 Hz 本来就支持 HDR,脚本不会为了保险先降到 120 Hz。反过来,如果系统在打开 HDR 时顺手换了显示模式,脚本也能发现,不会把它当成一次成功的切换。
3. 开不了 HDR,再试 120 Hz
只有两种情况,脚本才会去找 120 Hz:当前模式明确不支持 HDR,而且刷新率高于约 120 Hz;或者当前用的是 VRR/ProMotion 这类可变刷新率模式。
找到的模式还得同时满足下面这些条件:
这些条件必须同时满足。只比较“3840 × 2160”还不够,因为同一个物理分辨率下面可能有好几个缩放档位。我可以接受刷新率暂时低一点,但不能接受桌面缩放突然变了。
如果有多个候选,就按离 120 Hz 的距离排序,选最接近的一个;距离相同时,优先选刷新率更高的。找不到合适的 120 Hz 就直接失败,不会再一路试到 100 Hz 或 60 Hz。毕竟我只是想把刷新率降一点来换 HDR,不想把整个显示体验都打乱。
4. 关闭 HDR 时,把原刷新率还原
如果开启 HDR 时确实从 160/144 Hz 降到了约 120 Hz,脚本会把切换前后的显示模式都记下来。它不会只保存一个重连后可能变化的模式编号,还会一起记录分辨率、刷新率、HiDPI、缩放、像素格式,以及显示器的 UUID、vendor、model、serial 等信息。
关闭 HDR 时,脚本会先确认现在还是同一块显示器,而且当前模式确实是它之前切到的那个约 120 Hz 模式,确认无误后才恢复原来的刷新率。显示器重连后,原来的模式编号可能已经变了;这时脚本会用分辨率、刷新率和缩放等信息重新查找。只有找到唯一对应的模式才会恢复,找不到或者匹配出多个结果就放弃。
如果 HDR 开着时我手动改过刷新率或缩放,脚本就不再恢复旧设置。它只负责把 HDR 关掉,保留我后来手动选的模式。
为什么第一次运行还要编译
Shell 没办法直接调用 Objective-C 私有框架。为了把所有东西都塞进一个文件里,toggle-external-hdr.command 内嵌了一段 Objective-C helper 源码。
第一次运行时,外层 zsh 脚本会调用系统自带的 xcrun clang 编译这段 helper,然后把结果缓存在:
缓存会根据 macOS build、CPU 架构和内嵌源码的哈希来区分。系统或 helper 源码变了,就重新编译;没变的话,后面直接使用缓存,不必每次都等编译。
脚本还会检查缓存目录和二进制是不是当前用户所有、权限是否正常,以及它们是不是可疑的符号链接或硬链接。检查不过就停,编译失败也不会退回去运行旧版本。
整个过程不需要安装第三方显示器软件,不过机器上得先有 Apple 的 Xcode 或 Command Line Tools。
如果脚本刚好做到一半,切换显示模式时,屏幕可能会黑几秒,显示器的内部编号也可能跟着变化。还有一种更麻烦的情况:脚本已经把刷新率降到了 120 Hz,却在打开 HDR 之前被中断了。
所以脚本会把当前做到哪一步写下来,一共四种状态:
阶段
含义
`pending`
原模式已经保存,准备切换刷新率
`enabling`
已到回退模式,准备开启 HDR
`active`
HDR 已开启,恢复信息有效
`disabling`
HDR 正在关闭,接下来应恢复原模式
下次再运行时,脚本会先看看上一次是不是做到一半。能确认当前模式就是脚本改出来的,就接着恢复;确认不了就停下来,不会拿一个“看起来差不多”的模式覆盖当前设置。如果状态已经是 active,但我后来手动改过模式,脚本会删掉旧的恢复记录并继续关闭 HDR,免得下次又把过期的刷新率改回来。
脚本也加了运行锁。有人正在切换时,另一次运行会直接退出,不会在后台一直等。这样就算快速连按两次快捷键、双击两次脚本,或者在两个终端里同时运行,也不会有两个进程一起改同一块显示器。
使用方法
脚本已经设为可执行。进入脚本所在目录后,建议先看一眼当前状态:
平时直接无参数运行就行,另外也可以明确指定打开或关闭:
因为扩展名是 .command,也可以直接在 Finder 中双击。更方便的用法,是用 Space Launcher 给它绑定一个快捷键。脚本下载或复制后如果丢了执行权限,可以重新加回来:
--status 会显示脚本找到的显示器、当前分辨率和刷新率、HDR 是否可用、现在有没有打开,以及下一步大概会做什么。它不会切换 HDR 或修改显示模式,不过第一次运行时仍可能因为编译 helper 而写入缓存。
如果上一次运行刚好中断,下一次真正执行时会先处理残留状态,所以 --status 给出的“下一步”只是一条提示,不一定是最终会走的流程。
实际用起来,大致会是这样:
总结
脚本能做的只是切换 HDR 和显示模式,不能凭空改变接口和线缆的带宽。4K、120/144/160 Hz、HDR、色深能不能同时开,还是要看 Mac、线缆或转接器、显示器上报的能力、OSD 设置和固件。Apple 也提到,某些缩放分辨率会影响可选的刷新率或 HDR 模式。Apple 的外接显示器说明
目前脚本只处理一块普通外接屏。DisplayLink、虚拟显示器等特殊连接方式我还没有覆盖;遇到这类情况,先跑 --status,看看它有没有找对屏,再决定要不要切换。
另外,MonitorPanel.framework 毕竟是私有接口。macOS 大版本升级后,最好先跑一次 --status,再找一个方便观察屏幕、随时能手动改回设置的场合试切一次。--status 正常只能说明读取接口还在,不能保证写入行为和升级前完全一样。
最开始我以为,找到 HDR 开关对应的接口就差不多了。真正写下来才发现,开关反而是最简单的一步。更麻烦的是怎么找对显示器、什么时候才该降到 120 Hz、怎么保证缩放不变,以及中途失败后怎么把设置收回来。
这些事情处理好以后,日常使用很简单,可以很放心交给脚本:看片前按一下快捷键,结束后再按一下;能切就切,判断不清楚就原样不动。