commit a6bdd85385048e4e10daa78c2e6d2508fd262698 Author: jsahn Date: Mon Sep 28 00:58:54 2026 +0900 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e94f7ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.build/ +.DS_Store diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..47e71d0 --- /dev/null +++ b/Info.plist @@ -0,0 +1,17 @@ + + + + + CFBundleIdentifierlocal.razer-tilt + CFBundleNameRazer Tilt Mapper + CFBundleDisplayNameRazer Tilt Mapper + CFBundleExecutableRazerTiltMapper + CFBundlePackageTypeAPPL + CFBundleShortVersionString1.0 + CFBundleVersion1 + LSMinimumSystemVersion13.0 + LSUIElement + NSInputMonitoringUsageDescription + Read Razer mouse wheel tilt to send Command+[ and Command+]. + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..db2c65f --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# Razer Tilt Mapper + +Razer Pro Click V2 (vendor 5426, product 209)의 휠 좌우 틸트를 각각 `Command+[` / `Command+]`로 보냅니다. 제공한 HID 보고서의 첫 바이트와 비트 마스크를 그대로 사용합니다. + +## 설치 / 업데이트 + +```sh +cd /Users/jsahn/Personal_works/Razer_Tilt_Mapper +zsh build.sh +zsh install.sh +``` + +설치된 앱: `~/Library/Application Support/RazerTilt/Razer Tilt Mapper.app` + +자동 실행 설정: `~/Library/LaunchAgents/local.razer-tilt.plist` + +Mac에 로그인할 때 실행되며, 종료되면 launchd가 다시 실행합니다. 마우스를 나중에 연결해도 감지합니다. `/tmp`나 Swift 인터프리터에 의존하지 않습니다. + +## 최초 권한 설정 + +시스템 설정 → 개인정보 보호 및 보안에서 **Razer Tilt Mapper**의 **입력 모니터링**, **손쉬운 사용**을 허용합니다. macOS가 종료 및 다시 열기를 요청하면 허용합니다. 자동 실행 서비스가 앱을 다시 실행합니다. + +목록에 없으면 `+` 버튼으로 설치된 앱을 추가합니다. 파일 선택 창에서 `Command+Shift+G`를 눌러 위 앱 경로를 입력할 수 있습니다. + +권한 상태와 장치 감지는 아래 로그에서 확인합니다. 실제 동작은 브라우저에서 페이지를 이동한 다음 휠 좌우 틸트로 확인합니다. + +```sh +tail -n 30 ~/Library/Logs/RazerTiltMapper.log +launchctl print gui/$(id -u)/local.razer-tilt +``` + +## 자동 실행 끄기 / 다시 켜기 + +끄기: + +```sh +launchctl disable gui/$(id -u)/local.razer-tilt +launchctl bootout gui/$(id -u)/local.razer-tilt +``` + +다시 켜기: + +```sh +launchctl enable gui/$(id -u)/local.razer-tilt +launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/local.razer-tilt.plist +``` diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..7c42c25 --- /dev/null +++ b/build.sh @@ -0,0 +1,15 @@ +#!/bin/zsh +set -euo pipefail +project_dir="${0:A:h}" +app_dir="$project_dir/.build/Razer Tilt Mapper.app" +mkdir -p "$app_dir/Contents/MacOS" +# Swift's precompiled modules embed absolute paths; do not reuse them after a move. +module_cache=$(/usr/bin/mktemp -d "$project_dir/.build/module-cache.XXXXXX") +trap '/bin/rm -rf -- "$module_cache"' EXIT +/usr/bin/swiftc -O -module-cache-path "$module_cache" \ + "$project_dir/razer-tilt.swift" -o "$app_dir/Contents/MacOS/RazerTiltMapper" +/bin/cp "$project_dir/Info.plist" "$app_dir/Contents/Info.plist" +/usr/bin/plutil -lint "$app_dir/Contents/Info.plist" +/usr/bin/codesign --force --sign - --identifier local.razer-tilt "$app_dir" +/usr/bin/codesign --verify --strict "$app_dir" +print "Built: $app_dir" diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..e070f73 --- /dev/null +++ b/install.sh @@ -0,0 +1,35 @@ +#!/bin/zsh +set -euo pipefail +project_dir="${0:A:h}" +app_dir="$HOME/Library/Application Support/RazerTilt/Razer Tilt Mapper.app" +agent_file="$HOME/Library/LaunchAgents/local.razer-tilt.plist" +service="gui/$(/usr/bin/id -u)/local.razer-tilt" + +if [[ ! -x "$project_dir/.build/Razer Tilt Mapper.app/Contents/MacOS/RazerTiltMapper" ]]; then + /bin/zsh "$project_dir/build.sh" +fi +/usr/bin/codesign --verify --strict "$project_dir/.build/Razer Tilt Mapper.app" + +/bin/mkdir -p "${app_dir:h}" "${agent_file:h}" "$HOME/Library/Logs" +if /bin/launchctl print "$service" >/dev/null 2>&1; then + /bin/launchctl bootout "$service" +fi +/usr/bin/ditto "$project_dir/.build/Razer Tilt Mapper.app" "$app_dir" + +# Use plutil so spaces or XML characters in the home path are encoded safely. +/usr/bin/plutil -create xml1 "$agent_file" +/usr/bin/plutil -insert Label -string local.razer-tilt "$agent_file" +/usr/bin/plutil -insert ProgramArguments -json '[]' "$agent_file" +/usr/bin/plutil -insert ProgramArguments.0 -string "$app_dir/Contents/MacOS/RazerTiltMapper" "$agent_file" +/usr/bin/plutil -insert RunAtLoad -bool true "$agent_file" +/usr/bin/plutil -insert KeepAlive -bool true "$agent_file" +/usr/bin/plutil -insert ThrottleInterval -integer 10 "$agent_file" +/usr/bin/plutil -insert ProcessType -string Interactive "$agent_file" +/usr/bin/plutil -insert StandardOutPath -string "$HOME/Library/Logs/RazerTiltMapper.log" "$agent_file" +/usr/bin/plutil -insert StandardErrorPath -string "$HOME/Library/Logs/RazerTiltMapper.log" "$agent_file" +/bin/chmod 644 "$agent_file" +/usr/bin/plutil -lint "$agent_file" +/bin/launchctl enable "$service" +/bin/launchctl bootstrap "gui/$(/usr/bin/id -u)" "$agent_file" +/bin/launchctl print "$service" +print "Installed: $app_dir" diff --git a/razer-tilt.swift b/razer-tilt.swift new file mode 100644 index 0000000..bcf4eca --- /dev/null +++ b/razer-tilt.swift @@ -0,0 +1,107 @@ +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()