README 및 install.sh, razer-tilt.swift 파일 업데이트: 권한 설정 및 자동 재시작 로직 개선

This commit is contained in:
jsahn
2026-09-28 01:30:25 +09:00
parent a6bdd85385
commit f29845d981
3 changed files with 63 additions and 13 deletions
+4 -2
View File
@@ -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`는 설치할 때마다 기존 입력 모니터링·손쉬운 사용 등록을 자동으로 지웁니다. 빌드할 때마다 서명이 바뀌어 이전 허용이 더 이상 적용되지 않기 때문입니다. 설치 후에는 두 권한을 다시 허용합니다.
권한 상태와 장치 감지는 아래 로그에서 확인합니다. 실제 동작은 브라우저에서 페이지를 이동한 다음 휠 좌우 틸트로 확인합니다. 권한 상태와 장치 감지는 아래 로그에서 확인합니다. 실제 동작은 브라우저에서 페이지를 이동한 다음 휠 좌우 틸트로 확인합니다.
+9
View File
@@ -14,7 +14,13 @@ fi
if /bin/launchctl print "$service" >/dev/null 2>&1; then if /bin/launchctl print "$service" >/dev/null 2>&1; then
/bin/launchctl bootout "$service" /bin/launchctl bootout "$service"
fi fi
/bin/rm -rf -- "$app_dir"
/usr/bin/ditto "$project_dir/.build/Razer Tilt Mapper.app" "$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. # Use plutil so spaces or XML characters in the home path are encoded safely.
/usr/bin/plutil -create xml1 "$agent_file" /usr/bin/plutil -create xml1 "$agent_file"
@@ -33,3 +39,6 @@ fi
/bin/launchctl bootstrap "gui/$(/usr/bin/id -u)" "$agent_file" /bin/launchctl bootstrap "gui/$(/usr/bin/id -u)" "$agent_file"
/bin/launchctl print "$service" /bin/launchctl print "$service"
print "Installed: $app_dir" 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"
+50 -11
View File
@@ -9,8 +9,15 @@ func log(_ message: String) {
fflush(stdout) fflush(stdout)
} }
struct ReportSource: Hashable {
let device: Int
let reportID: UInt32
}
final class State { final class State {
var buffers: [IOHIDDevice: UnsafeMutablePointer<UInt8>] = [:] var buffers: [IOHIDDevice: UnsafeMutablePointer<UInt8>] = [:]
var lastBits: [ReportSource: UInt8] = [:]
var lastFired: [UInt8: TimeInterval] = [:]
var opened = false var opened = false
var lastStatus = "" var lastStatus = ""
} }
@@ -30,16 +37,25 @@ func sendCommandKey(_ keyCode: CGKeyCode) {
up.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. // 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 = { let reportCallback: IOHIDReportCallback = {
_, result, _, _, _, report, reportLength in context, result, sender, _, reportID, report, reportLength in
guard result == kIOReturnSuccess, reportLength >= 1 else { return } guard result == kIOReturnSuccess, reportLength >= 1, let context = context else { return }
let value = report[0] let state = Unmanaged<State>.fromOpaque(context).takeUnretainedValue()
if value & 0x20 != 0 { let source = ReportSource(device: Int(bitPattern: sender), reportID: reportID)
sendCommandKey(33) let value = report[0] & tiltMask
} let pressed = value & ~(state.lastBits[source] ?? 0)
if value & 0x40 != 0 { state.lastBits[source] = value
sendCommandKey(30) 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 let removalCallback: IOHIDDeviceCallback = { context, _, _, device in
guard let context = context else { return } guard let context = context else { return }
let state = Unmanaged<State>.fromOpaque(context).takeUnretainedValue() let state = Unmanaged<State>.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) { if let buffer = state.buffers.removeValue(forKey: device) {
IOHIDDeviceRegisterInputReportCallback(device, buffer, 0, nil, nil) IOHIDDeviceRegisterInputReportCallback(device, buffer, 0, nil, nil)
buffer.deallocate() buffer.deallocate()
@@ -77,14 +95,22 @@ IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetCurrent(), CFRunLoopMode.de
log("Razer Tilt Mapper started. LEFT -> Command+[; RIGHT -> Command+]") log("Razer Tilt Mapper started. LEFT -> Command+[; RIGHT -> Command+]")
// macOS presents its own permission dialogs. The user grants access in Settings. // 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] let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true]
_ = AXIsProcessTrustedWithOptions(options as CFDictionary) _ = AXIsProcessTrustedWithOptions(options as CFDictionary)
UserDefaults.standard.set(buildStamp, forKey: "promptedBuild")
} }
if IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) == kIOHIDAccessTypeUnknown { if IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) == kIOHIDAccessTypeUnknown {
_ = IOHIDRequestAccess(kIOHIDRequestTypeListenEvent) _ = IOHIDRequestAccess(kIOHIDRequestTypeListenEvent)
} }
func accessGranted() -> Bool {
IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) == kIOHIDAccessTypeGranted && CGPreflightPostEventAccess()
}
func updateAccess() { func updateAccess() {
let inputAllowed = IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) == kIOHIDAccessTypeGranted let inputAllowed = IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) == kIOHIDAccessTypeGranted
let outputAllowed = CGPreflightPostEventAccess() let outputAllowed = CGPreflightPostEventAccess()
@@ -102,6 +128,19 @@ func updateAccess() {
} }
updateAccess() updateAccess()
// Recheck after the initial permission grant without requiring a manual restart. // A running process may not see a new grant, so exit while access is missing and let
let accessTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { _ in updateAccess() } // 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() CFRunLoopRun()