Mos Documentation

一个用于在 macOS 上平滑你的鼠标滚动效果或单独设置滚动方向的小工具, 让你的滚轮爽如触控板 | A lightweight tool used to smooth scrolling and set scroll direction independently for your mouse on macOS

21,050 stars Swift Swift 1 file · ~30,454 tokens #homebrew#macos#mouse#osx#reverse#scroll#smooth#smooth-scrolling
RAW Doc

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 管理应用:

bash
brew install --cask mos

更新:

bash
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)。


架构设计

设计原则

  1. 互补而非替代: HID++ 是 CGEventTap 的补充事件源,不改变现有事件处理流程
  2. 统一抽象: 引入 MosInputEvent 统一两种来源的事件表示
  3. 状态隔离: 各模块只拥有自己的状态,单向依赖,无循环引用
  4. 向后兼容: 新增字段全部 optional,旧数据自动兼容

分层架构

text
/* Detailed source-code truncated for AI context efficiency. */

控制流详解

CGEventTap 路径 (同步,必须立即返回)

swift
// 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)

swift
// 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 走持久化路径。

text
/* Detailed source-code truncated for AI context efficiency. */

MosInputProcessor (新增)

swift
/// 处理结果
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 扩展 (修改)

swift
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 (新增)

swift
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
    }
}

按钮编号空间

swift
// 标准 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 模块设计

职责

  1. 通过 IOKit IOHIDManager 枚举和监控 Logitech HID 设备
  2. 实现 HID++ 2.0 协议子集(IRoot feature discovery + REPROG_CONTROLS_V4 button divert)
  3. 解析 HID++ 通知,将按键事件转换为 MosInputEvent
  4. 管理设备连接/断开生命周期

设备发现

swift
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++ 设备维护一个独立会话:

text
/* Detailed source-code truncated for AI context efficiency. */
text
### 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)
            }
        }
    }
}

事件解析与分发

swift
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
        )
    }
}

设备兼容性策略

swift
/// 已知设备的按键定义 (可通过 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 设备,使用动态发现:

  1. 检查设备是否支持 REPROG_CONTROLS_V4 feature
  2. 如果支持,查询其可 divert 的按键列表
  3. 自动 divert 所有标记为 divertable 的按键

这意味着未来新 Logitech 设备只要支持 HID++ 2.0 和 REPROG_CONTROLS_V4,无需修改代码即可自动兼容。

优先级: 动态发现始终优先。knownDevices 仅作为调试参考和显示名称来源,不影响 divert 决策。如果动态发现返回的可 divert 按键多于 knownDevices 中定义的,按动态发现结果执行。


应用生命周期集成

swift
// 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 无状态 触发来源

依赖方向 (单向, 无环)

text
LogitechHIDManager --+
                     +--> MosInputProcessor --> ButtonUtils/Options --> ShortcutExecutor
CGEventTap Adapter --+

线程模型

text
Main RunLoop
  +-- CGEventTap 回调 (系统调度, 同步)
  +-- IOKit HIDManager 回调 (schedule 到 Main RunLoop, 同步)
  +-- MosInputProcessor.process() (被上述两者同步调用, 无锁竞争)

IOKit schedule 到 Main RunLoop 的理由:

  • HID++ 按键事件极低频(用户按键级别, 每秒个位数)
  • 避免跨线程同步复杂性
  • ShortcutExecutor 内部的 CGEvent.post() 需要在 main thread

KeyRecorder 适配

改动方案

KeyRecorder 需要同时捕获 CGEventTap 和 HID++ 两种事件源:

swift
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:

swift
// 改造前:
// @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 传入时会崩溃。改造后的完整逻辑:

swift
@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 及社区补充。

当前问题

  1. 名称覆盖不足: 硬编码仅 8~12 个 CID,未知按钮显示 Logi(N) 格式
  2. 三处重复映射,互不引用:
    • MosInputEvent.swift → LogitechCIDMap.displayName() (8 条 MosCode→名称)
    • KeyCode.swift → mouseMap 中 1000+ 条目 (8 条,与上重复)
    • LogitechHIDDebugPanel.swift → HIDPPInfo.cidNames (12 条 CID→名称)
  3. Logi 逻辑分散: LogitechCIDMap 定义在通用模块 MosInputEvent.swift 中,污染非 Logi 代码
  4. 命名有误: 如 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 映射。

