File: README.md
Mos
为什么是 Mos
普通滚轮在 macOS 上常常显得生硬, 这是由于滚轮的精度不足导致的。Mos 会接管鼠标滚轮事件,将滚动行为插值转换成更顺滑的滚动,同时保留每个应用、每个方向、每颗按键的控制权。
同时, 你还可以使用 Mos 对任意鼠标按键进行重新映射或改写, 以适配你的工作流。
Mos 是免费的开源菜单栏工具,支持 macOS 10.13 及以上版本。
功能亮点
- 平滑滚动:自定义最短步长、速度增益和持续时间,也可以启用模拟触控板模式。
- 轴向独立:垂直/水平滚动可以分别设置平滑和反向。
- 滚动功能键:为加速、方向转换、禁用平滑滚动绑定任意自定义按键。
- 按应用配置:每个 App 可以继承全局设置,也可以单独覆盖滚动、快捷键和按钮绑定规则。
- 按钮绑定:录制鼠标、键盘或自定义事件,绑定到系统动作、快捷键、打开 App、运行脚本或打开文件。
- 动作库:内置调度中心、空间切换、截图、访达操作、文档编辑、鼠标滚动等常用动作。
- Logi/HID++ 支持:支持 Bolt、Unifying 接收器和蓝牙直连设备上的 Logitech 按钮事件,并可处理 Logi 专有动作。
截图
| 滚动调节 | 按应用配置 |
|---|---|
| 打开 App、脚本或文件 | 快捷动作菜单 |
|---|---|
下载与安装
手动安装
从 GitHub Releases 下载最新版本,解压后将 Mos.app 放入 /Applications。
首次启动时,macOS 可能会要求你授予 Mos 辅助功能权限。Mos 需要这项权限读取和重写滚动事件;如果授权后仍无法工作,可以参考 Wiki 中的权限排查。
Homebrew
如果你习惯用 Homebrew 管理应用:
brew install --cask mos更新:
brew update
brew upgrade --cask mos贡献
Mos 是一个会处理系统输入、辅助功能权限、Logi/HID 设备和用户持久化配置的小工具。维护成本和回归风险都很真实,所以我们更欢迎小而集中的改动。
涉及 Logi/HID、辅助功能权限、签名、notarization、更新机制或真实设备测试的改动风险较高,请先在 issue 或 Discussions 中说明背景。
PR 描述中请说明变更动机、测试方式和可能影响的行为。
由 AI 开发的代码已经成为主流,我们理解现在大量的 PR 都经由 AI 生成 (包括我自己)。但提交者仍需要自行理解、整理并验证每一行代码的实际作用, 因为每个 PR 审查都有成本。
非常欢迎
- 小范围 bug 修复,并提供复现路径或验证说明。
- UI/UX 细节修补,例如布局、文案、可读性和引导流程的小改进。
- 小范围安全加固,例如更稳妥的权限状态处理、输入保护和边界检查。
- 本地化、文档和测试补充。
- 单一主题、变更行数较小、容易 review 的 PR。
暂不合并
- 未经讨论的大型新功能、模块或架构改造。
- 大量 AI 生成的代码重写、格式化、迁移或“顺手优化”。
- 会改变输入事件处理、权限提示、更新检查、旧用户数据读取或持久化格式的行为。
- 一次性补全大量机器翻译,尤其是无法由母语使用者校对的翻译集。
我们欢迎任何形式的贡献,有任何建议或意见您可以给我们 提问。
如果你对新增功能非常有热情,欢迎先在 Discussions 发起讨论。
鸣谢
License
Copyright (c) 2017-2026 Caldis. All rights reserved.
Mos 使用 CC BY-NC 4.0 授权。请不要将 Mos 上传到 App Store。
File: docs/superpowers/specs/2026-03-16-logitech-hid-integration-design.md
Logitech HID++ 2.0 硬件按键集成设计
概述
为 Mos 新增 Logitech 鼠标硬件层面的按键识别能力。通过 IOKit HIDManager 实现 HID++ 2.0 协议通信,捕获 CGEventTap 无法感知的 Logitech 专有按键(手势按钮、DPI 按钮等),并以最小侵入方式整合到现有按键绑定系统中。
目标
- 识别并捕获 Logitech HID++ 鼠标上的专有硬件按键
- 用户可将这些按键绑定到系统快捷键(复用现有 ButtonBinding 机制)
- 兼容未来新增的 Logitech HID++ 设备
- 不影响现有滚动/按键/分应用/Remote 检测等全部功能
- 现有用户配置数据自动兼容,无需迁移
非目标
- 不替代 CGEventTap(两者互补)
- 不处理 Logitech 滚动数据(ScrollCore 保持不变)
- 不支持非 Logitech 的 HID 设备
- 不实现 DPI 控制、电池状态等非按键功能
背景知识
HID++ 2.0 协议
HID++ 2.0 是 Logitech 的私有扩展协议,基于标准 USB HID 的 vendor-specific usage page 传输。跨平台(Windows/macOS/Linux),非 Apple 专有。
关键概念:
- IRoot (Feature 0x0000): 所有 HID++ 2.0 设备必有的根特征,用于动态发现其他特征的 index
- REPROG_CONTROLS_V4 (Feature 0x1B04): 按键重编程特征,支持按键 divert(将按键事件从标准 OS 路径转移到 HID++ 通道)
- 报文格式: Short (7 bytes, Report ID 0x10), Long (20 bytes, Report ID 0x11)
- Feature Discovery: 通过 IRoot 的 GetFeature() 查询 Feature ID → Feature Index 映射
为什么需要 HID++
Logitech 鼠标上的某些按键(手势按钮、DPI 切换等)不会产生标准 CGEvent。这些按键的信号只在 HID++ 通道中可见。CGEventTap 对这类按键完全透明。
macOS 访问路径
macOS 上通过 IOKit 框架的 IOHIDManager API 访问底层 HID 设备。无需第三方库(Mouser 项目用的 Python hidapi 底层也是 IOKit)。
架构设计
设计原则
- 互补而非替代: HID++ 是 CGEventTap 的补充事件源,不改变现有事件处理流程
- 统一抽象: 引入 MosInputEvent 统一两种来源的事件表示
- 状态隔离: 各模块只拥有自己的状态,单向依赖,无循环引用
- 向后兼容: 新增字段全部 optional,旧数据自动兼容
分层架构
/* Detailed source-code truncated for AI context efficiency. */控制流详解
CGEventTap 路径 (同步,必须立即返回)
// ButtonCore.buttonEventCallBack 改造:
let buttonEventCallBack: CGEventTapCallBack = { (proxy, type, event, refcon) in
let mosEvent = MosInputEvent(fromCGEvent: event)
let result = MosInputProcessor.shared.process(mosEvent)
switch result {
case .consumed: return nil
case .passthrough: return Unmanaged.passUnretained(event)
}
}HID++ 路径 (同步,无 pass-through)
// LogitechHIDManager 内部:
func onHIDButtonEvent(cid: UInt16, isDown: Bool, device: MosInputDevice) {
let mosEvent = MosInputEvent(
type: .mouse,
code: LogitechCIDMap.toMosCode(cid),
modifiers: currentModifierFlags(),
phase: isDown ? .down : .up,
source: .hidPlusPlus,
device: device
)
// 结果被忽略 -- HID++ 按键不存在 "pass-through" 语义
// 因为这些按键本来就不会产生系统事件
let _ = MosInputProcessor.shared.process(mosEvent)
}数据模型
MosInputEvent (新增)
设计约束: MosInputEvent 是纯运行时对象,不遵循 Codable。因为 MosInputSource.cgEvent(CGEvent) 中的 CGEvent 不可序列化。只有从中提取的 RecordedEvent 走持久化路径。
/* Detailed source-code truncated for AI context efficiency. */MosInputProcessor (新增)
/// 处理结果
enum MosInputResult {
case consumed // 事件已处理,不再传递
case passthrough // 事件未匹配,继续传递
}
/// 统一事件处理器 (无状态单例)
class MosInputProcessor {
static let shared = MosInputProcessor()
func process(_ event: MosInputEvent) -> MosInputResult {
let bindings = ButtonUtils.shared.getButtonBindings()
guard let binding = bindings.first(where: {
$0.triggerEvent.matchesMosInput(event) && $0.isEnabled
}) else {
return .passthrough
}
ShortcutExecutor.shared.execute(named: binding.systemShortcutName)
return .consumed
}
}RecordedEvent 扩展 (修改)
struct RecordedEvent: Codable, Equatable {
// 现有字段 (不变)
let type: EventType
let code: UInt16
let modifiers: UInt
let displayComponents: [String]
// 新增字段 (optional, 向后兼容)
let deviceFilter: DeviceFilter?
// 新增: 匹配 MosInputEvent
func matchesMosInput(_ event: MosInputEvent) -> Bool {
// 1. 修饰键匹配
guard UInt(event.modifiers.rawValue) == modifiers else { return false }
// 2. 类型匹配
guard event.type == type else { return false }
// 3. 按键码匹配
switch type {
case .keyboard:
guard event.phase == .down else { return false }
guard code == event.code else { return false }
case .mouse:
guard code == event.code else { return false }
}
// 4. 设备过滤 (可选)
if let filter = deviceFilter {
guard filter.matches(event.device) else { return false }
}
return true
}
// 保留: 旧的 CGEvent 匹配 (供 ScrollCore 热键使用, 不改)
func matches(_ event: CGEvent) -> Bool {
// 现有逻辑完全不变
}
// 新增: 从 MosInputEvent 构造
init(from event: MosInputEvent, deviceFilter: DeviceFilter? = nil) {
self.type = event.type
self.code = event.code
self.modifiers = UInt(event.modifiers.rawValue)
self.deviceFilter = deviceFilter
self.displayComponents = MosInputEvent.buildDisplayComponents(event)
}
}
/// ScrollHotkey 扩展: 从 MosInputEvent 构造
extension ScrollHotkey {
init(from event: MosInputEvent) {
self.type = event.type
self.code = event.code
}
}DeviceFilter (新增)
struct DeviceFilter: Codable, Equatable {
let vendorId: UInt16? // nil = 不限厂商
let productId: UInt16? // nil = 不限型号
func matches(_ device: MosInputDevice?) -> Bool {
guard let device = device else { return false }
if let vid = vendorId, vid != device.vendorId { return false }
if let pid = productId, pid != device.productId { return false }
return true
}
}按钮编号空间
// 标准 CGEvent 鼠标按钮: 0 ~ 31 (系统分配)
// 标准键盘虚拟键码: 0 ~ 127 (由 EventType 区分, 不冲突)
// Logitech HID++ 专有: 1000+ (我们分配)
/// Logitech CID -> Mos 按钮码映射
struct LogitechCIDMap {
// CID 参考: Logitech HID++ 2.0 REPROG_CONTROLS_V4 规范
private static let cidToCode: [UInt16: UInt16] = [
0x00C3: 1000, // Gesture Button
0x00C4: 1001, // SmartShift
0x00D7: 1002, // DPI Change Button
// 后续设备新增 CID 在此追加, 从 1003 递增
]
static func toMosCode(_ cid: UInt16) -> UInt16 {
if let known = cidToCode[cid] { return known }
// 未知 CID: 映射到 2000+ 区段, 但限制在 UInt16 范围内
let mapped = UInt32(2000) + UInt32(cid)
return mapped <= UInt32(UInt16.max) ? UInt16(mapped) : UInt16(cid & 0x0FFF) + 2000
}
static func displayName(forCode code: UInt16) -> String {
switch code {
case 1000: return "Gesture"
case 1001: return "SmartShift"
case 1002: return "DPI"
default: return "Logi \(code)"
}
}
}LogitechHIDManager 模块设计
职责
- 通过 IOKit
IOHIDManager枚举和监控 Logitech HID 设备 - 实现 HID++ 2.0 协议子集(IRoot feature discovery + REPROG_CONTROLS_V4 button divert)
- 解析 HID++ 通知,将按键事件转换为 MosInputEvent
- 管理设备连接/断开生命周期
设备发现
class LogitechHIDManager {
static let shared = LogitechHIDManager()
private var hidManager: IOHIDManager?
private var connectedDevices: [IOHIDDevice: LogitechDeviceSession] = [:]
/// Logitech USB Vendor ID
private static let logitechVendorId: Int = 0x046D
func start() {
hidManager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
guard let manager = hidManager else { return }
// 只匹配 Logitech 设备
let matchDict: [String: Any] = [
kIOHIDVendorIDKey as String: LogitechHIDManager.logitechVendorId
]
IOHIDManagerSetDeviceMatching(manager, matchDict as CFDictionary)
// 注册连接/断开回调
IOHIDManagerRegisterDeviceMatchingCallback(manager, deviceConnected, nil)
IOHIDManagerRegisterDeviceRemovalCallback(manager, deviceDisconnected, nil)
// Schedule 到 main RunLoop (低频事件, 避免线程同步)
IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue)
IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone))
}
func stop() {
guard let manager = hidManager else { return }
// 清理所有设备会话
for (_, session) in connectedDevices {
session.teardown()
}
connectedDevices.removeAll()
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
hidManager = nil
}
}设备会话
每个已连接的 Logitech HID++ 设备维护一个独立会话:
/* Detailed source-code truncated for AI context efficiency. */### HID++ 2.0 消息收发
```swift
extension LogitechDeviceSession {
// 报文结构
// Short: [ReportID(0x10), DeviceIndex, FeatureIndex, FuncID/SwID, Param0, Param1, Param2]
// Long: [ReportID(0x11), DeviceIndex, FeatureIndex, FuncID/SwID, Param0...Param15]
/// 发送 HID++ 请求
/// - 使用实例的 deviceIndex (连接时探测确定, 非硬编码)
/// - 检查 IOReturn 返回值, 失败时记录日志
private func sendRequest(featureIndex: UInt8, functionId: UInt8, params: [UInt8] = []) {
var report = [UInt8](repeating: 0, count: 7) // Short report
report[0] = 0x10 // Report ID
report[1] = deviceIndex // 探测得到的 device index
report[2] = featureIndex
report[3] = (functionId << 4) | 0x01 // FuncID | SwID
for (i, p) in params.prefix(3).enumerated() {
report[4 + i] = p
}
let result = IOHIDDeviceSetReport(
hidDevice,
IOHIDReportType(kIOHIDReportTypeOutput), // Swift 需要显式类型转换
CFIndex(report[0]),
report,
report.count
)
if result != kIOReturnSuccess {
NSLog("[LogitechHID] SetReport failed: 0x%08x", result)
}
}
/// Feature Discovery: IRoot.GetFeature(featureId) -> featureIndex
/// 带超时保护: discoveryTimeout 秒内无响应则回调 nil
private func discoverFeature(featureId: UInt16, completion: @escaping (UInt8?) -> Void) {
let params: [UInt8] = [UInt8(featureId >> 8), UInt8(featureId & 0xFF)]
sendRequest(featureIndex: 0x00, functionId: 0, params: params)
pendingDiscovery[featureId] = completion
// 超时保护
discoveryTimer = Timer.scheduledTimer(withTimeInterval: Self.discoveryTimeout, repeats: false) { [weak self] _ in
guard let self = self else { return }
if let pending = self.pendingDiscovery.removeValue(forKey: featureId) {
NSLog("[LogitechHID] Feature discovery timed out for 0x%04X", featureId)
pending(nil)
}
}
}
/// Button Divert: REPROG_CONTROLS_V4.SetControlReporting(CID, divert=1)
/// 动态发现始终优先; knownDevices 仅作为调试参考, 不影响 divert 决策
private func divertButtons(featureIndex: UInt8) {
queryControlList(featureIndex: featureIndex) { [weak self] controls in
for control in controls where control.isDivertable {
self?.divertButton(featureIndex: featureIndex, cid: control.cid)
}
}
}
}事件解析与分发
extension LogitechDeviceSession {
// C 函数指针, 作为 IOHIDDeviceRegisterInputReportCallback 的 callback
static let inputReportCallback: IOHIDReportCallback = { context, result, sender, type, reportID, report, reportLength in
guard let context = context else { return }
let session = Unmanaged<LogitechDeviceSession>.fromOpaque(context).takeUnretainedValue()
let reportData = Array(UnsafeBufferPointer(start: report, count: reportLength))
session.handleInputReport(reportData)
}
/// 处理 Input Report
func handleInputReport(_ report: [UInt8]) {
// 验证是 HID++ 报文
guard report.count >= 7, (report[0] == 0x10 || report[0] == 0x11) else { return }
let featureIdx = report[2]
let funcAndSw = report[3]
// HID++ Error Report 检测 (Feature Index = 0xFF)
// 格式: [ReportID, DevIdx, 0xFF, ErrorFeatureIdx, ErrorFuncID, SoftwareID, ErrorCode]
if featureIdx == 0xFF {
let errorFeatureIdx = report[3]
let errorCode = report.count > 6 ? report[6] : 0
NSLog("[LogitechHID] Error report: featureIdx=0x%02X errorCode=0x%02X", errorFeatureIdx, errorCode)
// 如果有 pending discovery 对应此 feature, 回调 nil
handleErrorReport(report)
return
}
// 检查是否为 pending feature discovery 的响应
if featureIdx == 0x00 {
handleDiscoveryResponse(report)
return
}
// 检查是否为 REPROG_CONTROLS_V4 通知
guard let reprogIdx = featureIndex[Self.featureReprogV4],
featureIdx == reprogIdx else { return }
// 解析按键事件
// REPROG_CONTROLS_V4 divertedButtonsEvent 通知格式:
// Params: [CID_MSB, CID_LSB, CID_MSB, CID_LSB, ...] (成对 CID, 0x0000 结束)
var activeCIDs: Set = []
var offset = 4
while offset + 1 < report.count {
let cid = (UInt16(report[offset]) << 8) | UInt16(report[offset + 1])
if cid == 0 { break }
activeCIDs.insert(cid)
offset += 2
}
// 差分检测: 按下/抬起
let newlyPressed = activeCIDs.subtracting(lastActiveCIDs)
let newlyReleased = lastActiveCIDs.subtracting(activeCIDs)
lastActiveCIDs = activeCIDs
for cid in newlyPressed {
dispatchButtonEvent(cid: cid, isDown: true)
}
for cid in newlyReleased {
dispatchButtonEvent(cid: cid, isDown: false)
}
}
private func dispatchButtonEvent(cid: UInt16, isDown: Bool) {
// 使用 CGEventSource.flagsState 获取修饰键 (比 NSEvent.current 更可靠)
// NSEvent.current 只在 sendEvent 期间有效, IOKit 回调中可能为 nil
// CGEventSource.flagsState(.combinedSessionState) 在 macOS 10.13+ 可用
let currentFlags = CGEventSource.flagsState(.combinedSessionState)
let mosEvent = MosInputEvent(
type: .mouse,
code: LogitechCIDMap.toMosCode(cid),
modifiers: currentFlags,
phase: isDown ? .down : .up,
source: .hidPlusPlus,
device: deviceInfo
)
// 处理结果: HID++ 事件无 pass-through 语义
let _ = MosInputProcessor.shared.process(mosEvent)
// 同时发送通知 (供 KeyRecorder 录制期间监听)
NotificationCenter.default.post(
name: LogitechHIDManager.buttonEventNotification,
object: mosEvent
)
}
}设备兼容性策略
/// 已知设备的按键定义 (可通过 plist/JSON 配置文件外置, 未来不用改代码)
struct LogitechDeviceProfile {
let productId: UInt16
let name: String
let divertableCIDs: [UInt16] // 需要 divert 的 CID 列表
static let knownDevices: [UInt16: LogitechDeviceProfile] = [
0xB034: LogitechDeviceProfile(
productId: 0xB034,
name: "MX Master 3S",
divertableCIDs: [0x00C3, 0x00C4, 0x00D7]
),
// 后续设备在此追加
]
}未知设备处理: 对于 knownDevices 中没有的 Logitech 设备,使用动态发现:
- 检查设备是否支持 REPROG_CONTROLS_V4 feature
- 如果支持,查询其可 divert 的按键列表
- 自动 divert 所有标记为 divertable 的按键
这意味着未来新 Logitech 设备只要支持 HID++ 2.0 和 REPROG_CONTROLS_V4,无需修改代码即可自动兼容。
优先级: 动态发现始终优先。knownDevices 仅作为调试参考和显示名称来源,不影响 divert 决策。如果动态发现返回的可 divert 按键多于 knownDevices 中定义的,按动态发现结果执行。
应用生命周期集成
// AppDelegate 或等效的启动入口
func applicationDidFinishLaunching() {
// ... 现有初始化 ...
ScrollCore.shared.enable()
ButtonCore.shared.enable()
LogitechHIDManager.shared.start() // 在 ButtonCore 之后启动
}
func applicationWillTerminate() {
LogitechHIDManager.shared.stop() // 在 ButtonCore 之前停止
ButtonCore.shared.disable()
ScrollCore.shared.disable()
}LogitechHIDManager 的启停独立于 ButtonCore,但顺序上:
- 启动时: ButtonCore 先启用 (确保 MosInputProcessor 的下游就绪), 然后启动 LogitechHID
- 停止时: 先停 LogitechHID (停止产生事件), 然后停 ButtonCore
状态隔离
状态归属矩阵
| 模块 | 拥有的状态 | 不知道的事 |
|---|---|---|
| CGEventTap Adapter (ButtonCore) | Interceptor 实例、EventTap 生命周期 | LogitechHIDManager 存在 |
| LogitechHIDManager | IOHIDManager、已连接设备列表 | CGEventTap 存在、ButtonBinding 配置 |
| LogitechDeviceSession | HID++ 会话状态、feature index 缓存、divert 状态、lastActiveCIDs | 处理层如何消费事件 |
| MosInputProcessor | 无状态 | 事件来自哪里 |
| Options / ButtonUtils | ButtonBinding 持久化配置 | 事件如何产生 |
| KeyRecorder | 录制会话状态 | 设备协议实现 |
| ShortcutExecutor | 无状态 | 触发来源 |
依赖方向 (单向, 无环)
LogitechHIDManager --+
+--> MosInputProcessor --> ButtonUtils/Options --> ShortcutExecutor
CGEventTap Adapter --+线程模型
Main RunLoop
+-- CGEventTap 回调 (系统调度, 同步)
+-- IOKit HIDManager 回调 (schedule 到 Main RunLoop, 同步)
+-- MosInputProcessor.process() (被上述两者同步调用, 无锁竞争)IOKit schedule 到 Main RunLoop 的理由:
- HID++ 按键事件极低频(用户按键级别, 每秒个位数)
- 避免跨线程同步复杂性
- ShortcutExecutor 内部的 CGEvent.post() 需要在 main thread
KeyRecorder 适配
改动方案
KeyRecorder 需要同时捕获 CGEventTap 和 HID++ 两种事件源:
class KeyRecorder: NSObject {
// 新增: HID++ 事件监听 (录制期间临时启用)
private var hidEventObserver: NSObjectProtocol?
func startRecording(from sourceView: NSView, mode: KeyRecordingMode = .combination) {
// ... 现有 CGEventTap interceptor 启动逻辑不变 ...
// 新增: 监听 HID++ 事件
hidEventObserver = NotificationCenter.default.addObserver(
forName: LogitechHIDManager.buttonEventNotification,
object: nil,
queue: .main
) { [weak self] notification in
guard let mosEvent = notification.object as? MosInputEvent else { return }
// 复用现有的录制完成通知机制
NotificationCenter.default.post(
name: KeyRecorder.FINISH_NOTI_NAME,
object: mosEvent // 注意: 这里传的是 MosInputEvent 而非 CGEvent
)
}
}
func stopRecording() {
// ... 现有清理逻辑 ...
// 新增: 移除 HID++ 监听
if let observer = hidEventObserver {
NotificationCenter.default.removeObserver(observer)
hidEventObserver = nil
}
}
}Delegate 协议变更
问题: 现有 KeyRecorderDelegate 是 @objc protocol (因为使用了 @objc optional)。但 MosInputEvent 是 struct,不能在 @objc 方法中使用。
解决方案: 去掉 @objc,用 protocol extension 提供默认实现替代 @objc optional:
// 改造前:
// @objc protocol KeyRecorderDelegate: AnyObject {
// func onEventRecorded(_ recorder: KeyRecorder, didRecordEvent event: CGEvent, isDuplicate: Bool)
// @objc optional func validateRecordedEvent(_ recorder: KeyRecorder, event: CGEvent) -> Bool
// }
// 改造后:
protocol KeyRecorderDelegate: AnyObject {
func onEventRecorded(_ recorder: KeyRecorder, didRecordEvent event: MosInputEvent, isDuplicate: Bool)
func validateRecordedEvent(_ recorder: KeyRecorder, event: MosInputEvent) -> Bool
}
// 默认实现 (替代 @objc optional 语义)
extension KeyRecorderDelegate {
func validateRecordedEvent(_ recorder: KeyRecorder, event: MosInputEvent) -> Bool {
return true // 默认: 视为新录制
}
}handleRecordedEvent 改造
现有代码第 208 行使用 notification.object as! CGEvent 强制转换。HID++ 事件作为 MosInputEvent 通过同一 notification 传入时会崩溃。改造后的完整逻辑:
@objc private func handleRecordedEvent(_ notification: NSNotification) {
guard isRecording, !isRecorded else { return }
// 统一转换为 MosInputEvent (兼容两种来源)
let mosEvent: MosInputEvent
if let cgEvent = notification.object as? CGEvent {
mosEvent = MosInputEvent(fromCGEvent: cgEvent)
} else if let hidEvent = notification.object as? MosInputEvent {
mosEvent = hidEvent
} else {
NSLog("[EventRecorder] Unknown event type: \(type(of: notification.object))")
return
}
// 验证有效性 (根据录制模式)
let isValid = recordingMode == .singleKey
? isRecordableAsSingleKey(mosEvent) // 需要新增 MosInputEvent 版本的验证
: isRecordable(mosEvent)
guard isValid else {
keyPopover?.keyPreview.shakeWarning()
invalidKeyPressCount += 1
if invalidKeyPressCount >= invalidKeyThreshold {
keyPopover?.showEscHint()
}
return
}
isRecorded = true
let isNew = self.delegate?.validateRecordedEvent(self, event: mosEvent) ?? true
let isDuplicate = !isNew
let status: KeyPreview.Status = isNew ? .recorded : .duplicate
keyPopover?.keyPreview
.update(from: MosInputEvent.buildDisplayComponents(mosEvent), status: status)
self.delegate?.onEventRecorded(self, didRecordEvent: mosEvent, isDuplicate: isDuplicate)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in
self?.stopRecording()
}
}注意: MosInputEvent 是 struct (不继承 NSObject),通过 NotificationCenter 传递时需要注意: notification.object 会被 box 为 Any?。使用 as? 类型检查即可安全判断。如果 box 行为有问题,备选方案是将 MosInputEvent 包装在一个轻量 NSObject wrapper class 中。
现有功能兼容性
| 功能 | 影响 | 说明 |
|---|---|---|
| 滚动平滑 (ScrollCore) | 无 | 不改, 继续用 CGEventTap |
| 滚动翻转 | 无 | 同上 |
| 滚动热键 (dash/toggle/block) | 无 | 继续用 ScrollHotkey + CGEvent 匹配 |
| 按钮绑定 (ButtonCore) | 主要改动 | callback 内转为 MosInputProcessor |
| 分应用配置 | 无 | Application.buttons 数据结构已就绪 |
| Remote 检测 | 无 | 只作用于 scrollWheel CGEvent |
| Trackpad 过滤 | 无 | 只作用于 scrollWheel CGEvent |
| Monitor 窗口 | 无 | 只可视化滚动数据 |
| 快捷键执行 | 无 | ShortcutExecutor 接口不变 |
| KeyRecorder | 需适配 | delegate 参数类型变更 |
| 配置持久化 | 向后兼容 | RecordedEvent 新增 optional 字段 |
ButtonFilter 与 HID++ 事件
当前 ButtonFilter 存在但未被 ButtonCore.buttonEventCallBack 调用(是预留接口)。MosInputProcessor 同样不集成 ButtonFilter。如果未来 ButtonFilter 被启用,需要同时将其应用于 HID++ 事件路径。目前两者保持一致: 都不经过 ButtonFilter。
Phase 2: ScrollCore 热键统一
当前 ScrollCore 的热键系统 (dash/toggle/block) 使用 ScrollHotkey.matches(_ event: CGEvent) 直接匹配 CGEvent。如果未来用户需要将 Logitech 手势按钮用作滚动热键,需要在 LogitechHIDManager 和 ScrollCore 之间增加桥接层,将 HID++ 按键事件转化为 ScrollCore 的热键状态更新。这不在本次设计范围内。
UserDefaults 数据兼容
RecordedEvent 新增 deviceFilter: DeviceFilter?:
- 旧数据反序列化时该字段缺失 ->
decodeIfPresent返回 nil -> 匹配任何设备 -> 行为与改动前完全一致 - 无需数据迁移逻辑
文件变更清单
新增文件 (4个)
| 文件 | 位置 | 职责 |
|---|---|---|
MosInputEvent.swift |
Mos/InputEvent/ |
MosInputEvent, MosInputPhase, MosInputSource, MosInputDevice, DeviceFilter |
MosInputProcessor.swift |
Mos/InputEvent/ |
MosInputProcessor, MosInputResult |
LogitechHIDManager.swift |
Mos/LogitechHID/ |
IOKit HIDManager 封装, 设备枚举, 生命周期 |
LogitechDeviceSession.swift |
Mos/LogitechHID/ |
HID++ 2.0 协议通信, Feature Discovery, Button Divert, 事件解析 |
修改文件 (6个)
| 文件 | 变更内容 |
|---|---|
RecordedEvent.swift |
新增 deviceFilter 字段 + matchesMosInput() + init(from: MosInputEvent) + ScrollHotkey.init(from: MosInputEvent) |
ButtonCore.swift |
callback 内部改为构造 MosInputEvent 并调用 MosInputProcessor |
KeyRecorder.swift |
delegate 从 @objc protocol 改为 Swift protocol + extension 默认实现; 参数类型改为 MosInputEvent; handleRecordedEvent 增加类型分支; 录制时增加 HID++ 监听 |
KeyCode.swift |
新增 Logitech 按钮码显示名称映射 (1000+ 段) |
PreferencesButtonsViewController |
delegate 方法签名跟随变更, RecordedEvent(from: MosInputEvent) |
PreferencesScrollingViewController |
delegate 方法签名跟随变更, ScrollHotkey(from: MosInputEvent) |
不修改文件
ScrollCore, ScrollPoster, Interpolator, ScrollFilter, ScrollUtils, ScrollEvent, Interceptor, ShortcutExecutor, SystemShortcut, ButtonFilter, ButtonUtils, Application, Options, Monitor 窗口
风险与缓解
1. Logitech Options+ 冲突
风险: Logitech Options+ 也会访问 HID++ 设备,两者争夺 HID 句柄。
缓解: IOHIDManager 的 kIOHIDOptionsTypeNone 模式允许多个进程同时打开设备。但 divert 操作可能冲突 -- 需要在文档中告知用户: 如使用 Mos 的 Logitech 按键绑定功能,建议退出 Logitech Options+。
2. 设备兼容性
风险: 不同 Logitech 设备的 HID++ 实现细节可能不同。
缓解: 使用动态 Feature Discovery 而非硬编码 feature index。对于不支持 REPROG_CONTROLS_V4 的设备,graceful fallback -- 不 divert 任何按键,不影响 CGEventTap 路径。
3. 蓝牙 vs USB Receiver
风险: Unifying Receiver 和蓝牙连接的设备在 HID 层的表现不同。
缓解: Unifying Receiver 作为一个复合 HID 设备出现,需要正确处理 device index。初版优先支持蓝牙直连(更简单),USB Receiver 作为后续增强。
4. macOS 版本兼容
风险: IOKit API 在不同 macOS 版本上的行为。
缓解: IOHIDManager 从 macOS 10.5 就存在,API 稳定。项目最低支持 10.13,完全兼容。
5. 权限
风险: 访问 HID 设备可能需要额外权限。
缓解: Mos 已经要求 Accessibility 权限。IOHIDManager 访问 HID 设备在 macOS 上通常不需要额外权限(除了某些 kext 级别的操作)。需要实际测试确认。
File: docs/superpowers/specs/2026-03-21-logitech-cid-registry-design.md
Logitech CID Registry 统一设计
日期: 2026-03-21
状态: Review Round 2 修订, 待用户审核
背景
Mos 的按键模块中,Logitech HID++ 按钮的 CID(Control ID)到人类可读名称的映射存在三处独立的硬编码,且覆盖范围有限(仅 8~12 个 CID)。未匹配的 CID 在 UI 中显示为不友好的 Logi(2253) 格式。
HID++ REPROG_CONTROLS_V4 协议不返回按钮名称字符串,只返回 CID + TaskID + Flags 数值。所有按钮名称必须由客户端维护静态映射表。Solaar 开源项目维护了一份约 310 条的权威 CID→名称映射表,来源于 Logitech 官方 controls.xml 及社区补充。
当前问题
- 名称覆盖不足: 硬编码仅 8~12 个 CID,未知按钮显示
Logi(N)格式 - 三处重复映射,互不引用:
MosInputEvent.swift→LogitechCIDMap.displayName()(8 条 MosCode→名称)KeyCode.swift→mouseMap中 1000+ 条目 (8 条,与上重复)LogitechHIDDebugPanel.swift→HIDPPInfo.cidNames(12 条 CID→名称)
- Logi 逻辑分散:
LogitechCIDMap定义在通用模块MosInputEvent.swift中,污染非 Logi 代码 - 命名有误: 如 CID 0x00FD 在 Mos 中标记为 "Battery LED",Solaar 中为 "DPI Switch"
设计方案
核心原则
- Single Source of Truth: 一张 CID 表,Debug 面板和按键面板共用
- Logi 模块内聚: 所有 Logi 专属逻辑内聚在
Mos/LogitechHID/目录 - 数据溯源: 注释中标注 Solaar 项目来源引用,便于后续同步
1. 新建 `LogitechCIDRegistry.swift`
位置: Mos/LogitechHID/LogitechCIDRegistry.swift
职责: CID 信息的唯一数据源,整合名称查询和 MosCode 映射。
/* Detailed source-code truncated for AI context efficiency. */2. 迁移 `LogitechCIDMap`
- 删除
MosInputEvent.swift中的整个LogitechCIDMap结构体 - 所有引用
LogitechCIDMap.xxx的调用点改为LogitechCIDRegistry.xxx - 接口签名保持兼容 (
toMosCode,toCID,isLogitechCode),仅类型名变更
3. 清理重复映射
| 操作 | 文件 | 详情 |
|---|---|---|
| 删除 | LogitechHIDDebugPanel.swift |
删除 HIDPPInfo.cidNames 字典,改为调用 LogitechCIDRegistry.name(forCID:) |
| 删除 | MosInputEvent.swift |
删除整个 LogitechCIDMap 结构体 |
| 删除 | KeyCode.swift → mouseMap |
删除 1000+ 的 Logi 条目 (1000~1007 共 8 条) |
4. 更新消费者代码
以下为所有引用点的完整清单 (基于全局搜索确认, 无遗漏):
4.1 `MosInputEvent.swift` (3 处)
// displayComponents - 改前:
if LogitechCIDMap.isLogitechCode(code) {
components.append(LogitechCIDMap.displayName(forCode: code))
components.append("[Logi]")
}
// 改后:
if LogitechCIDRegistry.isLogitechCode(code) {
components.append(LogitechCIDRegistry.name(forMosCode: code))
components.append("[Logi]")
}
// isRecordable - 改前:
if LogitechCIDMap.isLogitechCode(code) { return true }
// 改后:
if LogitechCIDRegistry.isLogitechCode(code) { return true }4.2 `BrandTag.swift` (1 处)
// isLogiCode - 改前:
return LogitechCIDMap.isLogitechCode(code)
// 改后:
return LogitechCIDRegistry.isLogitechCode(code)4.3 `ButtonTableCellView.swift` (1 处)
// 改前:
let isLogiTrigger = binding.triggerEvent.type == .mouse && LogitechCIDMap.isLogitechCode(binding.triggerEvent.code)
// 改后:
let isLogiTrigger = binding.triggerEvent.type == .mouse && LogitechCIDRegistry.isLogitechCode(binding.triggerEvent.code)4.4 `LogitechDeviceSession.swift` (7 处)
LogitechCIDMap 引用 (2 处):
// 改前:
let mosCode = LogitechCIDMap.toMosCode(c.cid)
code: LogitechCIDMap.toMosCode(cid),
// 改后:
let mosCode = LogitechCIDRegistry.toMosCode(c.cid)
code: LogitechCIDRegistry.toMosCode(cid),HIDPPInfo.cidNames 引用 (5 处, 行 407/443/453/872/877):
// 改前:
let cidName = HIDPPInfo.cidNames[cid] ?? "?"
// 改后:
let cidName = LogitechCIDRegistry.name(forCID: cid)4.5 `LogitechHIDDebugPanel.swift` (1 处)
// refreshControls() - 改前:
let name = HIDPPInfo.cidNames[c.cid] ?? "Unknown"
// 改后:
let name = LogitechCIDRegistry.name(forCID: c.cid)4.6 `RecordedEvent.swift` → `ScrollHotkey.displayName` (需修改)
ScrollHotkey 除了 init(from: CGEvent) 外, 还有 init(from: MosInputEvent) (line 232),
且 KeyRecorder 录制时会接收 Logi HID++ 按钮事件。因此 ScrollHotkey 可以持有 Logi 码 (>= 1000)。
// 改前:
var displayName: String {
switch type {
case .keyboard: return KeyCode.keyMap[code] ?? "Key \(code)"
case .mouse: return KeyCode.mouseMap[code] ?? "🖱\(code)"
}
}
// 改后:
var displayName: String {
switch type {
case .keyboard: return KeyCode.keyMap[code] ?? "Key \(code)"
case .mouse:
if LogitechCIDRegistry.isLogitechCode(code) {
return LogitechCIDRegistry.name(forMosCode: code)
}
return KeyCode.mouseMap[code] ?? "🖱\(code)"
}
}4.7 `PreferencesScrollingViewController.swift` → `getBaseDisplayName` (需修改)
// 改前:
case .mouse:
return KeyCode.mouseMap[hotkey.code] ?? "🖱\(hotkey.code)"
// 改后:
case .mouse:
if LogitechCIDRegistry.isLogitechCode(hotkey.code) {
return LogitechCIDRegistry.name(forMosCode: hotkey.code)
}
return KeyCode.mouseMap[hotkey.code] ?? "🖱\(hotkey.code)"4.8 `CGEvent+Extensions.swift` (无需修改)
CGEventTap 产生的 mouseCode 范围为 0~31, 不会出现 Logi 码, 不受 mouseMap 清理影响。
4.9 `tools/hidpp/full-test.swift` (不在 scope 内)
该文件是独立脚本 (swift tools/hidpp/full-test.swift 直接运行), 不属于 app target,
无法编译引用 app 源码中的 LogitechCIDRegistry。保留其本地 cidNames 副本。
后续如需统一, 需要将 Registry 提取为 shared Swift module 或将工具纳入 Xcode build target。
5. Solaar 名称格式化策略
- 在
cidNames字典中直接存储格式化后的名称(编译期确定) - 格式化规则: 下划线 → 空格, 保持原始大小写
- 示例:
"DPI_Switch"→"DPI Switch","Mouse_Gesture_Button"→"Mouse Gesture Button" - 对于双下划线表示的
/分隔:"Screen_Capture__Print_Screen"→"Screen Capture / Print Screen"
6. 命名修正 (以 Solaar 为准)
| CID | Mos 当前 | Solaar (采用) |
|---|---|---|
| 0x00FD | Battery LED | DPI Switch |
| 0x00D7 | DPI Change | Virtual Gesture Button |
| 0x00D0 | Top Button | MultiPlatform Gesture Button |
| 0x00C3 | Gesture Button | Mouse Gesture Button |
| 0x00C4 | SmartShift | Smart Shift |
| 0x00E8 | Thumb Wheel Up | Volume Down |
| 0x00E9 | Thumb Wheel Down | Volume Up |
已持久化 displayComponents 的处理
RecordedEvent.displayComponents 是在录制时生成并通过 Codable 序列化到 UserDefaults 的。
现有绑定中的旧名称 (如 "Gesture", "SmartShift") 会保留, 新录制的绑定会使用 Solaar 的名称
(如 "Mouse Gesture Button", "Smart Shift")。
处理策略: 接受临时不一致, 不做数据迁移。
- 绑定的功能由
code(MosCode) 决定, 名称仅影响 UI 展示, 不影响功能 - 用户重新录制绑定时, displayComponents 会自然更新为新名称
- 数据迁移的复杂度和风险与收益不成正比
改动范围汇总
| 文件 | 操作 |
|---|---|
Mos/LogitechHID/LogitechCIDRegistry.swift |
新建 - CID 注册表 (唯一数据源) |
Mos/InputEvent/MosInputEvent.swift |
改 - 删除 LogitechCIDMap, 引用改为 LogitechCIDRegistry |
Mos/LogitechHID/LogitechHIDDebugPanel.swift |
改 - 删除 HIDPPInfo.cidNames, 改用 Registry |
Mos/LogitechHID/LogitechDeviceSession.swift |
改 - LogitechCIDMap → LogitechCIDRegistry (2处), HIDPPInfo.cidNames → Registry (5处) |
Mos/Keys/KeyCode.swift |
改 - 删除 mouseMap 中 1000+ 条目 (8条) |
Mos/Components/BrandTag.swift |
改 - isLogiCode 改为引用 Registry |
Mos/Windows/PreferencesWindow/ButtonsView/ButtonTableCellView.swift |
改 - LogitechCIDMap → LogitechCIDRegistry |
| Mos/Windows/PreferencesWindow/ButtonsView/RecordedEvent.swift | 改 - ScrollHotkey.displayName 增加 Logi 码 fallback |
| Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingViewController.swift | 改 - getBaseDisplayName 增加 Logi 码 fallback |
不涉及 (经确认无影响):
MosInputProcessor.swift(无 LogitechCIDMap/HIDPPInfo.cidNames 引用)CGEvent+Extensions.swift(mouseCode 范围 0~31, 不涉及 Logi 码)tools/hidpp/full-test.swift(独立脚本, 不属于 app target, 无法引用 app 源码)
不变的部分
BrandTag的渲染逻辑、品牌标签视觉样式KeyPreview的[Logi]标记识别和渲染MosInputEvent的整体结构和displayComponents的输出格式RecordedEvent的序列化格式和ButtonBinding的存储结构KeyRecorder的录制流程HIDPPInfo.featureNames及其他非 CID 字典保留在LogitechHIDDebugPanel.swift
来源引用与许可
CID→名称映射数据的原始来源为 Logitech 官方 controls.xml。
Solaar 项目 (GPL-2.0) 将其整理为 Python 字典格式并持续维护。
许可分析:
- CID 编号是 Logitech 硬件协议的事实性标识符, 不构成版权保护对象
- 按钮功能名称 (如 "Left Button", "Smart Shift") 是对硬件功能的描述性标签,
属于 Logitech 产品定义的事实性数据, 非 Solaar 的创意表达 - Solaar 的贡献在于整理和补充这些映射, 我们在注释中明确标注来源以示致谢
- 我们不复制 Solaar 的任何代码逻辑、算法或程序结构, 仅移植事实性的 CID→名称对照关系
- Mos 项目 (CC BY-NC 4.0) 与 GPL-2.0 在许可条款上存在差异,
但对于事实性数据的引用不触发 GPL 的 copyleft 传播条款
最终决定: 由项目维护者 (Caldis) 确认此许可分析是否可接受。
文件头部注释需包含:
// Logitech HID++ Control ID (CID) 名称注册表
//
// 数据来源: Solaar 项目
// https://github.com/pwr-Solaar/Solaar
// 文件: lib/logitech_receiver/special_keys.py
// Commit: b9e0cf823543ba1dadc8eb188083b5c8db6280b0
// 原始数据基于 Logitech 官方 controls.xml, 由 Solaar 社区维护和补充
// Solaar 项目采用 GPL-2.0 许可证File: docs/superpowers/specs/2026-03-22-toast-component-evolution-design.md
Toast 组件扩展设计
概述
将 Mos 项目中的 Toast 通知组件从单文件单 toast 架构演进为多 toast、可拖拽、可配置的产品级模块,并确保可独立解耦开源。
目标
- 状态栏入口图标语义优化
- Debug 面板全面重设计(面向用户的产品功能)
- 支持多 toast 同时展示,堆叠方向自适应
- 支持 toast 位置拖拽 + 独立持久化
- 模块可独立插拔解耦,为独立开源做准备
架构方案:模块目录化
将现有单文件 Toast.swift(666 行)拆分为 Toast/ 目录下多个内聚文件,与项目现有模式(ScrollCore/、ButtonCore/)一致。对外仍只暴露 Toast struct 的 public 方法。
文件结构
Mos/Components/Toast/
├── Toast.swift (~80 行) 公开 API 入口
├── ToastManager.swift (~250 行) 多 toast 生命周期管理
├── ToastWindow.swift (~200 行) 容器窗口 + 拖拽手势
├── ToastContentView.swift (~200 行) 视觉渲染层
├── ToastStorage.swift (~60 行) 独立 UserDefaults 持久化
├── ToastPanel.swift (~400 行) 产品级 Debug 面板
└── README.md 组件文档依赖关系
Toast (公开 API) → ToastManager → ToastWindow → ToastContentView
↘ ToastStorage
Toast.showTestPanel() → ToastPanel → Toast (通过公开 API 测试)所有内部类使用 internal 访问级别(同 module 内可见),对外只暴露 Toast struct 的 public 方法。
§1 公开 API
public struct Toast {
/// 显示通知
static func show(
_ message: String,
style: Toast.Style = .info,
duration: TimeInterval = 2.5,
icon: NSImage? = nil
)
/// 关闭所有通知
static func dismissAll()
/// 打开 Debug 面板
static func showTestPanel()
/// 返回可直接加入菜单的 MenuItem
/// 内部使用 ToastPanel 单例作为 target,action 指向其 show() 方法。
/// NSMenuItem 的 title、icon 等均由 Toast 模块自包含,调用方无需额外配置。
static func debugMenuItem() -> NSMenuItem
enum Style: CaseIterable { case info, success, warning, error }
}变更说明:
show()— 签名不变,完全向后兼容dismissAll()— 新增,支持一键清除所有 toastdebugMenuItem()— 新增,使用方一行menu.addItem(Toast.debugMenuItem())接入。target 为 ToastPanel 单例(NSObject 子类),action/icon/title 自包含showTestPanel()— 保留,内部重写Style— 改为CaseIterable,ToastPanel 可遍历所有样式
§2 多 Toast 管理(ToastManager)
容器窗口策略
采用单容器窗口方案:一个透明 NSPanel 作为容器,所有 ToastContentView 作为其子视图排列。
选择理由: 多独立 NSPanel 方案在拖拽时需要 N 个窗口同步 setFrame,高频 mouseDragged 下存在掉帧风险(尤其 macOS 10.13 + 旧硬件)。容器窗口方案从根本上消除此问题——拖拽永远只移动 1 个窗口。
降级方案: 如果实测容器窗口仍有性能问题,回退到"拖拽只移动被抓取的单个 toast,松手后其他 toast 动画归位"的简化方案。
堆叠算法
- 锚点:第一个 toast 出现在用户记忆的位置(或默认的屏幕上方 1/5 居中处)
- 方向判定:锚点在屏幕可见区域上半部 → 后续向下偏移;下半部 → 向上偏移
- 偏移量:每个新 toast 在堆叠方向上偏移
toastHeight + spacing(8px) - 溢出淘汰:超出 maxCount 时,最旧的 toast 立即淡出消失
- 回收重排:中间的 toast 消失后,后续 toast 动画滑动填补空位
多屏支持
根据鼠标当前位置自动选择目标屏幕(沿用现有逻辑)。
去重与防竞态
- 去重:检查当前所有可见 toast 中是否存在相同消息文本,若存在则不重复显示(从单 toast 的 lastMessage 时间窗口去重升级为多 toast 的可见集合去重)
- 防竞态:每个 ToastContentView 持有自己的 generation 计数器,避免旧淡出 completion 影响新 toast
dismissAll()语义:立即将所有 toast 从 manager 的跟踪集合中移除(先移除再动画),所有可见 toast 并发淡出,取消全部 timer,completion 后释放容器窗口。先移除确保淡出 completion 无竞态
§3 拖拽交互(ToastWindow)
容器窗口设计
NSPanel子类,透明背景,level: .floating.nonactivatingPanel+.fullScreenAuxiliary集合行为- 窗口大小动态计算:
maxCount × (toastHeight + spacing)为最大尺寸,maxCount 变更时容器窗口大小同步调整
事件穿透
容器窗口设置 ignoresMouseEvents = false(不再使用旧实现的 true),改由容器 NSPanel 的 contentView 重写 hitTest(_:) 精确控制事件响应区域:遍历所有 ToastContentView 子视图的 frame 判定命中,命中则返回 self(响应拖拽),未命中则返回 nil(事件穿透到下层窗口)。macOS 10.13 完全支持。
拖拽行为
- 拖拽任意一个 toast 内容区域时,整个容器窗口跟随移动(所有 toast 保持堆叠间距)
- 松开后,新的锚点位置写入 ToastStorage
- 堆叠方向仅在拖拽松手时根据锚点位置重新计算,堆叠过程中不动态翻转(即使堆叠延伸过屏幕中线,也继续当前方向直到溢出淘汰)
- 跨越屏幕中线拖拽松手后方向翻转,子视图动画重排
动画
- 淡入:250ms,easeOut(沿用)
- 淡出:300ms,easeIn(沿用)
- 回收重排滑动:200ms,easeInOut
- 方向翻转重排:250ms,easeInOut
§4 独立持久化(ToastStorage)
// 独立 UserDefaults suite,不与应用的 Options/UserDefaults 混合
// suiteName 默认基于 Bundle ID 动态生成,确保不同宿主应用间不冲突
UserDefaults(suiteName: "\(Bundle.main.bundleIdentifier ?? "app").toast")
// 例如 Mos → "com.caldis.Mos.toast"| Key | Type | Default | 说明 |
|---|---|---|---|
| positionX | CGFloat? | nil (屏幕居中) | 锚点 X 坐标(绝对屏幕坐标) |
| positionY | CGFloat? | nil (上 1/5) | 锚点 Y 坐标(绝对屏幕坐标) |
| maxCount | Int | 4 | 最大同时显示数 |
nil值表示使用默认位置(屏幕可见区域水平居中,垂直上 1/5 处)- Debug 面板中的 Reset 按钮清除 positionX/positionY 回到默认
- 坐标有效性校验:加载时检查保存的坐标是否在任何可见屏幕范围内,若不在(如外接显示器已断开)则回退到默认位置(nil)
- maxCount 实时生效:调小 maxCount 时,超出部分的最旧 toast 立即淡出
§5 产品级 Debug 面板(ToastPanel)
设计原则
Debug 面板是面向用户的产品功能,不只是开发调试工具。视觉品质要求与应用其他界面一致。
类设计
class ToastPanel: NSObject — 单例,管理 debug 面板窗口的生命周期。作为 NSObject 子类可直接作为 NSMenuItem 的 target。内部持有一个 NSPanel 实例(非自身继承 NSPanel),负责创建、显示和配置面板窗口。
视觉风格
- 整体使用
NSVisualEffectView毛玻璃背景 - Header 与内容融为一体,不做分隔线,统一材质
- 深色 HUD 风格,与 toast 本身的视觉风格统一
- 窗口尺寸约 420×520
三段式布局
Configuration(全局配置):
- Max Simultaneous:滑块控件,范围 1-8,默认 4,实时写入 ToastStorage
- Position:显示当前状态(Saved/Default),Reset 按钮清除记忆位置
Send Toast(自定义发送):
- Message:文本输入框
- Style:按钮组(非下拉),选中态高亮,颜色+图标直观表达(Info / Success / Warning / Error)
- Duration:滑块 0.5-10s,默认 2.5s
- Custom Icon:复选框
- Show Toast:主操作按钮
Quick Tests(一键场景测试):
2×3 网格布局:
| 测试 | 说明 |
|---|---|
| All Styles | 逐个显示 4 种样式 |
| Stack Test | 连续发送至 maxCount,验证堆叠 |
| Overflow | 超出上限,验证淘汰 |
| Dedup | 连续发送相同消息,验证去重 |
| Long Text | 超长文本,验证截断 |
| Dismiss All | 一键清除所有 toast |
§6 状态栏图标
变更: bubble.left.fill → text.bubble
- macOS 11+:使用
text.bubbleSF Symbol,语义从"聊天"改为"通知/消息" - macOS 10.13-10.15:保持现有 imageLiteral fallback
集成方式变化:
// 之前 (StatusItemManager 中硬编码)
Utils.addMenuItem(to: menu, title: " Toast Test",
icon: #imageLiteral(resourceName: "SF.bubble.left.fill"),
action: #selector(toastTestClick))
// 之后 (一行接入)
menu.addItem(Toast.debugMenuItem())§7 兼容性
所有实现必须兼容 macOS 10.13+:
- SF Symbols(macOS 11+)有 imageLiteral fallback
- NSVisualEffectView:10.14+ 用
.hudWindow+.vibrantDark,10.13 用.dark - hitTest 重写:全版本可靠
- UserDefaults suiteName:全版本可靠
- NSAnimationContext:全版本可靠
§8 模块解耦清单
确保 Toast 模块可独立插拔:
- 零外部依赖(不依赖 Options、Application、ScrollCore 等)
- 独立持久化(UserDefaults suite,不混用应用 UserDefaults)
- 公开 API 完整(show / dismissAll / showTestPanel / debugMenuItem)
- StatusItemManager 中通过
Toast.debugMenuItem()接入,无硬编码 - README.md 包含独立使用文档
- 所有字符串可本地化(使用 NSLocalizedString)
File: docs/superpowers/specs/2026-03-30-cvdisplaylink-recovery-and-core-tests-design.md
CVDisplayLink Recovery & Core Event Processing Tests
Problem
After macOS sleep/wake, the CVDisplayLink in ScrollPoster becomes invalid because display identifiers change. The CGEventTap continues to consume original scroll events (return nil), but ScrollPoster cannot post smooth replacement events because its CVDisplayLink is dead. Result: complete global scroll failure until Mos is restarted.
Root Cause Chain
macOS sleep
-> willSleepNotification -> disable() -> ScrollPoster.stop()
(CVDisplayLink stopped but object retained, bound to old displays)
-> System sleeps -> displays disconnect
macOS wake
-> didWakeNotification fires (displays may NOT be ready yet!)
-> enable() -> ScrollPoster.create()
-> CVDisplayLinkCreateWithActiveCGDisplays fails (display count = 0, error -6661)
-> poster becomes nil or zombie
User scrolls
-> CGEventTap intercepts event (Interceptor keeper keeps tap alive)
-> shouldSmoothAny == true -> return nil (original event consumed)
-> tryStart() silently fails (poster nil/invalid, return value unchecked)
-> processing() callback never fires -> smooth events never posted
-> Result: event swallowed, no replacement -> total scroll failureAdditional Trigger: Display-Only Sleep
Screen timeout (Energy Saver "Turn display off after X minutes") does NOT trigger willSleepNotification/didWakeNotification, but CVDisplayLink depends on VSync which stops when display sleeps. Current code has zero handling for this scenario.
Part 1: CVDisplayLink Recovery Fix
Files Changed
| File | Scope |
|---|---|
Mos/ScrollCore/ScrollPoster.swift |
Core changes: create error handling, tryStart failure recovery, keeper timer, isAvailable, zombie detection |
Mos/ScrollCore/ScrollCore.swift |
1 line: fallback passthrough in scrollEventCallBack |
Mos/AppDelegate.swift |
1 observer: didChangeScreenParametersNotification with debounced recreate |
Design Decisions
Thread Safety: Main Thread Serialization (No New Locks)
scrollEventCallBackruns on main thread (Interceptor adds source to main RunLoop)keepertimer runs on main threadcreate()/recreateDisplayLink()called fromenable()or keeper timer -> main threadtryStart()called fromscrollEventCallBackchain -> main threadposterwrites only on main thread, reads primarily on main thread- Only cross-thread access:
stop()from CVDisplayLink thread readsposter-> butcreate()callsCVDisplayLinkStop(old)first, Apple docs guarantee callback won't fire after stop returns lastCallbackTime: written inprocessing()(CVDisplayLink thread), read inhealthCheck()(main thread) -> protected by existingstateLock- Conclusion: no additional locks needed for
poster
Three-Layer Recovery (Passive + Active + Fallback)
- Passive:
tryStart()detects poster nil/start failure -> immediaterecreateDisplayLink()with 3-second cooldown - Active:
NSApplication.didChangeScreenParametersNotification-> debounced 1-second delay ->recreateDisplayLink()(covers hot-plug, display sleep/wake, resolution changes) - Fallback: keeper timer every 5 seconds +
lastCallbackTimezombie detection (running=true but callback silent >2 seconds)
Graceful Degradation
When CVDisplayLink is unavailable, scrollEventCallBack passes through the original event instead of consuming it. User gets raw (non-smooth) scrolling, which is far better than total scroll failure.
Detailed Changes
ScrollPoster.swift
New properties:
private var keeper: Timer?
private var lastCallbackTime: CFTimeInterval = 0.0
private var lastRecreateAttempt: CFTimeInterval = 0.0
private let recreateCooldown: CFTimeInterval = 3.0
// Main thread only, no lock needed
var isAvailable: Bool { return poster != nil }create() rewrite:
func create() {
// Clean up old CVDisplayLink before creating new one
if let old = poster {
if CVDisplayLinkIsRunning(old) {
CVDisplayLinkStop(old) // Synchronous: waits for callback to finish
}
poster = nil
}
var newPoster: CVDisplayLink?
let result = CVDisplayLinkCreateWithActiveCGDisplays(&newPoster)
if result == kCVReturnSuccess, let valid = newPoster {
CVDisplayLinkSetOutputCallback(valid, { (displayLink, inNow, inOutputTime, flagsIn, flagsOut, displayLinkContext) -> CVReturn in
ScrollPoster.shared.processing()
return kCVReturnSuccess
}, nil)
poster = valid
} else {
poster = nil
NSLog("ScrollPoster: CVDisplayLink creation failed (%d)", result)
}
}tryStart() rewrite:
func tryStart() {
guard let validPoster = poster else {
if !recreateDisplayLink() {
// Cooldown rejected recreate; clear stale buffer to prevent
// scroll jump when poster eventually recovers
reset()
}
return
}
if !CVDisplayLinkIsRunning(validPoster) {
let result = CVDisplayLinkStart(validPoster)
if result == kCVReturnSuccess {
// Give keeper a grace period so it doesn't misidentify
// a freshly started poster as zombie
os_unfair_lock_lock(&stateLock)
lastCallbackTime = CFAbsoluteTimeGetCurrent()
os_unfair_lock_unlock(&stateLock)
} else {
let _ = recreateDisplayLink()
}
}
}recreateDisplayLink() with cooldown:
@discardableResult
func recreateDisplayLink() -> Bool {
let now = CFAbsoluteTimeGetCurrent()
guard now - lastRecreateAttempt >= recreateCooldown else { return false }
lastRecreateAttempt = now
create()
if let validPoster = poster {
let result = CVDisplayLinkStart(validPoster)
if result == kCVReturnSuccess {
os_unfair_lock_lock(&stateLock)
lastCallbackTime = CFAbsoluteTimeGetCurrent()
os_unfair_lock_unlock(&stateLock)
} else {
NSLog("ScrollPoster: CVDisplayLink start failed after recreate (%d)", result)
}
}
return true
}Keeper timer:
func startKeeper() {
keeper?.invalidate()
keeper = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
self?.healthCheck()
}
}
func stopKeeper() {
keeper?.invalidate()
keeper = nil
}
private func healthCheck() {
guard let validPoster = poster else {
recreateDisplayLink()
return
}
if CVDisplayLinkIsRunning(validPoster) {
os_unfair_lock_lock(&stateLock)
let lastTime = lastCallbackTime
os_unfair_lock_unlock(&stateLock)
// lastTime > 0 avoids false positive before first callback fires
if lastTime > 0 && CFAbsoluteTimeGetCurrent() - lastTime > 2.0 {
NSLog("ScrollPoster: zombie CVDisplayLink detected, recreating")
recreateDisplayLink()
}
}
}processing() addition (at the top, inside existing lock):
func processing() {
// ... existing os_unfair_lock_lock(&stateLock) ...
lastCallbackTime = CFAbsoluteTimeGetCurrent()
// ... rest of existing logic unchanged ...
}Lifecycle integration:
ScrollCore.enable()callsScrollPoster.shared.startKeeper()aftercreate()ScrollCore.disable()callsScrollPoster.shared.stopKeeper()afterstop()
ScrollCore.swift
scrollEventCallBack change (1 line):
Replace:
if shouldSmoothAny {
return nil
}With:
if shouldSmoothAny {
if ScrollPoster.shared.isAvailable {
return nil
} else {
return Unmanaged.passUnretained(event) // Graceful degradation
}
}AppDelegate.swift
Add in applicationWillFinishLaunching, after existing notification registrations:
// Debounce timer for screen parameter changes
private var screenChangeTimer: Timer?
// In applicationWillFinishLaunching:
NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil, queue: .main
) { [weak self] _ in
self?.screenChangeTimer?.invalidate()
self?.screenChangeTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in
ScrollPoster.shared.recreateDisplayLink()
}
}Edge Cases Covered
| Scenario | Recovery Path | Max Latency |
|---|---|---|
| System sleep/wake (display not ready at didWake) | enable() -> create() fails -> tryStart() recreate -> OR keeper 5s healthCheck | ~3-8s |
| Display-only sleep (screen timeout) | didChangeScreenParameters + keeper zombie detection | ~1-6s |
| External display hot-unplug | didChangeScreenParameters 1s delay -> recreate | ~1s |
| Multi-display: VSync source removed | keeper zombie detection (running but no callbacks) | ~5-7s |
| Clamshell mode: unplug external | didChangeScreenParameters -> recreate with remaining display | ~1s |
| Rapid scroll during CVDisplayLink unavailable | fallback passthrough (raw scroll, no smooth) | 0ms |
| create() fails during cooldown period | reset() clears buffer; keeper retries after cooldown | 3-5s |
Part 2: XCTest Infrastructure & Test Suites
Infrastructure
- New
MosTestsXCTest target added toMos.xcodeproj @testable import Mosfor internal type access- Debug build configuration (ensures
#if DEBUGdiagnostics are visible) XCTSkipUnlessfor CGEvent-dependent tests in headless CI
Small Refactors to Enable Testing (Behavior-Preserving)
| Change | Purpose |
|---|---|
ScrollDispatchContext.init() -> internal (remove private) |
Allow test instances for isolation |
Add #if DEBUG resetDiagnostics() on ScrollDispatchContext |
Reset counters between tests |
Add #if DEBUG internal var eventTTL on ScrollDispatchContext |
Inject short TTL for timeout tests |
Add bindingsProvider closure on MosInputProcessor |
Inject test bindings without touching UserDefaults |
Extract ScrollCore.makeScrollDecision(...) static pure function |
Test smooth/reverse/passthrough decision matrix without CGEventTap |
Test Suites
P0: Suite 1 - ScrollPhaseTests
Full state machine coverage. Parameterized matrix: each of 5 event methods x all 9 states.
Key scenarios:
- Idle -> TrackingBegin (separated input)
- TrackingOngoing -> TrackingEnd (manual input ended)
- TrackingEnd -> MomentumBegin -> MomentumOngoing -> MomentumEnd -> Idle (full inertial)
- MomentumOngoing interrupted by new input -> MomentumEnd + TrackingBegin
- Non-inertial: Tracking -> TrackingEnd (no Momentum)
- didDeliverFrame autoAdvance mechanism
- All "empty plan" defensive cases (calling methods from unexpected states)
- apply() direct phase setting for Hold/Leave
P0: Suite 2 - ScrollDispatchContextTests
Test with independent instances (non-singleton) for isolation.
- capture stores template; preparePostingSnapshot returns valid clone
- capture failure (nil event clone) returns false
- advanceGeneration invalidates old-generation snapshots in enqueue
- clearContext makes preparePostingSnapshot return nil
- invalidateAll clears everything
- TTL expiry (inject short eventTTL, verify droppedFramesByTTL counter)
- Concurrent capture + preparePostingSnapshot stress test (DispatchQueue, not concurrentPerform)
- advanceGeneration + enqueue concurrent safety
P1: Suite 3 - ScrollEventTests
Requires CGEvent construction (XCTSkipUnless).
- Parse vertical/horizontal scroll from CGEvent fields
- usableValue priority: scrollPt > scrollFixPt > scrollFix (3 test cases for each fallback level)
- reverseY / reverseX negate values correctly
- normalizeY/X: below step -> normalize to step; above step -> preserve; positive vs negative direction
- clearY / clearX zero out values
- Both axes present simultaneously
P1: Suite 4 - InterpolatorTests + ScrollFilterTests
Pure math, no dependencies.
Interpolator:
- lerp: zero distance, full transition, half transition, negative values, trans > 1.0
- smoothStep2/3: boundary values, division by zero when dest=0
ScrollFilter:
- Initial value (0,0)
- Single fill -> verify window state (array[1] is pivot, not array[0])
- Multiple fills -> curve smoothing convergence
- Reset clears state
- Direction change transition behavior
P1: Suite 5 - ScrollCoreHotkeyTests
Test handleScrollHotkeyFromHIDPlusPlus (pure logic, no CGEventTap dependency).
- key-down matches dash hotkey -> dashScroll=true, dashAmplification=5.0
- key-down matches toggle hotkey -> toggleScroll=true
- key-down matches block hotkey -> blockSmooth=true
- key-up clears state by tracked code (not current app config)
- Multiple hotkeys simultaneously active
- key-up with different code than tracked -> no clear
- Application context refresh on key-down
P2: Suite 6 - ScrollPosterStateTests
No CVDisplayLink involvement.
- shift(): no shifting, vertical-to-horizontal, already horizontal, MXMaster normalization (both axes), both axes with shifting=true (doesn't swap)
- update() direction: same direction accumulates buffer; reverse direction resets buffer
- brake(): buffer = current
P2: Suite 7 - RecordedEventMatchTests
- Exact match (type + code + modifiers)
- Modifier mismatch -> false
- Code mismatch -> false
- DeviceFilter nil -> matches any device
- DeviceFilter vendor-only (productId=nil) -> wildcard product
- DeviceFilter full match (vendor + product)
- DeviceFilter present but event device=nil -> false
- Keyboard event phase=up -> does not match
- Equality operator doesn't compare deviceFilter (document behavior)
P2: Suite 8 - ScrollHotkeyTests
- isModifierKey for command/control/option/shift codes -> true
- Non-modifier key -> false
- matches() keyboard event
- matches() mouse button event
- modifierMask maps to correct CGEventFlags
- legacyCode Int migration to ScrollHotkey
P3: Suite 9 - MosInputProcessorTests
Uses injected bindingsProvider.
- Matching binding exists -> consumed
- No matching binding -> passthrough
- Disabled binding -> passthrough
- Multiple bindings, first match wins
P3: Suite 10 - MosInputEventTests
- CGEvent keyDown -> MosInputEvent type=keyboard
- CGEvent otherMouseDown -> MosInputEvent type=mouse
- Modifier flag extraction accuracy
- displayComponents formatting
P3: Suite 11 - ButtonFilterTests
- Unblocked app passes through
- Blocked app filters event
- Add/remove blocked app dynamically
P2: Suite 12 - ScrollDecisionTests
Tests extracted makeScrollDecision() pure function.
- Global smooth enabled, no exceptions -> smooth both axes
- Per-app exception with inherit=false -> use app-specific settings
- Allowlist mode: unlisted app -> no smooth/reverse
- blockSmooth hotkey active -> disable smooth
- toggleScroll active -> shift vertical to horizontal
- Launchpad active -> force disable smooth
- Reverse vertical + horizontal independently
- Combined: reverse + smooth + per-app + shift
Implementation Priority
Phase 1 (P0): ScrollPhaseTests, ScrollDispatchContextTests
Phase 2 (P1): ScrollEventTests, InterpolatorTests, ScrollFilterTests, ScrollCoreHotkeyTests
Phase 3 (P2): ScrollPosterStateTests, RecordedEventMatchTests, ScrollHotkeyTests, ScrollDecisionTests
Phase 4 (P3): MosInputProcessorTests, MosInputEventTests, ButtonFilterTestsFile: docs/superpowers/specs/2026-03-30-hidpp-debug-panel-redesign.md
HID++ Debug Panel Redesign
Overview
Redesign the Logitech HID++ Debug Panel from its current 5-section vertical layout into a modern IDE-style interface with NSVisualEffectView frosted glass dark theme, matching the Toast Debug Panel's visual language.
Goals
- Modernize visual design with
.hudWindow+.vibrantDarkfrosted glass style - Reorganize layout for efficient debugging workflow: device selection → state inspection → action execution → log monitoring
- Support known HID++ features as selectable test actions, plus generic function call for unknown features
- Add custom raw packet sending and log export
- Protocol log with type filtering and expandable entries
- Left sidebar device navigator with tree view and hot-plug dynamic updates
Window Configuration
Follows ToastPanel.swift construction pattern exactly:
- Window type:
NSPanel(notNSWindow) - Style mask:
.titled,.closable,.miniaturizable,.resizable,.fullSizeContentView - Default size: 1100x750, minimum: 1100x600, resizable
- Title:
"Logitech HID++ Debug",titlebarAppearsTransparent = true,titleVisibility = .hidden - Background:
NSVisualEffectViewas rootcontentView- material:
.hudWindow(macOS 10.14+) /.dark(10.13 fallback) - blendingMode:
.behindWindow - state:
.active
- material:
- Appearance: set on panel itself —
panel.appearance = NSAppearance(named: .vibrantDark)(available since macOS 10.10, no version guard needed) - Top inset: derived from
contentLayoutRectlike ToastPanel'sresolvedTopInset() - Shadow:
hasShadow = true - Behavior:
isMovableByWindowBackground = true,hidesOnDeactivate = false,isReleasedWhenClosed = false - Layout: 100% programmatic, frame-based with
autoresizingMaskfor resize support
Layout Structure
+-------------------------------------------------------------------+
| [Close][Min] (transparent titlebar) |
+----------+--------------------------------------------------------+
| | FEATURES (5) | CONTROLS (7) | ACTIONS |
| DEVICES | +-----------+ | +-----------+ | [Context Actions] |
| | | 0x00 IRoot| | | Left . | | based on selection |
| [M] MX | | 0x01 IFeat| | | Right . | | |
| Master | | 0x04 Repro| | | Mid DVRT | | --- Global --- |
| . BLE | | 0x05 Smart| | | Back DVRT | | [Re-Discover] |
| | | 0x06 DPI | | | Fwd . | | [Re-Divert] |
| [R] Bolt | +-----------+ | +-----------+ | [Undivert All] |
| |- Slot1| | | [Enumerate] |
| +- Slot2| | | [Clear Log] |
+----------+------------------------------------------------------ +
| Device | PROTOCOL LOG [Filters] |
| Info | > TX IRoot.GetFeature(0x1B04) > [Export] |
| VID 046D | < RX idx=0x04 ok > [Clear] |
| PID B034 | ! Button: Back (0x53) DOWN |
| Proto 4.2| x ERR: InvalidFeatureIdx > |
| BLE | ------------------------------------------------ |
| Idx 0xFF | RAW: [11 FF 00 01 1B 04 00 ... ] [Send] |
| [More..] | |
+----------+--------------------------------------------------------+Section 1: Left Sidebar — Device Navigator (180px fixed)
Upper Area — Device Tree (flexible height)
- Section label:
"DEVICES"(10pt medium, tertiaryLabelColor) - Implementation:
NSOutlineViewwith tree data source - BLE Direct devices: single-level nodes
- Text prefix
[M](mouse) or[K](keyboard) + device name + status dot - Green dot = connected, no red/offline nodes (disconnected devices are removed from tree)
- Unsupported Logitech interfaces (connectionMode == .unsupported) are filtered out and do not appear in the sidebar
- Text prefix
- Receiver devices: expandable parent nodes
- Parent:
[R]prefix + receiver name + connection status - Children: Slot 1-6, showing
device name (type)orempty, online/offline per receiver enumeration - Current target slot highlighted with blue selection background
- Parent:
- Hot-plug: observe
LogitechHIDManagersession add/remove notifications, auto-refresh outline view- Only connected sessions appear (no offline caching — matches
LogitechHIDManager.activeSessionsbehavior) - When a device disconnects, its node is removed; if it was selected, select the first remaining device or show empty state
- Only connected sessions appear (no offline caching — matches
- Click device/slot → trigger slot-switch flow (see State Machine section)
- Icons: use text-based prefixes
[M]/[R]/[K], not emoji, for rendering consistency across macOS versions
Lower Area — Device Info (fixed ~140px)
- 1px separator line above
- Primary info (always visible):
- VID / PID (hex format)
- Protocol version
- Transport (BLE / USB)
- Device Index
- Connection Mode
- Opened status (checkmark / cross mark)
- Expandable "More" section (click to toggle):
- Usage Page / Usage
- HID++ Candidate status
- Init Complete status
- Diverted CIDs count
- Feature cache status
- 9pt monospace font, compact two-column layout (key left-aligned, value right-aligned)
Section 2: Upper Right — Three-Column Info Area (~40% height)
Left Column — Features Table (flex: 1)
- Header:
"FEATURES (N)"with count NSTableViewsingle-select, 3 columns: Index (hex), Feature ID (hex), Name- Click row → Actions column updates to show available functions for that feature
- Empty state: gray placeholder
"No features discovered" - Loading state: gray placeholder
"Discovering features..."with activity indicator - Right-click context menu:
Copy Feature ID
Middle Column — Controls Table (flex: 1)
- Header:
"CONTROLS (N)"with count NSTableView, 4 columns: CID (hex), Name, Flags (iconized), Status- Status indicators:
- Green dot = normal
- Orange
DVRTbadge = diverted - Yellow
REMAPbadge = remapped
- Click row → Actions column updates to show control-specific operations
- Empty state: gray placeholder
"No controls discovered" - Loading state: gray placeholder
"Discovering controls..."
Right Column — Actions Panel (160px fixed)
Upper Section — Context Actions (dynamic)
Based on Features/Controls table selection:
When a Feature is selected, show callable functions. For known features, show named functions:
- IRoot (0x0000):
Ping,GetFeature - IFeatureSet (0x0001):
GetCount,GetFeatureID - ReprogControlsV4 (0x1B04):
GetCount,GetInfo,GetReporting,SetReporting - SmartShift (0x2110):
GetStatus,SetStatus - AdjustableDPI (0x2201):
GetSensorCount,GetDPI,SetDPI,GetDPIList - HiResWheel (0x2121):
GetCapability,GetMode,SetMode - BatteryStatus (0x1000):
GetLevel - UnifiedBattery (0x1004):
GetStatus - DeviceFWVersion (0x0003):
GetEntityCount,GetFWVersion - DeviceNameType (0x0005):
GetCount,GetName,GetType
Parameter handling for named actions:
- Getter functions (GetStatus, GetDPI, GetCount, GetLevel, etc.): send with all-zero parameters, no user input needed. These are read-only queries.
- Setter functions (SetStatus, SetDPI, SetMode, SetReporting): show a hex parameter input field below the button. The field pre-fills with common defaults where known (e.g., SetReporting pre-fills with CID bytes + divert flag). User can edit before sending.
- Functions requiring an index (GetFeatureID, GetControlInfo, GetName, GetFWVersion): show a small numeric index stepper (0-255) to select the entity index.
For unknown/other features: show generic Func 0 through Func 15 buttons. Each button sends a HID++ 2.0 request with the selected feature's index and the function ID. All show a hex parameter input field (default all zeros) that user can edit before sending.
Implementation: actions construct packets directly in the debug panel using the raw IOHIDDeviceSetReport path (same as raw packet sender), not via LogitechDeviceSession's high-level API. This avoids needing to extend the session's public API while enabling arbitrary protocol exploration. The panel constructs the 20-byte HID++ 2.0 long report: [0x11, deviceIndex, featureIndex, (funcId << 4) | 0x01, params...].
When a Control is selected, show:
Divert/Undiverttoggle (calls existingsession.setControlDivert())Query Reporting— sends GetControlReporting (func 2) via raw packet; panel parses the RX response itself to extract reportingFlags and targetCID, then updates the in-memorydiscoveredControlsarray and refreshes the Controls table. This avoids modifying session internals.- Display current flags and target CID
When nothing is selected: gray text "Select a feature or control"
Lower Section — Global Actions (fixed)
Separator line, then always-visible buttons:
Re-Discover— callssession.rediscoverFeatures()which clears in-memory feature/control state and re-runs discovery (persisted UserDefaults cache is NOT cleared — that only updates when new discovery results arrive)Re-Divert— re-apply divert bindingsUndivert All— remove all divertsEnumerate— receiver enumeration (disabled for BLE devices)Clear Log— clear protocol log
Button style: 24px height, blue semi-transparent background + border, compact
Section 3: Lower Area — Protocol Log (~60% height)
Toolbar Row
- Left: section label
"PROTOCOL LOG" - Center/Right: filter toggle chips —
TXRXERRBTNWARNINFO- Each chip is a toggle button, active = highlighted color, inactive = dimmed
- All active by default
- Far right:
Exportbutton +Clearbutton
Log Body
NSTableView(single-column, variable row height) insideNSScrollView— NOTNSTextView- Enables per-row expand/collapse, row selection, and structured data binding
- Each row backed by an enhanced
LogEntrymodel
- 11pt monospace font, terminal aesthetic on dark background (
rgba(0,0,0,0.4)) - Color coding per row (same as current):
- TX: Blue (0.4, 0.6, 1.0)
- RX: Green (0.3, 0.8, 0.4)
- Error: Red (1.0, 0.3, 0.3)
- Button Event: Yellow (1.0, 0.8, 0.2)
- Warning: Orange (1.0, 0.6, 0.2)
- Info: Gray (0.75)
- Expandable entries: each row shows summary line; click expand indicator to reveal detail view
- Collapsed:
[arrow] [timestamp] [direction] summary(single line) - Expanded: adds hex dump in grouped format (
11 FF 00 01 | 1B 04 00 00 | ...) and decoded field annotations (reportId=0x11, devIdx=0xFF, featureIdx=0x00, funcId=0, swId=1) - Arrow indicator:
>collapsed,vexpanded
- Collapsed:
- Auto-scroll to bottom on new entries (disable auto-scroll if user has scrolled up manually)
- Buffer limit: 500 entries max
- Filtering: when a filter chip is deactivated, matching rows are hidden (not removed from buffer)
Enhanced LogEntry Model
Extend existing LogEntry to support structured data:
struct LogEntry {
let timestamp: String
let deviceName: String
let type: LogEntryType
let message: String // summary line (preserved for backward compatibility)
let decoded: String? // decoded annotation (preserved)
let rawBytes: [UInt8]? // NEW: raw packet bytes for hex dump display
var isExpanded: Bool // NEW: UI state for expand/collapse
}Raw Packet Input Bar
- 1px separator line
- Layout:
"RAW:"label + hex text field + report type selector +Sendbutton - Text field: monospace, placeholder
"11 FF 00 01 1B 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00" - Report type selector: segmented control with
Short (7B)/Long (20B)options- Short: reportID 0x10, auto-pad to 7 bytes
- Long: reportID 0x11, auto-pad to 20 bytes
- Default: Long
- Input validation:
- Only hex characters (0-9, A-F, a-f) and spaces accepted
- Auto-format: insert space every 2 characters on input
- Reject if total byte count exceeds report length
- Show inline error for invalid input (red text below field)
- Send button: green accent style
- Disabled when: no device selected, device not opened, input empty/invalid
- Sends via
IOHIDDeviceSetReportwith appropriate reportID and length - For receiver mode: automatically uses current target slot's deviceIndex
- Sent packet auto-logged as TX entry with raw bytes; response appears as RX entry
Export Behavior
Exportbutton opensNSSavePanelwith default filename"hidpp-debug-YYYY-MM-DD-HHmmss.log"- Exports the full 500-entry buffer (not filtered view), including:
- Timestamp, device name, type tag, message, and decoded text for each entry
- Raw hex bytes (if available) on a second line
- Format: plain text, one entry per line (or two lines if hex dump present)
State Machine
Panel States
| State | Sidebar | Features | Controls | Actions | Log | Raw Sender |
|---|---|---|---|---|---|---|
| No devices | Empty + "No devices connected" |
Disabled, empty | Disabled, empty | All disabled | Active (can show old logs) | Disabled |
| Device selected, not opened | Device shown, selected | "Device not opened" |
"Device not opened" |
All disabled except Clear Log | Active | Disabled |
| Device selected, unsupported | Device shown | "Unsupported device" |
"Unsupported device" |
All disabled except Clear Log | Active | Disabled |
| Discovering features | Device shown | "Discovering..." + spinner |
"Discovering..." |
Global actions enabled | Active | Enabled |
| Ready | Device shown | Feature list | Control list | Full functionality | Active | Enabled |
| Receiver enumerating | Receiver shown, slots loading | Previous state | Previous state | Enumerate disabled | Active | Enabled (if was ready) |
Slot Switch Flow
- User clicks receiver slot in sidebar
- Validate slot is 1-6 and online
- Call
session.setTargetSlot(slot)— this resets in-memory feature/control state and posts session-changed notification - Immediately transition Features/Controls to "Discovering..." loading state (triggered by session-changed notification)
- Debug panel explicitly calls
session.rediscoverFeatures()to kick off feature discovery for the new slot - Session discovers features → posts session-changed notification again → panel refreshes to Ready state
- If discovery times out (5s): show
"Discovery timed out"withRetrybutton
Visual Style Details
Typography
| Element | Size | Weight | Color |
|---|---|---|---|
| Section headers | 10pt | medium | tertiaryLabelColor |
| Table content | 11pt | regular | labelColor |
| Device info values | 9pt | regular (monospace) | secondaryLabelColor |
| Log entries | 11pt | regular (monospace) | per-type color coding |
| Action buttons | 11pt | medium | labelColor |
| Filter chips | 10pt | medium | per-type color when active |
Colors
Inherit vibrancy from .vibrantDark appearance. Specific colors:
- Panel backgrounds:
rgba(255,255,255,0.05)for info sections - Log background:
rgba(0,0,0,0.4)for terminal contrast - Separators:
rgba(255,255,255,0.1) - Active selection:
rgba(100,160,255,0.2)background - Action buttons:
rgba(100,160,255,0.15)background +rgba(100,160,255,0.3)border - Send button:
rgba(100,200,100,0.15)background +rgba(100,200,100,0.3)border - Status badges: DVRT = orange
rgba(255,160,0,0.8), REMAP = yellowrgba(255,200,50,0.8)
Spacing
- Sidebar width: 180px
- Section gaps: 2px
- Internal padding: 8px
- Action button height: 24px
- Button vertical spacing: 4px
- Filter chip horizontal spacing: 4px
Data Flow
LogitechHIDManagernotifies device connect/disconnect → sidebar refreshes (add/remove nodes)- User clicks device in sidebar →
currentSessionupdated → trigger slot-switch flow if receiver, else refresh panels - Feature/Control tables populated from
currentSession.featureIndexandcurrentSession.discoveredControls - User clicks feature row → Actions panel queries
HIDPPInfo.featureNamesfor available functions - User clicks action button → panel constructs HID++ packet directly → sends via
IOHIDDeviceSetReport→ logs TX with raw bytes - Session receives response via input report callback → logs RX with raw bytes → updates relevant tables if state changed
- Raw packet input → validates hex + report type → sends via
IOHIDDeviceSetReport→ logs TX/RX
Migration Notes
- Replace entire
LogitechHIDDebugPanel.swift(791 lines) with new implementation - Enhance
LogEntrystruct withrawBytesandisExpandedfields (backward compatible — existing callers still usemessage+decoded) - Preserve all existing
LogEntryType, notification names, and logging API signatures - Keep
HIDPPInfo,LogitechCIDRegistryas-is (read-only data) - Do not modify
LogitechDeviceSessionorLogitechHIDManagerpublic APIs - Feature action buttons and raw sender both use
IOHIDDeviceSetReportdirectly, bypassing session's high-level methods
macOS Compatibility
- All APIs must be compatible with macOS 10.13+
NSVisualEffectView.material = .hudWindowrequires macOS 10.14+ (fallback to.darkon 10.13)NSAppearance(named: .vibrantDark)available since macOS 10.10 — no version guard needed- Use
NSFont.monospacedDigitSystemFontfor monospace (available since 10.11) NSOutlineViewavailable since macOS 10.0+NSTableViewwith variable row heights: useusesAutomaticRowHeights = false, manually calculate row heights- Avoid
NSGridView(10.12+), use manual frame layout for maximum compatibility NSSavePanelavailable since macOS 10.0+
File: docs/superpowers/specs/2026-04-01-custom-shortcut-binding-design.md
Custom Shortcut Binding Design
Overview
Allow users to record custom key combinations as button binding actions, complementing the existing predefined SystemShortcut list. Users can bind any key, modifier, or combination — enabling scenarios like binding a mouse side button to Shift for hold-to-modify workflows.
Requirements
- Add "自定义…" menu item to the action popup in ButtonsView
- Reuse existing KeyRecorder/KeyPopover components for recording
- New
.adaptiverecording mode supporting: single keys, single modifiers, modifier combinations, and modifier+key combinations - Timing-based intent detection (similar to single-click vs double-click on mobile)
- Mouse button down/up 1:1 maps to bound key down/up
- Zero performance impact on the event processing hot path
- macOS 10.13+ compatibility
Design
1. Adaptive Recording Mode
New Mode
Add .adaptive to KeyRecordingMode enum. This mode is the superset of .combination and .singleKey, supporting all input types.
State Machine
IDLE
│
├── Non-modifier key / Function key / Mouse / Logi
│ → Record immediately → RECORDED
│
└── Modifier key(s) pressed → MODIFIER_HELD
│
├── Another key pressed (modifier or regular)
│ → Record as combination → RECORDED
│
├── All modifiers released → MODIFIER_RELEASED_WAITING
│ │
│ ├── 300ms expires, no new input
│ │ → Confirm single modifier(s) → RECORDED
│ │
│ └── New key pressed within 300ms
│ → Cancel timer, return to appropriate state
│
└── 10s HOLD_CONFIRM_DELAY (held without release)
→ Confirm single modifier(s) → RECORDED (fallback)Constants
ADAPTIVE_CONFIRM_DELAY = 0.3(300ms) — post-release confirmation windowHOLD_CONFIRM_DELAY = 9.5(9.5s) — fallback for indefinitely held modifiers (slightly less than global TIMEOUT to avoid race)
Behavior Matrix
| User Action | Result |
|---|---|
Press K (non-modifier) |
Immediately record K |
Press F5 (function key) |
Immediately record F5 |
Hold ⌘ → press K |
Record ⌘+K |
Hold ⌘+⇧ → press K |
Record ⌘+⇧+K |
Press ⇧ → release → 300ms |
Record ⇧ |
Hold ⌘+⇧ → release all → 300ms |
Record ⌘+⇧ |
Press ⌘ → release → 150ms → press ⌘ → press K |
Record ⌘+K (timer cancelled) |
Hold ⌘ for 10s without releasing |
Record ⌘ (fallback) |
| Mouse side button | Immediately record |
| Logi HID++ button | Immediately record |
| Press ESC | Cancel recording |
Implementation
Add a handleAdaptiveEvent() method in KeyRecorder, branching from the existing switch mode flow. Existing .combination and .singleKey modes remain untouched.
Track adaptive state via a private enum:
private enum AdaptiveState {
case idle
case modifierHeld(modifiers: CGEventFlags)
case modifierReleasedWaiting(modifiers: CGEventFlags) // carries pending modifiers for 300ms timer
case recorded
}The modifierReleasedWaiting state carries the previously held modifiers so that:
- If a new modifier is pressed within 300ms, cancel timer and return to
modifierHeldwith combined modifiers - If a non-modifier key is pressed within 300ms, record as combination (pending modifiers + key)
- If 300ms expires, confirm the pending modifiers as single-modifier recording
Two timers:
adaptiveConfirmTimer: Timer?— 300ms post-release timerholdConfirmTimer: Timer?— 9.5s fallback timer (started on first modifier press, cancelled when non-modifier pressed or recording completes). Set to 9.5s (not 10s) to avoid race with the globalKeyRecorder.TIMEOUT(10s)
2. Menu Integration & Popup Timing
Menu Structure
Add "自定义…" as the last item in the action popup menu, after all category submenus:
[Placeholder — current binding display]
─────────────
取消绑定
─────────────
⌨️ 功能键 ▶
🖥 应用与窗口 ▶
📄 文档编辑 ▶
📁 访达操作 ▶
⚙️ 系统功能 ▶
📸 截图 ▶
↔️ 导航 ▶
🖱 鼠标按键 ▶
Logi Logi 操作 ▶ (conditional)
─────────────
⌨️ 自定义…Implementation in ShortcutManager
Add the "自定义…" menu item in ShortcutManager.buildShortcutMenu():
- Separator before it
representedObject = "__custom__"(String marker)- SF Symbol:
keyboard(macOS 11.0+) - Localized title via
NSLocalizedString
Popup Timing (Critical)
Menu close and popover open must not overlap. Use NSMenu.didEndTrackingNotification for reliable sequencing:
// In ButtonTableCellView.shortcutSelected(_:)
@objc private func shortcutSelected(_ sender: NSMenuItem) {
if sender.representedObject as? String == "__custom__" {
let menu = sender.menu!
var observer: NSObjectProtocol?
observer = NotificationCenter.default.addObserver(
forName: NSMenu.didEndTrackingNotification,
object: menu,
queue: .main
) { [weak self] _ in
NotificationCenter.default.removeObserver(observer!)
guard let self = self, self.window != nil else { return }
self.startCustomRecording()
}
return
}
// Normal shortcut handling...
}Why this approach:
- Event-driven: fires only when menu fully closes
- One-shot: observer removed immediately after use
- Safe:
weak self+window != nilguard prevents ghost popovers - Scoped: bound to the specific menu instance
Recording Anchor
startCustomRecording() calls KeyRecorder.startRecording(from: actionPopUpButton, mode: .adaptive), using the popup button itself as the popover anchor point.
Recording Completion
The cell receives the recorded event via a delegate/callback, converts it to a custom:: binding string, and calls onShortcutSelected to update the ButtonBinding.
3. Data Model & Storage
Encoding Custom Bindings
Reuse ButtonBinding.systemShortcutName with a custom:: prefix:
Format: "custom::<keyCode>:<modifierFlags>"
Examples:
"custom::56:0" → Left Shift alone (keyCode=56, no extra modifiers)
"custom::40:1048576" → ⌘+K (keyCode=40, command flag)
"custom::56:131072" → ⇧+Shift (if recording captures it this way)Why reuse systemShortcutName:
- No changes to the
CodableButtonBindingstruct - No UserDefaults data migration needed
- All existing serialization, enable/disable, delete logic works unchanged
MosInputProcessorrouting only needs onehasPrefixcheck
Performance: Pre-parsing
To avoid string parsing on every event in the hot path, pre-parse custom:: values at load time.
ButtonBinding is a value type (struct), so calling prepareCustomCache() on a returned copy won't persist. Instead, ButtonUtils maintains its own cached array:
class ButtonUtils {
static let shared = ButtonUtils()
// Cached bindings with pre-parsed custom fields
private var cachedBindings: [ButtonBinding] = []
private var isDirty = true
func getButtonBindings() -> [ButtonBinding] {
if isDirty {
cachedBindings = Options.shared.buttons.binding.map { binding in
var b = binding
b.prepareCustomCache()
return b
}
isDirty = false
}
return cachedBindings
}
func invalidateCache() {
isDirty = true
}
}Call invalidateCache() whenever bindings change (in syncViewWithOptions() and Options.readOptions()).
Add transient cache fields on ButtonBinding:
struct ButtonBinding: Codable, Equatable {
// ... existing fields ...
// Transient cache, NOT part of Codable or Equatable
private(set) var cachedCustomCode: UInt16? = nil
private(set) var cachedCustomModifiers: UInt64? = nil
mutating func prepareCustomCache() {
guard systemShortcutName.hasPrefix("custom::") else { return }
let parts = systemShortcutName.dropFirst(8).split(separator: ":")
guard parts.count == 2,
let code = UInt16(parts[0]),
let mods = UInt64(parts[1]) else { return }
cachedCustomCode = code
// Clean redundant modifier flag: when binding a modifier key alone,
// the recorded modifiers may include the key's own flag (e.g., Shift keyCode=56
// with maskShift in modifiers). Strip the self-referencing flag to avoid doubling.
var cleanedMods = mods
if KeyCode.modifierKeys.contains(code) {
let selfFlag = KeyCode.getKeyMask(code).rawValue
cleanedMods = mods & ~selfFlag
}
cachedCustomModifiers = cleanedMods
}
// Exclude transient fields from Codable
enum CodingKeys: String, CodingKey {
case id, triggerEvent, systemShortcutName, isEnabled, createdAt
}
// Exclude transient fields from Equatable (use only coded fields)
static func == (lhs: ButtonBinding, rhs: ButtonBinding) -> Bool {
return lhs.id == rhs.id
&& lhs.triggerEvent == rhs.triggerEvent
&& lhs.systemShortcutName == rhs.systemShortcutName
&& lhs.isEnabled == rhs.isEnabled
&& lhs.createdAt == rhs.createdAt
}
}Note on CodingKeys: Adding explicit CodingKeys that match all existing property names produces identical encoding to the auto-synthesized version. No data migration needed. However, any future fields added to ButtonBinding must also be added to CodingKeys — add a code comment to flag this.
Display Name
Generate display text from cached code + modifiers using existing KeyCode.keyMap and modifier flag decomposition. This is the same logic MosInputEvent.displayComponents uses — extract into a shared utility if not already.
4. Execution Model: down/up Passthrough
Changes to ButtonCore (CRITICAL)
Current eventMask only includes Down events (leftMouseDown, rightMouseDown, otherMouseDown, keyDown). For custom binding down/up passthrough, Up events must also be captured.
Add to ButtonCore.eventMask:
otherMouseUp(for side buttons and middle button)keyUp(for keyboard trigger bindings)
Note: leftMouseUp/rightMouseUp are intentionally omitted — binding left/right click as triggers is not a supported use case and intercepting their Up events could break normal mouse behavior.
Changes to RecordedEvent.matchesMosInput() (CRITICAL)
Current matchesMosInput() has guard event.phase == .down for keyboard events (line ~167). This must be removed so .up events can also match. The phase filtering is handled downstream in ShortcutExecutor, not at the matching level.
Changes to MosInputProcessor
Current: guard event.phase == .down else { return .passthrough } — only processes Down events.
New: Support both Down and Up via an active bindings table. This solves the critical problem where Up events may have different modifier flags than Down (e.g., user releases ⌘ before releasing the trigger button), which would cause matchesMosInput() to fail on Up and leave the bound key stuck.
class MosInputProcessor {
static let shared = MosInputProcessor()
// Active bindings: tracks which bindings are currently "held down"
// Key: (EventType, keyCode) of the trigger event
// Value: the matched ButtonBinding
private var activeBindings: [TriggerKey: ButtonBinding] = [:]
private struct TriggerKey: Hashable {
let type: EventType
let code: UInt16
}
func process(_ event: MosInputEvent) -> MosInputResult {
let key = TriggerKey(type: event.type == .keyboard ? .keyboard : .mouse, code: event.code)
if event.phase == .up {
// On Up: look up by (type, code) only — ignore modifiers
if let binding = activeBindings.removeValue(forKey: key) {
ShortcutExecutor.shared.execute(
named: binding.systemShortcutName,
phase: .up,
binding: binding
)
return .consumed
}
return .passthrough
}
// On Down: full matching including modifiers
let bindings = ButtonUtils.shared.getButtonBindings()
for binding in bindings where binding.isEnabled {
if binding.triggerEvent.matchesMosInput(event) {
// Track this binding as active for Up pairing
activeBindings[key] = binding
ShortcutExecutor.shared.execute(
named: binding.systemShortcutName,
phase: .down,
binding: binding
)
return .consumed
}
}
return .passthrough
}
}Why active bindings table:
- Down events use full matching (type + code + modifiers + device filter) — same as current behavior
- Up events use (type, code) lookup only — guaranteed to pair with the corresponding Down
- Automatic cleanup:
removeValue(forKey:)returns and removes in one operation - No stale entries: each Down overwrites, each Up removes
- Predefined SystemShortcut bindings also enter the table but their Up is ignored by
ShortcutExecutor(guard phase == .down)
Changes to ShortcutExecutor
Add phase parameter to execute(named:phase:binding:):
/* Detailed source-code truncated for AI context efficiency. */Key design decisions:
- Use
CGEventSource(stateID: .hidSystemState)— consistent with existingShortcutExecutor.execute(code:flags:) - Use
.cghidEventTapfor posting — consistent with existing executor, avoids self-interception byButtonCorewhich listens at.cgAnnotatedSessionEventTap - Modifier keys use
flagsChangedevent type — this is how macOS natively represents modifier state changes;keyDown/keyUpwould not be recognized by apps as modifier presses - Regular keys use
keyDown/keyUp— standard keyboard event posting
Key behavior:
- Custom bindings: down → keyDown/flagsChanged, up → keyUp/flagsChanged (1:1 mapping)
- Predefined SystemShortcut: down only (unchanged behavior)
- Logi actions: down only (unchanged behavior)
5. UI Integration Details
Callback Signature Change (CRITICAL)
Current onShortcutSelected callback is (SystemShortcut.Shortcut?) -> Void. Custom bindings are not SystemShortcut.Shortcut objects. Solution: add a second callback for custom bindings.
// ButtonTableCellView — add alongside existing callback
private var onCustomShortcutRecorded: ((String) -> Void)? // receives "custom::56:0"PreferencesButtonsViewController provides this callback when configuring the cell:
// In tableView(_:viewFor:) cell configuration
cell.onCustomShortcutRecorded = { [weak self] customName in
self?.updateButtonBinding(id: binding.id, withCustomName: customName)
}Add updateButtonBinding(id:withCustomName:) to PreferencesButtonsViewController:
func updateButtonBinding(id: UUID, withCustomName name: String) {
guard let index = buttonBindings.firstIndex(where: { $0.id == id }) else { return }
// Note: ButtonBinding.init currently hardcodes createdAt = Date().
// Modify init to accept optional createdAt parameter to preserve original timestamp.
buttonBindings[index] = ButtonBinding(
id: id,
triggerEvent: buttonBindings[index].triggerEvent,
systemShortcutName: name,
isEnabled: true,
createdAt: buttonBindings[index].createdAt
)
syncViewWithOptions()
tableView.reloadData()
}Note: ButtonBinding.init must be updated to accept an optional createdAt parameter (defaulting to Date()). This also fixes the existing updateButtonBinding(id:with:) which currently resets createdAt on every update.
This keeps the existing onShortcutSelected for predefined shortcuts unchanged.
KeyRecorder Lifecycle in Cell
Each ButtonTableCellView creates its own KeyRecorder instance — NOT shared with the ViewController's recorder (which is used for trigger key recording). This avoids delegate conflicts.
// ButtonTableCellView
private lazy var customRecorder: KeyRecorder = {
let recorder = KeyRecorder()
recorder.delegate = self
return recorder
}()Lifecycle management:
customRecorderis lazily created on first "自定义…" click- Cleanup in
configure()method (notprepareForReuse()—NSTableCellViewdoes not have aprepareForReusecallback likeUITableViewCell). At the start ofconfigure(), callcustomRecorder.stopRecording()to clean up if cell is recycled during recording - The 0.5s post-recording delay in
KeyRecorder.stopRecording()is acceptable because each cell has its own recorder — concurrent recordings on different cells won't conflict
ButtonTableCellView startCustomRecording()
private func startCustomRecording() {
customRecorder.startRecording(from: actionPopUpButton, mode: .adaptive)
}Display for Custom Bindings
When systemShortcutName.hasPrefix("custom::"):
- Parse code + modifiers → generate display components using
KeyCode.keyMapand modifier flag decomposition - Show in popup button placeholder with
⌨️icon prefix (SF Symbolkeyboard, macOS 11.0+; no icon on older systems) - No category submenu highlight (custom is not in any submenu)
KeyRecorderDelegate Implementation in ButtonTableCellView
ButtonTableCellView conforms to KeyRecorderDelegate with two methods:
onEventRecorded(_:didRecordEvent:isDuplicate:): ConvertMosInputEventtocustom::<code>:<modifiers>string. CallonCustomShortcutRecorded?(customString). IfisDuplicate, show highlight on existing row instead.validateRecordedEvent(_:event:): Check if the recorded event (as a custom binding) conflicts with any existing binding. Returnfalseif duplicate.
Re-recording (Changing Custom Binding)
User clicks popup → selects "自定义…" again → records new combination → replaces previous binding. Same flow, no special handling needed.
6. Scope Clarification
Trigger keys (left column): Recorded via existing .combination mode in PreferencesButtonsViewController. This is unchanged — triggers are mouse buttons, Logi buttons, or modifier+key combinations. Standalone modifier keys are NOT supported as triggers (because ButtonCore.eventMask does not include flagsChanged).
Action keys (right column / "自定义…"): Recorded via new .adaptive mode. This is where the new feature applies — actions support standalone modifiers, single keys, and modifier+key combinations.
Test Coverage
5.1 Adaptive Recording Mode
| # | Scenario | Operation | Expected |
|---|---|---|---|
| 1 | Single regular key | Press K |
Immediately record K |
| 2 | Single function key | Press F5 |
Immediately record F5 |
| 3 | Combination key | Hold ⌘ → press K |
Record ⌘+K |
| 4 | Multi-modifier combination | Hold ⌘+⇧ → press K |
Record ⌘+⇧+K |
| 5 | Single modifier (quick release) | Press ⇧ → release → wait 300ms |
Record ⇧ |
| 6 | Multi-modifier single press | Hold ⌘+⇧ → release all → wait 300ms |
Record ⌘+⇧ |
| 7 | Release then reconsider | Press ⌘ → release → within 150ms press ⌘ → press K |
Record ⌘+K (timer cancelled) |
| 8 | Mouse side button | Press side button | Immediately record |
| 9 | Logi HID++ button | Press Logi button | Immediately record |
| 10 | ESC cancel | Press ESC during recording | Cancel, no result |
| 11 | Held modifier fallback | Hold ⌘ for 10s without release |
Record ⌘ |
| 12 | No input timeout | Start recording, do nothing for 10s | Cancel recording |
5.2 Menu Interaction & Timing
| # | Scenario | Operation | Expected |
|---|---|---|---|
| 13 | Normal custom flow | Click "自定义…" | Menu closes → popover appears → can record |
| 14 | Window switch before popover | Click "自定义…" then ⌘+Tab | Observer fires, window == nil → no popover |
| 15 | Select predefined shortcut | Click "调度中心" | Normal binding, no recording popover |
| 16 | Unbind | Click "取消绑定" | Binding cleared, no recording popover |
| 17 | Observer cleanup | Click "自定义…" then complete recording | Observer removed, subsequent menu operations unaffected |
5.3 Data Model & Persistence
| # | Scenario | Operation | Expected |
|---|---|---|---|
| 18 | Serialization | Save custom::56:0 binding |
UserDefaults written correctly, survives restart |
| 19 | Display (single modifier) | Load custom::56:0 |
UI shows "⇧ Shift" |
| 20 | Display (combination) | Load custom::40:1048576 |
UI shows "⌘+K" |
| 21 | Pre-parse cache | Load bindings with custom entries | cachedCustomCode/cachedCustomModifiers populated |
| 22 | Coexistence | Both predefined and custom bindings | Both display and execute correctly |
5.4 Execution Model (down/up)
| # | Scenario | Operation | Expected |
|---|---|---|---|
| 23 | Custom single modifier down/up | Side button bound to ⇧: press → release |
flagsChanged(⇧ down) → flagsChanged(⇧ up), 1:1 |
| 24 | Custom combo down/up | Side button bound to ⌘+K: press → release |
keyDown(⌘+K) → keyUp(⌘+K) |
| 25 | Predefined unaffected | Side button bound to "Mission Control": press → release | Only down triggers, up ignored |
| 26 | Rapid press | Side button bound to ⇧: rapid press-release-press-release |
Correct down-up-down-up sequence |
| 27 | Long hold | Side button bound to ⇧: hold 3s |
⇧ held, other keys can combine |
5.5 Active Bindings Table (Up Event Pairing)
| # | Scenario | Operation | Expected |
|---|---|---|---|
| 28 | Modifier released before trigger | Bind ⌘+Side3 → custom ⇧: hold ⌘, press Side3, release ⌘, release Side3 | Down matches (⌘+Side3), Up pairs via (mouse, Side3) lookup — ⇧ released correctly |
| 29 | Clean Up pairing | Press Side3 (bound to ⇧) → release Side3 | activeBindings entry removed on Up, no stale entries |
| 30 | Multiple concurrent triggers | Press Side3 (→⇧) then press Side4 (→⌘) then release Side3 then release Side4 | Each trigger independently tracked and released |
5.6 Edge Cases
| # | Scenario | Operation | Expected |
|---|---|---|---|
| 31 | Duplicate detection | Record existing custom key | Detected, existing row highlighted |
| 32 | Custom replaces custom | Same trigger: change from ⇧ to ⌘+K |
Correctly updated |
| 33 | Custom replaces predefined | Same trigger: "Mission Control" → custom ⇧ |
Switches execution path |
| 34 | Predefined replaces custom | Same trigger: custom ⇧ → "Mission Control" |
Restores predefined path, up no longer responds |
| 35 | Invalid custom string | custom::abc:xyz in storage |
Graceful degradation, binding disabled or ignored |
Files to Modify
| File | Changes |
|---|---|
Mos/Keys/KeyRecorder.swift |
Add .adaptive mode, adaptive state machine, two timers |
Mos/Shortcut/ShortcutManager.swift |
Add "自定义…" menu item with separator |
Mos/Windows/PreferencesWindow/ButtonsView/ButtonTableCellView.swift |
Handle custom menu selection, NSMenu notification, own KeyRecorder instance, KeyRecorderDelegate, display custom bindings, onCustomShortcutRecorded callback |
Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift |
Add onCustomShortcutRecorded callback wiring in cell config, add updateButtonBinding(id:withCustomName:) method |
Mos/Windows/PreferencesWindow/ButtonsView/RecordedEvent.swift |
Add cachedCustomCode/cachedCustomModifiers transient fields, prepareCustomCache() with modifier flag cleanup, explicit CodingKeys and Equatable, remove .down guard from matchesMosInput() |
Mos/ButtonCore/ButtonCore.swift |
Add otherMouseUp and keyUp to eventMask |
Mos/ButtonCore/ButtonUtils.swift |
Add cached bindings array with prepareCustomCache() pre-processing, invalidateCache() |
Mos/InputEvent/MosInputProcessor.swift |
Remove .down-only guard, pass phase to executor |
Mos/Shortcut/ShortcutExecutor.swift |
Add phase parameter, executeCustom() with modifier/regular key branching, use .hidSystemState source and .cghidEventTap posting |
Mos/Logi/LogitechDeviceSession.swift |
Update independent binding matching path (~line 1564-1575): remove if isDown guard, pass phase to MosInputProcessor or update to use new execute(named:phase:binding:) signature. Ensure Logi button Up events also reach the active bindings table |
Mos/Extension/CGEvent+Extensions.swift |
Add otherMouseUp, keyUp to isMouseEvent recognition (future-proofing, not currently used in MosInputEvent init path but prevents misclassification) |
Mos/Localizable.xcstrings |
Add "自定义…" localization key |
Files NOT Modified
| File | Reason |
|---|---|
KeyPopover.swift |
Reused as-is |
KeyPreview.swift |
Reused as-is |
KeyCode.swift |
No new key codes needed |
SystemShortcut.swift |
Custom bindings bypass SystemShortcut entirely |
Options.swift |
No schema changes (call ButtonUtils.shared.invalidateCache() in existing readOptions()) |
File: docs/superpowers/plans/2026-03-16-logitech-hid-integration.md
Logitech HID++ 2.0 Hardware Button Integration Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add Logitech HID++ 2.0 hardware button identification to Mos, allowing users to bind Logitech-specific buttons (gesture, DPI, etc.) to system shortcuts.
Architecture: Introduce MosInputEvent abstraction layer (Mode C) that unifies CGEventTap and HID++ event sources. Both sources feed into MosInputProcessor independently. LogitechHIDManager communicates with Logitech devices via IOKit HIDManager and HID++ 2.0 protocol.
Tech Stack: Swift, IOKit (IOHIDManager), HID++ 2.0 protocol, existing Mos Interceptor/ButtonCore/KeyRecorder infrastructure.
Spec: docs/superpowers/specs/2026-03-16-logitech-hid-integration-design.md
Important: This is a macOS Xcode project (Mos.xcodeproj). New .swift files must be added to the Xcode project's target. After creating each new file, run open Mos.xcodeproj and add it via File > Add Files, or use the ruby script in the verification step. macOS Deployment Target is 10.13+.
File Structure
New Files
| File | Directory | Responsibility |
|---|---|---|
MosInputEvent.swift |
Mos/InputEvent/ |
MosInputEvent struct, MosInputPhase, MosInputSource, MosInputDevice, DeviceFilter, LogitechCIDMap |
MosInputProcessor.swift |
Mos/InputEvent/ |
MosInputProcessor singleton, MosInputResult enum |
LogitechHIDManager.swift |
Mos/LogitechHID/ |
IOKit HIDManager wrapper, device enumeration, lifecycle, notification constants |
LogitechDeviceSession.swift |
Mos/LogitechHID/ |
HID++ 2.0 protocol: feature discovery, button divert, report parsing, event dispatch |
Modified Files
| File | Key Changes |
|---|---|
Mos/Windows/PreferencesWindow/ButtonsView/RecordedEvent.swift |
Add deviceFilter to RecordedEvent, add matchesMosInput(), add init(from: MosInputEvent), add ScrollHotkey.init(from: MosInputEvent) |
Mos/Keys/KeyCode.swift |
Add Logitech button code display mappings (1000+ range) |
Mos/ButtonCore/ButtonCore.swift |
Refactor callback to use MosInputProcessor |
Mos/Keys/KeyRecorder.swift |
Change delegate protocol from @objc to Swift protocol, add HID++ event listening during recording, update handleRecordedEvent for dual-source |
Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift |
Update delegate method signatures from CGEvent to MosInputEvent |
Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingViewController.swift |
Update delegate method signatures from CGEvent to MosInputEvent |
Mos/AppDelegate.swift |
Add LogitechHIDManager start/stop lifecycle calls |
Chunk 1: Data Model Layer
Task 1: Create MosInputEvent
Files:
Create:
Mos/InputEvent/MosInputEvent.swiftStep 1: Create directory and file
mkdir -p /Users/caldis/Desktop/Code/Mos/Mos/InputEventWrite Mos/InputEvent/MosInputEvent.swift:
/* Detailed source-code truncated for AI context efficiency. */- Step 2: Verify file compiles
cd /Users/caldis/Desktop/Code/Mos && xcodebuild -project Mos.xcodeproj -scheme Mos -configuration Debug build 2>&1 | tail -5Note: The file must first be added to Xcode project. If building from CLI fails because the file isn't in the project, add it manually or verify syntax by checking for obvious errors.
- Step 3: Commit
git add Mos/InputEvent/MosInputEvent.swift
git commit -m "feat: add MosInputEvent unified input event abstraction"Task 2: Create MosInputProcessor
Files:
Create:
Mos/InputEvent/MosInputProcessor.swiftStep 1: Write MosInputProcessor
//
// MosInputProcessor.swift
// Mos
// 统一事件处理器 - 接收 MosInputEvent, 匹配 ButtonBinding, 执行动作
// Created by Mos on 2026/3/16.
// Copyright © 2026 Caldis. All rights reserved.
//
import Cocoa
// MARK: - MosInputResult
/// 事件处理结果
enum MosInputResult {
case consumed // 事件已处理,不再传递
case passthrough // 事件未匹配,继续传递
}
// MARK: - MosInputProcessor
/// 统一事件处理器 (无状态单例)
/// 从 ButtonUtils 获取绑定配置, 匹配 MosInputEvent, 执行 ShortcutExecutor
class MosInputProcessor {
static let shared = MosInputProcessor()
init() { NSLog("Module initialized: MosInputProcessor") }
/// 处理输入事件
/// - Parameter event: 统一输入事件
/// - Returns: .consumed 表示事件已处理, .passthrough 表示未匹配
func process(_ event: MosInputEvent) -> MosInputResult {
// 只处理按下事件 (避免 down+up 触发两次)
guard event.phase == .down else { return .passthrough }
let bindings = ButtonUtils.shared.getButtonBindings()
guard let binding = bindings.first(where: {
$0.triggerEvent.matchesMosInput(event) && $0.isEnabled
}) else {
return .passthrough
}
ShortcutExecutor.shared.execute(named: binding.systemShortcutName)
return .consumed
}
}- Step 2: Commit
git add Mos/InputEvent/MosInputProcessor.swift
git commit -m "feat: add MosInputProcessor unified event processor"Task 3: Extend RecordedEvent with MosInputEvent support
Files:
Modify:
Mos/Windows/PreferencesWindow/ButtonsView/RecordedEvent.swiftStep 1: Add
deviceFilterfield to RecordedEvent
In RecordedEvent.swift, after line 99 (let displayComponents: [String]), add:
// 设备过滤器 (optional, 向后兼容: 旧数据解码为 nil, 匹配所有设备)
let deviceFilter: DeviceFilter?- Step 2: Add
matchesMosInputmethod
In RecordedEvent.swift, after the existing matches(_ event: CGEvent) method (line 147), add:
/// 匹配 MosInputEvent (供 MosInputProcessor 使用)
func matchesMosInput(_ event: MosInputEvent) -> Bool {
// 1. 修饰键匹配
guard UInt(event.modifiers.rawValue) == modifiers else { return false }
// 2. 类型匹配
guard event.type == type else { return false }
// 3. 按键码匹配
switch type {
case .keyboard:
guard event.phase == .down else { return false }
guard code == event.code else { return false }
case .mouse:
guard code == event.code else { return false }
}
// 4. 设备过滤 (可选)
if let filter = deviceFilter {
guard filter.matches(event.device) else { return false }
}
return true
}- Step 3: Add
init(from: MosInputEvent)constructor
After the existing init(from event: CGEvent) (line 127), add:
/// 从 MosInputEvent 构造
init(from event: MosInputEvent, deviceFilter: DeviceFilter? = nil) {
self.type = event.type
self.code = event.code
self.modifiers = UInt(event.modifiers.rawValue)
self.deviceFilter = deviceFilter
self.displayComponents = event.displayComponents
}- Step 4: Update existing
init(from event: CGEvent)to include deviceFilter
Change the existing CGEvent init to also set deviceFilter = nil:
init(from event: CGEvent) {
self.modifiers = UInt(event.flags.rawValue)
if event.isKeyboardEvent {
self.type = .keyboard
self.code = event.keyCode
} else {
self.type = .mouse
self.code = event.mouseCode
}
self.displayComponents = event.displayComponents
self.deviceFilter = nil
}- Step 5: Add
ScrollHotkey.init(from: MosInputEvent)extension
At the bottom of the file, add:
// MARK: - ScrollHotkey + MosInputEvent
extension ScrollHotkey {
/// 从 MosInputEvent 构造
init(from event: MosInputEvent) {
self.type = event.type
self.code = event.code
}
}- Step 6: Commit
git add Mos/Windows/PreferencesWindow/ButtonsView/RecordedEvent.swift
git commit -m "feat: extend RecordedEvent with MosInputEvent support and DeviceFilter"Task 4: Extend KeyCode with Logitech button names
Files:
Modify:
Mos/Keys/KeyCode.swift:157-167Step 1: Add Logitech mouse map entries
In KeyCode.swift, extend the mouseMap dictionary (line 157) to include Logitech codes. Add after the existing entries (before the closing ] on line 165):
// Logitech HID++ 专有按键
1000: "Gesture", 1001: "SmartShift", 1002: "DPI",- Step 2: Commit
git add Mos/Keys/KeyCode.swift
git commit -m "feat: add Logitech HID++ button names to KeyCode map"Task 5: Refactor ButtonCore to use MosInputProcessor
Files:
Modify:
Mos/ButtonCore/ButtonCore.swift:34-50Step 1: Replace buttonEventCallBack implementation
Replace the entire buttonEventCallBack closure (lines 34-50) with:
let buttonEventCallBack: CGEventTapCallBack = { (proxy, type, event, refcon) in
let mosEvent = MosInputEvent(fromCGEvent: event)
let result = MosInputProcessor.shared.process(mosEvent)
switch result {
case .consumed:
return nil
case .passthrough:
return Unmanaged.passUnretained(event)
}
}- Step 2: Verify existing button bindings still work
Build and run. Test that existing button bindings (e.g., mouse button 3 -> Mission Control) still trigger correctly. The behavior should be identical to before.
- Step 3: Commit
git add Mos/ButtonCore/ButtonCore.swift
git commit -m "refactor: ButtonCore callback uses MosInputProcessor"Task 6: Refactor KeyRecorder delegate and recording
Files:
- Modify:
Mos/Keys/KeyRecorder.swift
This is the most complex modification. Three changes:
- Delegate protocol:
@objc protocol-> Swift protocol + extension default handleRecordedEvent: support both CGEvent and MosInputEvent from notification- Add HID++ event observer during recording
- Step 1: Change delegate protocol (lines 21-32)
Replace:
@objc protocol KeyRecorderDelegate: AnyObject {
/// 录制完成回调
func onEventRecorded(_ recorder: KeyRecorder, didRecordEvent event: CGEvent, isDuplicate: Bool)
@objc optional func validateRecordedEvent(_ recorder: KeyRecorder, event: CGEvent) -> Bool
}With:
protocol KeyRecorderDelegate: AnyObject {
/// 录制完成回调
func onEventRecorded(_ recorder: KeyRecorder, didRecordEvent event: MosInputEvent, isDuplicate: Bool)
/// 验证录制的事件是否为重复
func validateRecordedEvent(_ recorder: KeyRecorder, event: MosInputEvent) -> Bool
}
/// 默认实现 (替代 @objc optional 语义)
extension KeyRecorderDelegate {
func validateRecordedEvent(_ recorder: KeyRecorder, event: MosInputEvent) -> Bool {
return true
}
}- Step 2: Add HID++ event observer property (after line 48)
Add to the private properties section:
private var hidEventObserver: NSObjectProtocol? // HID++ 事件监听 (录制期间)- Step 3: Update
startRecordingto add HID++ listener
At the end of the do block in startRecording (after the interceptor creation, before keyPopover = KeyPopover()), add:
// 监听 HID++ 事件 (如果 LogitechHIDManager 已启动)
hidEventObserver = NotificationCenter.default.addObserver(
forName: LogitechHIDManager.buttonEventNotification,
object: nil,
queue: .main
) { [weak self] notification in
guard let self = self, self.isRecording, !self.isRecorded else { return }
guard let mosEvent = notification.userInfo?["event"] as? MosInputEvent else { return }
guard mosEvent.phase == .down else { return }
NotificationCenter.default.post(
name: KeyRecorder.FINISH_NOTI_NAME,
object: mosEvent
)
}- Step 4: Update
stopRecordingto remove HID++ listener
In stopRecording(), after the line removing CANCEL_NOTI_NAME observer (line 287), add:
if let observer = hidEventObserver {
NotificationCenter.default.removeObserver(observer)
hidEventObserver = nil
}- Step 5: Rewrite
handleRecordedEventto support dual-source (lines 204-240)
Replace the entire method:
@objc private func handleRecordedEvent(_ notification: NSNotification) {
guard isRecording else { return }
// 统一转换为 MosInputEvent
let mosEvent: MosInputEvent
if let cgEvent = notification.object as? CGEvent {
mosEvent = MosInputEvent(fromCGEvent: cgEvent)
} else if let hidEvent = notification.object as? MosInputEvent {
mosEvent = hidEvent
} else {
NSLog("[EventRecorder] Unknown event type in notification")
return
}
// 检查事件有效性 (根据录制模式)
let isValid = recordingMode == .singleKey
? mosEvent.isRecordableAsSingleKey
: mosEvent.isRecordable
guard isValid else {
NSLog("[EventRecorder] Invalid event ignored")
keyPopover?.keyPreview.shakeWarning()
invalidKeyPressCount += 1
if invalidKeyPressCount >= invalidKeyThreshold {
keyPopover?.showEscHint()
}
return
}
guard !isRecorded else { return }
isRecorded = true
let isNew = self.delegate?.validateRecordedEvent(self, event: mosEvent) ?? true
let isDuplicate = !isNew
let status: KeyPreview.Status = isNew ? .recorded : .duplicate
keyPopover?.keyPreview
.update(from: mosEvent.displayComponents, status: status)
self.delegate?.onEventRecorded(self, didRecordEvent: mosEvent, isDuplicate: isDuplicate)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in
self?.stopRecording()
}
}- Step 6: Update
handleModifierFlagsChangedto use MosInputEvent for singleKey recording
In handleModifierFlagsChanged (line 172), the method posts to FINISH_NOTI_NAME with a CGEvent. This is fine -- handleRecordedEvent now handles both types. No change needed here since it posts a CGEvent that gets converted.
- Step 7: Remove the private
isRecordableAsSingleKeymethod (lines 249-270)
This logic is now in MosInputEvent.isRecordableAsSingleKey. Delete the method:
// DELETE: private func isRecordableAsSingleKey(_ event: CGEvent) -> Bool { ... }- Step 8: Commit
git add Mos/Keys/KeyRecorder.swift
git commit -m "refactor: KeyRecorder supports MosInputEvent dual-source recording"Task 7: Update PreferencesButtonsViewController delegate
Files:
Modify:
Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift:228-243Step 1: Update delegate methods
Replace the KeyRecorderDelegate extension (lines 228-243):
// MARK: - EventRecorderDelegate
extension PreferencesButtonsViewController: KeyRecorderDelegate {
func validateRecordedEvent(_ recorder: KeyRecorder, event: MosInputEvent) -> Bool {
let recordedEvent = RecordedEvent(from: event)
return !buttonBindings.contains(where: { $0.triggerEvent == recordedEvent })
}
func onEventRecorded(_ recorder: KeyRecorder, didRecordEvent event: MosInputEvent, isDuplicate: Bool) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.66) { [weak self] in
self?.addRecordedEvent(event, isDuplicate: isDuplicate)
}
}
}- Step 2: Update
addRecordedEventmethod signature (line 92)
Change from private func addRecordedEvent(_ event: CGEvent, isDuplicate: Bool) to:
private func addRecordedEvent(_ event: MosInputEvent, isDuplicate: Bool) {
let recordedEvent = RecordedEvent(from: event)
if isDuplicate {
if let existing = buttonBindings.first(where: { $0.triggerEvent == recordedEvent }) {
highlightExistingRow(with: existing.id)
}
return
}
let binding = ButtonBinding(triggerEvent: recordedEvent, systemShortcutName: "", isEnabled: false)
buttonBindings.append(binding)
tableView.reloadData()
toggleNoDataHint()
syncViewWithOptions()
}- Step 3: Commit
git add Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift
git commit -m "refactor: PreferencesButtonsViewController uses MosInputEvent"Task 8: Update PreferencesScrollingViewController delegate
Files:
Modify:
Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingViewController.swift:328-347Step 1: Update delegate method
Replace the KeyRecorderDelegate extension (lines 328-347):
// MARK: - KeyRecorderDelegate
extension PreferencesScrollingViewController: KeyRecorderDelegate {
func onEventRecorded(_ recorder: KeyRecorder, didRecordEvent event: MosInputEvent, isDuplicate: Bool) {
guard let popup = currentRecordingPopup else { return }
let hotkey = ScrollHotkey(from: event)
if popup === dashKeyBindButton {
getTargetApplicationScrollOptions().dash = hotkey
} else if popup === toggleKeyBindButton {
getTargetApplicationScrollOptions().toggle = hotkey
} else if popup === disableKeyBindButton {
getTargetApplicationScrollOptions().block = hotkey
}
currentRecordingPopup = nil
syncViewWithOptions()
}
}- Step 2: Commit
git add Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingViewController.swift
git commit -m "refactor: PreferencesScrollingViewController uses MosInputEvent"Task 9: Verify all existing functionality
- Step 1: Build project
cd /Users/caldis/Desktop/Code/Mos && xcodebuild -project Mos.xcodeproj -scheme Mos -configuration Debug build 2>&1 | tail -20Fix any compilation errors.
- Step 2: Manual testing checklist
Run the app and verify:
Scroll smoothing works as before
Existing button bindings trigger correctly
Recording new button bindings works (both combination and singleKey mode)
Scroll hotkey recording works (dash/toggle/block)
Per-app scroll settings work
ESC cancels recording
Duplicate detection works (blue highlight for repeat bindings)
Step 3: Commit if any fixes were needed
git add -A && git commit -m "fix: resolve compilation issues from MosInputEvent migration"Chunk 2: Logitech HID Module
Task 10: Create LogitechHIDManager
Files:
Create:
Mos/LogitechHID/LogitechHIDManager.swiftStep 1: Create directory
mkdir -p /Users/caldis/Desktop/Code/Mos/Mos/LogitechHID- Step 2: Write LogitechHIDManager
/* Detailed source-code truncated for AI context efficiency. */- Step 3: Commit
git add Mos/LogitechHID/LogitechHIDManager.swift
git commit -m "feat: add LogitechHIDManager for IOKit device enumeration"Task 11: Create LogitechDeviceSession
Files:
Create:
Mos/LogitechHID/LogitechDeviceSession.swiftStep 1: Write LogitechDeviceSession
/* Detailed source-code truncated for AI context efficiency. */- Step 2: Commit
git add Mos/LogitechHID/LogitechDeviceSession.swift
git commit -m "feat: add LogitechDeviceSession for HID++ 2.0 protocol communication"Task 12: Integrate LogitechHIDManager into AppDelegate
Files:
Modify:
Mos/AppDelegate.swiftStep 1: Add LogitechHIDManager.start() after ButtonCore.enable()
In startWithAccessibilityPermissionsChecker (lines 69-96), add LogitechHIDManager.shared.start() after every ButtonCore.shared.enable() call.
Line 76 (after first ButtonCore.shared.enable()):
LogitechHIDManager.shared.start()Line 82 (after second ButtonCore.shared.enable()):
LogitechHIDManager.shared.start()- Step 2: Add LogitechHIDManager.stop() in applicationWillTerminate
In applicationWillTerminate (line 62), add before ScrollCore.shared.disable():
LogitechHIDManager.shared.stop()- Step 3: Add LogitechHIDManager lifecycle in session callbacks
In sessionDidActive (line 99), add after ButtonCore.shared.enable():
LogitechHIDManager.shared.start()In sessionDidResign (line 103), add before ScrollCore.shared.disable():
LogitechHIDManager.shared.stop()- Step 4: Commit
git add Mos/AppDelegate.swift
git commit -m "feat: integrate LogitechHIDManager lifecycle into AppDelegate"Task 13: Final build and integration test
- Step 1: Ensure all new files are in Xcode project
New files that must be added to the Mos target in Xcode:
Mos/InputEvent/MosInputEvent.swiftMos/InputEvent/MosInputProcessor.swiftMos/LogitechHID/LogitechHIDManager.swiftMos/LogitechHID/LogitechDeviceSession.swiftStep 2: Full build
cd /Users/caldis/Desktop/Code/Mos && xcodebuild -project Mos.xcodeproj -scheme Mos -configuration Debug build 2>&1 | tail -20Step 3: Manual testing - existing features
Scroll smoothing: mouse wheel in any app
Scroll reverse: toggle in preferences
Button binding: record mouse button 3, bind to Mission Control, verify trigger
Scroll hotkey: set dash key, verify amplification works
Per-app settings: add exception for an app, verify independent scroll config
ESC cancels recording
Monitor window: verify scroll event visualization
Step 4: Manual testing - Logitech HID (requires Logitech mouse)
Check Console.app logs for
[LogitechHID] Device connected: ...If Logitech Options+ is running, quit it first
Verify gesture button press appears in logs as
[LogitechHID] CID 0x00C3 divert=ONOpen Preferences > Buttons, click Add, press gesture button -> should appear as "Gesture"
Bind gesture button to a system shortcut, verify it triggers
Step 5: Final commit
git add -A && git commit -m "feat: complete Logitech HID++ 2.0 hardware button integration"