스크롤 이벤트를 가로채 제스처 phase가 없는 휠 이벤트만 세로 방향을 뒤집은 새 이벤트로 바꿔 보낸다. 원래 이벤트의 delta 필드만 수정하면 앱에 반영되지 않아 대체 이벤트 방식을 사용한다. 트랙패드와 가로 스크롤은 그대로 둔다. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
199 lines
9.3 KiB
Swift
199 lines
9.3 KiB
Swift
import Foundation
|
|
import IOKit
|
|
import IOKit.hid
|
|
import CoreGraphics
|
|
import ApplicationServices
|
|
|
|
func log(_ message: String) {
|
|
print("\(ISO8601DateFormatter().string(from: Date())) \(message)")
|
|
fflush(stdout)
|
|
}
|
|
|
|
struct ReportSource: Hashable {
|
|
let device: Int
|
|
let reportID: UInt32
|
|
}
|
|
|
|
final class State {
|
|
var buffers: [IOHIDDevice: UnsafeMutablePointer<UInt8>] = [:]
|
|
var lastBits: [ReportSource: UInt8] = [:]
|
|
var lastFired: [CGKeyCode: TimeInterval] = [:]
|
|
var opened = false
|
|
var lastStatus = ""
|
|
}
|
|
|
|
let state = State()
|
|
let statePtr = Unmanaged.passUnretained(state).toOpaque()
|
|
|
|
func sendCommandKey(_ keyCode: CGKeyCode) {
|
|
guard CGPreflightPostEventAccess(),
|
|
let source = CGEventSource(stateID: .hidSystemState),
|
|
let down = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true),
|
|
let up = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false)
|
|
else { return }
|
|
down.flags = .maskCommand
|
|
up.flags = .maskCommand
|
|
down.post(tap: .cgSessionEventTap)
|
|
up.post(tap: .cgSessionEventTap)
|
|
}
|
|
|
|
// The mouse reports tilt on either bit pair depending on its mode after (re)connecting:
|
|
// 0x20/0x40, or 0x08/0x10 (seen after a replug).
|
|
let leftTiltMask: UInt8 = 0x20 | 0x08
|
|
let rightTiltMask: UInt8 = 0x40 | 0x10
|
|
let tiltMask = leftTiltMask | rightTiltMask
|
|
// Ignore a second press of the same tilt within this window (switch bounce).
|
|
let debounceInterval: TimeInterval = 0.12
|
|
|
|
// Preserve the supplied Razer report layout and Command+[ / Command+] mapping.
|
|
// The mouse repeats the tilt bit in every report while held, so fire only on the press edge.
|
|
let reportCallback: IOHIDReportCallback = {
|
|
context, result, sender, _, reportID, report, reportLength in
|
|
guard result == kIOReturnSuccess, reportLength >= 1, let context = context else { return }
|
|
let state = Unmanaged<State>.fromOpaque(context).takeUnretainedValue()
|
|
let source = ReportSource(device: Int(bitPattern: sender), reportID: reportID)
|
|
let value = report[0] & tiltMask
|
|
let pressed = value & ~(state.lastBits[source] ?? 0)
|
|
state.lastBits[source] = value
|
|
let now = ProcessInfo.processInfo.systemUptime
|
|
for (mask, keyCode) in [(leftTiltMask, CGKeyCode(33)), (rightTiltMask, CGKeyCode(30))] where pressed & mask != 0 {
|
|
if let last = state.lastFired[keyCode], now - last < debounceInterval { continue }
|
|
state.lastFired[keyCode] = now
|
|
sendCommandKey(keyCode)
|
|
}
|
|
}
|
|
|
|
let deviceCallback: IOHIDDeviceCallback = { context, result, _, device in
|
|
guard result == kIOReturnSuccess, let context = context else { return }
|
|
let state = Unmanaged<State>.fromOpaque(context).takeUnretainedValue()
|
|
guard state.buffers[device] == nil else { return }
|
|
let reportedSize = IOHIDDeviceGetProperty(device, kIOHIDMaxInputReportSizeKey as CFString) as? NSNumber
|
|
let bufferSize = max(64, reportedSize?.intValue ?? 64)
|
|
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
|
|
buffer.initialize(repeating: 0, count: bufferSize)
|
|
state.buffers[device] = buffer
|
|
IOHIDDeviceRegisterInputReportCallback(device, buffer, bufferSize, reportCallback, context)
|
|
let product = IOHIDDeviceGetProperty(device, kIOHIDProductKey as CFString)
|
|
log("Watching: \(product ?? "Razer Pro Click V2" as CFString)")
|
|
}
|
|
|
|
let removalCallback: IOHIDDeviceCallback = { context, _, _, device in
|
|
guard let context = context else { return }
|
|
let state = Unmanaged<State>.fromOpaque(context).takeUnretainedValue()
|
|
let deviceKey = Int(bitPattern: Unmanaged.passUnretained(device).toOpaque())
|
|
state.lastBits = state.lastBits.filter { $0.key.device != deviceKey }
|
|
if let buffer = state.buffers.removeValue(forKey: device) {
|
|
IOHIDDeviceRegisterInputReportCallback(device, buffer, 0, nil, nil)
|
|
buffer.deallocate()
|
|
log("Device disconnected; waiting for reconnection.")
|
|
}
|
|
}
|
|
|
|
let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
|
|
let matching: [String: Any] = [kIOHIDVendorIDKey: 5426, kIOHIDProductIDKey: 209]
|
|
IOHIDManagerSetDeviceMatching(manager, matching as CFDictionary)
|
|
IOHIDManagerRegisterDeviceMatchingCallback(manager, deviceCallback, statePtr)
|
|
IOHIDManagerRegisterDeviceRemovalCallback(manager, removalCallback, statePtr)
|
|
IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetCurrent(), CFRunLoopMode.defaultMode.rawValue)
|
|
|
|
log("Razer Tilt Mapper started. LEFT -> Command+[; RIGHT -> Command+]")
|
|
|
|
// macOS presents its own permission dialogs. The user grants access in Settings.
|
|
// Launchd restarts this app while permissions are missing, so prompt only once per build.
|
|
let buildStamp = (try? FileManager.default.attributesOfItem(atPath: CommandLine.arguments[0])[.modificationDate] as? Date)
|
|
.map { String($0.timeIntervalSince1970) } ?? ""
|
|
if !AXIsProcessTrusted() && UserDefaults.standard.string(forKey: "promptedBuild") != buildStamp {
|
|
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true]
|
|
_ = AXIsProcessTrustedWithOptions(options as CFDictionary)
|
|
UserDefaults.standard.set(buildStamp, forKey: "promptedBuild")
|
|
}
|
|
if IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) == kIOHIDAccessTypeUnknown {
|
|
_ = IOHIDRequestAccess(kIOHIDRequestTypeListenEvent)
|
|
}
|
|
|
|
func accessGranted() -> Bool {
|
|
IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) == kIOHIDAccessTypeGranted && CGPreflightPostEventAccess()
|
|
}
|
|
|
|
func updateAccess() {
|
|
let inputAllowed = IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) == kIOHIDAccessTypeGranted
|
|
let outputAllowed = CGPreflightPostEventAccess()
|
|
var status = "Input Monitoring: \(inputAllowed ? "granted" : "required"); Accessibility: \(outputAllowed ? "granted" : "required")"
|
|
if inputAllowed && !state.opened {
|
|
let result = IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone))
|
|
state.opened = result == kIOReturnSuccess
|
|
if !state.opened { status += "; HID open failed: \(result)" }
|
|
}
|
|
if state.opened { status += "; HID manager running" }
|
|
if status != state.lastStatus {
|
|
log(status)
|
|
state.lastStatus = status
|
|
}
|
|
}
|
|
|
|
updateAccess()
|
|
// A running process may not see a new grant, so exit while access is missing and let
|
|
// launchd (KeepAlive) start a fresh process that picks it up.
|
|
let accessTimer: Timer? = accessGranted() ? nil : {
|
|
let started = Date()
|
|
return Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { _ in
|
|
if accessGranted() {
|
|
log("Permissions granted; restarting to apply them.")
|
|
exit(0)
|
|
}
|
|
if Date().timeIntervalSince(started) >= 20 {
|
|
log("Permissions still missing; restarting to recheck.")
|
|
exit(0)
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Invert vertical mouse-wheel scrolling. Trackpads and Magic Mouse send gesture phases;
|
|
// wheels do not, even when their high-resolution scrolling is marked continuous.
|
|
var scrollTap: CFMachPort?
|
|
var lastScrollKind = ""
|
|
// Marks the replacement events this app posts so the tap lets them through untouched.
|
|
let invertedScrollMarker: Int64 = 0x52544D31
|
|
let scrollCallback: CGEventTapCallBack = { proxy, type, event, _ in
|
|
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
|
|
if let tap = scrollTap { CGEvent.tapEnable(tap: tap, enable: true) }
|
|
return Unmanaged.passUnretained(event)
|
|
}
|
|
guard type == .scrollWheel,
|
|
event.getIntegerValueField(.eventSourceUserData) != invertedScrollMarker
|
|
else { return Unmanaged.passUnretained(event) }
|
|
let phase = event.getIntegerValueField(.scrollWheelEventScrollPhase)
|
|
let momentum = event.getIntegerValueField(.scrollWheelEventMomentumPhase)
|
|
let isWheel = phase == 0 && momentum == 0
|
|
let kind = "continuous=\(event.getIntegerValueField(.scrollWheelEventIsContinuous)) wheel=\(isWheel)"
|
|
if kind != lastScrollKind {
|
|
log("Scroll events: \(kind)")
|
|
lastScrollKind = kind
|
|
}
|
|
let pointY = event.getIntegerValueField(.scrollWheelEventPointDeltaAxis1)
|
|
guard isWheel, pointY != 0 else { return Unmanaged.passUnretained(event) }
|
|
// Apps read the hardware data attached to the original event, so editing its delta
|
|
// fields has no visible effect. Post a fresh event with the vertical delta flipped.
|
|
let pointX = event.getIntegerValueField(.scrollWheelEventPointDeltaAxis2)
|
|
guard let replacement = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2,
|
|
wheel1: Int32(clamping: -pointY), wheel2: Int32(clamping: pointX), wheel3: 0)
|
|
else { return Unmanaged.passUnretained(event) }
|
|
replacement.location = event.location
|
|
replacement.flags = event.flags
|
|
replacement.setIntegerValueField(.eventSourceUserData, value: invertedScrollMarker)
|
|
replacement.tapPostEvent(proxy)
|
|
return nil
|
|
}
|
|
if accessGranted() {
|
|
scrollTap = CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap,
|
|
eventsOfInterest: CGEventMask(1 << CGEventType.scrollWheel.rawValue),
|
|
callback: scrollCallback, userInfo: nil)
|
|
if let tap = scrollTap {
|
|
CFRunLoopAddSource(CFRunLoopGetCurrent(), CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0), .commonModes)
|
|
log("Mouse wheel inversion running")
|
|
} else {
|
|
log("Mouse wheel inversion failed: could not create event tap")
|
|
}
|
|
}
|
|
CFRunLoopRun()
|