text
/* 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 处)

swift
// 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 处)

swift
// isLogiCode - 改前:
return LogitechCIDMap.isLogitechCode(code)
// 改后:
return LogitechCIDRegistry.isLogitechCode(code)

4.3 `ButtonTableCellView.swift` (1 处)

swift
// 改前:
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 处):

swift
// 改前:
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):

swift
// 改前:
let cidName = HIDPPInfo.cidNames[cid] ?? "?"
// 改后:
let cidName = LogitechCIDRegistry.name(forCID: cid)

4.5 `LogitechHIDDebugPanel.swift` (1 处)

swift
// 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)。

swift
// 改前:
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` (需修改)

swift
// 改前:
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) 确认此许可分析是否可接受。

文件头部注释需包含:

text
// 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、可拖拽、可配置的产品级模块,并确保可独立解耦开源。

目标

  1. 状态栏入口图标语义优化
  2. Debug 面板全面重设计(面向用户的产品功能)
  3. 支持多 toast 同时展示,堆叠方向自适应
  4. 支持 toast 位置拖拽 + 独立持久化
  5. 模块可独立插拔解耦,为独立开源做准备

架构方案:模块目录化

将现有单文件 Toast.swift(666 行)拆分为 Toast/ 目录下多个内聚文件,与项目现有模式(ScrollCore/、ButtonCore/)一致。对外仍只暴露 Toast struct 的 public 方法。

文件结构

text
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                         组件文档

依赖关系

text
Toast (公开 API) → ToastManager → ToastWindow → ToastContentView
                                ↘ ToastStorage
Toast.showTestPanel() → ToastPanel → Toast (通过公开 API 测试)

所有内部类使用 internal 访问级别(同 module 内可见),对外只暴露 Toast struct 的 public 方法。

§1 公开 API

swift
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() — 新增,支持一键清除所有 toast
  • debugMenuItem() — 新增,使用方一行 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)

swift
// 独立 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.bubble SF Symbol,语义从"聊天"改为"通知/消息"
  • macOS 10.13-10.15:保持现有 imageLiteral fallback

集成方式变化:

swift
// 之前 (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)

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

text
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 failure

Additional 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.


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)

  • scrollEventCallBack runs on main thread (Interceptor adds source to main RunLoop)
  • keeper timer runs on main thread
  • create() / recreateDisplayLink() called from enable() or keeper timer -> main thread
  • tryStart() called from scrollEventCallBack chain -> main thread
  • poster writes only on main thread, reads primarily on main thread
  • Only cross-thread access: stop() from CVDisplayLink thread reads poster -> but create() calls CVDisplayLinkStop(old) first, Apple docs guarantee callback won't fire after stop returns
  • lastCallbackTime: written in processing() (CVDisplayLink thread), read in healthCheck() (main thread) -> protected by existing stateLock
  • Conclusion: no additional locks needed for poster

Three-Layer Recovery (Passive + Active + Fallback)

  1. Passive: tryStart() detects poster nil/start failure -> immediate recreateDisplayLink() with 3-second cooldown
  2. Active: NSApplication.didChangeScreenParametersNotification -> debounced 1-second delay -> recreateDisplayLink() (covers hot-plug, display sleep/wake, resolution changes)
  3. Fallback: keeper timer every 5 seconds + lastCallbackTime zombie 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:

swift
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:

swift
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:

swift
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:

swift
@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:

swift
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):

swift
func processing() {
    // ... existing os_unfair_lock_lock(&stateLock) ...
    lastCallbackTime = CFAbsoluteTimeGetCurrent()
    // ... rest of existing logic unchanged ...
}

Lifecycle integration:

  • ScrollCore.enable() calls ScrollPoster.shared.startKeeper() after create()
  • ScrollCore.disable() calls ScrollPoster.shared.stopKeeper() after stop()

ScrollCore.swift

scrollEventCallBack change (1 line):

Replace:

swift
if shouldSmoothAny {
    return nil
}

With:

swift
if shouldSmoothAny {
    if ScrollPoster.shared.isAvailable {
        return nil
    } else {
        return Unmanaged.passUnretained(event)  // Graceful degradation
    }
}

AppDelegate.swift

Add in applicationWillFinishLaunching, after existing notification registrations:

swift
// 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 MosTests XCTest target added to Mos.xcodeproj
  • @testable import Mos for internal type access
  • Debug build configuration (ensures #if DEBUG diagnostics are visible)
  • XCTSkipUnless for 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

text
Phase 1 (P0): ScrollPhaseTests, ScrollDispatchContextTests
Phase 2 (P1): ScrollEventTests, InterpolatorTests, ScrollFilterTests, ScrollCoreHotkeyTests
Phase 3 (P2): ScrollPosterStateTests, RecordedEventMatchTests, ScrollHotkeyTests, ScrollDecisionTests
Phase 4 (P3): MosInputProcessorTests, MosInputEventTests, ButtonFilterTests

File: 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

  1. Modernize visual design with .hudWindow + .vibrantDark frosted glass style
  2. Reorganize layout for efficient debugging workflow: device selection → state inspection → action execution → log monitoring
  3. Support known HID++ features as selectable test actions, plus generic function call for unknown features
  4. Add custom raw packet sending and log export
  5. Protocol log with type filtering and expandable entries
  6. Left sidebar device navigator with tree view and hot-plug dynamic updates

Window Configuration

Follows ToastPanel.swift construction pattern exactly:

  • Window type: NSPanel (not NSWindow)
  • Style mask: .titled, .closable, .miniaturizable, .resizable, .fullSizeContentView
  • Default size: 1100x750, minimum: 1100x600, resizable
  • Title: "Logitech HID++ Debug", titlebarAppearsTransparent = true, titleVisibility = .hidden
  • Background: NSVisualEffectView as root contentView
    • material: .hudWindow (macOS 10.14+) / .dark (10.13 fallback)
    • blendingMode: .behindWindow
    • state: .active
  • Appearance: set on panel itself — panel.appearance = NSAppearance(named: .vibrantDark) (available since macOS 10.10, no version guard needed)
  • Top inset: derived from contentLayoutRect like ToastPanel's resolvedTopInset()
  • Shadow: hasShadow = true
  • Behavior: isMovableByWindowBackground = true, hidesOnDeactivate = false, isReleasedWhenClosed = false
  • Layout: 100% programmatic, frame-based with autoresizingMask for resize support

Layout Structure

text
+-------------------------------------------------------------------+
|  [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: NSOutlineView with 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
  • Receiver devices: expandable parent nodes
    • Parent: [R] prefix + receiver name + connection status
    • Children: Slot 1-6, showing device name (type) or empty, online/offline per receiver enumeration
    • Current target slot highlighted with blue selection background
  • Hot-plug: observe LogitechHIDManager session add/remove notifications, auto-refresh outline view
    • Only connected sessions appear (no offline caching — matches LogitechHIDManager.activeSessions behavior)
    • When a device disconnects, its node is removed; if it was selected, select the first remaining device or show empty state
  • 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
  • NSTableView single-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 DVRT badge = diverted
    • Yellow REMAP badge = 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 / Undivert toggle (calls existing session.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-memory discoveredControls array 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 — calls session.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 bindings
  • Undivert All — remove all diverts
  • Enumerate — 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 — TX RX ERR BTN WARN INFO
    • Each chip is a toggle button, active = highlighted color, inactive = dimmed
    • All active by default
  • Far right: Export button + Clear button

Log Body

  • NSTableView (single-column, variable row height) inside NSScrollView — NOT NSTextView
    • Enables per-row expand/collapse, row selection, and structured data binding
    • Each row backed by an enhanced LogEntry model
  • 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, v expanded
  • 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:

swift
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 + Send button
  • 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 IOHIDDeviceSetReport with 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

  • Export button opens NSSavePanel with 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

  1. User clicks receiver slot in sidebar
  2. Validate slot is 1-6 and online
  3. Call session.setTargetSlot(slot) — this resets in-memory feature/control state and posts session-changed notification
  4. Immediately transition Features/Controls to "Discovering..." loading state (triggered by session-changed notification)
  5. Debug panel explicitly calls session.rediscoverFeatures() to kick off feature discovery for the new slot
  6. Session discovers features → posts session-changed notification again → panel refreshes to Ready state
  7. If discovery times out (5s): show "Discovery timed out" with Retry button

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 = yellow rgba(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

  1. LogitechHIDManager notifies device connect/disconnect → sidebar refreshes (add/remove nodes)
  2. User clicks device in sidebar → currentSession updated → trigger slot-switch flow if receiver, else refresh panels
  3. Feature/Control tables populated from currentSession.featureIndex and currentSession.discoveredControls
  4. User clicks feature row → Actions panel queries HIDPPInfo.featureNames for available functions
  5. User clicks action button → panel constructs HID++ packet directly → sends via IOHIDDeviceSetReport → logs TX with raw bytes
  6. Session receives response via input report callback → logs RX with raw bytes → updates relevant tables if state changed
  7. 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 LogEntry struct with rawBytes and isExpanded fields (backward compatible — existing callers still use message + decoded)
  • Preserve all existing LogEntryType, notification names, and logging API signatures
  • Keep HIDPPInfo, LogitechCIDRegistry as-is (read-only data)
  • Do not modify LogitechDeviceSession or LogitechHIDManager public APIs
  • Feature action buttons and raw sender both use IOHIDDeviceSetReport directly, bypassing session's high-level methods

macOS Compatibility

  • All APIs must be compatible with macOS 10.13+
  • NSVisualEffectView.material = .hudWindow requires macOS 10.14+ (fallback to .dark on 10.13)
  • NSAppearance(named: .vibrantDark) available since macOS 10.10 — no version guard needed
  • Use NSFont.monospacedDigitSystemFont for monospace (available since 10.11)
  • NSOutlineView available since macOS 10.0+
  • NSTableView with variable row heights: use usesAutomaticRowHeights = false, manually calculate row heights
  • Avoid NSGridView (10.12+), use manual frame layout for maximum compatibility
  • NSSavePanel available 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 .adaptive recording 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

text
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 window
  • HOLD_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:

swift
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 modifierHeld with 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 timer
  • holdConfirmTimer: 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 global KeyRecorder.TIMEOUT (10s)

2. Menu Integration & Popup Timing

Add "自定义…" as the last item in the action popup menu, after all category submenus:

text
[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

Menu close and popover open must not overlap. Use NSMenu.didEndTrackingNotification for reliable sequencing:

swift
// 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 != nil guard 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:

text
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 Codable ButtonBinding struct
  • No UserDefaults data migration needed
  • All existing serialization, enable/disable, delete logic works unchanged
  • MosInputProcessor routing only needs one hasPrefix check

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:

swift
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:

swift
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.

swift
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:):

text
/* Detailed source-code truncated for AI context efficiency. */

