포스트

MyKVM (3) 맥에서 키보드와 마우스 입력을 가장 먼저 받는 이벤트 탭

이 시리즈는 내가 직접 쓴 개발기가 아니다. 무엇을 만들지 정해 Claude Code 에게 앱을 만들게 했고, 만든 과정과 쓰인 기술은 Claude Code 가 정리했다. 나는 방향을 정하고, 실기기에서 써 보며 고칠 곳을 찾아 다시 맡겼다. 그래도 글로 남기는 건 공부 때문이다. 아직 내가 아는 프레임워크가 몇 개 없어서, 이 앱에 쓰인 여러 프레임워크를 정리해 두고 나중에 하나씩 찾아보며 익히려고 한다.

커서가 윈도우 화면으로 넘어간 동안에는, 맥에서 누른 키와 움직인 마우스가 맥 앱으로 가면 안 되고 윈도우로 가야 한다. 그러려면 입력이 맥 앱에 닿기 전에 먼저 받아서 가로챌 수 있어야 한다. 이번 글은 그 일을 하는 이벤트 탭과, 실기기에서 커서 위치를 잘못 읽어 고생한 이야기다.


입력이 앱에 닿기까지

키를 누르면 신호는 키보드에서 WindowServer를 거쳐 앞에 떠 있는 앱으로 간다. WindowServer는 화면과 입력을 관리하는 macOS 프로그램이다. 커서를 움직이고, Command+Space 같은 시스템 단축키를 처리하고, 남은 입력을 앱에 나눠 준다.


이벤트 탭이란?

macOS 입력이 지나가는 길목에 다는 검문소다. 키 누름, 마우스 이동, 클릭 같은 이벤트가 하나 지날 때마다 내 함수가 불리고, 그 함수가 이벤트를 그대로 보낼지, 바꿔서 보낼지, 없앨지 정한다.

키보드와 마우스 입력은 MyKVM 이벤트 탭을 가장 먼저 지나고, 맥 화면이면 WindowServer와 앱으로 흘려보내고, 커서가 윈도우에 있으면 삼켜서 네트워크로 윈도우에 보낸다

검문소를 둘 수 있는 자리는 여러 곳인데, MyKVM은 하드웨어에서 막 들어온 자리(HID 단계)에 둔다. 거기 두면 WindowServer가 단축키를 처리하기 전에 받을 수 있다. 맥 손버릇대로 Control+Space로 윈도우의 한/영을 바꾸려면(4편), macOS가 그 조합으로 맥 입력 소스를 바꾸기 전에 가로채야 해서 이 자리가 필요했다.


다른 앱의 입력을 보려면 필요한 권한

남의 키 입력을 다 보는 일이라 macOS가 두 가지 권한을 따로 받는다. 손쉬운 사용(다른 앱을 조작하기)과 입력 모니터링(키보드 입력 보기)이다. 그리고 샌드박스 앱에서는 아예 쓸 수 없어서 MyKVM은 샌드박스를 껐다.

1
2
3
4
5
6
7
8
9
10
// Permissions.swift (맥 앱)
static var accessibility: Bool { AXIsProcessTrusted() }
static var inputMonitoring: Bool { CGPreflightListenEventAccess() }

static func request() {
    if !accessibility {
        _ = AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary)
    }
    if !inputMonitoring { _ = CGRequestListenEventAccess() }
}

AXIsProcessTrustedWithOptions와 CGRequestListenEventAccess로 권한 묻기

AXIsProcessTrustedWithOptions는 이 앱이 손쉬운 사용 권한을 받았는지 알려 준다. AXTrustedCheckOptionPrompt를 true로 주면, 권한이 없을 때 시스템이 설정으로 안내하는 창을 띄운다. 입력 모니터링은 CGPreflightListenEventAccess로 확인만 하고, CGRequestListenEventAccess로 요청한다. 권한은 사람이 설정에서 직접 켜야 해서, 앱은 2초마다 다시 확인하다가 켜지는 순간 이벤트 탭을 시작한다. 앱을 다시 켤 필요가 없다.


이벤트 탭 만들기

