Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ let package = Package(
.testTarget(
name: "NagaControllerTests",
dependencies: ["NagaController"],
path: "Tests/NagaControllerTests"
path: "Tests/NagaControllerTests",
exclude: ["Fixtures"]
)
]
)
21 changes: 12 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
50 changes: 37 additions & 13 deletions Sources/NagaController/ButtonMapping/ButtonMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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) }
Expand Down Expand Up @@ -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)")
}
}
}
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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
Expand Down
82 changes: 82 additions & 0 deletions Sources/NagaController/EventTap/EventTapManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int> = []
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)?

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down
60 changes: 60 additions & 0 deletions Sources/NagaController/EventTap/PointerInputRouter.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading