☰
Lunar 6.4.1 自定义预设修复解析:同名预设残留问题的根因与 Enter 键保存的实现
2026/10/16 2:58:54 网站建设 项目流程
  • 桌面应用

【免费下载链接】Lunar

Intelligent adaptive brightness for your external monitors

项目地址:https://gitcode.com/gh_mirrors/lu/Lunar
点击查看免费下载

本篇文章围绕 Lunar 6.4.1 版本发布说明(ReleaseNotes/6.4.1.md)中的两项修复展开:一是自定义预设(Custom Preset)在与已删除预设同名保存时无法正确应用的问题,二是允许在文本输入框中按Enter键直接保存预设。文章将结合仓库源码,深入讲解 Lunar 预设系统的数据模型、保存与删除流程、Bug 产生的根本原因以及修复后的完整操作方式,帮助读者理解并正确使用 Lunar 的自定义预设功能。

一、版本背景:6.4.1 修复了什么

Lunar 是一款面向 macOS 外部显示器的智能亮度调节工具,其菜单栏面板中内置了「标准预设」(Standard Presets)与「自定义预设」(Custom Presets)两套快捷设置。标准预设是 0%、25%、50%、75%、100% 五档固定亮度/对比度按钮;自定义预设则允许用户将任意显示器组合的亮度、对比度、软件亮度快照保存为具名预设,并可绑定一个⌃⌘+ 快捷键的组合快速切换。