1
2
3
4
5
6
7
8
9
10
11
12
// EventTap.swift (맥 앱)
let mask = types.reduce(CGEventMask(0)) { $0 | (1 << CGEventMask($1.rawValue)) }
let context = Unmanaged.passUnretained(self).toOpaque()
guard let tap = CGEvent.tapCreate(tap: .cghidEventTap, place: .headInsertEventTap, options: .defaultTap,
                                  eventsOfInterest: mask, callback: { _, type, event, context in
    guard let context else { return Unmanaged.passUnretained(event) }
    let tap = Unmanaged<EventTap>.fromOpaque(context).takeUnretainedValue()
    return MainActor.assumeIsolated { tap.handle(type: type, event: event) }
}, userInfo: context) else { return false }
let source = CFMachPortCreateRunLoopSource(nil, tap, 0)
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
CGEvent.tapEnable(tap: tap, enable: true)

CGEvent.tapCreate로 키보드와 마우스 가로채기

  1. CGEvent.tapCreate가 검문소를 만든다. tap은 자리로, cghidEventTap이 하드웨어에서 막 들어온 자리다. place: .headInsertEventTap은 같은 자리의 다른 검문소보다 앞에 선다는 뜻이고, options: .defaultTap은 보기만 하지 않고 이벤트를 바꾸거나 없앨 수 있다는 뜻이다. eventsOfInterest는 받을 이벤트 종류를 비트로 모은 값이다. 권한이 없으면 nil을 돌려준다.
  2. callback은 이벤트마다 불리는 C 함수라서 바깥 변수를 붙잡을 수 없다. 그래서 userInfo에 이 객체의 주소를 넘기고, 콜백 안에서 Unmanaged로 다시 객체로 바꾼다. 콜백이 돌려준 이벤트가 그대로 다음으로 가고, nil을 돌려주면 그 이벤트는 사라진다.
  3. CFMachPortCreateRunLoopSource와 CFRunLoopAddSource로 검문소를 메인 런루프에 붙인다. 런루프는 앱이 할 일을 하나씩 꺼내 처리하는 반복이다. 그래서 콜백이 메인 스레드에서 불린다.
  4. CGEvent.tapEnable로 켠다.

콜백이 오래 걸리면 macOS가 검문소를 꺼 버린다. 그러면 키보드가 맥에서도 윈도우에서도 먹지 않는다. 그래서 꺼졌다는 이벤트(tapDisabledByTimeout)가 오면 바로 다시 켜고, 콜백 안에서는 계산만 한 뒤 네트워크로 보내는 일은 다른 큐에 맡긴다.


다른 앱이 만든 입력 구별하기

TouchDeck 버튼이 맥에서 볼륨 키를 누르는 것처럼, 프로그램이 만들어 넣은 입력도 이 검문소를 지난다. 이런 입력은 커서가 윈도우에 있어도 맥에서 동작해야 한다.

1
2
3
4
5
// EventTap.swift (맥 앱)
if event.getIntegerValueField(.eventSourceUnixProcessID) != 0 {
    guard type == .keyDown || type == .keyUp else { return pass }
    return hotkey(type: type, keyCode: keyCode, flags: flags) ? nil : pass
}

eventSourceUnixProcessID는 그 이벤트를 만든 프로그램 번호다. 실제 키보드와 마우스에서 온 입력은 0이다. 0이 아니면 스왑 단축키인지만 보고 그대로 맥에 흘려보낸다. TouchDeck 버튼에 스왑 단축키를 걸어 두면 버튼으로도 스왑할 수 있는 이유다.


커서를 숨기고 세워 두기

윈도우를 조작하는 동안 맥 커서는 보이면 안 된다. 커서는 CGDisplayHideCursor로 숨기고, 맥이 보는 가장 큰 화면(LG)의 가운데로 옮겨 세워 둔다. 원래 이 함수는 앞에 떠 있는 앱만 쓸 수 있어서, 메뉴 막대 앱도 쓸 수 있게 하는 비공개 설정을 먼저 켠다. 실패해도 숨김만 안 될 뿐 나머지는 동작한다.

세워 둔 뒤로는 커서가 움직이지 않는다. 이벤트 탭이 마우스 이벤트를 삼키니 WindowServer가 커서를 옮길 일이 없다. 윈도우로 보낼 움직인 양은 이벤트에 담긴 값(mouseEventDeltaX, mouseEventDeltaY)을 쓴다.


이벤트에 붙은 위치가 실제 커서가 아닌 함정

여기서 실기기 시험 때 크게 헤맸다. HID 단계 이벤트에도 위치(event.location)가 붙어 오는데, 이 값은 화면의 실제 커서 위치가 아니다. 하드웨어 쪽에서 따로 누적한 값이라, 커서를 순간 이동시켜도 반영되지 않고 마우스를 움직이는 대로 혼자 흘러간다.

