포스트

MyKVM (10) SendInput 절대 좌표와 마우스 없는 윈도우의 포인터

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

맥이 보낸 커서 위치와 키를 윈도우가 받으면, 이제 “방금 이 키가 눌렸다”, “커서가 여기로 갔다”고 윈도우에 알려 줘야 한다. 이번 글은 그 입력 넣기와, 마우스가 없는 윈도우에서 포인터가 사라진 문제, 그리고 윈도우가 잠겼을 때의 처리다.


입력을 넣는 SendInput

윈도우에서 키보드나 마우스를 누른 것처럼 입력을 만들어 넣는 함수가 SendInput이다. 9편에서 본 P/Invoke로 부른다. 넣은 입력은 진짜 키보드와 마우스에서 온 것과 같은 길로 들어가서, 지금 앞에 있는 창이 받는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// InputInjector.cs (MyKVM.Win)
void Key(KeyMessage key)
{
    var flags = key.Down ? 0u : KEYEVENTF_KEYUP;
    var input = new INPUT { type = INPUT_KEYBOARD };
    if (key.Vk is { } vk)
    {
        input.u.ki = new KEYBDINPUT { wVk = (ushort)vk, dwFlags = flags };
    }
    else if (key.Sc is { } sc)
    {
        if (key.Ext == true) flags |= KEYEVENTF_EXTENDEDKEY;
        input.u.ki = new KEYBDINPUT { wScan = (ushort)sc, dwFlags = flags | KEYEVENTF_SCANCODE };
    }
    else return;
    Send(input);
}

KEYBDINPUT의 칸과 표시

칸 또는 표시하는 일
wScan + KEYEVENTF_SCANCODE스캔 코드(키보드 자리 번호)로 넣는다. 4편에서 맥 키를 바꾼 값이 여기로 온다
KEYEVENTF_EXTENDEDKEY확장 키 표시. 오른쪽 Alt, 방향키처럼 확장 표시가 붙은 키에 켠다
wVk가상 키(윈도우가 정한 키 이름 번호)로 넣는다. 한/영 키(0x15)처럼 스캔 코드가 키보드마다 다른 키에 쓴다
KEYEVENTF_KEYUP키 뗌. 없으면 누름이다

스캔 코드로 넣는 이유는 4편과 같다. 키의 자리를 넣으면 윈도우가 지금 자판(한글, 영어)에 맞춰 글자를 정한다. 자세한 칸 설명은 KEYBDINPUT Docs에 있다.

SendInput이 0을 돌려주면 입력이 막힌 것이다. 9편의 관리자 권한 문제가 대표적이다. 같은 오류가 1초에 수십 번 쌓이지 않게, 키보드와 마우스 각각 처음 한 번만 오류 번호를 로그에 남긴다. 직접 손댈 수 없는 윈도우 PC에서 원인을 찾는 유일한 단서라서다.


커서를 정확한 자리에 놓는 절대 좌표

마우스 입력은 두 가지로 넣을 수 있다. “지금 자리에서 오른쪽으로 10픽셀”(상대 이동)과 “이 자리로”(절대 위치)다. 상대 이동은 윈도우의 마우스 가속(포인터 정확도 향상)이 한 번 더 걸려서, 맥이 계산한 거리와 실제로 움직인 거리가 달라진다. 2편에서 커서가 어느 화면 어느 자리에 있는지 맥이 이미 다 계산하니, 윈도우에는 “이 자리로”만 보내면 된다.


SendInput 절대 좌표란?

절대 위치는 픽셀 값을 그대로 받지 않는다. 모든 모니터를 딱 감싼 큰 네모를 가로세로 0부터 65535까지로 나눈 값을 받는다. 지도에서 “몇 번지”가 아니라 “가로 몇 %, 세로 몇 %”로 위치를 말하는 것과 비슷하다.

LG(3440×1440)가 위에, 16MR(1680×1050)이 그 아래 가운데쯤 있다. 두 모니터를 딱 감싼 점선 네모의 왼쪽 위가 (0, 0), 오른쪽 아래가 (65535, 65535)이고, SendInput 절대 좌표는 픽셀이 아니라 이 네모를 0부터 65535로 나눈 값이다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// AbsoluteMapper.cs (MyKVM.Core)
public static (int nx, int ny) ToNormalized(int x, int y, int vx, int vy, int vw, int vh)
{
    var cx = Math.Clamp(x, vx, vx + vw - 1);
    var cy = Math.Clamp(y, vy, vy + vh - 1);
    return ((int)Math.Round((cx - vx) * 65535.0 / Math.Max(vw - 1, 1)), (int)Math.Round((cy - vy) * 65535.0 / Math.Max(vh - 1, 1)));
}

// InputInjector.cs (MyKVM.Win)
void MoveTo(int x, int y)
{
    var (nx, ny) = AbsoluteMapper.ToNormalized(x, y, GetSystemMetrics(SM_XVIRTUALSCREEN), GetSystemMetrics(SM_YVIRTUALSCREEN),
                                               GetSystemMetrics(SM_CXVIRTUALSCREEN), GetSystemMetrics(SM_CYVIRTUALSCREEN));
    Mouse(nx, ny, 0, MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK);
}

가상 화면 크기와 MOUSEEVENTF 표시

  1. GetSystemMetrics의 SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN은 모든 모니터를 감싼 네모(가상 화면)의 왼쪽 위 좌표다. 주 모니터 왼쪽이나 위에 다른 모니터가 있으면 음수가 된다.
  2. SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN은 그 네모의 가로, 세로 길이다.
  3. 받은 픽셀 좌표를 먼저 네모 안으로 자른다(Math.Clamp). 네모 밖 좌표가 와도 엉뚱한 곳으로 튀지 않는다.
  4. 네모 왼쪽 위에서 얼마나 떨어졌는지를 0~65535 비율로 바꾼다.
  5. MOUSEEVENTF_ABSOLUTE는 “이 값은 절대 위치”, MOUSEEVENTF_VIRTUALDESK는 “주 모니터가 아니라 모든 모니터를 감싼 네모 기준”이라는 뜻이다. 뒤의 것이 없으면 0~65535가 주 모니터 하나에만 대응해서 다른 모니터로 커서를 못 보낸다.

계산이 윈도우 기능을 쓰지 않으니 MyKVM.Core에 두고 맥에서 테스트했다. 네모의 왼쪽 위가 음수인 경우도 테스트에 넣었다. 가상 화면 Docs에 네모의 모양이 그림으로 나온다.


화면 배율과 PerMonitorV2

윈도우에서는 모니터마다 화면 배율(100%, 125%, 150% 등)을 따로 정할 수 있다. 배율을 모르는 앱에게 윈도우는 좌표를 배율로 나눈 “가짜 픽셀”을 알려 준다. 그러면 모니터 목록의 크기와 SendInput이 쓰는 네모의 크기가 서로 다른 단위가 되어, 커서가 생각한 자리보다 조금씩 어긋난다.

9편의 매니페스트와 프로젝트 설정에 PerMonitorV2를 적어 둔 게 이 때문이다. “이 앱은 모니터마다 배율을 알아서 다룬다”고 알리면 윈도우는 모든 좌표를 실제 픽셀로 준다. 모니터 목록을 읽는 EnumDisplayMonitors도, 가상 화면 크기도, 맥에 보낸 화면 목록도 전부 같은 실제 픽셀이 된다.

모니터 목록은 앱이 붙을 때 한 번, 그리고 SystemEvents.DisplaySettingsChanged로 모니터 구성이 바뀔 때마다 맥에 보낸다. 모니터마다 붙이는 이름(id)은 모니터 장치 경로를 써서, 재부팅해도 같은 이름이 나와 2편의 배치 짝지음이 유지된다.


