포스트

MyKVM (9) 트레이 아이콘, P/Invoke, 자동 실행으로 짠 C# 윈도우 앱

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

윈도우 쪽은 C#으로 짰다. 나는 C#도 윈도우 앱도 처음이라, 이번 글은 윈도우 앱의 뼈대를 정리한다. 창 없이 트레이 아이콘으로만 뜨는 방법, .NET에 없는 윈도우 기능을 부르는 방법, 관리자 권한, 토큰 저장, 로그인할 때 자동 실행, 그리고 런타임 설치 없이 도는 exe 하나로 묶는 방법까지다. 입력을 실제로 넣는 이야기는 10편에 있다.


창 없이 트레이에만 뜨는 앱

MyKVM 윈도우 앱에는 큰 창이 없다. 작업 표시줄 오른쪽 아래 알림 영역(트레이)에 아이콘 하나만 뜨고, 아이콘을 오른쪽 클릭하면 메뉴가 나온다.

메뉴하는 일
(상태 줄)찾는 중, 연결됨 같은 지금 상태. 누를 수 없다
페어링 다시 하기저장한 토큰을 지우고 처음부터 페어링
로그 폴더 열기%APPDATA%\MyKVM\logs를 탐색기로 연다
로그인 시 자동 실행켜고 끄기(아래 작업 스케줄러)
종료눌린 키를 모두 떼고 끝낸다

WinForms와 ApplicationContext란?

WinForms는 .NET에서 윈도우용 창, 버튼, 메뉴를 만드는 오래된 UI 도구다. 보통은 Application.Run(new 창())으로 창 하나를 띄우고, 그 창이 닫히면 앱이 끝난다. 창 없이 앱만 살려 두고 싶을 때는 창 대신 ApplicationContext를 넘긴다. 앱이 언제 끝날지를 창 대신 이 객체가 정한다. MyKVM의 TrayApp이 이걸 물려받아, “종료” 메뉴를 누를 때만 끝난다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Program.cs (MyKVM.Win)
[STAThread]
static void Main()
{
    using var mutex = new Mutex(true, @"Global\MyKVM", out var first);
    if (!first) return;

    var log = new FileLog(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MyKVM", "logs"));
    // 지난번에 커서를 숨긴 채 앱이 죽었더라도 여기서 되살린다
    CursorHider.Restore();
    // 예외가 나도 앱은 죽지 않고 로그만 남긴다
    Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
    // 생략: 예외 종류별로 로그를 남기는 처리
    ApplicationConfiguration.Initialize();
    Application.Run(new TrayApp(log));
}

Mutex로 한 번만 뜨게 하고 예외를 로그로 받기

줄하는 일
[STAThread]WinForms가 요구하는 표시. UI를 그리는 스레드 방식을 정한다
new Mutex(true, @"Global\MyKVM", out var first)이름이 붙은 잠금을 만든다. 이미 누가 갖고 있으면 first가 false라 바로 끝난다. 자동 실행과 손으로 실행이 겹쳐도 하나만 뜬다
CursorHider.Restore()11편에서 다룰 커서 숨기기를 되돌린다. 앱이 숨긴 채 죽었으면 다음 실행 때 살린다
SetUnhandledExceptionMode(CatchException)UI 스레드의 처리 안 된 예외로 앱이 죽지 않게 한다
ApplicationConfiguration.Initialize()화면 배율 처리 같은 WinForms 기본 설정을 켠다
Application.Run(new TrayApp(log))앱을 시작하고, TrayApp이 끝내라고 할 때까지 여기서 머문다

키보드와 마우스를 넣는 앱이 예외 하나로 꺼지면 키가 눌린 채 남을 수 있다. 그래서 어떤 예외든 로그로 남기고 앱은 계속 돌게 했다.


NotifyIcon이란?

트레이 아이콘 하나를 나타내는 WinForms 객체다.

1
2
// TrayApp.cs (MyKVM.Win)
_icon = new NotifyIcon { Icon = AppIcon.Small, Text = "MyKVM: 찾는 중", ContextMenuStrip = menu, Visible = true };

NotifyIcon의 Icon은 트레이에 보일 그림, Text는 아이콘에 마우스를 올렸을 때 나오는 글자, ContextMenuStrip은 오른쪽 클릭 메뉴다. Visible = true여야 트레이에 실제로 나타난다. 연결 상태가 바뀌면 Text와 메뉴의 상태 줄을 고친다.


UI는 UI 스레드에서만

맥과의 연결은 8편의 KvmClient가 다른 스레드에서 돌린다. 그런데 WinForms는 메뉴나 아이콘 같은 UI를 만든 스레드에서만 고칠 수 있다. 다른 스레드에서 고치면 오류가 나거나 이상하게 그려진다.

1
2
3
4
5
6
7
8
9
// TrayApp.cs (MyKVM.Win)
client.StatusChanged += status => OnUi(() => ShowStatus(status));
client.CodeRequested += () => OnUi(ShowPairingForm);

void OnUi(Action action)
{
    if (_ui.IsDisposed) return;
    _ui.BeginInvoke(action);
}

그래서 연결 쪽에서 오는 알림은 전부 OnUi로 감싼다. Control.BeginInvoke는 “이 일을 UI 스레드의 할 일 목록에 넣어 줘”라는 뜻이다. 넣기만 하고 바로 돌아오니 연결 쪽은 기다리지 않는다. 맥에서 DispatchQueue.main.async로 메인 스레드에 일을 넘기는 것과 같은 일이다.


윈도우 함수를 직접 부르는 P/Invoke

키 넣기, 화면 목록 읽기, 잠김 알림 받기 같은 기능은 .NET 라이브러리에 없다. 이런 건 윈도우 자체에 들어 있는 함수(Win32 API)를 직접 불러야 한다.


P/Invoke란?

C# 코드에서 윈도우의 DLL 파일 안에 있는 함수를 부르는 방법이다(Platform Invoke). C#에는 함수의 “모양”만 적어 두고, 실제 함수는 윈도우 DLL에서 찾아 부른다. 값을 넘길 때는 C# 값을 윈도우 함수가 기대하는 바이트 모양으로 바꿔 건넨다.

C# 코드에는 SendInput의 모양만 적어 둔 선언이 있고, 호출하면 P/Invoke가 값을 바꿔 user32.dll 안의 진짜 SendInput에 건넨다. .NET에 없는 윈도우 기능은 이렇게 윈도우 자체 함수를 직접 부른다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// NativeMethods.cs (MyKVM.Win)
[StructLayout(LayoutKind.Sequential)]
public struct MOUSEINPUT { public int dx; public int dy; public uint mouseData; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; }

[StructLayout(LayoutKind.Sequential)]
public struct KEYBDINPUT { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; }

[StructLayout(LayoutKind.Explicit)]
public struct InputUnion { [FieldOffset(0)] public MOUSEINPUT mi; [FieldOffset(0)] public KEYBDINPUT ki; }

[StructLayout(LayoutKind.Sequential)]
public struct INPUT { public uint type; public InputUnion u; }

[DllImport("user32.dll", SetLastError = true)]
public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);

