first commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
.build/
|
||||||
|
.DS_Store
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleIdentifier</key><string>local.razer-tilt</string>
|
||||||
|
<key>CFBundleName</key><string>Razer Tilt Mapper</string>
|
||||||
|
<key>CFBundleDisplayName</key><string>Razer Tilt Mapper</string>
|
||||||
|
<key>CFBundleExecutable</key><string>RazerTiltMapper</string>
|
||||||
|
<key>CFBundlePackageType</key><string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key><string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key><string>1</string>
|
||||||
|
<key>LSMinimumSystemVersion</key><string>13.0</string>
|
||||||
|
<key>LSUIElement</key><true/>
|
||||||
|
<key>NSInputMonitoringUsageDescription</key>
|
||||||
|
<string>Read Razer mouse wheel tilt to send Command+[ and Command+].</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
+35
@@ -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"
|
||||||
@@ -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<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()
|
||||||
Reference in New Issue
Block a user