diff --git a/README.md b/README.md index db2c65f..7983b7a 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,11 @@ Mac에 로그인할 때 실행되며, 종료되면 launchd가 다시 실행합 ## 최초 권한 설정 -시스템 설정 → 개인정보 보호 및 보안에서 **Razer Tilt Mapper**의 **입력 모니터링**, **손쉬운 사용**을 허용합니다. macOS가 종료 및 다시 열기를 요청하면 허용합니다. 자동 실행 서비스가 앱을 다시 실행합니다. +시스템 설정 → 개인정보 보호 및 보안에서 **Razer Tilt Mapper**의 **입력 모니터링**, **손쉬운 사용**을 허용합니다. macOS가 종료 및 다시 열기를 요청하면 허용합니다. 자동 실행 서비스가 앱을 다시 실행합니다. 권한이 없는 동안 앱은 약 30초마다 스스로 재시작하며 권한을 다시 확인하므로, 허용한 뒤 직접 재시작할 필요가 없습니다. -목록에 없으면 `+` 버튼으로 설치된 앱을 추가합니다. 파일 선택 창에서 `Command+Shift+G`를 눌러 위 앱 경로를 입력할 수 있습니다. +`install.sh`가 끝나면 Finder에서 설치된 앱이 선택된 채로 열리고, 입력 모니터링 설정 화면도 함께 열립니다. 목록에 없으면 Finder에 선택된 **Razer Tilt Mapper**를 목록으로 끌어다 놓습니다. `+` 버튼을 쓸 때는 파일 선택 창에서 `Command+Shift+G`를 누르고 위 앱 경로를 입력합니다. 손쉬운 사용도 같은 방법으로 추가합니다. + +`install.sh`는 설치할 때마다 기존 입력 모니터링·손쉬운 사용 등록을 자동으로 지웁니다. 빌드할 때마다 서명이 바뀌어 이전 허용이 더 이상 적용되지 않기 때문입니다. 설치 후에는 두 권한을 다시 허용합니다. 권한 상태와 장치 감지는 아래 로그에서 확인합니다. 실제 동작은 브라우저에서 페이지를 이동한 다음 휠 좌우 틸트로 확인합니다. diff --git a/install.sh b/install.sh index e070f73..a1de5e5 100644 --- a/install.sh +++ b/install.sh @@ -14,7 +14,13 @@ fi if /bin/launchctl print "$service" >/dev/null 2>&1; then /bin/launchctl bootout "$service" fi +/bin/rm -rf -- "$app_dir" /usr/bin/ditto "$project_dir/.build/Razer Tilt Mapper.app" "$app_dir" +# tccutil resolves the bundle ID through Launch Services, so register the new copy first. +/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "$app_dir" +# Each ad-hoc build gets a new signature, so old grants go stale; clear them so re-adding is clean. +/usr/bin/tccutil reset Accessibility local.razer-tilt 2>/dev/null || true +/usr/bin/tccutil reset ListenEvent local.razer-tilt 2>/dev/null || true # Use plutil so spaces or XML characters in the home path are encoded safely. /usr/bin/plutil -create xml1 "$agent_file" @@ -33,3 +39,6 @@ fi /bin/launchctl bootstrap "gui/$(/usr/bin/id -u)" "$agent_file" /bin/launchctl print "$service" print "Installed: $app_dir" +# Reveal the app so it can be dragged into Input Monitoring / Accessibility. +/usr/bin/open -R "$app_dir" +/usr/bin/open "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent" diff --git a/razer-tilt.swift b/razer-tilt.swift index bcf4eca..213ede9 100644 --- a/razer-tilt.swift +++ b/razer-tilt.swift @@ -9,8 +9,15 @@ func log(_ message: String) { 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 = "" } @@ -30,16 +37,25 @@ func sendCommandKey(_ keyCode: CGKeyCode) { 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 = { - _, 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) + 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) } } @@ -60,6 +76,8 @@ let deviceCallback: IOHIDDeviceCallback = { context, result, _, device in 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() @@ -77,14 +95,22 @@ IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetCurrent(), CFRunLoopMode.de log("Razer Tilt Mapper started. LEFT -> Command+[; RIGHT -> Command+]") // macOS presents its own permission dialogs. The user grants access in Settings. -if !AXIsProcessTrusted() { +// 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() @@ -102,6 +128,19 @@ func updateAccess() { } updateAccess() -// Recheck after the initial permission grant without requiring a manual restart. -let accessTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { _ in 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()