Lunar 5.6.8a1 热修复深度解析:`delayDDCAfterWake` 死锁修复与圆角遮罩窗口的 HUD 层级调整

原创2026-10-14 18:07:402,176 阅读
文章标签:桌面应用硬件开发

Lunar 5.6.8a1 热修复深度解析:delayDDCAfterWake 死锁修复与圆角遮罩窗口的 HUD 层级调整

本篇文章围绕 Lunar 开源仓库中 5.6.8a1 版本的发布记录(ReleaseNotes/5.6.8a1.md)展开,逐条剖析这次热修复涉及的两处关键改动:一是修复由新引入的 delayDDCAfterWake 唤醒延迟设置引发的死锁问题,二是将显示器圆角遮罩窗口的层级调整为 HUD(Head-Up Display)级别。结合仓库源码(DDC.swift、Display.swift、ModernWindow.swift 等),你将理解这两个修复背后的运行机制、涉及的配置项与代码路径,并能举一反三地排查同类显示器固件兼容性问题。


一、背景:delayDDCAfterWake 因何而来

在 5.6.8 系列之前的版本中,Lunar 曾引入过名为 Delay DDC requests after wake(即代码中的 delayDDCAfterWake)的设置项,其设计初衷记录在 ReleaseNotes/5.6.7.md:

为那些在简单 DDC 命令下就会丢失信号的显示器固件 Bug 所困扰的用户,提供延迟发送 DDC 请求的开关。

也就是说,某些显示器在系统从睡眠中唤醒后,视频信号尚未完全重建,此时若立即发送 DDC(Display Data Channel,显示器数据通道)命令(如亮度、对比度、音量、输入源切换等),显示器可能因固件问题直接黑屏或丢失信号。delayDDCAfterWake 让 Lunar 在唤醒后的指定时间内完全暂停发送 DDC 命令,等显示器连接稳定后再恢复操作。

该设置对应的默认值与存储键定义在 LunarShortcuts/Shared.swift:

static let delayDDCAfterWake = Key<Bool>("delayDDCAfterWake", default: false)
static let waitAfterWakeSeconds = Key<Int>("waitAfterWakeSeconds", default: 30)
  • delayDDCAfterWake:布尔开关,默认 false(不启用);
  • waitAfterWakeSeconds:唤醒后等待的秒数,默认 30 秒,范围可在设置界面中通过滑块调节。

而 5.6.8a1 要修复的,正是这个新设置本身在实现上暴露出的问题——死锁(deadlock)。


二、死锁修复:delayDDCAfterWake 的唤醒等待逻辑

2.1 等待条件 shouldWait 的源码实现

在 Lunar/DDC/DDC.swift 中,delayDDCAfterWake 与 shouldWait 的定义如下:

static var delayDDCAfterWake: Bool = CachedDefaults[.delayDDCAfterWake]

static var shouldWait: Bool {
    delayDDCAfterWake && waitAfterWakeSeconds > 0
        && wakeTime != Date.distantPast
        && timeSince(wakeTime) > waitAfterWakeSeconds.d
}

shouldWait 的含义是:只要开启了延迟开关、等待秒数大于 0、系统有过真正的唤醒记录、并且当前距离唤醒时间仍在等待窗口内,就应当暂缓一切 DDC 操作。一旦超过 waitAfterWakeSeconds,shouldWait 自动变为 false,Lunar 恢复正常的 DDC 通信。

2.2 该条件在哪些代码路径上被使用

shouldWait 并不是只作用于某一处,而是贯穿整个 DDC 读写链路和亮度自适应链路,主要调用点包括:

调用位置 作用
DDC.swift 在向显示器写入控制值前拦截:guard apply, !isTestID(displayID), !shouldWait, ...,满足等待条件时直接返回 true,跳过写入
DDC.swift 在读取显示器属性前拦截:guard !isTestID(displayID), !shouldWait, ...,满足等待条件时返回 nil,跳过读取
DisplayController.swift adaptBrightness(for:) 自适应亮度入口,满足等待条件时输出 "DDC.shouldWait" 日志并跳过本次调光
DisplayController.swift 批量版 adaptBrightness(for:force:),同样在等待窗口内跳过所有显示器的调光

从这些调用点可以看出,delayDDCAfterWake 生效时,Lunar 会同时截断写入、读取与自适应调光三条链路。这正是它作为一种"稳妥措施"(safe measure)的定位:宁可暂时不响应,也不要在显示器未就绪时贸然发送 DDC。

2.3 死锁是如何产生的

在 Lunar/Data/DataStore.swift 中,delayDDCAfterWake 对应的 Combine 发布者被定义为:

let delayDDCAfterWakePublisher = pub(.delayDDCAfterWake)

并在 DDC.swift 的 addObservers() 中监听该发布者的变化:

static func addObservers() {
    delayDDCAfterWake = CachedDefaults[.delayDDCAfterWake]
    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

            for d in DC.displays.values {
                d.reapplyColorGain = false
            }
        }
        .store(in: &observers)
}

这段代码揭示了 delayDDCAfterWake 的完整副作用:

  • 开启时:自动临时关闭 detectResponsiveness(DDC 响应性检测)、reapplyValuesAfterWake(唤醒后重新应用亮度/对比度)与 brightnessTransition(平滑过渡,改为瞬时切换),并为每台显示器设置 reapplyColorGain = false(不再在唤醒后重新应用色彩增益)。这正好对应设置界面帮助文本中列出的"被禁用或削弱的功能"。
  • 关闭时:如果用户此前手动修改过上述三个设置,则恢复其旧值(通过 oldReapplyValuesAfterWake、oldBrightnessTransition、oldDetectResponsiveness 三个"存档键")。

死锁问题便隐藏在这套"开关联动"逻辑中:监听回调运行在主线程(RunLoop.main),而回调内部又通过 CachedDefaults[...] = ... 写回设置值,写回动作会再次触发 delayDDCAfterWakePublisher 或关联设置的发布者。在特定时序(例如 debounce 期间又有新的设置变更、或唤醒与设置切换同时发生)下,主线程上形成"发布者 → sink → 写回 → 再次发布 → 再次 sink"的循环等待,最终导致主线程被阻塞,界面卡死、DDE 操作悬挂。

5.6.8a1 的热修复目标就是在不改动该设置功能的前提下,解除这条回调链路中的循环等待,使开关切换与唤醒等待不再互相阻塞。从 CHANGELOG 与 5.6.8 正式版记录(CHANGELOG.md、ReleaseNotes/5.6.8.md)可以看到该修复随后进入了正式发布。

2.4 设置界面对应的操作入口

该开关在 SwiftUIViews/AdvancedSettingsView.swift 中对应 Delay DDC commands after wake 开关(SettingsToggle),并附有同步的等待秒数滑块与 Reset 按钮:

  • 滑块将 waitAfterWakeSeconds 映射为 0–100 的百分比(waitAfterWakeSeconds.f / 100),默认回到 30 秒;
  • 开启开关后,界面会提示 "Lunar will wait X seconds before sending the first DDC command after screen wake"。

需要留意的是,官方帮助文本将其标注为 Experimental(实验性),并明确警告它会禁用或削弱 Smooth transitions(平滑过渡)、DDC responsiveness checker(DDC 响应性检测)、Re-applying color gain on wake(唤醒后重新应用色彩增益)与 Re-applying brightness/contrast on wake(唤醒后重新应用亮度/对比度)这几项能力。因此它只应作为"显示器固件有缺陷时的应急开关"使用,而不是默认开启的功能。


三、圆角遮罩窗口的 HUD 层级调整

3.1 为什么要用遮罩窗口实现屏幕圆角

5.6.8 系列的另一项工作是:用 4 个独立的角落遮罩窗口替代一个覆盖全屏的大隐藏窗口来实现屏幕圆角效果(见 ReleaseNotes/5.6.8.md)。

其动机是绕开一个 macOS 系统 Bug:即使窗口设置了允许点击穿透(ignoresMouseEvents),一个大而隐藏的覆盖窗口仍可能拦截对话框按钮的点击事件。改为四个只覆盖屏幕四角的 50×50 小窗口后,中间区域不再有遮罩,鼠标点击可以正常穿透到达对话框按钮。

3.2 源码实现:四个角落窗口的创建与定位

圆角窗口的创建集中在 Display.swift 的 updateCornerWindow():

func updateCornerWindow() {
    mainThread {
        guard cornerRadius.intValue > 0, active, !isInNonWirelessHardwareMirrorSet,
              !isIndependentDummy, let screen = nsScreen ?? primaryMirrorScreen
        else { ... 关闭并清空四个角落窗口控制器 ... }

        let create: (inout NSWindowController?, ScreenCorner) -> Void = { wc, corner in
            createWindow(
                "cornerWindowController",
                controller: &wc,
                screen: screen,
                show: true,
                backgroundColor: .clear,
                level: .hud,              // ← 5.6.8a1 的关键改动
                stationary: true,
                corner: corner,
                size: NSSize(width: 50, height: 50)
            )
            if let wc = wc as? CornerWindowController {
                wc.corner = corner
                wc.display = self
            }
        }

        create(&cornerWindowControllerTopLeft, .topLeft)
        create(&cornerWindowControllerTopRight, .topRight)
        create(&cornerWindowControllerBottomLeft, .bottomLeft)
        create(&cornerWindowControllerBottomRight, .bottomRight)
    }
}

四个控制器 cornerWindowControllerTopLeft/TopRight/BottomLeft/BottomRight 分别负责屏幕四角,每个窗口的原始尺寸为 50×50。createWindow 是全局通用窗口工厂(Util.swift),它负责实例化 Storyboard 中注册的 CornerWindowController(见 Main.storyboard),并按 corner 参数把窗口定位到屏幕对应角上:

case .bottomLeft:  // 左下角
    w.setFrame(NSRect(origin: NSPoint(x: screen.frame.minX, y: screen.frame.minY), size: size ?? w.frame.size), display: false)
case .bottomRight: // 右下角
    w.setFrame(NSRect(origin: NSPoint(x: screen.frame.maxX, y: screen.frame.minY), size: size ?? w.frame.size), display: false)
case .topLeft:     // 左上角
    w.setFrame(NSRect(origin: NSPoint(x: screen.frame.minX, y: screen.frame.maxY), size: size ?? w.frame.size), display: false)
case .topRight:    // 右上角
    w.setFrame(NSRect(origin: NSPoint(x: screen.frame.maxX, y: screen.frame.maxY), size: size ?? w.frame.size), display: false)

同时,stationary: true 会给窗口附加一组集合行为与穿透属性:

window.collectionBehavior = [.stationary, .canJoinAllSpaces, .ignoresCycle, .fullScreenDisallowsTiling]
window.sharingType = .readOnly
window.ignoresMouseEvents = true
window.setAccessibilityRole(.popover)
window.setAccessibilitySubrole(.unknown)

即窗口固定不动、可进入所有 Space、忽略鼠标事件,从系统层面保证不会挡住对话框交互。

3.3 遮罩的具体绘制:反向圆角 mask

CornerWindowController 定义在 ModernWindow.swift,它以 CornerWindow 为窗口类,根据所在角落对内容视图绘制反向圆角遮罩(inverse: true):

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)

每个角落窗口都以黑色背景(view.bg = .black)配合同样的 cornerRadius(即用户在界面上设置的圆角半径)在角上绘制一个"吃掉"对应圆角区域的 mask,四个角组合起来,就在屏幕上形成了完整的圆角矩形效果。遮罩尺寸(70×70)比窗口(50×50)略大,并带有偏移(如右上角 x: -20, y: -20),以保证圆角曲线能完整覆盖屏幕角点区域;rasterScale 取屏幕的 backingScaleFactor,确保在 Retina 屏上圆角边缘清晰不糊。

3.4 为什么要把层级改成 HUD

5.6.8a1 将这四个遮罩窗口的层级从默认的 .normal(或此前使用的其他层级)显式改为 level: .hud(即 NSWindow.Level.hud,对应辅助功能 HUD 窗口层级,位于普通窗口之上):

createWindow(
    "cornerWindowController",
    ...
    level: .hud,        // 5.6.8a1
    ...
)

在 macOS 的窗口层级体系中,NSWindow.Level.hud 高于普通应用窗口。这样做的效果是:

  • 遮罩窗口永远绘制在普通窗口之上,圆角不会被其他普通窗口遮挡;
  • 同时它又保持在菜单栏、系统关键窗口之下,不会干扰系统级 UI;
  • 由于 ignoresMouseEvents = true 与 setAccessibilityRole(.popover),HUD 层级并不会影响鼠标事件穿透。

这一点在代码的其他窗口(如 OSD 通知、调光遮罩 shadeWindowController,见 Display.swift)中也采用同样的 .hud 策略,属于 Lunar 处理"覆盖型辅助窗口"的统一做法。修改后,圆角遮罩在窗口层级错乱时(例如与其他全屏窗口叠加)也能稳定显示在最上层,同时继续保证对话框点击不被拦截。


四、小结与排查建议

5.6.8a1 是一次聚焦的热修复版本,其改动可归纳为两点:

  1. 稳定性修复:解除 delayDDCAfterWake 设置开关联动逻辑中可能形成的主线程死锁(DDC.swift),让"唤醒后延迟 DDC"这一实验性功能可以安全地开关与运行;
  2. 窗口层级修正:将屏幕圆角的四个角落遮罩窗口统一提升到 HUD 窗口层级(Display.swift),在保证鼠标穿透的前提下确保圆角效果不被普通窗口覆盖。

如果你正在使用 Lunar 且遇到显示器唤醒后丢信号、黑屏等固件类问题,可以按以下思路排查:

  • 在 Options → Advanced(高级设置)中打开 Delay DDC commands after wake,并将等待秒数从默认 30 秒逐步调高,观察是否改善;
  • 注意该开关会临时关闭平滑过渡、DDC 响应性检测与唤醒后重应用亮度/对比度/色彩增益等能力,确认这些功能的取舍;
  • 如果显示器存在无法正常唤醒的问题,升级到包含 5.6.8a1 修复的正式版本,避免使用早期 alpha 版本中可能触发的死锁问题。

相关源码与发布记录可继续在仓库中查阅:DDC.swift、Display.swift、ModernWindow.swift、AdvancedSettingsView.swift、ReleaseNotes/5.6.7.md、ReleaseNotes/5.6.8.md。

登录后查看全文
Lunar