DllImport와 StructLayout으로 윈도우 함수 모양 적기

표시하는 일
[DllImport("user32.dll")]이 함수는 user32.dll 안에 있다고 알린다. 본문 없이 static extern으로 모양만 적는다
SetLastError = true실패하면 윈도우가 남긴 오류 번호를 나중에 Marshal.GetLastWin32Error()로 읽게 한다
[StructLayout(LayoutKind.Sequential)]칸을 적은 순서 그대로 메모리에 놓는다. 윈도우가 기대하는 순서와 같아야 한다
[StructLayout(LayoutKind.Explicit)]칸마다 놓을 위치를 직접 정한다
[FieldOffset(0)]이 칸을 맨 앞(0번 바이트)에 놓는다
IntPtr주소 크기의 숫자. 64비트 윈도우면 8바이트다

InputUnion이 재밌는 부분이다. 마우스 입력(mi)과 키 입력(ki) 둘 다 FieldOffset(0)이라 같은 자리를 겹쳐 쓴다. 윈도우의 INPUT은 “종류(type)와, 종류에 따라 마우스 칸 또는 키 칸 하나”로 되어 있어서, 두 칸이 같은 메모리를 나눠 쓰는 모양을 C#에서 이렇게 흉내 낸다. SendInput의 마지막 인자에는 INPUT 하나의 바이트 크기를 넘기는데, 이 모양이 틀리면 크기가 달라져 윈도우가 입력을 하나도 받지 않는다. 전체 방식은 P/Invoke Docs에 있다.


관리자 권한과 화면 배율을 적는 매니페스트

앱을 어떤 권한으로 띄울지는 코드가 아니라 exe에 딸린 설명서에 적는다.


매니페스트란?

exe 파일 안에 함께 넣는 XML 설명서다. 윈도우는 앱을 실행하기 전에 이걸 읽고, 어떤 권한으로 띄울지, 화면 배율을 어떻게 다룰지 등을 정한다.

1
2
3
4
5
6
7
<!-- app.manifest (MyKVM.Win) -->
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
  <!-- 관리자 권한 창(작업 관리자 등)에도 입력을 넣으려면 같은 권한이어야 한다 -->
  <requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