Key design decisions:

  • Use CGEventSource(stateID: .hidSystemState) — consistent with existing ShortcutExecutor.execute(code:flags:)
  • Use .cghidEventTap for posting — consistent with existing executor, avoids self-interception by ButtonCore which listens at .cgAnnotatedSessionEventTap
  • Modifier keys use flagsChanged event type — this is how macOS natively represents modifier state changes; keyDown/keyUp would 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.

swift
// ButtonTableCellView — add alongside existing callback
private var onCustomShortcutRecorded: ((String) -> Void)?  // receives "custom::56:0"

PreferencesButtonsViewController provides this callback when configuring the cell:

swift
// In tableView(_:viewFor:) cell configuration
cell.onCustomShortcutRecorded = { [weak self] customName in
    self?.updateButtonBinding(id: binding.id, withCustomName: customName)
}

Add updateButtonBinding(id:withCustomName:) to PreferencesButtonsViewController:

swift
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.

swift
// ButtonTableCellView
private lazy var customRecorder: KeyRecorder = {
    let recorder = KeyRecorder()
    recorder.delegate = self
    return recorder
}()

Lifecycle management:

  • customRecorder is lazily created on first "自定义…" click
  • Cleanup in configure() method (not prepareForReuse() — NSTableCellView does not have a prepareForReuse callback like UITableViewCell). At the start of configure(), call customRecorder.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()

