372 lines
14 KiB
C#
372 lines
14 KiB
C#
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
|
|
}
|