From 204f7562b32bbbf43ded62d2e0fd535a44625caa Mon Sep 17 00:00:00 2001 From: distractedhero Date: Sun, 13 Sep 2026 19:49:02 -0400 Subject: [PATCH 1/4] feat: remap wheel tilt and middle click, with GUI slots The event tap returned early for anything that was not a keyboard event, so bindings could never replace middle click or horizontal pan. Learned wheel controls were recorded but never acted on. Match device-bound HID observations to auxiliary mouse and horizontal scroll events by hardware timestamp, deferring 25ms so main-loop HID callbacks can run, and replay anything unmatched. Suppress matched events and dispatch the configured action instead. Direction comes from the signed HID pan value, so it is independent of Natural Scrolling. One physical tilt emits a burst of pan reports, so debounce per direction: reports arriving within 150ms extend the gesture rather than firing again. Verified on a Naga V2 HS where 136 HID reports collapsed to 14 actions across 14 physical tilts. Expose the capability in the mapping window as slots 15, 16 and 17: Wheel Tilt Left, Wheel Tilt Right and Wheel Click. They ship unbound because tilt and click report different HID usages per device, so each user pairs them with Learn Hardware Trigger. Without these cards the feature is reachable only by hand-editing profiles.json. Also releases shortcut modifiers explicitly, accepts "opt" as an Option alias, and ignores continuous trackpad scrolling and primary/secondary buttons. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017mrRRGrRu2XN8q5ruiZKkT --- .gitignore | 5 + Package.swift | 3 +- Sources/NagaController/AppDelegate.swift | 4 +- .../ButtonMapping/ButtonMapper.swift | 38 ++- .../EventTap/EventTapManager.swift | 75 ++++ .../EventTap/PointerInputRouter.swift | 53 +++ Sources/NagaController/HID/HIDListener.swift | 25 ++ .../UI/MappingViewController.swift | 42 ++- .../Fixtures/profiles.json | 321 ++++++++++++++++++ .../ModifierReleaseTests.swift | 16 + .../PointerInputRouterTests.swift | 51 +++ .../ProfileCompatibilityTests.swift | 31 ++ 12 files changed, 653 insertions(+), 11 deletions(-) create mode 100644 Sources/NagaController/EventTap/PointerInputRouter.swift create mode 100644 Tests/NagaControllerTests/Fixtures/profiles.json create mode 100644 Tests/NagaControllerTests/ModifierReleaseTests.swift create mode 100644 Tests/NagaControllerTests/PointerInputRouterTests.swift create mode 100644 Tests/NagaControllerTests/ProfileCompatibilityTests.swift diff --git a/.gitignore b/.gitignore index 1f9e671..2ed6de5 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,8 @@ dmg-assets/temp.dmg # Ignore built binaries dist/ + +# Local working directories: diagnostic logs, pre-change backups, packaged app +build/ +work/ +backups/ diff --git a/Package.swift b/Package.swift index 7eff7d1..9ec3bc0 100644 --- a/Package.swift +++ b/Package.swift @@ -27,7 +27,8 @@ let package = Package( .testTarget( name: "NagaControllerTests", dependencies: ["NagaController"], - path: "Tests/NagaControllerTests" + path: "Tests/NagaControllerTests", + exclude: ["Fixtures"] ) ] ) diff --git a/Sources/NagaController/AppDelegate.swift b/Sources/NagaController/AppDelegate.swift index 9ace48f..cf9ac56 100644 --- a/Sources/NagaController/AppDelegate.swift +++ b/Sources/NagaController/AppDelegate.swift @@ -137,10 +137,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let hasImage = (button.image != nil) let profile = ConfigManager.shared.currentProfileName if let lvl = level { - button.title = (hasImage ? " " : "🖱️ ") + "\(lvl)% · \(profile)" + button.title = hasImage ? "" : "🖱️" button.toolTip = "Naga battery: \(lvl)% · Profile: \(profile)" } else { - button.title = (hasImage ? " " : "🖱️ ") + profile + button.title = hasImage ? "" : "🖱️" button.toolTip = "Naga battery: — · Profile: \(profile)" } } diff --git a/Sources/NagaController/ButtonMapping/ButtonMapper.swift b/Sources/NagaController/ButtonMapping/ButtonMapper.swift index d995bb2..321b1b0 100644 --- a/Sources/NagaController/ButtonMapping/ButtonMapper.swift +++ b/Sources/NagaController/ButtonMapping/ButtonMapper.swift @@ -34,6 +34,28 @@ final class ButtonMapper { /// swallowed by our own tap as an auto-repeat of the button that triggered it. static let syntheticEventTag: Int64 = 0x4E41_4741 // "NAGA" + // A private source keeps emitted shortcut modifiers out of the combined source. + private let keyboardSource = CGEventSource(stateID: .privateState) + + static func modifierReleaseEvents(_ flags: CGEventFlags, physicalFlags: CGEventFlags) -> [CGEvent] { + let keys: [(CGEventFlags, CGKeyCode)] = [(.maskCommand, 55), (.maskShift, 56), + (.maskAlternate, 58), (.maskControl, 59)] + return keys.compactMap { flag, code in + guard flags.contains(flag), !physicalFlags.contains(flag), + let event = CGEvent(keyboardEventSource: CGEventSource(stateID: .privateState), + virtualKey: code, keyDown: false) else { return nil } + event.type = .flagsChanged + event.flags = physicalFlags + return event + } + } + + private func finishShortcut(_ flags: CGEventFlags) { + for event in Self.modifierReleaseEvents(flags, physicalFlags: CGEventSource.flagsState(.hidSystemState)) { + post(event) + } + } + private func post(_ event: CGEvent) { event.setIntegerValueField(.eventSourceUserData, value: ButtonMapper.syntheticEventTag) event.post(tap: .cghidEventTap) @@ -110,7 +132,7 @@ final class ButtonMapper { } let flags = modifierFlags(from: stroke.modifiers) - if let code = keyCode, let eventDown = CGEvent(keyboardEventSource: nil, virtualKey: code, keyDown: true) { + if let code = keyCode, let eventDown = CGEvent(keyboardEventSource: keyboardSource, virtualKey: code, keyDown: true) { eventDown.flags = flags post(eventDown) activeHolds[buttonIndex] = (code, flags) @@ -161,9 +183,10 @@ final class ButtonMapper { } if let (keyCode, flags) = activeHolds.removeValue(forKey: buttonIndex) { - if let eventUp = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: false) { - eventUp.flags = flags + if let eventUp = CGEvent(keyboardEventSource: keyboardSource, virtualKey: keyCode, keyDown: false) { + eventUp.flags = CGEventSource.flagsState(.hidSystemState) post(eventUp) + finishShortcut(flags) NSLog("[Mapping] Hold end for button \(buttonIndex)") } } @@ -248,14 +271,15 @@ final class ButtonMapper { let flags = modifierFlags(from: stroke.modifiers) // Key down - if let eventDown = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true) { + if let eventDown = CGEvent(keyboardEventSource: keyboardSource, virtualKey: keyCode, keyDown: true) { eventDown.flags = flags post(eventDown) } // Key up - if let eventUp = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: false) { - eventUp.flags = flags + if let eventUp = CGEvent(keyboardEventSource: keyboardSource, virtualKey: keyCode, keyDown: false) { + eventUp.flags = CGEventSource.flagsState(.hidSystemState) post(eventUp) + finishShortcut(flags) } } @@ -272,7 +296,7 @@ final class ButtonMapper { switch m { case "cmd", "command": flags.insert(.maskCommand) case "shift": flags.insert(.maskShift) - case "alt", "option": flags.insert(.maskAlternate) + case "alt", "option", "opt": flags.insert(.maskAlternate) case "ctrl", "control": flags.insert(.maskControl) case "fn": flags.insert(.maskSecondaryFn) default: break diff --git a/Sources/NagaController/EventTap/EventTapManager.swift b/Sources/NagaController/EventTap/EventTapManager.swift index acd8c9f..4878459 100644 --- a/Sources/NagaController/EventTap/EventTapManager.swift +++ b/Sources/NagaController/EventTap/EventTapManager.swift @@ -10,6 +10,13 @@ final class EventTapManager { // Track buttons whose original number keyDown we intercepted so we can also intercept keyUp private var activeDownButtons: Set = [] + private var pointerGeneration = 0 + private var activePointerButtons: [UInt32: Int] = [:] + + // One physical wheel tilt emits a burst of pan reports. Reports arriving + // within this gap belong to the same gesture, so one tilt performs one action. + private var lastPanTimestamp: [Int: UInt64] = [:] + private static let panGestureGapNanos: UInt64 = 150_000_000 private var learningCallback: ((CGKeyCode) -> Void)? @@ -87,6 +94,10 @@ final class EventTapManager { } func stop() { + pointerGeneration += 1 + for index in activePointerButtons.values { ButtonMapper.shared.handleRelease(buttonIndex: index) } + activePointerButtons.removeAll() + lastPanTimestamp.removeAll() if let tap = eventTap { CGEvent.tapEnable(tap: tap, enable: false) } @@ -121,6 +132,10 @@ final class EventTapManager { return Unmanaged.passUnretained(event) } + if !manager.isListeningOnly, manager.deferMappedPointer(type: type, event: event) { + return nil + } + // Only handle remapping/blocking logic for keyboard events guard type == .keyDown || type == .keyUp || type == .flagsChanged else { return Unmanaged.passUnretained(event) @@ -182,6 +197,66 @@ final class EventTapManager { return Unmanaged.passUnretained(event) } + private func deferMappedPointer(type: CGEventType, event: CGEvent) -> Bool { + guard ConfigManager.shared.getRemappingEnabled() else { return false } + let bindings = ConfigManager.shared.hardwareBindingsForCurrentProfile() + let kind: PointerInputRouter.Kind + if type == .otherMouseDown || type == .otherMouseUp || type == .otherMouseDragged { + let usage = UInt32(event.getIntegerValueField(.mouseEventButtonNumber) + 1) + if type == .otherMouseDragged { return activePointerButtons[usage] != nil } + guard bindings.values.contains(where: { $0.usagePage == 9 && $0.usage == usage }) else { return false } + kind = .button(usage: usage, down: type == .otherMouseDown) + } else if type == .scrollWheel { + guard event.getIntegerValueField(.scrollWheelEventIsContinuous) == 0, + event.getIntegerValueField(.scrollWheelEventDeltaAxis2) != 0, + event.getIntegerValueField(.scrollWheelEventDeltaAxis1) == 0, + bindings.values.contains(where: { $0.usagePage == 12 && $0.usage == 568 }) else { return false } + kind = .horizontalScroll + } else { return false } + + guard let original = event.copy() else { return false } + let generation = pointerGeneration + // HID and CGEvent callbacks run on this same loop. Sleeping here prevents + // HID from arriving. Defer briefly instead and replay unmatched input. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.025) { [weak self] in + guard let self else { return } + func replay() { + original.setIntegerValueField(.eventSourceUserData, value: ButtonMapper.syntheticEventTag) + original.post(tap: .cgSessionEventTap) + } + guard generation == self.pointerGeneration, !self.isListeningOnly, + ConfigManager.shared.getRemappingEnabled() else { replay(); return } + let matched = HIDListener.shared.consumePointer(kind: kind, timestamp: original.timestamp) + switch kind { + case .button(let usage, let down): + if down, let index = matched { + guard self.activePointerButtons[usage] == nil else { return } + self.activePointerButtons[usage] = index + ButtonMapper.shared.handlePress(buttonIndex: index) + NSLog("[Pointer] Suppressed native button down; performed slot=%d", index) + } else if !down, let index = self.activePointerButtons.removeValue(forKey: usage) { + ButtonMapper.shared.handleRelease(buttonIndex: index) + NSLog("[Pointer] Suppressed native button up; released slot=%d", index) + } else { replay() } + case .horizontalScroll: + guard let index = matched else { replay(); return } + let now = original.timestamp + if let previous = self.lastPanTimestamp[index], + now >= previous, now - previous < Self.panGestureGapNanos { + // Same tilt still streaming. Swallow it: acting again would + // repeat the action, replaying it would scroll the page. + self.lastPanTimestamp[index] = now + return + } + self.lastPanTimestamp[index] = now + ButtonMapper.shared.handlePress(buttonIndex: index) + ButtonMapper.shared.handleRelease(buttonIndex: index) + NSLog("[Pointer] Suppressed native pan; performed slot=%d", index) + } + } + return true + } + private func promptForInputMonitoring() { let alert = NSAlert() alert.messageText = "Enable Input Monitoring" diff --git a/Sources/NagaController/EventTap/PointerInputRouter.swift b/Sources/NagaController/EventTap/PointerInputRouter.swift new file mode 100644 index 0000000..974cce0 --- /dev/null +++ b/Sources/NagaController/EventTap/PointerInputRouter.swift @@ -0,0 +1,53 @@ +import Foundation + +/// HID identifies the physical device; CGEvent is where its native action can be +/// suppressed. Match one HID observation to one event, using hardware timestamps. +/// No CGEvent direction inference: Natural Scrolling may invert the pan delta. +struct PointerInputRouter { + enum Kind: Equatable { + case button(usage: UInt32, down: Bool) + case horizontalScroll + } + struct Observation { + let kind: Kind + let timestamp: UInt64 // nanoseconds since boot + let buttonIndex: Int + } + private var observations: [Observation] = [] + static let tolerance: UInt64 = 50_000_000 + + mutating func record(_ observation: Observation) { + observations.append(observation) + // Bound storage even if a device sends HID data without CGEvents. + if observations.count > 128 { observations.removeFirst(observations.count - 128) } + } + + mutating func consume(kind: Kind, timestamp: UInt64) -> Int? { + observations.removeAll { $0.timestamp < timestamp && timestamp - $0.timestamp > Self.tolerance } + let matching = observations.indices.filter { + let item = observations[$0] + let distance = item.timestamp > timestamp ? item.timestamp - timestamp : timestamp - item.timestamp + return item.kind == kind && distance <= Self.tolerance + } + guard let index = matching.min(by: { + abs(Double(observations[$0].timestamp) - Double(timestamp)) < abs(Double(observations[$1].timestamp) - Double(timestamp)) + }) else { return nil } + return observations.remove(at: index).buttonIndex + } + + static func bindingIndex(bindings: [Int: HardwareBinding], usagePage: UInt32, + usage: UInt32, cookie: UInt32, value: Int32, + vendorID: Int, productID: Int) -> Int? { + // Only mapped auxiliary buttons and signed horizontal pan are eligible. + guard (usagePage == 9 && usage >= 3) || (usagePage == 12 && usage == 568 && value != 0) else { return nil } + return bindings.keys.sorted().first { index in + let b = bindings[index]! + guard b.usagePage == usagePage, b.usage == usage, + b.vendorID == vendorID, b.productID == productID, + b.cookie == nil || b.cookie == cookie else { return false } + if usagePage == 9 { return b.value == nil || b.value == 1 } + guard let expected = b.value else { return true } + return (expected < 0 && value < 0) || (expected > 0 && value > 0) + } + } +} diff --git a/Sources/NagaController/HID/HIDListener.swift b/Sources/NagaController/HID/HIDListener.swift index 9af0861..839b18e 100644 --- a/Sources/NagaController/HID/HIDListener.swift +++ b/Sources/NagaController/HID/HIDListener.swift @@ -16,6 +16,16 @@ final class HIDListener { // Increased to account for scheduling/processing latency between HID and event tap private let recentWindow: TimeInterval = 1.00 private var syntheticStates: [Int: Bool] = [:] + private var pointerRouter = PointerInputRouter() + private static let timebase: mach_timebase_info_data_t = { + var info = mach_timebase_info_data_t() + mach_timebase_info(&info) + return info + }() + + func consumePointer(kind: PointerInputRouter.Kind, timestamp: UInt64) -> Int? { + pointerRouter.consume(kind: kind, timestamp: timestamp) + } private var learningCallback: ((UInt32, UInt32, IOHIDElementCookie, Int32, Int, Int) -> Void)? @@ -148,6 +158,21 @@ final class HIDListener { } if isLearning { return } + // Mouse clicks and pan require the pointer event tap, not the number-key + // path. Record releases too; they must never leak through to Chrome. + if let index = PointerInputRouter.bindingIndex( + bindings: ConfigManager.shared.hardwareBindingsForCurrentProfile(), + usagePage: usagePage, usage: usage, cookie: UInt32(cookie), value: activeVal, + vendorID: vendor, productID: productID) { + let ticks = IOHIDValueGetTimeStamp(value) + let nanos = UInt64(Double(ticks) * Double(Self.timebase.numer) / Double(Self.timebase.denom)) + let kind: PointerInputRouter.Kind = usagePage == 9 + ? .button(usage: usage, down: pressedValue != 0) : .horizontalScroll + pointerRouter.record(.init(kind: kind, timestamp: nanos, buttonIndex: index)) + NSLog("[Pointer] HID observed slot=%d page=%u usage=%u value=%d", index, usagePage, usage, activeVal) + return + } + // Support dynamic mappings for non-keyboard pages let buttonIndex = HIDListener.buttonIndex(forUsage: usage, usagePage: usagePage, cookie: UInt32(cookie), value: activeVal, vendorID: vendor, productID: productID) diff --git a/Sources/NagaController/UI/MappingViewController.swift b/Sources/NagaController/UI/MappingViewController.swift index 686a3db..ca9eaa5 100644 --- a/Sources/NagaController/UI/MappingViewController.swift +++ b/Sources/NagaController/UI/MappingViewController.swift @@ -166,12 +166,49 @@ final class MappingViewController: NSViewController { dpiStack.bottomAnchor.constraint(equalTo: extrasCard.bottomAnchor, constant: -16) ]) + // Wheel controls ship unbound: each user pairs them to their own mouse + // with Learn Hardware Trigger, since tilt and click differ per device. + let wheelStack = NSStackView() + wheelStack.translatesAutoresizingMaskIntoConstraints = false + wheelStack.orientation = .horizontal + wheelStack.spacing = 16 + wheelStack.distribution = .fillEqually + for idx in [15, 16, 17] { + let card = makeCard(for: idx) + rowViews[idx] = card + wheelStack.addArrangedSubview(card) + } + + let wheelHint = NSTextField(labelWithString: "Wheel controls need pairing. Open Configure, choose Learn Hardware Trigger, then tilt or click the wheel.") + wheelHint.font = .systemFont(ofSize: 11) + wheelHint.textColor = NSColor.white.withAlphaComponent(0.5) + wheelHint.lineBreakMode = .byWordWrapping + wheelHint.usesSingleLineMode = false + wheelHint.translatesAutoresizingMaskIntoConstraints = false + + let wheelColumn = NSStackView(views: [wheelHint, wheelStack]) + wheelColumn.orientation = .vertical + wheelColumn.spacing = 12 + wheelColumn.translatesAutoresizingMaskIntoConstraints = false + + let wheelCard = UIStyle.makeCard() + wheelCard.addSubview(wheelColumn) + NSLayoutConstraint.activate([ + wheelColumn.leadingAnchor.constraint(equalTo: wheelCard.leadingAnchor, constant: 16), + wheelColumn.trailingAnchor.constraint(equalTo: wheelCard.trailingAnchor, constant: -16), + wheelColumn.topAnchor.constraint(equalTo: wheelCard.topAnchor, constant: 16), + wheelColumn.bottomAnchor.constraint(equalTo: wheelCard.bottomAnchor, constant: -16), + wheelStack.leadingAnchor.constraint(equalTo: wheelColumn.leadingAnchor), + wheelStack.trailingAnchor.constraint(equalTo: wheelColumn.trailingAnchor) + ]) + let contentStack = NSStackView() contentStack.orientation = .vertical contentStack.spacing = 16 contentStack.translatesAutoresizingMaskIntoConstraints = false contentStack.addArrangedSubview(cardsCard) contentStack.addArrangedSubview(extrasCard) + contentStack.addArrangedSubview(wheelCard) let scrollView = NSScrollView() scrollView.documentView = contentStack @@ -397,6 +434,9 @@ final class MappingViewController: NSViewController { switch index { case 13: return "DPI Up" case 14: return "DPI Down" + case 15: return "Wheel Tilt Left" + case 16: return "Wheel Tilt Right" + case 17: return "Wheel Click" default: return "Button \(index)" } } @@ -409,7 +449,7 @@ final class MappingViewController: NSViewController { private func refreshRows() { let mapping = isHypershiftView ? ConfigManager.shared.hypershiftMappingForCurrentProfile() : ConfigManager.shared.mappingForCurrentProfile() - for i in 1...14 { + for i in 1...17 { descLabels[i]?.stringValue = actionDescription(mapping[i]) } diff --git a/Tests/NagaControllerTests/Fixtures/profiles.json b/Tests/NagaControllerTests/Fixtures/profiles.json new file mode 100644 index 0000000..9293f3d --- /dev/null +++ b/Tests/NagaControllerTests/Fixtures/profiles.json @@ -0,0 +1,321 @@ +{ + "profiles" : { + "Default" : { + "buttons" : { + "1" : { + "description" : "Copy", + "keys" : [ + { + "key" : "c", + "modifiers" : [ + "cmd" + ] + } + ], + "type" : "keySequence" + }, + "2" : { + "description" : "Paste", + "keys" : [ + { + "key" : "v", + "modifiers" : [ + "cmd" + ] + } + ], + "type" : "keySequence" + } + } + }, + "Naga Productivity" : { + "buttons" : { + "1" : { + "description" : "Copy", + "keys" : [ + { + "key" : "c", + "modifiers" : [ + "cmd" + ] + } + ], + "type" : "keySequence" + }, + "10" : { + "description" : "New Tab", + "keys" : [ + { + "key" : "t", + "modifiers" : [ + "cmd" + ] + } + ], + "type" : "keySequence" + }, + "11" : { + "description" : "Close Tab or Window", + "keys" : [ + { + "key" : "w", + "modifiers" : [ + "cmd" + ] + } + ], + "type" : "keySequence" + }, + "12" : { + "description" : "Hypershift (Toggle)", + "mode" : "toggle", + "type" : "hypershift" + }, + "2" : { + "description" : "Paste", + "keys" : [ + { + "key" : "v", + "modifiers" : [ + "cmd" + ] + } + ], + "type" : "keySequence" + }, + "3" : { + "description" : "Undo", + "keys" : [ + { + "key" : "z", + "modifiers" : [ + "cmd" + ] + } + ], + "type" : "keySequence" + }, + "4" : { + "description" : "Back", + "keys" : [ + { + "key" : "[", + "modifiers" : [ + "cmd" + ] + } + ], + "type" : "keySequence" + }, + "5" : { + "description" : "Screenshot to Clipboard", + "keys" : [ + { + "key" : "4", + "modifiers" : [ + "cmd", + "ctrl", + "shift" + ] + } + ], + "type" : "keySequence" + }, + "6" : { + "description" : "Forward", + "keys" : [ + { + "key" : "]", + "modifiers" : [ + "cmd" + ] + } + ], + "type" : "keySequence" + }, + "7" : { + "description" : "Previous Tab", + "keys" : [ + { + "key" : "tab", + "modifiers" : [ + "ctrl", + "shift" + ] + } + ], + "type" : "keySequence" + }, + "8" : { + "description" : "Next Tab", + "keys" : [ + { + "key" : "tab", + "modifiers" : [ + "ctrl" + ] + } + ], + "type" : "keySequence" + }, + "9" : { + "description" : "Switch to Last App", + "keys" : [ + { + "key" : "tab", + "keyCode" : 48, + "modifiers" : [ + "cmd" + ] + } + ], + "type" : "keySequence" + } + }, + "hardwareBindings" : { + "7" : { + "cookie" : 896, + "productID" : 181, + "usage" : 568, + "usagePage" : 12, + "value" : -1, + "vendorID" : 1678 + }, + "8" : { + "cookie" : 896, + "productID" : 181, + "usage" : 568, + "usagePage" : 12, + "value" : 1, + "vendorID" : 1678 + }, + "9" : { + "cookie" : 24, + "productID" : 181, + "usage" : 3, + "usagePage" : 9, + "value" : 1, + "vendorID" : 1678 + } + }, + "hypershiftMappings" : { + "1" : { + "description" : "Cut", + "keys" : [ + { + "key" : "x", + "modifiers" : [ + "cmd" + ] + } + ], + "type" : "keySequence" + }, + "10" : { + "description" : "Reopen Closed Tab", + "keys" : [ + { + "key" : "t", + "modifiers" : [ + "cmd", + "shift" + ] + } + ], + "type" : "keySequence" + }, + "11" : { + "description" : "New Window", + "keys" : [ + { + "key" : "n", + "modifiers" : [ + "cmd" + ] + } + ], + "type" : "keySequence" + }, + "2" : { + "description" : "Paste and Match Style", + "keys" : [ + { + "key" : "v", + "modifiers" : [ + "cmd", + "opt", + "shift" + ] + } + ], + "type" : "keySequence" + }, + "3" : { + "description" : "Redo", + "keys" : [ + { + "key" : "z", + "modifiers" : [ + "cmd", + "shift" + ] + } + ], + "type" : "keySequence" + }, + "4" : { + "description" : "Previous Window in Current App", + "keys" : [ + { + "key" : "`", + "modifiers" : [ + "cmd", + "shift" + ] + } + ], + "type" : "keySequence" + }, + "5" : { + "description" : "Spotlight", + "keys" : [ + { + "key" : "space", + "modifiers" : [ + "cmd" + ] + } + ], + "type" : "keySequence" + }, + "6" : { + "description" : "Next Window in Current App", + "keys" : [ + { + "key" : "`", + "modifiers" : [ + "cmd" + ] + } + ], + "type" : "keySequence" + }, + "7" : { + "mediaKey" : 1, + "type" : "mediaKey" + }, + "8" : { + "mediaKey" : 16, + "type" : "mediaKey" + }, + "9" : { + "mediaKey" : 0, + "type" : "mediaKey" + } + } + } + }, + "settings" : { + "currentProfile" : "Naga Productivity" + } +} \ No newline at end of file diff --git a/Tests/NagaControllerTests/ModifierReleaseTests.swift b/Tests/NagaControllerTests/ModifierReleaseTests.swift new file mode 100644 index 0000000..9bd761d --- /dev/null +++ b/Tests/NagaControllerTests/ModifierReleaseTests.swift @@ -0,0 +1,16 @@ +import XCTest +import Cocoa +@testable import NagaController + +final class ModifierReleaseTests: XCTestCase { + func testCommandTabEndsWithCommandRelease() { + let events = ButtonMapper.modifierReleaseEvents(.maskCommand, physicalFlags: []) + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first?.type, .flagsChanged) + XCTAssertEqual(events.first?.getIntegerValueField(.keyboardEventKeycode), 55) + XCTAssertEqual(events.first?.flags, []) + } + func testPhysicalKeyboardModifierIsNotReleased() { + XCTAssertTrue(ButtonMapper.modifierReleaseEvents(.maskCommand, physicalFlags: .maskCommand).isEmpty) + } +} diff --git a/Tests/NagaControllerTests/PointerInputRouterTests.swift b/Tests/NagaControllerTests/PointerInputRouterTests.swift new file mode 100644 index 0000000..9d5e224 --- /dev/null +++ b/Tests/NagaControllerTests/PointerInputRouterTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import NagaController + +final class PointerInputRouterTests: XCTestCase { + private let bindings: [Int: HardwareBinding] = [ + 7: .init(usagePage: 12, usage: 568, cookie: 896, value: -1, vendorID: 1678, productID: 181), + 8: .init(usagePage: 12, usage: 568, cookie: 896, value: 1, vendorID: 1678, productID: 181), + 9: .init(usagePage: 9, usage: 3, cookie: 24, value: 1, vendorID: 1678, productID: 181) + ] + private func index(page: UInt32 = 12, usage: UInt32 = 568, cookie: UInt32 = 896, value: Int32, + vendor: Int = 1678, product: Int = 181) -> Int? { + PointerInputRouter.bindingIndex(bindings: bindings, usagePage: page, usage: usage, + cookie: cookie, value: value, vendorID: vendor, productID: product) + } + func testTiltDirectionsAndRepeatMagnitude() { + XCTAssertEqual(index(value: -1), 7) + XCTAssertEqual(index(value: 1), 8) + XCTAssertEqual(index(value: -3), 7) + XCTAssertEqual(index(value: 4), 8) + XCTAssertNil(index(value: 0)) + } + func testMiddleDownAndUpAreRoutedWithoutInterceptingPrimaryButtons() { + XCTAssertEqual(index(page: 9, usage: 3, cookie: 24, value: 1), 9) + XCTAssertEqual(index(page: 9, usage: 3, cookie: 24, value: 0), 9) + XCTAssertNil(index(page: 9, usage: 1, cookie: 22, value: 1)) + XCTAssertNil(index(page: 9, usage: 2, cookie: 23, value: 1)) + } + func testUnrelatedDevicesAndVerticalScrollingDoNotMatch() { + XCTAssertNil(index(value: 1, vendor: 1452)) + XCTAssertNil(index(value: 1, product: 999)) + XCTAssertNil(index(page: 1, usage: 56, value: 1)) + } + func testOneObservationCannotTriggerTwice() { + var router = PointerInputRouter() + router.record(.init(kind: .horizontalScroll, timestamp: 100_000_000, buttonIndex: 7)) + XCTAssertEqual(router.consume(kind: .horizontalScroll, timestamp: 101_000_000), 7) + XCTAssertNil(router.consume(kind: .horizontalScroll, timestamp: 101_000_000)) + } + func testDelayedCallbacksAndNaturalScrollingUseHIDDirection() { + var router = PointerInputRouter() + router.record(.init(kind: .horizontalScroll, timestamp: 120_000_000, buttonIndex: 8)) + XCTAssertEqual(router.consume(kind: .horizontalScroll, timestamp: 100_000_000), 8) + } + func testExpiredAndWrongEventKindsPassThrough() { + var router = PointerInputRouter() + router.record(.init(kind: .button(usage: 3, down: true), timestamp: 100_000_000, buttonIndex: 9)) + XCTAssertNil(router.consume(kind: .horizontalScroll, timestamp: 100_000_000)) + XCTAssertNil(router.consume(kind: .button(usage: 3, down: false), timestamp: 100_000_000)) + XCTAssertNil(router.consume(kind: .button(usage: 3, down: true), timestamp: 200_000_000)) + } +} diff --git a/Tests/NagaControllerTests/ProfileCompatibilityTests.swift b/Tests/NagaControllerTests/ProfileCompatibilityTests.swift new file mode 100644 index 0000000..1b123b1 --- /dev/null +++ b/Tests/NagaControllerTests/ProfileCompatibilityTests.swift @@ -0,0 +1,31 @@ +import XCTest +@testable import NagaController + +final class ProfileCompatibilityTests: XCTestCase { + func testActualSavedProfileUsesSupportedActionsAndKeys() throws { + let path = URL(fileURLWithPath: #filePath).deletingLastPathComponent().appendingPathComponent("Fixtures/profiles.json") + let file = try JSONDecoder().decode(ProfilesFile.self, from: Data(contentsOf: path)) + let profile = try XCTUnwrap(file.profiles["Naga Productivity"]) + XCTAssertEqual(profile.buttons.count, 12) + XCTAssertEqual(profile.hypershiftMappings?.count, 11) + for (index, action) in Array(profile.buttons) + Array(profile.hypershiftMappings ?? [:]) { + switch action.type { + case "keySequence": + let strokes = try XCTUnwrap(action.keys, "Button \(index)") + XCTAssertFalse(strokes.isEmpty) + for stroke in strokes { + XCTAssertNotNil(stroke.keyCode ?? KeyStroke.keyCode(for: stroke.key), stroke.key) + for modifier in stroke.modifiers { + XCTAssertTrue(["cmd", "ctrl", "shift", "opt", "option", "alt"].contains(modifier)) + } + } + case "mediaKey": + XCTAssertNotNil(action.mediaKey.flatMap(MediaKeyType.init(rawValue:)), "Button \(index)") + case "hypershift": + XCTAssertEqual(action.mode, "toggle") + default: + XCTFail("Unexpected action \(action.type) at \(index)") + } + } + } +} From 324deb0451974aa16579d8e6c506c528d78ce421 Mon Sep 17 00:00:00 2001 From: distractedhero Date: Sun, 13 Sep 2026 20:06:12 -0400 Subject: [PATCH 2/4] chore: gate per-event tracing behind NAGA_DEBUG A normal launch logged every HID report, every matched device and every mapping lookup. Seven wheel tilts produced 136 lines. Route that tracing through Log.debug, which stays silent unless NAGA_DEBUG is set in the environment. Permission state, event tap state, profile load counts and errors still log unconditionally. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017mrRRGrRu2XN8q5ruiZKkT --- .../ButtonMapping/ButtonMapper.swift | 12 ++++++------ .../EventTap/EventTapManager.swift | 6 +++--- Sources/NagaController/HID/HIDListener.swift | 14 +++++++------- Sources/NagaController/Utils/Log.swift | 16 ++++++++++++++++ 4 files changed, 32 insertions(+), 16 deletions(-) create mode 100644 Sources/NagaController/Utils/Log.swift diff --git a/Sources/NagaController/ButtonMapping/ButtonMapper.swift b/Sources/NagaController/ButtonMapping/ButtonMapper.swift index 321b1b0..810b4f2 100644 --- a/Sources/NagaController/ButtonMapping/ButtonMapper.swift +++ b/Sources/NagaController/ButtonMapping/ButtonMapper.swift @@ -102,11 +102,11 @@ final class ButtonMapper { NSLog("[Mapping] Button \(buttonIndex) matched Hypershift mapping: \(hAction)") } else { actionToPerform = mapping[buttonIndex] - NSLog("[Mapping] Button \(buttonIndex) fallback to Standard mapping (Hypershift active but no specific mapping): \(String(describing: actionToPerform))") + Log.debug("[Mapping] Button \(buttonIndex) fallback to Standard mapping (Hypershift active but no specific mapping): \(String(describing: actionToPerform))") } } else { actionToPerform = mapping[buttonIndex] - NSLog("[Mapping] Button \(buttonIndex) Standard mapping: \(String(describing: actionToPerform))") + Log.debug("[Mapping] Button \(buttonIndex) Standard mapping: \(String(describing: actionToPerform))") } guard let action = actionToPerform else { @@ -126,7 +126,7 @@ final class ButtonMapper { event.type = .flagsChanged event.flags = currentModifierFlags post(event) - NSLog("[Mapping] Modifier hold start: button \(buttonIndex) -> \(stroke.displayLabel), cumulative flags: \(event.flags)") + Log.debug("[Mapping] Modifier hold start: button \(buttonIndex) -> \(stroke.displayLabel), cumulative flags: \(event.flags)") } return } @@ -136,7 +136,7 @@ final class ButtonMapper { eventDown.flags = flags post(eventDown) activeHolds[buttonIndex] = (code, flags) - NSLog("[Mapping] Hold start for button \(buttonIndex) -> key=\(stroke.displayLabel), flags=\(flags)") + Log.debug("[Mapping] Hold start for button \(buttonIndex) -> key=\(stroke.displayLabel), flags=\(flags)") } else { // If no keycode, fallback to sending sequence taps to stay functional for stroke in keys { sendKeyStroke(stroke) } @@ -176,7 +176,7 @@ final class ButtonMapper { event.type = .flagsChanged event.flags = currentModifierFlags post(event) - NSLog("[Mapping] Modifier hold end: button \(buttonIndex) -> \(stroke.displayLabel), cumulative flags: \(event.flags)") + Log.debug("[Mapping] Modifier hold end: button \(buttonIndex) -> \(stroke.displayLabel), cumulative flags: \(event.flags)") } } return @@ -187,7 +187,7 @@ final class ButtonMapper { eventUp.flags = CGEventSource.flagsState(.hidSystemState) post(eventUp) finishShortcut(flags) - NSLog("[Mapping] Hold end for button \(buttonIndex)") + Log.debug("[Mapping] Hold end for button \(buttonIndex)") } } } diff --git a/Sources/NagaController/EventTap/EventTapManager.swift b/Sources/NagaController/EventTap/EventTapManager.swift index 4878459..663334e 100644 --- a/Sources/NagaController/EventTap/EventTapManager.swift +++ b/Sources/NagaController/EventTap/EventTapManager.swift @@ -233,10 +233,10 @@ final class EventTapManager { guard self.activePointerButtons[usage] == nil else { return } self.activePointerButtons[usage] = index ButtonMapper.shared.handlePress(buttonIndex: index) - NSLog("[Pointer] Suppressed native button down; performed slot=%d", index) + Log.debug("[Pointer] Suppressed native button down; performed slot=\(index)") } else if !down, let index = self.activePointerButtons.removeValue(forKey: usage) { ButtonMapper.shared.handleRelease(buttonIndex: index) - NSLog("[Pointer] Suppressed native button up; released slot=%d", index) + Log.debug("[Pointer] Suppressed native button up; released slot=\(index)") } else { replay() } case .horizontalScroll: guard let index = matched else { replay(); return } @@ -251,7 +251,7 @@ final class EventTapManager { self.lastPanTimestamp[index] = now ButtonMapper.shared.handlePress(buttonIndex: index) ButtonMapper.shared.handleRelease(buttonIndex: index) - NSLog("[Pointer] Suppressed native pan; performed slot=%d", index) + Log.debug("[Pointer] Suppressed native pan; performed slot=\(index)") } } return true diff --git a/Sources/NagaController/HID/HIDListener.swift b/Sources/NagaController/HID/HIDListener.swift index 839b18e..b30449b 100644 --- a/Sources/NagaController/HID/HIDListener.swift +++ b/Sources/NagaController/HID/HIDListener.swift @@ -54,7 +54,7 @@ final class HIDListener { guard context != nil else { return } let vendor = HIDListener.vendorID(device: device) ?? -1 let product = (IOHIDDeviceGetProperty(device, kIOHIDProductKey as CFString) as? String) ?? "" - NSLog("[HID] Device plugged/matched: vendor=0x\(String(vendor, radix: 16)), product=\(product)") + Log.debug("[HID] Device plugged/matched: vendor=0x\(String(vendor, radix: 16)), product=\(product)") }, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())) IOHIDManagerRegisterInputValueCallback(manager, { context, result, sender, value in @@ -74,8 +74,8 @@ final class HIDListener { if openResult != kIOReturnSuccess { NSLog("[HID] IOHIDManagerOpen failed: \(openResult)") } else { - NSLog("[HID] Listener started. VERSION: \(HIDListener.DIAGNOSTIC_VERSION)") - NSLog("[HID] Matching ALL devices for diagnostics + RAW reports enabled.") + Log.debug("[HID] Listener started. VERSION: \(HIDListener.DIAGNOSTIC_VERSION)") + Log.debug("[HID] Matching ALL devices for diagnostics + RAW reports enabled.") if let set = IOHIDManagerCopyDevices(manager) { let devices = (set as NSSet) as! Set for dev in devices { @@ -85,7 +85,7 @@ final class HIDListener { let usage = (IOHIDDeviceGetProperty(dev, kIOHIDPrimaryUsageKey as CFString) as? Int) ?? -1 let usagePage = (IOHIDDeviceGetProperty(dev, kIOHIDPrimaryUsagePageKey as CFString) as? Int) ?? -1 let ptr = Unmanaged.passUnretained(dev).toOpaque() - NSLog("[HID] DISCOVERY: [\(ptr)] product=\(product), vendor=0x\(String(vendor, radix: 16)), usage=0x\(String(usagePage, radix: 16)):0x\(String(usage, radix: 16))") + Log.debug("[HID] DISCOVERY: [\(ptr)] product=\(product), vendor=0x\(String(vendor, radix: 16)), usage=0x\(String(usagePage, radix: 16)):0x\(String(usage, radix: 16))") } } } @@ -169,7 +169,7 @@ final class HIDListener { let kind: PointerInputRouter.Kind = usagePage == 9 ? .button(usage: usage, down: pressedValue != 0) : .horizontalScroll pointerRouter.record(.init(kind: kind, timestamp: nanos, buttonIndex: index)) - NSLog("[Pointer] HID observed slot=%d page=%u usage=%u value=%d", index, usagePage, usage, activeVal) + Log.debug("[Pointer] HID observed slot=\(index) page=\(usagePage) usage=\(usage) value=\(activeVal)") return } @@ -235,7 +235,7 @@ final class HIDListener { queue.sync { syntheticStates[buttonIndex] = pressed } if pressed { - NSLog("[HID] Synthetic press captured for button \(buttonIndex) (raw=0x\(String(rawValue, radix: 16)))") + Log.debug("[HID] Synthetic press captured for button \(buttonIndex) (raw=0x\(String(rawValue, radix: 16)))") if ConfigManager.shared.getRemappingEnabled() { ButtonMapper.shared.handlePress(buttonIndex: buttonIndex) } @@ -300,7 +300,7 @@ final class HIDListener { #if DEBUG let bytes = UnsafeBufferPointer(start: report, count: length) let hex = bytes.map { String(format: "%02x", $0) }.joined(separator: " ") - NSLog("[HID] RAW REPORT: [ID=\(id)] Len=\(length), Data=\(hex)") + Log.debug("[HID] RAW REPORT: [ID=\(id)] Len=\(length), Data=\(hex)") #endif } } diff --git a/Sources/NagaController/Utils/Log.swift b/Sources/NagaController/Utils/Log.swift new file mode 100644 index 0000000..25f7ca7 --- /dev/null +++ b/Sources/NagaController/Utils/Log.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Verbose tracing for development. Off unless NAGA_DEBUG is set in the +/// environment, so a normal launch logs only errors and state changes rather +/// than a line per HID report. +/// +/// NAGA_DEBUG=1 /Applications/NagaController.app/Contents/MacOS/NagaController +enum Log { + static let verbose: Bool = ProcessInfo.processInfo.environment["NAGA_DEBUG"] != nil + + /// Autoclosure keeps the string out of the release path entirely. + static func debug(_ message: @autoclosure () -> String) { + guard verbose else { return } + NSLog("%@", message()) + } +} From 0e3569189a209c4880750fd1235fafd83819a7b8 Mon Sep 17 00:00:00 2001 From: DParent10 <127067984+DParent10@users.noreply.github.com> Date: Thu, 17 Sep 2026 06:49:11 -0400 Subject: [PATCH 3/4] review: keep the profile name in the menu bar; defer bindings lookup; document NAGA_DEBUG - Restore AppDelegate's status item title from main: showing the active profile there landed in #12 and this branch reverted it unintentionally. - deferMappedPointer runs for every tapped event including keystrokes; check the event type before building the bindings dictionary. - README troubleshooting now explains NAGA_DEBUG and lists the log lines the app actually prints today, including the pointer router's. Co-Authored-By: Claude Fable 5.1 --- README.md | 21 +++++++++++-------- Sources/NagaController/AppDelegate.swift | 4 ++-- .../EventTap/EventTapManager.swift | 15 +++++++++---- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 2bd39cf..229cd6b 100644 --- a/README.md +++ b/README.md @@ -60,23 +60,26 @@ See the [user guide](USER-GUIDE.md) for shortcuts, profile switching, Hypershift ## Troubleshooting 1. Grant Accessibility permissions (System Settings → Privacy & Security → Accessibility) and ensure the app is checked -2. Launch from Terminal to see diagnostics (launch through `open`; running the binary directly makes macOS kill the app on its first Bluetooth access): +2. Launch from Terminal with verbose tracing to see diagnostics (launch through `open`; running the binary directly makes macOS kill the app on its first Bluetooth access). Per-event logs only print when `NAGA_DEBUG` is set: ```bash - open --stdout /tmp/naga.log --stderr /tmp/naga.log ./NagaController.app && tail -f /tmp/naga.log + open --env NAGA_DEBUG=1 --stdout /tmp/naga.log --stderr /tmp/naga.log /Applications/NagaController.app && tail -f /tmp/naga.log ``` 3. Turn ON "Enable remapping" in the menu bar popover 4. Expected logs: - Startup: - - `[HID] Listener started (vendors: 0x68e, 0x1532; plus product contains 'naga')` - - `[HID] Device matched: vendor=0x68e, product=Naga V2 HS` (your device may vary) + - `[Permissions] Accessibility trusted = true` + - `[HID] Listener started. VERSION: ...` (if you see `IOHIDManagerOpen failed` instead, Input Monitoring is missing) + - `[HID] DISCOVERY: [...] product=Naga V2 HS, vendor=0x68e, ...` (your device may vary) + - `[EventTap] Starting with listenOnly=false` with no `CRITICAL` line after it - On side-button press: - - `[HID] Press recorded: vendor=0x..., product=..., usage=0x1e, buttonIndex=1` (etc.) - - On keyboard safety (non-Naga): - - `[HID] Ignored keyboard usage from device: vendor=0x..., product=...` + - `[HID] Mapped Keyboard press for button 1` followed by `[EventTap] Detected Naga button 1` (etc.) + - On a paired wheel tilt or click: + - `[Pointer] HID observed slot=15 ...` followed by `[Pointer] Suppressed native pan; performed slot=15` 5. If mouse buttons still type digits instead of your mapping: - Ensure remapping is enabled - - Verify the Accessibility permission is granted - - Paste the relevant `[HID] Device matched` and `[HID] Press recorded` lines into an issue so we can whitelist your device if needed + - Verify both Accessibility and Input Monitoring are granted, then quit and reopen the app + - If the mouse was customised in Razer Synapse on Windows, reset its on-board profile there first (see the user guide) + - Paste the relevant `[HID] DISCOVERY` and `[HID] Mapped` lines into an issue so we can whitelist your device if needed ## Battery (Bluetooth) diff --git a/Sources/NagaController/AppDelegate.swift b/Sources/NagaController/AppDelegate.swift index cf9ac56..9ace48f 100644 --- a/Sources/NagaController/AppDelegate.swift +++ b/Sources/NagaController/AppDelegate.swift @@ -137,10 +137,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let hasImage = (button.image != nil) let profile = ConfigManager.shared.currentProfileName if let lvl = level { - button.title = hasImage ? "" : "🖱️" + button.title = (hasImage ? " " : "🖱️ ") + "\(lvl)% · \(profile)" button.toolTip = "Naga battery: \(lvl)% · Profile: \(profile)" } else { - button.title = hasImage ? "" : "🖱️" + button.title = (hasImage ? " " : "🖱️ ") + profile button.toolTip = "Naga battery: — · Profile: \(profile)" } } diff --git a/Sources/NagaController/EventTap/EventTapManager.swift b/Sources/NagaController/EventTap/EventTapManager.swift index 663334e..664c593 100644 --- a/Sources/NagaController/EventTap/EventTapManager.swift +++ b/Sources/NagaController/EventTap/EventTapManager.swift @@ -198,19 +198,21 @@ final class EventTapManager { } private func deferMappedPointer(type: CGEventType, event: CGEvent) -> Bool { - guard ConfigManager.shared.getRemappingEnabled() else { return false } - let bindings = ConfigManager.shared.hardwareBindingsForCurrentProfile() + // Cheap type checks first: this runs for every event the tap sees, including + // keystrokes, and the bindings lookup builds a dictionary. let kind: PointerInputRouter.Kind if type == .otherMouseDown || type == .otherMouseUp || type == .otherMouseDragged { let usage = UInt32(event.getIntegerValueField(.mouseEventButtonNumber) + 1) if type == .otherMouseDragged { return activePointerButtons[usage] != nil } - guard bindings.values.contains(where: { $0.usagePage == 9 && $0.usage == usage }) else { return false } + guard ConfigManager.shared.getRemappingEnabled(), + hasPointerBinding(usagePage: 9, usage: usage) else { return false } kind = .button(usage: usage, down: type == .otherMouseDown) } else if type == .scrollWheel { guard event.getIntegerValueField(.scrollWheelEventIsContinuous) == 0, event.getIntegerValueField(.scrollWheelEventDeltaAxis2) != 0, event.getIntegerValueField(.scrollWheelEventDeltaAxis1) == 0, - bindings.values.contains(where: { $0.usagePage == 12 && $0.usage == 568 }) else { return false } + ConfigManager.shared.getRemappingEnabled(), + hasPointerBinding(usagePage: 12, usage: 568) else { return false } kind = .horizontalScroll } else { return false } @@ -257,6 +259,11 @@ final class EventTapManager { return true } + private func hasPointerBinding(usagePage: UInt32, usage: UInt32) -> Bool { + ConfigManager.shared.hardwareBindingsForCurrentProfile().values + .contains { $0.usagePage == usagePage && $0.usage == usage } + } + private func promptForInputMonitoring() { let alert = NSAlert() alert.messageText = "Enable Input Monitoring" From 030263257d4bb4736f4b1ad5b6e025842aac21e8 Mon Sep 17 00:00:00 2001 From: DParent10 <127067984+DParent10@users.noreply.github.com> Date: Thu, 17 Sep 2026 07:40:54 -0400 Subject: [PATCH 4/4] fix: keep unbound pointer inputs off the key-press path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With only one tilt direction learned, tilting the other way fell through to the key-press path, whose single-candidate fallback ignores the pan sign and fired the learned slot twice per burst while the native horizontal scroll leaked through (seen on a Naga V2 HS). Pointer inputs — auxiliary mouse buttons and horizontal pan — now stop at the router whether or not they matched a binding. Also drop the stale AC Pan → DPI Up/Down fallback. Co-Authored-By: Claude Fable 5.1 --- .../EventTap/PointerInputRouter.swift | 7 +++++++ Sources/NagaController/HID/HIDListener.swift | 9 +++------ .../PointerInputRouterTests.swift | 14 ++++++++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/Sources/NagaController/EventTap/PointerInputRouter.swift b/Sources/NagaController/EventTap/PointerInputRouter.swift index 974cce0..082818c 100644 --- a/Sources/NagaController/EventTap/PointerInputRouter.swift +++ b/Sources/NagaController/EventTap/PointerInputRouter.swift @@ -35,6 +35,13 @@ struct PointerInputRouter { return observations.remove(at: index).buttonIndex } + /// Inputs that belong to the pointer path: auxiliary mouse buttons and horizontal pan. + /// They must never fall through to the key-press path, whose single-candidate fallback + /// ignores the pan sign and fires the opposite tilt's slot without suppressing the scroll. + static func isPointerInput(usagePage: UInt32, usage: UInt32) -> Bool { + (usagePage == 9 && usage >= 3) || (usagePage == 12 && usage == 568) + } + static func bindingIndex(bindings: [Int: HardwareBinding], usagePage: UInt32, usage: UInt32, cookie: UInt32, value: Int32, vendorID: Int, productID: Int) -> Int? { diff --git a/Sources/NagaController/HID/HIDListener.swift b/Sources/NagaController/HID/HIDListener.swift index b30449b..9240c6f 100644 --- a/Sources/NagaController/HID/HIDListener.swift +++ b/Sources/NagaController/HID/HIDListener.swift @@ -172,6 +172,9 @@ final class HIDListener { Log.debug("[Pointer] HID observed slot=\(index) page=\(usagePage) usage=\(usage) value=\(activeVal)") return } + // An unbound pointer input (e.g. the tilt direction that wasn't learned) stays + // native; it is not a candidate for the key-press path below. + if PointerInputRouter.isPointerInput(usagePage: usagePage, usage: usage) { return } // Support dynamic mappings for non-keyboard pages let buttonIndex = HIDListener.buttonIndex(forUsage: usage, usagePage: usagePage, cookie: UInt32(cookie), value: activeVal, vendorID: vendor, productID: productID) @@ -361,12 +364,6 @@ final class HIDListener { if usage == 0x02 { return 14 } } - // Manual fallback for DPI buttons if not yet mapped in config - if usagePage == 0x0C && usage == 0x238 { - if value == 1 { return 13 } - if value == -1 { return 14 } - } - // Page 0x07 (Keyboard) fallback for standard number keys if not in config if usagePage == 0x07 { switch usage { diff --git a/Tests/NagaControllerTests/PointerInputRouterTests.swift b/Tests/NagaControllerTests/PointerInputRouterTests.swift index 9d5e224..cb5bafa 100644 --- a/Tests/NagaControllerTests/PointerInputRouterTests.swift +++ b/Tests/NagaControllerTests/PointerInputRouterTests.swift @@ -30,6 +30,20 @@ final class PointerInputRouterTests: XCTestCase { XCTAssertNil(index(value: 1, product: 999)) XCTAssertNil(index(page: 1, usage: 56, value: 1)) } + func testPointerInputsNeverFallThroughToKeyPath() { + XCTAssertTrue(PointerInputRouter.isPointerInput(usagePage: 12, usage: 568)) + XCTAssertTrue(PointerInputRouter.isPointerInput(usagePage: 9, usage: 3)) + XCTAssertFalse(PointerInputRouter.isPointerInput(usagePage: 9, usage: 1)) + XCTAssertFalse(PointerInputRouter.isPointerInput(usagePage: 7, usage: 0x1e)) + // Only one tilt direction learned: the other direction must not resolve to that slot. + let oneDirection: [Int: HardwareBinding] = [ + 15: .init(usagePage: 12, usage: 568, cookie: 896, value: -1, vendorID: 1678, productID: 181) + ] + XCTAssertEqual(PointerInputRouter.bindingIndex(bindings: oneDirection, usagePage: 12, usage: 568, + cookie: 896, value: -1, vendorID: 1678, productID: 181), 15) + XCTAssertNil(PointerInputRouter.bindingIndex(bindings: oneDirection, usagePage: 12, usage: 568, + cookie: 896, value: 1, vendorID: 1678, productID: 181)) + } func testOneObservationCannotTriggerTwice() { var router = PointerInputRouter() router.record(.init(kind: .horizontalScroll, timestamp: 100_000_000, buttonIndex: 7))