swift
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.keyMap and modifier flag decomposition
  • Show in popup button placeholder with ⌨️ icon prefix (SF Symbol keyboard, 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:): Convert MosInputEvent to custom::<code>:<modifiers> string. Call onCustomShortcutRecorded?(customString). If isDuplicate, show highlight on existing row instead.
  • validateRecordedEvent(_:event:): Check if the recorded event (as a custom binding) conflicts with any existing binding. Return false if 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.swift

  • Step 1: Create directory and file

bash
mkdir -p /Users/caldis/Desktop/Code/Mos/Mos/InputEvent

Write Mos/InputEvent/MosInputEvent.swift:

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 2: Verify file compiles
bash
cd /Users/caldis/Desktop/Code/Mos && xcodebuild -project Mos.xcodeproj -scheme Mos -configuration Debug build 2>&1 | tail -5

Note: 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
bash
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.swift

  • Step 1: Write MosInputProcessor

swift
//
//  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
bash
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.swift

  • Step 1: Add deviceFilter field to RecordedEvent

In RecordedEvent.swift, after line 99 (let displayComponents: [String]), add:

swift
// 设备过滤器 (optional, 向后兼容: 旧数据解码为 nil, 匹配所有设备)
    let deviceFilter: DeviceFilter?
  • Step 2: Add matchesMosInput method

In RecordedEvent.swift, after the existing matches(_ event: CGEvent) method (line 147), add:

swift
/// 匹配 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:

swift
/// 从 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:

