Lunar 5.9.6 Alpha 2 技术解读:菜单栏重构、环境光传感器快速发现与 Sync Mode 算法演进

原创2026-10-15 11:27:461,412 阅读
文章标签:桌面应用硬件开发

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 项改动呈现出清晰的两条主线:

  1. 体验优化:菜单 footer 重排(QuickActionsMenuView.swift)+ ALS 发现改为 IP 直连(NetworkControl.swift),分别改善视觉结构与传感器联机速度;
  2. 算法与准确性: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"能力库最终落地的位置。

登录后查看全文
Lunar