이 값을 믿고 계산했더니 이상한 일이 잇달았다.

증상그때 믿은 것
맥으로 돌아오자마자 0.005초 만에 다시 윈도우로 튕김숨은 커서가 따라 움직여 맥북 끝에 붙어 있다
커서를 되돌려도 다음 위치가 그대로 이어짐순간 이동이 안 먹는다
윈도우 커서가 미친 듯이 튐되돌린 직후 위치 차이를 움직인 양으로 썼다

macOS에 직접 물어보니 실제 커서는 세워 둔 자리(1720, 720)에 그대로 있었다. 세 증상 모두 엉뚱한 위치값에 속은 결과였다. 그래서 위치는 이벤트에 붙은 값 대신 macOS에 지금 커서 위치를 물어서 쓴다.

1
2
3
4
5
6
7
8
9
// EventTap.swift (맥 앱)
let here = CGEvent(source: nil).map { Point(x: $0.location.x, y: $0.location.y) } ?? Point(x: event.location.x, y: event.location.y)
var delta = Vector(dx: Double(event.getIntegerValueField(.mouseEventDeltaX)),
                   dy: Double(event.getIntegerValueField(.mouseEventDeltaY)))
if CursorControl.warpCount != seenWarpCount {
    // 워프 직후 첫 이벤트의 움직인 값에는 옛 자리까지의 거리가 섞여 온다. 버린다.
    seenWarpCount = CursorControl.warpCount
    delta = Vector(dx: 0, dy: 0)
}

CGEvent(source:)와 CGWarpMouseCursorPosition으로 실제 커서 다루기

  1. CGEvent(source:)에 nil을 주면 빈 이벤트가 하나 만들어지는데, 그 location이 지금 화면의 커서 위치다. 이벤트 탭 안에서 부르면 이번 이벤트가 반영되기 전 자리가 나온다. 맥 모드에서는 이 자리에 움직인 양을 더한 곳이 윈도우 화면이면 넘어간다.
  2. CGWarpMouseCursorPosition은 커서를 맥 전역 좌표의 한 점으로 순간 이동(워프)시킨다. 윈도우로 넘어갈 때 커서를 세워 두는 데, 맥으로 돌아올 때 넘어온 자리에 놓는 데 쓴다. 이동 직후 0.25초 동안 실제 마우스 입력을 무시하는 성질이 있어서, 바로 뒤에 CGAssociateMouseAndMouseCursorPosition(1)을 불러 이 멈춤을 푼다. 커서가 마우스를 따라 움직이게 하는 함수인데, 이렇게 부르면 멈춤도 같이 풀린다.
  3. 워프 직후 첫 이벤트의 움직인 값에는 옛 자리까지의 거리가 섞여 온다. 기록에는 (1480, -605) 같은 큰 값이 찍혀 있었다. 그래서 워프할 때마다 횟수(warpCount)를 늘리고, 그다음 첫 이벤트의 움직인 값은 버린다.

윈도우를 조작하는 동안 맥 지키기

윈도우를 조작하는 동안 맥은 마우스 입력을 전혀 받지 않으니, 가만두면 “아무도 안 쓴다”고 보고 잠들 수 있다. 맥이 잠들면 연결이 끊기고 윈도우 조작도 멈춘다.

1
2
3
4
// SleepGuard.swift (맥 앱)
active = IOPMAssertionCreateWithName(kIOPMAssertionTypePreventUserIdleSystemSleep as CFString,
                                     IOPMAssertionLevel(kIOPMAssertionLevelOn),
                                     "MyKVM: 윈도우 조작 중" as CFString, &assertion) == kIOReturnSuccess

IOPMAssertionCreateWithName은 “지금은 잠들지 마”라는 표시를 시스템에 건다. 종류는 사용자가 쉬는 동안의 시스템 잠자기를 막는 것으로 골랐고, 이름은 활동 모니터 같은 곳에 보일 이유다. 맥으로 돌아오면 IOPMAssertionRelease로 풀어 준다.

맥에서 비밀번호 칸이 선택돼 있으면 macOS는 보안 입력이라는 상태가 되어, 키보드 입력이 어떤 이벤트 탭에도 가지 않는다. 그러면 커서는 윈도우에 있는데 키만 안 가는 일이 생긴다. 1초마다 IsSecureEventInputEnabled()(문서가 따로 없는 오래된 Carbon 함수)로 확인해서, 켜져 있으면 메뉴 막대에 경고를 띄운다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.