swift
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:

swift
// MARK: - ScrollHotkey + MosInputEvent
extension ScrollHotkey {
    /// 从 MosInputEvent 构造
    init(from event: MosInputEvent) {
        self.type = event.type
        self.code = event.code
    }
}
  • Step 6: Commit
bash
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-167

  • Step 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):

swift
// Logitech HID++ 专有按键
        1000: "Gesture", 1001: "SmartShift", 1002: "DPI",
  • Step 2: Commit
bash
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-50

  • Step 1: Replace buttonEventCallBack implementation

Replace the entire buttonEventCallBack closure (lines 34-50) with:

swift
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
bash
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:

  1. Delegate protocol: @objc protocol -> Swift protocol + extension default
  2. handleRecordedEvent: support both CGEvent and MosInputEvent from notification
  3. Add HID++ event observer during recording
  • Step 1: Change delegate protocol (lines 21-32)

Replace:

swift
@objc protocol KeyRecorderDelegate: AnyObject {
    /// 录制完成回调
    func onEventRecorded(_ recorder: KeyRecorder, didRecordEvent event: CGEvent, isDuplicate: Bool)

    @objc optional func validateRecordedEvent(_ recorder: KeyRecorder, event: CGEvent) -> Bool
}

With:

swift
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:

swift
private var hidEventObserver: NSObjectProtocol?  // HID++ 事件监听 (录制期间)
  • Step 3: Update startRecording to add HID++ listener

At the end of the do block in startRecording (after the interceptor creation, before keyPopover = KeyPopover()), add:

swift
// 监听 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 stopRecording to remove HID++ listener

In stopRecording(), after the line removing CANCEL_NOTI_NAME observer (line 287), add:

swift
if let observer = hidEventObserver {
            NotificationCenter.default.removeObserver(observer)
            hidEventObserver = nil
        }
  • Step 5: Rewrite handleRecordedEvent to support dual-source (lines 204-240)

Replace the entire method:

swift
@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 handleModifierFlagsChanged to 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 isRecordableAsSingleKey method (lines 249-270)

This logic is now in MosInputEvent.isRecordableAsSingleKey. Delete the method:

swift
// DELETE: private func isRecordableAsSingleKey(_ event: CGEvent) -> Bool { ... }
  • Step 8: Commit
bash
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-243

  • Step 1: Update delegate methods

Replace the KeyRecorderDelegate extension (lines 228-243):

swift
// 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 addRecordedEvent method signature (line 92)

Change from private func addRecordedEvent(_ event: CGEvent, isDuplicate: Bool) to:

swift
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
bash
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-347

  • Step 1: Update delegate method

Replace the KeyRecorderDelegate extension (lines 328-347):

swift
// 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
bash
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
bash
cd /Users/caldis/Desktop/Code/Mos && xcodebuild -project Mos.xcodeproj -scheme Mos -configuration Debug build 2>&1 | tail -20

Fix 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

bash
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.swift

  • Step 1: Create directory

bash
mkdir -p /Users/caldis/Desktop/Code/Mos/Mos/LogitechHID
  • Step 2: Write LogitechHIDManager
text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 3: Commit
bash
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.swift

  • Step 1: Write LogitechDeviceSession

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 2: Commit
bash
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.swift

  • Step 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()):

swift
LogitechHIDManager.shared.start()

Line 82 (after second ButtonCore.shared.enable()):

swift
LogitechHIDManager.shared.start()
  • Step 2: Add LogitechHIDManager.stop() in applicationWillTerminate

In applicationWillTerminate (line 62), add before ScrollCore.shared.disable():

swift
LogitechHIDManager.shared.stop()
  • Step 3: Add LogitechHIDManager lifecycle in session callbacks

In sessionDidActive (line 99), add after ButtonCore.shared.enable():

swift
LogitechHIDManager.shared.start()

In sessionDidResign (line 103), add before ScrollCore.shared.disable():

swift
LogitechHIDManager.shared.stop()
  • Step 4: Commit
bash
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.swift

  • Mos/InputEvent/MosInputProcessor.swift

  • Mos/LogitechHID/LogitechHIDManager.swift

  • Mos/LogitechHID/LogitechDeviceSession.swift

  • Step 2: Full build

bash
cd /Users/caldis/Desktop/Code/Mos && xcodebuild -project Mos.xcodeproj -scheme Mos -configuration Debug build 2>&1 | tail -20
  • Step 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=ON

  • Open 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

bash
git add -A && git commit -m "feat: complete Logitech HID++ 2.0 hardware button integration"