Lunar 6.4.1(对应 CHANGELOG.md 中的# 6.4.1一节)是一个 Fix 版本,共包含两项修复:

  1. 修复自定义预设在与已删除预设同名保存时无法正确应用的问题("Fix custom preset not being applied correctly if saved with the same name as a previously deleted preset")。
  2. 允许在文本输入框中按Enter键保存预设("Allow saving the preset by pressingEnterin the text field")。

值得注意的是,紧随其后的 6.4.2 版本发布说明中有一条"修复 6.4.1 引入的与预设相关的崩溃"("Fix a crash introduced in 6.4.1 related to presets"),说明这一版对预设代码的改动确实触及了核心数据流,理解其实现细节对排查后续问题也有帮助。

二、Lunar 预设系统的数据模型与存储

在深入分析 Bug 之前,先了解预设系统的整体结构。所有预设相关的实现都集中在 Lunar/Data/Presets.swift 中。

2.1 Preset 与 PresetConfig 数据结构

Lunar 使用两个核心结构体描述预设:

  • Preset:一个自定义预设的完整描述,包含:

    • id: String:预设名称(即保存时输入的名字,同时作为唯一标识);
    • key: Int:绑定的快捷键对应的 QWERTY 键码;
    • configs: [PresetConfig]:该预设为每个显示器保存的配置快照;
    • hotkey: PersistentHotkey?:根据key派生并注册的持久化快捷键对象(见 Presets.swift)。
    • 两个Preset判等(==)时只比较id(见 Presets.swift),这意味着名称就是预设的身份。
  • PresetConfig:单个显示器的配置快照,包含:

    • id: String:显示器的序列号(serial);
    • brightness: Double:精确亮度值;
    • contrast: Double:精确对比度值;
    • softwareBrightness: Double:软件亮度(默认值为1.0,解码时通过decodeIfPresent兼容旧数据,见 Presets.swift)。

两个结构体都遵循Codable、Defaults.Serializable、Hashable、Identifiable,可以直接序列化到 UserDefaults 中持久化。

2.2 预设的持久化存储

预设列表通过Defaults框架存储在 UserDefaults 中,键名为"presets",默认值为空数组:

// Lunar/Data/DataStore.swift#L15 static let presets = Key<[Preset]>("presets", default: [])

该键被注册到CachedDefaults缓存列表(DataStore.swift)并参与缓存失效处理(DataStore.swift)。所有对预设的读写最终都作用于CachedDefaults[.presets]这个数组,这就为后续"同名残留"问题埋下了伏笔——因为列表操作的正确性完全依赖于"名称唯一"这一约定。

2.3 预设应用(apply)的完整链路

点击一个预设按钮或按下其绑定快捷键时,会调用Preset.apply():

// Lunar/Data/Presets.swift#L78-L89 func apply() { DC.disable() let preset = CachedDefaults[.presets].first(where: { $0.id == id }) ?? self for config in preset.configs { guard let display = DC.activeDisplaysBySerial[config.id] else { continue } display.preciseBrightness = config.brightness display.preciseContrast = config.contrast if config.brightness <= 0.01 { display.softwareBrightness = config.softwareBrightness.f } } }

apply()的执行逻辑值得逐行解读:

  1. DC.disable():暂停显示控制器(DisplayController)的自动调整,避免与手动应用预设相互干扰;
  2. 关键一行:let preset = CachedDefaults[.presets].first(where: { $0.id == id }) ?? self——它不是直接使用自身(self)的 configs,而是先从当前持久化的预设列表中按id重新查找一份。只有当列表里找不到同名预设时,才回退使用自身的快照;
  3. 遍历preset.configs,通过DC.activeDisplaysBySerial[config.id]找到当前活动显示器,写入精确亮度(preciseBrightness)与精确对比度(preciseContrast);
  4. 若亮度 ≤ 0.01(即接近关闭状态),则额外写入softwareBrightness,确保黑屏状态下仍可感知亮度变化。

正是第 2 步"按名称查找覆盖"的设计,直接导致了 6.4.1 修复的 Bug:如果持久化列表中残留了一个与被点击预设同名但内容过时的旧对象,apply()就会找到这个旧对象并应用其过时配置,而不是点击按钮时预期的配置。

2.4 快捷键触发链路

自定义预设的快捷键也走apply()。在Preset.init中通过getHotkey()注册持久化快捷键,其回调为:

// Lunar/Data/Presets.swift#L57-L76 func getHotkey() -> PersistentHotkey? { guard let keyCombo = KeyCombo(QWERTYKeyCode: key, cocoaModifiers: [.control, .command]) else { return nil } let hotkey = PersistentHotkey("\(PersistentHotkey.PRESET_PREFIX)\(id)", dict: [...]) .with { hk in guard let preset = CachedDefaults[.presets].first(where: { $0.key == hk.keyCombo.QWERTYKeyCode }) else { ... hk.unregister() return } preset.apply() } ... }

回调先按key在持久化列表中查找预设,再调用apply(),同样依赖列表中的对象。注册的快捷键标识符(identifier)是PRESET_PREFIX + id,即快捷键与预设名称绑定。

三、Bug 根因:同名预设的残留与名称唯一性被破坏

3.1 删除预设的逻辑

先看删除逻辑。Preset.delete(id:)是一个静态方法:

// Lunar/Data/Presets.swift#L50-L55 static func delete(id: String) { if let hotkey = CachedDefaults[.presets].first(where: { $0.id == id })?.hotkey { hotkey.delete() } CachedDefaults[.presets] = CachedDefaults[.presets].filter { $0.id != id } }

它先找到同名预设并删除其注册的快捷键,再用filter { $0.id != id }从列表中移除所有同名对象。从这段代码看,删除本身是"按名称全量清除"的,理论上不会留下同名残留。

3.2 保存预设的逻辑:唯一性校验

再看保存逻辑。CustomPresetsView.save()是 6.4.1 修复涉及的另一个核心函数:

// Lunar/Data/Presets.swift#L320-L353 func save() { guard !presetName.isEmpty else { error = "Preset name is empty" return } guard !presets.contains(where: { $0.id == presetName }) else { error = "Preset '\(presetName)' already exists" return } guard !presets.contains(where: { $0.key == presetKey }) else { let preset = presets.first { $0.key == presetKey } error = "Preset with hotkey \(DynamicKey.keyString(presetKey)) already exists: \(preset?.id ?? "")" return } presets.append(Preset( id: presetName, key: presetKey, configs: DC.activeDisplayList.map { PresetConfig( id: $0.serial, brightness: $0.preciseBrightness, contrast: $0.preciseContrast, softwareBrightness: cap($0.softwareBrightness, minVal: 0.0, maxVal: 1.0).d ) } )) ... }

save()依次做三重校验:

  1. 名称非空:presetName.isEmpty时报 "Preset name is empty";
  2. 名称唯一:presets.contains(where: { $0.id == presetName })时报 "Preset 'xxx' already exists";
  3. 快捷键唯一:presets.contains(where: { $0.key == presetKey })时报 "Preset with hotkey ... already exists: ..."。

通过校验后,将当前所有活动显示器(DC.activeDisplayList)的精确亮度、精确对比度、软件亮度(通过cap限制在 0.0~1.0)封装为PresetConfig列表,构造Preset并 append 到列表中。

3.3 两个 Bug 之间的关联:悬空句柄指向过时对象

综合apply()的"按 id 重新查找"逻辑与save()的"名称唯一"校验,可以推断 6.4.1 修复的 Bug 根因如下:

  • 在 6.4.1 之前,当用户先删除一个预设 A,再用相同名称保存新预设 B时,界面按钮、@Default(.presets)绑定或某些已捕获的引用可能仍持有旧对象 A(悬空句柄);而持久化列表中的 B 与 A 同名(id相同)。
  • 当用户点击 B 的按钮时,apply()执行CachedDefaults[.presets].first(where: { $0.id == id })——由于列表可能仍按旧顺序或旧内容包含过时对象,first(where:)命中了一个与 B 同名但保存着 A 时代配置快照的旧对象,导致应用出来的亮度/对比度是 A 的旧值,而不是 B 当前保存的值。
  • 同时,Preset的==只比较id,KeyCombo/快捷键标识符也绑定在名称上,同名对象共存会进一步放大这种不确定性。

修复方向(从 6.4.1 发布说明与当前源码形态推断)是:确保删除操作彻底清理同名残留、保存操作强制名称唯一,并在应用时以列表中的最新同名对象为准,从而杜绝"同名不同内容"的悬挂状态。从当前仓库 Presets.swift 的实现看,delete(id:)已按名称全量filter,save()已三重校验名称与快捷键唯一性,两者共同保证了"同一时刻列表中不存在两个同名预设"这一不变量。

四、第二个修复:按 Enter 键保存预设

6.4.1 的第二项修复是允许在预设名称输入框中直接按Enter保存。该功能实现在CustomPresetsView的编辑弹窗(editPopover)中:

// Lunar/Data/Presets.swift#L207-L266 var editPopover: some View { PaddedPopoverView(background: Color.warmBlack.any) { VStack { HStack(spacing: 2) { // 快捷键提示区:⌃ ⌘ + DynamicKey 录制按钮 ... TextField("", text: $presetName, onCommit: { save() }) .onReceive(Just(presetName)) { name in limitText(10) if !presetKeyChanged, let char = name.first(...), let key = Key(character: String(char), virtualKeyCode: nil) { presetKey = key.QWERTYKeyCode.i } } ... SwiftUI.Button("Save") { save() } ... } ... } } }

关键点在于TextField("", text: $presetName, onCommit: { save() }):SwiftUI 的TextField提供了onCommit闭包,当用户在文本框内按下回车(Return/Enter)时触发。这里onCommit直接调用save(),与右侧 "Save" 按钮共享同一条保存路径,因此按 Enter 与点击 Save 按钮在行为上完全等价。

围绕这个输入框,还有几个值得一提的交互细节:

  • 名称长度限制:limitText(10)将预设名称限制为最多 10 个字符,超出时弹出错误提示 "Preset name should contain a maximum of 10 characters" 并截断输入(见 Presets.swift);
  • 自动推断快捷键:当用户尚未手动录制快捷键(presetKeyChanged为 false)时,输入名称会取其中的第一个字母数字字符自动映射为预设的快捷键键码,无需用户手动录制(见 Presets.swift);
  • 占位提示:名称为空时输入框显示 "Preset name" 占位文本(placeholder(when:)扩展,见 Presets.swift);
  • 错误提示:校验失败时错误信息以红色小字显示在弹窗下方(见 Presets.swift)。

保存成功后会重置presetName、presetKey、presetKeyChanged等状态并关闭弹窗(见 Presets.swift)。

五、从源码看自定义预设的完整使用流程

综合 Presets.swift、QuickActionsMenuView.swift 与 DataStore.swift,Lunar 6.4.1 之后自定义预设的完整使用流程如下:

  1. 开启自定义预设入口:QuickActionsMenuView通过@Default(.showCustomPresets)控制是否在菜单栏面板中显示自定义预设区域(见 QuickActionsMenuView.swift),并与标准预设共用"当只有一个显示器时隐藏"等显示策略(QuickActionsMenuView.swift);
  2. 创建预设:面板为空时显示 "Create Preset" 按钮(非空时显示 "+ Preset"),点击后弹出编辑弹窗(adding状态控制popover显隐,见 Presets.swift);
  3. 命名并绑定快捷键:输入预设名称(自动截断为 10 字符)、按需录制⌃⌘+ 键的快捷键(DynamicKey组件通过KeyEventHandling捕获按键事件,允许的键包括 F1~F20、数字 0~9 及字母 A~Z,见 Presets.swift),然后点击 "Save" 或直接按Enter;
  4. 持久化:save()通过三重校验后构造Preset并 append 到CachedDefaults[.presets],自动序列化到 UserDefaults;
  5. 应用预设:点击网格中的预设按钮或按下绑定快捷键,触发Preset.apply():禁用自动调整 → 从持久化列表按名称取最新对象 → 遍历各显示器写入精确亮度/对比度/软件亮度;
  6. 删除预设:鼠标悬停预设卡片时左上角出现红色删除按钮(xmark.circle.fill),点击后preset.delete()移除对象并注销对应快捷键(见 Presets.swift 与 Presets.swift)。

六、修复后的验证要点与注意事项

基于源码分析,6.4.1 之后验证这两项修复是否生效,可以关注以下要点:

  1. 同名重建场景:先创建一个名为 "Work" 的预设,删除它,再创建一个同名新预设(此时亮度/对比度应设置为不同的值),点击新预设按钮,确认应用的是新值而非旧值——这是 6.4.1 第一项修复的直接验证场景;
  2. Enter 保存:在名称输入框中键入名称后按Enter,确认预设被创建、弹窗关闭、错误校验(空名称/重名/快捷键冲突)与点击 Save 时表现一致;
  3. 名称唯一性不变量:通过任何路径(UI、快捷键、CLI)操作后,CachedDefaults[.presets]中不应存在两个id相同的Preset;
  4. 6.4.2 的后续修复:若升级到 6.4.2,需留意其发布说明中"修复 6.4.1 引入的与预设相关的崩溃"的提示——对预设改动较大的版本,建议关注升级后首次启动是否异常。

七、小结

Lunar 6.4.1 的两项修复虽小,但切中了自定义预设系统两个最影响体验的环节:

  • 数据一致性:通过强制"名称唯一"并让apply()始终以持久化列表中的最新同名对象为准,消除了"删除后同名重建却应用旧配置"的悬挂状态,其核心教训是:以用户输入的名称作为业务主键时,必须同时保证插入时的唯一性校验与删除时的彻底清理,两者缺一不可;
  • 输入效率:TextField的onCommit让键盘流用户无需移开双手点击 Save 按钮,且与自动推断快捷键结合后,一个预设几乎可以全程用键盘在几秒内完成创建。

对于需要在 macOS 上管理多显示器亮度配置的用户,理解这套数据模型与调用链,既能更高效地使用自定义预设,也能在遇到"预设应用结果与预期不符"时快速定位到名称冲突或缓存残留这一根因。

附:文中涉及的仓库源码路径索引

内容文件
版本发布说明ReleaseNotes/6.4.1.md
全局变更日志中的 6.4.1/6.4.2 条目CHANGELOG.md
预设数据模型、保存/删除/应用/快捷键实现Lunar/Data/Presets.swift
预设存储键定义与缓存注册Lunar/Data/DataStore.swift
菜单栏面板中自定义预设的展示与入口Lunar/SwiftUIViews/QuickActionsMenuView.swift
标准预设按钮(0%~100%)Lunar/SwiftUIViews/PresetButtonView.swift
  • 桌面应用

【免费下载链接】Lunar

Intelligent adaptive brightness for your external monitors

项目地址:https://gitcode.com/gh_mirrors/lu/Lunar
点击查看免费下载
上一篇:让角色会躲到树后:Agent Sprite Forge 分层地图的 Y 轴排序与遮挡实战
下一篇:如何实现B站视频断点续传?bilibili-downloader基于HTTP Range的可靠下载机制源码解析

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询