Lunar 5.6.8 热修复深度解析:DDC 唤醒死锁、强制停用刷新读取与圆角遮罩窗口的重构
Lunar 5.6.8 热修复深度解析:DDC 唤醒死锁、强制停用刷新读取与圆角遮罩窗口的重构
Lunar 是 macOS 上的外部显示器智能亮度调节工具,其 5.6.8 版本是一份针对稳定性问题的 Hotfix 版本。本文基于 ReleaseNotes/5.6.8.md,结合仓库源码逐条拆解该版本修复的四个关键问题:delayDDCAfterWake 设置引发的死锁、圆角遮罩窗口的窗口层级、被强制禁用的 "Refresh values from monitor settings" 选项,以及用 4 个角部遮罩窗口替代单个大遮罩窗口的实现原理。读完本文,你将理解 DDC 读写与系统唤醒状态之间的竞态关系,以及 Lunar 如何在 macOS 窗口系统的限制下实现显示器圆角遮罩。
版本背景:一次针对稳定性风险的集中修复
5.6.8 发布于 6.x 系列迭代期间,主要修复此前版本中引入的新功能带来的回归问题。从 CHANGELOG.md 可以看到,该版本的修复条目与发布说明完全一致,属于典型的 "bugfix-only" 热修复版本,不包含新功能:
- 修复
delayDDCAfterWake设置导致的死锁; - 圆角遮罩窗口改用
HUD窗口层级; - 强制禁用 "Refresh values from monitor settings" 选项(因其导致大量用户遇到挂起);
- 用 4 个角部遮罩窗口替代单个大型隐藏窗口,以绕开 macOS 的窗口点击事件 Bug。
修复一:delayDDCAfterWake 引发的死锁
该设置的设计初衷
delayDDCAfterWake 是 Lunar 在"高级设置"中提供的一个实验性开关,位于 AdvancedSettingsView.swift。其帮助文本明确指出,它面向那些遇到显示器 Bug 的用户,例如系统睡眠唤醒后视频信号丢失、屏幕无法唤醒等场景:开启后,Lunar 会确保在显示器连接完全建立之前,不发送任何 DDC 命令。
对应的配置键定义在 LunarShortcuts/Shared.swift:
static let delayDDCAfterWake = Key<Bool>("delayDDCAfterWake", default: false)
默认关闭,且与 waitAfterWakeSeconds(唤醒后等待秒数)配合使用。
底层实现:shouldWait 守卫
该设置的核心逻辑位于 DDC.swift:
static var delayDDCAfterWake: Bool = CachedDefaults[.delayDDCAfterWake]
static var shouldWait: Bool {
delayDDCAfterWake && waitAfterWakeSeconds > 0 && wakeTime != Date.distantPast && timeSince(wakeTime) > waitAfterWakeSeconds.d
}
shouldWait 的语义是:当开启延迟开关、等待秒数大于 0、且系统确实经历过一次唤醒(wakeTime 不是 Date.distantPast)时,只要距离唤醒时刻尚未超过设定的秒数,就判定为"应当等待"。wakeTime 由 AppDelegate 在收到系统的 wake 通知后写入(AppDelegate.swift):
wakePublisher
.debounce(for: .milliseconds(200), scheduler: RunLoop.main)
.sink { _ in
wakeTime = Date()
...
}
shouldWait 被嵌入 DDC 读写的最前端保护逻辑。例如 DDC.write 的入口守卫(DDC.swift):
static func write(displayID: CGDirectDisplayID, controlID: ControlID, newValue: UInt16, sourceAddr: UInt8? = nil) -> Bool {
#if DEBUG
guard apply, !isTestID(displayID), !shouldWait, !DC.screensSleeping, !DC.locked || DC.allowAdjustmentsWhileLocked else { return true }
#else
guard apply, !shouldWait, !DC.screensSleeping, !DC.locked || DC.allowAdjustmentsWhileLocked else { return true }
#endif
...
}
读取路径同样受 shouldWait 保护(DDC.swift)。也就是说,开启该设置后,从唤醒那一刻起的 waitAfterWakeSeconds 秒内,所有 DDC 读写都会被静默跳过,避免向尚未就绪的显示器发送命令。
开启时对其他功能的连带影响
从源码看,delayDDCAfterWake 并不是一个"独立生效"的开关:开启它时,Lunar 会临时关闭一批与之冲突的功能,并在关闭开关时恢复原值。这段逻辑位于 DDC.addObservers()(DDC.swift):
delayDDCAfterWakePublisher.debounce(for: .seconds(2), scheduler: RunLoop.main)
.sink { change in
delayDDCAfterWake = change.newValue
guard change.newValue else {
if let oldVal = CachedDefaults[.oldReapplyValuesAfterWake] { CachedDefaults[.reapplyValuesAfterWake] = oldVal }
if let oldVal = CachedDefaults[.oldBrightnessTransition] { CachedDefaults[.brightnessTransition] = oldVal }
if let oldVal = CachedDefaults[.oldDetectResponsiveness] { CachedDefaults[.detectResponsiveness] = oldVal }
return
}
CachedDefaults[.oldReapplyValuesAfterWake] = CachedDefaults[.reapplyValuesAfterWake]
CachedDefaults[.oldBrightnessTransition] = CachedDefaults[.brightnessTransition]
CachedDefaults[.oldDetectResponsiveness] = CachedDefaults[.detectResponsiveness]
CachedDefaults[.detectResponsiveness] = false
CachedDefaults[.reapplyValuesAfterWake] = false
CachedDefaults[.brightnessTransition] = .instant
...
}
可以看到,开启 delayDDCAfterWake 会连带:
- 关闭 DDC 响应性检测(
detectResponsiveness),避免在等待窗口内误判显示器故障; - 关闭 唤醒后重新应用亮度/对比度(
reapplyValuesAfterWake)与 唤醒后重应用颜色增益(通过reapplyColorGain = false); - 将亮度渐变切换为瞬时生效(
brightnessTransition = .instant),因为平滑过渡(Smooth transitions)依赖持续发送 DDC 命令,与"唤醒后静默"冲突。
原值保存在 oldReapplyValuesAfterWake、oldBrightnessTransition、oldDetectResponsiveness 三个隐藏键中(定义于 DataStore.swift),关闭开关时自动还原。
5.6.8 修复了什么
发布说明只给出结论:"修复了该新设置引发的死锁"。从代码结构看,这个设置在多个层面引入了全局可变状态:wakeTime 由唤醒通知异步更新,delayDDCAfterWake 通过 debounce 后的 Publisher 在 Main RunLoop 上变更,而 shouldWait 又被嵌进 DDC.write/DDC.read 的同步屏障(sync(barrier: true),见 DDC.swift)路径中——在多显示器并发读写、且主线程同时处理唤醒事件的场景下,这些新增的状态访问是典型的死锁诱因。5.6.8 正是针对这条路径上的状态切换逻辑做了修正。
修复二:圆角遮罩窗口改用 HUD 窗口层级
5.6.8 的另一处修改是:圆角遮罩窗口(rounded corners mask window)的窗口层级从原先的值改为 HUD。在 AppKit 中,NSWindow.Level.hud 属于悬浮于普通应用窗口之上的层级,常用于工具提示类窗口,可保证遮罩窗口始终盖在显示器内容之上、同时又不会抢占键盘焦点或干扰正常的窗口管理。
对应实现可以在 Display.swift 的 updateCornerWindow() 中看到,4 个角部窗口创建时统一指定了 level: .hud:
let create: (inout NSWindowController?, ScreenCorner) -> Void = { wc, corner in
createWindow(
"cornerWindowController",
controller: &wc,
screen: screen,
show: true,
backgroundColor: .clear,
level: .hud,
stationary: true,
corner: corner,
size: NSSize(width: 50, height: 50)
)
...
}
配套地,CornerWindow 继承自 NSWindow(ModernWindow.swift),并在 windowDidLoad 中确保窗口透明、无背景、不可见边框(ModernWindow.swift)。
修复三:强制禁用 "Refresh values from monitor settings"
为什么它会拖垮 Lunar
"Refresh values from monitor settings"(refreshValues)是一个高级选项:当用户在 Lunar 之外(例如通过显示器物理按键)改变亮度时,Lunar 可以通过周期性地读取显示器当前值来同步内部状态。其键定义为(LunarShortcuts/Shared.swift):
static let refreshValues = Key<Bool>("refreshValues", default: false)
当该选项开启时,AppDelegate 会启动一个名为 DDCReader 的常驻线程,每 10 秒执行一次全量值读取(AppDelegate.swift):
func startValuesReaderThread() {
valuesReaderThread = Repeater(every: 10, name: "DDCReader", tolerance: 5) {
guard !DC.screensSleeping, !DC.locked else { return }
if CachedDefaults[.refreshValues] {
DC.fetchValues()
}
}
}
问题在于:绝大多数显示器并不支持 DDC 读取(或读取实现不规范),而 DDC.read 在等待显示器返回时属于阻塞式 I/O。5.6.8 的发布说明明确解释了现象:当显示器不响应时,Lunar 会因为长时间等待响应而挂起、卡顿。从 DDC.swift 的超时阈值也可以侧面印证——单次写入超过 2000ms 就会被记为严重故障(writeFault(severity: 4)),而读取路径同样缺乏可靠的通用超时兜底。
因此官方结论是:这个设置只在"亮度在 Lunar 之外被修改"的场景下才有帮助,而 99% 的用户并不需要它。
强制禁用的落地方式
5.6.8 采用"升级时强制重置"的方式禁用该选项。仓库中的迁移逻辑位于 DataStore.swift:
static func firstRunAfterExperimentalDDCUpgrade() {
thisIsFirstRunAfterExperimentalDDCUpgrade = true
CachedDefaults[.refreshValues] = false
}
该迁移在应用升级时触发一次(DataStore.swift),将 refreshValues 直接写回 false,随后在 initDisplayController 中(AppDelegate.swift)判断是否启动 DDCReader 线程,从而避免大量用户因默认继承的旧设置而卡死。
真正需要时如何重新启用
发布说明特别注明:确实需要该功能的用户可以手动重新打开。入口为 Options 菜单 → Advanced settings(高级设置) 选项卡,对应界面源码在 AdvancedSettingsView.swift:
@Default(.refreshValues) var refreshValues
需要提醒的是,重新开启前请先确认自己的显示器支持 DDC/CI 读取(例如能用 Lunar 正常读到亮度值),否则仍可能再次触发挂起问题。
修复四:用 4 个角部遮罩窗口替代单个大型隐藏窗口
macOS 的点击穿透 Bug
这是 5.6.8 中实现层面最有趣的一处改动。为了给外接显示器模拟圆角效果,Lunar 需要在屏幕四角放置黑色遮罩。此前版本的做法是:放置一个覆盖整块屏幕的大型隐藏窗口,并设置其忽略鼠标事件(ignoresMouseEvents = true,参见 Util.swift)。
但这个方案踩到了 macOS 的 Bug:即便窗口声明了允许点击穿透,大型覆盖窗口依然会拦截对话框按钮的点击。也就是说,当用户在屏幕上与任何对话框交互时,鼠标事件会被这个隐形大窗口吞掉,表现为"按钮点了没反应"。
四个 50×50 的角窗口方案
5.6.8 将"一个大窗口"重构为 4 个 50×50 的小窗口,分别钉在屏幕的四个角上。这样鼠标在屏幕绝大部分区域点击时根本不会命中遮罩窗口,从根上绕开了该 Bug。
实现位于 Display.swift 的 updateCornerWindow():它维护 cornerWindowControllerTopLeft / TopRight / BottomLeft / BottomRight 四个窗口控制器,每个角窗口尺寸为 50×50,创建时指定 level: .hud、stationary: true(保持静止、可加入所有 Space)。当显示器处于非激活、镜像或虚拟显示器等不适用场景时,四个窗口会被统一关闭并置空(Display.swift)。
四个角窗口的定位与遮罩绘制集中在 ModernWindow.swift 的 CornerWindowController.display 回调中,例如左下角:
case .bottomLeft:
w.setFrame(
NSRect(origin: NSPoint(x: screen.frame.minX, y: screen.frame.minY), size: NSSize(width: 50, height: 50)),
display: false
)
view.mask(
withRect: NSRect(x: 0, y: 0, width: 70, height: 70),
cornerRadius: CGFloat(display.cornerRadius.floatValue),
inverse: true, rasterScale: screen.backingScaleFactor
)
每个角窗口覆盖 70×70 的区域(窗口本身 50×50,遮罩范围向屏幕内部延伸 20pt),圆角半径取自用户配置的 display.cornerRadius。
反相遮罩的图形学实现
遮罩效果由 NSView.mask(withRect:cornerRadius:inverse:rasterScale:) 完成(ModernWindow.swift),核心思路是用 CAShapeLayer 构造一个"挖掉圆角矩形"的反相路径:
let path = CGMutablePath()
if inverse {
path.addPath(CGPath(rect: bounds, transform: nil))
}
path.addPath(CGPath(roundedRect: maskRect, cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil))
maskLayer.path = path
if inverse {
maskLayer.fillRule = CAShapeLayerFillRule.evenOdd
}
配合 evenOdd 填充规则,图层会绘制"整块矩形减去圆角矩形"之后剩余的部分,即四个角上的圆角化黑色遮罩;同时启用连续曲率(cornerCurve = .continuous)与边缘抗锯齿,并按屏幕 backingScaleFactor 栅格化,保证在 Retina 屏上边缘平滑。
窗口的背景则被设为黑色(view.bg = .black),这正是遮罩呈现"圆角"视觉效果的来源。
小结
Lunar 5.6.8 是典型的"为稳定性让路"的热修复版本,四条改动分别对应了不同类型的风险:
| 修复项 | 问题本质 | 解决方案 |
|---|---|---|
delayDDCAfterWake 死锁 |
新增全局唤醒状态与 DDC 同步屏障并发访问 | 修正状态切换逻辑 |
| 遮罩窗口层级 | 遮罩可能被普通窗口遮挡或层级不当 | 统一改用 NSWindow.Level.hud |
refreshValues 导致挂起 |
大量显示器不支持 DDC 读取,阻塞轮询拖垮应用 | 升级时强制置 false,保留手动开启入口 |
| 大遮罩窗口拦截点击 | macOS 忽略点击穿透声明,吞掉对话框按钮事件 | 改为 4 个 50×50 角窗口 + 反相遮罩 |
对于使用 Lunar 的用户,这份版本说明的价值在于:如果你遇到"唤醒后显示器无响应",可以尝试在高级设置中开启 delayDDCAfterWake 并合理设置等待秒数(默认恢复值为 30 秒,见 AdvancedSettingsView.swift);如果你不需要在 Lunar 外部调节亮度,请保持 "Refresh values from monitor settings" 处于关闭状态,避免显示器不支持 DDC 读取时出现卡顿。相关代码路径可继续在 DDC.swift、Display.swift 与 ModernWindow.swift 中查阅。