본문 바로가기
C.W.K.
Stream
Lesson 05 of 06 · published

전역 단축키: 조합 키, 수정 키 하나, 이름 없는 키

~17 min · macos-citizen, hotkeys, carbon, accessibility, keycodes

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"녹화는 되는데 이름은 못 붙이는 조합이었어."

내 것도 아닌 키를 듣는 방법 셋

다른 앱이 맨 앞에 있을 때도 단축키에 반응해야 하는 유틸리티가 쓸 수 있는 도구는 세 가지고, 저마다 치르는 대가가 달라.

  • Carbon RegisterEventHotKey는 조합(키 하나에 수정 키들)을 시스템 전체에 등록하고 앱에 단축키 이벤트를 보내줘. 손쉬운 사용 권한이 필요 없고, 다른 키 입력은 못 봐. 가족 텍스트 변환기, 받아쓰기 도구, 런처들이 아직 이걸 써. API는 낡았지만 이 일을 대신할 더 새로운 API가 없어.
  • NSEvent.addGlobalMonitorForEvents는 다른 앱으로 가는 이벤트를 지켜봐. 키 관련 이벤트는 앱이 손쉬운 사용 권한을 받았을 때만 오고, 모니터는 보기만 하지 가로채진 못해.
  • CGEventTap은 이벤트를 듣기만 할 수도 있고, 능동 필터로서 바꾸거나 삼킬 수도 있어. 듣기만 하려면 입력 모니터링, 능동 탭은 손쉬운 사용 권한이 필요하고, 엉뚱한 이벤트를 삼키는 탭은 그 키를 Mac의 모든 앱에서 먹어버려.

등록은 조합마다 실패할 수 있어

RegisterEventHotKey는 이미 등록된 조합이면 eventHotKeyExistsErr(-9878)를 돌려줘. 한 프로세스에서 ⌘⌥K를 두 번 등록해서 재봤어. 첫 번째는 성공, 두 번째는 -9878. 텍스트 변환기는 켜진 매크로를 하나씩 따로 등록하고 실패를 바인딩마다 설정 창에 돌려줘. 그래서 하나가 겹쳐도 나머지는 살아 있고, 앱 안에서 서로 겹친 조합은 Carbon까지 가기 전에 이름을 붙여 보고해.

펑션 키는 범위가 아니야

가상 키 코드는 키보드 위의 물리적 위치지 순서가 아니야. kVK_F10x7AkVK_F120x6F야. 그래서 UInt16(kVK_F1)...UInt16(kVK_F12)는 아래 경계가 위 경계보다 커. 이걸로 만든 단축키 녹화기는 아무 키나 누르는 순간 "Range requires lowerBound <= upperBound"로 죽었어. 상수를 집합으로 하나하나 적어. 녹화기는 NSEvent에 묶여 있어서 테스트가 없었어. 그래서 그 검사는 셀프 테스트가 붙은 순수 도우미 함수로 옮겼어.

앱이 이름 못 붙이는 키는 받지도 마

앱 세 개가 같은 키 코드와 기호 표를 각자 복사해서 들고 있었어. 한 녹화기는 Escape, Caps Lock, Fn만 빼고 아무 키나 받았는데, 표엔 Home, End, Page Up과 Down, F13부터 F20, 키패드가 없었어. 그래서 ⌘⌥Home은 녹화되고, 저장되고, 등록되고, 단축키도 잘 됐는데 설정엔 "⌘⌥Key 115"로 보였어. 이제 공유 키트 타입은 "이 키에 이름을 붙일 수 있나"를 "이 앱이 이 키를 받나"와 따로 대답해. 키 값은 완전한 표를 거쳐야만 만들 수 있으니까 이름 없는 표시는 생길 수가 없어. 앱별 제외 목록은 그 위에 각자 얹어.

수정 키 하나로 누르고 말하기

오른쪽 ⌘나 Fn을 누르고 있는 동안 받아쓰기하는 것처럼, 수정 키만으로 된 조합은 Carbon으로 표현할 수 없어. 받아쓰기 도구는 이미 받은 손쉬운 사용 권한으로 flagsChanged를 지켜봐. 다른 앱용으로 전역 모니터, 자기 창용으로 로컬 모니터를 둬(전역 모니터는 자기 앱으로 오는 이벤트를 절대 안 봐). 눌렸는지 떼졌는지는 CGEventSource.keyState로 정해. 반대쪽 ⌘ 키가 아직 눌려 있으면 수정 키 플래그가 계속 켜져 있거든. Fn은 지구본 키도 겸하니까, 사용자는 지구본 키 동작을 아무것도 안 함으로 바꿔둬.

