MyKVM (11) 반대 방향, 윈도우 저수준 훅으로 키보드와 마우스 가로채기
이 시리즈는 내가 직접 쓴 개발기가 아니다. 무엇을 만들지 정해 Claude Code 에게 앱을 만들게 했고, 만든 과정과 쓰인 기술은 Claude Code 가 정리했다. 나는 방향을 정하고, 실기기에서 써 보며 고칠 곳을 찾아 다시 맡겼다. 그래도 글로 남기는 건 공부 때문이다. 아직 내가 아는 프레임워크가 몇 개 없어서, 이 앱에 쓰인 여러 프레임워크를 정리해 두고 나중에 하나씩 찾아보며 익히려고 한다.
10편까지는 키보드와 마우스가 맥에 꽂혀 있을 때 윈도우를 조작하는 이야기였다. 그런데 윈도우에서 영상을 보다 보면 하드웨어 KVM을 윈도우 쪽으로 돌려 두기도 한다. 그때도 마우스를 화면 끝으로 밀면 맥으로 넘어가게 하는 것이 반대 방향 기능이다. 이번 글은 윈도우가 자기 키보드와 마우스를 가로채 맥으로 보내는 쪽이고, 맥이 받아서 넣는 쪽은 12편에 있다.
맥이 알려 주는 나가는 구역
반대 방향에서도 판단은 맥이 한다. 화면 배치를 아는 건 맥뿐이라, 윈도우는 “커서가 여기에 닿으면 맥으로 넘어갈 수 있다”는 구역 목록만 받는다.
나가는 구역은 윈도우 화면 가장자리 중 맞은편에 맥 화면이 맞닿은 부분의 1픽셀 띠다. 같은 왼쪽 가장자리라도 맞은편이 비어 있으면 구역이 아니다. 맥은 2편의 배치에서 윈도우 칸과 맥 칸이 맞닿은 변을 찾고, 그 겹치는 길이만큼을 윈도우 픽셀 좌표로 바꿔 zones 메시지로 보낸다. 배치를 바꾸거나 LG를 스왑할 때마다 다시 보낸다. 맥이 잠겨 있으면 빈 목록을 보내서 아예 넘어갈 수 없게 한다.
| 순서 | 방향 | 메시지 | 뜻 |
|---|---|---|---|
| 1 | 윈도우 → 맥 | enterMac(x, y) | 커서가 구역의 이 점에 닿았다. 넘어가도 될까? |
| 2 | 맥 → 윈도우 | remoteOn | 받았다. 지금부터 입력을 보내라. 맥 커서는 그 점의 맞은편에 놓는다 |
| 2’ | 맥 → 윈도우 | leaveMac | 거절(맥이 잠겼거나 맥 쪽 키보드로 윈도우를 조작하는 중). 윈도우 커서는 그대로 |
| 3 | 윈도우 → 맥 | rmove, rbutton, rwheel, rkey | 움직인 거리, 버튼, 휠, 키 |
| 4 | 맥 → 윈도우 | leaveMac(x, y) | 맥 커서가 윈도우 쪽 끝에 닿았다. 윈도우 커서를 이 점에 놓고 되찾아라. 맥이 잠기면 점 없이(-1, -1) 보낸다 |
넘어갈지 물어보는 단계(1, 2)를 둔 건, 그 순간 맥이 받아 줄 수 없는 상태일 수 있어서다. 맥이 잠겨 있거나 맥 키보드로 윈도우를 쓰는 중이면 맥이 거절하고, 윈도우 커서는 그대로 윈도우에 남는다.
입력을 가로채는 저수준 훅
맥으로 보내는 동안 윈도우 자신은 그 입력을 받으면 안 된다. 맥에서 글자를 치는데 윈도우 메모장에도 같은 글자가 찍히면 곤란하다. 그래서 키보드와 마우스 입력이 윈도우 앱들에 가기 전에 가로채서, 보낼지 막을지 정한다.
저수준 훅이란?
윈도우의 모든 키보드, 마우스 입력이 앱에 전달되기 전에 먼저 거쳐 가는 검문소다. 3편 맥의 이벤트 탭과 같은 역할이다. 검문소에 등록한 함수는 입력 하나가 올 때마다 불리고, 그대로 보내거나(통과) 그 자리에서 없앨(막기) 수 있다. 키보드용(WH_KEYBOARD_LL)과 마우스용(WH_MOUSE_LL)이 따로 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// InputHooks.cs (MyKVM.Win)
public InputHooks(RemoteRouter router, Action<Message> send, InputInjector injector, ILog log)
{
// 생략: 필드 저장
_keyboardProc = OnKeyboard;
_mouseProc = OnMouse;
var module = GetModuleHandle(null);
_keyboardHook = SetWindowsHookEx(WH_KEYBOARD_LL, _keyboardProc, module, 0);
_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, _mouseProc, module, 0);
}
IntPtr OnKeyboard(int nCode, IntPtr wParam, IntPtr lParam)
{
if (nCode < 0) return CallNextHookEx(_keyboardHook, nCode, wParam, lParam);
var info = Marshal.PtrToStructure<KBDLLHOOKSTRUCT>(lParam);
var message = (int)wParam;
var down = message is WM_KEYDOWN or WM_SYSKEYDOWN;
var result = Handle(() => _router.OnKey((int)info.vkCode, (int)info.scanCode, (info.flags & LLKHF_EXTENDED) != 0, down,
(info.flags & LLKHF_INJECTED) != 0));
return result.Block ? 1 : CallNextHookEx(_keyboardHook, nCode, wParam, lParam);
}
SetWindowsHookEx와 훅 함수의 약속
| 이름 | 하는 일 |
|---|---|
SetWindowsHookEx | 검문소에 함수를 등록한다. 마지막 인자 0은 “모든 앱의 입력”이라는 뜻이다 |
nCode < 0 | 윈도우가 “이건 처리하지 말고 넘겨라”라고 할 때. 그대로 다음으로 넘긴다 |
KBDLLHOOKSTRUCT | 키 입력 정보. 가상 키, 스캔 코드, 확장 키와 “넣은 입력” 표시가 들어 있다 |
Marshal.PtrToStructure | 윈도우가 넘긴 주소(lParam)에서 구조체를 C# 값으로 읽어 온다 |
| 1을 돌려줌 | 막기. 이 입력은 어떤 앱에도 가지 않는다 |
CallNextHookEx | 통과. 다음 검문소와 앱으로 넘긴다 |
마우스 쪽도 모양이 같고, MSLLHOOKSTRUCT에서 커서 좌표와 휠 값을 읽는다.
C# 함수를 윈도우에 넘길 때는 그 함수를 가리키는 대리자(delegate)를 만들어 넘긴다. 이 대리자를 지역 변수로만 두면 .NET이 “아무도 안 쓴다”고 보고 치워 버리고, 윈도우가 사라진 함수를 부르다 앱이 죽는다. 그래서 _keyboardProc, _mouseProc 필드에 붙잡아 둔다.
훅 함수는 빨리 끝나야 한다. 윈도우는 훅 함수가 정해진 시간 안에 안 돌아오면 그 입력을 그냥 넘기고, 계속 늦으면 훅을 말없이 떼어 버린다. 그래서 맥으로 보내기는 기다리지 않고 맡기기만 한다. 판단 중에 예외가 나면 입력을 통과시킨다. 훅에서 예외가 새면 키보드 전체가 먹통이 될 수 있어서다.
내가 넣은 입력은 건드리지 않기
10편의 SendInput으로 넣은 입력도 이 검문소를 지나간다. 맥 키보드로 윈도우를 조작하는 중에 훅이 그 입력을 또 가로채면, 맥에서 온 키를 다시 맥으로 보내는 꼴이 된다. 그래서 LLKHF_INJECTED(키보드), LLMHF_INJECTED(마우스) 표시가 켜진 입력, 즉 진짜 장치가 아니라 프로그램이 넣은 입력은 무조건 통과시킨다. 3편 맥에서 합성 입력을 거르던 것과 같은 일이다.
막을지 보낼지 정하는 RemoteRouter
훅 함수는 입력을 읽어 RemoteRouter에 넘기고, 돌려받은 결과대로 막거나 통과시킨다. 판단은 윈도우 기능을 쓰지 않는 RemoteRouter에 모아서 맥에서 테스트했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// RemoteRouter.cs (MyKVM.Core)
public HookResult OnMouseMove(int x, int y, bool injected)
{
if (injected) return HookResult.Pass;
if (Remote)
{
var (dx, dy) = (x - parkX, y - parkY);
if (dx == 0 && dy == 0) return HookResult.Swallow;
return new HookResult(true, [new RemoteMoveMessage(dx, dy)], (parkX, parkY), []);
}
var zone = _localButtons.Count == 0 ? _zones.FirstOrDefault(z => Touches(z, x, y)) : null;
if (zone is null)
{
_pending = false;
return HookResult.Pass;
}
if (_pending) return HookResult.Pass;
_pending = true;
// 빠르게 밀면 첫 점이 1픽셀 띠를 지나친다. 맥은 윈도우 화면 안의 점만 알아들으니 띠 위로 당겨서 보낸다.
return HookResult.Pass with
{
Send = [new EnterMacMessage(Math.Clamp(x, zone.X, zone.X + zone.W - 1), Math.Clamp(y, zone.Y, zone.Y + zone.H - 1))],
};
}
맥으로 보내는 동안(Remote)에는 윈도우 커서를 한 점(주 화면 가운데)에 세워 두고, 마우스가 움직일 때마다 그 점에서 얼마나 벗어났는지만 맥에 보낸다. 그리고 커서를 다시 그 점으로 돌린다(SetCursorPos). 3편에서 맥 커서를 세워 두고 움직인 거리만 읽던 것과 같은 방식이다. 커서가 화면 끝에 막히면 그 너머로 민 거리를 알 수 없으니, 늘 가운데로 되돌려 둔다.
| 규칙 | 이유 |
|---|---|
| 구역에 닿은 첫 점을 띠 위로 당겨서 보낸다 | 빠르게 밀면 첫 점이 띠를 지나쳐 화면 밖 좌표가 된다. 그대로 보내면 맥이 어느 화면인지 몰라 거절했다 |
물어본 뒤 답을 기다리는 동안(_pending)은 다시 묻지 않는다 | 구역 위를 지나는 점마다 enterMac이 쏟아지지 않게 |
| 윈도우에서 버튼을 누른 채로는 넘어가지 않는다 | 창을 끌다가 화면 끝에 닿았다고 맥으로 넘어가면, 윈도우에 버튼이 눌린 채 남는다 |
| 돌아온 직후에는 띠를 벗어났다 다시 올 때까지 묻지 않는다 | 맥에서 돌아오면 커서가 띠 위에 놓이니, 바로 다시 넘어가는 왔다 갔다를 막는다 |
| 키와 버튼의 뗌은 누름이 간 쪽으로 | 4편과 같은 이유. 넘어가는 도중에 뗀 키가 반대편에 눌린 채 남지 않게 |
| 누르고 있는 키의 자동 반복도 누름이 간 쪽으로 | 넘어간 뒤에 오는 반복이 엉뚱한 쪽에 들어가지 않게 |
| Alt+Win+X(스왑), Alt+Win+Z(비상 복귀)는 꾹 눌러도 한 번만 | 4편의 ⌥⌘X, ⌥⌘Z와 같은 손가락 자리. 반복으로 스왑이 여러 번 일어나지 않게 |
비상 복귀는 맥을 조작하는 중이면 맥의 답을 기다리지 않고 윈도우가 그 자리에서 입력을 되찾는다(rleave로 맥에 알리기만 한다). 스왑은 맥에 rhotkey로 알려서 맥이 4편과 똑같이 처리한다. 단축키 뒤에 시작 메뉴가 열리지 않게 빈 가상 키(0xE8)를 끼우는 것도 4편과 같다.
맥이 조용히 사라지면
윈도우 입력을 맥으로 보내는 중에 맥이 갑자기 잠들거나 네트워크가 끊기면, 윈도우 키보드와 마우스가 아무 데도 안 가는 상태가 된다. 하드웨어 KVM은 윈도우 쪽이니 맥 키보드로 구할 수도 없다.
8편의 하트비트가 3초 뒤 연결을 끊어 주지만, 맥이 조용히 사라지면 보내기가 막혀서 연결 쪽 확인이 늦어질 수 있다. 그래서 UI 쪽에 0.5초마다 도는 타이머를 따로 두고, 맥에게서 마지막으로 무언가 받은 시각만 본다.
1
2
3
4
5
// RemoteWatchdog.cs (MyKVM.Core)
public const double TimeoutSeconds = 3;
public static bool ShouldEnd(bool remote, DateTime lastReceived, DateTime now) =>
remote && (now - lastReceived).TotalSeconds > TimeoutSeconds;
맥으로 보내는 중인데 3초 넘게 아무것도 못 받았으면, 보내기 상태와 상관없이 윈도우가 입력을 되찾는다. 윈도우가 잠겨도 같은 처리를 한다.
맥을 쓰는 동안 윈도우 커서 숨기기
처음에는 맥으로 보내는 동안 윈도우 커서가 주 화면 가운데에 그대로 보였다. 커서를 그 점에 세워 두는 방식이라 어쩔 수 없었는데, 실제로 써 보니 LG 한가운데 멈춘 화살표가 영상을 볼 때 거슬렸다. 그래서 맥을 조작하는 동안에는 윈도우 커서를 투명하게 바꾼다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// CursorHider.cs (MyKVM.Win)
// 화살표, I빔, 대기, 십자, 위 화살표, 크기 조절 네 가지, 이동, 금지, 손, 백그라운드 작업, 도움말
static readonly uint[] SystemCursorIds = [32512, 32513, 32514, 32515, 32516, 32642, 32643, 32644, 32645, 32646, 32648, 32649, 32650, 32651];
public static void Hide(ILog log)
{
foreach (var id in SystemCursorIds)
{
// SetSystemCursor는 넘긴 커서를 가져가 없애므로 칸마다 새로 만든다. AND 면이 전부 1, XOR 면이 전부 0이면 투명하다.
var blank = CreateCursor(IntPtr.Zero, 0, 0, 32, 32, Enumerable.Repeat((byte)0xFF, 128).ToArray(), new byte[128]);
if (blank == IntPtr.Zero || !SetSystemCursor(blank, id))
{
log.Error($"cursor: hide failed ({Marshal.GetLastWin32Error()})");
Restore();
return;
}
}
}
/// 사용자가 고른 포인터 모양을 다시 불러온다
public static void Restore() => SystemParametersInfoPtr(SPI_SETCURSORS, 0, IntPtr.Zero, 0);
CreateCursor와 SetSystemCursor로 투명 커서 만들기
CreateCursor로 32×32 커서를 만든다. 커서 그림은 두 장의 흑백 면(AND 면, XOR 면)을 겹쳐 그리는데, AND 면이 전부 1이고 XOR 면이 전부 0이면 아래 화면이 그대로 보이는 투명한 커서가 된다. 한 줄이 32비트(4바이트)라 한 면이 128바이트다.SetSystemCursor가 윈도우 전체의 커서 하나(화살표, 글자 입력용 I빔 등)를 그 커서로 바꾼다. 넘긴 커서는 윈도우가 가져가 없애 버리니, 모양마다 새로 만들어 넘긴다.- 커서가 지나는 곳에 따라 모양이 바뀌니, 화살표만이 아니라 자주 쓰이는 14가지 모양을 모두 바꾼다.
- 되돌릴 때는 10편에서 쓴
SystemParametersInfo에SPI_SETCURSORS를 넘긴다. 사용자가 설정해 둔 포인터 모양을 다시 불러온다.
이 방식은 윈도우 전체 설정을 바꾸는 거라, MyKVM이 숨긴 채 죽으면 커서가 안 보인 채 남는다. 그래서 되살리기를 빠짐없이 걸어 뒀다. 맥에서 돌아올 때, 연결이 끊길 때, 앱을 끌 때, 처리 못 한 예외가 났을 때, 그리고 9편의 Program.cs에서 본 것처럼 앱이 시작할 때마다 먼저 되살린다. 숨기다 하나라도 실패해도 바로 되살린다.