마우스가 없으면 사라지는 포인터

처음 실기기에서 써 봤을 때, 맥 키보드로 친 글자는 윈도우에 들어가는데 윈도우 화면에 포인터가 보이지 않았다. 클릭은 되는데 어디를 누르는지 모르는 상태였다.

원인은 윈도우의 동작이었다. 키보드와 마우스는 하드웨어 KVM을 통해 맥에 꽂혀 있으니, 윈도우 입장에서는 마우스 장치가 하나도 없다. 윈도우는 마우스가 없으면 포인터를 아예 그리지 않는다. SendInput으로 넣는 이동은 장치가 아니라서 포인터를 다시 살려 주지 못한다.


마우스 키란?

윈도우의 접근성 기능 중 하나로, 숫자 키패드로 포인터를 움직이게 해 주는 기능이다. 이걸 켜면 윈도우는 “마우스처럼 쓰는 것이 있다”고 보고 포인터를 그린다. Synergy도 같은 방법을 쓴다. MyKVM은 숫자 키패드로 움직이려는 게 아니라, 포인터를 보이게 하려고 이 기능을 잠깐 켠다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// PointerVisibility.cs (MyKVM.Win)
void ForceShow()
{
    if (_original is not null) return;
    var keys = new MOUSEKEYS { cbSize = (uint)Marshal.SizeOf<MOUSEKEYS>() };
    if (!SystemParametersInfo(SPI_GETMOUSEKEYS, keys.cbSize, ref keys, 0))
    {
        log.Error($"pointer: SPI_GETMOUSEKEYS failed ({Marshal.GetLastWin32Error()})");
        return;
    }
    _original = keys;
    var on = keys;
    on.dwFlags |= MKF_AVAILABLE | MKF_MOUSEKEYSON;
    if (!SystemParametersInfo(SPI_SETMOUSEKEYS, on.cbSize, ref on, SPIF_SENDCHANGE))
        log.Error($"pointer: SPI_SETMOUSEKEYS failed ({Marshal.GetLastWin32Error()})");
    else
        log.Info("pointer: no mouse attached, turned on mouse keys so the pointer shows");
}

SystemParametersInfo로 마우스 키 켜고 되돌리기

  1. SystemParametersInfo는 윈도우의 여러 설정을 읽고 바꾸는 함수다. 첫 인자로 어떤 설정인지 고른다.
  2. SPI_GETMOUSEKEYS로 지금 마우스 키 설정을 MOUSEKEYS 구조체로 읽어, 원래 값(_original)으로 기억해 둔다.
  3. MKF_AVAILABLE(쓸 수 있음)과 MKF_MOUSEKEYSON(켜짐) 표시를 더해 SPI_SETMOUSEKEYS로 쓴다.
  4. 마지막 인자 SPIF_SENDCHANGE는 다른 앱에 설정이 바뀌었다고 알리기만 하고, 설정 파일에는 저장하지 않는다. 그래서 MyKVM이 되돌리지 못하고 죽어도 로그아웃하면 원래대로 돌아간다.
  5. 필요 없어지면 기억해 둔 원래 값을 다시 쓴다.

2초마다 켜졌다 꺼지던 포인터

처음에는 규칙을 단순하게 짰다. 2초마다 “마우스가 있나”(SM_MOUSEPRESENT)를 보고, 없으면 마우스 키를 켜고, 있으면 끈다. 그런데 써 보니 윈도우 포인터가 가끔 사라졌다. 윈도우 로그에 “마우스 키 켬”과 “마우스 키 끔”이 2초마다 번갈아 찍혀 있었다.