Code

실패를 하나씩 보고하는 Carbon 조합 등록, 그리고 집합으로 적은 펑션 키·swift
import AppKit
import Carbon.HIToolbox

/// kVK_F1 is 0x7A and kVK_F12 is 0x6F: the function keys are not a range.
let functionKeyCodes: Set<UInt16> = Set([
    kVK_F1, kVK_F2, kVK_F3, kVK_F4, kVK_F5, kVK_F6, kVK_F7, kVK_F8, kVK_F9, kVK_F10,
    kVK_F11, kVK_F12, kVK_F13, kVK_F14, kVK_F15, kVK_F16, kVK_F17, kVK_F18, kVK_F19, kVK_F20,
].map { UInt16($0) })

func carbonModifiers(from flags: NSEvent.ModifierFlags) -> UInt32 {
    var mask: UInt32 = 0
    if flags.contains(.command) { mask |= UInt32(cmdKey) }
    if flags.contains(.option) { mask |= UInt32(optionKey) }
    if flags.contains(.control) { mask |= UInt32(controlKey) }
    if flags.contains(.shift) { mask |= UInt32(shiftKey) }
    return mask
}

@MainActor
final class HotkeyCenter {
    private var handler: EventHandlerRef?
    private var refs: [UInt32: EventHotKeyRef] = [:]
    private var actions: [UInt32: () -> Void] = [:]
    private var nextID: UInt32 = 1

    /// Returns nil on success, or a message naming the chord that could not be registered.
    func register(keyCode: UInt16, modifiers: NSEvent.ModifierFlags, label: String,
                  action: @escaping () -> Void) -> String? {
        if handler == nil {
            var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
            InstallEventHandler(GetApplicationEventTarget(), { _, event, userData in
                var hotKeyID = EventHotKeyID()
                GetEventParameter(event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID),
                                  nil, MemoryLayout<EventHotKeyID>.size, nil, &hotKeyID)
                let center = Unmanaged<HotkeyCenter>.fromOpaque(userData!).takeUnretainedValue()
                let id = hotKeyID.id
                Task { @MainActor in center.actions[id]?() }
                return noErr
            }, 1, &spec, Unmanaged.passUnretained(self).toOpaque(), &handler)
        }
        var ref: EventHotKeyRef?
        let id = EventHotKeyID(signature: OSType(0x5350524B), id: nextID)   // 'SPRK'
        let status = RegisterEventHotKey(UInt32(keyCode), carbonModifiers(from: modifiers), id,
                                         GetApplicationEventTarget(), 0, &ref)
        guard status == noErr, let ref else { return "\(label) is already taken (\(status))" }
        refs[nextID] = ref
        actions[nextID] = action
        nextID += 1
        return nil
    }
}
오른쪽 Command로 누르고 말하기: flagsChanged를 보고, 키한테 직접 묻기·swift
import AppKit
import Carbon.HIToolbox

@MainActor
final class HoldToTalk {
    private var monitors: [Any] = []
    private let keyCode = CGKeyCode(kVK_RightCommand)
    private var held = false

    func start(onChange: @escaping @MainActor (Bool) -> Void) {
        let handle: (NSEvent) -> Void = { [weak self] event in
            guard let self, event.keyCode == UInt16(self.keyCode) else { return }
            // The flag alone lies while the LEFT Command key is also down. Ask the key itself.
            let down = CGEventSource.keyState(.combinedSessionState, key: self.keyCode)
            if down != self.held { self.held = down; onChange(down) }
        }
        if let global = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged, handler: { event in
            MainActor.assumeIsolated { handle(event) }
        }) { monitors.append(global) }
        if let local = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged, handler: { event in
            handle(event); return event
        }) { monitors.append(local) }
    }
}

External links

Exercise

Spark Mac 앱에 HotkeyCenter를 넣고 ⌘⌥K를 눌렀을 때 한 줄을 찍게 등록해. 같은 조합을 한 번 더 등록해서 돌아온 메시지를 로그에 보여줘. 이번엔 녹화기 검사 isAcceptableChordKey(_ keyCode: UInt16) -> Bool을 써. 글자, 숫자, F1부터 F20은 받고 Escape는 거절하게. 셀프 테스트 실행 파일에서 증명하고, 범위로 만들었으면 죽었을 경우도 넣어.
Hint
셀프 테스트는 키를 못 누르는데, 누를 필요도 없어. 이 검사는 UInt16 하나만 받는 순수 함수니까. kVK_F5, kVK_F12, kVK_F20은 코드가 흩어져 있으니 따로따로 확인해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.