MyKVM (12) 반대 방향, CGEvent로 맥에 입력 넣기와 더블클릭, fn 표시
이 시리즈는 내가 직접 쓴 개발기가 아니다. 무엇을 만들지 정해 Claude Code 에게 앱을 만들게 했고, 만든 과정과 쓰인 기술은 Claude Code 가 정리했다. 나는 방향을 정하고, 실기기에서 써 보며 고칠 곳을 찾아 다시 맡겼다. 그래도 글로 남기는 건 공부 때문이다. 아직 내가 아는 프레임워크가 몇 개 없어서, 이 앱에 쓰인 여러 프레임워크를 정리해 두고 나중에 하나씩 찾아보며 익히려고 한다.
11편에서 윈도우가 자기 키보드와 마우스를 가로채 맥으로 보냈다. 이제 맥이 받은 거리, 버튼, 키를 실제 입력으로 만들어 넣을 차례다. 넣는 함수 자체는 간단한데, 실제로 써 보니 더블클릭이 안 되고, 우측 Shift가 안 먹고, Control+↑ 같은 단축키가 안 되는 일이 차례로 나왔다. 이번 글은 그 원인들까지 정리한다.
맥이 계산하는 맥 커서 위치
윈도우가 보내는 건 “주 화면 가운데에서 얼마나 움직였나”(rmove)뿐이다. 맥 커서가 지금 어디 있는지는 맥이 따로 기억하고(remoteCursor), 받은 거리만큼 옮긴 다음 그 자리에 마우스 이동을 넣는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// KVMController.swift (KVMCore)
case let .move(dx, dy):
guard remoteActive else { return [] }
let next = layout.clampMove(from: remoteCursor, by: Vector(dx: Double(dx), dy: Double(dy)))
if layout.usableSlot(at: next)?.owner == .windows {
// 끌기 중에는 윈도우로 넘어가지 않는다(끌던 것이 경계에 떨어지지 않게). 1차와 같은 규칙.
guard remoteButtons.isEmpty else { return [] }
let target = layout.toWindows(next)
return stopRemote() + [.send(.leaveMac(x: target?.x ?? -1, y: target?.y ?? -1))]
}
guard next != remoteCursor else { return [] }
remoteCursor = next
return [.postMouseMove(next, dragging: !remoteButtons.isEmpty)]
옮긴 자리는 2편의 clampMove로 화면 밖으로 나가지 않게 자른다. 옮긴 자리가 윈도우 화면 칸이면 맥 조작을 끝내고, 그 점을 윈도우 좌표로 바꿔 leaveMac으로 돌려준다. 윈도우는 그 자리에 커서를 놓고 입력을 되찾는다. 버튼을 누른 채면 넘어가지 않는 것은 맥 키보드로 윈도우를 쓸 때의 규칙과 같다.
조작을 끝낼 때(stopRemote)는 맥에 눌러 둔 버튼과 키를 전부 뗀다. 맥 앱을 그 상태로 종료해도 먼저 뗀 다음 윈도우에 leaveMac을 보낸다. 맥에 Command가 눌린 채 남으면 그 뒤로 맥 키보드로 치는 글자가 전부 단축키가 되기 때문이다.
이 판단은 화면 배치와 함께 KVMCore 패키지 안에 있어서, 실제 입력을 넣지 않고 맥에서 테스트했다. 판단 결과(postMouseMove 같은 할 일 목록)를 실제 입력으로 바꾸는 것만 앱 쪽 RemoteInjector가 한다.
CGEvent로 입력 만들어 넣기
3편의 이벤트 탭이 입력을 “가로채는” 쪽이었다면, 이번에는 같은 CGEvent로 입력을 “만들어 넣는” 쪽이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// RemoteInjector.swift (MyKVMMac)
private static let source = CGEventSource(stateID: .hidSystemState)
case let .postMouseMove(point, dragging):
let event = CGEvent(mouseEventSource: source, mouseType: dragging ? .leftMouseDragged : .mouseMoved,
mouseCursorPosition: CGPoint(x: point.x, y: point.y), mouseButton: .left)
event?.flags = CGEventFlags(rawValue: flags)
event?.post(tap: .cghidEventTap)
case let .postKey(code, down, keyFlags):
flags = keyFlags
let event = CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: down)
event?.flags = CGEventFlags(rawValue: keyFlags)
event?.post(tap: .cghidEventTap)
CGEvent 만들기와 post(tap:)
| 이름 | 하는 일 |
|---|---|
CGEventSource(stateID: .hidSystemState) | 넣을 입력의 출처. 진짜 장치 입력과 같은 상태(눌린 키 등)를 함께 쓰는 출처다 |
CGEvent(mouseEventSource:mouseType:mouseCursorPosition:mouseButton:) | 마우스 이벤트. 버튼을 누른 채 움직이면 .mouseMoved 대신 .leftMouseDragged로 만들어야 창이나 글자 선택이 끌린다 |
CGEvent(keyboardEventSource:virtualKey:keyDown:) | 키 이벤트. 4편의 맥 가상 키코드로 만든다 |
CGEvent(scrollWheelEvent2Source:...) | 스크롤 이벤트. wheel1이 세로, wheel2가 가로다 |
flags | 이 입력과 함께 눌려 있는 수정 키(Shift, Command 등) 표시 |
post(tap: .cghidEventTap) | 진짜 장치 입력이 들어오는 맨 앞 단계에 넣는다. 이후로는 진짜 입력과 같은 길을 간다 |
맨 앞 단계에 넣으니 3편의 MyKVM 이벤트 탭도 이 입력을 본다. 그래도 맥 키보드 입력으로 착각하지 않는 건, 프로그램이 넣은 입력에는 만든 프로세스 번호가 붙어 있어서 3편에서 그런 입력은 흘려보내게 해 뒀기 때문이다. 입력을 넣으려면 손쉬운 사용 권한이 필요한데, 3편에서 이벤트 탭 때문에 받은 권한과 같다.
윈도우 휠은 한 칸이 120이다. 맥은 줄 단위(.line)로 넣으니, 받은 값을 모아 두었다가 120이 찰 때마다 한 줄씩 넣는다. 가로 방향은 윈도우와 맥이 반대라 부호를 뒤집는다.
마우스 이벤트에도 수정 키를
처음에는 키 이벤트에만 flags를 적었다. 그러자 윈도우 키보드로 Command를 누른 채 클릭해도 맥에서는 그냥 클릭이 됐다. 진짜 마우스 클릭 이벤트에는 그 순간 눌린 수정 키가 같이 적혀 오는데, 넣은 클릭에는 아무것도 없었던 것이다.
그래서 마지막으로 넣은 키 이벤트의 수정 키 상태를 기억해 두고(flags), 마우스 이동, 클릭, 스크롤을 넣을 때도 같이 적는다. Command+클릭(여러 개 선택), Shift+클릭(범위 선택), Option+끌기(복사)가 이걸로 된다.
더블클릭이 안 되던 이유
실제로 써 보니 클릭은 되는데 더블클릭이 안 됐다. 파일을 두 번 눌러도 열리지 않고, 단어를 두 번 눌러도 선택되지 않았다.
진짜 마우스로 누르면 macOS가 “몇 번째 연속 클릭인지”를 세어 이벤트에 적어 준다. 앱은 이 숫자가 2인 클릭을 보고 더블클릭으로 안다. 그런데 프로그램이 넣은 클릭은 macOS가 세어 주지 않아서 늘 1로 남는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ClickCounter.swift (KVMCore)
/// 누를 때 부른다. 같은 버튼을 가까운 자리에서 시간 안에 다시 누르면 1씩 늘고, 아니면 1부터.
public mutating func down(_ button: MouseButton, at point: Point, time: Double) -> Int {
var count = 1
if let last, last.button == button, time - last.time <= interval,
abs(point.x - last.point.x) <= slop, abs(point.y - last.point.y) <= slop {
count = last.count + 1
}
last = (button, point, time, count)
return count
}
// RemoteInjector.swift (MyKVMMac)
private static var clicks = ClickCounter(interval: NSEvent.doubleClickInterval)
// macOS는 프로그램이 넣은 클릭을 세 주지 않는다. 직접 센 연속 클릭 수를 적어야 더블클릭이 된다.
let count = down ? clicks.down(button, at: point, time: ProcessInfo.processInfo.systemUptime) : clicks.current
event?.setIntegerValueField(.mouseEventClickState, value: Int64(count))
mouseEventClickState에 연속 클릭 수 적기
- 같은 버튼을, 4포인트 안의 가까운 자리에서, 시스템 설정의 더블클릭 간격 안에 다시 누르면 앞의 수에 1을 더한다. 셋 중 하나라도 안 맞으면 1부터 다시 센다.
- 간격은
NSEvent.doubleClickInterval로 읽는다. 시스템 설정에서 사용자가 정한 더블클릭 속도를 그대로 따른다. - 뗄 때는 새로 세지 않고, 마지막 누름의 수를 그대로 적는다.
- 센 수를
setIntegerValueField(_:value:)로 이벤트의mouseEventClickState칸에 적는다. 3이면 세 번 클릭(문단 선택)이 된다.
세는 규칙은 OS 기능을 안 쓰니 KVMCore에 두고 테스트했다. 시간은 시계를 바꿔도 거꾸로 가지 않는 systemUptime을 쓴다.
우측 Shift가 안 먹던 이유
윈도우 키보드로 맥을 조작해 보니 다른 키는 다 되는데 우측 Shift만 반응이 없었다. 4편에서 본 대응표를 거꾸로 써서 윈도우 스캔 코드를 맥 키코드로 바꾸는데, 우측 Shift가 표에서 못 찾고 버려지고 있었다.
우측 Shift의 스캔 코드는 0x36이고 원래 확장 키가 아니다. 그런데 윈도우 저수준 훅은 이 키를 보고할 때 확장 키 표시(LLKHF_EXTENDED)를 붙인다. MyKVM은 확장 키를 따로 된 표에서 찾으니, “확장 0x36”이라는 없는 키를 찾다가 실패한 것이다.
1
2
3
4
5
6
7
// KeyMap.swift (KVMCore)
/// 윈도우 스캔 코드 → 맥 가상 키코드(winKey 표의 반대). 윈도우 키보드로 맥을 조작할 때 쓴다.
public static func macKeyCode(forScanCode sc: Int, ext: Bool) -> UInt16? {
// 윈도우 저수준 훅은 우측 Shift(0x36)를 확장 키 표시를 붙여 보고한다. 원래는 확장 키가 아니다.
if sc == rightShiftScanCode { return 0x3C }
return (ext ? reverseExtended : reversePlain)[sc]
}
그래서 0x36은 확장 표시와 상관없이 맥의 우측 Shift(0x3C)로 바꾼다. 훅이 실제로 보고하는 값(스캔 코드 0x36, 확장 표시 켜짐)으로 테스트를 먼저 만들고 고쳤다. 맥 쪽만 바꾸면 되는 일이라 윈도우 exe는 다시 만들지 않았다.
Control+↑이 안 먹던 이유
Command+Space(Spotlight)는 잘 되는데, Control+↑(Mission Control)이나 Control+←, →(데스크톱 넘기기) 같은 단축키가 안 됐다. 방향키 자체는 글자 사이를 잘 움직였다.
원인은 macOS가 방향키 이벤트에 몰래 붙이는 표시였다. 맥 키보드로 방향키를 누르면, 사용자가 누르지 않았는데도 이벤트에 fn 표시(maskSecondaryFn, 0x800000)와 숫자패드 표시(maskNumericPad, 0x200000)가 붙어 온다. 옛 키보드에서 방향키가 숫자패드 쪽에 있던 시절의 흔적이다. 글자 입력 칸은 이 표시를 신경 쓰지 않지만, 시스템 단축키는 이 표시까지 맞아야 “Control+↑”로 알아듣는다.
1
2
3
4
5
6
7
// KVMController.swift (KVMCore)
/// 맥 키보드의 방향키, F키, Home 같은 키 이벤트에는 macOS가 fn(0x800000)과 숫자패드(0x200000) 표시를 붙인다.
/// Control+↑(Mission Control) 같은 시스템 단축키는 이 표시까지 맞아야 알아들어서, 넣는 이벤트에도 똑같이 붙인다.
static func hiddenFlags(for code: UInt16) -> UInt64 {
if arrowKeys.contains(code) { return fnFlag | numericPadFlag }
return fnKeys.contains(code) ? fnFlag : 0
}
그래서 방향키에는 두 표시를 다 붙이고, F1~F12와 Home, End, Page Up, Page Down, Forward Delete, Help에는 fn 표시를 붙여 넣는다. 진짜 맥 키보드가 보내는 이벤트와 같은 모양이 된 뒤로 Mission Control과 데스크톱 넘기기가 됐다.
윈도우 한/영 키는 맥 Control+Space로
윈도우 키보드의 우측 Option 자리는 윈도우에서 한/영 키로 쓴다. 이 키는 훅에 가상 키 0x15(VK_HANGUL)로 온다. 맥에는 한/영 키가 없으니, 이 키가 오면 맥에 Control+Space를 눌렀다 뗀 것으로 넣는다. 4편의 Control+Space → 한/영과 정확히 반대 방향이다. 어느 키보드로 어느 컴퓨터를 쓰든 같은 손가락으로 한/영을 바꿀 수 있다.
맥이 잠기면
맥이 잠기면 잠금 화면에는 넣은 입력이 들어가지 않는다. 그 사이 윈도우 커서가 맥으로 넘어오면 윈도우 키보드가 아무 데도 안 가는 상태가 된다.
1
2
3
4
5
6
7
8
9
// AppModel.swift (MyKVMMac)
private func checkMacLock() {
let session = CGSessionCopyCurrentDictionary() as? [String: Any]
let locked = session?["CGSSessionScreenIsLocked"] as? Bool ?? false
guard locked != macLocked else { return }
macLocked = locked
apply(controller.handle(.macLocked(locked)))
publishZones()
}
CGSessionCopyCurrentDictionary로 잠김 확인하기
CGSessionCopyCurrentDictionary()는 지금 로그인 세션의 정보를 사전으로 돌려준다. 그 안의 CGSSessionScreenIsLocked 값이 true면 화면이 잠긴 것이다. 이 키 이름은 공식 문서에 나오지 않지만 오래전부터 쓰여 온 값이다. 1초마다 이 값을 보고, 바뀌었을 때만 처리한다.
잠기면 맥은 세 가지를 한다. 윈도우 입력을 맥으로 받는 중이면 눌린 것을 다 떼고 leaveMac(-1, -1)로 윈도우에 돌려준다. 11편의 나가는 구역을 빈 목록으로 다시 보내서 아예 넘어올 수 없게 한다. 그리고 윈도우가 enterMac을 보내도 거절한다. 맥 잠금을 풀면 구역을 다시 보내 평소대로 넘나든다.
아직 못 한 것
양쪽 방향 모두 실기기에서 확인했다. 아직 못 한 것은 저장소의 docs/roadmap.md에 적어 뒀다. 이사나 모니터 교체로 구성이 바뀌었을 때 지금 인식한 모니터 목록에서 배치를 다시 잡는 기능, 윈도우 로그인 화면과 잠금 화면에도 입력 넣기(윈도우 서비스가 필요하다), 5편에서 말한 페어링 순간의 중간자 공격 대비, 맥 토큰을 키체인에 저장하기, 오른쪽이나 가운데 버튼으로 끌 때의 이벤트 종류 맞추기 같은 것들이다.