first commit

This commit is contained in:
2026-08-25 00:55:37 +09:00
commit 8d1881749c
72 changed files with 3250 additions and 0 deletions

View File

@@ -0,0 +1,371 @@
using UnityEngine;
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
using System;
using System.Runtime.InteropServices;
#endif
/// <summary>
/// 시스템 트레이 아이콘과 우클릭 메뉴.
///
/// WS_EX_TOOLWINDOW 로 작업표시줄에서 사라졌기 때문에 종료/제어 수단이 필요하다.
///
/// 구현 노트: Shell_NotifyIcon 은 콜백 메시지를 받을 창이 필요한데, Unity 창의
/// 윈도우 프로시저를 SetWindowLongPtr 로 가로채는 것은 관리 델리게이트 수명과
/// 재진입 문제가 있어 위험하다. 대신 전용 창을 따로 만들어 거기에 아이콘을 걸고,
/// PeekMessage 를 그 창으로 한정해 직접 펌핑한다. Unity 의 메시지 루프는 건드리지 않는다.
///
/// HWND_MESSAGE(메시지 전용 창)를 쓰지 않는 이유: 메시지 전용 창은 브로드캐스트
/// 메시지를 받지 못해 TaskbarCreated 재등록이 동작하지 않고, z-order 가 없어
/// SetForegroundWindow 가 실패해 팝업 메뉴가 제대로 닫히지 않는다.
/// 대신 WS_EX_TOOLWINDOW + WS_POPUP 인 일반 최상위 창을 만들되 표시하지 않는다.
/// </summary>
public class TrayIcon : MonoBehaviour
{
[SerializeField] string tooltip = "MyCharacterAgent";
[SerializeField] bool verboseLog = true;
TransparentWindow window;
void Awake()
{
window = GetComponent<TransparentWindow>();
if (window == null) window = FindFirstObjectByType<TransparentWindow>();
}
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
const string ClassName = "MyCharacterAgentTrayWnd";
const int ID_TOGGLE = 1;
const int ID_EXIT = 2;
delegate IntPtr WndProcDelegate(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
// GC 가 델리게이트를 수거하면 네이티브 쪽에서 죽은 포인터를 호출하게 되므로
// 반드시 static 필드로 붙잡아 둔다.
static WndProcDelegate wndProcHolder;
IntPtr trayHwnd = IntPtr.Zero;
bool iconAdded;
bool characterVisible = true;
uint taskbarCreatedMsg;
bool cleaned;
// wndproc 에서 세우고 Update 에서 처리한다. TrackPopupMenu 는 중첩 메시지 루프를
// 돌리므로 메시지 처리 도중이 아니라 프레임의 안전한 지점에서 부르는 편이 낫다.
bool pendingMenu;
bool pendingToggle;
bool pendingReAdd;
void Start()
{
StartCoroutine(CreateWhenReady());
}
System.Collections.IEnumerator CreateWhenReady()
{
// 보이기/숨기기가 Unity 창 핸들을 쓰므로 창 준비를 기다린다.
float deadline = Time.realtimeSinceStartup + 5f;
while ((window == null || !window.IsReady) && Time.realtimeSinceStartup < deadline)
yield return null;
// 창 생성 전에 등록해야 wndproc 이 첫 브로드캐스트부터 인식할 수 있다.
// (탐색기가 재시작되면 트레이 아이콘이 사라지므로 재등록이 필요하다)
taskbarCreatedMsg = RegisterWindowMessageW("TaskbarCreated");
if (!CreateTrayWindow()) yield break;
AddIcon();
}
bool CreateTrayWindow()
{
IntPtr hInstance = GetModuleHandleW(null);
wndProcHolder = WndProc;
var wc = new WNDCLASSEXW
{
cbSize = Marshal.SizeOf(typeof(WNDCLASSEXW)),
lpfnWndProc = Marshal.GetFunctionPointerForDelegate(wndProcHolder),
hInstance = hInstance,
lpszClassName = ClassName,
};
// 이미 등록된 클래스여도 무시하고 창 생성을 시도한다.
RegisterClassExW(ref wc);
// WS_VISIBLE 를 주지 않으므로 화면에 나타나지 않는다.
// WS_EX_TOOLWINDOW 로 작업표시줄/Alt+Tab 에도 뜨지 않는다.
trayHwnd = CreateWindowExW(Win32.WS_EX_TOOLWINDOW, ClassName, string.Empty,
Win32.WS_POPUP, 0, 0, 0, 0,
IntPtr.Zero, IntPtr.Zero, hInstance, IntPtr.Zero);
if (trayHwnd == IntPtr.Zero)
{
Debug.LogError($"[TrayIcon] 트레이용 창 생성 실패. err={Marshal.GetLastWin32Error()}");
return false;
}
return true;
}
/// <summary>
/// 우리 창의 윈도우 프로시저. Unity 메인 스레드의 DispatchMessage 에서 호출된다.
/// </summary>
IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam)
{
if (msg == WM_TRAYICON)
{
uint ev = (uint)(lParam.ToInt64() & 0xFFFF);
// 아이콘 위로 마우스가 지나가기만 해도 WM_MOUSEMOVE 가 계속 온다.
// 상시 구동되는 앱이라 그대로 찍으면 로그가 끝없이 커진다.
if (verboseLog && ev != WM_MOUSEMOVE)
Debug.Log($"[TrayIcon] WM_TRAYICON ev=0x{ev:X4}");
if (ev == WM_RBUTTONUP || ev == WM_CONTEXTMENU) pendingMenu = true;
else if (ev == WM_LBUTTONDBLCLK) pendingToggle = true;
return IntPtr.Zero;
}
if (taskbarCreatedMsg != 0 && msg == taskbarCreatedMsg)
{
pendingReAdd = true;
return IntPtr.Zero;
}
return DefWindowProcW(hWnd, msg, wParam, lParam);
}
IntPtr LoadAppIcon()
{
try
{
string exe = System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName;
IntPtr icon = ExtractIconW(GetModuleHandleW(null), exe, 0);
if (icon != IntPtr.Zero && icon != new IntPtr(1)) return icon;
}
catch (Exception e)
{
Debug.LogWarning($"[TrayIcon] exe 아이콘 추출 실패, 기본 아이콘 사용: {e.Message}");
}
return LoadIconW(IntPtr.Zero, new IntPtr(IDI_APPLICATION));
}
void AddIcon()
{
var data = new NOTIFYICONDATAW
{
cbSize = Marshal.SizeOf(typeof(NOTIFYICONDATAW)),
hWnd = trayHwnd,
uID = 1,
uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP,
uCallbackMessage = WM_TRAYICON,
hIcon = LoadAppIcon(),
szTip = tooltip,
};
iconAdded = Shell_NotifyIconW(NIM_ADD, ref data);
if (iconAdded) Debug.Log("[TrayIcon] 트레이 아이콘 등록됨");
else Debug.LogError("[TrayIcon] Shell_NotifyIcon(NIM_ADD) 실패");
}
void Update()
{
if (trayHwnd == IntPtr.Zero) return;
// Unity 펌프가 우리 창 메시지를 대부분 처리하지만, 놓치는 경우를 대비한 보조 펌프.
// 처리는 전적으로 wndproc 이 하므로 여기서는 디스패치만 한다(이중 처리 방지).
while (PeekMessageW(out MSG msg, trayHwnd, 0, 0, PM_REMOVE))
{
TranslateMessage(ref msg);
DispatchMessageW(ref msg);
}
if (pendingReAdd) { pendingReAdd = false; AddIcon(); }
if (pendingToggle) { pendingToggle = false; ToggleCharacter(); }
if (pendingMenu) { pendingMenu = false; ShowContextMenu(); }
}
void ShowContextMenu()
{
IntPtr menu = CreatePopupMenu();
if (menu == IntPtr.Zero) return;
AppendMenuW(menu, MF_STRING, ID_TOGGLE, characterVisible ? "캐릭터 숨기기" : "캐릭터 보이기");
AppendMenuW(menu, MF_SEPARATOR, 0, null);
AppendMenuW(menu, MF_STRING, ID_EXIT, "종료");
Win32.GetCursorPos(out var pt);
// 트레이 메뉴 관용구: 앞으로 가져와야 메뉴 밖 클릭 시 정상적으로 닫힌다.
SetForegroundWindow(trayHwnd);
int cmd = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_RIGHTBUTTON | TPM_NONOTIFY,
pt.x, pt.y, 0, trayHwnd, IntPtr.Zero);
PostMessageW(trayHwnd, WM_NULL, IntPtr.Zero, IntPtr.Zero);
DestroyMenu(menu);
if (verboseLog) Debug.Log($"[TrayIcon] 메뉴 선택 결과 cmd={cmd}");
if (cmd == ID_EXIT)
{
Debug.Log("[TrayIcon] 메뉴에서 종료 선택");
Application.Quit();
}
else if (cmd == ID_TOGGLE)
{
ToggleCharacter();
}
}
void ToggleCharacter()
{
if (window == null || !window.IsReady) return;
characterVisible = !characterVisible;
Win32.ShowWindow(window.Hwnd, characterVisible ? Win32.SW_SHOW : Win32.SW_HIDE);
if (characterVisible)
{
// SW_SHOW 후 최상단 속성이 풀릴 수 있어 다시 걸어준다.
Win32.SetWindowPos(window.Hwnd, Win32.HWND_TOPMOST, 0, 0, 0, 0,
Win32.SWP_NOMOVE | Win32.SWP_NOSIZE | Win32.SWP_NOACTIVATE);
}
}
void RemoveIcon()
{
if (!iconAdded) return;
var data = new NOTIFYICONDATAW
{
cbSize = Marshal.SizeOf(typeof(NOTIFYICONDATAW)),
hWnd = trayHwnd,
uID = 1,
};
Shell_NotifyIconW(NIM_DELETE, ref data);
iconAdded = false;
}
// 정리를 빠뜨리면 종료 후에도 유령 아이콘이 트레이에 남는다.
void OnApplicationQuit() => Cleanup();
void OnDestroy() => Cleanup();
void Cleanup()
{
if (cleaned) return;
cleaned = true;
RemoveIcon();
if (trayHwnd != IntPtr.Zero)
{
DestroyWindow(trayHwnd);
trayHwnd = IntPtr.Zero;
}
UnregisterClassW(ClassName, GetModuleHandleW(null));
wndProcHolder = null;
}
// ---------------- P/Invoke (트레이 전용) ----------------
const int IDI_APPLICATION = 32512;
const uint NIM_ADD = 0x00000000;
const uint NIM_DELETE = 0x00000002;
const uint NIF_MESSAGE = 0x00000001;
const uint NIF_ICON = 0x00000002;
const uint NIF_TIP = 0x00000004;
const uint WM_NULL = 0x0000;
const uint WM_MOUSEMOVE = 0x0200;
const uint WM_LBUTTONDBLCLK = 0x0203;
const uint WM_RBUTTONUP = 0x0205;
const uint WM_CONTEXTMENU = 0x007B;
const uint WM_APP = 0x8000;
const uint WM_TRAYICON = WM_APP + 1;
const uint PM_REMOVE = 0x0001;
const uint MF_STRING = 0x0000;
const uint MF_SEPARATOR = 0x0800;
const uint TPM_RIGHTBUTTON = 0x0002;
const uint TPM_RETURNCMD = 0x0100;
const uint TPM_NONOTIFY = 0x0080;
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct WNDCLASSEXW
{
public int cbSize;
public uint style;
public IntPtr lpfnWndProc;
public int cbClsExtra;
public int cbWndExtra;
public IntPtr hInstance;
public IntPtr hIcon;
public IntPtr hCursor;
public IntPtr hbrBackground;
[MarshalAs(UnmanagedType.LPWStr)] public string lpszMenuName;
[MarshalAs(UnmanagedType.LPWStr)] public string lpszClassName;
public IntPtr hIconSm;
}
[StructLayout(LayoutKind.Sequential)]
struct MSG
{
public IntPtr hwnd;
public uint message;
public IntPtr wParam;
public IntPtr lParam;
public uint time;
public Win32.POINT pt;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct NOTIFYICONDATAW
{
public int cbSize;
public IntPtr hWnd;
public uint uID;
public uint uFlags;
public uint uCallbackMessage;
public IntPtr hIcon;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string szTip;
public uint dwState;
public uint dwStateMask;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string szInfo;
public uint uVersion;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string szInfoTitle;
public uint dwInfoFlags;
public Guid guidItem;
public IntPtr hBalloonIcon;
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)] static extern IntPtr GetModuleHandleW(string lpModuleName);
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] static extern ushort RegisterClassExW(ref WNDCLASSEXW lpwcx);
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] static extern bool UnregisterClassW(string lpClassName, IntPtr hInstance);
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern IntPtr CreateWindowExW(uint dwExStyle, string lpClassName, string lpWindowName,
uint dwStyle, int x, int y, int nWidth, int nHeight,
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
[DllImport("user32.dll")] static extern bool DestroyWindow(IntPtr hWnd);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern IntPtr DefWindowProcW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern bool PeekMessageW(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
[DllImport("user32.dll")] static extern bool TranslateMessage(ref MSG lpMsg);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern IntPtr DispatchMessageW(ref MSG lpMsg);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern bool PostMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern uint RegisterWindowMessageW(string lpString);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern IntPtr LoadIconW(IntPtr hInstance, IntPtr lpIconName);
[DllImport("shell32.dll", CharSet = CharSet.Unicode)] static extern IntPtr ExtractIconW(IntPtr hInst, string lpszExeFileName, int nIconIndex);
[DllImport("shell32.dll", CharSet = CharSet.Unicode)] static extern bool Shell_NotifyIconW(uint dwMessage, ref NOTIFYICONDATAW lpData);
[DllImport("user32.dll")] static extern IntPtr CreatePopupMenu();
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern bool AppendMenuW(IntPtr hMenu, uint uFlags, int uIDNewItem, string lpNewItem);
[DllImport("user32.dll")] static extern bool DestroyMenu(IntPtr hMenu);
[DllImport("user32.dll")] static extern int TrackPopupMenu(IntPtr hMenu, uint uFlags, int x, int y, int nReserved, IntPtr hWnd, IntPtr prcRect);
[DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr hWnd);
#endif
}