<!-- 생략 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>

윈도우는 권한이 낮은 앱이 높은 앱의 창에 입력을 넣지 못하게 막는다. MyKVM이 보통 권한으로 돌면 작업 관리자나 설치 프로그램처럼 관리자 권한으로 뜬 창에서는 키도 마우스 클릭도 먹지 않는다. 그래서 requireAdministrator로 늘 관리자 권한으로 뜨게 했다. 대신 실행할 때마다 “이 앱이 디바이스를 변경하도록 허용하시겠어요?” 창(UAC)이 뜨는데, 이건 아래 작업 스케줄러로 피한다.

PerMonitorV2는 모니터마다 배율이 달라도 좌표를 실제 픽셀로 다루겠다는 뜻이다. 이게 왜 필요한지는 10편에서 다룬다. 매니페스트에 쓸 수 있는 항목은 애플리케이션 매니페스트 Docs에 있다.


토큰을 DPAPI로 감싸 저장하기

5편의 토큰은 이 PC가 맥과 페어링했다는 증거라, 남이 파일을 복사해 가면 그 사람이 내 PC인 척할 수 있다. 그래서 윈도우 쪽은 설정 파일(%APPDATA%\MyKVM\config.json)에 토큰을 그대로 쓰지 않고 감싸서 쓴다.


DPAPI란?

윈도우가 로그인한 사용자마다 따로 갖고 있는 열쇠로 데이터를 잠가 주는 기능이다. 열쇠는 윈도우가 관리하니 앱이 따로 보관할 필요가 없다. 내 계정으로 로그인한 상태에서만 풀 수 있어서, 파일을 다른 PC나 다른 계정으로 가져가면 열리지 않는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// DpapiConfigStore.cs (MyKVM.Win)
static byte[] Transform(byte[] data, bool protect)
{
    var handle = GCHandle.Alloc(data, GCHandleType.Pinned);
    try
    {
        var input = new NativeMethods.DATA_BLOB { cbData = data.Length, pbData = handle.AddrOfPinnedObject() };
        var output = new NativeMethods.DATA_BLOB();
        var ok = protect
            ? NativeMethods.CryptProtectData(ref input, "MyKVM", IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, 0, ref output)
            : NativeMethods.CryptUnprotectData(ref input, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, 0, ref output);
        if (!ok) throw new Win32Exception(Marshal.GetLastWin32Error());
        var result = new byte[output.cbData];
        Marshal.Copy(output.pbData, result, 0, output.cbData);
        NativeMethods.LocalFree(output.pbData);
        return result;
    }
    finally { handle.Free(); }
}

CryptProtectData를 P/Invoke로 부르기

  1. GCHandle.Alloc(data, GCHandleType.Pinned)으로 바이트 배열을 메모리에 고정한다. .NET은 메모리 정리 중에 배열을 옮길 수 있는데, 윈도우 함수가 읽는 도중에 옮겨지면 안 되기 때문이다.
  2. DATA_BLOB(길이와 주소 한 쌍)에 고정한 배열의 길이와 주소를 담는다.
  3. CryptProtectData가 잠그고, 결과를 윈도우가 새로 잡은 메모리에 담아 output으로 돌려준다. 풀 때는 짝인 CryptUnprotectData를 쓴다.
  4. Marshal.Copy로 결과를 C# 배열로 옮겨 오고, 윈도우가 잡은 메모리는 LocalFree로 돌려준다.
  5. finally에서 고정을 푼다.

C#에는 이걸 감싼 ProtectedData 클래스도 있지만, .NET 10에서는 따로 패키지를 받아야 해서 윈도우 함수를 직접 불렀다. 덧붙이면 맥 쪽은 아직 토큰을 설정 파일에 그대로 저장한다. 맥의 키체인으로 옮기는 건 남은 일로 적어 뒀다.


로그인할 때 UAC 없이 자동 실행

