Files
Razer_Tilt_Mapper/razer-tilt.swift
T
2026-09-28 00:58:54 +09:00

108 lines
4.4 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)
}
final class State {
var buffers: [IOHIDDevice: UnsafeMutablePointer<UInt8>] = [:]
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<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()
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()