docs/superpowers/plans/2026-04-01-custom-shortcut-binding.md
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Allow users to record custom key/modifier combinations as button binding actions with 1:1 down/up passthrough execution.
Architecture: Adds .adaptive recording mode to KeyRecorder with timing-based intent detection, extends ButtonBinding to store custom bindings via custom::<code>:<modifiers> encoding in the existing systemShortcutName field, and modifies MosInputProcessor to track active bindings for reliable down/up pairing.
Tech Stack: Swift 4+, AppKit (NSMenu, NSPopUpButton, KeyPopover), CGEvent, macOS 10.13+
Spec: docs/superpowers/specs/2026-04-01-custom-shortcut-binding-design.md
Files:
Modify: Mos/Windows/PreferencesWindow/ButtonsView/RecordedEvent.swift:189-230
Step 1: Write failing tests for ButtonBinding cache and init
Create test file:
// MosTests/ButtonBindingTests.swift
import XCTest
@testable import Mos_Debug
final class ButtonBindingTests: XCTestCase {
// MARK: - prepareCustomCache
func testPrepareCustomCache_regularKey() {
var binding = ButtonBinding(
triggerEvent: RecordedEvent(type: .mouse, code: 3, modifiers: 0, displayComponents: ["🖱4"], deviceFilter: nil),
systemShortcutName: "custom::40:1048576"
)
binding.prepareCustomCache()
XCTAssertEqual(binding.cachedCustomCode, 40)
XCTAssertEqual(binding.cachedCustomModifiers, 1048576) // ⌘
}
func testPrepareCustomCache_modifierKey_stripsRedundantFlag() {
// Shift keyCode=56, maskShift=131072. When recording Shift alone,
// modifiers may include maskShift — prepareCustomCache must strip it.
var binding = ButtonBinding(
triggerEvent: RecordedEvent(type: .mouse, code: 3, modifiers: 0, displayComponents: ["🖱4"], deviceFilter: nil),
systemShortcutName: "custom::56:131072"
)
binding.prepareCustomCache()
XCTAssertEqual(binding.cachedCustomCode, 56)
XCTAssertEqual(binding.cachedCustomModifiers, 0) // self-flag stripped
}
func testPrepareCustomCache_nonCustomBinding() {
var binding = ButtonBinding(
triggerEvent: RecordedEvent(type: .mouse, code: 3, modifiers: 0, displayComponents: ["🖱4"], deviceFilter: nil),
systemShortcutName: "missionControl"
)
binding.prepareCustomCache()
XCTAssertNil(binding.cachedCustomCode)
XCTAssertNil(binding.cachedCustomModifiers)
}
func testPrepareCustomCache_invalidFormat() {
var binding = ButtonBinding(
triggerEvent: RecordedEvent(type: .mouse, code: 3, modifiers: 0, displayComponents: ["🖱4"], deviceFilter: nil),
systemShortcutName: "custom::abc:xyz"
)
binding.prepareCustomCache()
XCTAssertNil(binding.cachedCustomCode)
XCTAssertNil(binding.cachedCustomModifiers)
}
// MARK: - Init with createdAt
func testInit_withCreatedAt_preservesTimestamp() {
let pastDate = Date(timeIntervalSince1970: 1000000)
let binding = ButtonBinding(
triggerEvent: RecordedEvent(type: .mouse, code: 3, modifiers: 0, displayComponents: ["🖱4"], deviceFilter: nil),
systemShortcutName: "test",
createdAt: pastDate
)
XCTAssertEqual(binding.createdAt, pastDate)
}
func testInit_defaultCreatedAt_usesNow() {
let before = Date()
let binding = ButtonBinding(
triggerEvent: RecordedEvent(type: .mouse, code: 3, modifiers: 0, displayComponents: ["🖱4"], deviceFilter: nil),
systemShortcutName: "test"
)
let after = Date()
XCTAssertGreaterThanOrEqual(binding.createdAt, before)
XCTAssertLessThanOrEqual(binding.createdAt, after)
}
// MARK: - Codable roundtrip
func testCodableRoundtrip_preservesFields() {
let original = ButtonBinding(
triggerEvent: RecordedEvent(type: .mouse, code: 3, modifiers: 0, displayComponents: ["🖱4"], deviceFilter: nil),
systemShortcutName: "custom::56:0"
)
let data = try! JSONEncoder().encode(original)
let decoded = try! JSONDecoder().decode(ButtonBinding.self, from: data)
XCTAssertEqual(decoded.id, original.id)
XCTAssertEqual(decoded.systemShortcutName, "custom::56:0")
XCTAssertNil(decoded.cachedCustomCode) // transient, not encoded
}
// MARK: - Equatable
func testEquatable_ignoresTransientCache() {
var a = ButtonBinding(
triggerEvent: RecordedEvent(type: .mouse, code: 3, modifiers: 0, displayComponents: ["🖱4"], deviceFilter: nil),
systemShortcutName: "custom::56:0"
)
var b = a
a.prepareCustomCache()
// b has no cache, a has cache — should still be equal
XCTAssertEqual(a, b)
}
}
Run: xcodebuild test -scheme Debug -only-testing:MosTests/ButtonBindingTests -destination 'platform=macOS' 2>&1 | tail -20
Expected: Compilation errors — cachedCustomCode, prepareCustomCache(), createdAt parameter don't exist yet.
In Mos/Windows/PreferencesWindow/ButtonsView/RecordedEvent.swift, replace lines 189-230 (the entire ButtonBinding struct) with:
// MARK: - ButtonBinding
/// 按钮绑定 - 将录制的事件与系统快捷键关联
struct ButtonBinding: Codable, Equatable {
// MARK: - 数据字段
/// 唯一标识符
let id: UUID
/// 录制的触发事件
let triggerEvent: RecordedEvent
/// 绑定的系统快捷键名称
/// 预定义快捷键: SystemShortcut identifier (如 "missionControl")
/// 自定义绑定: "custom::<keyCode>:<modifierFlags>" (如 "custom::56:0")
let systemShortcutName: String
/// 是否启用
var isEnabled: Bool
/// 创建时间
let createdAt: Date
// MARK: - Transient Cache (NOT part of Codable or Equatable)
// 注意: 添加新持久化字段时必须同步更新 CodingKeys
private(set) var cachedCustomCode: UInt16? = nil
private(set) var cachedCustomModifiers: UInt64? = nil
// MARK: - CodingKeys
enum CodingKeys: String, CodingKey {
case id, triggerEvent, systemShortcutName, isEnabled, createdAt
}
// MARK: - 计算属性
/// 获取系统快捷键对象
var systemShortcut: SystemShortcut.Shortcut? {
return SystemShortcut.getShortcut(named: systemShortcutName)
}
/// 是否为自定义绑定
var isCustomBinding: Bool {
return systemShortcutName.hasPrefix("custom::")
}
// MARK: - 初始化
init(id: UUID = UUID(), triggerEvent: RecordedEvent, systemShortcutName: String, isEnabled: Bool = true, createdAt: Date = Date()) {
self.id = id
self.triggerEvent = triggerEvent
self.systemShortcutName = systemShortcutName
self.isEnabled = isEnabled
self.createdAt = createdAt
}
// MARK: - Custom Cache
/// 预解析 custom:: 绑定的 keyCode 和 modifiers (加载时调用一次, 避免热路径字符串解析)
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
// 清理冗余修饰键 flag: 录制修饰键时 modifiers 可能包含自身 flag
var cleanedMods = mods
if KeyCode.modifierKeys.contains(code) {
let selfFlag = KeyCode.getKeyMask(code).rawValue
cleanedMods = mods & ~selfFlag
}
cachedCustomModifiers = cleanedMods
}
// MARK: - Equatable (基于持久化字段)
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
}
}
Also add RecordedEvent convenience initializer for tests (after the existing inits, around line 141):
/// 直接构造 (用于测试和自定义绑定)
init(type: EventType, code: UInt16, modifiers: UInt, displayComponents: [String], deviceFilter: DeviceFilter?) {
self.type = type
self.code = code
self.modifiers = modifiers
self.displayComponents = displayComponents
self.deviceFilter = deviceFilter
}
Run: xcodebuild test -scheme Debug -only-testing:MosTests/ButtonBindingTests -destination 'platform=macOS' 2>&1 | tail -20
Expected: All 7 tests PASS.
git add MosTests/ButtonBindingTests.swift Mos/Windows/PreferencesWindow/ButtonsView/RecordedEvent.swift
git commit -m "feat(data-model): add ButtonBinding cache fields, createdAt param, custom:: support"
Files:
Modify: Mos/ButtonCore/ButtonUtils.swift:11-42
Step 1: Write failing tests for ButtonUtils cache
// MosTests/ButtonUtilsCacheTests.swift
import XCTest
@testable import Mos_Debug
final class ButtonUtilsCacheTests: XCTestCase {
func testInvalidateCache_causesFreshLoad() {
// After invalidation, getButtonBindings should re-read from Options
ButtonUtils.shared.invalidateCache()
let bindings = ButtonUtils.shared.getButtonBindings()
// Should succeed without crash (validates cache mechanism works)
XCTAssertNotNil(bindings)
}
func testGetButtonBindings_preparesCustomCache() {
// Setup: add a custom binding to Options
let trigger = RecordedEvent(type: .mouse, code: 3, modifiers: 0, displayComponents: ["🖱4"], deviceFilter: nil)
let binding = ButtonBinding(triggerEvent: trigger, systemShortcutName: "custom::56:0", isEnabled: true)
Options.shared.buttons.binding = [binding]
ButtonUtils.shared.invalidateCache()
let loaded = ButtonUtils.shared.getButtonBindings()
XCTAssertEqual(loaded.count, 1)
XCTAssertEqual(loaded[0].cachedCustomCode, 56)
XCTAssertEqual(loaded[0].cachedCustomModifiers, 0)
// Cleanup
Options.shared.buttons.binding = []
ButtonUtils.shared.invalidateCache()
}
}
Run: xcodebuild test -scheme Debug -only-testing:MosTests/ButtonUtilsCacheTests -destination 'platform=macOS' 2>&1 | tail -20
Expected: FAIL — invalidateCache() method does not exist.
Replace the entire content of Mos/ButtonCore/ButtonUtils.swift:
//
// ButtonUtils.swift
// Mos
// 按钮绑定工具类 - 获取配置和管理绑定 (带缓存)
// Created by Claude on 2025/8/10.
// Copyright © 2025年 Caldis. All rights reserved.
//
import Cocoa
class ButtonUtils {
// 单例
static let shared = ButtonUtils()
init() {}
// MARK: - 缓存
/// 缓存的绑定列表 (已预解析 custom:: 字段)
private var cachedBindings: [ButtonBinding] = []
private var isDirty = true
// MARK: - 获取按钮绑定配置
/// 获取当前应用的按钮绑定配置 (带缓存和预解析)
/// - Returns: 按钮绑定列表
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
}
// MARK: - 分应用支持 (预留接口)
/// 获取当前焦点应用的配置对象 (预留)
/// - Returns: Application 对象或 nil
private func getTargetApplication() -> Application? {
return nil
}
}
In Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift, add at line 77 (inside syncViewWithOptions()), after Options.shared.buttons.binding = buttonBindings:
ButtonUtils.shared.invalidateCache()
In Mos/Options/Options.swift, in readOptions() method, after buttons.binding = loadButtonsData() add:
ButtonUtils.shared.invalidateCache()
Run: xcodebuild test -scheme Debug -only-testing:MosTests/ButtonUtilsCacheTests -destination 'platform=macOS' 2>&1 | tail -20
Expected: All 2 tests PASS.
git add Mos/ButtonCore/ButtonUtils.swift Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift Mos/Options/Options.swift MosTests/ButtonUtilsCacheTests.swift
git commit -m "feat(cache): add ButtonUtils cached bindings with prepareCustomCache"
Files:
Modify: Mos/InputEvent/MosInputProcessor.swift:18-43
Modify: Mos/Shortcut/ShortcutExecutor.swift:55-82
Modify: Mos/ButtonCore/ButtonCore.swift:29-31
Modify: Mos/Windows/PreferencesWindow/ButtonsView/RecordedEvent.swift:166-168
Step 1: Write failing tests
// MosTests/MosInputProcessorTests.swift
import XCTest
@testable import Mos_Debug
final class MosInputProcessorTests: XCTestCase {
override func setUp() {
super.setUp()
Options.shared.buttons.binding = []
ButtonUtils.shared.invalidateCache()
}
override func tearDown() {
Options.shared.buttons.binding = []
ButtonUtils.shared.invalidateCache()
super.tearDown()
}
// MARK: - Active Bindings Table
func testProcess_downEvent_consumedWhenBindingMatches() {
let trigger = RecordedEvent(type: .mouse, code: 3, modifiers: 0, displayComponents: ["🖱4"], deviceFilter: nil)
let binding = ButtonBinding(triggerEvent: trigger, systemShortcutName: "custom::56:0", isEnabled: true)
Options.shared.buttons.binding = [binding]
ButtonUtils.shared.invalidateCache()
let event = MosInputEvent(type: .mouse, code: 3, modifiers: CGEventFlags(rawValue: 0),
phase: .down, source: .hidPlusPlus, device: nil)
let result = MosInputProcessor.shared.process(event)
XCTAssertEqual(result, .consumed)
}
func testProcess_upEvent_consumedViaActiveBindings() {
let trigger = RecordedEvent(type: .mouse, code: 3, modifiers: 0, displayComponents: ["🖱4"], deviceFilter: nil)
let binding = ButtonBinding(triggerEvent: trigger, systemShortcutName: "custom::56:0", isEnabled: true)
Options.shared.buttons.binding = [binding]
ButtonUtils.shared.invalidateCache()
// Down first
let downEvent = MosInputEvent(type: .mouse, code: 3, modifiers: CGEventFlags(rawValue: 0),
phase: .down, source: .hidPlusPlus, device: nil)
_ = MosInputProcessor.shared.process(downEvent)
// Up — should match via active bindings table
let upEvent = MosInputEvent(type: .mouse, code: 3, modifiers: CGEventFlags(rawValue: 0),
phase: .up, source: .hidPlusPlus, device: nil)
let result = MosInputProcessor.shared.process(upEvent)
XCTAssertEqual(result, .consumed)
}
func testProcess_upEvent_passthroughWithoutPriorDown() {
let event = MosInputEvent(type: .mouse, code: 99, modifiers: CGEventFlags(rawValue: 0),
phase: .up, source: .hidPlusPlus, device: nil)
let result = MosInputProcessor.shared.process(event)
XCTAssertEqual(result, .passthrough)
}
func testProcess_upEvent_matchesDespiteModifierChange() {
// Trigger recorded with ⌘ modifier
let trigger = RecordedEvent(type: .mouse, code: 3, modifiers: UInt(CGEventFlags.maskCommand.rawValue),
displayComponents: ["⌘", "🖱4"], deviceFilter: nil)
let binding = ButtonBinding(triggerEvent: trigger, systemShortcutName: "custom::56:0", isEnabled: true)
Options.shared.buttons.binding = [binding]
ButtonUtils.shared.invalidateCache()
// Down with ⌘ held
let downEvent = MosInputEvent(type: .mouse, code: 3, modifiers: .maskCommand,
phase: .down, source: .hidPlusPlus, device: nil)
_ = MosInputProcessor.shared.process(downEvent)
// Up with ⌘ already released (modifiers = 0)
let upEvent = MosInputEvent(type: .mouse, code: 3, modifiers: CGEventFlags(rawValue: 0),
phase: .up, source: .hidPlusPlus, device: nil)
let result = MosInputProcessor.shared.process(upEvent)
XCTAssertEqual(result, .consumed) // active bindings table matches by (type, code) only
}
}
Run: xcodebuild test -scheme Debug -only-testing:MosTests/MosInputProcessorTests -destination 'platform=macOS' 2>&1 | tail -20
Expected: FAIL — MosInputResult doesn't conform to Equatable, active bindings table doesn't exist.
In Mos/InputEvent/MosInputProcessor.swift line 13, change:
enum MosInputResult {
to:
enum MosInputResult: Equatable {
In Mos/Windows/PreferencesWindow/ButtonsView/RecordedEvent.swift line 167-168, remove:
guard event.phase == .down else { return false }
So the keyboard case becomes just:
case .keyboard:
guard code == event.code else { return false }
In Mos/ButtonCore/ButtonCore.swift, add after line 28:
let otherUp = CGEventMask(1 << CGEventType.otherMouseUp.rawValue)
let keyUp = CGEventMask(1 << CGEventType.keyUp.rawValue)
And change line 30 (eventMask) to:
var eventMask: CGEventMask {
return leftDown | rightDown | otherDown | otherUp | keyDown | keyUp
}
Replace the entire content of Mos/InputEvent/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: Equatable {
case consumed // 事件已处理,不再传递
case passthrough // 事件未匹配,继续传递
}
// MARK: - MosInputProcessor
/// 统一事件处理器
/// 从 ButtonUtils 获取绑定配置, 匹配 MosInputEvent, 执行 ShortcutExecutor
/// 使用 activeBindings 表跟踪按下中的绑定, 确保 Up 事件正确配对
class MosInputProcessor {
static let shared = MosInputProcessor()
init() { NSLog("Module initialized: MosInputProcessor") }
// MARK: - Active Bindings Table
/// 跟踪当前按下的绑定, 用于 Up 事件配对
/// Key: (EventType, keyCode) Value: 匹配到的 ButtonBinding
private var activeBindings: [TriggerKey: ButtonBinding] = [:]
private struct TriggerKey: Hashable {
let type: EventType
let code: UInt16
}
/// 处理输入事件
/// - Parameter event: 统一输入事件
/// - Returns: .consumed 表示事件已处理, .passthrough 表示未匹配
func process(_ event: MosInputEvent) -> MosInputResult {
let key = TriggerKey(type: event.type, code: event.code)
if event.phase == .up {
// Up 事件: 按 (type, code) 查表, 忽略 modifiers (用户可能已松开修饰键)
if let binding = activeBindings.removeValue(forKey: key) {
ShortcutExecutor.shared.execute(named: binding.systemShortcutName, phase: .up, binding: binding)
return .consumed
}
return .passthrough
}
// Down 事件: 完整匹配 (type + code + modifiers + deviceFilter)
let bindings = ButtonUtils.shared.getButtonBindings()
for binding in bindings where binding.isEnabled {
if binding.triggerEvent.matchesMosInput(event) {
activeBindings[key] = binding
ShortcutExecutor.shared.execute(named: binding.systemShortcutName, phase: .down, binding: binding)
return .consumed
}
}
return .passthrough
}
}
In Mos/Shortcut/ShortcutExecutor.swift, replace lines 55-82 (the execute(named:) method) with:
/// 执行系统快捷键 (从名称解析, 支持动态读取系统配置)
/// - Parameters:
/// - shortcutName: 快捷键名称
/// - phase: 事件阶段 (down/up), 默认 .down
/// - binding: 可选的 ButtonBinding (用于访问预解析的 custom cache)
func execute(named shortcutName: String, phase: MosInputPhase = .down, binding: ButtonBinding? = nil) {
// 自定义绑定: 根据 phase 发送 keyDown/keyUp 或 flagsChanged
if let code = binding?.cachedCustomCode {
let modifiers = binding?.cachedCustomModifiers ?? 0
executeCustom(code: code, modifiers: modifiers, phase: phase)
return
}
// 以下预定义类型仅响应 down
guard phase == .down else { return }
// 鼠标按键动作
if shortcutName.hasPrefix("mouse") {
executeMouseAction(shortcutName)
return
}
// Logi HID++ 动作
if shortcutName.hasPrefix("logi") {
executeLogiAction(shortcutName)
return
}
// 优先使用系统实际配置 (对于Mission Control相关快捷键)
if let resolved = SystemShortcut.resolveSystemShortcut(shortcutName) {
execute(code: resolved.code, flags: resolved.modifiers)
return
}
// Fallback到内置快捷键定义
guard let shortcut = SystemShortcut.getShortcut(named: shortcutName) else {
return
}
execute(shortcut)
}
// MARK: - Custom Binding Execution
/// 执行自定义绑定 (1:1 down/up 映射)
private func executeCustom(code: UInt16, modifiers: UInt64, phase: MosInputPhase) {
guard let source = CGEventSource(stateID: .hidSystemState) else { return }
let isModifierKey = KeyCode.modifierKeys.contains(code)
if isModifierKey {
// 修饰键: 使用 flagsChanged 事件类型
guard let event = CGEvent(source: source) else { return }
event.type = .flagsChanged
event.setIntegerValueField(.keyboardEventKeycode, value: Int64(code))
if phase == .down {
// 按下: 设置所有修饰键 flags (自身 + 附加修饰键)
let keyMask = KeyCode.getKeyMask(code)
event.flags = CGEventFlags(rawValue: modifiers | keyMask.rawValue)
} else {
// 松开: 清除所有 flags (释放全部修饰键)
// 注意: 对于多修饰键绑定 (如 Cmd+Shift), modifiers 含非自身 flag,
// 必须发送 flags=0 才能完全释放, 否则系统认为部分修饰键仍按下
event.flags = CGEventFlags(rawValue: 0)
}
event.post(tap: .cghidEventTap)
} else {
// 普通键: 使用 keyDown/keyUp
guard let event = CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: phase == .down) else { return }
event.flags = CGEventFlags(rawValue: modifiers)
event.post(tap: .cghidEventTap)
}
}
Run: xcodebuild test -scheme Debug -only-testing:MosTests/MosInputProcessorTests -destination 'platform=macOS' 2>&1 | tail -20
Expected: All 4 tests PASS.
git add Mos/InputEvent/MosInputProcessor.swift Mos/Shortcut/ShortcutExecutor.swift Mos/ButtonCore/ButtonCore.swift Mos/Windows/PreferencesWindow/ButtonsView/RecordedEvent.swift MosTests/MosInputProcessorTests.swift
git commit -m "feat(execution): active bindings table for down/up pairing, custom key execution"
Files:
Modify: Mos/LogitechHID/LogitechDeviceSession.swift:1564-1578
Step 1: Update LogitechDeviceSession binding path
Replace lines 1564-1578 in LogitechDeviceSession.swift:
// 匹配 binding: logi* 动作在当前 session 执行 (设备隔离, 仅 down)
// 其余一律走 MosInputProcessor (支持 down/up 和 custom 绑定)
if isDown {
let bindings = ButtonUtils.shared.getButtonBindings()
if let binding = bindings.first(where: { $0.triggerEvent.matchesMosInput(mosEvent) && $0.isEnabled }),
binding.systemShortcutName.hasPrefix("logi") {
// Logi 动作: 在当前 session 执行 (设备隔离, 不注册 activeBindings)
executeLogiAction(binding.systemShortcutName)
return
}
}
// 非 logi 绑定 (含 custom::) 和所有 Up 事件: 统一走 MosInputProcessor
let result = MosInputProcessor.shared.process(mosEvent)
if result == .consumed { return }
Run: xcodebuild build -scheme Debug -destination 'platform=macOS' 2>&1 | grep -E '(error:|BUILD)' | tail -10
Expected: BUILD SUCCEEDED
git add Mos/LogitechHID/LogitechDeviceSession.swift
git commit -m "feat(logi): route Logi button up events through MosInputProcessor for custom bindings"
Files:
Modify: Mos/Keys/KeyRecorder.swift
Step 1: Add .adaptive to KeyRecordingMode
In Mos/Keys/KeyRecorder.swift line 13-18, add the new case:
enum KeyRecordingMode {
/// 组合键模式:需要修饰键+普通键的组合 (用于 ButtonsView)
case combination
/// 单键模式:支持单个按键,包括单独的修饰键 (用于 ScrollingView)
case singleKey
/// 自适应模式:支持所有输入类型,通过时间间隔判断意图 (用于自定义绑定)
case adaptive
}
After private var hidEventObserver: NSObjectProtocol? (line 53), add:
// Adaptive mode state
private enum AdaptiveState {
case idle
case modifierHeld(modifiers: CGEventFlags)
case modifierReleasedWaiting(modifiers: CGEventFlags)
case recorded
}
private var adaptiveState: AdaptiveState = .idle
private var adaptiveConfirmTimer: Timer? // 300ms post-release timer
private var holdConfirmTimer: Timer? // 9.5s fallback timer
// Adaptive mode constants
private static let ADAPTIVE_CONFIRM_DELAY: TimeInterval = 0.3
private static let HOLD_CONFIRM_DELAY: TimeInterval = 9.5
isRecordableAsAdaptive to MosInputEventIn Mos/InputEvent/MosInputEvent.swift, add after isRecordableAsSingleKey (line 154):
/// 事件是否可录制 (adaptive 模式 — 接受所有可用输入)
var isRecordableAsAdaptive: Bool {
switch type {
case .keyboard:
// 修饰键: 只在 down 时录制
if KeyCode.modifierKeys.contains(code) {
return phase == .down
}
return true
case .mouse:
// 主鼠标键不允许录制 (左键、右键)
if KeyCode.mouseMainKeys.contains(code) { return false }
return true
}
}
In Mos/Keys/KeyRecorder.swift, in handleModifierFlagsChanged (line 192), add the adaptive mode branch. Replace lines 192-216:
@objc private func handleModifierFlagsChanged(_ notification: NSNotification) {
guard isRecording && !isRecorded else { return }
let event = notification.object as! CGEvent
// Adaptive 模式: 使用状态机处理修饰键
if recordingMode == .adaptive {
handleAdaptiveFlagsChanged(event)
return
}
// 单键模式:修饰键按下时直接完成录制
if recordingMode == .singleKey && event.isKeyDown && event.isModifiers {
NSLog("[EventRecorder] Single key mode: modifier key recorded")
NotificationCenter.default.post(
name: KeyRecorder.FINISH_NOTI_NAME,
object: event
)
return
}
// 组合键模式:如果有修饰键被按下,刷新超时定时器给用户更多时间
let hasActiveModifiers = event.hasModifiers
if hasActiveModifiers {
startTimeoutTimer()
NSLog("[EventRecorder] Modifier key pressed, timeout timer refreshed")
}
// 实时更新录制界面显示当前已按下的修饰键
keyPopover?.keyPreview
.updateForRecording(from: event)
}
Add below handleModifierFlagsChanged:
// MARK: - Adaptive Mode
private func handleAdaptiveFlagsChanged(_ event: CGEvent) {
let hasActiveModifiers = event.hasModifiers
if hasActiveModifiers {
// 修饰键按下
cancelAdaptiveConfirmTimer()
startHoldConfirmTimer()
startTimeoutTimer() // 刷新全局超时
adaptiveState = .modifierHeld(modifiers: event.flags)
NSLog("[EventRecorder] Adaptive: modifier held, flags=\(event.flags.rawValue)")
// 实时更新显示
keyPopover?.keyPreview.updateForRecording(from: event)
} else {
// 所有修饰键松开
switch adaptiveState {
case .modifierHeld(let modifiers):
// 从按住状态松开 → 启动 300ms 确认定时器
adaptiveState = .modifierReleasedWaiting(modifiers: modifiers)
startAdaptiveConfirmTimer(modifiers: modifiers)
NSLog("[EventRecorder] Adaptive: modifiers released, waiting 300ms for confirmation")
default:
break
}
}
}
/// Adaptive 模式下的事件完成处理
/// 在 handleRecordedEvent 前检查: 非修饰键立即录制, 组合键立即录制
private func handleAdaptiveRecordedEvent(_ event: MosInputEvent) {
// 取消所有 adaptive 定时器
cancelAdaptiveConfirmTimer()
cancelHoldConfirmTimer()
// 状态重置
adaptiveState = .recorded
}
/// 确认录制当前修饰键组合 (300ms 定时器或 9.5s hold 定时器触发)
private func confirmAdaptiveModifiers(_ modifiers: CGEventFlags) {
guard isRecording && !isRecorded else { return }
cancelAdaptiveConfirmTimer()
cancelHoldConfirmTimer()
adaptiveState = .recorded
// 构造 MosInputEvent 并通过 FINISH 通知完成录制
let mosEvent = MosInputEvent(
type: .keyboard,
code: extractPrimaryModifierCode(from: modifiers),
modifiers: modifiers,
phase: .down,
source: .hidPlusPlus, // 标记为非 CGEvent 源
device: nil
)
NotificationCenter.default.post(
name: KeyRecorder.FINISH_NOTI_NAME,
object: mosEvent
)
}
/// 从 flags 中提取主要修饰键的 keyCode
private func extractPrimaryModifierCode(from flags: CGEventFlags) -> UInt16 {
// 优先级: Command > Shift > Option > Control > Fn
if flags.rawValue & CGEventFlags.maskCommand.rawValue != 0 { return KeyCode.commandL }
if flags.rawValue & CGEventFlags.maskShift.rawValue != 0 { return KeyCode.shiftL }
if flags.rawValue & CGEventFlags.maskAlternate.rawValue != 0 { return KeyCode.optionL }
if flags.rawValue & CGEventFlags.maskControl.rawValue != 0 { return KeyCode.controlL }
if flags.rawValue & CGEventFlags.maskSecondaryFn.rawValue != 0 { return KeyCode.fnL }
return KeyCode.commandL // fallback
}
// MARK: - Adaptive Timers
private func startAdaptiveConfirmTimer(modifiers: CGEventFlags) {
cancelAdaptiveConfirmTimer()
adaptiveConfirmTimer = Timer.scheduledTimer(withTimeInterval: KeyRecorder.ADAPTIVE_CONFIRM_DELAY, repeats: false) { [weak self] _ in
NSLog("[EventRecorder] Adaptive: 300ms confirm timer fired, confirming modifier(s)")
self?.confirmAdaptiveModifiers(modifiers)
}
}
private func cancelAdaptiveConfirmTimer() {
adaptiveConfirmTimer?.invalidate()
adaptiveConfirmTimer = nil
}
private func startHoldConfirmTimer() {
cancelHoldConfirmTimer()
holdConfirmTimer = Timer.scheduledTimer(withTimeInterval: KeyRecorder.HOLD_CONFIRM_DELAY, repeats: false) { [weak self] _ in
guard let self = self else { return }
NSLog("[EventRecorder] Adaptive: 9.5s hold timer fired")
if case .modifierHeld(let modifiers) = self.adaptiveState {
self.confirmAdaptiveModifiers(modifiers)
}
}
}
private func cancelHoldConfirmTimer() {
holdConfirmTimer?.invalidate()
holdConfirmTimer = nil
}
In handleRecordedEvent (line 223), change the validity check (lines 240-242):
// 检查事件有效性 (根据录制模式)
let isValid: Bool
switch recordingMode {
case .singleKey:
isValid = mosEvent.isRecordableAsSingleKey
case .combination:
isValid = mosEvent.isRecordable
case .adaptive:
isValid = mosEvent.isRecordableAsAdaptive
}
And add adaptive state cleanup before the validity check:
// Adaptive 模式: 清理定时器和状态
if recordingMode == .adaptive {
handleAdaptiveRecordedEvent(mosEvent)
}
In stopRecording() (line 270), add after cancelTimeoutTimer() (line 279):
// 清理 adaptive 定时器
cancelAdaptiveConfirmTimer()
cancelHoldConfirmTimer()
adaptiveState = .idle
Run: xcodebuild test -scheme Debug -destination 'platform=macOS' 2>&1 | grep -E '(error:|Test Suite|Executed)' | tail -10
Expected: BUILD SUCCEEDED, all existing tests pass.
git add Mos/Keys/KeyRecorder.swift Mos/InputEvent/MosInputEvent.swift
git commit -m "feat(recording): add .adaptive recording mode with timing-based intent detection"
Files:
Modify: Mos/Shortcut/ShortcutManager.swift:117-138
Modify: Mos/Localizable.xcstrings
Step 1: Add "自定义…" menu item to ShortcutManager
In Mos/Shortcut/ShortcutManager.swift, after the Logi actions block (after line 137, before the closing }), add:
// 自定义绑定分隔线
menu.addItem(NSMenuItem.separator())
// "自定义…" 菜单项 (representedObject 为字符串标记)
let customItem = NSMenuItem(
title: NSLocalizedString("custom-shortcut", comment: ""),
action: action,
keyEquivalent: ""
)
customItem.target = target
customItem.representedObject = "__custom__" as NSString
if supportsSFSymbols {
if #available(macOS 11.0, *) {
customItem.image = createSymbolImage("keyboard")
}
}
menu.addItem(customItem)
Add "custom-shortcut" key to Mos/Localizable.xcstrings with value "自定义…" for zh-Hans and "Custom…" for en. Use the xcstrings JSON format matching existing entries.
Run: xcodebuild build -scheme Debug -destination 'platform=macOS' 2>&1 | grep -E '(error:|BUILD)' | tail -5
Expected: BUILD SUCCEEDED
git add Mos/Shortcut/ShortcutManager.swift Mos/Localizable.xcstrings
git commit -m "feat(menu): add custom shortcut menu item to action popup"
Files:
Modify: Mos/Windows/PreferencesWindow/ButtonsView/ButtonTableCellView.swift
Modify: Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift
Step 1: Add custom recording callback and KeyRecorder to ButtonTableCellView
In ButtonTableCellView.swift, add after line 24 (onDeleteRequested):
private var onCustomShortcutRecorded: ((String) -> Void)?
// MARK: - Custom Recording
private lazy var customRecorder: KeyRecorder = {
let recorder = KeyRecorder()
recorder.delegate = self
return recorder
}()
Change the configure method signature (line 31-35) to:
func configure(
with binding: ButtonBinding,
onShortcutSelected: @escaping (SystemShortcut.Shortcut?) -> Void,
onCustomShortcutRecorded: @escaping (String) -> Void,
onDeleteRequested: @escaping () -> Void
) {
Add at the start of configure (after saving callbacks):
self.onCustomShortcutRecorded = onCustomShortcutRecorded
// 清理可能残留的录制状态 (cell 复用时)
customRecorder.stopRecording()
And update the current shortcut display to handle custom bindings. Replace line 39:
self.currentShortcut = binding.systemShortcut
with:
self.currentShortcut = binding.systemShortcut
self.currentCustomName = binding.isCustomBinding ? binding.systemShortcutName : nil
Add the property after currentShortcut (line 27):
private var currentCustomName: String?
In shortcutSelected(_:) (line 265), add at the beginning before existing logic:
// 自定义录制: 等菜单关闭后弹出录制弹窗
if sender.representedObject as? String == "__custom__" {
guard let menu = sender.menu else { return }
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
}
Add the startCustomRecording method:
private func startCustomRecording() {
customRecorder.startRecording(from: actionPopUpButton, mode: .adaptive)
}
Add extension at the end of the file:
// MARK: - KeyRecorderDelegate (Custom Recording)
extension ButtonTableCellView: KeyRecorderDelegate {
func onEventRecorded(_ recorder: KeyRecorder, didRecordEvent event: MosInputEvent, isDuplicate: Bool) {
guard !isDuplicate else { return }
// 构造 custom:: 字符串
let code = event.code
let modifiers = UInt64(event.modifiers.rawValue)
let customName = "custom::\(code):\(modifiers)"
DispatchQueue.main.asyncAfter(deadline: .now() + 0.66) { [weak self] in
guard let self = self else { return }
// 更新本地显示
self.currentShortcut = nil
self.currentCustomName = customName
self.updateCustomDisplay(event: event)
// 通知外部
self.onCustomShortcutRecorded?(customName)
}
}
func validateRecordedEvent(_ recorder: KeyRecorder, event: MosInputEvent) -> Bool {
// 检查自定义按键是否和现有绑定的 action 冲突 (不太可能但防万一)
return true
}
}
In the existing shortcutSelected(_:) method, after the __custom__ early return block, add at the start of the normal flow:
// 清除自定义绑定状态
self.currentCustomName = nil
}
Add display helper method:
/// 更新 PopUpButton 显示为自定义绑定名称
private func updateCustomDisplay(event: MosInputEvent) {
let displayTitle = event.displayComponents.filter { $0 != "[Logi]" }.joined(separator: "+")
var image: NSImage? = nil
if #available(macOS 11.0, *) {
image = NSImage(systemSymbolName: "keyboard", accessibilityDescription: nil)
}
setCustomTitle(displayTitle, image: image)
}
Update setupActionPopUpButton (line 178-182) to handle custom bindings:
// 设置当前选择
if let shortcut = currentShortcut {
selectShortcutInMenu(shortcut)
} else if let customName = currentCustomName, customName.hasPrefix("custom::") {
displayCustomBinding(customName)
} else {
setPlaceholderToUnbound()
}
Add the displayCustomBinding method:
/// 显示自定义绑定 (从 custom:: 字符串解析)
private func displayCustomBinding(_ customName: String) {
let parts = customName.dropFirst(8).split(separator: ":")
guard parts.count == 2,
let code = UInt16(parts[0]),
let mods = UInt64(parts[1]) else {
setPlaceholderToUnbound()
return
}
// 构造显示组件 (复用 MosInputEvent.displayComponents 逻辑)
var components: [String] = []
let selfMask = KeyCode.getKeyMask(code).rawValue
if mods & CGEventFlags.maskShift.rawValue != 0 && CGEventFlags.maskShift.rawValue & selfMask == 0 { components.append("⇧") }
if mods & CGEventFlags.maskControl.rawValue != 0 && CGEventFlags.maskControl.rawValue & selfMask == 0 { components.append("⌃") }
if mods & CGEventFlags.maskAlternate.rawValue != 0 && CGEventFlags.maskAlternate.rawValue & selfMask == 0 { components.append("⌥") }
if mods & CGEventFlags.maskCommand.rawValue != 0 && CGEventFlags.maskCommand.rawValue & selfMask == 0 { components.append("⌘") }
components.append(KeyCode.keyMap[code] ?? "Key(\(code))")
let displayTitle = components.joined(separator: "+")
var image: NSImage? = nil
if #available(macOS 11.0, *) {
image = NSImage(systemSymbolName: "keyboard", accessibilityDescription: nil)
}
setCustomTitle(displayTitle, image: image)
}
Also update adjustMenuStructure to handle custom bindings:
private func adjustMenuStructure(_ menu: NSMenu) {
guard menu.items.count >= 3 else { return }
let placeholderItem = menu.items[0]
let firstSeparator = menu.items[1]
let unboundItem = menu.items[2]
let hasBoundAction = currentShortcut != nil || currentCustomName != nil
if !hasBoundAction {
placeholderItem.isHidden = true
firstSeparator.isHidden = true
unboundItem.title = NSLocalizedString("unbound", comment: "")
} else {
placeholderItem.isHidden = false
firstSeparator.isHidden = false
unboundItem.title = NSLocalizedString("unbind", comment: "")
}
}
In PreferencesButtonsViewController.swift, add updateButtonBinding(id:withCustomName:) method (after updateButtonBinding(id:with:) around line 158):
/// 更新按钮绑定 (自定义快捷键)
func updateButtonBinding(id: UUID, withCustomName name: String) {
guard let index = buttonBindings.firstIndex(where: { $0.id == id }) else { return }
let old = buttonBindings[index]
buttonBindings[index] = ButtonBinding(
id: old.id,
triggerEvent: old.triggerEvent,
systemShortcutName: name,
isEnabled: true,
createdAt: old.createdAt
)
syncViewWithOptions()
}
Update the cell configuration in tableView(_:viewFor:) (lines 186-194) to include the new callback:
cell.configure(
with: binding,
onShortcutSelected: { [weak self] shortcut in
self?.updateButtonBinding(id: binding.id, with: shortcut)
},
onCustomShortcutRecorded: { [weak self] customName in
self?.updateButtonBinding(id: binding.id, withCustomName: customName)
},
onDeleteRequested: { [weak self] in
self?.removeButtonBinding(id: binding.id)
}
)
Run: xcodebuild build -scheme Debug -destination 'platform=macOS' 2>&1 | grep -E '(error:|BUILD)' | tail -10
Expected: BUILD SUCCEEDED
git add Mos/Windows/PreferencesWindow/ButtonsView/ButtonTableCellView.swift Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift
git commit -m "feat(ui): custom shortcut recording in ButtonTableCellView with menu timing"
Files:
Modify: Mos/Extension/CGEvent+Extensions.swift:144-151
Step 1: Update isMouseEvent to recognize Up events
In Mos/Extension/CGEvent+Extensions.swift line 144-151, change:
var isMouseEvent: Bool {
switch type {
case .leftMouseDown, .rightMouseDown, .otherMouseDown,
.leftMouseUp, .rightMouseUp, .otherMouseUp:
return true
default:
return false
}
}
Run: xcodebuild build -scheme Debug -destination 'platform=macOS' 2>&1 | grep -E '(error:|BUILD)' | tail -5
Expected: BUILD SUCCEEDED
git add Mos/Extension/CGEvent+Extensions.swift
git commit -m "fix(extensions): recognize MouseUp event types in isMouseEvent"
Files: None (verification only)
Run: xcodebuild build -scheme Debug -destination 'platform=macOS' 2>&1 | grep -E '(error:|warning:|BUILD)' | tail -20
Expected: BUILD SUCCEEDED, no new warnings
Run: xcodebuild test -scheme Debug -destination 'platform=macOS' 2>&1 | grep -E '(Test Suite|Executed|FAIL)' | tail -20
Expected: All test suites PASS
Only if previous steps required fixes.