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] = [:] var lastBits: [ReportSource: UInt8] = [:] var lastFired: [UInt8: 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) } let tiltMask: UInt8 = 0x60 // 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.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 [(UInt8(0x20), CGKeyCode(33)), (UInt8(0x40), CGKeyCode(30))] where pressed & mask != 0 { if let last = state.lastFired[mask], now - last < debounceInterval { continue } state.lastFired[mask] = now sendCommandKey(keyCode) } } 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() 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) } } }() CFRunLoopRun()