윈도우는 부팅하면 비밀번호를 쳐야 로그인된다. 그 비밀번호는 하드웨어 KVM으로 윈도우에 붙은 키보드로 치고, 로그인한 뒤에는 MyKVM이 알아서 떠 있어야 한다. 시작 프로그램 폴더에 넣는 흔한 방법은 관리자 권한 앱에는 맞지 않는다. 띄울 때마다 UAC 확인이 필요해서 로그인할 때 그냥은 뜨지 않는다. 그래서 작업 스케줄러에 “로그인할 때 가장 높은 권한으로 실행”하는 작업을 등록한다. 작업 스케줄러로 띄운 앱은 UAC 창 없이 관리자 권한으로 뜬다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// StartupTaskXml.cs (MyKVM.Core)
/// schtasks /Create를 옵션만으로 부르면 "3일 넘게 실행되면 중지", "배터리면 시작 안 함"이 기본으로 켜져서 XML로 직접 정한다.
public static string Build(string exePath, string userId) => $"""
    <?xml version="1.0" encoding="UTF-16"?>
    <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
      <Triggers>
        <LogonTrigger>
          <Enabled>true</Enabled>
          <UserId>{SecurityElement.Escape(userId)}</UserId>
        </LogonTrigger>
      </Triggers>
      <Principals>
        <Principal id="Author">
          <UserId>{SecurityElement.Escape(userId)}</UserId>
          <LogonType>InteractiveToken</LogonType>
          <RunLevel>HighestAvailable</RunLevel>
        </Principal>
      </Principals>
      <Settings>
        <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
        <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
        <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
        <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
        <Enabled>true</Enabled>
      </Settings>
      <Actions Context="Author">
        <Exec>
          <Command>{SecurityElement.Escape(exePath)}</Command>
        </Exec>
      </Actions>
    </Task>
    """;

작업 스케줄러 XML의 칸들

칸값뜻
LogonTrigger내 계정이 계정이 로그인할 때 시작
LogonTypeInteractiveToken로그인한 내 화면 안에서 띄운다. 그래야 트레이 아이콘이 보이고 입력을 넣을 수 있다
RunLevelHighestAvailable이 계정이 가질 수 있는 가장 높은 권한(관리자)으로
MultipleInstancesPolicyIgnoreNew이미 돌고 있으면 새로 띄우지 않는다
DisallowStartIfOnBatteriesfalse배터리로 돌아도 시작한다
StopIfGoingOnBatteriesfalse배터리로 바뀌어도 멈추지 않는다
ExecutionTimeLimitPT0S실행 시간 제한 없음. 0초라고 적으면 “제한 없음”이 된다
Commandexe 경로실행할 파일

명령줄 옵션만으로 작업을 만들면 실행 시간 제한이 기본 3일로 걸려서, PC를 사흘 넘게 켜 두면 MyKVM이 말없이 꺼진다. 데스크톱이라도 배터리 칸은 기본값이 “배터리면 시작 안 함”이다. 그래서 XML로 이 칸들을 직접 적었다. 경로나 사용자 이름에 &, < 같은 글자가 있어도 XML이 깨지지 않게 SecurityElement.Escape로 감싼다.

트레이 메뉴의 “로그인 시 자동 실행”을 켜면 이 XML을 임시 파일로 쓰고 schtasks /Create /XML로 등록한다. schtasks는 XML 파일을 UTF-16으로 읽어서, 파일도 UTF-16(Encoding.Unicode)으로 쓴다. 칸 전체 목록은 작업 스케줄러 XML 형식 Docs에 있다.


런타임 없이 도는 exe 하나

C# 앱은 보통 윈도우에 .NET 런타임이 깔려 있어야 돈다. 윈도우 PC에 따로 설치할 게 없도록, 런타임까지 exe 하나에 다 넣었다.


단일 파일 배포란?

앱 코드, 앱이 쓰는 라이브러리, .NET 런타임을 전부 exe 하나로 묶는 방식이다. 받는 쪽은 파일 하나만 복사해서 실행하면 된다. 대신 크기가 커진다.

1
2
3
4
5
# windows/build.sh
for rid in win-x64 win-arm64; do
  dotnet publish "$ROOT/src/MyKVM.Win" -c Release -r "$rid" --self-contained true \
    -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true -o "$ROOT/dist/$rid"
done

dotnet publish 옵션

옵션하는 일
-c Release디버그 정보를 빼고 최적화한 빌드
-r win-x64, -r win-arm64인텔/AMD용, ARM용 윈도우를 각각 대상으로
--self-contained true.NET 런타임을 같이 넣는다. 윈도우에 .NET 설치가 필요 없다
PublishSingleFile=true결과를 exe 하나로 묶는다
IncludeNativeLibrariesForSelfExtract=trueWinForms가 쓰는 윈도우용 라이브러리 파일까지 exe 안에 넣는다
EnableCompressionInSingleFile=true묶은 내용을 압축해서 크기를 줄인다
-o결과를 놓을 폴더

결과는 x64용과 ARM64용 모두 약 50MB짜리 MyKVM.exe다. 1편에서 말한 EnableWindowsTargeting 덕분에 이 빌드를 맥에서 하고, 결과를 NAS로 옮겨 윈도우에서 실행한다. 더 자세한 내용은 단일 파일 배포 Docs에 있다.

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