마우스 키를 켜면 윈도우는 마우스가 있는 것으로 친다. 그래서 켜고 나면 다음 확인에서 “마우스 있음”이 나와 끄고, 끄면 다시 “마우스 없음”이 나와 켜는 일을 반복했다. 내가 켠 것을 내가 확인하고 있었던 셈이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// CursorVisibility.cs (MyKVM.Core)
public CursorAction Update(bool connected, bool mousePresent, int rawMouseCount)
{
    if (!connected || (_forced && rawMouseCount > _baselineMice))
    {
        if (!_forced) return CursorAction.None;
        _forced = false;
        return CursorAction.Restore;
    }
    if (_forced || mousePresent) return CursorAction.None;
    _forced = true;
    _baselineMice = rawMouseCount;
    return CursorAction.Force;
}

그래서 한 번 켜면 그 상태를 붙잡아 두고(_forced), 켠 뒤로는 SM_MOUSEPRESENT를 믿지 않게 바꿨다. 대신 진짜 마우스가 돌아왔는지는 실제 입력 장치 목록을 세어 본다. GetRawInputDeviceList는 윈도우에 붙은 키보드, 마우스 장치를 하나씩 돌려주는데, 마우스 키는 여기에 끼지 않는다. 켤 때의 마우스 개수를 기억해 두고, 그보다 늘어나면 하드웨어 KVM으로 마우스가 돌아온 것이니 원래 설정으로 되돌린다. 맥과의 연결이 끊겨도 되돌린다.

판단은 윈도우 기능을 안 쓰는 CursorVisibility에 두고, 옛 규칙으로는 실패하고 새 규칙으로는 통과하는 테스트를 맥에서 먼저 만들었다.


윈도우가 잠기면

윈도우가 잠기면 잠금 화면은 다른 앱이 입력을 넣을 수 없는 별도의 화면에 뜬다. MyKVM이 보낸 키는 잠금 화면에 들어가지 않으니, 비밀번호는 하드웨어 KVM으로 윈도우에 붙은 키보드로 친다. 그 사이 맥 커서가 윈도우로 넘어가면 아무것도 안 되는 화면에 갇히게 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// LockWatcher.cs (MyKVM.Win)
public LockWatcher()
{
    CreateHandle(new CreateParams());
    WTSRegisterSessionNotification(Handle, NOTIFY_FOR_THIS_SESSION);
}

protected override void WndProc(ref System.Windows.Forms.Message m)
{
    if (m.Msg == WM_WTSSESSION_CHANGE)
    {
        var reason = m.WParam.ToInt32();
        if (reason == WTS_SESSION_LOCK) LockChanged?.Invoke(true);
        else if (reason == WTS_SESSION_UNLOCK) LockChanged?.Invoke(false);
    }
    base.WndProc(ref m);
}

WTSRegisterSessionNotification으로 잠김 알림 받기

윈도우는 알림을 “창”에게 메시지로 보낸다. 트레이 앱에는 창이 없으니, 화면에 안 보이는 빈 창을 하나 만든다(NativeWindow의 CreateHandle). WTSRegisterSessionNotification에 그 창을 등록하면, 잠기거나 풀릴 때 WM_WTSSESSION_CHANGE 메시지가 온다. WndProc은 창에 온 메시지를 받는 함수라, 여기서 잠김(WTS_SESSION_LOCK)과 풀림(WTS_SESSION_UNLOCK)만 골라 알린다.

잠기면 윈도우 쪽은 눌려 있던 키와 버튼을 전부 떼고, 맥에 locked를 보낸다. 맥은 윈도우 화면을 “쓸 수 없음”으로 바꿔 커서를 맥으로 되돌리고, 풀릴 때까지 화면 끝으로 밀어도 넘어가지 않는다. 메뉴 막대에는 윈도우가 잠겼다고 보인다. 풀리면 다시 locked: false가 가서 평소대로 넘나든다.

맥 키보드로 Win+L을 보내 잠그는 건 안 될 수 있다. 윈도우가 SendInput으로 들어온 Win+L을 막기 때문이다. 그래서 시험할 때는 시작 메뉴의 잠금으로 잠갔다.

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