import Foundation import IOKit import IOKit.hid import CoreGraphics import ApplicationServices func log(_ message: String) { print("\(ISO8601DateFormatter().string(from: Date())) \(message)") fflush(stdout) } final class State { var buffers: [IOHIDDevice: UnsafeMutablePointer] = [:] 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) } // Preserve the supplied Razer report layout and Command+[ / Command+] mapping. let reportCallback: IOHIDReportCallback = { _, result, _, _, _, report, reportLength in guard result == kIOReturnSuccess, reportLength >= 1 else { return } let value = report[0] if value & 0x20 != 0 { sendCommandKey(33) } if value & 0x40 != 0 { sendCommandKey(30) } } let deviceCallback: IOHIDDeviceCallback = { context, result, _, device in guard result == kIOReturnSuccess, let context = context else { return } let state = Unmanaged.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.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.fromOpaque(context).takeUnretainedValue() 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. if !AXIsProcessTrusted() { let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] _ = AXIsProcessTrustedWithOptions(options as CFDictionary) } if IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) == kIOHIDAccessTypeUnknown { _ = IOHIDRequestAccess(kIOHIDRequestTypeListenEvent) } 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() // Recheck after the initial permission grant without requiring a manual restart. let accessTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { _ in updateAccess() } CFRunLoopRun()