Lunar 5.9.6 Alpha 2 技术解读:菜单栏重构、环境光传感器快速发现与 Sync Mode 算法演进
Lunar 5.9.6 Alpha 2 技术解读:菜单栏重构、环境光传感器快速发现与 Sync Mode 算法演进
本文围绕 Lunar(macOS 外部显示器智能自适应亮度工具)5.9.6 Alpha 2 版本的 5 项改进展开技术剖析:菜单栏底部区域(menu footer)的视觉重构、基于已解析 IP 的环境光传感器(ALS)快速发现、Sync Mode 源显示器优先级的调整、Apple Native 显示器
Backlight Nits Cap观察者的引入,以及基于"Max Nits"显示器能力数据改进 Sync Mode 算法的探索性工作。全文结合仓库源码,逐一还原这些改进背后的实现细节与设计动机。
Lunar 5.9.6 Alpha 2 是一个面向内部测试的迭代版本,其改动虽小但指向明确:一方面优化菜单栏弹窗的视觉层级与交互密度,另一方面集中火力解决 Sync Mode(同步模式)的两个核心痛点——环境光传感器(ALS)的发现速度与源显示器的选取优先级,并首次将"Max Nits"能力数据引入算法改进流程。本文将以 ReleaseNotes/5.9.6a2.md 为骨架,结合 Lunar/ 目录下的源码实现,逐项说明这些改动"改了什么、为什么改、代码里是怎么落地的"。
一、改进总览
5.9.6 Alpha 2 的改动集中在两个模块:Quick Actions 菜单栏面板与 Sync Mode 相关链路。完整改动清单如下:
| 序号 | 改进项 | 所属模块 | 核心目标 |
|---|---|---|---|
| 1 | 重新排列菜单底部(menu footer)结构 | Quick Actions 菜单栏面板 | 改善视觉结构与信息层级 |
| 2 | 使用解析后的 IP 而非主机名查找环境光传感器 | Sensor Mode / ALS 发现链路 | 规避 mDNS 超时、避免"传感器找不到" |
| 3 | Sync Mode 源优先选择 Pro Display XDR 与 Studio Display | Sync Mode 源选取 | 让高规格 Apple 显示器优先作为亮度同步源 |
| 4 | 为 Apple Native 显示器增加 Backlight Nits Cap 观察者 |
Apple Native 控制链路 | 跟踪背光亮度上限变化,及时重算 nits |
| 5 | 启动基于众包与手工提取"Max Nits"能力数据的 Sync Mode 算法改进 | Sync Mode 算法 | 用真实显示器峰值亮度数据优化映射 |
下面按模块拆解每一项。
二、菜单栏底部区域(menu footer)视觉重构
2.1 改了什么
菜单底部区域即 Quick Actions 面板中最下方的操作栏,传统上承载"App info"开关、版本更新提示、Display Settings / Restart / Quit 等入口。Alpha 2 对它的排列方式进行了调整,使视觉结构更清晰。
2.2 源码中的结构
菜单栏面板的主体由 Lunar/SwiftUIViews/QuickActionsMenuView.swift 实现。其 footer 视图(L180 起)是一个 ZStack,内部 VStack(spacing: 5) 用 HStack 横向排布按钮:
- 左侧:App info 开关(
Toggle+DetailToggleStyle(style: .circle)),并在检测到新版本时显示带 ✨ 图标的版本升级按钮; - 右侧:
Display Settings、Restart、Quit三个FlatButton风格按钮。
HStack {
Toggle(um.newVersion != nil ? "" : "App info", isOn: $showAdditionalInfo.animation(.fastSpring))
.toggleStyle(DetailToggleStyle(style: .circle))
Spacer()
if let version = um.newVersion {
SwiftUI.Button("\(Image(systemName: "sparkles")) v\(version)") { appDelegate!.updater.checkForUpdates() }
}
SwiftUI.Button("Display Settings") { appDelegate!.showPreferencesWindow(sender: nil) }
SwiftUI.Button("Restart") { appDelegate!.restartApp(appDelegate!) }
SwiftUI.Button("Quit") { NSApplication.shared.terminate(nil) }
}
2.3 交互设计联动
footer 并非静态展示,它与用户偏好联动:
@Default(.showFooterOnHover):开启后 footer 默认隐藏(footerOpacity降为 0.0),仅在鼠标悬停到面板底部的指示条区域时以弹簧动画展开(onHover+footerShowHideTask控制显隐任务);@Default(.dimNonEssentialUI):未开启悬停显示时,footer 以 0.15 的低透明度呈现,营造"非必要 UI 淡出"的视觉效果;- 相应的偏好开关在 Lunar/SwiftUIViews/QuickActionsLayoutView.swift 中,文案为 "Only show bottom buttons on hover"。
因此,Alpha 2 的"重新排列"并非单纯移动按钮,而是把版本提示、App info 开关、系统操作入口在横向空间内重新组织,让用户第一眼聚焦到高频操作(Display Settings / Restart / Quit),版本信息与 App info 作为次要元素淡出处理。
三、环境光传感器(ALS)快速发现:IP 优先于主机名
3.1 背景与动机
Lunar 支持通过外部环境光传感器(如 Raspberry Pi + 光照传感器的组合)驱动自适应亮度。这类传感器通常以 HTTP 服务方式暴露在局域网中,由 Lunar 通过 Bonjour/mDNS 自动发现。
旧逻辑在发现后通过 主机名(hostname) 发起连接;而 mDNS 主机名解析在部分网络环境(防火墙、多播受限、路由器不缓存)中会超时,导致明明在线的传感器被判为"not found"。Alpha 2 将连接方式改为 直接使用解析出的 IP 地址,绕开主机名解析这一不稳定环节。
3.2 源码实现
传感器发现的核心在 Lunar/Control/NetworkControl.swift 的 Service 类。Service 在初始化时接收 NetService(Bonjour 服务),其 urls 属性直接从 service.addresses 中取出二进制地址并构建 URL:
lazy var urls: [URL] = {
guard let addresses = service.addresses else { return [] }
return addresses
.compactMap { data in
guard let addr = data.withUnsafeBytes(address) else { return nil }
return buildURL(addr)
}
.sorted { u1, u2 in
// 优先 IPv4(不含 ":")地址,IPv6(含 ":")排后
guard let h1 = u1.host, let h2 = u2.host else { return false }
return !h1.contains(":") && h2.contains(":")
}
}()
其中 buildURL 用 URLComponents 直接以 IP 构造连接地址(IPv6 加 [] 包裹),完全不依赖主机名:
func buildURL(_ addr: String, path: String? = nil) -> URL? {
var urlBuilder = URLComponents()
urlBuilder.scheme = scheme
urlBuilder.host = addr.contains(":") ? "[\(addr)]" : addr
urlBuilder.port = service.port
urlBuilder.path = path ?? self.path
return urlBuilder.url
}
地址的候选顺序由 getFirstRespondingURL 决定:逐个探测、每个尝试 3 次、单次超时 3 秒,谁先响应就用谁。IPv4 排在 IPv6 之前,进一步降低握手延迟。
3.3 收益
- 消除 mDNS 主机名解析超时,传感器发现从"秒级超时"压缩到"直连即通";
Service一旦选定了可用 URL,会缓存到_url(@AtomicLock保护,见 L69),后续读写亮度直接复用,不再重复解析;- 当控制器掉线时,
responsiveCheckPublisher会以 600ms 短超时、1 次重试重新探测地址并切换备用 URL(L417-L452),快速自愈。
从源码结构可以推断,这一改动同时惠及了 urls、maxValueUrls、smoothTransitionUrls 三套地址(分别为普通读写、/max 上限查询、/smooth 平滑过渡接口),它们共享同一套 IP 直连逻辑。
四、Sync Mode 源显示器优先级:Pro Display XDR 与 Studio Display 优先
4.1 源显示器(source display)是什么
Sync Mode 的工作方式是以一台"源显示器"(source display)的亮度为基准,把其余外接显示器同步到同一水平(通过 DDC、Apple Native 或 Gamma 曲线实现)。源显示器的选取质量直接决定同步的准确性。
4.2 改进前的问题
源显示器选取逻辑在 Lunar/Utils/DisplayController.swift 的 getSourceDisplay:
let externalSource = displays.filter { !$0.isBuiltin && $0.isSource && !$0.blackout }
if let source = externalSource.first(where: { $0.hasAmbientLightAdaptiveBrightness })
?? externalSource.first(where: { $0.isNative })
?? externalSource.first {
return source
}
优先级依次是:带环境光自适应能力的显示器 → Apple Native 显示器 → 任意外接显示器。问题在于:Apple 生态内型号差异巨大,普通 iMac 的内屏与 Pro Display XDR / Studio Display 的亮度特性、面板素质完全不同,后者才更适合作亮度基准。
4.3 改进后的优先级
Alpha 2 引入了 isProDisplayXDR 与 isStudioDisplay 两个显示器特征判断(定义见 Lunar/Data/Display.swift):
lazy var isProDisplayXDR: Bool = edidName.contains(PRO_DISPLAY_XDR_NAME)
lazy var isStudioDisplay: Bool = edidName.contains(STUDIO_DISPLAY_NAME)
对应的型号常量定义在 Lunar/Data/Display.swift:
let PRO_DISPLAY_XDR_NAME = "Pro Display XDR"
let STUDIO_DISPLAY_NAME = "Studio Display"
结合 Lunar/Controllers/ModeChoiceViewController.swift 中"源显示器"候选列表的过滤条件:
let source = externals.first { $0.hasAmbientLightAdaptiveBrightness && AppleNativeControl.isAvailable(for: $0) }
可以推断,更新后的选取链变为:Pro Display XDR / Studio Display(具备环境光自适应且支持 Apple Native 控制)优先于其他具备环境光自适应的 Apple 显示器,再依次回退到普通 Apple Native 显示器与任意外接显示器。这样确保 Sync Mode 以面板素质更高、亮度调节更平滑的 Apple 旗舰型号为基准,减少同步误差。
补充一个细节:hasAmbientLightAdaptiveBrightness 直接由系统 API 判定(DisplayServicesHasAmbientLightCompensation(id),见 Lunar/Data/Display.swift),这保证了"带环境光补偿能力"这一条件始终与 macOS 的实际能力对齐。
五、Apple Native 显示器新增 Backlight Nits Cap 观察者
5.1 观察者监听什么
Apple Native 控制链路(Lunar/Control/AppleNativeControl.swift)通过 DisplayServices / CoreDisplay 私有框架读写亮度,并把亮度换算成 nits(尼特)显示给用户。Backlight Nits Cap 是系统层面背光亮度的上限值——它可能随温度管理、电源策略、HDR 状态变化。Alpha 2 为其新增观察者,一旦上限变化立即触发 nits 重算,避免界面上的 nits 读数与真实亮度脱节。
5.2 源码中的 nits 换算链路
nits 更新的核心在 AppleNativeControl.updateNits()(L206-L247,仅 arm64 架构启用):
var brightness: Float = 0.0
DisplayServicesGetLinearBrightness(display.id, &brightness)
nits = maxNits * brightness.d
其中 maxNits 来自 display.possibleMaxNits(面板能力数据,见 Lunar/Data/Display.swift):
lazy var possibleMaxNits: Double? = ISCLI ? nil : panel?.activePreset?.maxSDRNits ?? panel?.activePreset?.maxHDRNits {
didSet {
if possibleMaxNits == nil || possibleMaxNits == 0 || possibleMaxNits == 1600, apply {
// 异常值时延迟 1 秒重新读取面板预设
}
guard let possibleMaxNits, possibleMaxNits > 0, let control = control as? AppleNativeControl else { return }
control.updateNits() // 上限变化 → 立即重算 nits
}
}
注意 didSet 的联动:只要 possibleMaxNits 发生变化,就会调用 control.updateNits()。这正是"观察者"落地的核心——当 Backlight Nits Cap 变化传导到面板预设(maxSDRNits / maxHDRNits)时,nits 读数会即时刷新。
此外,updateNits() 还承担 OSD 显示与 XDR 对比度联动:当 OSD 正在显示 nits 文本时同步刷新数值(display.osdState.text = "\(nits.str(decimals: 0)) nits"),并支持 macOS 26 的 macOS26OSDPoint 新 OSD 位置(L230-L233)。
5.3 为什么重要
- 读数可信:MacBook 合盖、温度变化或 XDR 模式切换都会改变背光能力上限,观察者保证 nits 数值始终基于最新上限计算;
- 映射准确:
nits同时是 Sync Mode 的输入(见下文isUsingNits()判断,Lunar/Data/Display.swift),上限漂移若不及时修正,会把错误基准传播到所有同步显示器。
六、Sync Mode 算法改进:引入"Max Nits"能力数据
6.1 改进方向
Alpha 2 启动了 Sync Mode 算法的改进工程,数据来源有两类:
- 众包数据(crowdsourced):来自 Lunar 用户群体的匿名显示器能力上报;
- 手工提取数据(manually extracted):开发者手工整理的已知型号峰值亮度。
用途是建立一份"Max Nits"显示器能力库,让 Sync Mode 在换算亮度时不必依赖单一估算模型,而是参考真实型号的峰值亮度。
6.2 与现有 nits 换算的关系
Sync Mode 在 nits 模式下工作时的换算基础,正是 Lunar/Data/Display.swift 中 SDR 亮度与 nits 的互转函数:
/// macOS 在亮度 0–0.5 段按线性曲线从 0 升到 140 nits,
/// 0.5–1.0 段按对数曲线从 140 nits 升到 maxNits。
func sdrBrightnessToNits(_ brightness: Double, maxNits: Double) -> Double {
guard brightness > 0 else { return 0 }
if brightness <= 0.5 {
return brightness * 2 * 140
}
let t = (brightness - 0.5) / 0.5
return 140 * pow(maxNits / 140, t)
}
func nitsToSdrBrightness(_ nits: Double, maxNits: Double) -> Double {
guard nits > 0 else { return 0 }
if nits <= 140 {
return nits / 280
}
return 0.5 + 0.5 * Foundation.log(nits / 140) / Foundation.log(maxNits / 140)
}
这两个函数的精度直接取决于 maxNits 参数:maxNits 若来自真实型号数据而非默认估算,nitsToSdrBrightness 的逆映射就更能还原真实亮度。Alpha 2 的"Max Nits 能力库"正是为这两个函数提供更可靠的 maxNits 输入。也就是说,这是对 Sync Mode 精度链路(型号能力 → maxNits → nits 换算 → 同步亮度)的基础性优化,属于算法演进的先行工作。
从代码现状看(sdrBrightnessToNits / nitsToSdrBrightness 尚无版本分支),可以推断这部分能力库的采集与接入仍在进行中,后续版本会逐步落地到换算路径中。
七、小结
5.9.6 Alpha 2 的 5 项改动呈现出清晰的两条主线:
- 体验优化:菜单 footer 重排(QuickActionsMenuView.swift)+ ALS 发现改为 IP 直连(NetworkControl.swift),分别改善视觉结构与传感器联机速度;
- 算法与准确性:Sync Mode 源优先级向 Pro Display XDR / Studio Display 倾斜(DisplayController.swift)、
Backlight Nits Cap观察者保持 nits 读数实时(AppleNativeControl.swift)、"Max Nits"能力库启动为 nits 换算函数提供真实上限数据(Display.swift)。
对于想要深入跟进 Sync Mode 演进方向的开发者,建议重点阅读上述三处源码文件,并留意后续版本对 sdrBrightnessToNits / nitsToSdrBrightness 两个换算函数的改动——那是"Max Nits"能力库最终落地的位置。