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/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/ButtonMapping/ButtonMapper.swift b/Sources/NagaController/ButtonMapping/ButtonMapper.swift index d995bb2..810b4f2 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) @@ -80,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 { @@ -104,17 +126,17 @@ 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 } 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) - 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) } @@ -154,17 +176,18 @@ 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 } 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) - NSLog("[Mapping] Hold end for button \(buttonIndex)") + finishShortcut(flags) + Log.debug("[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..664c593 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,73 @@ final class EventTapManager { return Unmanaged.passUnretained(event) } + private func deferMappedPointer(type: CGEventType, event: CGEvent) -> Bool { + // 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 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, + ConfigManager.shared.getRemappingEnabled(), + hasPointerBinding(usagePage: 12, 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) + 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) + Log.debug("[Pointer] Suppressed native button up; released slot=\(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) + Log.debug("[Pointer] Suppressed native pan; performed slot=\(index)") + } + } + 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" diff --git a/Sources/NagaController/EventTap/PointerInputRouter.swift b/Sources/NagaController/EventTap/PointerInputRouter.swift new file mode 100644 index 0000000..082818c --- /dev/null +++ b/Sources/NagaController/EventTap/PointerInputRouter.swift @@ -0,0 +1,60 @@ +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 + } + + /// 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? { + // 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..9240c6f 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)? @@ -44,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 @@ -64,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 { @@ -75,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))") } } } @@ -148,6 +158,24 @@ 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)) + 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) @@ -210,7 +238,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) } @@ -275,7 +303,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 } } @@ -336,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/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/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()) + } +} 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..cb5bafa --- /dev/null +++ b/Tests/NagaControllerTests/PointerInputRouterTests.swift @@ -0,0 +1,65 @@ +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 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)) + 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)") + } + } + } +}