From 827f09c6f553b009240f8758028fc52be0f96a44 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Sun, 5 Apr 2026 01:42:57 +0300 Subject: [PATCH 01/56] Implement network toggle in system tray Adds a network toggle feature to the system tray, allowing users to enable or disable network adapters with a single click. The implementation includes error logging, UAC handling, and dynamic icon updates based on network status. --- net-toggle.cpp | 390 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 390 insertions(+) create mode 100644 net-toggle.cpp diff --git a/net-toggle.cpp b/net-toggle.cpp new file mode 100644 index 0000000000..f6ec4f860f --- /dev/null +++ b/net-toggle.cpp @@ -0,0 +1,390 @@ +// ==WindhawkMod== +// @id net-toggle +// @name Network Toggle +// @description Adds a network toggle to the system tray - click the icon to enable/disable network adapters +// @version 1.0 +// @author BlackPaw +// @github https://github.com/blackpaw21 +// @include explorer.exe +// @architecture x86-64 +// ==/WindhawkMod== + +// ==WindhawkModReadme== +/* +# Network Toggle + +Adds a native-looking network toggle to the system tray for quick enable/disable of network adapters. + +## Features + +- **Hardware-Level Kill Switch:** Instantly disable or enable all physical network interface cards (NICs) with a single click. +- **Virtual-Environment Aware:** Automatically ignores software adapters like Hyper-V, WSL, and VM switches. It only targets actual hardware. +- **Native OS Integration:** Extracts official Windows network status icons directly from system DLLs for a seamless, built-in look. + +*/ +// ==/WindhawkModReadme== + +#include +#include +#include + +#pragma comment(lib, "shell32.lib") + +#define TRAY_ICON_ID 1 +#define WM_TRAY_CALLBACK (WM_USER + 1) +#define WM_UPDATE_TRAY_STATE (WM_USER + 2) + +const DWORD CLICK_DEBOUNCE_MS = 2000; + +static volatile LONG g_isProcessingClick = 0; +static volatile LONG g_trayIconInstalled = 0; +static volatile LONG g_networkIsUp = 1; // 1 = ON, 0 = OFF +static HANDLE g_trayThread = nullptr; +static HWND g_trayHwnd = nullptr; +static HINSTANCE g_hInstance = nullptr; +static HICON g_iconEnabled = nullptr; +static HICON g_iconDisabled = nullptr; +static DWORD g_lastClickTime = 0; + +void LogLastError(LPCWSTR context) { + DWORD error = GetLastError(); + if (error == 0) return; + + LPWSTR errorMsg = nullptr; + if (FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, error, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), + (LPWSTR)&errorMsg, 0, nullptr)) { + Wh_Log(L"[ERROR] %s: %s (0x%X)", context, errorMsg, error); + LocalFree(errorMsg); + } else { + Wh_Log(L"[ERROR] %s: Error %d", context, error); + } +} + +BOOL CheckActualNetworkState() { + SECURITY_ATTRIBUTES sa = {sizeof(sa), nullptr, TRUE}; + HANDLE stdoutRead, stdoutWrite; + if (!CreatePipe(&stdoutRead, &stdoutWrite, &sa, 0)) return TRUE; // Default to TRUE on fail + SetHandleInformation(stdoutRead, HANDLE_FLAG_INHERIT, 0); + + PROCESS_INFORMATION pi = {}; + STARTUPINFOW si = {sizeof(si)}; + si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW; + si.wShowWindow = SW_HIDE; + si.hStdOutput = stdoutWrite; + si.hStdError = stdoutWrite; + + BOOL isUp = TRUE; + + // Check if adapters are NOT disabled, rather than fully 'Up', to account for connection delays + if (CreateProcessW(L"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + (LPWSTR)L"powershell.exe -NoProfile -NonInteractive -Command \"(Get-NetAdapter -Physical | Where-Object Status -ne 'Disabled').Count\"", + nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi)) { + CloseHandle(stdoutWrite); + char buffer[128] = {0}; + DWORD bytesRead; + if (ReadFile(stdoutRead, buffer, sizeof(buffer) - 1, &bytesRead, nullptr) && bytesRead > 0) { + int count = atoi(buffer); + isUp = (count > 0); + } + CloseHandle(stdoutRead); + WaitForSingleObject(pi.hProcess, 5000); + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + } else { + CloseHandle(stdoutRead); + CloseHandle(stdoutWrite); + } + + return isUp; +} + +BOOL RunPowerShellCommand(LPCWSTR psCommand, BOOL targetState) { + Wh_Log(L"Executing PowerShell command"); + + WCHAR cmdArgs[2048]; + if (wsprintfW(cmdArgs, L"-NoProfile -NonInteractive -WindowStyle Hidden -Command \"%s\"", psCommand) <= 0) { + Wh_Log(L"Failed to format command"); + return FALSE; + } + + SHELLEXECUTEINFOW sei = {sizeof(sei)}; + sei.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NO_CONSOLE; + sei.hwnd = nullptr; + sei.lpVerb = L"runas"; + sei.lpFile = L"powershell.exe"; + sei.lpParameters = cmdArgs; + sei.nShow = SW_HIDE; + + if (!ShellExecuteExW(&sei)) { + DWORD error = GetLastError(); + if (error == ERROR_CANCELLED) { + Wh_Log(L"UAC prompt cancelled by user"); + } else { + Wh_Log(L"ShellExecuteEx failed: %d", error); + } + return FALSE; + } + + // --- INSTANT UI UPDATE --- + // At this exact line of code, the user just clicked "Yes" on the UAC prompt. + // The PowerShell script is starting up, but the adapters haven't turned off yet. + // We update the tray icon immediately so it feels perfectly responsive. + Wh_Log(L"UAC cleared. Updating UI to target state: %d", targetState); + InterlockedExchange(&g_networkIsUp, targetState ? 1 : 0); + if (g_trayHwnd) { + PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)targetState, 0); + } + + if (!sei.hProcess) { + Wh_Log(L"No process handle returned"); + return FALSE; + } + + // Now we wait for the script to finish doing the actual heavy lifting + DWORD waitResult = WaitForSingleObject(sei.hProcess, 20000); + if (waitResult == WAIT_TIMEOUT) { + Wh_Log(L"Process timed out, terminating"); + TerminateProcess(sei.hProcess, 1); + CloseHandle(sei.hProcess); + return FALSE; + } + + DWORD exitCode = 1; + if (!GetExitCodeProcess(sei.hProcess, &exitCode)) { + LogLastError(L"GetExitCodeProcess"); + CloseHandle(sei.hProcess); + return FALSE; + } + + CloseHandle(sei.hProcess); + Wh_Log(L"Process exited with code: %d", exitCode); + return exitCode == 0; +} + +// Background thread so the Tray UI doesn't freeze during UAC prompt +DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { + BOOL enable = (BOOL)(UINT_PTR)lpParam; + + Wh_Log(L"Toggling network adapters: %s", enable ? L"ENABLE" : L"DISABLE"); + LPCWSTR command = enable + ? L"Get-NetAdapter -Physical | Enable-NetAdapter -Confirm:$false" + : L"Get-NetAdapter -Physical | Disable-NetAdapter -Confirm:$false"; + + BOOL success = RunPowerShellCommand(command, enable); + + if (success) { + Wh_Log(L"Network %s operation completed successfully", enable ? L"enable" : L"disable"); + } else { + Wh_Log(L"Network toggle operation failed or cancelled"); + } + + InterlockedExchange(&g_isProcessingClick, 0); + return 0; +} + +void UpdateTrayIconTooltip(BOOL enabled) { + if (!g_trayHwnd) return; + + NOTIFYICONDATAW nid = {sizeof(nid)}; + nid.hWnd = g_trayHwnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_TIP | NIF_ICON; + wsprintfW(nid.szTip, enabled ? L"Network Toggle: ON (Click to disable)" : L"Network Toggle: OFF (Click to enable)"); + nid.hIcon = enabled ? g_iconEnabled : g_iconDisabled; + + if (!Shell_NotifyIconW(NIM_MODIFY, &nid)) { + LogLastError(L"NIM_MODIFY"); + } +} + +void ProcessTrayClick() { + if (InterlockedExchange(&g_isProcessingClick, 1) != 0) { + Wh_Log(L"Already processing a click, ignoring"); + return; + } + + DWORD now = GetTickCount(); + if (now - g_lastClickTime < CLICK_DEBOUNCE_MS) { + Wh_Log(L"Click debounced (cooldown active)"); + InterlockedExchange(&g_isProcessingClick, 0); + return; + } + g_lastClickTime = now; + + // Calculate our desired state based on current knowledge + BOOL targetState = (g_networkIsUp == 0); + Wh_Log(L"Processing network toggle click. Target state: %s", targetState ? L"ON" : L"OFF"); + + // Spawn worker thread to handle the heavy lifting (UAC & Execution) + DWORD threadId; + HANDLE hWorker = CreateThread(nullptr, 0, WorkerThreadProc, (LPVOID)(UINT_PTR)targetState, 0, &threadId); + if (hWorker) { + CloseHandle(hWorker); + } else { + InterlockedExchange(&g_isProcessingClick, 0); + } +} + +LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { + switch (msg) { + case WM_TRAY_CALLBACK: + if (lParam == WM_LBUTTONUP) { + ProcessTrayClick(); + } + return 0; + + case WM_UPDATE_TRAY_STATE: + UpdateTrayIconTooltip((BOOL)wParam); + return 0; + + case WM_DESTROY: + PostQuitMessage(0); + return 0; + } + return DefWindowProcW(hWnd, msg, wParam, lParam); +} + +DWORD WINAPI TrayThreadProc(LPVOID) { + Wh_Log(L"Tray thread started"); + + BOOL initialState = CheckActualNetworkState(); + InterlockedExchange(&g_networkIsUp, initialState ? 1 : 0); + + WNDCLASSW wc = {0}; + wc.lpfnWndProc = TrayWndProc; + wc.hInstance = g_hInstance; + wc.lpszClassName = L"NetworkToggleWindowClass"; + wc.hIcon = initialState ? g_iconEnabled : g_iconDisabled; + wc.hCursor = LoadCursor(nullptr, IDC_ARROW); + + if (!RegisterClassW(&wc)) { + LogLastError(L"RegisterClassW"); + return 1; + } + + HWND hWnd = CreateWindowExW( + WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, + wc.lpszClassName, + L"Network Toggle", + WS_POPUP, + 0, 0, 1, 1, + nullptr, nullptr, g_hInstance, nullptr + ); + + if (!hWnd) { + LogLastError(L"CreateWindowExW"); + UnregisterClassW(wc.lpszClassName, g_hInstance); + return 1; + } + + g_trayHwnd = hWnd; + + NOTIFYICONDATAW nid = {sizeof(nid)}; + nid.hWnd = hWnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_ICON; + nid.uCallbackMessage = WM_TRAY_CALLBACK; + nid.hIcon = initialState ? g_iconEnabled : g_iconDisabled; + wsprintfW(nid.szTip, initialState ? L"Network Toggle: ON (Click to disable)" : L"Network Toggle: OFF (Click to enable)"); + + if (!Shell_NotifyIconW(NIM_ADD, &nid)) { + LogLastError(L"Shell_NotifyIcon NIM_ADD"); + DestroyWindow(hWnd); + UnregisterClassW(wc.lpszClassName, g_hInstance); + return 1; + } + + InterlockedExchange(&g_trayIconInstalled, 1); + Wh_Log(L"Tray icon installed successfully. Initial state: %s", initialState ? L"ON" : L"OFF"); + + MSG msg; + while (GetMessageW(&msg, nullptr, 0, 0) > 0) { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + + Wh_Log(L"Tray message loop ending"); + + Shell_NotifyIconW(NIM_DELETE, &nid); + DestroyWindow(hWnd); + UnregisterClassW(wc.lpszClassName, g_hInstance); + InterlockedExchange(&g_trayIconInstalled, 0); + g_trayHwnd = nullptr; + + return 0; +} + +BOOL Wh_ModInit() { + Wh_Log(L"Network Toggle Mod Init"); + + g_hInstance = GetModuleHandle(nullptr); + if (!g_hInstance) { + Wh_Log(L"Failed to get module handle"); + return FALSE; + } + + // Extract the official Windows Network Connected/Disconnected icons + // pnidui.dll (Personal Network Indicator UI) contains the actual system tray network icons + // Index 4: Ethernet Connected (Computer monitor with cable) + // Index 5: Ethernet Disconnected (Computer monitor with red X) + ExtractIconExW(L"pnidui.dll", 4, nullptr, &g_iconEnabled, 1); + ExtractIconExW(L"pnidui.dll", 5, nullptr, &g_iconDisabled, 1); + + // Safe fallbacks just in case the system is missing pnidui + if (!g_iconEnabled) ExtractIconExW(L"shell32.dll", 9, nullptr, &g_iconEnabled, 1); + if (!g_iconDisabled) ExtractIconExW(L"shell32.dll", 131, nullptr, &g_iconDisabled, 1); + + Wh_Log(L"Icons loaded successfully."); + + DWORD threadId = 0; + g_trayThread = CreateThread(nullptr, 0, TrayThreadProc, nullptr, CREATE_SUSPENDED, &threadId); + + if (!g_trayThread) { + LogLastError(L"CreateThread"); + return FALSE; + } + + SetThreadPriority(g_trayThread, THREAD_PRIORITY_ABOVE_NORMAL); + + if (ResumeThread(g_trayThread) == (DWORD)-1) { + LogLastError(L"ResumeThread"); + CloseHandle(g_trayThread); + g_trayThread = nullptr; + return FALSE; + } + + return TRUE; +} + +void Wh_ModUninit() { + Wh_Log(L"Network Toggle Mod Uninit"); + + if (g_trayHwnd) { + PostMessageW(g_trayHwnd, WM_DESTROY, 0, 0); + } + + if (g_trayThread) { + Wh_Log(L"Waiting for tray thread to exit..."); + + DWORD waitResult = WaitForSingleObject(g_trayThread, 5000); + if (waitResult == WAIT_TIMEOUT) { + Wh_Log(L"Thread didn't exit in time, terminating"); + TerminateThread(g_trayThread, 1); + } + + CloseHandle(g_trayThread); + g_trayThread = nullptr; + } + + if (g_iconEnabled) { + DestroyIcon(g_iconEnabled); + g_iconEnabled = nullptr; + } + if (g_iconDisabled) { + DestroyIcon(g_iconDisabled); + g_iconDisabled = nullptr; + } + + Wh_Log(L"Network Toggle Mod Uninit complete"); +} From 29240823aaed60b9cee3f64257e386d8f33f7f25 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Sun, 5 Apr 2026 05:05:38 +0300 Subject: [PATCH 02/56] Fix casing in net-toggle.cpp --- net-toggle.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/net-toggle.cpp b/net-toggle.cpp index f6ec4f860f..57ee639279 100644 --- a/net-toggle.cpp +++ b/net-toggle.cpp @@ -4,7 +4,7 @@ // @description Adds a network toggle to the system tray - click the icon to enable/disable network adapters // @version 1.0 // @author BlackPaw -// @github https://github.com/blackpaw21 +// @github https://github.com/BlackPaw21 // @include explorer.exe // @architecture x86-64 // ==/WindhawkMod== From 939807950a59dd1292b07c570215caa8e310bca8 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Sun, 5 Apr 2026 05:24:13 +0300 Subject: [PATCH 03/56] Rename net-toggle.cpp and moved to mods/net-toggle.wh.cpp --- net-toggle.cpp => mods/net-toggle.wh.cpp | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename net-toggle.cpp => mods/net-toggle.wh.cpp (100%) diff --git a/net-toggle.cpp b/mods/net-toggle.wh.cpp similarity index 100% rename from net-toggle.cpp rename to mods/net-toggle.wh.cpp From 407e4c2b2da2e952b63d7c2d9b82a34e792ac5d5 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Mon, 6 Apr 2026 07:03:05 +0300 Subject: [PATCH 04/56] converted from explorer.exe to windhawk.exe --- mods/net-toggle.wh.cpp | 382 ++++++++++++++++++++++++++++++++++------- 1 file changed, 316 insertions(+), 66 deletions(-) diff --git a/mods/net-toggle.wh.cpp b/mods/net-toggle.wh.cpp index 57ee639279..9d51c49c57 100644 --- a/mods/net-toggle.wh.cpp +++ b/mods/net-toggle.wh.cpp @@ -5,40 +5,31 @@ // @version 1.0 // @author BlackPaw // @github https://github.com/BlackPaw21 -// @include explorer.exe -// @architecture x86-64 +// @include windhawk.exe +// @compilerOptions -lshell32 -lgdi32 -luser32 -lwtsapi32 -lwininet // ==/WindhawkMod== -// ==WindhawkModReadme== -/* -# Network Toggle - -Adds a native-looking network toggle to the system tray for quick enable/disable of network adapters. - -## Features - -- **Hardware-Level Kill Switch:** Instantly disable or enable all physical network interface cards (NICs) with a single click. -- **Virtual-Environment Aware:** Automatically ignores software adapters like Hyper-V, WSL, and VM switches. It only targets actual hardware. -- **Native OS Integration:** Extracts official Windows network status icons directly from system DLLs for a seamless, built-in look. - -*/ -// ==/WindhawkModReadme== - #include #include #include +#include +#include +#ifdef _MSC_VER #pragma comment(lib, "shell32.lib") +#pragma comment(lib, "wtsapi32.lib") +#endif #define TRAY_ICON_ID 1 #define WM_TRAY_CALLBACK (WM_USER + 1) #define WM_UPDATE_TRAY_STATE (WM_USER + 2) +#define WM_RUN_ELEVATED (WM_USER + 3) const DWORD CLICK_DEBOUNCE_MS = 2000; static volatile LONG g_isProcessingClick = 0; static volatile LONG g_trayIconInstalled = 0; -static volatile LONG g_networkIsUp = 1; // 1 = ON, 0 = OFF +static volatile LONG g_networkIsUp = 1; static HANDLE g_trayThread = nullptr; static HWND g_trayHwnd = nullptr; static HINSTANCE g_hInstance = nullptr; @@ -64,7 +55,7 @@ void LogLastError(LPCWSTR context) { BOOL CheckActualNetworkState() { SECURITY_ATTRIBUTES sa = {sizeof(sa), nullptr, TRUE}; HANDLE stdoutRead, stdoutWrite; - if (!CreatePipe(&stdoutRead, &stdoutWrite, &sa, 0)) return TRUE; // Default to TRUE on fail + if (!CreatePipe(&stdoutRead, &stdoutWrite, &sa, 0)) return TRUE; SetHandleInformation(stdoutRead, HANDLE_FLAG_INHERIT, 0); PROCESS_INFORMATION pi = {}; @@ -76,7 +67,6 @@ BOOL CheckActualNetworkState() { BOOL isUp = TRUE; - // Check if adapters are NOT disabled, rather than fully 'Up', to account for connection delays if (CreateProcessW(L"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", (LPWSTR)L"powershell.exe -NoProfile -NonInteractive -Command \"(Get-NetAdapter -Physical | Where-Object Status -ne 'Disabled').Count\"", nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi)) { @@ -99,80 +89,186 @@ BOOL CheckActualNetworkState() { return isUp; } -BOOL RunPowerShellCommand(LPCWSTR psCommand, BOOL targetState) { - Wh_Log(L"Executing PowerShell command"); +BOOL RunPowerShellCommandAsSystem(LPCWSTR psCommand, BOOL targetState) { + Wh_Log(L"Executing PowerShell command with Windhawk privileges"); - WCHAR cmdArgs[2048]; - if (wsprintfW(cmdArgs, L"-NoProfile -NonInteractive -WindowStyle Hidden -Command \"%s\"", psCommand) <= 0) { + WCHAR cmdLine[2048]; + if (wsprintfW(cmdLine, L"\"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\" -NoProfile -NonInteractive -Command \"%s 2>&1\"", psCommand) <= 0) { Wh_Log(L"Failed to format command"); return FALSE; } - SHELLEXECUTEINFOW sei = {sizeof(sei)}; - sei.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NO_CONSOLE; - sei.hwnd = nullptr; - sei.lpVerb = L"runas"; - sei.lpFile = L"powershell.exe"; - sei.lpParameters = cmdArgs; - sei.nShow = SW_HIDE; + HMODULE kernel32 = GetModuleHandle(L"kernel32"); + if (!kernel32) { + Wh_Log(L"Failed to get kernel32 handle"); + return FALSE; + } - if (!ShellExecuteExW(&sei)) { - DWORD error = GetLastError(); - if (error == ERROR_CANCELLED) { - Wh_Log(L"UAC prompt cancelled by user"); - } else { - Wh_Log(L"ShellExecuteEx failed: %d", error); - } + typedef BOOL(WINAPI* CreateProcessAsUserW_t)(HANDLE, LPCWSTR, LPWSTR, LPSECURITY_ATTRIBUTES, + LPSECURITY_ATTRIBUTES, BOOL, DWORD, LPVOID, LPCWSTR, LPSTARTUPINFOW, LPPROCESS_INFORMATION); + CreateProcessAsUserW_t pCreateProcessAsUserW = + (CreateProcessAsUserW_t)GetProcAddress(kernel32, "CreateProcessAsUserW"); + + HANDLE hProcessToken = NULL; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ALL_ACCESS, &hProcessToken)) { + Wh_Log(L"OpenProcessToken failed: %d", GetLastError()); + return FALSE; + } + + SECURITY_ATTRIBUTES sa = {sizeof(sa), nullptr, TRUE}; + HANDLE stdoutRead, stdoutWrite; + if (!CreatePipe(&stdoutRead, &stdoutWrite, &sa, 0)) { + Wh_Log(L"CreatePipe failed"); + CloseHandle(hProcessToken); return FALSE; } + SetHandleInformation(stdoutRead, HANDLE_FLAG_INHERIT, 0); + + STARTUPINFOW si{sizeof(si)}; + si.dwFlags = STARTF_USESHOWWINDOW | STARTF_USESTDHANDLES; + si.wShowWindow = SW_HIDE; + si.hStdOutput = stdoutWrite; + si.hStdError = stdoutWrite; + + PROCESS_INFORMATION pi{}; + + BOOL success = FALSE; + if (pCreateProcessAsUserW) { + success = pCreateProcessAsUserW(hProcessToken, nullptr, cmdLine, + nullptr, nullptr, TRUE, CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT, + nullptr, L"C:\\Windows\\System32", &si, &pi); + } + + CloseHandle(hProcessToken); + + if (!success) { + Wh_Log(L"CreateProcessAsUserW failed, trying direct: %d", GetLastError()); + CloseHandle(stdoutWrite); + CloseHandle(stdoutRead); + + if (!CreateProcessW(nullptr, cmdLine, nullptr, nullptr, TRUE, + CREATE_NO_WINDOW, nullptr, L"C:\\Windows\\System32", &si, &pi)) { + Wh_Log(L"CreateProcess failed: %d", GetLastError()); + return FALSE; + } + success = TRUE; + } + + CloseHandle(stdoutWrite); + + Wh_Log(L"Process started with PID: %d", pi.dwProcessId); - // --- INSTANT UI UPDATE --- - // At this exact line of code, the user just clicked "Yes" on the UAC prompt. - // The PowerShell script is starting up, but the adapters haven't turned off yet. - // We update the tray icon immediately so it feels perfectly responsive. - Wh_Log(L"UAC cleared. Updating UI to target state: %d", targetState); InterlockedExchange(&g_networkIsUp, targetState ? 1 : 0); if (g_trayHwnd) { PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)targetState, 0); } - if (!sei.hProcess) { + if (!pi.hProcess) { Wh_Log(L"No process handle returned"); + CloseHandle(stdoutRead); return FALSE; } - // Now we wait for the script to finish doing the actual heavy lifting - DWORD waitResult = WaitForSingleObject(sei.hProcess, 20000); + DWORD waitResult = WaitForSingleObject(pi.hProcess, 20000); + + char output[4096] = {0}; + DWORD bytesRead; + if (ReadFile(stdoutRead, output, sizeof(output) - 1, &bytesRead, nullptr) && bytesRead > 0) { + output[bytesRead] = '\0'; + Wh_Log(L"PowerShell output: %S", output); + } + CloseHandle(stdoutRead); + if (waitResult == WAIT_TIMEOUT) { Wh_Log(L"Process timed out, terminating"); - TerminateProcess(sei.hProcess, 1); - CloseHandle(sei.hProcess); + TerminateProcess(pi.hProcess, 1); + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); return FALSE; } DWORD exitCode = 1; - if (!GetExitCodeProcess(sei.hProcess, &exitCode)) { + if (!GetExitCodeProcess(pi.hProcess, &exitCode)) { LogLastError(L"GetExitCodeProcess"); - CloseHandle(sei.hProcess); + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); return FALSE; } - CloseHandle(sei.hProcess); + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); Wh_Log(L"Process exited with code: %d", exitCode); return exitCode == 0; } -// Background thread so the Tray UI doesn't freeze during UAC prompt DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { BOOL enable = (BOOL)(UINT_PTR)lpParam; Wh_Log(L"Toggling network adapters: %s", enable ? L"ENABLE" : L"DISABLE"); - LPCWSTR command = enable + + WCHAR cmdArgs[2048]; + LPCWSTR psCommand = enable ? L"Get-NetAdapter -Physical | Enable-NetAdapter -Confirm:$false" : L"Get-NetAdapter -Physical | Disable-NetAdapter -Confirm:$false"; - - BOOL success = RunPowerShellCommand(command, enable); + if (wsprintfW(cmdArgs, L"-NoProfile -NonInteractive -WindowStyle Hidden -Command \"%s\"", psCommand) <= 0) { + Wh_Log(L"Failed to format command"); + InterlockedExchange(&g_isProcessingClick, 0); + return 0; + } + + SHELLEXECUTEINFOW sei = {sizeof(sei)}; + sei.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NO_CONSOLE; + sei.hwnd = nullptr; + sei.lpVerb = L"runas"; + sei.lpFile = L"powershell.exe"; + sei.lpParameters = cmdArgs; + sei.nShow = SW_HIDE; + + if (!ShellExecuteExW(&sei)) { + DWORD error = GetLastError(); + if (error == ERROR_CANCELLED) { + Wh_Log(L"UAC prompt cancelled by user"); + } else { + Wh_Log(L"ShellExecuteEx failed: %d", error); + } + InterlockedExchange(&g_isProcessingClick, 0); + return 0; + } + + Wh_Log(L"UAC cleared. Updating UI to target state: %d", enable); + InterlockedExchange(&g_networkIsUp, enable ? 1 : 0); + if (g_trayHwnd) { + PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)enable, 0); + } + + if (!sei.hProcess) { + Wh_Log(L"No process handle returned"); + InterlockedExchange(&g_isProcessingClick, 0); + return 0; + } + + DWORD waitResult = WaitForSingleObject(sei.hProcess, 20000); + BOOL success = FALSE; + if (waitResult == WAIT_TIMEOUT) { + Wh_Log(L"Process timed out, terminating"); + TerminateProcess(sei.hProcess, 1); + } else { + DWORD exitCode = 1; + if (GetExitCodeProcess(sei.hProcess, &exitCode)) { + success = (exitCode == 0); + Wh_Log(L"Process exited with code: %d", exitCode); + } + } + + CloseHandle(sei.hProcess); + + if (success) { + Wh_Log(L"Network %s operation completed successfully", enable ? L"enable" : L"disable"); + } else { + Wh_Log(L"Network toggle operation failed"); + } + if (success) { Wh_Log(L"Network %s operation completed successfully", enable ? L"enable" : L"disable"); } else { @@ -212,11 +308,9 @@ void ProcessTrayClick() { } g_lastClickTime = now; - // Calculate our desired state based on current knowledge BOOL targetState = (g_networkIsUp == 0); Wh_Log(L"Processing network toggle click. Target state: %s", targetState ? L"ON" : L"OFF"); - // Spawn worker thread to handle the heavy lifting (UAC & Execution) DWORD threadId; HANDLE hWorker = CreateThread(nullptr, 0, WorkerThreadProc, (LPVOID)(UINT_PTR)targetState, 0, &threadId); if (hWorker) { @@ -315,8 +409,25 @@ DWORD WINAPI TrayThreadProc(LPVOID) { return 0; } -BOOL Wh_ModInit() { - Wh_Log(L"Network Toggle Mod Init"); +//////////////////////////////////////////////////////////////////////////////// +// Windhawk tool mod implementation +bool g_isToolModProcessLauncher; +HANDLE g_toolModProcessMutex; + +void WINAPI EntryPoint_Hook() { + Wh_Log(L"Tool mod process started. Entering message loop."); + + MSG msg; + while (GetMessage(&msg, nullptr, 0, 0)) { + TranslateMessage(&msg); + DispatchMessage(&msg); + } + + Wh_Log(L"Tool mod message loop exited."); +} + +BOOL WhTool_ModInit() { + Wh_Log(L"Network Toggle Tool Mod Init"); g_hInstance = GetModuleHandle(nullptr); if (!g_hInstance) { @@ -324,18 +435,13 @@ BOOL Wh_ModInit() { return FALSE; } - // Extract the official Windows Network Connected/Disconnected icons - // pnidui.dll (Personal Network Indicator UI) contains the actual system tray network icons - // Index 4: Ethernet Connected (Computer monitor with cable) - // Index 5: Ethernet Disconnected (Computer monitor with red X) ExtractIconExW(L"pnidui.dll", 4, nullptr, &g_iconEnabled, 1); ExtractIconExW(L"pnidui.dll", 5, nullptr, &g_iconDisabled, 1); - // Safe fallbacks just in case the system is missing pnidui if (!g_iconEnabled) ExtractIconExW(L"shell32.dll", 9, nullptr, &g_iconEnabled, 1); if (!g_iconDisabled) ExtractIconExW(L"shell32.dll", 131, nullptr, &g_iconDisabled, 1); - Wh_Log(L"Icons loaded successfully."); + Wh_Log(L"Icons loaded. Enabled: %p, Disabled: %p", g_iconEnabled, g_iconDisabled); DWORD threadId = 0; g_trayThread = CreateThread(nullptr, 0, TrayThreadProc, nullptr, CREATE_SUSPENDED, &threadId); @@ -354,10 +460,11 @@ BOOL Wh_ModInit() { return FALSE; } + Wh_Log(L"Tray thread started successfully"); return TRUE; } -void Wh_ModUninit() { +void WhTool_ModUninit() { Wh_Log(L"Network Toggle Mod Uninit"); if (g_trayHwnd) { @@ -386,5 +493,148 @@ void Wh_ModUninit() { g_iconDisabled = nullptr; } + PostQuitMessage(0); Wh_Log(L"Network Toggle Mod Uninit complete"); } + +//////////////////////////////////////////////////////////////////////////////// +// Windhawk tool mod bootstrap - runs in windhawk.exe process + +BOOL Wh_ModInit() { + bool isService = false; + bool isToolModProcess = false; + bool isCurrentToolModProcess = false; + int argc; + LPWSTR* argv = CommandLineToArgvW(GetCommandLine(), &argc); + if (!argv) { + Wh_Log(L"CommandLineToArgvW failed"); + return FALSE; + } + + for (int i = 1; i < argc; i++) { + if (wcscmp(argv[i], L"-service") == 0) { + isService = true; + break; + } + } + + for (int i = 1; i < argc - 1; i++) { + if (wcscmp(argv[i], L"-tool-mod") == 0) { + isToolModProcess = true; + if (wcscmp(argv[i + 1], WH_MOD_ID) == 0) { + isCurrentToolModProcess = true; + } + break; + } + } + + LocalFree(argv); + + if (isService) { + return FALSE; + } + + if (isCurrentToolModProcess) { + g_toolModProcessMutex = CreateMutex(nullptr, TRUE, L"windhawk-tool-mod_" WH_MOD_ID); + if (!g_toolModProcessMutex) { + Wh_Log(L"CreateMutex failed"); + ExitProcess(1); + } + + if (GetLastError() == ERROR_ALREADY_EXISTS) { + Wh_Log(L"Tool mod already running (%s)", WH_MOD_ID); + ExitProcess(1); + } + + if (!WhTool_ModInit()) { + ExitProcess(1); + } + + IMAGE_DOS_HEADER* dosHeader = (IMAGE_DOS_HEADER*)GetModuleHandle(nullptr); + IMAGE_NT_HEADERS* ntHeaders = (IMAGE_NT_HEADERS*)((BYTE*)dosHeader + dosHeader->e_lfanew); + DWORD entryPointRVA = ntHeaders->OptionalHeader.AddressOfEntryPoint; + void* entryPoint = (BYTE*)dosHeader + entryPointRVA; + + Wh_SetFunctionHook(entryPoint, (void*)EntryPoint_Hook, nullptr); + return TRUE; + } + + if (isToolModProcess) { + return FALSE; + } + + g_isToolModProcessLauncher = true; + return TRUE; +} + +void Wh_ModAfterInit() { + if (!g_isToolModProcessLauncher) { + return; + } + + WCHAR currentProcessPath[MAX_PATH]; + switch (GetModuleFileName(nullptr, currentProcessPath, ARRAYSIZE(currentProcessPath))) { + case 0: + case ARRAYSIZE(currentProcessPath): + Wh_Log(L"GetModuleFileName failed"); + return; + } + + WCHAR commandLine[MAX_PATH + 2 + (sizeof(L" -tool-mod \"" WH_MOD_ID "\"") / sizeof(WCHAR)) - 1]; + swprintf_s(commandLine, L"\"%s\" -tool-mod \"%s\"", currentProcessPath, WH_MOD_ID); + + HMODULE kernelModule = GetModuleHandle(L"kernelbase.dll"); + if (!kernelModule) { + kernelModule = GetModuleHandle(L"kernel32.dll"); + if (!kernelModule) { + Wh_Log(L"No kernelbase.dll/kernel32.dll"); + return; + } + } + + using CreateProcessInternalW_t = BOOL(WINAPI*)( + HANDLE hUserToken, LPCWSTR lpApplicationName, LPWSTR lpCommandLine, + LPSECURITY_ATTRIBUTES lpProcessAttributes, LPSECURITY_ATTRIBUTES lpThreadAttributes, + WINBOOL bInheritHandles, DWORD dwCreationFlags, LPVOID lpEnvironment, + LPCWSTR lpCurrentDirectory, LPSTARTUPINFOW lpStartupInfo, + LPPROCESS_INFORMATION lpProcessInformation, PHANDLE hRestrictedUserToken); + + CreateProcessInternalW_t pCreateProcessInternalW = + (CreateProcessInternalW_t)GetProcAddress(kernelModule, "CreateProcessInternalW"); + if (!pCreateProcessInternalW) { + Wh_Log(L"No CreateProcessInternalW"); + return; + } + + STARTUPINFO si{ .cb = sizeof(STARTUPINFO), .dwFlags = STARTF_FORCEOFFFEEDBACK }; + PROCESS_INFORMATION pi; + if (!pCreateProcessInternalW(nullptr, currentProcessPath, commandLine, + nullptr, nullptr, FALSE, CREATE_NO_WINDOW, + nullptr, nullptr, &si, &pi, nullptr)) { + Wh_Log(L"CreateProcess failed, error: %d", GetLastError()); + return; + } + + Wh_Log(L"Launched tool mod process, PID: %d", pi.dwProcessId); + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); +} + +void WhTool_ModSettingsChanged() { + Wh_Log(L"Settings changed"); +} + +void Wh_ModSettingsChanged() { + if (g_isToolModProcessLauncher) { + return; + } + WhTool_ModSettingsChanged(); +} + +void Wh_ModUninit() { + if (g_isToolModProcessLauncher) { + return; + } + WhTool_ModUninit(); + ExitProcess(0); +} From 7453831b2aa542bb56fc7a3659a7da7c7d5d6e01 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Mon, 6 Apr 2026 07:06:14 +0300 Subject: [PATCH 05/56] Add readme for Network Toggle mod Added a readme section for the Network Toggle mod, detailing its features and functionality. --- mods/net-toggle.wh.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/mods/net-toggle.wh.cpp b/mods/net-toggle.wh.cpp index 9d51c49c57..ef64692142 100644 --- a/mods/net-toggle.wh.cpp +++ b/mods/net-toggle.wh.cpp @@ -9,6 +9,21 @@ // @compilerOptions -lshell32 -lgdi32 -luser32 -lwtsapi32 -lwininet // ==/WindhawkMod== +// ==WindhawkModReadme== +/* +# Network Toggle + +Adds a native-looking network toggle to the system tray for quick enable/disable of network adapters. + +## Features + +- **Hardware-Level Kill Switch:** Instantly disable or enable all physical network interface cards (NICs) with a single click. +- **Virtual-Environment Aware:** Automatically ignores software adapters like Hyper-V, WSL, and VM switches. It only targets actual hardware. +- **Native OS Integration:** Extracts official Windows network status icons directly from system DLLs for a seamless, built-in look. + +*/ +// ==/WindhawkModReadme== + #include #include #include From 4f1b65149fd225d003565f817946a8f9a56017fc Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:01:37 +0300 Subject: [PATCH 06/56] addressing the reviews using original code snippet from: https://github.com/ramensoftware/windhawk/wiki/Mods-as-tools:-Running-mods-in-a-dedicated-process Icon does not disappear on explorer.exe restart Removed -lwtsapi32 and -lwininet Removed , , and all #pragma directives. Removed the unused RunPowerShellCommandAsSystem function. Removed SetThreadPriority; the tray UI thread now runs at standard priority as requested. overall stability and maintenance upgrade --- mods/net-toggle.wh.cpp | 415 +++++++++++++++++------------------------ 1 file changed, 167 insertions(+), 248 deletions(-) diff --git a/mods/net-toggle.wh.cpp b/mods/net-toggle.wh.cpp index ef64692142..28c81ce60a 100644 --- a/mods/net-toggle.wh.cpp +++ b/mods/net-toggle.wh.cpp @@ -6,51 +6,59 @@ // @author BlackPaw // @github https://github.com/BlackPaw21 // @include windhawk.exe -// @compilerOptions -lshell32 -lgdi32 -luser32 -lwtsapi32 -lwininet +// @compilerOptions -lshell32 -lgdi32 -luser32 // ==/WindhawkMod== // ==WindhawkModReadme== /* # Network Toggle -Adds a native-looking network toggle to the system tray for quick enable/disable of network adapters. +A lightning-fast internet kill switch right in your taskbar! ⚡ -## Features +Ever needed to quickly disconnect from the web without digging through Windows settings or ripping the ethernet cable out of the wall? Network Toggle adds a clean, native-looking button directly to your system tray. -- **Hardware-Level Kill Switch:** Instantly disable or enable all physical network interface cards (NICs) with a single click. -- **Virtual-Environment Aware:** Automatically ignores software adapters like Hyper-V, WSL, and VM switches. It only targets actual hardware. -- **Native OS Integration:** Extracts official Windows network status icons directly from system DLLs for a seamless, built-in look. +One click drops your connection. Click it again, and you're back online. +## 📖 How to Use It +Using the toggle is incredibly simple: +1. 🔍 **Find the Icon:** Look in your system tray (bottom right of your screen, next to the clock) for the little `^` arrow. hit it and look for the network icon. +2. 🎯 **Click to Toggle:** Give the icon a single click. +3. ✅ **Approve the Prompt:** Windows will pop up a quick UAC screen asking for permission. Click **Yes**. +4. 🕒 **Wait a Sec:** The tray icon will update instantly, but give Windows just a few seconds to actually power your network adapters down or back up in the background. + + > **Why do I need to accept the UAC?** + > + > Windows requires admin permission to physically turn off your network hardware. + > + > This is a built-in security feature to stop rogue background apps from disconnecting you secretly. + +## ✨ Why You'll Love It +- **Instant Access:** Your network power switch is always exactly one click away. +- **Native Look & Feel:** Uses official Windows system icons, so it blends perfectly into your taskbar. +- **Smart & Safe:** Only toggles your actual, physical hardware. It completely ignores virtual networks (like WSL or VMs), so your local environments stay perfectly intact! */ // ==/WindhawkModReadme== #include #include #include -#include -#include - -#ifdef _MSC_VER -#pragma comment(lib, "shell32.lib") -#pragma comment(lib, "wtsapi32.lib") -#endif #define TRAY_ICON_ID 1 #define WM_TRAY_CALLBACK (WM_USER + 1) #define WM_UPDATE_TRAY_STATE (WM_USER + 2) -#define WM_RUN_ELEVATED (WM_USER + 3) const DWORD CLICK_DEBOUNCE_MS = 2000; static volatile LONG g_isProcessingClick = 0; static volatile LONG g_trayIconInstalled = 0; -static volatile LONG g_networkIsUp = 1; +static volatile LONG g_networkIsUp = 1; // 1 = ON, 0 = OFF static HANDLE g_trayThread = nullptr; static HWND g_trayHwnd = nullptr; static HINSTANCE g_hInstance = nullptr; static HICON g_iconEnabled = nullptr; static HICON g_iconDisabled = nullptr; static DWORD g_lastClickTime = 0; +static UINT g_taskbarCreatedMsg = 0; void LogLastError(LPCWSTR context) { DWORD error = GetLastError(); @@ -82,8 +90,11 @@ BOOL CheckActualNetworkState() { BOOL isUp = TRUE; + // Use a mutable array for CreateProcessW to prevent Access Violations + WCHAR cmdLine[] = L"powershell.exe -NoProfile -NonInteractive -Command \"(Get-NetAdapter -Physical | Where-Object Status -ne 'Disabled').Count\""; + if (CreateProcessW(L"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", - (LPWSTR)L"powershell.exe -NoProfile -NonInteractive -Command \"(Get-NetAdapter -Physical | Where-Object Status -ne 'Disabled').Count\"", + cmdLine, nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi)) { CloseHandle(stdoutWrite); char buffer[128] = {0}; @@ -104,132 +115,13 @@ BOOL CheckActualNetworkState() { return isUp; } -BOOL RunPowerShellCommandAsSystem(LPCWSTR psCommand, BOOL targetState) { - Wh_Log(L"Executing PowerShell command with Windhawk privileges"); - - WCHAR cmdLine[2048]; - if (wsprintfW(cmdLine, L"\"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\" -NoProfile -NonInteractive -Command \"%s 2>&1\"", psCommand) <= 0) { - Wh_Log(L"Failed to format command"); - return FALSE; - } - - HMODULE kernel32 = GetModuleHandle(L"kernel32"); - if (!kernel32) { - Wh_Log(L"Failed to get kernel32 handle"); - return FALSE; - } - - typedef BOOL(WINAPI* CreateProcessAsUserW_t)(HANDLE, LPCWSTR, LPWSTR, LPSECURITY_ATTRIBUTES, - LPSECURITY_ATTRIBUTES, BOOL, DWORD, LPVOID, LPCWSTR, LPSTARTUPINFOW, LPPROCESS_INFORMATION); - CreateProcessAsUserW_t pCreateProcessAsUserW = - (CreateProcessAsUserW_t)GetProcAddress(kernel32, "CreateProcessAsUserW"); +BOOL RunPowerShellCommand(LPCWSTR psCommand, BOOL targetState) { + Wh_Log(L"Executing PowerShell command"); - HANDLE hProcessToken = NULL; - if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ALL_ACCESS, &hProcessToken)) { - Wh_Log(L"OpenProcessToken failed: %d", GetLastError()); - return FALSE; - } - - SECURITY_ATTRIBUTES sa = {sizeof(sa), nullptr, TRUE}; - HANDLE stdoutRead, stdoutWrite; - if (!CreatePipe(&stdoutRead, &stdoutWrite, &sa, 0)) { - Wh_Log(L"CreatePipe failed"); - CloseHandle(hProcessToken); - return FALSE; - } - SetHandleInformation(stdoutRead, HANDLE_FLAG_INHERIT, 0); - - STARTUPINFOW si{sizeof(si)}; - si.dwFlags = STARTF_USESHOWWINDOW | STARTF_USESTDHANDLES; - si.wShowWindow = SW_HIDE; - si.hStdOutput = stdoutWrite; - si.hStdError = stdoutWrite; - - PROCESS_INFORMATION pi{}; - - BOOL success = FALSE; - if (pCreateProcessAsUserW) { - success = pCreateProcessAsUserW(hProcessToken, nullptr, cmdLine, - nullptr, nullptr, TRUE, CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT, - nullptr, L"C:\\Windows\\System32", &si, &pi); - } - - CloseHandle(hProcessToken); - - if (!success) { - Wh_Log(L"CreateProcessAsUserW failed, trying direct: %d", GetLastError()); - CloseHandle(stdoutWrite); - CloseHandle(stdoutRead); - - if (!CreateProcessW(nullptr, cmdLine, nullptr, nullptr, TRUE, - CREATE_NO_WINDOW, nullptr, L"C:\\Windows\\System32", &si, &pi)) { - Wh_Log(L"CreateProcess failed: %d", GetLastError()); - return FALSE; - } - success = TRUE; - } - - CloseHandle(stdoutWrite); - - Wh_Log(L"Process started with PID: %d", pi.dwProcessId); - - InterlockedExchange(&g_networkIsUp, targetState ? 1 : 0); - if (g_trayHwnd) { - PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)targetState, 0); - } - - if (!pi.hProcess) { - Wh_Log(L"No process handle returned"); - CloseHandle(stdoutRead); - return FALSE; - } - - DWORD waitResult = WaitForSingleObject(pi.hProcess, 20000); - - char output[4096] = {0}; - DWORD bytesRead; - if (ReadFile(stdoutRead, output, sizeof(output) - 1, &bytesRead, nullptr) && bytesRead > 0) { - output[bytesRead] = '\0'; - Wh_Log(L"PowerShell output: %S", output); - } - CloseHandle(stdoutRead); - - if (waitResult == WAIT_TIMEOUT) { - Wh_Log(L"Process timed out, terminating"); - TerminateProcess(pi.hProcess, 1); - CloseHandle(pi.hProcess); - CloseHandle(pi.hThread); - return FALSE; - } - - DWORD exitCode = 1; - if (!GetExitCodeProcess(pi.hProcess, &exitCode)) { - LogLastError(L"GetExitCodeProcess"); - CloseHandle(pi.hProcess); - CloseHandle(pi.hThread); - return FALSE; - } - - CloseHandle(pi.hProcess); - CloseHandle(pi.hThread); - Wh_Log(L"Process exited with code: %d", exitCode); - return exitCode == 0; -} - -DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { - BOOL enable = (BOOL)(UINT_PTR)lpParam; - - Wh_Log(L"Toggling network adapters: %s", enable ? L"ENABLE" : L"DISABLE"); - WCHAR cmdArgs[2048]; - LPCWSTR psCommand = enable - ? L"Get-NetAdapter -Physical | Enable-NetAdapter -Confirm:$false" - : L"Get-NetAdapter -Physical | Disable-NetAdapter -Confirm:$false"; - if (wsprintfW(cmdArgs, L"-NoProfile -NonInteractive -WindowStyle Hidden -Command \"%s\"", psCommand) <= 0) { Wh_Log(L"Failed to format command"); - InterlockedExchange(&g_isProcessingClick, 0); - return 0; + return FALSE; } SHELLEXECUTEINFOW sei = {sizeof(sei)}; @@ -247,43 +139,50 @@ DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { } else { Wh_Log(L"ShellExecuteEx failed: %d", error); } - InterlockedExchange(&g_isProcessingClick, 0); - return 0; + return FALSE; } - Wh_Log(L"UAC cleared. Updating UI to target state: %d", enable); - InterlockedExchange(&g_networkIsUp, enable ? 1 : 0); + Wh_Log(L"UAC cleared. Updating UI to target state: %d", targetState); + InterlockedExchange(&g_networkIsUp, targetState ? 1 : 0); if (g_trayHwnd) { - PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)enable, 0); + PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)targetState, 0); } if (!sei.hProcess) { Wh_Log(L"No process handle returned"); - InterlockedExchange(&g_isProcessingClick, 0); - return 0; + return FALSE; } DWORD waitResult = WaitForSingleObject(sei.hProcess, 20000); - BOOL success = FALSE; if (waitResult == WAIT_TIMEOUT) { Wh_Log(L"Process timed out, terminating"); TerminateProcess(sei.hProcess, 1); - } else { - DWORD exitCode = 1; - if (GetExitCodeProcess(sei.hProcess, &exitCode)) { - success = (exitCode == 0); - Wh_Log(L"Process exited with code: %d", exitCode); - } + CloseHandle(sei.hProcess); + return FALSE; + } + + DWORD exitCode = 1; + if (!GetExitCodeProcess(sei.hProcess, &exitCode)) { + LogLastError(L"GetExitCodeProcess"); + CloseHandle(sei.hProcess); + return FALSE; } CloseHandle(sei.hProcess); + Wh_Log(L"Process exited with code: %d", exitCode); + return exitCode == 0; +} - if (success) { - Wh_Log(L"Network %s operation completed successfully", enable ? L"enable" : L"disable"); - } else { - Wh_Log(L"Network toggle operation failed"); - } +DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { + BOOL enable = (BOOL)(UINT_PTR)lpParam; + + Wh_Log(L"Toggling network adapters: %s", enable ? L"ENABLE" : L"DISABLE"); + LPCWSTR command = enable + ? L"Get-NetAdapter -Physical | Enable-NetAdapter -Confirm:$false" + : L"Get-NetAdapter -Physical | Disable-NetAdapter -Confirm:$false"; + BOOL success = RunPowerShellCommand(command, enable); + if (success) { Wh_Log(L"Network %s operation completed successfully", enable ? L"enable" : L"disable"); } else { @@ -294,18 +193,25 @@ DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { return 0; } -void UpdateTrayIconTooltip(BOOL enabled) { - if (!g_trayHwnd) return; - +void AddOrUpdateTrayIcon(HWND hWnd, BOOL enabled, BOOL isAdd) { NOTIFYICONDATAW nid = {sizeof(nid)}; - nid.hWnd = g_trayHwnd; + nid.hWnd = hWnd; nid.uID = TRAY_ICON_ID; - nid.uFlags = NIF_TIP | NIF_ICON; + nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_ICON; + nid.uCallbackMessage = WM_TRAY_CALLBACK; wsprintfW(nid.szTip, enabled ? L"Network Toggle: ON (Click to disable)" : L"Network Toggle: OFF (Click to enable)"); nid.hIcon = enabled ? g_iconEnabled : g_iconDisabled; - if (!Shell_NotifyIconW(NIM_MODIFY, &nid)) { - LogLastError(L"NIM_MODIFY"); + if (isAdd) { + if (!Shell_NotifyIconW(NIM_ADD, &nid)) { + LogLastError(L"Shell_NotifyIcon NIM_ADD"); + } else { + InterlockedExchange(&g_trayIconInstalled, 1); + } + } else { + if (!Shell_NotifyIconW(NIM_MODIFY, &nid)) { + LogLastError(L"Shell_NotifyIcon NIM_MODIFY"); + } } } @@ -336,20 +242,22 @@ void ProcessTrayClick() { } LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { - switch (msg) { - case WM_TRAY_CALLBACK: - if (lParam == WM_LBUTTONUP) { - ProcessTrayClick(); - } - return 0; - - case WM_UPDATE_TRAY_STATE: - UpdateTrayIconTooltip((BOOL)wParam); - return 0; - - case WM_DESTROY: - PostQuitMessage(0); - return 0; + if (msg == WM_TRAY_CALLBACK) { + if (lParam == WM_LBUTTONUP) { + ProcessTrayClick(); + } + return 0; + } else if (msg == WM_UPDATE_TRAY_STATE) { + AddOrUpdateTrayIcon(hWnd, (BOOL)wParam, FALSE); + return 0; + } else if (msg == WM_DESTROY) { + PostQuitMessage(0); + return 0; + } else if (msg == g_taskbarCreatedMsg && g_taskbarCreatedMsg != 0) { + // Explorer restarted! Re-add the tray icon automatically. + Wh_Log(L"Explorer restarted. Re-adding tray icon."); + AddOrUpdateTrayIcon(hWnd, g_networkIsUp, TRUE); + return 0; } return DefWindowProcW(hWnd, msg, wParam, lParam); } @@ -357,6 +265,8 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) DWORD WINAPI TrayThreadProc(LPVOID) { Wh_Log(L"Tray thread started"); + g_taskbarCreatedMsg = RegisterWindowMessageW(L"TaskbarCreated"); + BOOL initialState = CheckActualNetworkState(); InterlockedExchange(&g_networkIsUp, initialState ? 1 : 0); @@ -389,22 +299,7 @@ DWORD WINAPI TrayThreadProc(LPVOID) { g_trayHwnd = hWnd; - NOTIFYICONDATAW nid = {sizeof(nid)}; - nid.hWnd = hWnd; - nid.uID = TRAY_ICON_ID; - nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_ICON; - nid.uCallbackMessage = WM_TRAY_CALLBACK; - nid.hIcon = initialState ? g_iconEnabled : g_iconDisabled; - wsprintfW(nid.szTip, initialState ? L"Network Toggle: ON (Click to disable)" : L"Network Toggle: OFF (Click to enable)"); - - if (!Shell_NotifyIconW(NIM_ADD, &nid)) { - LogLastError(L"Shell_NotifyIcon NIM_ADD"); - DestroyWindow(hWnd); - UnregisterClassW(wc.lpszClassName, g_hInstance); - return 1; - } - - InterlockedExchange(&g_trayIconInstalled, 1); + AddOrUpdateTrayIcon(hWnd, initialState, TRUE); Wh_Log(L"Tray icon installed successfully. Initial state: %s", initialState ? L"ON" : L"OFF"); MSG msg; @@ -415,7 +310,11 @@ DWORD WINAPI TrayThreadProc(LPVOID) { Wh_Log(L"Tray message loop ending"); + NOTIFYICONDATAW nid = {sizeof(nid)}; + nid.hWnd = hWnd; + nid.uID = TRAY_ICON_ID; Shell_NotifyIconW(NIM_DELETE, &nid); + DestroyWindow(hWnd); UnregisterClassW(wc.lpszClassName, g_hInstance); InterlockedExchange(&g_trayIconInstalled, 0); @@ -424,25 +323,13 @@ DWORD WINAPI TrayThreadProc(LPVOID) { return 0; } -//////////////////////////////////////////////////////////////////////////////// -// Windhawk tool mod implementation -bool g_isToolModProcessLauncher; -HANDLE g_toolModProcessMutex; - -void WINAPI EntryPoint_Hook() { - Wh_Log(L"Tool mod process started. Entering message loop."); - MSG msg; - while (GetMessage(&msg, nullptr, 0, 0)) { - TranslateMessage(&msg); - DispatchMessage(&msg); - } - - Wh_Log(L"Tool mod message loop exited."); -} +// ============================================================================== +// TOOL MOD IMPLEMENTATION +// ============================================================================== BOOL WhTool_ModInit() { - Wh_Log(L"Network Toggle Tool Mod Init"); + Wh_Log(L"Network Toggle Mod Init"); g_hInstance = GetModuleHandle(nullptr); if (!g_hInstance) { @@ -456,29 +343,24 @@ BOOL WhTool_ModInit() { if (!g_iconEnabled) ExtractIconExW(L"shell32.dll", 9, nullptr, &g_iconEnabled, 1); if (!g_iconDisabled) ExtractIconExW(L"shell32.dll", 131, nullptr, &g_iconDisabled, 1); - Wh_Log(L"Icons loaded. Enabled: %p, Disabled: %p", g_iconEnabled, g_iconDisabled); + Wh_Log(L"Icons loaded successfully."); DWORD threadId = 0; - g_trayThread = CreateThread(nullptr, 0, TrayThreadProc, nullptr, CREATE_SUSPENDED, &threadId); + // Removed CREATE_SUSPENDED and SetThreadPriority per developer feedback + g_trayThread = CreateThread(nullptr, 0, TrayThreadProc, nullptr, 0, &threadId); if (!g_trayThread) { LogLastError(L"CreateThread"); return FALSE; } - SetThreadPriority(g_trayThread, THREAD_PRIORITY_ABOVE_NORMAL); - - if (ResumeThread(g_trayThread) == (DWORD)-1) { - LogLastError(L"ResumeThread"); - CloseHandle(g_trayThread); - g_trayThread = nullptr; - return FALSE; - } - - Wh_Log(L"Tray thread started successfully"); return TRUE; } +void WhTool_ModSettingsChanged() { + // Empty, but required to exist for the boilerplate compiler. +} + void WhTool_ModUninit() { Wh_Log(L"Network Toggle Mod Uninit"); @@ -508,15 +390,40 @@ void WhTool_ModUninit() { g_iconDisabled = nullptr; } - PostQuitMessage(0); Wh_Log(L"Network Toggle Mod Uninit complete"); } + //////////////////////////////////////////////////////////////////////////////// -// Windhawk tool mod bootstrap - runs in windhawk.exe process +// Windhawk tool mod implementation for mods which don't need to inject to other +// processes or hook other functions. Context: +// https://github.com/ramensoftware/windhawk/wiki/Mods-as-tools:-Running-mods-in-a-dedicated-process +// +// The mod will load and run in a dedicated windhawk.exe process. +// +// Paste the code below as part of the mod code, and use these callbacks: +// * WhTool_ModInit +// * WhTool_ModSettingsChanged +// * WhTool_ModUninit +// +// Currently, other callbacks are not supported. + +bool g_isToolModProcessLauncher; +HANDLE g_toolModProcessMutex; + +void WINAPI EntryPoint_Hook() { + Wh_Log(L">"); + ExitThread(0); +} BOOL Wh_ModInit() { - bool isService = false; + DWORD sessionId; + if (ProcessIdToSessionId(GetCurrentProcessId(), &sessionId) && + sessionId == 0) { + return FALSE; + } + + bool isExcluded = false; bool isToolModProcess = false; bool isCurrentToolModProcess = false; int argc; @@ -527,8 +434,10 @@ BOOL Wh_ModInit() { } for (int i = 1; i < argc; i++) { - if (wcscmp(argv[i], L"-service") == 0) { - isService = true; + if (wcscmp(argv[i], L"-service") == 0 || + wcscmp(argv[i], L"-service-start") == 0 || + wcscmp(argv[i], L"-service-stop") == 0) { + isExcluded = true; break; } } @@ -545,12 +454,13 @@ BOOL Wh_ModInit() { LocalFree(argv); - if (isService) { + if (isExcluded) { return FALSE; } if (isCurrentToolModProcess) { - g_toolModProcessMutex = CreateMutex(nullptr, TRUE, L"windhawk-tool-mod_" WH_MOD_ID); + g_toolModProcessMutex = + CreateMutex(nullptr, TRUE, L"windhawk-tool-mod_" WH_MOD_ID); if (!g_toolModProcessMutex) { Wh_Log(L"CreateMutex failed"); ExitProcess(1); @@ -565,8 +475,11 @@ BOOL Wh_ModInit() { ExitProcess(1); } - IMAGE_DOS_HEADER* dosHeader = (IMAGE_DOS_HEADER*)GetModuleHandle(nullptr); - IMAGE_NT_HEADERS* ntHeaders = (IMAGE_NT_HEADERS*)((BYTE*)dosHeader + dosHeader->e_lfanew); + IMAGE_DOS_HEADER* dosHeader = + (IMAGE_DOS_HEADER*)GetModuleHandle(nullptr); + IMAGE_NT_HEADERS* ntHeaders = + (IMAGE_NT_HEADERS*)((BYTE*)dosHeader + dosHeader->e_lfanew); + DWORD entryPointRVA = ntHeaders->OptionalHeader.AddressOfEntryPoint; void* entryPoint = (BYTE*)dosHeader + entryPointRVA; @@ -588,15 +501,19 @@ void Wh_ModAfterInit() { } WCHAR currentProcessPath[MAX_PATH]; - switch (GetModuleFileName(nullptr, currentProcessPath, ARRAYSIZE(currentProcessPath))) { + switch (GetModuleFileName(nullptr, currentProcessPath, + ARRAYSIZE(currentProcessPath))) { case 0: case ARRAYSIZE(currentProcessPath): Wh_Log(L"GetModuleFileName failed"); return; } - WCHAR commandLine[MAX_PATH + 2 + (sizeof(L" -tool-mod \"" WH_MOD_ID "\"") / sizeof(WCHAR)) - 1]; - swprintf_s(commandLine, L"\"%s\" -tool-mod \"%s\"", currentProcessPath, WH_MOD_ID); + WCHAR + commandLine[MAX_PATH + 2 + + (sizeof(L" -tool-mod \"" WH_MOD_ID "\"") / sizeof(WCHAR)) - 1]; + swprintf_s(commandLine, L"\"%s\" -tool-mod \"%s\"", currentProcessPath, + WH_MOD_ID); HMODULE kernelModule = GetModuleHandle(L"kernelbase.dll"); if (!kernelModule) { @@ -609,40 +526,41 @@ void Wh_ModAfterInit() { using CreateProcessInternalW_t = BOOL(WINAPI*)( HANDLE hUserToken, LPCWSTR lpApplicationName, LPWSTR lpCommandLine, - LPSECURITY_ATTRIBUTES lpProcessAttributes, LPSECURITY_ATTRIBUTES lpThreadAttributes, - WINBOOL bInheritHandles, DWORD dwCreationFlags, LPVOID lpEnvironment, - LPCWSTR lpCurrentDirectory, LPSTARTUPINFOW lpStartupInfo, - LPPROCESS_INFORMATION lpProcessInformation, PHANDLE hRestrictedUserToken); - + LPSECURITY_ATTRIBUTES lpProcessAttributes, + LPSECURITY_ATTRIBUTES lpThreadAttributes, WINBOOL bInheritHandles, + DWORD dwCreationFlags, LPVOID lpEnvironment, LPCWSTR lpCurrentDirectory, + LPSTARTUPINFOW lpStartupInfo, + LPPROCESS_INFORMATION lpProcessInformation, + PHANDLE hRestrictedUserToken); CreateProcessInternalW_t pCreateProcessInternalW = - (CreateProcessInternalW_t)GetProcAddress(kernelModule, "CreateProcessInternalW"); + (CreateProcessInternalW_t)GetProcAddress(kernelModule, + "CreateProcessInternalW"); if (!pCreateProcessInternalW) { Wh_Log(L"No CreateProcessInternalW"); return; } - STARTUPINFO si{ .cb = sizeof(STARTUPINFO), .dwFlags = STARTF_FORCEOFFFEEDBACK }; + STARTUPINFO si{ + .cb = sizeof(STARTUPINFO), + .dwFlags = STARTF_FORCEOFFFEEDBACK, + }; PROCESS_INFORMATION pi; if (!pCreateProcessInternalW(nullptr, currentProcessPath, commandLine, - nullptr, nullptr, FALSE, CREATE_NO_WINDOW, + nullptr, nullptr, FALSE, NORMAL_PRIORITY_CLASS, nullptr, nullptr, &si, &pi, nullptr)) { - Wh_Log(L"CreateProcess failed, error: %d", GetLastError()); + Wh_Log(L"CreateProcess failed"); return; } - Wh_Log(L"Launched tool mod process, PID: %d", pi.dwProcessId); CloseHandle(pi.hProcess); CloseHandle(pi.hThread); } -void WhTool_ModSettingsChanged() { - Wh_Log(L"Settings changed"); -} - void Wh_ModSettingsChanged() { if (g_isToolModProcessLauncher) { return; } + WhTool_ModSettingsChanged(); } @@ -650,6 +568,7 @@ void Wh_ModUninit() { if (g_isToolModProcessLauncher) { return; } + WhTool_ModUninit(); ExitProcess(0); } From 55c9ccc42ad5e679831afec5de12ab8ea37a8680 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:05:14 +0300 Subject: [PATCH 07/56] Revise usage instructions and add known issues Updated usage instructions and added known issues section. --- mods/net-toggle.wh.cpp | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/mods/net-toggle.wh.cpp b/mods/net-toggle.wh.cpp index 28c81ce60a..4de7e0a64d 100644 --- a/mods/net-toggle.wh.cpp +++ b/mods/net-toggle.wh.cpp @@ -21,13 +21,16 @@ One click drops your connection. Click it again, and you're back online. ## 📖 How to Use It Using the toggle is incredibly simple: -1. 🔍 **Find the Icon:** Look in your system tray (bottom right of your screen, next to the clock) for the little `^` arrow. hit it and look for the network icon. -2. 🎯 **Click to Toggle:** Give the icon a single click. -3. ✅ **Approve the Prompt:** Windows will pop up a quick UAC screen asking for permission. Click **Yes**. -4. 🕒 **Wait a Sec:** The tray icon will update instantly, but give Windows just a few seconds to actually power your network adapters down or back up in the background. - - > **Why do I need to accept the UAC?** - > +1. 🔍 **Find the Icon:** + > **Look in your system tray (bottom right of your screen, next to the clock) for the little `^` arrow. hit it and look for the network icon.** +2. 🎯 **Click to Toggle:** + > **Give the icon a single click.** +3. ✅ **Approve the Prompt:** + > **Windows will pop up a quick UAC screen asking for permission. Click **Yes**.** +4. 🕒 **Wait a Sec:** + > **The tray icon will update instantly, but give Windows just a few seconds to actually power your network adapters down or back up in the background.** + +**Why do I need to accept the UAC?** > Windows requires admin permission to physically turn off your network hardware. > > This is a built-in security feature to stop rogue background apps from disconnecting you secretly. @@ -36,6 +39,10 @@ Using the toggle is incredibly simple: - **Instant Access:** Your network power switch is always exactly one click away. - **Native Look & Feel:** Uses official Windows system icons, so it blends perfectly into your taskbar. - **Smart & Safe:** Only toggles your actual, physical hardware. It completely ignores virtual networks (like WSL or VMs), so your local environments stay perfectly intact! + +## ⚠️ Known Issues +- **The UAC Popup:** You will get a User Account Control prompt *every time* you use the toggle. While it adds an extra click, it's an unavoidable Windows security rule for turning physical hardware on and off. +- **Icon Grouping on the Taskbar:** If you try to manually drag the icon out of the hidden `^` menu to drop it onto your main taskbar, Windows might get confused and group it with the main Windhawk app icon. this is still fully functional but showing 2 icons instead of 1. */ // ==/WindhawkModReadme== From bbb318d91ed664058e29f94c54c1e7bd82e8d594 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:38:14 +0300 Subject: [PATCH 08/56] Add AudioSwap mod for quick audio output switching This mod adds a tray icon for toggling between two audio outputs, allowing users to switch devices quickly. --- mods/AudioSwap | 497 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 497 insertions(+) create mode 100644 mods/AudioSwap diff --git a/mods/AudioSwap b/mods/AudioSwap new file mode 100644 index 0000000000..e556057a8f --- /dev/null +++ b/mods/AudioSwap @@ -0,0 +1,497 @@ +// ==WindhawkMod== +// @id audioswap +// @name AudioSwap +// @description Adds a tray icon to instantly toggle between two preferred audio outputs. +// @version 1.0 +// @author BlackPaw +// @github https://github.com/BlackPaw21 +// @include windhawk.exe +// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lshlwapi +// ==/WindhawkMod== + +// ==WindhawkModReadme== +/* +# Audio Output Switcher +A lightning-fast audio toggle right in your taskbar! 🎧🔊 + +## 📖 How to Use It +1. ⚙️ **Configure Your Devices:** Go to the **Settings** tab. + - Type the Name of your device names (e.g., "Headphones" and "Speakers"). + - Select an icon for each device from the dropdown menus. +2. 🔍 **Find the Icon:** Look in your system tray. The icon will change based on which device is currently active! +3. 🎯 **Click to Toggle:** A single click swaps your audio and updates the icon instantly. +*/ +// ==/WindhawkModReadme== + +// ==WindhawkModSettings== +/* +- device1: "Headphones" + $name: First Audio Device Name + $description: Name of the first device's name. +- icon1: headphones1 + $name: First Device Icon + $options: + - headphones1: Headphones (normal) + - headphones2: Modern Headset (white) + - speaker1: Basic Speaker (normal) + - speaker2: Modern Speaker (white) +- device2: "Speakers" + $name: Second Audio Device Name + $description: Name of the second device's name. +- icon2: speaker1 + $name: Second Device Icon + $options: + - headphones1: Headphones (normal) + - headphones2: Modern Headset (white) + - speaker1: Basic Speaker (normal) + - speaker2: Modern Speaker (white) +*/ +// ==/WindhawkModSettings== + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#define TRAY_ICON_ID 1 +#define WM_TRAY_CALLBACK (WM_USER + 1) +#define WM_UPDATE_TRAY_STATE (WM_USER + 2) + +const DWORD CLICK_DEBOUNCE_MS = 500; + +static volatile LONG g_isProcessingClick = 0; +static HANDLE g_trayThread = nullptr; +static HANDLE g_workerThread = nullptr; // Track worker thread +static HWND g_trayHwnd = nullptr; +static HINSTANCE g_hInstance = nullptr; +static HICON g_iconDev1 = nullptr; +static HICON g_iconDev2 = nullptr; +static DWORD g_lastClickTime = 0; +static UINT g_taskbarCreatedMsg = 0; + +// Cached settings +static WCHAR g_cachedDev1[256] = {0}; +static WCHAR g_cachedDev2[256] = {0}; + +const CLSID CLSID_CPolicyConfigClient = { + 0x870af99c, 0x171d, 0x4f9e, {0xaf, 0x0d, 0xe6, 0x3d, 0xf4, 0x0c, 0x2b, 0xc9} +}; +const IID IID_IPolicyConfig_Win10_11 = { + 0xf8679f50, 0x850a, 0x41cf, {0x9c, 0x72, 0x43, 0x0f, 0x29, 0x02, 0x90, 0xc8} +}; + +MIDL_INTERFACE("f8679f50-850a-41cf-9c72-430f290290c8") +IPolicyConfig : public IUnknown +{ +public: + virtual HRESULT STDMETHODCALLTYPE GetMixFormat(PCWSTR, void**) = 0; + virtual HRESULT STDMETHODCALLTYPE GetDeviceFormat(PCWSTR, INT, void**) = 0; + virtual HRESULT STDMETHODCALLTYPE ResetDeviceFormat(PCWSTR) = 0; + virtual HRESULT STDMETHODCALLTYPE SetDeviceFormat(PCWSTR, void*, void*) = 0; + virtual HRESULT STDMETHODCALLTYPE GetProcessingPeriod(PCWSTR, INT, PINT, PINT) = 0; + virtual HRESULT STDMETHODCALLTYPE SetProcessingPeriod(PCWSTR, PINT) = 0; + virtual HRESULT STDMETHODCALLTYPE GetShareMode(PCWSTR, void*) = 0; + virtual HRESULT STDMETHODCALLTYPE SetShareMode(PCWSTR, void*) = 0; + virtual HRESULT STDMETHODCALLTYPE GetPropertyValue(PCWSTR, const PROPERTYKEY&, PROPVARIANT*) = 0; + virtual HRESULT STDMETHODCALLTYPE SetPropertyValue(PCWSTR, const PROPERTYKEY&, PROPVARIANT*) = 0; + virtual HRESULT STDMETHODCALLTYPE SetDefaultEndpoint(PCWSTR wszDeviceId, ERole eRole) = 0; + virtual HRESULT STDMETHODCALLTYPE SetEndpointVisibility(PCWSTR, INT) = 0; +}; + +int GetIconIndex(PCWSTR iconSetting) { + if (iconSetting) { + if (wcscmp(iconSetting, L"headphones1") == 0) return 2; + if (wcscmp(iconSetting, L"headphones2") == 0) return 91; + if (wcscmp(iconSetting, L"speaker1") == 0) return 4; + if (wcscmp(iconSetting, L"speaker2") == 0) return 93; + } + return 4; +} + +void LoadUserIconsAndSettings() { + if (g_iconDev1) DestroyIcon(g_iconDev1); + if (g_iconDev2) DestroyIcon(g_iconDev2); + + PCWSTR s1 = Wh_GetStringSetting(L"icon1"); + PCWSTR s2 = Wh_GetStringSetting(L"icon2"); + + ExtractIconExW(L"ddores.dll", GetIconIndex(s1), nullptr, &g_iconDev1, 1); + ExtractIconExW(L"ddores.dll", GetIconIndex(s2), nullptr, &g_iconDev2, 1); + + if (s1) Wh_FreeStringSetting(s1); + if (s2) Wh_FreeStringSetting(s2); + + PCWSTR rawDev1 = Wh_GetStringSetting(L"device1"); + PCWSTR rawDev2 = Wh_GetStringSetting(L"device2"); + if (rawDev1) { + lstrcpynW(g_cachedDev1, rawDev1, 256); + Wh_FreeStringSetting(rawDev1); + } + if (rawDev2) { + lstrcpynW(g_cachedDev2, rawDev2, 256); + Wh_FreeStringSetting(rawDev2); + } +} + +void UpdateTrayTip(HWND hWnd, BOOL isAdd) { + WCHAR currentDev[256] = L"Unknown Device"; + HICON currentIcon = g_iconDev1; + + IMMDeviceEnumerator *pEnum = nullptr; + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { + IMMDevice *pDefaultDevice = nullptr; + if (SUCCEEDED(pEnum->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDefaultDevice))) { + IPropertyStore *pStore = nullptr; + if (SUCCEEDED(pDefaultDevice->OpenPropertyStore(STGM_READ, &pStore))) { + PROPVARIANT varName; + PropVariantInit(&varName); + if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &varName)) && varName.pwszVal) { + lstrcpynW(currentDev, varName.pwszVal, 256); + } + PropVariantClear(&varName); + pStore->Release(); + } + pDefaultDevice->Release(); + } + pEnum->Release(); + } + + if (StrStrIW(currentDev, g_cachedDev1)) currentIcon = g_iconDev1; + else if (StrStrIW(currentDev, g_cachedDev2)) currentIcon = g_iconDev2; + + // FIX: CPU Optimization - Only redraw the shell icon if data actually changed + static WCHAR s_lastDev[256] = {0}; + static HICON s_lastIcon = nullptr; + if (!isAdd && wcscmp(currentDev, s_lastDev) == 0 && currentIcon == s_lastIcon) { + return; + } + lstrcpyW(s_lastDev, currentDev); + s_lastIcon = currentIcon; + + NOTIFYICONDATAW nid = {sizeof(nid)}; + nid.hWnd = hWnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_ICON; + nid.uCallbackMessage = WM_TRAY_CALLBACK; + swprintf_s(nid.szTip, L"Audio: %s", currentDev); + nid.hIcon = currentIcon; + + Shell_NotifyIconW(isAdd ? NIM_ADD : NIM_MODIFY, &nid); +} + +BOOL ToggleAudioDevice() { + CoInitialize(nullptr); + IMMDeviceEnumerator *pEnum = nullptr; + if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { + CoUninitialize(); return FALSE; + } + + IMMDevice *pDefaultDevice = nullptr; + if (FAILED(pEnum->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDefaultDevice))) { + pEnum->Release(); CoUninitialize(); return FALSE; + } + + IPropertyStore *pStore = nullptr; + pDefaultDevice->OpenPropertyStore(STGM_READ, &pStore); + PROPVARIANT varName; PropVariantInit(&varName); + pStore->GetValue(PKEY_Device_FriendlyName, &varName); + + BOOL isDev1Active = (varName.pwszVal && StrStrIW(varName.pwszVal, g_cachedDev1)); + PropVariantClear(&varName); pStore->Release(); pDefaultDevice->Release(); + + PCWSTR targetStr = isDev1Active ? g_cachedDev2 : g_cachedDev1; + IMMDeviceCollection *pDevices = nullptr; + pEnum->EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE, &pDevices); + UINT count = 0; pDevices->GetCount(&count); + + LPWSTR targetId = nullptr; + for (UINT i = 0; i < count; i++) { + IMMDevice *pDevice = nullptr; + pDevices->Item(i, &pDevice); + pDevice->OpenPropertyStore(STGM_READ, &pStore); + PropVariantInit(&varName); + pStore->GetValue(PKEY_Device_FriendlyName, &varName); + if (varName.pwszVal && StrStrIW(varName.pwszVal, targetStr)) { + pDevice->GetId(&targetId); + PropVariantClear(&varName); pStore->Release(); pDevice->Release(); + break; + } + PropVariantClear(&varName); pStore->Release(); pDevice->Release(); + } + + if (targetId) { + IPolicyConfig *pPolicyConfig = nullptr; + if (SUCCEEDED(CoCreateInstance(CLSID_CPolicyConfigClient, nullptr, CLSCTX_ALL, IID_IPolicyConfig_Win10_11, (void**)&pPolicyConfig))) { + pPolicyConfig->SetDefaultEndpoint(targetId, eConsole); + pPolicyConfig->SetDefaultEndpoint(targetId, eMultimedia); + pPolicyConfig->SetDefaultEndpoint(targetId, eCommunications); + pPolicyConfig->Release(); + } + CoTaskMemFree(targetId); + } + pDevices->Release(); pEnum->Release(); CoUninitialize(); + return TRUE; +} + +DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { + if (ToggleAudioDevice() && g_trayHwnd) { + PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, 0, 0); + } + InterlockedExchange(&g_isProcessingClick, 0); + return 0; +} + +LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { + if (msg == WM_TRAY_CALLBACK && lParam == WM_LBUTTONUP) { + if (InterlockedExchange(&g_isProcessingClick, 1) == 0) { + DWORD now = GetTickCount(); + if (now - g_lastClickTime > CLICK_DEBOUNCE_MS) { // Corrected check + g_lastClickTime = now; + if (g_workerThread) { CloseHandle(g_workerThread); g_workerThread = nullptr; } + g_workerThread = CreateThread(nullptr, 0, WorkerThreadProc, nullptr, 0, nullptr); + } else InterlockedExchange(&g_isProcessingClick, 0); + } + } else if (msg == WM_UPDATE_TRAY_STATE || (msg == WM_TIMER && wParam == 1)) { + UpdateTrayTip(hWnd, FALSE); + } else if (msg == g_taskbarCreatedMsg && g_taskbarCreatedMsg != 0) { + UpdateTrayTip(hWnd, TRUE); + } else if (msg == WM_DESTROY) PostQuitMessage(0); + return DefWindowProcW(hWnd, msg, wParam, lParam); +} + +DWORD WINAPI TrayThreadProc(LPVOID) { + CoInitialize(nullptr); + g_taskbarCreatedMsg = RegisterWindowMessageW(L"TaskbarCreated"); + WNDCLASSW wc = {0}; + wc.lpfnWndProc = TrayWndProc; + wc.hInstance = g_hInstance; + wc.lpszClassName = L"AudioSwitcherWindowClass"; + RegisterClassW(&wc); + g_trayHwnd = CreateWindowExW(0, wc.lpszClassName, L"Audio Switcher", WS_OVERLAPPED, 0,0,1,1, nullptr, nullptr, g_hInstance, nullptr); + + IPropertyStore* pps = nullptr; + if (SUCCEEDED(SHGetPropertyStoreForWindow(g_trayHwnd, IID_PPV_ARGS(&pps)))) { + PROPVARIANT var; var.vt = VT_LPWSTR; var.pwszVal = (LPWSTR)CoTaskMemAlloc(MAX_PATH); + if (var.pwszVal) { + lstrcpyW(var.pwszVal, L"BlackPaw.AudioSwitcher"); + pps->SetValue(PKEY_AppUserModel_ID, var); + CoTaskMemFree(var.pwszVal); + } + pps->Commit(); pps->Release(); + } + + SetTimer(g_trayHwnd, 1, 1500, nullptr); + UpdateTrayTip(g_trayHwnd, TRUE); + MSG msg; + while (GetMessageW(&msg, nullptr, 0, 0) > 0) { DispatchMessageW(&msg); } + CoUninitialize(); + return 0; +} + +BOOL WhTool_ModInit() { + Wh_Log(L"AudioSwap Mod Init"); + g_hInstance = GetModuleHandle(nullptr); + LoadUserIconsAndSettings(); + g_trayThread = CreateThread(nullptr, 0, TrayThreadProc, nullptr, 0, nullptr); + return TRUE; +} + +void WhTool_ModSettingsChanged() { + LoadUserIconsAndSettings(); + if (g_trayHwnd) PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, 0, 0); +} + +void WhTool_ModUninit() { + Wh_Log(L"AudioSwap Mod Uninit"); + if (g_trayHwnd) PostMessageW(g_trayHwnd, WM_DESTROY, 0, 0); + if (g_trayThread) { WaitForSingleObject(g_trayThread, 2000); CloseHandle(g_trayThread); g_trayThread = nullptr; } + if (g_workerThread) { WaitForSingleObject(g_workerThread, 2000); CloseHandle(g_workerThread); g_workerThread = nullptr; } + if (g_iconDev1) { DestroyIcon(g_iconDev1); g_iconDev1 = nullptr; } + if (g_iconDev2) { DestroyIcon(g_iconDev2); g_iconDev2 = nullptr; } +} + +//////////////////////////////////////////////////////////////////////////////// +// Windhawk tool mod implementation for mods which don't need to inject to other +// processes or hook other functions. Context: +// https://github.com/ramensoftware/windhawk/wiki/Mods-as-tools:-Running-mods-in-a-dedicated-process +// +// The mod will load and run in a dedicated windhawk.exe process. +// +// Paste the code below as part of the mod code, and use these callbacks: +// * WhTool_ModInit +// * WhTool_ModSettingsChanged +// * WhTool_ModUninit +// +// Currently, other callbacks are not supported. + +bool g_isToolModProcessLauncher; +HANDLE g_toolModProcessMutex; + +void WINAPI EntryPoint_Hook() { + Wh_Log(L">"); + ExitThread(0); +} + +BOOL Wh_ModInit() { + DWORD sessionId; + if (ProcessIdToSessionId(GetCurrentProcessId(), &sessionId) && + sessionId == 0) { + return FALSE; + } + + bool isExcluded = false; + bool isToolModProcess = false; + bool isCurrentToolModProcess = false; + int argc; + LPWSTR* argv = CommandLineToArgvW(GetCommandLine(), &argc); + if (!argv) { + Wh_Log(L"CommandLineToArgvW failed"); + return FALSE; + } + + for (int i = 1; i < argc; i++) { + if (wcscmp(argv[i], L"-service") == 0 || + wcscmp(argv[i], L"-service-start") == 0 || + wcscmp(argv[i], L"-service-stop") == 0) { + isExcluded = true; + break; + } + } + + for (int i = 1; i < argc - 1; i++) { + if (wcscmp(argv[i], L"-tool-mod") == 0) { + isToolModProcess = true; + if (wcscmp(argv[i + 1], WH_MOD_ID) == 0) { + isCurrentToolModProcess = true; + } + break; + } + } + + LocalFree(argv); + + if (isExcluded) { + return FALSE; + } + + if (isCurrentToolModProcess) { + g_toolModProcessMutex = + CreateMutex(nullptr, TRUE, L"windhawk-tool-mod_" WH_MOD_ID); + if (!g_toolModProcessMutex) { + Wh_Log(L"CreateMutex failed"); + ExitProcess(1); + } + + if (GetLastError() == ERROR_ALREADY_EXISTS) { + Wh_Log(L"Tool mod already running (%s)", WH_MOD_ID); + ExitProcess(1); + } + + if (!WhTool_ModInit()) { + ExitProcess(1); + } + + IMAGE_DOS_HEADER* dosHeader = + (IMAGE_DOS_HEADER*)GetModuleHandle(nullptr); + IMAGE_NT_HEADERS* ntHeaders = + (IMAGE_NT_HEADERS*)((BYTE*)dosHeader + dosHeader->e_lfanew); + + DWORD entryPointRVA = ntHeaders->OptionalHeader.AddressOfEntryPoint; + void* entryPoint = (BYTE*)dosHeader + entryPointRVA; + + Wh_SetFunctionHook(entryPoint, (void*)EntryPoint_Hook, nullptr); + return TRUE; + } + + if (isToolModProcess) { + return FALSE; + } + + g_isToolModProcessLauncher = true; + return TRUE; +} + +void Wh_ModAfterInit() { + if (!g_isToolModProcessLauncher) { + return; + } + + WCHAR currentProcessPath[MAX_PATH]; + switch (GetModuleFileName(nullptr, currentProcessPath, + ARRAYSIZE(currentProcessPath))) { + case 0: + case ARRAYSIZE(currentProcessPath): + Wh_Log(L"GetModuleFileName failed"); + return; + } + + WCHAR + commandLine[MAX_PATH + 2 + + (sizeof(L" -tool-mod \"" WH_MOD_ID "\"") / sizeof(WCHAR)) - 1]; + swprintf_s(commandLine, L"\"%s\" -tool-mod \"%s\"", currentProcessPath, + WH_MOD_ID); + + HMODULE kernelModule = GetModuleHandle(L"kernelbase.dll"); + if (!kernelModule) { + kernelModule = GetModuleHandle(L"kernel32.dll"); + if (!kernelModule) { + Wh_Log(L"No kernelbase.dll/kernel32.dll"); + return; + } + } + + using CreateProcessInternalW_t = BOOL(WINAPI*)( + HANDLE hUserToken, LPCWSTR lpApplicationName, LPWSTR lpCommandLine, + LPSECURITY_ATTRIBUTES lpProcessAttributes, + LPSECURITY_ATTRIBUTES lpThreadAttributes, WINBOOL bInheritHandles, + DWORD dwCreationFlags, LPVOID lpEnvironment, LPCWSTR lpCurrentDirectory, + LPSTARTUPINFOW lpStartupInfo, + LPPROCESS_INFORMATION lpProcessInformation, + PHANDLE hRestrictedUserToken); + CreateProcessInternalW_t pCreateProcessInternalW = + (CreateProcessInternalW_t)GetProcAddress(kernelModule, + "CreateProcessInternalW"); + if (!pCreateProcessInternalW) { + Wh_Log(L"No CreateProcessInternalW"); + return; + } + + STARTUPINFO si{ + .cb = sizeof(STARTUPINFO), + .dwFlags = STARTF_FORCEOFFFEEDBACK, + }; + PROCESS_INFORMATION pi; + if (!pCreateProcessInternalW(nullptr, currentProcessPath, commandLine, + nullptr, nullptr, FALSE, NORMAL_PRIORITY_CLASS, + nullptr, nullptr, &si, &pi, nullptr)) { + Wh_Log(L"CreateProcess failed"); + return; + } + + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); +} + +void Wh_ModSettingsChanged() { + if (g_isToolModProcessLauncher) { + return; + } + + WhTool_ModSettingsChanged(); +} + +void Wh_ModUninit() { + if (g_isToolModProcessLauncher) { + return; + } + + WhTool_ModUninit(); + ExitProcess(0); +} From c738f84c6f98f6245620b15ead24300a0d831f86 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:38:35 +0300 Subject: [PATCH 09/56] Rename AudioSwap to AudioSwap.wh.cpp --- mods/{AudioSwap => AudioSwap.wh.cpp} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename mods/{AudioSwap => AudioSwap.wh.cpp} (100%) diff --git a/mods/AudioSwap b/mods/AudioSwap.wh.cpp similarity index 100% rename from mods/AudioSwap rename to mods/AudioSwap.wh.cpp From 870f6798c2bbd25470eabda6ee7cac94ad102594 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:55:22 +0300 Subject: [PATCH 10/56] Update and rename AudioSwap.wh.cpp to audioswap.wh.cpp --- mods/{AudioSwap.wh.cpp => audioswap.wh.cpp} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename mods/{AudioSwap.wh.cpp => audioswap.wh.cpp} (100%) diff --git a/mods/AudioSwap.wh.cpp b/mods/audioswap.wh.cpp similarity index 100% rename from mods/AudioSwap.wh.cpp rename to mods/audioswap.wh.cpp From e22918bf77ab5d398832fe506c2b0c6262622f72 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Thu, 16 Apr 2026 02:40:35 +0300 Subject: [PATCH 11/56] Bump version to 1.1.0 and enhance device selection Updated version to 1.1.0 and improved device selection handling. now devices can be automatically detected and selected via Right Click on the icon. --- mods/audioswap.wh.cpp | 355 +++++++++++++++++++++++++++++++----------- 1 file changed, 265 insertions(+), 90 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index e556057a8f..b9f2120978 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -2,32 +2,46 @@ // @id audioswap // @name AudioSwap // @description Adds a tray icon to instantly toggle between two preferred audio outputs. -// @version 1.0 +// @version 1.1.0 // @author BlackPaw // @github https://github.com/BlackPaw21 // @include windhawk.exe -// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lshlwapi +// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lshlwapi -ladvapi32 // ==/WindhawkMod== // ==WindhawkModReadme== /* -# Audio Output Switcher -A lightning-fast audio toggle right in your taskbar! 🎧🔊 - -## 📖 How to Use It -1. ⚙️ **Configure Your Devices:** Go to the **Settings** tab. - - Type the Name of your device names (e.g., "Headphones" and "Speakers"). - - Select an icon for each device from the dropdown menus. -2. 🔍 **Find the Icon:** Look in your system tray. The icon will change based on which device is currently active! -3. 🎯 **Click to Toggle:** A single click swaps your audio and updates the icon instantly. +# AudioSwap +Instantly toggle between two audio output devices from your system tray — no diving into Sound settings. + +--- + +## How to Use + +1. **Choose Icons** — Open the **Settings** tab and pick an icon for each of your two devices. +2. **Select Your Devices** — Right-click the tray icon. Use **Set as Device 1** and **Set as Device 2** to assign your outputs from the live device list. +3. **Toggle** — Left-click the tray icon at any time to swap between the two devices instantly. + +> The tray tooltip always shows the active device. On first run it will read *"Right-click to configure"* until both devices are assigned. + +--- + +## Changelog + +### v1.1.0 +- **New:** Right-click context menu — auto-detects all active audio outputs and lets you assign Device 1 and Device 2 directly from a live list. No more typing device names manually. +- **New:** Device selections persist across restarts via the registry (`HKCU\Software\WindHawk\AudioSwap`). +- **Improved:** Toggle now matches devices by their unique system ID instead of a name substring search — works correctly regardless of how Windows names your device. +- **Improved:** Tray tooltip prompts you to configure on first run instead of showing "Unknown Device". +- **Removed:** Device name text fields from the Settings tab (replaced by the right-click menu). + +### v1.0.1 +- Initial release. */ // ==/WindhawkModReadme== // ==WindhawkModSettings== /* -- device1: "Headphones" - $name: First Audio Device Name - $description: Name of the first device's name. - icon1: headphones1 $name: First Device Icon $options: @@ -35,9 +49,6 @@ A lightning-fast audio toggle right in your taskbar! 🎧🔊 - headphones2: Modern Headset (white) - speaker1: Basic Speaker (normal) - speaker2: Modern Speaker (white) -- device2: "Speakers" - $name: Second Audio Device Name - $description: Name of the second device's name. - icon2: speaker1 $name: Second Device Icon $options: @@ -59,26 +70,32 @@ A lightning-fast audio toggle right in your taskbar! 🎧🔊 #include #include #include +#include #define TRAY_ICON_ID 1 #define WM_TRAY_CALLBACK (WM_USER + 1) #define WM_UPDATE_TRAY_STATE (WM_USER + 2) +#define MENU_DEVICE1_BASE 1000 +#define MENU_DEVICE2_BASE 2000 +#define MENU_MAX_DEVICES 32 const DWORD CLICK_DEBOUNCE_MS = 500; static volatile LONG g_isProcessingClick = 0; static HANDLE g_trayThread = nullptr; -static HANDLE g_workerThread = nullptr; // Track worker thread +static HANDLE g_workerThread = nullptr; static HWND g_trayHwnd = nullptr; static HINSTANCE g_hInstance = nullptr; -static HICON g_iconDev1 = nullptr; -static HICON g_iconDev2 = nullptr; +static HICON g_iconDev1 = nullptr; +static HICON g_iconDev2 = nullptr; static DWORD g_lastClickTime = 0; static UINT g_taskbarCreatedMsg = 0; -// Cached settings -static WCHAR g_cachedDev1[256] = {0}; -static WCHAR g_cachedDev2[256] = {0}; +// Cached device selections (loaded from registry) +static WCHAR g_cachedDev1Id[512] = {0}; +static WCHAR g_cachedDev1Name[256] = {0}; +static WCHAR g_cachedDev2Id[512] = {0}; +static WCHAR g_cachedDev2Name[256] = {0}; const CLSID CLSID_CPolicyConfigClient = { 0x870af99c, 0x171d, 0x4f9e, {0xaf, 0x0d, 0xe6, 0x3d, 0xf4, 0x0c, 0x2b, 0xc9} @@ -115,6 +132,57 @@ int GetIconIndex(PCWSTR iconSetting) { return 4; } +void LoadDeviceSelections() { + // Zero out first so stale data is cleared if key doesn't exist + g_cachedDev1Id[0] = g_cachedDev1Name[0] = L'\0'; + g_cachedDev2Id[0] = g_cachedDev2Name[0] = L'\0'; + + HKEY hKey = nullptr; + if (RegOpenKeyExW(HKEY_CURRENT_USER, L"Software\\WindHawk\\AudioSwap", + 0, KEY_READ, &hKey) != ERROR_SUCCESS) { + return; + } + + DWORD size; + size = sizeof(g_cachedDev1Id); + RegQueryValueExW(hKey, L"Device1Id", nullptr, nullptr, (LPBYTE)g_cachedDev1Id, &size); + size = sizeof(g_cachedDev1Name); + RegQueryValueExW(hKey, L"Device1Name", nullptr, nullptr, (LPBYTE)g_cachedDev1Name, &size); + size = sizeof(g_cachedDev2Id); + RegQueryValueExW(hKey, L"Device2Id", nullptr, nullptr, (LPBYTE)g_cachedDev2Id, &size); + size = sizeof(g_cachedDev2Name); + RegQueryValueExW(hKey, L"Device2Name", nullptr, nullptr, (LPBYTE)g_cachedDev2Name, &size); + + RegCloseKey(hKey); +} + +void SaveDeviceSelection(int slot, PCWSTR deviceId, PCWSTR friendlyName) { + HKEY hKey = nullptr; + if (RegCreateKeyExW(HKEY_CURRENT_USER, L"Software\\WindHawk\\AudioSwap", + 0, nullptr, REG_OPTION_NON_VOLATILE, KEY_WRITE, + nullptr, &hKey, nullptr) != ERROR_SUCCESS) { + return; + } + + if (slot == 1) { + RegSetValueExW(hKey, L"Device1Id", 0, REG_SZ, (const BYTE*)deviceId, + (lstrlenW(deviceId) + 1) * sizeof(WCHAR)); + RegSetValueExW(hKey, L"Device1Name", 0, REG_SZ, (const BYTE*)friendlyName, + (lstrlenW(friendlyName) + 1) * sizeof(WCHAR)); + lstrcpynW(g_cachedDev1Id, deviceId, 512); + lstrcpynW(g_cachedDev1Name, friendlyName, 256); + } else { + RegSetValueExW(hKey, L"Device2Id", 0, REG_SZ, (const BYTE*)deviceId, + (lstrlenW(deviceId) + 1) * sizeof(WCHAR)); + RegSetValueExW(hKey, L"Device2Name", 0, REG_SZ, (const BYTE*)friendlyName, + (lstrlenW(friendlyName) + 1) * sizeof(WCHAR)); + lstrcpynW(g_cachedDev2Id, deviceId, 512); + lstrcpynW(g_cachedDev2Name, friendlyName, 256); + } + + RegCloseKey(hKey); +} + void LoadUserIconsAndSettings() { if (g_iconDev1) DestroyIcon(g_iconDev1); if (g_iconDev2) DestroyIcon(g_iconDev2); @@ -127,34 +195,29 @@ void LoadUserIconsAndSettings() { if (s1) Wh_FreeStringSetting(s1); if (s2) Wh_FreeStringSetting(s2); - - PCWSTR rawDev1 = Wh_GetStringSetting(L"device1"); - PCWSTR rawDev2 = Wh_GetStringSetting(L"device2"); - if (rawDev1) { - lstrcpynW(g_cachedDev1, rawDev1, 256); - Wh_FreeStringSetting(rawDev1); - } - if (rawDev2) { - lstrcpynW(g_cachedDev2, rawDev2, 256); - Wh_FreeStringSetting(rawDev2); - } } void UpdateTrayTip(HWND hWnd, BOOL isAdd) { WCHAR currentDev[256] = L"Unknown Device"; - HICON currentIcon = g_iconDev1; - - IMMDeviceEnumerator *pEnum = nullptr; - if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { - IMMDevice *pDefaultDevice = nullptr; + WCHAR currentId[512] = {0}; + HICON currentIcon = g_iconDev1; + + IMMDeviceEnumerator* pEnum = nullptr; + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { + IMMDevice* pDefaultDevice = nullptr; if (SUCCEEDED(pEnum->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDefaultDevice))) { - IPropertyStore *pStore = nullptr; + LPWSTR pId = nullptr; + if (SUCCEEDED(pDefaultDevice->GetId(&pId))) { + lstrcpynW(currentId, pId, 512); + CoTaskMemFree(pId); + } + IPropertyStore* pStore = nullptr; if (SUCCEEDED(pDefaultDevice->OpenPropertyStore(STGM_READ, &pStore))) { PROPVARIANT varName; PropVariantInit(&varName); - if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &varName)) && varName.pwszVal) { + if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &varName)) && varName.pwszVal) lstrcpynW(currentDev, varName.pwszVal, 256); - } PropVariantClear(&varName); pStore->Release(); } @@ -162,16 +225,18 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { } pEnum->Release(); } - - if (StrStrIW(currentDev, g_cachedDev1)) currentIcon = g_iconDev1; - else if (StrStrIW(currentDev, g_cachedDev2)) currentIcon = g_iconDev2; - // FIX: CPU Optimization - Only redraw the shell icon if data actually changed + // ID-based icon selection — exact match, no fuzzy strings + if (g_cachedDev1Id[0] != L'\0' && wcscmp(currentId, g_cachedDev1Id) == 0) + currentIcon = g_iconDev1; + else if (g_cachedDev2Id[0] != L'\0' && wcscmp(currentId, g_cachedDev2Id) == 0) + currentIcon = g_iconDev2; + + // CPU opt: skip redraw if nothing changed static WCHAR s_lastDev[256] = {0}; static HICON s_lastIcon = nullptr; - if (!isAdd && wcscmp(currentDev, s_lastDev) == 0 && currentIcon == s_lastIcon) { - return; - } + if (!isAdd && wcscmp(currentDev, s_lastDev) == 0 && currentIcon == s_lastIcon) + return; lstrcpyW(s_lastDev, currentDev); s_lastIcon = currentIcon; @@ -180,64 +245,170 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { nid.uID = TRAY_ICON_ID; nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_ICON; nid.uCallbackMessage = WM_TRAY_CALLBACK; - swprintf_s(nid.szTip, L"Audio: %s", currentDev); + + if (g_cachedDev1Id[0] == L'\0' || g_cachedDev2Id[0] == L'\0') + swprintf_s(nid.szTip, L"AudioSwap: Right-click to configure"); + else + swprintf_s(nid.szTip, L"Audio: %s", currentDev); + nid.hIcon = currentIcon; - Shell_NotifyIconW(isAdd ? NIM_ADD : NIM_MODIFY, &nid); } BOOL ToggleAudioDevice() { + // Guard: no-op if devices not configured yet + if (g_cachedDev1Id[0] == L'\0' || g_cachedDev2Id[0] == L'\0') { + return FALSE; + } + CoInitialize(nullptr); - IMMDeviceEnumerator *pEnum = nullptr; - if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { + IMMDeviceEnumerator* pEnum = nullptr; + if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { CoUninitialize(); return FALSE; } - IMMDevice *pDefaultDevice = nullptr; + IMMDevice* pDefaultDevice = nullptr; if (FAILED(pEnum->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDefaultDevice))) { pEnum->Release(); CoUninitialize(); return FALSE; } - IPropertyStore *pStore = nullptr; - pDefaultDevice->OpenPropertyStore(STGM_READ, &pStore); - PROPVARIANT varName; PropVariantInit(&varName); - pStore->GetValue(PKEY_Device_FriendlyName, &varName); - - BOOL isDev1Active = (varName.pwszVal && StrStrIW(varName.pwszVal, g_cachedDev1)); - PropVariantClear(&varName); pStore->Release(); pDefaultDevice->Release(); - - PCWSTR targetStr = isDev1Active ? g_cachedDev2 : g_cachedDev1; - IMMDeviceCollection *pDevices = nullptr; - pEnum->EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE, &pDevices); - UINT count = 0; pDevices->GetCount(&count); - - LPWSTR targetId = nullptr; - for (UINT i = 0; i < count; i++) { - IMMDevice *pDevice = nullptr; - pDevices->Item(i, &pDevice); - pDevice->OpenPropertyStore(STGM_READ, &pStore); - PropVariantInit(&varName); - pStore->GetValue(PKEY_Device_FriendlyName, &varName); - if (varName.pwszVal && StrStrIW(varName.pwszVal, targetStr)) { - pDevice->GetId(&targetId); - PropVariantClear(&varName); pStore->Release(); pDevice->Release(); - break; + LPWSTR currentId = nullptr; + pDefaultDevice->GetId(¤tId); + pDefaultDevice->Release(); + + // Pick the other device by comparing IDs directly — no fuzzy name search + PCWSTR targetId = (currentId && wcscmp(currentId, g_cachedDev1Id) == 0) + ? g_cachedDev2Id + : g_cachedDev1Id; + + if (currentId) CoTaskMemFree(currentId); + + IPolicyConfig* pPolicyConfig = nullptr; + if (SUCCEEDED(CoCreateInstance(CLSID_CPolicyConfigClient, nullptr, CLSCTX_ALL, + IID_IPolicyConfig_Win10_11, (void**)&pPolicyConfig))) { + pPolicyConfig->SetDefaultEndpoint(targetId, eConsole); + pPolicyConfig->SetDefaultEndpoint(targetId, eMultimedia); + pPolicyConfig->SetDefaultEndpoint(targetId, eCommunications); + pPolicyConfig->Release(); + } + + pEnum->Release(); + CoUninitialize(); + return TRUE; +} + +struct AudioDevice { + WCHAR id[512]; + WCHAR name[256]; +}; + +void BuildAndShowContextMenu(HWND hWnd) { + AudioDevice devices[MENU_MAX_DEVICES]; + int deviceCount = 0; + + // Enumerate active render endpoints (COM already init on tray thread) + IMMDeviceEnumerator* pEnum = nullptr; + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { + IMMDeviceCollection* pDevices = nullptr; + if (SUCCEEDED(pEnum->EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE, &pDevices))) { + UINT count = 0; + pDevices->GetCount(&count); + if (count > MENU_MAX_DEVICES) count = MENU_MAX_DEVICES; + + for (UINT i = 0; i < count; i++) { + IMMDevice* pDevice = nullptr; + if (FAILED(pDevices->Item(i, &pDevice))) continue; + + LPWSTR pId = nullptr; + if (SUCCEEDED(pDevice->GetId(&pId))) { + lstrcpynW(devices[deviceCount].id, pId, 512); + CoTaskMemFree(pId); + + IPropertyStore* pStore = nullptr; + if (SUCCEEDED(pDevice->OpenPropertyStore(STGM_READ, &pStore))) { + PROPVARIANT varName; + PropVariantInit(&varName); + if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &varName)) && varName.pwszVal) { + lstrcpynW(devices[deviceCount].name, varName.pwszVal, 256); + deviceCount++; + } + PropVariantClear(&varName); + pStore->Release(); + } + } + pDevice->Release(); + } + pDevices->Release(); } - PropVariantClear(&varName); pStore->Release(); pDevice->Release(); + pEnum->Release(); } - if (targetId) { - IPolicyConfig *pPolicyConfig = nullptr; - if (SUCCEEDED(CoCreateInstance(CLSID_CPolicyConfigClient, nullptr, CLSCTX_ALL, IID_IPolicyConfig_Win10_11, (void**)&pPolicyConfig))) { - pPolicyConfig->SetDefaultEndpoint(targetId, eConsole); - pPolicyConfig->SetDefaultEndpoint(targetId, eMultimedia); - pPolicyConfig->SetDefaultEndpoint(targetId, eCommunications); - pPolicyConfig->Release(); + // Build submenus + HMENU hSub1 = CreatePopupMenu(); + HMENU hSub2 = CreatePopupMenu(); + + for (int i = 0; i < deviceCount; i++) { + UINT flags1 = MF_STRING | (wcscmp(devices[i].id, g_cachedDev1Id) == 0 ? MF_CHECKED : 0); + UINT flags2 = MF_STRING | (wcscmp(devices[i].id, g_cachedDev2Id) == 0 ? MF_CHECKED : 0); + AppendMenuW(hSub1, flags1, MENU_DEVICE1_BASE + i, devices[i].name); + AppendMenuW(hSub2, flags2, MENU_DEVICE2_BASE + i, devices[i].name); + } + + // Assemble root menu + HMENU hMenu = CreatePopupMenu(); + AppendMenuW(hMenu, MF_POPUP, (UINT_PTR)hSub1, L"Set as Device 1"); + AppendMenuW(hMenu, MF_POPUP, (UINT_PTR)hSub2, L"Set as Device 2"); + AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); + + // Status line — get current active device name + WCHAR statusText[300]; + if (g_cachedDev1Id[0] == L'\0' || g_cachedDev2Id[0] == L'\0') { + lstrcpyW(statusText, L"Right-click to configure devices"); + } else { + WCHAR activeName[256] = L"Unknown"; + IMMDeviceEnumerator* pEnum2 = nullptr; + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum2))) { + IMMDevice* pDefault = nullptr; + if (SUCCEEDED(pEnum2->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDefault))) { + IPropertyStore* pStore = nullptr; + if (SUCCEEDED(pDefault->OpenPropertyStore(STGM_READ, &pStore))) { + PROPVARIANT v; PropVariantInit(&v); + if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &v)) && v.pwszVal) + lstrcpynW(activeName, v.pwszVal, 256); + PropVariantClear(&v); + pStore->Release(); + } + pDefault->Release(); + } + pEnum2->Release(); } - CoTaskMemFree(targetId); + swprintf_s(statusText, L"Active: %s", activeName); + } + AppendMenuW(hMenu, MF_STRING | MF_GRAYED, 0, statusText); + + // Show menu at cursor + POINT pt; + GetCursorPos(&pt); + SetForegroundWindow(hWnd); + int cmd = TrackPopupMenu(hMenu, TPM_RETURNCMD | TPM_RIGHTBUTTON | TPM_BOTTOMALIGN | TPM_RIGHTALIGN, + pt.x, pt.y, 0, hWnd, nullptr); + PostMessageW(hWnd, WM_NULL, 0, 0); // flush menu + + DestroyMenu(hMenu); // also destroys attached submenus + + // Handle selection + if (cmd >= MENU_DEVICE1_BASE && cmd < MENU_DEVICE1_BASE + deviceCount) { + int idx = cmd - MENU_DEVICE1_BASE; + SaveDeviceSelection(1, devices[idx].id, devices[idx].name); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + } else if (cmd >= MENU_DEVICE2_BASE && cmd < MENU_DEVICE2_BASE + deviceCount) { + int idx = cmd - MENU_DEVICE2_BASE; + SaveDeviceSelection(2, devices[idx].id, devices[idx].name); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); } - pDevices->Release(); pEnum->Release(); CoUninitialize(); - return TRUE; } DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { @@ -249,7 +420,9 @@ DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { } LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { - if (msg == WM_TRAY_CALLBACK && lParam == WM_LBUTTONUP) { + if (msg == WM_TRAY_CALLBACK && lParam == WM_RBUTTONUP) { + BuildAndShowContextMenu(hWnd); + } else if (msg == WM_TRAY_CALLBACK && lParam == WM_LBUTTONUP) { if (InterlockedExchange(&g_isProcessingClick, 1) == 0) { DWORD now = GetTickCount(); if (now - g_lastClickTime > CLICK_DEBOUNCE_MS) { // Corrected check @@ -299,12 +472,14 @@ BOOL WhTool_ModInit() { Wh_Log(L"AudioSwap Mod Init"); g_hInstance = GetModuleHandle(nullptr); LoadUserIconsAndSettings(); + LoadDeviceSelections(); g_trayThread = CreateThread(nullptr, 0, TrayThreadProc, nullptr, 0, nullptr); return TRUE; } void WhTool_ModSettingsChanged() { LoadUserIconsAndSettings(); + LoadDeviceSelections(); if (g_trayHwnd) PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, 0, 0); } From 374720697ed54060d685a1353f91e2d331f5e77f Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Fri, 17 Apr 2026 02:54:06 +0300 Subject: [PATCH 12/56] Updated to use Windhawk storage instead of registry. Updated device selection persistence to use Windhawk storage instead of registry. Simplified loading and saving of device selections. --- mods/audioswap.wh.cpp | 56 ++++++++++++++----------------------------- 1 file changed, 18 insertions(+), 38 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index b9f2120978..1b75d5e2e3 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -6,7 +6,7 @@ // @author BlackPaw // @github https://github.com/BlackPaw21 // @include windhawk.exe -// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lshlwapi -ladvapi32 +// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lshlwapi // ==/WindhawkMod== // ==WindhawkModReadme== @@ -30,7 +30,7 @@ Instantly toggle between two audio output devices from your system tray — no d ### v1.1.0 - **New:** Right-click context menu — auto-detects all active audio outputs and lets you assign Device 1 and Device 2 directly from a live list. No more typing device names manually. -- **New:** Device selections persist across restarts via the registry (`HKCU\Software\WindHawk\AudioSwap`). +- **New:** Device selections persist across restarts. - **Improved:** Toggle now matches devices by their unique system ID instead of a name substring search — works correctly regardless of how Windows names your device. - **Improved:** Tray tooltip prompts you to configure on first run instead of showing "Unknown Device". - **Removed:** Device name text fields from the Settings tab (replaced by the right-click menu). @@ -70,7 +70,6 @@ Instantly toggle between two audio output devices from your system tray — no d #include #include #include -#include #define TRAY_ICON_ID 1 #define WM_TRAY_CALLBACK (WM_USER + 1) @@ -91,7 +90,7 @@ static HICON g_iconDev2 = nullptr; static DWORD g_lastClickTime = 0; static UINT g_taskbarCreatedMsg = 0; -// Cached device selections (loaded from registry) +// Cached device selections (loaded from Windhawk storage) static WCHAR g_cachedDev1Id[512] = {0}; static WCHAR g_cachedDev1Name[256] = {0}; static WCHAR g_cachedDev2Id[512] = {0}; @@ -133,54 +132,35 @@ int GetIconIndex(PCWSTR iconSetting) { } void LoadDeviceSelections() { - // Zero out first so stale data is cleared if key doesn't exist g_cachedDev1Id[0] = g_cachedDev1Name[0] = L'\0'; g_cachedDev2Id[0] = g_cachedDev2Name[0] = L'\0'; - HKEY hKey = nullptr; - if (RegOpenKeyExW(HKEY_CURRENT_USER, L"Software\\WindHawk\\AudioSwap", - 0, KEY_READ, &hKey) != ERROR_SUCCESS) { - return; - } + auto load = [](PCWSTR key, PWSTR buf, int size) { + PCWSTR val = Wh_GetStringValue(key); + if (val) { + lstrcpynW(buf, val, size); + Wh_FreeStringValue(val); + } + }; - DWORD size; - size = sizeof(g_cachedDev1Id); - RegQueryValueExW(hKey, L"Device1Id", nullptr, nullptr, (LPBYTE)g_cachedDev1Id, &size); - size = sizeof(g_cachedDev1Name); - RegQueryValueExW(hKey, L"Device1Name", nullptr, nullptr, (LPBYTE)g_cachedDev1Name, &size); - size = sizeof(g_cachedDev2Id); - RegQueryValueExW(hKey, L"Device2Id", nullptr, nullptr, (LPBYTE)g_cachedDev2Id, &size); - size = sizeof(g_cachedDev2Name); - RegQueryValueExW(hKey, L"Device2Name", nullptr, nullptr, (LPBYTE)g_cachedDev2Name, &size); - - RegCloseKey(hKey); + load(L"Device1Id", g_cachedDev1Id, 512); + load(L"Device1Name", g_cachedDev1Name, 256); + load(L"Device2Id", g_cachedDev2Id, 512); + load(L"Device2Name", g_cachedDev2Name, 256); } void SaveDeviceSelection(int slot, PCWSTR deviceId, PCWSTR friendlyName) { - HKEY hKey = nullptr; - if (RegCreateKeyExW(HKEY_CURRENT_USER, L"Software\\WindHawk\\AudioSwap", - 0, nullptr, REG_OPTION_NON_VOLATILE, KEY_WRITE, - nullptr, &hKey, nullptr) != ERROR_SUCCESS) { - return; - } - if (slot == 1) { - RegSetValueExW(hKey, L"Device1Id", 0, REG_SZ, (const BYTE*)deviceId, - (lstrlenW(deviceId) + 1) * sizeof(WCHAR)); - RegSetValueExW(hKey, L"Device1Name", 0, REG_SZ, (const BYTE*)friendlyName, - (lstrlenW(friendlyName) + 1) * sizeof(WCHAR)); + Wh_SetStringValue(L"Device1Id", deviceId); + Wh_SetStringValue(L"Device1Name", friendlyName); lstrcpynW(g_cachedDev1Id, deviceId, 512); lstrcpynW(g_cachedDev1Name, friendlyName, 256); } else { - RegSetValueExW(hKey, L"Device2Id", 0, REG_SZ, (const BYTE*)deviceId, - (lstrlenW(deviceId) + 1) * sizeof(WCHAR)); - RegSetValueExW(hKey, L"Device2Name", 0, REG_SZ, (const BYTE*)friendlyName, - (lstrlenW(friendlyName) + 1) * sizeof(WCHAR)); + Wh_SetStringValue(L"Device2Id", deviceId); + Wh_SetStringValue(L"Device2Name", friendlyName); lstrcpynW(g_cachedDev2Id, deviceId, 512); lstrcpynW(g_cachedDev2Name, friendlyName, 256); } - - RegCloseKey(hKey); } void LoadUserIconsAndSettings() { From 7fa9917b42ecc86f33462031ee943f47a9394121 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Fri, 17 Apr 2026 02:57:09 +0300 Subject: [PATCH 13/56] bugfixes --- mods/audioswap.wh.cpp | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index 1b75d5e2e3..188f6e5fec 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -135,18 +135,10 @@ void LoadDeviceSelections() { g_cachedDev1Id[0] = g_cachedDev1Name[0] = L'\0'; g_cachedDev2Id[0] = g_cachedDev2Name[0] = L'\0'; - auto load = [](PCWSTR key, PWSTR buf, int size) { - PCWSTR val = Wh_GetStringValue(key); - if (val) { - lstrcpynW(buf, val, size); - Wh_FreeStringValue(val); - } - }; - - load(L"Device1Id", g_cachedDev1Id, 512); - load(L"Device1Name", g_cachedDev1Name, 256); - load(L"Device2Id", g_cachedDev2Id, 512); - load(L"Device2Name", g_cachedDev2Name, 256); + Wh_GetStringValue(L"Device1Id", g_cachedDev1Id, ARRAYSIZE(g_cachedDev1Id)); + Wh_GetStringValue(L"Device1Name", g_cachedDev1Name, ARRAYSIZE(g_cachedDev1Name)); + Wh_GetStringValue(L"Device2Id", g_cachedDev2Id, ARRAYSIZE(g_cachedDev2Id)); + Wh_GetStringValue(L"Device2Name", g_cachedDev2Name, ARRAYSIZE(g_cachedDev2Name)); } void SaveDeviceSelection(int slot, PCWSTR deviceId, PCWSTR friendlyName) { From 04ee09b5e24ee1ccf734665c86e45b9695b2ee2e Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Thu, 30 Apr 2026 07:55:36 +0300 Subject: [PATCH 14/56] Bump version to 1.1.1 and enhance features Updated version to 1.1.1 and added new features including opening WindHawk.exe from the mod itself, new icons in the settings. --- mods/audioswap.wh.cpp | 152 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 133 insertions(+), 19 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index 188f6e5fec..abc4701e3d 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -2,7 +2,7 @@ // @id audioswap // @name AudioSwap // @description Adds a tray icon to instantly toggle between two preferred audio outputs. -// @version 1.1.0 +// @version 1.1.1 // @author BlackPaw // @github https://github.com/BlackPaw21 // @include windhawk.exe @@ -28,6 +28,11 @@ Instantly toggle between two audio output devices from your system tray — no d ## Changelog +### v1.1.0 +- **New:** Can now open WindHawk directly from the icon using right click +- **New:** Added new icons to select from +- **Improved:** added icons in the right click menu + ### v1.1.0 - **New:** Right-click context menu — auto-detects all active audio outputs and lets you assign Device 1 and Device 2 directly from a live list. No more typing device names manually. - **New:** Device selections persist across restarts. @@ -42,20 +47,32 @@ Instantly toggle between two audio output devices from your system tray — no d // ==WindhawkModSettings== /* -- icon1: headphones1 +- icon1: speaker_normal $name: First Device Icon $options: - - headphones1: Headphones (normal) - - headphones2: Modern Headset (white) - - speaker1: Basic Speaker (normal) - - speaker2: Modern Speaker (white) -- icon2: speaker1 + - speaker_normal: Normal Speaker + - speaker_square: Square Speaker + - speaker_modern: Modern Speaker + - speaker_modern_square: Modern Square Speaker + - audio_wave: Audio Wave + - headphones: Headphones + - headset_gaming: Gaming Headset + - headphones_modern: Modern Headphones + - headset_modern: Modern Gaming Headset + - earphones: Earphones +- icon2: speaker_square $name: Second Device Icon $options: - - headphones1: Headphones (normal) - - headphones2: Modern Headset (white) - - speaker1: Basic Speaker (normal) - - speaker2: Modern Speaker (white) + - speaker_normal: Normal Speaker + - speaker_square: Square Speaker + - speaker_modern: Modern Speaker + - speaker_modern_square: Modern Square Speaker + - audio_wave: Audio Wave + - headphones: Headphones + - headset_gaming: Gaming Headset + - headphones_modern: Modern Headphones + - headset_modern: Modern Gaming Headset + - earphones: Earphones */ // ==/WindhawkModSettings== @@ -76,6 +93,7 @@ Instantly toggle between two audio output devices from your system tray — no d #define WM_UPDATE_TRAY_STATE (WM_USER + 2) #define MENU_DEVICE1_BASE 1000 #define MENU_DEVICE2_BASE 2000 +#define MENU_OPEN_WINDHAWK 3000 #define MENU_MAX_DEVICES 32 const DWORD CLICK_DEBOUNCE_MS = 500; @@ -85,8 +103,13 @@ static HANDLE g_trayThread = nullptr; static HANDLE g_workerThread = nullptr; static HWND g_trayHwnd = nullptr; static HINSTANCE g_hInstance = nullptr; +static WCHAR g_windhawkPath[MAX_PATH] = {0}; +static HICON g_hWindHawkIcon = nullptr; +static HBITMAP g_hWindHawkBmp = nullptr; static HICON g_iconDev1 = nullptr; +static HBITMAP g_hIconDev1Bmp = nullptr; static HICON g_iconDev2 = nullptr; +static HBITMAP g_hIconDev2Bmp = nullptr; static DWORD g_lastClickTime = 0; static UINT g_taskbarCreatedMsg = 0; @@ -123,10 +146,16 @@ IPolicyConfig : public IUnknown int GetIconIndex(PCWSTR iconSetting) { if (iconSetting) { - if (wcscmp(iconSetting, L"headphones1") == 0) return 2; - if (wcscmp(iconSetting, L"headphones2") == 0) return 91; - if (wcscmp(iconSetting, L"speaker1") == 0) return 4; - if (wcscmp(iconSetting, L"speaker2") == 0) return 93; + if (wcscmp(iconSetting, L"speaker_normal") == 0) return 1; + if (wcscmp(iconSetting, L"speaker_square") == 0) return 4; + if (wcscmp(iconSetting, L"speaker_modern") == 0) return 90; + if (wcscmp(iconSetting, L"speaker_modern_square") == 0) return 93; + if (wcscmp(iconSetting, L"audio_wave") == 0) return 94; + if (wcscmp(iconSetting, L"headphones") == 0) return 2; + if (wcscmp(iconSetting, L"headset_gaming") == 0) return 8; + if (wcscmp(iconSetting, L"headphones_modern") == 0) return 91; + if (wcscmp(iconSetting, L"headset_modern") == 0) return 95; + if (wcscmp(iconSetting, L"earphones") == 0) return 6; } return 4; } @@ -158,6 +187,10 @@ void SaveDeviceSelection(int slot, PCWSTR deviceId, PCWSTR friendlyName) { void LoadUserIconsAndSettings() { if (g_iconDev1) DestroyIcon(g_iconDev1); if (g_iconDev2) DestroyIcon(g_iconDev2); + if (g_hIconDev1Bmp) DeleteObject(g_hIconDev1Bmp); + if (g_hIconDev2Bmp) DeleteObject(g_hIconDev2Bmp); + g_hIconDev1Bmp = nullptr; + g_hIconDev2Bmp = nullptr; PCWSTR s1 = Wh_GetStringSetting(L"icon1"); PCWSTR s2 = Wh_GetStringSetting(L"icon2"); @@ -165,6 +198,30 @@ void LoadUserIconsAndSettings() { ExtractIconExW(L"ddores.dll", GetIconIndex(s1), nullptr, &g_iconDev1, 1); ExtractIconExW(L"ddores.dll", GetIconIndex(s2), nullptr, &g_iconDev2, 1); + if (g_iconDev1) { + ICONINFO iconInfo = {0}; + if (GetIconInfo(g_iconDev1, &iconInfo)) { + g_hIconDev1Bmp = iconInfo.hbmColor; + if (!g_hIconDev1Bmp) { + g_hIconDev1Bmp = iconInfo.hbmMask; + } else if (iconInfo.hbmMask) { + DeleteObject(iconInfo.hbmMask); + } + } + } + + if (g_iconDev2) { + ICONINFO iconInfo = {0}; + if (GetIconInfo(g_iconDev2, &iconInfo)) { + g_hIconDev2Bmp = iconInfo.hbmColor; + if (!g_hIconDev2Bmp) { + g_hIconDev2Bmp = iconInfo.hbmMask; + } else if (iconInfo.hbmMask) { + DeleteObject(iconInfo.hbmMask); + } + } + } + if (s1) Wh_FreeStringSetting(s1); if (s2) Wh_FreeStringSetting(s2); } @@ -246,14 +303,22 @@ BOOL ToggleAudioDevice() { } LPWSTR currentId = nullptr; - pDefaultDevice->GetId(¤tId); + HRESULT hr = pDefaultDevice->GetId(¤tId); pDefaultDevice->Release(); + if (FAILED(hr) || !currentId) { + pEnum->Release(); CoUninitialize(); return FALSE; + } // Pick the other device by comparing IDs directly — no fuzzy name search - PCWSTR targetId = (currentId && wcscmp(currentId, g_cachedDev1Id) == 0) + PCWSTR targetId = (wcscmp(currentId, g_cachedDev1Id) == 0) ? g_cachedDev2Id : g_cachedDev1Id; + if (!targetId || !targetId[0]) { + CoTaskMemFree(currentId); + pEnum->Release(); CoUninitialize(); return FALSE; + } + if (currentId) CoTaskMemFree(currentId); IPolicyConfig* pPolicyConfig = nullptr; @@ -330,8 +395,33 @@ void BuildAndShowContextMenu(HWND hWnd) { // Assemble root menu HMENU hMenu = CreatePopupMenu(); - AppendMenuW(hMenu, MF_POPUP, (UINT_PTR)hSub1, L"Set as Device 1"); - AppendMenuW(hMenu, MF_POPUP, (UINT_PTR)hSub2, L"Set as Device 2"); + + // Set as Device 1 with icon + MENUITEMINFOW mii1 = {sizeof(mii1)}; + mii1.fMask = MIIM_SUBMENU | MIIM_STRING | MIIM_BITMAP; + mii1.hSubMenu = hSub1; + mii1.dwTypeData = (LPWSTR)L"Set as Device 1"; + mii1.hbmpItem = g_hIconDev1Bmp; + InsertMenuItemW(hMenu, (UINT)-1, TRUE, &mii1); + + // Set as Device 2 with icon + MENUITEMINFOW mii2 = {sizeof(mii2)}; + mii2.fMask = MIIM_SUBMENU | MIIM_STRING | MIIM_BITMAP; + mii2.hSubMenu = hSub2; + mii2.dwTypeData = (LPWSTR)L"Set as Device 2"; + mii2.hbmpItem = g_hIconDev2Bmp; + InsertMenuItemW(hMenu, (UINT)-1, TRUE, &mii2); + + AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); + + // Open WindHawk menu item with icon + MENUITEMINFOW mii = {sizeof(mii)}; + mii.fMask = MIIM_ID | MIIM_STRING | MIIM_BITMAP; + mii.wID = MENU_OPEN_WINDHAWK; + mii.dwTypeData = (LPWSTR)L"Open WindHawk"; + mii.hbmpItem = g_hWindHawkBmp; + InsertMenuItemW(hMenu, (UINT)-1, TRUE, &mii); + AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); // Status line — get current active device name @@ -380,6 +470,13 @@ void BuildAndShowContextMenu(HWND hWnd) { int idx = cmd - MENU_DEVICE2_BASE; SaveDeviceSelection(2, devices[idx].id, devices[idx].name); PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + } else if (cmd == MENU_OPEN_WINDHAWK) { + SHELLEXECUTEINFOW sei; + ZeroMemory(&sei, sizeof(sei)); + sei.cbSize = sizeof(sei); + sei.lpFile = g_windhawkPath; + sei.nShow = SW_SHOWNORMAL; + ShellExecuteExW(&sei); } } @@ -443,6 +540,21 @@ DWORD WINAPI TrayThreadProc(LPVOID) { BOOL WhTool_ModInit() { Wh_Log(L"AudioSwap Mod Init"); g_hInstance = GetModuleHandle(nullptr); + GetModuleFileName(nullptr, g_windhawkPath, ARRAYSIZE(g_windhawkPath)); + ExtractIconExW(L"ddores.dll", 98, nullptr, &g_hWindHawkIcon, 1); + if (!g_hWindHawkIcon) ExtractIconExW(L"ddores.dll", 94, nullptr, &g_hWindHawkIcon, 1); + if (!g_hWindHawkIcon) ExtractIconExW(L"ddores.dll", 95, nullptr, &g_hWindHawkIcon, 1); + if (!g_hWindHawkIcon) ExtractIconExW(L"ddores.dll", 6, nullptr, &g_hWindHawkIcon, 1); + if (g_hWindHawkIcon) { + ICONINFO iconInfo = {0}; + GetIconInfo(g_hWindHawkIcon, &iconInfo); + g_hWindHawkBmp = iconInfo.hbmColor; + if (!g_hWindHawkBmp) { + g_hWindHawkBmp = iconInfo.hbmMask; + } else if (iconInfo.hbmMask) { + DeleteObject(iconInfo.hbmMask); + } + } LoadUserIconsAndSettings(); LoadDeviceSelections(); g_trayThread = CreateThread(nullptr, 0, TrayThreadProc, nullptr, 0, nullptr); @@ -462,6 +574,8 @@ void WhTool_ModUninit() { if (g_workerThread) { WaitForSingleObject(g_workerThread, 2000); CloseHandle(g_workerThread); g_workerThread = nullptr; } if (g_iconDev1) { DestroyIcon(g_iconDev1); g_iconDev1 = nullptr; } if (g_iconDev2) { DestroyIcon(g_iconDev2); g_iconDev2 = nullptr; } + if (g_hWindHawkIcon) { DestroyIcon(g_hWindHawkIcon); g_hWindHawkIcon = nullptr; } + if (g_hWindHawkBmp) { DeleteObject(g_hWindHawkBmp); g_hWindHawkBmp = nullptr; } } //////////////////////////////////////////////////////////////////////////////// From 63a6544737cb3e20c58c58ae4c6bf6356873f575 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Sat, 2 May 2026 00:43:56 +0300 Subject: [PATCH 15/56] new MicSwap mod for audio input toggling created MicSwap mod that allows toggling between two audio input devices from the system tray. --- mods/MicSwap.wh.cpp | 730 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 730 insertions(+) create mode 100644 mods/MicSwap.wh.cpp diff --git a/mods/MicSwap.wh.cpp b/mods/MicSwap.wh.cpp new file mode 100644 index 0000000000..3ad25553bd --- /dev/null +++ b/mods/MicSwap.wh.cpp @@ -0,0 +1,730 @@ +// ==WindhawkMod== +// @id micswap +// @name MicSwap +// @description Adds a tray icon to instantly toggle between two preferred audio inputs (microphones). +// @version 1.0.0 +// @author BlackPaw +// @github https://github.com/BlackPaw21 +// @include windhawk.exe +// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lshlwapi +// ==/WindhawkMod== + +// ==WindhawkModReadme== +/* +# MicSwap +Instantly toggle between two audio input devices (microphones) from your system tray — no diving into Sound settings. + +--- + +## How to Use + +1. **Choose Icons** — Open the **Settings** tab and pick an icon for each of your two devices. +2. **Select Your Devices** — Right-click the tray icon. Use **Set as Input 1** and **Set as Input 2** to assign your inputs from the live device list. +3. **Toggle** — Left-click the tray icon at any time to swap between the two devices instantly. + +> The tray tooltip always shows the active input. On first run it will read *"Right-click to configure"* until both devices are assigned. + +--- + +## Changelog + +### v1.0.0 +- Initial release. Mirrors AudioSwap but targets audio input/capture devices. +- Right-click context menu auto-detects all active audio inputs and lets you assign Input 1 and Input 2 from a live list. +- Device selections persist across restarts. +- Toggle matches devices by their unique system ID for reliable switching regardless of device naming. +*/ +// ==/WindhawkModReadme== + +// ==WindhawkModSettings== +/* +- icon1: mic_classic + $name: First Device Icon + $options: + - mic_classic: Classic Microphone + - mic_modern: Modern Microphone + - headset_gaming: Gaming Headset + - headset_modern: Modern Gaming Headset + - earphones: Earphones +- icon2: headset_gaming + $name: Second Device Icon + $options: + - mic_classic: Classic Microphone + - mic_modern: Modern Microphone + - headset_gaming: Gaming Headset + - headset_modern: Modern Gaming Headset + - earphones: Earphones +*/ +// ==/WindhawkModSettings== + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#define TRAY_ICON_ID 1 +#define WM_TRAY_CALLBACK (WM_USER + 1) +#define WM_UPDATE_TRAY_STATE (WM_USER + 2) +#define MENU_DEVICE1_BASE 1000 +#define MENU_DEVICE2_BASE 2000 +#define MENU_OPEN_WINDHAWK 3000 +#define MENU_MAX_DEVICES 32 + +const DWORD CLICK_DEBOUNCE_MS = 500; + +static volatile LONG g_isProcessingClick = 0; +static HANDLE g_trayThread = nullptr; +static HANDLE g_workerThread = nullptr; +static HWND g_trayHwnd = nullptr; +static HINSTANCE g_hInstance = nullptr; +static WCHAR g_windhawkPath[MAX_PATH] = {0}; +static HICON g_hWindHawkIcon = nullptr; +static HBITMAP g_hWindHawkBmp = nullptr; +static HICON g_iconDev1 = nullptr; +static HBITMAP g_hIconDev1Bmp = nullptr; +static HICON g_iconDev2 = nullptr; +static HBITMAP g_hIconDev2Bmp = nullptr; +static DWORD g_lastClickTime = 0; +static UINT g_taskbarCreatedMsg = 0; + +// Cached device selections (loaded from Windhawk storage) +static WCHAR g_cachedDev1Id[512] = {0}; +static WCHAR g_cachedDev1Name[256] = {0}; +static WCHAR g_cachedDev2Id[512] = {0}; +static WCHAR g_cachedDev2Name[256] = {0}; + +const CLSID CLSID_CPolicyConfigClient = { + 0x870af99c, 0x171d, 0x4f9e, {0xaf, 0x0d, 0xe6, 0x3d, 0xf4, 0x0c, 0x2b, 0xc9} +}; +const IID IID_IPolicyConfig_Win10_11 = { + 0xf8679f50, 0x850a, 0x41cf, {0x9c, 0x72, 0x43, 0x0f, 0x29, 0x02, 0x90, 0xc8} +}; + +MIDL_INTERFACE("f8679f50-850a-41cf-9c72-430f290290c8") +IPolicyConfig : public IUnknown +{ +public: + virtual HRESULT STDMETHODCALLTYPE GetMixFormat(PCWSTR, void**) = 0; + virtual HRESULT STDMETHODCALLTYPE GetDeviceFormat(PCWSTR, INT, void**) = 0; + virtual HRESULT STDMETHODCALLTYPE ResetDeviceFormat(PCWSTR) = 0; + virtual HRESULT STDMETHODCALLTYPE SetDeviceFormat(PCWSTR, void*, void*) = 0; + virtual HRESULT STDMETHODCALLTYPE GetProcessingPeriod(PCWSTR, INT, PINT, PINT) = 0; + virtual HRESULT STDMETHODCALLTYPE SetProcessingPeriod(PCWSTR, PINT) = 0; + virtual HRESULT STDMETHODCALLTYPE GetShareMode(PCWSTR, void*) = 0; + virtual HRESULT STDMETHODCALLTYPE SetShareMode(PCWSTR, void*) = 0; + virtual HRESULT STDMETHODCALLTYPE GetPropertyValue(PCWSTR, const PROPERTYKEY&, PROPVARIANT*) = 0; + virtual HRESULT STDMETHODCALLTYPE SetPropertyValue(PCWSTR, const PROPERTYKEY&, PROPVARIANT*) = 0; + virtual HRESULT STDMETHODCALLTYPE SetDefaultEndpoint(PCWSTR wszDeviceId, ERole eRole) = 0; + virtual HRESULT STDMETHODCALLTYPE SetEndpointVisibility(PCWSTR, INT) = 0; +}; + +int GetIconIndex(PCWSTR iconSetting) { + if (iconSetting) { + if (wcscmp(iconSetting, L"mic_classic") == 0) return 3; + if (wcscmp(iconSetting, L"mic_modern") == 0) return 92; + if (wcscmp(iconSetting, L"headset_gaming") == 0) return 8; + if (wcscmp(iconSetting, L"headset_modern") == 0) return 95; + if (wcscmp(iconSetting, L"earphones") == 0) return 6; + } + return 3; +} + +void LoadDeviceSelections() { + g_cachedDev1Id[0] = g_cachedDev1Name[0] = L'\0'; + g_cachedDev2Id[0] = g_cachedDev2Name[0] = L'\0'; + + Wh_GetStringValue(L"Device1Id", g_cachedDev1Id, ARRAYSIZE(g_cachedDev1Id)); + Wh_GetStringValue(L"Device1Name", g_cachedDev1Name, ARRAYSIZE(g_cachedDev1Name)); + Wh_GetStringValue(L"Device2Id", g_cachedDev2Id, ARRAYSIZE(g_cachedDev2Id)); + Wh_GetStringValue(L"Device2Name", g_cachedDev2Name, ARRAYSIZE(g_cachedDev2Name)); +} + +void SaveDeviceSelection(int slot, PCWSTR deviceId, PCWSTR friendlyName) { + if (slot == 1) { + Wh_SetStringValue(L"Device1Id", deviceId); + Wh_SetStringValue(L"Device1Name", friendlyName); + lstrcpynW(g_cachedDev1Id, deviceId, 512); + lstrcpynW(g_cachedDev1Name, friendlyName, 256); + } else { + Wh_SetStringValue(L"Device2Id", deviceId); + Wh_SetStringValue(L"Device2Name", friendlyName); + lstrcpynW(g_cachedDev2Id, deviceId, 512); + lstrcpynW(g_cachedDev2Name, friendlyName, 256); + } +} + +void LoadUserIconsAndSettings() { + if (g_iconDev1) DestroyIcon(g_iconDev1); + if (g_iconDev2) DestroyIcon(g_iconDev2); + if (g_hIconDev1Bmp) DeleteObject(g_hIconDev1Bmp); + if (g_hIconDev2Bmp) DeleteObject(g_hIconDev2Bmp); + g_hIconDev1Bmp = nullptr; + g_hIconDev2Bmp = nullptr; + + PCWSTR s1 = Wh_GetStringSetting(L"icon1"); + PCWSTR s2 = Wh_GetStringSetting(L"icon2"); + + ExtractIconExW(L"ddores.dll", GetIconIndex(s1), nullptr, &g_iconDev1, 1); + ExtractIconExW(L"ddores.dll", GetIconIndex(s2), nullptr, &g_iconDev2, 1); + + if (g_iconDev1) { + ICONINFO iconInfo = {0}; + if (GetIconInfo(g_iconDev1, &iconInfo)) { + g_hIconDev1Bmp = iconInfo.hbmColor; + if (!g_hIconDev1Bmp) { + g_hIconDev1Bmp = iconInfo.hbmMask; + } else if (iconInfo.hbmMask) { + DeleteObject(iconInfo.hbmMask); + } + } + } + + if (g_iconDev2) { + ICONINFO iconInfo = {0}; + if (GetIconInfo(g_iconDev2, &iconInfo)) { + g_hIconDev2Bmp = iconInfo.hbmColor; + if (!g_hIconDev2Bmp) { + g_hIconDev2Bmp = iconInfo.hbmMask; + } else if (iconInfo.hbmMask) { + DeleteObject(iconInfo.hbmMask); + } + } + } + + if (s1) Wh_FreeStringSetting(s1); + if (s2) Wh_FreeStringSetting(s2); +} + +void UpdateTrayTip(HWND hWnd, BOOL isAdd) { + WCHAR currentDev[256] = L"Unknown Device"; + WCHAR currentId[512] = {0}; + HICON currentIcon = g_iconDev1; + + IMMDeviceEnumerator* pEnum = nullptr; + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { + IMMDevice* pDefaultDevice = nullptr; + if (SUCCEEDED(pEnum->GetDefaultAudioEndpoint(eCapture, eMultimedia, &pDefaultDevice))) { + LPWSTR pId = nullptr; + if (SUCCEEDED(pDefaultDevice->GetId(&pId))) { + lstrcpynW(currentId, pId, 512); + CoTaskMemFree(pId); + } + IPropertyStore* pStore = nullptr; + if (SUCCEEDED(pDefaultDevice->OpenPropertyStore(STGM_READ, &pStore))) { + PROPVARIANT varName; + PropVariantInit(&varName); + if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &varName)) && varName.pwszVal) + lstrcpynW(currentDev, varName.pwszVal, 256); + PropVariantClear(&varName); + pStore->Release(); + } + pDefaultDevice->Release(); + } + pEnum->Release(); + } + + if (g_cachedDev1Id[0] != L'\0' && wcscmp(currentId, g_cachedDev1Id) == 0) + currentIcon = g_iconDev1; + else if (g_cachedDev2Id[0] != L'\0' && wcscmp(currentId, g_cachedDev2Id) == 0) + currentIcon = g_iconDev2; + + static WCHAR s_lastDev[256] = {0}; + static HICON s_lastIcon = nullptr; + if (!isAdd && wcscmp(currentDev, s_lastDev) == 0 && currentIcon == s_lastIcon) + return; + lstrcpyW(s_lastDev, currentDev); + s_lastIcon = currentIcon; + + NOTIFYICONDATAW nid = {sizeof(nid)}; + nid.hWnd = hWnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_ICON; + nid.uCallbackMessage = WM_TRAY_CALLBACK; + + if (g_cachedDev1Id[0] == L'\0' || g_cachedDev2Id[0] == L'\0') + swprintf_s(nid.szTip, L"MicSwap: Right-click to configure"); + else + swprintf_s(nid.szTip, L"Mic: %s", currentDev); + + nid.hIcon = currentIcon; + Shell_NotifyIconW(isAdd ? NIM_ADD : NIM_MODIFY, &nid); +} + +BOOL ToggleAudioDevice() { + if (g_cachedDev1Id[0] == L'\0' || g_cachedDev2Id[0] == L'\0') { + return FALSE; + } + + CoInitialize(nullptr); + IMMDeviceEnumerator* pEnum = nullptr; + if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { + CoUninitialize(); return FALSE; + } + + IMMDevice* pDefaultDevice = nullptr; + if (FAILED(pEnum->GetDefaultAudioEndpoint(eCapture, eMultimedia, &pDefaultDevice))) { + pEnum->Release(); CoUninitialize(); return FALSE; + } + + LPWSTR currentId = nullptr; + HRESULT hr = pDefaultDevice->GetId(¤tId); + pDefaultDevice->Release(); + if (FAILED(hr) || !currentId) { + pEnum->Release(); CoUninitialize(); return FALSE; + } + + PCWSTR targetId = (wcscmp(currentId, g_cachedDev1Id) == 0) + ? g_cachedDev2Id + : g_cachedDev1Id; + + if (!targetId || !targetId[0]) { + CoTaskMemFree(currentId); + pEnum->Release(); CoUninitialize(); return FALSE; + } + + if (currentId) CoTaskMemFree(currentId); + + IPolicyConfig* pPolicyConfig = nullptr; + if (SUCCEEDED(CoCreateInstance(CLSID_CPolicyConfigClient, nullptr, CLSCTX_ALL, + IID_IPolicyConfig_Win10_11, (void**)&pPolicyConfig))) { + pPolicyConfig->SetDefaultEndpoint(targetId, eConsole); + pPolicyConfig->SetDefaultEndpoint(targetId, eMultimedia); + pPolicyConfig->SetDefaultEndpoint(targetId, eCommunications); + pPolicyConfig->Release(); + } + + pEnum->Release(); + CoUninitialize(); + return TRUE; +} + +struct AudioDevice { + WCHAR id[512]; + WCHAR name[256]; +}; + +void BuildAndShowContextMenu(HWND hWnd) { + AudioDevice devices[MENU_MAX_DEVICES]; + int deviceCount = 0; + + IMMDeviceEnumerator* pEnum = nullptr; + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { + IMMDeviceCollection* pDevices = nullptr; + if (SUCCEEDED(pEnum->EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE, &pDevices))) { + UINT count = 0; + pDevices->GetCount(&count); + if (count > MENU_MAX_DEVICES) count = MENU_MAX_DEVICES; + + for (UINT i = 0; i < count; i++) { + IMMDevice* pDevice = nullptr; + if (FAILED(pDevices->Item(i, &pDevice))) continue; + + LPWSTR pId = nullptr; + if (SUCCEEDED(pDevice->GetId(&pId))) { + lstrcpynW(devices[deviceCount].id, pId, 512); + CoTaskMemFree(pId); + + IPropertyStore* pStore = nullptr; + if (SUCCEEDED(pDevice->OpenPropertyStore(STGM_READ, &pStore))) { + PROPVARIANT varName; + PropVariantInit(&varName); + if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &varName)) && varName.pwszVal) { + lstrcpynW(devices[deviceCount].name, varName.pwszVal, 256); + deviceCount++; + } + PropVariantClear(&varName); + pStore->Release(); + } + } + pDevice->Release(); + } + pDevices->Release(); + } + pEnum->Release(); + } + + HMENU hSub1 = CreatePopupMenu(); + HMENU hSub2 = CreatePopupMenu(); + + for (int i = 0; i < deviceCount; i++) { + UINT flags1 = MF_STRING | (wcscmp(devices[i].id, g_cachedDev1Id) == 0 ? MF_CHECKED : 0); + UINT flags2 = MF_STRING | (wcscmp(devices[i].id, g_cachedDev2Id) == 0 ? MF_CHECKED : 0); + AppendMenuW(hSub1, flags1, MENU_DEVICE1_BASE + i, devices[i].name); + AppendMenuW(hSub2, flags2, MENU_DEVICE2_BASE + i, devices[i].name); + } + + HMENU hMenu = CreatePopupMenu(); + + MENUITEMINFOW mii1 = {sizeof(mii1)}; + mii1.fMask = MIIM_SUBMENU | MIIM_STRING | MIIM_BITMAP; + mii1.hSubMenu = hSub1; + mii1.dwTypeData = (LPWSTR)L"Set as Input 1"; + mii1.hbmpItem = g_hIconDev1Bmp; + InsertMenuItemW(hMenu, (UINT)-1, TRUE, &mii1); + + MENUITEMINFOW mii2 = {sizeof(mii2)}; + mii2.fMask = MIIM_SUBMENU | MIIM_STRING | MIIM_BITMAP; + mii2.hSubMenu = hSub2; + mii2.dwTypeData = (LPWSTR)L"Set as Input 2"; + mii2.hbmpItem = g_hIconDev2Bmp; + InsertMenuItemW(hMenu, (UINT)-1, TRUE, &mii2); + + AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); + + MENUITEMINFOW mii = {sizeof(mii)}; + mii.fMask = MIIM_ID | MIIM_STRING | MIIM_BITMAP; + mii.wID = MENU_OPEN_WINDHAWK; + mii.dwTypeData = (LPWSTR)L"Open WindHawk"; + mii.hbmpItem = g_hWindHawkBmp; + InsertMenuItemW(hMenu, (UINT)-1, TRUE, &mii); + + AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); + + WCHAR statusText[300]; + if (g_cachedDev1Id[0] == L'\0' || g_cachedDev2Id[0] == L'\0') { + lstrcpyW(statusText, L"Right-click to configure inputs"); + } else { + WCHAR activeName[256] = L"Unknown"; + IMMDeviceEnumerator* pEnum2 = nullptr; + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum2))) { + IMMDevice* pDefault = nullptr; + if (SUCCEEDED(pEnum2->GetDefaultAudioEndpoint(eCapture, eMultimedia, &pDefault))) { + IPropertyStore* pStore = nullptr; + if (SUCCEEDED(pDefault->OpenPropertyStore(STGM_READ, &pStore))) { + PROPVARIANT v; PropVariantInit(&v); + if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &v)) && v.pwszVal) + lstrcpynW(activeName, v.pwszVal, 256); + PropVariantClear(&v); + pStore->Release(); + } + pDefault->Release(); + } + pEnum2->Release(); + } + swprintf_s(statusText, L"Active: %s", activeName); + } + AppendMenuW(hMenu, MF_STRING | MF_GRAYED, 0, statusText); + + POINT pt; + GetCursorPos(&pt); + SetForegroundWindow(hWnd); + int cmd = TrackPopupMenu(hMenu, TPM_RETURNCMD | TPM_RIGHTBUTTON | TPM_BOTTOMALIGN | TPM_RIGHTALIGN, + pt.x, pt.y, 0, hWnd, nullptr); + PostMessageW(hWnd, WM_NULL, 0, 0); + + DestroyMenu(hMenu); + + if (cmd >= MENU_DEVICE1_BASE && cmd < MENU_DEVICE1_BASE + deviceCount) { + int idx = cmd - MENU_DEVICE1_BASE; + SaveDeviceSelection(1, devices[idx].id, devices[idx].name); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + } else if (cmd >= MENU_DEVICE2_BASE && cmd < MENU_DEVICE2_BASE + deviceCount) { + int idx = cmd - MENU_DEVICE2_BASE; + SaveDeviceSelection(2, devices[idx].id, devices[idx].name); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + } else if (cmd == MENU_OPEN_WINDHAWK) { + SHELLEXECUTEINFOW sei; + ZeroMemory(&sei, sizeof(sei)); + sei.cbSize = sizeof(sei); + sei.lpFile = g_windhawkPath; + sei.nShow = SW_SHOWNORMAL; + ShellExecuteExW(&sei); + } +} + +DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { + if (ToggleAudioDevice() && g_trayHwnd) { + PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, 0, 0); + } + InterlockedExchange(&g_isProcessingClick, 0); + return 0; +} + +LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { + if (msg == WM_TRAY_CALLBACK && lParam == WM_RBUTTONUP) { + BuildAndShowContextMenu(hWnd); + } else if (msg == WM_TRAY_CALLBACK && lParam == WM_LBUTTONUP) { + if (InterlockedExchange(&g_isProcessingClick, 1) == 0) { + DWORD now = GetTickCount(); + if (now - g_lastClickTime > CLICK_DEBOUNCE_MS) { + g_lastClickTime = now; + if (g_workerThread) { CloseHandle(g_workerThread); g_workerThread = nullptr; } + g_workerThread = CreateThread(nullptr, 0, WorkerThreadProc, nullptr, 0, nullptr); + } else InterlockedExchange(&g_isProcessingClick, 0); + } + } else if (msg == WM_UPDATE_TRAY_STATE || (msg == WM_TIMER && wParam == 1)) { + UpdateTrayTip(hWnd, FALSE); + } else if (msg == g_taskbarCreatedMsg && g_taskbarCreatedMsg != 0) { + UpdateTrayTip(hWnd, TRUE); + } else if (msg == WM_DESTROY) { + KillTimer(hWnd, 1); + NOTIFYICONDATAW nid = {sizeof(nid)}; + nid.hWnd = hWnd; + nid.uID = TRAY_ICON_ID; + Shell_NotifyIconW(NIM_DELETE, &nid); + PostQuitMessage(0); + } + return DefWindowProcW(hWnd, msg, wParam, lParam); +} + +DWORD WINAPI TrayThreadProc(LPVOID) { + CoInitialize(nullptr); + g_taskbarCreatedMsg = RegisterWindowMessageW(L"TaskbarCreated"); + WNDCLASSW wc = {0}; + wc.lpfnWndProc = TrayWndProc; + wc.hInstance = g_hInstance; + wc.lpszClassName = L"MicSwitcherWindowClass"; + RegisterClassW(&wc); + g_trayHwnd = CreateWindowExW(0, wc.lpszClassName, L"Mic Switcher", WS_OVERLAPPED, 0,0,1,1, nullptr, nullptr, g_hInstance, nullptr); + + IPropertyStore* pps = nullptr; + if (SUCCEEDED(SHGetPropertyStoreForWindow(g_trayHwnd, IID_PPV_ARGS(&pps)))) { + PROPVARIANT var; var.vt = VT_LPWSTR; var.pwszVal = (LPWSTR)CoTaskMemAlloc(MAX_PATH); + if (var.pwszVal) { + lstrcpyW(var.pwszVal, L"BlackPaw.MicSwitcher"); + pps->SetValue(PKEY_AppUserModel_ID, var); + CoTaskMemFree(var.pwszVal); + } + pps->Commit(); pps->Release(); + } + + SetTimer(g_trayHwnd, 1, 1500, nullptr); + UpdateTrayTip(g_trayHwnd, TRUE); + MSG msg; + while (GetMessageW(&msg, nullptr, 0, 0) > 0) { DispatchMessageW(&msg); } + CoUninitialize(); + return 0; +} + +BOOL WhTool_ModInit() { + Wh_Log(L"MicSwap Mod Init"); + g_hInstance = GetModuleHandle(nullptr); + GetModuleFileName(nullptr, g_windhawkPath, ARRAYSIZE(g_windhawkPath)); + ExtractIconExW(L"ddores.dll", 98, nullptr, &g_hWindHawkIcon, 1); + if (!g_hWindHawkIcon) ExtractIconExW(L"ddores.dll", 94, nullptr, &g_hWindHawkIcon, 1); + if (!g_hWindHawkIcon) ExtractIconExW(L"ddores.dll", 95, nullptr, &g_hWindHawkIcon, 1); + if (!g_hWindHawkIcon) ExtractIconExW(L"ddores.dll", 6, nullptr, &g_hWindHawkIcon, 1); + if (g_hWindHawkIcon) { + ICONINFO iconInfo = {0}; + GetIconInfo(g_hWindHawkIcon, &iconInfo); + g_hWindHawkBmp = iconInfo.hbmColor; + if (!g_hWindHawkBmp) { + g_hWindHawkBmp = iconInfo.hbmMask; + } else if (iconInfo.hbmMask) { + DeleteObject(iconInfo.hbmMask); + } + } + LoadUserIconsAndSettings(); + LoadDeviceSelections(); + g_trayThread = CreateThread(nullptr, 0, TrayThreadProc, nullptr, 0, nullptr); + return TRUE; +} + +void WhTool_ModSettingsChanged() { + LoadUserIconsAndSettings(); + LoadDeviceSelections(); + if (g_trayHwnd) PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, 0, 0); +} + +void WhTool_ModUninit() { + Wh_Log(L"MicSwap Mod Uninit"); + if (g_trayHwnd) PostMessageW(g_trayHwnd, WM_DESTROY, 0, 0); + if (g_trayThread) { WaitForSingleObject(g_trayThread, 2000); CloseHandle(g_trayThread); g_trayThread = nullptr; } + if (g_workerThread) { WaitForSingleObject(g_workerThread, 2000); CloseHandle(g_workerThread); g_workerThread = nullptr; } + if (g_iconDev1) { DestroyIcon(g_iconDev1); g_iconDev1 = nullptr; } + if (g_iconDev2) { DestroyIcon(g_iconDev2); g_iconDev2 = nullptr; } + if (g_hIconDev1Bmp) { DeleteObject(g_hIconDev1Bmp); g_hIconDev1Bmp = nullptr; } + if (g_hIconDev2Bmp) { DeleteObject(g_hIconDev2Bmp); g_hIconDev2Bmp = nullptr; } + if (g_hWindHawkIcon) { DestroyIcon(g_hWindHawkIcon); g_hWindHawkIcon = nullptr; } + if (g_hWindHawkBmp) { DeleteObject(g_hWindHawkBmp); g_hWindHawkBmp = nullptr; } +} + +//////////////////////////////////////////////////////////////////////////////// +// Windhawk tool mod implementation for mods which don't need to inject to other +// processes or hook other functions. Context: +// https://github.com/ramensoftware/windhawk/wiki/Mods-as-tools:-Running-mods-in-a-dedicated-process +// +// The mod will load and run in a dedicated windhawk.exe process. +// +// Paste the code below as part of the mod code, and use these callbacks: +// * WhTool_ModInit +// * WhTool_ModSettingsChanged +// * WhTool_ModUninit +// +// Currently, other callbacks are not supported. + +bool g_isToolModProcessLauncher; +HANDLE g_toolModProcessMutex; + +void WINAPI EntryPoint_Hook() { + Wh_Log(L">"); + ExitThread(0); +} + +BOOL Wh_ModInit() { + DWORD sessionId; + if (ProcessIdToSessionId(GetCurrentProcessId(), &sessionId) && + sessionId == 0) { + return FALSE; + } + + bool isExcluded = false; + bool isToolModProcess = false; + bool isCurrentToolModProcess = false; + int argc; + LPWSTR* argv = CommandLineToArgvW(GetCommandLine(), &argc); + if (!argv) { + Wh_Log(L"CommandLineToArgvW failed"); + return FALSE; + } + + for (int i = 1; i < argc; i++) { + if (wcscmp(argv[i], L"-service") == 0 || + wcscmp(argv[i], L"-service-start") == 0 || + wcscmp(argv[i], L"-service-stop") == 0) { + isExcluded = true; + break; + } + } + + for (int i = 1; i < argc - 1; i++) { + if (wcscmp(argv[i], L"-tool-mod") == 0) { + isToolModProcess = true; + if (wcscmp(argv[i + 1], WH_MOD_ID) == 0) { + isCurrentToolModProcess = true; + } + break; + } + } + + LocalFree(argv); + + if (isExcluded) { + return FALSE; + } + + if (isCurrentToolModProcess) { + g_toolModProcessMutex = + CreateMutex(nullptr, TRUE, L"windhawk-tool-mod_" WH_MOD_ID); + if (!g_toolModProcessMutex) { + Wh_Log(L"CreateMutex failed"); + ExitProcess(1); + } + + if (GetLastError() == ERROR_ALREADY_EXISTS) { + Wh_Log(L"Tool mod already running (%s)", WH_MOD_ID); + ExitProcess(1); + } + + if (!WhTool_ModInit()) { + ExitProcess(1); + } + + IMAGE_DOS_HEADER* dosHeader = + (IMAGE_DOS_HEADER*)GetModuleHandle(nullptr); + IMAGE_NT_HEADERS* ntHeaders = + (IMAGE_NT_HEADERS*)((BYTE*)dosHeader + dosHeader->e_lfanew); + + DWORD entryPointRVA = ntHeaders->OptionalHeader.AddressOfEntryPoint; + void* entryPoint = (BYTE*)dosHeader + entryPointRVA; + + Wh_SetFunctionHook(entryPoint, (void*)EntryPoint_Hook, nullptr); + return TRUE; + } + + if (isToolModProcess) { + return FALSE; + } + + g_isToolModProcessLauncher = true; + return TRUE; +} + +void Wh_ModAfterInit() { + if (!g_isToolModProcessLauncher) { + return; + } + + WCHAR currentProcessPath[MAX_PATH]; + switch (GetModuleFileName(nullptr, currentProcessPath, + ARRAYSIZE(currentProcessPath))) { + case 0: + case ARRAYSIZE(currentProcessPath): + Wh_Log(L"GetModuleFileName failed"); + return; + } + + WCHAR + commandLine[MAX_PATH + 2 + + (sizeof(L" -tool-mod \"" WH_MOD_ID "\"") / sizeof(WCHAR)) - 1]; + swprintf_s(commandLine, L"\"%s\" -tool-mod \"%s\"", currentProcessPath, + WH_MOD_ID); + + HMODULE kernelModule = GetModuleHandle(L"kernelbase.dll"); + if (!kernelModule) { + kernelModule = GetModuleHandle(L"kernel32.dll"); + if (!kernelModule) { + Wh_Log(L"No kernelbase.dll/kernel32.dll"); + return; + } + } + + using CreateProcessInternalW_t = BOOL(WINAPI*)( + HANDLE hUserToken, LPCWSTR lpApplicationName, LPWSTR lpCommandLine, + LPSECURITY_ATTRIBUTES lpProcessAttributes, + LPSECURITY_ATTRIBUTES lpThreadAttributes, WINBOOL bInheritHandles, + DWORD dwCreationFlags, LPVOID lpEnvironment, LPCWSTR lpCurrentDirectory, + LPSTARTUPINFOW lpStartupInfo, + LPPROCESS_INFORMATION lpProcessInformation, + PHANDLE hRestrictedUserToken); + CreateProcessInternalW_t pCreateProcessInternalW = + (CreateProcessInternalW_t)GetProcAddress(kernelModule, + "CreateProcessInternalW"); + if (!pCreateProcessInternalW) { + Wh_Log(L"No CreateProcessInternalW"); + return; + } + + STARTUPINFO si{ + .cb = sizeof(STARTUPINFO), + .dwFlags = STARTF_FORCEOFFFEEDBACK, + }; + PROCESS_INFORMATION pi; + if (!pCreateProcessInternalW(nullptr, currentProcessPath, commandLine, + nullptr, nullptr, FALSE, NORMAL_PRIORITY_CLASS, + nullptr, nullptr, &si, &pi, nullptr)) { + Wh_Log(L"CreateProcess failed"); + return; + } + + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); +} + +void Wh_ModSettingsChanged() { + if (g_isToolModProcessLauncher) { + return; + } + + WhTool_ModSettingsChanged(); +} + +void Wh_ModUninit() { + if (g_isToolModProcessLauncher) { + return; + } + + WhTool_ModUninit(); + ExitProcess(0); +} From 76a8b1d81067f6d9c7234863bb03bc6ab210e24d Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Sat, 2 May 2026 14:05:18 +0300 Subject: [PATCH 16/56] Update and rename MicSwap.wh.cpp to microswap.wh.cpp --- mods/{MicSwap.wh.cpp => microswap.wh.cpp} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename mods/{MicSwap.wh.cpp => microswap.wh.cpp} (99%) diff --git a/mods/MicSwap.wh.cpp b/mods/microswap.wh.cpp similarity index 99% rename from mods/MicSwap.wh.cpp rename to mods/microswap.wh.cpp index 3ad25553bd..79a29d576a 100644 --- a/mods/MicSwap.wh.cpp +++ b/mods/microswap.wh.cpp @@ -1,6 +1,6 @@ // ==WindhawkMod== -// @id micswap -// @name MicSwap +// @id microswap +// @name MicroSwap // @description Adds a tray icon to instantly toggle between two preferred audio inputs (microphones). // @version 1.0.0 // @author BlackPaw From 88b81a62893d6a9a28431696116ccd84b472e9f4 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Sun, 10 May 2026 20:17:56 +0300 Subject: [PATCH 17/56] Updated functionality new features and functions added. scroll capability over 2 devices can be selected. --- mods/audioswap.wh.cpp | 1137 +++++++++++++++++++++++++++++++---------- 1 file changed, 870 insertions(+), 267 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index abc4701e3d..fc6d4727f1 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -1,34 +1,55 @@ // ==WindhawkMod== // @id audioswap // @name AudioSwap -// @description Adds a tray icon to instantly toggle between two preferred audio outputs. -// @version 1.1.1 +// @description Tray icon to cycle between multiple preferred audio outputs. Supports up to 6 devices with click or scroll to swap. +// @version 1.3.0 // @author BlackPaw // @github https://github.com/BlackPaw21 // @include windhawk.exe -// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lshlwapi +// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 // ==/WindhawkMod== // ==WindhawkModReadme== /* # AudioSwap -Instantly toggle between two audio output devices from your system tray — no diving into Sound settings. +Instantly cycle between multiple audio output devices from your system tray — no diving into Sound settings. --- ## How to Use -1. **Choose Icons** — Open the **Settings** tab and pick an icon for each of your two devices. -2. **Select Your Devices** — Right-click the tray icon. Use **Set as Device 1** and **Set as Device 2** to assign your outputs from the live device list. -3. **Toggle** — Left-click the tray icon at any time to swap between the two devices instantly. +1. **Choose Device Count** — Open the **Settings** tab and pick how many devices to cycle through (2–6). +2. **Choose Interaction Mode** — Select **Click to Swap** (left-click the icon) or **Scroll to Swap** (mouse wheel over the icon). +3. **Choose Icons** — Pick an icon for each device slot. Icons for slots 3–6 are only used when the device count is set high enough. +4. **Assign Devices** — Right-click the tray icon. Use **Set as Device 1 / 2 / 3...** to assign each slot from the live device list. +5. **Swap** — Click or scroll the tray icon to cycle through your assigned devices. -> The tray tooltip always shows the active device. On first run it will read *"Right-click to configure"* until both devices are assigned. +### Scroll to Swap mode extras + +- **Left-click** mutes the current device. Click again to unmute. The tray tooltip shows *(Muted)* and the icon gains a red dot while active. +- **Scrolling** to a different device automatically unmutes the previous one before switching. + +> The tray tooltip always shows the active device. On first run it reads *"Right-click to configure"* until at least two slots are assigned. + +--- + +## Known Bugs + +- **Scroll to Swap may not respond while the Windhawk window has focus.** This is a limitation of the low-level mouse hook when another application owns keyboard/mouse focus. Switch focus away from Windhawk and scrolling will work normally. --- ## Changelog -### v1.1.0 +### v1.3.0 +- Select a mode in the Windhawk settings: Click / Scroll +- Up to 6 devices; scroll-to-swap mode; dynamic right-click menu. +- Left-click mutes in scroll mode; black dot overlay on tray icon. +- Cycling auto-unmutes previous device. +- Inactive/disconnected devices skipped when cycling. +- Mute state persisted across mod restarts. + +### v1.2.0 - **New:** Can now open WindHawk directly from the icon using right click - **New:** Added new icons to select from - **Improved:** added icons in the right click menu @@ -40,15 +61,30 @@ Instantly toggle between two audio output devices from your system tray — no d - **Improved:** Tray tooltip prompts you to configure on first run instead of showing "Unknown Device". - **Removed:** Device name text fields from the Settings tab (replaced by the right-click menu). -### v1.0.1 +### v1.0.0 - Initial release. */ // ==/WindhawkModReadme== // ==WindhawkModSettings== /* +- deviceCount: 2 + $name: Number of Devices + $description: How many audio devices to cycle through (2 to 6) + $options: + - 2: 2 Devices + - 3: 3 Devices + - 4: 4 Devices + - 5: 5 Devices + - 6: 6 Devices +- swapMode: click_to_swap + $name: Interaction Mode + $description: How to cycle through devices + $options: + - click_to_swap: Click to Swap + - scroll_to_swap: Scroll to Swap - icon1: speaker_normal - $name: First Device Icon + $name: Device 1 Icon $options: - speaker_normal: Normal Speaker - speaker_square: Square Speaker @@ -61,7 +97,63 @@ Instantly toggle between two audio output devices from your system tray — no d - headset_modern: Modern Gaming Headset - earphones: Earphones - icon2: speaker_square - $name: Second Device Icon + $name: Device 2 Icon + $options: + - speaker_normal: Normal Speaker + - speaker_square: Square Speaker + - speaker_modern: Modern Speaker + - speaker_modern_square: Modern Square Speaker + - audio_wave: Audio Wave + - headphones: Headphones + - headset_gaming: Gaming Headset + - headphones_modern: Modern Headphones + - headset_modern: Modern Gaming Headset + - earphones: Earphones +- icon3: headphones + $name: Device 3 Icon + $description: Used when Number of Devices is 3 or more + $options: + - speaker_normal: Normal Speaker + - speaker_square: Square Speaker + - speaker_modern: Modern Speaker + - speaker_modern_square: Modern Square Speaker + - audio_wave: Audio Wave + - headphones: Headphones + - headset_gaming: Gaming Headset + - headphones_modern: Modern Headphones + - headset_modern: Modern Gaming Headset + - earphones: Earphones +- icon4: headset_gaming + $name: Device 4 Icon + $description: Used when Number of Devices is 4 or more + $options: + - speaker_normal: Normal Speaker + - speaker_square: Square Speaker + - speaker_modern: Modern Speaker + - speaker_modern_square: Modern Square Speaker + - audio_wave: Audio Wave + - headphones: Headphones + - headset_gaming: Gaming Headset + - headphones_modern: Modern Headphones + - headset_modern: Modern Gaming Headset + - earphones: Earphones +- icon5: headphones_modern + $name: Device 5 Icon + $description: Used when Number of Devices is 5 or more + $options: + - speaker_normal: Normal Speaker + - speaker_square: Square Speaker + - speaker_modern: Modern Speaker + - speaker_modern_square: Modern Square Speaker + - audio_wave: Audio Wave + - headphones: Headphones + - headset_gaming: Gaming Headset + - headphones_modern: Modern Headphones + - headset_modern: Modern Gaming Headset + - earphones: Earphones +- icon6: headset_modern + $name: Device 6 Icon + $description: Used when Number of Devices is 6 $options: - speaker_normal: Normal Speaker - speaker_square: Square Speaker @@ -79,45 +171,71 @@ Instantly toggle between two audio output devices from your system tray — no d #include #include #include -#include -#include #include +#include #include #include #include #include -#define TRAY_ICON_ID 1 -#define WM_TRAY_CALLBACK (WM_USER + 1) +#define TRAY_ICON_ID 1 +#define WM_TRAY_CALLBACK (WM_USER + 1) #define WM_UPDATE_TRAY_STATE (WM_USER + 2) -#define MENU_DEVICE1_BASE 1000 -#define MENU_DEVICE2_BASE 2000 -#define MENU_OPEN_WINDHAWK 3000 -#define MENU_MAX_DEVICES 32 - -const DWORD CLICK_DEBOUNCE_MS = 500; - -static volatile LONG g_isProcessingClick = 0; -static HANDLE g_trayThread = nullptr; -static HANDLE g_workerThread = nullptr; -static HWND g_trayHwnd = nullptr; -static HINSTANCE g_hInstance = nullptr; -static WCHAR g_windhawkPath[MAX_PATH] = {0}; -static HICON g_hWindHawkIcon = nullptr; -static HBITMAP g_hWindHawkBmp = nullptr; -static HICON g_iconDev1 = nullptr; -static HBITMAP g_hIconDev1Bmp = nullptr; -static HICON g_iconDev2 = nullptr; -static HBITMAP g_hIconDev2Bmp = nullptr; -static DWORD g_lastClickTime = 0; -static UINT g_taskbarCreatedMsg = 0; - -// Cached device selections (loaded from Windhawk storage) -static WCHAR g_cachedDev1Id[512] = {0}; -static WCHAR g_cachedDev1Name[256] = {0}; -static WCHAR g_cachedDev2Id[512] = {0}; -static WCHAR g_cachedDev2Name[256] = {0}; +#define WM_TRAY_SCROLL (WM_USER + 3) // wParam = direction (+1 or -1) + +// Slot N uses menu IDs: N*100 + device_index (0..31). Max slot 6 → ID 631. +#define MENU_SLOT_BASE 100 +#define MENU_MAX_DEVICES 32 +#define MENU_OPEN_WINDHAWK 9000 + +#define MAX_DEVICE_SLOTS 6 + +const DWORD CLICK_DEBOUNCE_MS = 500; +const DWORD SCROLL_DEBOUNCE_MS = 300; + +// ─── Globals ────────────────────────────────────────────────────────────────── + +static volatile LONG g_isProcessingClick = 0; +static HANDLE g_trayThread = nullptr; +static HANDLE g_workerThread = nullptr; +static volatile HWND g_trayHwnd = nullptr; +static HINSTANCE g_hInstance = nullptr; +static WCHAR g_windhawkPath[MAX_PATH] = {}; +static WCHAR g_ddoresDllPath[MAX_PATH] = {}; // full system32 path +static HICON g_hWindHawkIcon = nullptr; +static HBITMAP g_hWindHawkBmp = nullptr; +static DWORD g_lastClickTime = 0; +static DWORD g_lastScrollTime = 0; +static UINT g_taskbarCreatedMsg = 0; + +// Mouse hook +static HHOOK g_mouseHook = nullptr; +static RECT g_trayIconRect = {}; + +// Per-slot icon handles +static HICON g_iconDev[MAX_DEVICE_SLOTS] = {}; +static HBITMAP g_hIconDevBmp[MAX_DEVICE_SLOTS] = {}; + +// ── Shared state — protected by g_stateLock ────────────────────────────────── +// Read by the worker thread (CycleAudioDevice) and tray thread (UpdateTrayTip, +// BuildAndShowContextMenu). Written by the main thread (LoadDeviceSelections, +// LoadUserIconsAndSettings, SaveDeviceSelection) and tray thread (mute toggle). +static CRITICAL_SECTION g_stateLock; +static WCHAR g_cachedDevId[MAX_DEVICE_SLOTS][512] = {}; +static WCHAR g_cachedDevName[MAX_DEVICE_SLOTS][256] = {}; +static int g_deviceSlotCount = 2; +static bool g_isMutedByUs = false; +static WCHAR g_mutedDeviceId[512] = {}; +// ───────────────────────────────────────────────────────────────────────────── + +// Mode flag: set/read atomically with InterlockedExchange/InterlockedRead. +static volatile LONG g_scrollToSwap = 0; // 1 = scroll mode + +// IMMNotificationClient registration +class AudioDeviceNotifier; +static IMMDeviceEnumerator* g_notifEnum = nullptr; +static AudioDeviceNotifier* g_deviceNotifier = nullptr; const CLSID CLSID_CPolicyConfigClient = { 0x870af99c, 0x171d, 0x4f9e, {0xaf, 0x0d, 0xe6, 0x3d, 0xf4, 0x0c, 0x2b, 0xc9} @@ -144,189 +262,548 @@ IPolicyConfig : public IUnknown virtual HRESULT STDMETHODCALLTYPE SetEndpointVisibility(PCWSTR, INT) = 0; }; -int GetIconIndex(PCWSTR iconSetting) { - if (iconSetting) { - if (wcscmp(iconSetting, L"speaker_normal") == 0) return 1; - if (wcscmp(iconSetting, L"speaker_square") == 0) return 4; - if (wcscmp(iconSetting, L"speaker_modern") == 0) return 90; - if (wcscmp(iconSetting, L"speaker_modern_square") == 0) return 93; - if (wcscmp(iconSetting, L"audio_wave") == 0) return 94; - if (wcscmp(iconSetting, L"headphones") == 0) return 2; - if (wcscmp(iconSetting, L"headset_gaming") == 0) return 8; - if (wcscmp(iconSetting, L"headphones_modern") == 0) return 91; - if (wcscmp(iconSetting, L"headset_modern") == 0) return 95; - if (wcscmp(iconSetting, L"earphones") == 0) return 6; +// ─── Helpers ────────────────────────────────────────────────────────────────── + +int GetIconIndex(PCWSTR s) { + if (s) { + if (wcscmp(s, L"speaker_normal") == 0) return 1; + if (wcscmp(s, L"speaker_square") == 0) return 4; + if (wcscmp(s, L"speaker_modern") == 0) return 90; + if (wcscmp(s, L"speaker_modern_square") == 0) return 93; + if (wcscmp(s, L"audio_wave") == 0) return 94; + if (wcscmp(s, L"headphones") == 0) return 2; + if (wcscmp(s, L"headset_gaming") == 0) return 8; + if (wcscmp(s, L"headphones_modern") == 0) return 91; + if (wcscmp(s, L"headset_modern") == 0) return 95; + if (wcscmp(s, L"earphones") == 0) return 6; } return 4; } +// ─── Device selection data ──────────────────────────────────────────────────── +// All functions below acquire g_stateLock for writes to shared slot data. + void LoadDeviceSelections() { - g_cachedDev1Id[0] = g_cachedDev1Name[0] = L'\0'; - g_cachedDev2Id[0] = g_cachedDev2Name[0] = L'\0'; + // Read from Windhawk storage outside the lock (Wh_GetStringValue is thread-safe). + WCHAR tmpIds[MAX_DEVICE_SLOTS][512] = {}; + WCHAR tmpNames[MAX_DEVICE_SLOTS][256] = {}; + + int count; + EnterCriticalSection(&g_stateLock); + count = g_deviceSlotCount; + LeaveCriticalSection(&g_stateLock); + + WCHAR keyId[32], keyName[32]; + for (int i = 0; i < count; i++) { + swprintf_s(keyId, L"Device%dId", i + 1); + swprintf_s(keyName, L"Device%dName", i + 1); + Wh_GetStringValue(keyId, tmpIds[i], 512); + Wh_GetStringValue(keyName, tmpNames[i], 256); + } - Wh_GetStringValue(L"Device1Id", g_cachedDev1Id, ARRAYSIZE(g_cachedDev1Id)); - Wh_GetStringValue(L"Device1Name", g_cachedDev1Name, ARRAYSIZE(g_cachedDev1Name)); - Wh_GetStringValue(L"Device2Id", g_cachedDev2Id, ARRAYSIZE(g_cachedDev2Id)); - Wh_GetStringValue(L"Device2Name", g_cachedDev2Name, ARRAYSIZE(g_cachedDev2Name)); + EnterCriticalSection(&g_stateLock); + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + lstrcpynW(g_cachedDevId[i], tmpIds[i], 512); + lstrcpynW(g_cachedDevName[i], tmpNames[i], 256); + } + LeaveCriticalSection(&g_stateLock); } void SaveDeviceSelection(int slot, PCWSTR deviceId, PCWSTR friendlyName) { - if (slot == 1) { - Wh_SetStringValue(L"Device1Id", deviceId); - Wh_SetStringValue(L"Device1Name", friendlyName); - lstrcpynW(g_cachedDev1Id, deviceId, 512); - lstrcpynW(g_cachedDev1Name, friendlyName, 256); - } else { - Wh_SetStringValue(L"Device2Id", deviceId); - Wh_SetStringValue(L"Device2Name", friendlyName); - lstrcpynW(g_cachedDev2Id, deviceId, 512); - lstrcpynW(g_cachedDev2Name, friendlyName, 256); + if (slot < 1 || slot > MAX_DEVICE_SLOTS) return; + int idx = slot - 1; + WCHAR keyId[32], keyName[32]; + swprintf_s(keyId, L"Device%dId", slot); + swprintf_s(keyName, L"Device%dName", slot); + Wh_SetStringValue(keyId, deviceId); + Wh_SetStringValue(keyName, friendlyName); + + EnterCriticalSection(&g_stateLock); + lstrcpynW(g_cachedDevId[idx], deviceId, 512); + lstrcpynW(g_cachedDevName[idx], friendlyName, 256); + LeaveCriticalSection(&g_stateLock); +} + +static int ReadDeviceCountSetting() { + PCWSTR s = Wh_GetStringSetting(L"deviceCount"); + int n = 2; + if (s && s[0] >= L'2' && s[0] <= L'9') { + n = 0; + for (const WCHAR* p = s; *p >= L'0' && *p <= L'9'; p++) + n = n * 10 + (*p - L'0'); } + if (s) Wh_FreeStringSetting(s); + if (n < 2) n = 2; + if (n > MAX_DEVICE_SLOTS) n = MAX_DEVICE_SLOTS; + return n; } void LoadUserIconsAndSettings() { - if (g_iconDev1) DestroyIcon(g_iconDev1); - if (g_iconDev2) DestroyIcon(g_iconDev2); - if (g_hIconDev1Bmp) DeleteObject(g_hIconDev1Bmp); - if (g_hIconDev2Bmp) DeleteObject(g_hIconDev2Bmp); - g_hIconDev1Bmp = nullptr; - g_hIconDev2Bmp = nullptr; - - PCWSTR s1 = Wh_GetStringSetting(L"icon1"); - PCWSTR s2 = Wh_GetStringSetting(L"icon2"); - - ExtractIconExW(L"ddores.dll", GetIconIndex(s1), nullptr, &g_iconDev1, 1); - ExtractIconExW(L"ddores.dll", GetIconIndex(s2), nullptr, &g_iconDev2, 1); - - if (g_iconDev1) { - ICONINFO iconInfo = {0}; - if (GetIconInfo(g_iconDev1, &iconInfo)) { - g_hIconDev1Bmp = iconInfo.hbmColor; - if (!g_hIconDev1Bmp) { - g_hIconDev1Bmp = iconInfo.hbmMask; - } else if (iconInfo.hbmMask) { - DeleteObject(iconInfo.hbmMask); + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + if (g_iconDev[i]) { DestroyIcon(g_iconDev[i]); g_iconDev[i] = nullptr; } + if (g_hIconDevBmp[i]){ DeleteObject(g_hIconDevBmp[i]); g_hIconDevBmp[i] = nullptr; } + } + + int newCount = ReadDeviceCountSetting(); + + PCWSTR swapMode = Wh_GetStringSetting(L"swapMode"); + LONG newScroll = (swapMode && wcscmp(swapMode, L"scroll_to_swap") == 0) ? 1 : 0; + if (swapMode) Wh_FreeStringSetting(swapMode); + + EnterCriticalSection(&g_stateLock); + g_deviceSlotCount = newCount; + LeaveCriticalSection(&g_stateLock); + + InterlockedExchange(&g_scrollToSwap, newScroll); + + WCHAR iconKey[16]; + for (int i = 0; i < newCount; i++) { + swprintf_s(iconKey, L"icon%d", i + 1); + PCWSTR s = Wh_GetStringSetting(iconKey); + ExtractIconExW(g_ddoresDllPath, GetIconIndex(s), nullptr, &g_iconDev[i], 1); + if (s) Wh_FreeStringSetting(s); + + if (g_iconDev[i]) { + ICONINFO ii = {}; + if (GetIconInfo(g_iconDev[i], &ii)) { + g_hIconDevBmp[i] = ii.hbmColor; + if (!g_hIconDevBmp[i]) { + g_hIconDevBmp[i] = ii.hbmMask; + } else if (ii.hbmMask) { + DeleteObject(ii.hbmMask); + } + } + } + } +} + + +// ─── Tray icon rect ──────────────────────────────────────────────────────────── + +static void RefreshTrayIconRect() { + HWND hwnd = g_trayHwnd; + if (!hwnd) return; + NOTIFYICONIDENTIFIER nii = {sizeof(nii)}; + nii.hWnd = hwnd; + nii.uID = TRAY_ICON_ID; + Shell_NotifyIconGetRect(&nii, &g_trayIconRect); +} + +// ─── Mute helpers ───────────────────────────────────────────────────────────── +// Every function requires COM already initialized on the calling thread. + +static BOOL ApplyMute(PCWSTR deviceId, BOOL mute) { + IMMDeviceEnumerator* pEnum = nullptr; + if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) + return FALSE; + + IMMDevice* pDevice = nullptr; + HRESULT hr = (deviceId && deviceId[0]) + ? pEnum->GetDevice(deviceId, &pDevice) + : pEnum->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDevice); + pEnum->Release(); + if (FAILED(hr) || !pDevice) return FALSE; + + IAudioEndpointVolume* pVol = nullptr; + hr = pDevice->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, nullptr, (void**)&pVol); + pDevice->Release(); + if (FAILED(hr) || !pVol) return FALSE; + + pVol->SetMute(mute, nullptr); + pVol->Release(); + return TRUE; +} + + +// Call from tray thread (COM already initialized) or worker thread (has its own COM). +static void RestoreMute() { + EnterCriticalSection(&g_stateLock); + if (!g_isMutedByUs) { LeaveCriticalSection(&g_stateLock); return; } + WCHAR localId[512]; + lstrcpynW(localId, g_mutedDeviceId, 512); + g_isMutedByUs = false; + g_mutedDeviceId[0] = L'\0'; + LeaveCriticalSection(&g_stateLock); + ApplyMute(localId, FALSE); + Wh_SetStringValue(L"MutedDeviceId", L""); +} + +// For callers that lack a COM apartment (main thread callbacks). +static void RestoreMuteExternal() { + EnterCriticalSection(&g_stateLock); + bool wasMuted = g_isMutedByUs; + WCHAR localId[512] = {}; + if (wasMuted) { + lstrcpynW(localId, g_mutedDeviceId, 512); + g_isMutedByUs = false; + g_mutedDeviceId[0] = L'\0'; + } + LeaveCriticalSection(&g_stateLock); + if (wasMuted) { + CoInitialize(nullptr); + ApplyMute(localId, FALSE); + CoUninitialize(); + Wh_SetStringValue(L"MutedDeviceId", L""); + } +} + +// Called from TrayWndProc — tray thread has COM initialized by TrayThreadProc. +static void ToggleMuteCurrentDevice() { + EnterCriticalSection(&g_stateLock); + bool already = g_isMutedByUs; + WCHAR localId[512] = {}; + if (already) { + lstrcpynW(localId, g_mutedDeviceId, 512); + g_isMutedByUs = false; + g_mutedDeviceId[0] = L'\0'; + } + LeaveCriticalSection(&g_stateLock); + + if (already) { + ApplyMute(localId, FALSE); + Wh_SetStringValue(L"MutedDeviceId", L""); + return; + } + + // Mute the current default device + IMMDeviceEnumerator* pEnum = nullptr; + if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) + return; + + IMMDevice* pDefault = nullptr; + if (SUCCEEDED(pEnum->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDefault))) { + LPWSTR pId = nullptr; + if (SUCCEEDED(pDefault->GetId(&pId))) { + if (ApplyMute(pId, TRUE)) { + EnterCriticalSection(&g_stateLock); + lstrcpynW(g_mutedDeviceId, pId, 512); + g_isMutedByUs = true; + LeaveCriticalSection(&g_stateLock); + Wh_SetStringValue(L"MutedDeviceId", pId); } + CoTaskMemFree(pId); + } + pDefault->Release(); + } + pEnum->Release(); +} + +// ─── Device-state check ─────────────────────────────────────────────────────── + +static bool IsDeviceActive(IMMDeviceEnumerator* pEnum, PCWSTR deviceId) { + if (!deviceId || !deviceId[0]) return false; + IMMDevice* pDevice = nullptr; + if (FAILED(pEnum->GetDevice(deviceId, &pDevice))) return false; + DWORD state = 0; + bool active = SUCCEEDED(pDevice->GetState(&state)) && (state == DEVICE_STATE_ACTIVE); + pDevice->Release(); + return active; +} + +// ─── IMMNotificationClient ──────────────────────────────────────────────────── + +class AudioDeviceNotifier : public IMMNotificationClient { + volatile LONG m_ref; +public: + AudioDeviceNotifier() : m_ref(1) {} + virtual ~AudioDeviceNotifier() = default; + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override { + if (riid == __uuidof(IUnknown) || riid == __uuidof(IMMNotificationClient)) { + *ppv = static_cast(this); + AddRef(); return S_OK; } + *ppv = nullptr; return E_NOINTERFACE; + } + ULONG STDMETHODCALLTYPE AddRef() override { + return static_cast(InterlockedIncrement(&m_ref)); + } + ULONG STDMETHODCALLTYPE Release() override { + LONG r = InterlockedDecrement(&m_ref); + if (r == 0) delete this; + return static_cast(r); + } + + // Callbacks are fired on an OS thread — only PostMessageW is safe here. + HRESULT STDMETHODCALLTYPE OnDefaultDeviceChanged( + EDataFlow flow, ERole role, LPCWSTR) override + { + if (flow == eRender && role == eMultimedia) { + HWND hwnd = g_trayHwnd; + if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + } + return S_OK; + } + HRESULT STDMETHODCALLTYPE OnDeviceAdded(LPCWSTR) override { + HWND hwnd = g_trayHwnd; + if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + return S_OK; + } + HRESULT STDMETHODCALLTYPE OnDeviceRemoved(LPCWSTR) override { + HWND hwnd = g_trayHwnd; + if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + return S_OK; } + HRESULT STDMETHODCALLTYPE OnDeviceStateChanged(LPCWSTR, DWORD) override { + HWND hwnd = g_trayHwnd; + if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + return S_OK; + } + HRESULT STDMETHODCALLTYPE OnPropertyValueChanged( + LPCWSTR, const PROPERTYKEY) override { return S_OK; } +}; - if (g_iconDev2) { - ICONINFO iconInfo = {0}; - if (GetIconInfo(g_iconDev2, &iconInfo)) { - g_hIconDev2Bmp = iconInfo.hbmColor; - if (!g_hIconDev2Bmp) { - g_hIconDev2Bmp = iconInfo.hbmMask; - } else if (iconInfo.hbmMask) { - DeleteObject(iconInfo.hbmMask); +// ─── Mouse hook ─────────────────────────────────────────────────────────────── + +LRESULT CALLBACK LowLevelMouseProc(int nCode, WPARAM wParam, LPARAM lParam) { + if (nCode == HC_ACTION && wParam == WM_MOUSEWHEEL && + InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 1) + { + HWND hwnd = g_trayHwnd; + if (hwnd) { + const MSLLHOOKSTRUCT* ms = reinterpret_cast(lParam); + if (PtInRect(&g_trayIconRect, ms->pt)) { + short delta = static_cast(HIWORD(ms->mouseData)); + int direction = (delta > 0) ? 1 : -1; + PostMessageW(hwnd, WM_TRAY_SCROLL, + static_cast(static_cast(direction)), 0); } } } + return CallNextHookEx(g_mouseHook, nCode, wParam, lParam); +} + +// ─── Muted icon overlay ─────────────────────────────────────────────────────── + +static HICON CreateMutedOverlayIcon(HICON hBase) { + if (!hBase) return nullptr; + + const int SZ = 32; + + HDC hScreen = GetDC(nullptr); + if (!hScreen) return nullptr; + + HDC hColorDC = CreateCompatibleDC(hScreen); + HBITMAP hColor = CreateCompatibleBitmap(hScreen, SZ, SZ); + HBITMAP hOldColor = (HBITMAP)SelectObject(hColorDC, hColor); + + HDC hMaskDC = CreateCompatibleDC(hScreen); + HBITMAP hMask = CreateBitmap(SZ, SZ, 1, 1, nullptr); + HBITMAP hOldMask = (HBITMAP)SelectObject(hMaskDC, hMask); + + PatBlt(hMaskDC, 0, 0, SZ, SZ, WHITENESS); + DrawIconEx(hColorDC, 0, 0, hBase, SZ, SZ, 0, nullptr, DI_NORMAL); + DrawIconEx(hMaskDC, 0, 0, hBase, SZ, SZ, 0, nullptr, DI_MASK); + + const int D = 10; + const int MG = 2; + const int X = SZ - D - MG; + const int Y = SZ - D - MG; + + HPEN hNoPen = (HPEN)GetStockObject(NULL_PEN); + SelectObject(hColorDC, hNoPen); + SelectObject(hMaskDC, hNoPen); + + HBRUSH hWhite = CreateSolidBrush(RGB(255, 255, 255)); + SelectObject(hColorDC, hWhite); + Ellipse(hColorDC, X - 1, Y - 1, X + D + 2, Y + D + 2); + SelectObject(hMaskDC, GetStockObject(BLACK_BRUSH)); + Ellipse(hMaskDC, X - 1, Y - 1, X + D + 2, Y + D + 2); + DeleteObject(hWhite); - if (s1) Wh_FreeStringSetting(s1); - if (s2) Wh_FreeStringSetting(s2); + HBRUSH hRed = CreateSolidBrush(RGB(220, 30, 30)); + SelectObject(hColorDC, hRed); + Ellipse(hColorDC, X, Y, X + D, Y + D); + DeleteObject(hRed); + + SelectObject(hColorDC, hOldColor); + SelectObject(hMaskDC, hOldMask); + + ICONINFO ii = {}; + ii.fIcon = TRUE; + ii.hbmColor = hColor; + ii.hbmMask = hMask; + HICON hResult = CreateIconIndirect(&ii); + + DeleteObject(hColor); + DeleteObject(hMask); + DeleteDC(hColorDC); + DeleteDC(hMaskDC); + ReleaseDC(nullptr, hScreen); + return hResult; } +// ─── Tray tip ───────────────────────────────────────────────────────────────── + void UpdateTrayTip(HWND hWnd, BOOL isAdd) { + // Snapshot shared state under lock (no COM inside the lock). + WCHAR localDevIds[MAX_DEVICE_SLOTS][512] = {}; + int localSlotCount; + bool localMuted; + + EnterCriticalSection(&g_stateLock); + localSlotCount = g_deviceSlotCount; + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) + lstrcpynW(localDevIds[i], g_cachedDevId[i], 512); + localMuted = g_isMutedByUs; + LeaveCriticalSection(&g_stateLock); + WCHAR currentDev[256] = L"Unknown Device"; - WCHAR currentId[512] = {0}; - HICON currentIcon = g_iconDev1; + WCHAR currentId[512] = {}; IMMDeviceEnumerator* pEnum = nullptr; if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { - IMMDevice* pDefaultDevice = nullptr; - if (SUCCEEDED(pEnum->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDefaultDevice))) { + IMMDevice* pDev = nullptr; + if (SUCCEEDED(pEnum->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDev))) { LPWSTR pId = nullptr; - if (SUCCEEDED(pDefaultDevice->GetId(&pId))) { + if (SUCCEEDED(pDev->GetId(&pId))) { lstrcpynW(currentId, pId, 512); CoTaskMemFree(pId); } IPropertyStore* pStore = nullptr; - if (SUCCEEDED(pDefaultDevice->OpenPropertyStore(STGM_READ, &pStore))) { - PROPVARIANT varName; - PropVariantInit(&varName); - if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &varName)) && varName.pwszVal) - lstrcpynW(currentDev, varName.pwszVal, 256); - PropVariantClear(&varName); + if (SUCCEEDED(pDev->OpenPropertyStore(STGM_READ, &pStore))) { + PROPVARIANT v; PropVariantInit(&v); + if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &v)) && v.pwszVal) + lstrcpynW(currentDev, v.pwszVal, 256); + PropVariantClear(&v); pStore->Release(); } - pDefaultDevice->Release(); + pDev->Release(); } pEnum->Release(); } - // ID-based icon selection — exact match, no fuzzy strings - if (g_cachedDev1Id[0] != L'\0' && wcscmp(currentId, g_cachedDev1Id) == 0) - currentIcon = g_iconDev1; - else if (g_cachedDev2Id[0] != L'\0' && wcscmp(currentId, g_cachedDev2Id) == 0) - currentIcon = g_iconDev2; + HICON currentIcon = g_iconDev[0]; + for (int i = 0; i < localSlotCount; i++) { + if (localDevIds[i][0] != L'\0' && wcscmp(currentId, localDevIds[i]) == 0) { + currentIcon = g_iconDev[i]; + break; + } + } - // CPU opt: skip redraw if nothing changed - static WCHAR s_lastDev[256] = {0}; - static HICON s_lastIcon = nullptr; - if (!isAdd && wcscmp(currentDev, s_lastDev) == 0 && currentIcon == s_lastIcon) + // Count configured slots from local snapshot + int configuredCount = 0; + for (int i = 0; i < localSlotCount; i++) + if (localDevIds[i][0] != L'\0') configuredCount++; + + static WCHAR s_lastDev[256] = {}; + static HICON s_lastIcon = nullptr; + static bool s_lastMuted = false; + if (!isAdd && + wcscmp(currentDev, s_lastDev) == 0 && + currentIcon == s_lastIcon && + s_lastMuted == localMuted) + { + RefreshTrayIconRect(); return; + } lstrcpyW(s_lastDev, currentDev); - s_lastIcon = currentIcon; + s_lastIcon = currentIcon; + s_lastMuted = localMuted; NOTIFYICONDATAW nid = {sizeof(nid)}; - nid.hWnd = hWnd; - nid.uID = TRAY_ICON_ID; - nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_ICON; + nid.hWnd = hWnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_ICON; nid.uCallbackMessage = WM_TRAY_CALLBACK; - if (g_cachedDev1Id[0] == L'\0' || g_cachedDev2Id[0] == L'\0') + if (configuredCount < 2) swprintf_s(nid.szTip, L"AudioSwap: Right-click to configure"); + else if (localMuted) + swprintf_s(nid.szTip, L"Audio: %s (Muted)", currentDev); else swprintf_s(nid.szTip, L"Audio: %s", currentDev); - nid.hIcon = currentIcon; + HICON hOverlay = localMuted ? CreateMutedOverlayIcon(currentIcon) : nullptr; + nid.hIcon = hOverlay ? hOverlay : currentIcon; Shell_NotifyIconW(isAdd ? NIM_ADD : NIM_MODIFY, &nid); + if (hOverlay) { DestroyIcon(hOverlay); } + + if (isAdd) { + NOTIFYICONDATAW nidVer = {sizeof(nidVer)}; + nidVer.hWnd = hWnd; + nidVer.uID = TRAY_ICON_ID; + nidVer.uVersion = NOTIFYICON_VERSION_4; + Shell_NotifyIconW(NIM_SETVERSION, &nidVer); + } + + RefreshTrayIconRect(); } -BOOL ToggleAudioDevice() { - // Guard: no-op if devices not configured yet - if (g_cachedDev1Id[0] == L'\0' || g_cachedDev2Id[0] == L'\0') { - return FALSE; - } +// ─── Audio cycling ──────────────────────────────────────────────────────────── + +BOOL CycleAudioDevice(int direction) { + // Snapshot shared device data under lock before any COM calls. + WCHAR localIds[MAX_DEVICE_SLOTS][512] = {}; + int localCount; + + EnterCriticalSection(&g_stateLock); + localCount = g_deviceSlotCount; + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) + lstrcpynW(localIds[i], g_cachedDevId[i], 512); + LeaveCriticalSection(&g_stateLock); + + int configuredCount = 0; + for (int i = 0; i < localCount; i++) + if (localIds[i][0] != L'\0') configuredCount++; + if (configuredCount < 2) return FALSE; + + HRESULT comHr = CoInitialize(nullptr); + bool comOk = SUCCEEDED(comHr) || comHr == S_FALSE; + if (!comOk) return FALSE; + + // Undo click-mute before switching (COM is now available). + RestoreMute(); + HWND hwnd = g_trayHwnd; + if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); - CoInitialize(nullptr); IMMDeviceEnumerator* pEnum = nullptr; if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { CoUninitialize(); return FALSE; } - IMMDevice* pDefaultDevice = nullptr; - if (FAILED(pEnum->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDefaultDevice))) { - pEnum->Release(); CoUninitialize(); return FALSE; + WCHAR currentId[512] = {}; + IMMDevice* pDefault = nullptr; + if (SUCCEEDED(pEnum->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDefault))) { + LPWSTR pId = nullptr; + if (SUCCEEDED(pDefault->GetId(&pId))) { + lstrcpynW(currentId, pId, 512); + CoTaskMemFree(pId); + } + pDefault->Release(); } - LPWSTR currentId = nullptr; - HRESULT hr = pDefaultDevice->GetId(¤tId); - pDefaultDevice->Release(); - if (FAILED(hr) || !currentId) { - pEnum->Release(); CoUninitialize(); return FALSE; + int currentSlot = -1; + for (int i = 0; i < localCount; i++) { + if (localIds[i][0] != L'\0' && wcscmp(currentId, localIds[i]) == 0) { + currentSlot = i; break; + } } - // Pick the other device by comparing IDs directly — no fuzzy name search - PCWSTR targetId = (wcscmp(currentId, g_cachedDev1Id) == 0) - ? g_cachedDev2Id - : g_cachedDev1Id; + int startSlot = (currentSlot >= 0) ? currentSlot : 0; + int validSlot = -1; - if (!targetId || !targetId[0]) { - CoTaskMemFree(currentId); - pEnum->Release(); CoUninitialize(); return FALSE; + for (int tries = 1; tries <= localCount; tries++) { + int candidate = ((startSlot + tries * direction) % localCount + localCount) % localCount; + if (IsDeviceActive(pEnum, localIds[candidate])) { + validSlot = candidate; break; + } } - if (currentId) CoTaskMemFree(currentId); + if (validSlot == -1) { + pEnum->Release(); CoUninitialize(); return FALSE; + } IPolicyConfig* pPolicyConfig = nullptr; if (SUCCEEDED(CoCreateInstance(CLSID_CPolicyConfigClient, nullptr, CLSCTX_ALL, IID_IPolicyConfig_Win10_11, (void**)&pPolicyConfig))) { - pPolicyConfig->SetDefaultEndpoint(targetId, eConsole); - pPolicyConfig->SetDefaultEndpoint(targetId, eMultimedia); - pPolicyConfig->SetDefaultEndpoint(targetId, eCommunications); + pPolicyConfig->SetDefaultEndpoint(localIds[validSlot], eConsole); + pPolicyConfig->SetDefaultEndpoint(localIds[validSlot], eMultimedia); + pPolicyConfig->SetDefaultEndpoint(localIds[validSlot], eCommunications); pPolicyConfig->Release(); } @@ -335,16 +812,26 @@ BOOL ToggleAudioDevice() { return TRUE; } -struct AudioDevice { - WCHAR id[512]; - WCHAR name[256]; -}; +// ─── Context menu ───────────────────────────────────────────────────────────── + +struct AudioDevice { WCHAR id[512]; WCHAR name[256]; }; void BuildAndShowContextMenu(HWND hWnd) { + // Snapshot shared data under lock. + WCHAR localIds[MAX_DEVICE_SLOTS][512] = {}; + int localCount; + bool localMuted; + + EnterCriticalSection(&g_stateLock); + localCount = g_deviceSlotCount; + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) + lstrcpynW(localIds[i], g_cachedDevId[i], 512); + localMuted = g_isMutedByUs; + LeaveCriticalSection(&g_stateLock); + AudioDevice devices[MENU_MAX_DEVICES]; int deviceCount = 0; - // Enumerate active render endpoints (COM already init on tray thread) IMMDeviceEnumerator* pEnum = nullptr; if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { @@ -353,25 +840,21 @@ void BuildAndShowContextMenu(HWND hWnd) { UINT count = 0; pDevices->GetCount(&count); if (count > MENU_MAX_DEVICES) count = MENU_MAX_DEVICES; - for (UINT i = 0; i < count; i++) { IMMDevice* pDevice = nullptr; if (FAILED(pDevices->Item(i, &pDevice))) continue; - LPWSTR pId = nullptr; if (SUCCEEDED(pDevice->GetId(&pId))) { lstrcpynW(devices[deviceCount].id, pId, 512); CoTaskMemFree(pId); - IPropertyStore* pStore = nullptr; if (SUCCEEDED(pDevice->OpenPropertyStore(STGM_READ, &pStore))) { - PROPVARIANT varName; - PropVariantInit(&varName); - if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &varName)) && varName.pwszVal) { - lstrcpynW(devices[deviceCount].name, varName.pwszVal, 256); + PROPVARIANT v; PropVariantInit(&v); + if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &v)) && v.pwszVal) { + lstrcpynW(devices[deviceCount].name, v.pwszVal, 256); deviceCount++; } - PropVariantClear(&varName); + PropVariantClear(&v); pStore->Release(); } } @@ -382,200 +865,320 @@ void BuildAndShowContextMenu(HWND hWnd) { pEnum->Release(); } - // Build submenus - HMENU hSub1 = CreatePopupMenu(); - HMENU hSub2 = CreatePopupMenu(); - - for (int i = 0; i < deviceCount; i++) { - UINT flags1 = MF_STRING | (wcscmp(devices[i].id, g_cachedDev1Id) == 0 ? MF_CHECKED : 0); - UINT flags2 = MF_STRING | (wcscmp(devices[i].id, g_cachedDev2Id) == 0 ? MF_CHECKED : 0); - AppendMenuW(hSub1, flags1, MENU_DEVICE1_BASE + i, devices[i].name); - AppendMenuW(hSub2, flags2, MENU_DEVICE2_BASE + i, devices[i].name); - } - - // Assemble root menu HMENU hMenu = CreatePopupMenu(); - // Set as Device 1 with icon - MENUITEMINFOW mii1 = {sizeof(mii1)}; - mii1.fMask = MIIM_SUBMENU | MIIM_STRING | MIIM_BITMAP; - mii1.hSubMenu = hSub1; - mii1.dwTypeData = (LPWSTR)L"Set as Device 1"; - mii1.hbmpItem = g_hIconDev1Bmp; - InsertMenuItemW(hMenu, (UINT)-1, TRUE, &mii1); - - // Set as Device 2 with icon - MENUITEMINFOW mii2 = {sizeof(mii2)}; - mii2.fMask = MIIM_SUBMENU | MIIM_STRING | MIIM_BITMAP; - mii2.hSubMenu = hSub2; - mii2.dwTypeData = (LPWSTR)L"Set as Device 2"; - mii2.hbmpItem = g_hIconDev2Bmp; - InsertMenuItemW(hMenu, (UINT)-1, TRUE, &mii2); + for (int slot = 0; slot < localCount; slot++) { + HMENU hSub = CreatePopupMenu(); + for (int i = 0; i < deviceCount; i++) { + UINT flags = MF_STRING | (wcscmp(devices[i].id, localIds[slot]) == 0 ? MF_CHECKED : 0); + AppendMenuW(hSub, flags, MENU_SLOT_BASE * (slot + 1) + i, devices[i].name); + } + WCHAR label[32]; + swprintf_s(label, L"Set as Device %d", slot + 1); + MENUITEMINFOW mii = {sizeof(mii)}; + mii.fMask = MIIM_SUBMENU | MIIM_STRING | MIIM_BITMAP; + mii.hSubMenu = hSub; + mii.dwTypeData = label; + mii.hbmpItem = g_hIconDevBmp[slot]; + InsertMenuItemW(hMenu, (UINT)-1, TRUE, &mii); + } AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); - // Open WindHawk menu item with icon - MENUITEMINFOW mii = {sizeof(mii)}; - mii.fMask = MIIM_ID | MIIM_STRING | MIIM_BITMAP; - mii.wID = MENU_OPEN_WINDHAWK; - mii.dwTypeData = (LPWSTR)L"Open WindHawk"; - mii.hbmpItem = g_hWindHawkBmp; - InsertMenuItemW(hMenu, (UINT)-1, TRUE, &mii); + MENUITEMINFOW miiWH = {sizeof(miiWH)}; + miiWH.fMask = MIIM_ID | MIIM_STRING | MIIM_BITMAP; + miiWH.wID = MENU_OPEN_WINDHAWK; + miiWH.dwTypeData = (LPWSTR)L"Open WindHawk"; + miiWH.hbmpItem = g_hWindHawkBmp; + InsertMenuItemW(hMenu, (UINT)-1, TRUE, &miiWH); AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); - // Status line — get current active device name WCHAR statusText[300]; - if (g_cachedDev1Id[0] == L'\0' || g_cachedDev2Id[0] == L'\0') { + int configuredCount = 0; + for (int i = 0; i < localCount; i++) if (localIds[i][0] != L'\0') configuredCount++; + if (configuredCount < 2) { lstrcpyW(statusText, L"Right-click to configure devices"); } else { WCHAR activeName[256] = L"Unknown"; IMMDeviceEnumerator* pEnum2 = nullptr; if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&pEnum2))) { - IMMDevice* pDefault = nullptr; - if (SUCCEEDED(pEnum2->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDefault))) { + IMMDevice* pDef = nullptr; + if (SUCCEEDED(pEnum2->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDef))) { IPropertyStore* pStore = nullptr; - if (SUCCEEDED(pDefault->OpenPropertyStore(STGM_READ, &pStore))) { + if (SUCCEEDED(pDef->OpenPropertyStore(STGM_READ, &pStore))) { PROPVARIANT v; PropVariantInit(&v); if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &v)) && v.pwszVal) lstrcpynW(activeName, v.pwszVal, 256); PropVariantClear(&v); pStore->Release(); } - pDefault->Release(); + pDef->Release(); } pEnum2->Release(); } - swprintf_s(statusText, L"Active: %s", activeName); + if (localMuted) + swprintf_s(statusText, L"Active: %s (Muted)", activeName); + else + swprintf_s(statusText, L"Active: %s", activeName); } AppendMenuW(hMenu, MF_STRING | MF_GRAYED, 0, statusText); - // Show menu at cursor POINT pt; GetCursorPos(&pt); SetForegroundWindow(hWnd); int cmd = TrackPopupMenu(hMenu, TPM_RETURNCMD | TPM_RIGHTBUTTON | TPM_BOTTOMALIGN | TPM_RIGHTALIGN, pt.x, pt.y, 0, hWnd, nullptr); - PostMessageW(hWnd, WM_NULL, 0, 0); // flush menu - - DestroyMenu(hMenu); // also destroys attached submenus - - // Handle selection - if (cmd >= MENU_DEVICE1_BASE && cmd < MENU_DEVICE1_BASE + deviceCount) { - int idx = cmd - MENU_DEVICE1_BASE; - SaveDeviceSelection(1, devices[idx].id, devices[idx].name); - PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); - } else if (cmd >= MENU_DEVICE2_BASE && cmd < MENU_DEVICE2_BASE + deviceCount) { - int idx = cmd - MENU_DEVICE2_BASE; - SaveDeviceSelection(2, devices[idx].id, devices[idx].name); - PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + PostMessageW(hWnd, WM_NULL, 0, 0); + DestroyMenu(hMenu); + + if (cmd > 0 && cmd != MENU_OPEN_WINDHAWK) { + int slot = cmd / MENU_SLOT_BASE; + int deviceIdx = cmd % MENU_SLOT_BASE; + if (slot >= 1 && slot <= localCount && deviceIdx < deviceCount) { + SaveDeviceSelection(slot, devices[deviceIdx].id, devices[deviceIdx].name); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + } } else if (cmd == MENU_OPEN_WINDHAWK) { - SHELLEXECUTEINFOW sei; - ZeroMemory(&sei, sizeof(sei)); - sei.cbSize = sizeof(sei); + SHELLEXECUTEINFOW sei = {sizeof(sei)}; sei.lpFile = g_windhawkPath; - sei.nShow = SW_SHOWNORMAL; + sei.nShow = SW_SHOWNORMAL; ShellExecuteExW(&sei); } } +// ─── Worker thread ──────────────────────────────────────────────────────────── + DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { - if (ToggleAudioDevice() && g_trayHwnd) { - PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, 0, 0); + int direction = static_cast(reinterpret_cast(lpParam)); + if (CycleAudioDevice(direction)) { + HWND hwnd = g_trayHwnd; + if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); } InterlockedExchange(&g_isProcessingClick, 0); return 0; } +static void SpawnCycleThread(int direction) { + if (g_workerThread) { CloseHandle(g_workerThread); g_workerThread = nullptr; } + g_workerThread = CreateThread(nullptr, 0, WorkerThreadProc, + reinterpret_cast(static_cast(direction)), + 0, nullptr); +} + +// ─── Tray window ────────────────────────────────────────────────────────────── + LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { - if (msg == WM_TRAY_CALLBACK && lParam == WM_RBUTTONUP) { - BuildAndShowContextMenu(hWnd); - } else if (msg == WM_TRAY_CALLBACK && lParam == WM_LBUTTONUP) { - if (InterlockedExchange(&g_isProcessingClick, 1) == 0) { - DWORD now = GetTickCount(); - if (now - g_lastClickTime > CLICK_DEBOUNCE_MS) { // Corrected check - g_lastClickTime = now; - if (g_workerThread) { CloseHandle(g_workerThread); g_workerThread = nullptr; } - g_workerThread = CreateThread(nullptr, 0, WorkerThreadProc, nullptr, 0, nullptr); - } else InterlockedExchange(&g_isProcessingClick, 0); + if (msg == WM_TRAY_CALLBACK) { + UINT event = LOWORD(lParam); // NOTIFYICON_VERSION_4: event in LOWORD(lParam) + + if (event == WM_RBUTTONUP) { + BuildAndShowContextMenu(hWnd); + + } else if (event == WM_LBUTTONUP && + InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 0) { + // Click mode: debounced forward cycle + if (InterlockedExchange(&g_isProcessingClick, 1) == 0) { + DWORD now = GetTickCount(); + if (now - g_lastClickTime > CLICK_DEBOUNCE_MS) { + g_lastClickTime = now; + SpawnCycleThread(1); + } else { + InterlockedExchange(&g_isProcessingClick, 0); + } + } + + } else if (event == WM_LBUTTONUP && + InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 1) { + // Scroll mode: left-click toggles mute (COM initialized on tray thread) + ToggleMuteCurrentDevice(); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); } + + } else if (msg == WM_TRAY_SCROLL) { + // Posted by LowLevelMouseProc when the user scrolls over the icon + DWORD now = GetTickCount(); + if (now - g_lastScrollTime > SCROLL_DEBOUNCE_MS && + InterlockedExchange(&g_isProcessingClick, 1) == 0) { + g_lastScrollTime = now; + int direction = static_cast(static_cast(wParam)); + SpawnCycleThread(direction); + } + } else if (msg == WM_UPDATE_TRAY_STATE || (msg == WM_TIMER && wParam == 1)) { UpdateTrayTip(hWnd, FALSE); + } else if (msg == g_taskbarCreatedMsg && g_taskbarCreatedMsg != 0) { - UpdateTrayTip(hWnd, TRUE); - } else if (msg == WM_DESTROY) PostQuitMessage(0); + UpdateTrayTip(hWnd, TRUE); // re-add icon after explorer restart + + } else if (msg == WM_CLOSE) { + // Orderly shutdown: kill timer, remove tray icon, then destroy window. + KillTimer(hWnd, 1); + NOTIFYICONDATAW nid = {sizeof(nid)}; + nid.hWnd = hWnd; + nid.uID = TRAY_ICON_ID; + Shell_NotifyIconW(NIM_DELETE, &nid); + DestroyWindow(hWnd); + return 0; + + } else if (msg == WM_DESTROY) { + g_trayHwnd = nullptr; // prevent IMMNotificationClient callbacks from posting + PostQuitMessage(0); + } return DefWindowProcW(hWnd, msg, wParam, lParam); } +// ─── Tray thread ────────────────────────────────────────────────────────────── + DWORD WINAPI TrayThreadProc(LPVOID) { CoInitialize(nullptr); g_taskbarCreatedMsg = RegisterWindowMessageW(L"TaskbarCreated"); - WNDCLASSW wc = {0}; - wc.lpfnWndProc = TrayWndProc; - wc.hInstance = g_hInstance; + + WNDCLASSW wc = {}; + wc.lpfnWndProc = TrayWndProc; + wc.hInstance = g_hInstance; wc.lpszClassName = L"AudioSwitcherWindowClass"; RegisterClassW(&wc); - g_trayHwnd = CreateWindowExW(0, wc.lpszClassName, L"Audio Switcher", WS_OVERLAPPED, 0,0,1,1, nullptr, nullptr, g_hInstance, nullptr); - + + // HWND_MESSAGE parent: proper message-only window, invisible to Alt+Tab / taskbar. + g_trayHwnd = CreateWindowExW(0, wc.lpszClassName, L"Audio Switcher", 0, + 0, 0, 0, 0, HWND_MESSAGE, nullptr, g_hInstance, nullptr); + + // Set a unique AUMID so the OS doesn't group this with the main Windhawk icon. IPropertyStore* pps = nullptr; if (SUCCEEDED(SHGetPropertyStoreForWindow(g_trayHwnd, IID_PPV_ARGS(&pps)))) { - PROPVARIANT var; var.vt = VT_LPWSTR; var.pwszVal = (LPWSTR)CoTaskMemAlloc(MAX_PATH); + PROPVARIANT var; + PropVariantInit(&var); + var.vt = VT_LPWSTR; + var.pwszVal = (LPWSTR)CoTaskMemAlloc(MAX_PATH * sizeof(WCHAR)); if (var.pwszVal) { lstrcpyW(var.pwszVal, L"BlackPaw.AudioSwitcher"); pps->SetValue(PKEY_AppUserModel_ID, var); CoTaskMemFree(var.pwszVal); } - pps->Commit(); pps->Release(); + pps->Commit(); + pps->Release(); } + // Register for instant device-change notifications. + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&g_notifEnum))) { + g_deviceNotifier = new AudioDeviceNotifier(); + g_notifEnum->RegisterEndpointNotificationCallback(g_deviceNotifier); + } + + // Global mouse hook for scroll-to-swap (always installed; mode checked in proc). + g_mouseHook = SetWindowsHookExW(WH_MOUSE_LL, LowLevelMouseProc, g_hInstance, 0); + SetTimer(g_trayHwnd, 1, 1500, nullptr); UpdateTrayTip(g_trayHwnd, TRUE); + MSG msg; while (GetMessageW(&msg, nullptr, 0, 0) > 0) { DispatchMessageW(&msg); } + + // Unregister notifications before releasing the enumerator. + if (g_notifEnum && g_deviceNotifier) { + g_notifEnum->UnregisterEndpointNotificationCallback(g_deviceNotifier); + g_deviceNotifier->Release(); g_deviceNotifier = nullptr; + g_notifEnum->Release(); g_notifEnum = nullptr; + } + if (g_mouseHook) { UnhookWindowsHookEx(g_mouseHook); g_mouseHook = nullptr; } CoUninitialize(); return 0; } +// ─── Mod lifecycle ──────────────────────────────────────────────────────────── + BOOL WhTool_ModInit() { Wh_Log(L"AudioSwap Mod Init"); + InitializeCriticalSection(&g_stateLock); + g_hInstance = GetModuleHandle(nullptr); GetModuleFileName(nullptr, g_windhawkPath, ARRAYSIZE(g_windhawkPath)); - ExtractIconExW(L"ddores.dll", 98, nullptr, &g_hWindHawkIcon, 1); - if (!g_hWindHawkIcon) ExtractIconExW(L"ddores.dll", 94, nullptr, &g_hWindHawkIcon, 1); - if (!g_hWindHawkIcon) ExtractIconExW(L"ddores.dll", 95, nullptr, &g_hWindHawkIcon, 1); - if (!g_hWindHawkIcon) ExtractIconExW(L"ddores.dll", 6, nullptr, &g_hWindHawkIcon, 1); + + // Build the full system32 path for ddores.dll. + UINT sysLen = GetSystemDirectoryW(g_ddoresDllPath, MAX_PATH); + if (sysLen > 0 && sysLen < MAX_PATH - 12) + lstrcatW(g_ddoresDllPath, L"\\ddores.dll"); + else + lstrcpyW(g_ddoresDllPath, L"ddores.dll"); // safe fallback + + // Load the Windhawk menu icon. + int whIconIndices[] = {98, 94, 95, 6}; + for (int idx : whIconIndices) { + ExtractIconExW(g_ddoresDllPath, idx, nullptr, &g_hWindHawkIcon, 1); + if (g_hWindHawkIcon) break; + } if (g_hWindHawkIcon) { - ICONINFO iconInfo = {0}; - GetIconInfo(g_hWindHawkIcon, &iconInfo); - g_hWindHawkBmp = iconInfo.hbmColor; - if (!g_hWindHawkBmp) { - g_hWindHawkBmp = iconInfo.hbmMask; - } else if (iconInfo.hbmMask) { - DeleteObject(iconInfo.hbmMask); + ICONINFO ii = {}; + if (GetIconInfo(g_hWindHawkIcon, &ii)) { + g_hWindHawkBmp = ii.hbmColor; + if (!g_hWindHawkBmp) { + g_hWindHawkBmp = ii.hbmMask; + } else if (ii.hbmMask) { + DeleteObject(ii.hbmMask); + } } } - LoadUserIconsAndSettings(); + + // If a previous run muted a device and was killed before unmuting, clean it up. + WCHAR savedMutedId[512] = {}; + Wh_GetStringValue(L"MutedDeviceId", savedMutedId, 512); + if (savedMutedId[0] != L'\0') { + CoInitialize(nullptr); + ApplyMute(savedMutedId, FALSE); + CoUninitialize(); + Wh_SetStringValue(L"MutedDeviceId", L""); + } + + LoadUserIconsAndSettings(); // sets g_deviceSlotCount first LoadDeviceSelections(); g_trayThread = CreateThread(nullptr, 0, TrayThreadProc, nullptr, 0, nullptr); return TRUE; } void WhTool_ModSettingsChanged() { + RestoreMuteExternal(); // undo mute before mode may change LoadUserIconsAndSettings(); LoadDeviceSelections(); - if (g_trayHwnd) PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, 0, 0); + HWND hwnd = g_trayHwnd; + if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); } void WhTool_ModUninit() { Wh_Log(L"AudioSwap Mod Uninit"); - if (g_trayHwnd) PostMessageW(g_trayHwnd, WM_DESTROY, 0, 0); - if (g_trayThread) { WaitForSingleObject(g_trayThread, 2000); CloseHandle(g_trayThread); g_trayThread = nullptr; } - if (g_workerThread) { WaitForSingleObject(g_workerThread, 2000); CloseHandle(g_workerThread); g_workerThread = nullptr; } - if (g_iconDev1) { DestroyIcon(g_iconDev1); g_iconDev1 = nullptr; } - if (g_iconDev2) { DestroyIcon(g_iconDev2); g_iconDev2 = nullptr; } - if (g_hWindHawkIcon) { DestroyIcon(g_hWindHawkIcon); g_hWindHawkIcon = nullptr; } - if (g_hWindHawkBmp) { DeleteObject(g_hWindHawkBmp); g_hWindHawkBmp = nullptr; } + RestoreMuteExternal(); + + // Send WM_CLOSE so TrayWndProc can kill the timer and delete the tray icon + // cleanly before DestroyWindow → PostQuitMessage exits the message loop. + HWND hwnd = g_trayHwnd; + if (hwnd) PostMessageW(hwnd, WM_CLOSE, 0, 0); + if (g_trayThread) { + WaitForSingleObject(g_trayThread, 3000); + CloseHandle(g_trayThread); + g_trayThread = nullptr; + } + if (g_workerThread) { + WaitForSingleObject(g_workerThread, 2000); + CloseHandle(g_workerThread); + g_workerThread = nullptr; + } + // Safety nets — normally cleaned up inside TrayThreadProc. + if (g_mouseHook) { UnhookWindowsHookEx(g_mouseHook); g_mouseHook = nullptr; } + if (g_notifEnum && g_deviceNotifier) { + g_notifEnum->UnregisterEndpointNotificationCallback(g_deviceNotifier); + g_deviceNotifier->Release(); g_deviceNotifier = nullptr; + g_notifEnum->Release(); g_notifEnum = nullptr; + } + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + if (g_iconDev[i]) { DestroyIcon(g_iconDev[i]); g_iconDev[i] = nullptr; } + if (g_hIconDevBmp[i]){ DeleteObject(g_hIconDevBmp[i]); g_hIconDevBmp[i] = nullptr; } + } + if (g_hWindHawkIcon){ DestroyIcon(g_hWindHawkIcon); g_hWindHawkIcon = nullptr; } + if (g_hWindHawkBmp) { DeleteObject(g_hWindHawkBmp); g_hWindHawkBmp = nullptr; } + + DeleteCriticalSection(&g_stateLock); } //////////////////////////////////////////////////////////////////////////////// From d12592b8246158d02aec83bd7b5b1f57a6a937c2 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Sun, 10 May 2026 20:24:12 +0300 Subject: [PATCH 18/56] Update description and changelog for AudioSwap --- mods/audioswap.wh.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index fc6d4727f1..34ffa38879 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -1,7 +1,7 @@ // ==WindhawkMod== // @id audioswap // @name AudioSwap -// @description Tray icon to cycle between multiple preferred audio outputs. Supports up to 6 devices with click or scroll to swap. +// @description Adds a tray icon to instantly toggle between 2 or more preferred audio outputs. // @version 1.3.0 // @author BlackPaw // @github https://github.com/BlackPaw21 @@ -42,9 +42,8 @@ Instantly cycle between multiple audio output devices from your system tray — ## Changelog ### v1.3.0 -- Select a mode in the Windhawk settings: Click / Scroll - Up to 6 devices; scroll-to-swap mode; dynamic right-click menu. -- Left-click mutes in scroll mode; black dot overlay on tray icon. +- Left-click mutes in scroll mode; red dot overlay on tray icon. - Cycling auto-unmutes previous device. - Inactive/disconnected devices skipped when cycling. - Mute state persisted across mod restarts. From 8debfad66a3253289809ecf38b6039d71cb25a33 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Mon, 11 May 2026 21:58:40 +0300 Subject: [PATCH 19/56] Enhance AudioSwap description and update hooks bug fixes and improvements. added a red dot over the icon when muted. persistent across restarts. --- mods/audioswap.wh.cpp | 93 +++++++++++++++++++++++++++++++++---------- 1 file changed, 73 insertions(+), 20 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index 34ffa38879..b71ca21602 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -1,12 +1,12 @@ // ==WindhawkMod== // @id audioswap // @name AudioSwap -// @description Adds a tray icon to instantly toggle between 2 or more preferred audio outputs. +// @description Tray icon to cycle between multiple preferred audio outputs. Supports up to 6 devices with click or scroll to swap. // @version 1.3.0 // @author BlackPaw // @github https://github.com/BlackPaw21 // @include windhawk.exe -// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 +// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -DUNICODE -D_UNICODE // ==/WindhawkMod== // ==WindhawkModReadme== @@ -35,7 +35,7 @@ Instantly cycle between multiple audio output devices from your system tray — ## Known Bugs -- **Scroll to Swap may not respond while the Windhawk window has focus.** This is a limitation of the low-level mouse hook when another application owns keyboard/mouse focus. Switch focus away from Windhawk and scrolling will work normally. +- **Scroll to Swap may not respond over elevated windows.** such as Task Manager or other admin-elevated applications. Switch focus away from an elevated window and scrolling will work normally. --- @@ -182,6 +182,7 @@ Instantly cycle between multiple audio output devices from your system tray — #define WM_TRAY_CALLBACK (WM_USER + 1) #define WM_UPDATE_TRAY_STATE (WM_USER + 2) #define WM_TRAY_SCROLL (WM_USER + 3) // wParam = direction (+1 or -1) +#define WM_UPDATE_HOOK_STATE (WM_USER + 4) // Slot N uses menu IDs: N*100 + device_index (0..31). Max slot 6 → ID 631. #define MENU_SLOT_BASE 100 @@ -584,9 +585,23 @@ static HICON CreateMutedOverlayIcon(HICON hBase) { HDC hScreen = GetDC(nullptr); if (!hScreen) return nullptr; - HDC hColorDC = CreateCompatibleDC(hScreen); - HBITMAP hColor = CreateCompatibleBitmap(hScreen, SZ, SZ); + // 32bpp DIBSECTION so we can fix the alpha channel post-GDI. + // GDI's Ellipse sets RGB but leaves alpha = 0; CreateIconIndirect + // uses the alpha channel for 32bpp color bitmaps, making the dot + // invisible without this fixup. + BITMAPINFO bmi = {}; + bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bmi.bmiHeader.biWidth = SZ; + bmi.bmiHeader.biHeight = -SZ; + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB; + + void* pBits = nullptr; + HBITMAP hColor = CreateDIBSection(hScreen, &bmi, DIB_RGB_COLORS, &pBits, nullptr, 0); + HDC hColorDC = CreateCompatibleDC(hScreen); HBITMAP hOldColor = (HBITMAP)SelectObject(hColorDC, hColor); + memset(pBits, 0, SZ * SZ * 4); HDC hMaskDC = CreateCompatibleDC(hScreen); HBITMAP hMask = CreateBitmap(SZ, SZ, 1, 1, nullptr); @@ -596,8 +611,8 @@ static HICON CreateMutedOverlayIcon(HICON hBase) { DrawIconEx(hColorDC, 0, 0, hBase, SZ, SZ, 0, nullptr, DI_NORMAL); DrawIconEx(hMaskDC, 0, 0, hBase, SZ, SZ, 0, nullptr, DI_MASK); - const int D = 10; - const int MG = 2; + const int D = 20; + const int MG = 1; const int X = SZ - D - MG; const int Y = SZ - D - MG; @@ -608,7 +623,7 @@ static HICON CreateMutedOverlayIcon(HICON hBase) { HBRUSH hWhite = CreateSolidBrush(RGB(255, 255, 255)); SelectObject(hColorDC, hWhite); Ellipse(hColorDC, X - 1, Y - 1, X + D + 2, Y + D + 2); - SelectObject(hMaskDC, GetStockObject(BLACK_BRUSH)); + SelectObject(hMaskDC, GetStockObject(WHITE_BRUSH)); Ellipse(hMaskDC, X - 1, Y - 1, X + D + 2, Y + D + 2); DeleteObject(hWhite); @@ -617,6 +632,22 @@ static HICON CreateMutedOverlayIcon(HICON hBase) { Ellipse(hColorDC, X, Y, X + D, Y + D); DeleteObject(hRed); + // GDI drew RGB pixels with alpha = 0 — stamp alpha = 255 over the + // overlay dot region so it is actually visible on 32bpp icons. + { + int y0 = Y - 2; if (y0 < 0) y0 = 0; + int y1 = Y + D + 3; if (y1 > SZ) y1 = SZ; + int x0 = X - 2; if (x0 < 0) x0 = 0; + int x1 = X + D + 3; if (x1 > SZ) x1 = SZ; + DWORD* pixels = (DWORD*)pBits; + for (int y = y0; y < y1; y++) + for (int x = x0; x < x1; x++) { + DWORD pixel = pixels[y * SZ + x]; + if (pixel & 0x00FFFFFF) + pixels[y * SZ + x] = pixel | 0xFF000000; + } + } + SelectObject(hColorDC, hOldColor); SelectObject(hMaskDC, hOldMask); @@ -1005,15 +1036,23 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) SpawnCycleThread(direction); } - } else if (msg == WM_UPDATE_TRAY_STATE || (msg == WM_TIMER && wParam == 1)) { + } else if (msg == WM_UPDATE_TRAY_STATE) { UpdateTrayTip(hWnd, FALSE); + } else if (msg == WM_UPDATE_HOOK_STATE) { + LONG isScroll = InterlockedCompareExchange(&g_scrollToSwap, 0, 0); + if (isScroll && !g_mouseHook) { + g_mouseHook = SetWindowsHookExW(WH_MOUSE_LL, LowLevelMouseProc, g_hInstance, 0); + } else if (!isScroll && g_mouseHook) { + UnhookWindowsHookEx(g_mouseHook); + g_mouseHook = nullptr; + } + } else if (msg == g_taskbarCreatedMsg && g_taskbarCreatedMsg != 0) { UpdateTrayTip(hWnd, TRUE); // re-add icon after explorer restart } else if (msg == WM_CLOSE) { - // Orderly shutdown: kill timer, remove tray icon, then destroy window. - KillTimer(hWnd, 1); + // Orderly shutdown: remove tray icon, then destroy window. NOTIFYICONDATAW nid = {sizeof(nid)}; nid.hWnd = hWnd; nid.uID = TRAY_ICON_ID; @@ -1040,9 +1079,21 @@ DWORD WINAPI TrayThreadProc(LPVOID) { wc.lpszClassName = L"AudioSwitcherWindowClass"; RegisterClassW(&wc); - // HWND_MESSAGE parent: proper message-only window, invisible to Alt+Tab / taskbar. - g_trayHwnd = CreateWindowExW(0, wc.lpszClassName, L"Audio Switcher", 0, - 0, 0, 0, 0, HWND_MESSAGE, nullptr, g_hInstance, nullptr); + // WS_EX_TOOLWINDOW: hidden popup window that Shell_NotifyIconW can track properly. + // AudioSwap used HWND_MESSAGE originally, but message-only windows can lose their + // tray icon after reboot because the shell cannot reliably associate the icon with + // the window across session boundaries. + g_trayHwnd = CreateWindowExW( + WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, + wc.lpszClassName, L"Audio Switcher", + WS_POPUP, + 0, 0, 1, 1, + nullptr, nullptr, g_hInstance, nullptr + ); + if (!g_trayHwnd) { + CoUninitialize(); + return 1; + } // Set a unique AUMID so the OS doesn't group this with the main Windhawk icon. IPropertyStore* pps = nullptr; @@ -1067,10 +1118,11 @@ DWORD WINAPI TrayThreadProc(LPVOID) { g_notifEnum->RegisterEndpointNotificationCallback(g_deviceNotifier); } - // Global mouse hook for scroll-to-swap (always installed; mode checked in proc). - g_mouseHook = SetWindowsHookExW(WH_MOUSE_LL, LowLevelMouseProc, g_hInstance, 0); + // Global mouse hook for scroll-to-swap (only installed in scroll mode). + if (InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 1) { + g_mouseHook = SetWindowsHookExW(WH_MOUSE_LL, LowLevelMouseProc, g_hInstance, 0); + } - SetTimer(g_trayHwnd, 1, 1500, nullptr); UpdateTrayTip(g_trayHwnd, TRUE); MSG msg; @@ -1143,13 +1195,15 @@ void WhTool_ModSettingsChanged() { LoadDeviceSelections(); HWND hwnd = g_trayHwnd; if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + HWND hwndHook = g_trayHwnd; + if (hwndHook) PostMessageW(hwndHook, WM_UPDATE_HOOK_STATE, 0, 0); } void WhTool_ModUninit() { Wh_Log(L"AudioSwap Mod Uninit"); RestoreMuteExternal(); - // Send WM_CLOSE so TrayWndProc can kill the timer and delete the tray icon + // Send WM_CLOSE so TrayWndProc can delete the tray icon // cleanly before DestroyWindow → PostQuitMessage exits the message loop. HWND hwnd = g_trayHwnd; if (hwnd) PostMessageW(hwnd, WM_CLOSE, 0, 0); @@ -1163,8 +1217,7 @@ void WhTool_ModUninit() { CloseHandle(g_workerThread); g_workerThread = nullptr; } - // Safety nets — normally cleaned up inside TrayThreadProc. - if (g_mouseHook) { UnhookWindowsHookEx(g_mouseHook); g_mouseHook = nullptr; } + if (g_mouseHook) { UnhookWindowsHookEx(g_mouseHook); g_mouseHook = nullptr; } if (g_notifEnum && g_deviceNotifier) { g_notifEnum->UnregisterEndpointNotificationCallback(g_deviceNotifier); g_deviceNotifier->Release(); g_deviceNotifier = nullptr; From 9dd93d3d52dd72873725d80e9aad9e00a7200c62 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Mon, 11 May 2026 22:33:12 +0300 Subject: [PATCH 20/56] Update functions to use wide character versions UNICODE-dependencies converted to explicit W/INFOW; -DUNICODE -D_UNICODE removed --- mods/audioswap.wh.cpp | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index b71ca21602..9909c2455e 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -6,7 +6,7 @@ // @author BlackPaw // @github https://github.com/BlackPaw21 // @include windhawk.exe -// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -DUNICODE -D_UNICODE +// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 // ==/WindhawkMod== // ==WindhawkModReadme== @@ -35,7 +35,7 @@ Instantly cycle between multiple audio output devices from your system tray — ## Known Bugs -- **Scroll to Swap may not respond over elevated windows.** such as Task Manager or other admin-elevated applications. Switch focus away from an elevated window and scrolling will work normally. +- **Scroll to Swap may not respond over elevated windows.** such as **Task Manager**, **Windhawk** or other admin-elevated applications. Switch focus away from an elevated window and scrolling will work normally. --- @@ -1145,8 +1145,8 @@ BOOL WhTool_ModInit() { Wh_Log(L"AudioSwap Mod Init"); InitializeCriticalSection(&g_stateLock); - g_hInstance = GetModuleHandle(nullptr); - GetModuleFileName(nullptr, g_windhawkPath, ARRAYSIZE(g_windhawkPath)); + g_hInstance = GetModuleHandleW(nullptr); + GetModuleFileNameW(nullptr, g_windhawkPath, ARRAYSIZE(g_windhawkPath)); // Build the full system32 path for ddores.dll. UINT sysLen = GetSystemDirectoryW(g_ddoresDllPath, MAX_PATH); @@ -1266,7 +1266,7 @@ BOOL Wh_ModInit() { bool isToolModProcess = false; bool isCurrentToolModProcess = false; int argc; - LPWSTR* argv = CommandLineToArgvW(GetCommandLine(), &argc); + LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc); if (!argv) { Wh_Log(L"CommandLineToArgvW failed"); return FALSE; @@ -1299,7 +1299,7 @@ BOOL Wh_ModInit() { if (isCurrentToolModProcess) { g_toolModProcessMutex = - CreateMutex(nullptr, TRUE, L"windhawk-tool-mod_" WH_MOD_ID); + CreateMutexW(nullptr, TRUE, L"windhawk-tool-mod_" WH_MOD_ID); if (!g_toolModProcessMutex) { Wh_Log(L"CreateMutex failed"); ExitProcess(1); @@ -1315,7 +1315,7 @@ BOOL Wh_ModInit() { } IMAGE_DOS_HEADER* dosHeader = - (IMAGE_DOS_HEADER*)GetModuleHandle(nullptr); + (IMAGE_DOS_HEADER*)GetModuleHandleW(nullptr); IMAGE_NT_HEADERS* ntHeaders = (IMAGE_NT_HEADERS*)((BYTE*)dosHeader + dosHeader->e_lfanew); @@ -1340,8 +1340,8 @@ void Wh_ModAfterInit() { } WCHAR currentProcessPath[MAX_PATH]; - switch (GetModuleFileName(nullptr, currentProcessPath, - ARRAYSIZE(currentProcessPath))) { + switch (GetModuleFileNameW(nullptr, currentProcessPath, + ARRAYSIZE(currentProcessPath))) { case 0: case ARRAYSIZE(currentProcessPath): Wh_Log(L"GetModuleFileName failed"); @@ -1354,9 +1354,9 @@ void Wh_ModAfterInit() { swprintf_s(commandLine, L"\"%s\" -tool-mod \"%s\"", currentProcessPath, WH_MOD_ID); - HMODULE kernelModule = GetModuleHandle(L"kernelbase.dll"); + HMODULE kernelModule = GetModuleHandleW(L"kernelbase.dll"); if (!kernelModule) { - kernelModule = GetModuleHandle(L"kernel32.dll"); + kernelModule = GetModuleHandleW(L"kernel32.dll"); if (!kernelModule) { Wh_Log(L"No kernelbase.dll/kernel32.dll"); return; @@ -1379,8 +1379,8 @@ void Wh_ModAfterInit() { return; } - STARTUPINFO si{ - .cb = sizeof(STARTUPINFO), + STARTUPINFOW si{ + .cb = sizeof(STARTUPINFOW), .dwFlags = STARTF_FORCEOFFFEEDBACK, }; PROCESS_INFORMATION pi; From 92770bb504897385528fe7e6f6dff58e7434042f Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Sat, 16 May 2026 22:14:21 +0300 Subject: [PATCH 21/56] Bump version to 1.5.0 and add custom icon feature Updated version to 1.5.0 and added custom icon support. --- mods/audioswap.wh.cpp | 130 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 124 insertions(+), 6 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index 9909c2455e..9b256d0c68 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -2,11 +2,11 @@ // @id audioswap // @name AudioSwap // @description Tray icon to cycle between multiple preferred audio outputs. Supports up to 6 devices with click or scroll to swap. -// @version 1.3.0 +// @version 1.5.0 // @author BlackPaw // @github https://github.com/BlackPaw21 // @include windhawk.exe -// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 +// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lcomdlg32 // ==/WindhawkMod== // ==WindhawkModReadme== @@ -35,12 +35,21 @@ Instantly cycle between multiple audio output devices from your system tray — ## Known Bugs -- **Scroll to Swap may not respond over elevated windows.** such as **Task Manager**, **Windhawk** or other admin-elevated applications. Switch focus away from an elevated window and scrolling will work normally. +- **Scroll to Swap may not respond over elevated windows.** A low-level mouse hook from a non-administrator process cannot see mouse events destined for elevated (administrator) windows, such as Task Manager or other admin-elevated applications. Switch focus away from an elevated window and scrolling will work normally. --- ## Changelog +### v1.5.0 +- Custom .ico support — selecting "Custom Icon" in settings now auto-opens a file picker; also available via right-click → "Custom Icon for Device X..." +- Custom icon paths stored internally (no separate text setting in the UI). + +### v1.4.0 +- Mouse hook only installed in scroll-to-swap mode (performance fix). +- Polling timer removed — device notifications use IMMNotificationClient instead. +- Known bug description corrected to reference elevated/admin windows. + ### v1.3.0 - Up to 6 devices; scroll-to-swap mode; dynamic right-click menu. - Left-click mutes in scroll mode; red dot overlay on tray icon. @@ -95,6 +104,7 @@ Instantly cycle between multiple audio output devices from your system tray — - headphones_modern: Modern Headphones - headset_modern: Modern Gaming Headset - earphones: Earphones + - custom: Custom Icon - icon2: speaker_square $name: Device 2 Icon $options: @@ -108,6 +118,7 @@ Instantly cycle between multiple audio output devices from your system tray — - headphones_modern: Modern Headphones - headset_modern: Modern Gaming Headset - earphones: Earphones + - custom: Custom Icon - icon3: headphones $name: Device 3 Icon $description: Used when Number of Devices is 3 or more @@ -122,6 +133,7 @@ Instantly cycle between multiple audio output devices from your system tray — - headphones_modern: Modern Headphones - headset_modern: Modern Gaming Headset - earphones: Earphones + - custom: Custom Icon - icon4: headset_gaming $name: Device 4 Icon $description: Used when Number of Devices is 4 or more @@ -136,6 +148,7 @@ Instantly cycle between multiple audio output devices from your system tray — - headphones_modern: Modern Headphones - headset_modern: Modern Gaming Headset - earphones: Earphones + - custom: Custom Icon - icon5: headphones_modern $name: Device 5 Icon $description: Used when Number of Devices is 5 or more @@ -150,6 +163,7 @@ Instantly cycle between multiple audio output devices from your system tray — - headphones_modern: Modern Headphones - headset_modern: Modern Gaming Headset - earphones: Earphones + - custom: Custom Icon - icon6: headset_modern $name: Device 6 Icon $description: Used when Number of Devices is 6 @@ -164,6 +178,7 @@ Instantly cycle between multiple audio output devices from your system tray — - headphones_modern: Modern Headphones - headset_modern: Modern Gaming Headset - earphones: Earphones + - custom: Custom Icon */ // ==/WindhawkModSettings== @@ -177,12 +192,14 @@ Instantly cycle between multiple audio output devices from your system tray — #include #include #include +#include #define TRAY_ICON_ID 1 #define WM_TRAY_CALLBACK (WM_USER + 1) #define WM_UPDATE_TRAY_STATE (WM_USER + 2) #define WM_TRAY_SCROLL (WM_USER + 3) // wParam = direction (+1 or -1) #define WM_UPDATE_HOOK_STATE (WM_USER + 4) +#define WM_SHOW_FILE_PICKER (WM_USER + 5) // lParam = bitmask of slots needing pickers // Slot N uses menu IDs: N*100 + device_index (0..31). Max slot 6 → ID 631. #define MENU_SLOT_BASE 100 @@ -190,6 +207,7 @@ Instantly cycle between multiple audio output devices from your system tray — #define MENU_OPEN_WINDHAWK 9000 #define MAX_DEVICE_SLOTS 6 +#define MENU_CUSTOM_ICON_BASE 8000 const DWORD CLICK_DEBOUNCE_MS = 500; const DWORD SCROLL_DEBOUNCE_MS = 300; @@ -203,6 +221,7 @@ static volatile HWND g_trayHwnd = nullptr; static HINSTANCE g_hInstance = nullptr; static WCHAR g_windhawkPath[MAX_PATH] = {}; static WCHAR g_ddoresDllPath[MAX_PATH] = {}; // full system32 path +static WCHAR g_lastIconSetting[MAX_DEVICE_SLOTS][32] = {}; static HICON g_hWindHawkIcon = nullptr; static HBITMAP g_hWindHawkBmp = nullptr; static DWORD g_lastClickTime = 0; @@ -356,11 +375,29 @@ void LoadUserIconsAndSettings() { InterlockedExchange(&g_scrollToSwap, newScroll); - WCHAR iconKey[16]; + WCHAR iconKey[16], pathKey[32]; for (int i = 0; i < newCount; i++) { swprintf_s(iconKey, L"icon%d", i + 1); PCWSTR s = Wh_GetStringSetting(iconKey); - ExtractIconExW(g_ddoresDllPath, GetIconIndex(s), nullptr, &g_iconDev[i], 1); + + if (s && wcscmp(s, L"custom") == 0) { + swprintf_s(pathKey, L"icon%d_custom_path", i + 1); + WCHAR customPath[MAX_PATH] = {}; + Wh_GetStringValue(pathKey, customPath, MAX_PATH); + if (customPath[0]) { + g_iconDev[i] = (HICON)LoadImageW(NULL, customPath, IMAGE_ICON, 32, 32, LR_LOADFROMFILE); + } + if (!g_iconDev[i]) { + ExtractIconExW(g_ddoresDllPath, 4, nullptr, &g_iconDev[i], 1); + } + } else { + ExtractIconExW(g_ddoresDllPath, GetIconIndex(s), nullptr, &g_iconDev[i], 1); + } + + // Save current icon setting for transition detection in SettingsChanged. + if (s) wcscpy_s(g_lastIconSetting[i], 32, s); + else g_lastIconSetting[i][0] = L'\0'; + if (s) Wh_FreeStringSetting(s); if (g_iconDev[i]) { @@ -915,6 +952,18 @@ void BuildAndShowContextMenu(HWND hWnd) { AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); + for (int slot = 0; slot < localCount; slot++) { + WCHAR label[48]; + swprintf_s(label, L"Custom Icon for Device %d...", slot + 1); + MENUITEMINFOW miiCI = {sizeof(miiCI)}; + miiCI.fMask = MIIM_ID | MIIM_STRING; + miiCI.wID = MENU_CUSTOM_ICON_BASE + slot; + miiCI.dwTypeData = label; + InsertMenuItemW(hMenu, (UINT)-1, TRUE, &miiCI); + } + + AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); + MENUITEMINFOW miiWH = {sizeof(miiWH)}; miiWH.fMask = MIIM_ID | MIIM_STRING | MIIM_BITMAP; miiWH.wID = MENU_OPEN_WINDHAWK; @@ -963,7 +1012,7 @@ void BuildAndShowContextMenu(HWND hWnd) { PostMessageW(hWnd, WM_NULL, 0, 0); DestroyMenu(hMenu); - if (cmd > 0 && cmd != MENU_OPEN_WINDHAWK) { + if (cmd >= MENU_SLOT_BASE && cmd < MENU_SLOT_BASE * (localCount + 1)) { int slot = cmd / MENU_SLOT_BASE; int deviceIdx = cmd % MENU_SLOT_BASE; if (slot >= 1 && slot <= localCount && deviceIdx < deviceCount) { @@ -975,6 +1024,26 @@ void BuildAndShowContextMenu(HWND hWnd) { sei.lpFile = g_windhawkPath; sei.nShow = SW_SHOWNORMAL; ShellExecuteExW(&sei); + } else if (cmd >= MENU_CUSTOM_ICON_BASE && cmd < MENU_CUSTOM_ICON_BASE + localCount) { + int slot = cmd - MENU_CUSTOM_ICON_BASE + 1; + WCHAR path[MAX_PATH] = {}; + OPENFILENAMEW ofn = {sizeof(ofn)}; + ofn.hwndOwner = hWnd; + ofn.lpstrFilter = L"Icon Files (*.ico)\0*.ico\0All Files (*.*)\0*.*\0"; + ofn.nFilterIndex = 1; + ofn.lpstrFile = path; + ofn.nMaxFile = MAX_PATH; + ofn.Flags = OFN_PATHMUSTEXIST | OFN_FILEMUSTEXIST | OFN_HIDEREADONLY | OFN_NOCHANGEDIR; + WCHAR title[64]; + swprintf_s(title, L"Select Icon for Device %d", slot); + ofn.lpstrTitle = title; + if (GetOpenFileNameW(&ofn)) { + WCHAR customPathKey[32]; + swprintf_s(customPathKey, L"icon%d_custom_path", slot); + Wh_SetStringValue(customPathKey, path); + LoadUserIconsAndSettings(); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + } } } @@ -1048,6 +1117,30 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) g_mouseHook = nullptr; } + } else if (msg == WM_SHOW_FILE_PICKER) { + DWORD slots = (DWORD)lParam; + for (int slot = 1; slots; slot++, slots >>= 1) { + if (!(slots & 1)) continue; + WCHAR path[MAX_PATH] = {}; + WCHAR title[64]; + swprintf_s(title, L"Select Icon for Device %d", slot); + OPENFILENAMEW ofn = {sizeof(ofn)}; + ofn.hwndOwner = hWnd; + ofn.lpstrFilter = L"Icon Files (*.ico)\0*.ico\0All Files (*.*)\0*.*\0"; + ofn.nFilterIndex = 1; + ofn.lpstrFile = path; + ofn.nMaxFile = MAX_PATH; + ofn.Flags = OFN_PATHMUSTEXIST | OFN_FILEMUSTEXIST | OFN_HIDEREADONLY | OFN_NOCHANGEDIR; + ofn.lpstrTitle = title; + if (GetOpenFileNameW(&ofn)) { + WCHAR customPathKey[32]; + swprintf_s(customPathKey, L"icon%d_custom_path", slot); + Wh_SetStringValue(customPathKey, path); + LoadUserIconsAndSettings(); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + } + } + } else if (msg == g_taskbarCreatedMsg && g_taskbarCreatedMsg != 0) { UpdateTrayTip(hWnd, TRUE); // re-add icon after explorer restart @@ -1191,12 +1284,37 @@ BOOL WhTool_ModInit() { void WhTool_ModSettingsChanged() { RestoreMuteExternal(); // undo mute before mode may change + + // Save previous icon settings BEFORE reloading, for transition detection. + WCHAR prevIcon[MAX_DEVICE_SLOTS][32] = {}; + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + wcscpy_s(prevIcon[i], 32, g_lastIconSetting[i]); + } + LoadUserIconsAndSettings(); LoadDeviceSelections(); HWND hwnd = g_trayHwnd; if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); HWND hwndHook = g_trayHwnd; if (hwndHook) PostMessageW(hwndHook, WM_UPDATE_HOOK_STATE, 0, 0); + + // Auto-trigger file picker for any slot switched TO "custom". + // Collect into a bitmask and post once to guarantee sequential order. + WCHAR iconKey[16]; + DWORD pickerSlots = 0; + for (int i = 0; i < g_deviceSlotCount; i++) { + swprintf_s(iconKey, L"icon%d", i + 1); + PCWSTR s = Wh_GetStringSetting(iconKey); + BOOL isCustom = (s && wcscmp(s, L"custom") == 0); + BOOL wasCustom = (wcscmp(prevIcon[i], L"custom") == 0); + if (s) Wh_FreeStringSetting(s); + if (isCustom && !wasCustom) { + pickerSlots |= (1u << i); + } + } + if (pickerSlots && hwnd) { + PostMessageW(hwnd, WM_SHOW_FILE_PICKER, 0, (LPARAM)pickerSlots); + } } void WhTool_ModUninit() { From e6daa5166e3cce924d9457228da2e44c6d109584 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Sat, 16 May 2026 22:29:17 +0300 Subject: [PATCH 22/56] Implement WM_RELOAD_ICONS for tray icon updates Added WM_RELOAD_ICONS message to reload tray icons and updated icon handling in WhTool_ModSettingsChanged function. --- mods/audioswap.wh.cpp | 57 +++++++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index 9b256d0c68..1550541d62 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -200,6 +200,7 @@ Instantly cycle between multiple audio output devices from your system tray — #define WM_TRAY_SCROLL (WM_USER + 3) // wParam = direction (+1 or -1) #define WM_UPDATE_HOOK_STATE (WM_USER + 4) #define WM_SHOW_FILE_PICKER (WM_USER + 5) // lParam = bitmask of slots needing pickers +#define WM_RELOAD_ICONS (WM_USER + 6) // reload icons on tray thread (eliminates cross-thread handle race) // Slot N uses menu IDs: N*100 + device_index (0..31). Max slot 6 → ID 631. #define MENU_SLOT_BASE 100 @@ -1117,6 +1118,29 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) g_mouseHook = nullptr; } + } else if (msg == WM_RELOAD_ICONS) { + // Run icon reload on tray thread to eliminate cross-thread handle race. + WCHAR prev[MAX_DEVICE_SLOTS][32] = {}; + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) + wcscpy_s(prev[i], 32, g_lastIconSetting[i]); + + LoadUserIconsAndSettings(); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + + DWORD pickerSlots = 0; + for (int i = 0; i < g_deviceSlotCount; i++) { + WCHAR key[16]; + swprintf_s(key, L"icon%d", i + 1); + PCWSTR s = Wh_GetStringSetting(key); + BOOL isCustom = (s && wcscmp(s, L"custom") == 0); + BOOL wasCustom = (wcscmp(prev[i], L"custom") == 0); + if (s) Wh_FreeStringSetting(s); + if (isCustom && !wasCustom) + pickerSlots |= (1u << i); + } + if (pickerSlots) + PostMessageW(hWnd, WM_SHOW_FILE_PICKER, 0, (LPARAM)pickerSlots); + } else if (msg == WM_SHOW_FILE_PICKER) { DWORD slots = (DWORD)lParam; for (int slot = 1; slots; slot++, slots >>= 1) { @@ -1283,37 +1307,12 @@ BOOL WhTool_ModInit() { } void WhTool_ModSettingsChanged() { - RestoreMuteExternal(); // undo mute before mode may change - - // Save previous icon settings BEFORE reloading, for transition detection. - WCHAR prevIcon[MAX_DEVICE_SLOTS][32] = {}; - for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { - wcscpy_s(prevIcon[i], 32, g_lastIconSetting[i]); - } - - LoadUserIconsAndSettings(); + RestoreMuteExternal(); LoadDeviceSelections(); HWND hwnd = g_trayHwnd; - if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); - HWND hwndHook = g_trayHwnd; - if (hwndHook) PostMessageW(hwndHook, WM_UPDATE_HOOK_STATE, 0, 0); - - // Auto-trigger file picker for any slot switched TO "custom". - // Collect into a bitmask and post once to guarantee sequential order. - WCHAR iconKey[16]; - DWORD pickerSlots = 0; - for (int i = 0; i < g_deviceSlotCount; i++) { - swprintf_s(iconKey, L"icon%d", i + 1); - PCWSTR s = Wh_GetStringSetting(iconKey); - BOOL isCustom = (s && wcscmp(s, L"custom") == 0); - BOOL wasCustom = (wcscmp(prevIcon[i], L"custom") == 0); - if (s) Wh_FreeStringSetting(s); - if (isCustom && !wasCustom) { - pickerSlots |= (1u << i); - } - } - if (pickerSlots && hwnd) { - PostMessageW(hwnd, WM_SHOW_FILE_PICKER, 0, (LPARAM)pickerSlots); + if (hwnd) { + PostMessageW(hwnd, WM_RELOAD_ICONS, 0, 0); + PostMessageW(hwnd, WM_UPDATE_HOOK_STATE, 0, 0); } } From 5b94364bfdf9b18e2359b2558dfde14453afc293 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Sat, 16 May 2026 22:35:23 +0300 Subject: [PATCH 23/56] Enhance instructions for choosing icons Added note about selecting Custom to open file explorer for icon selection. --- mods/audioswap.wh.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index 1550541d62..d5c3b1404e 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -20,7 +20,7 @@ Instantly cycle between multiple audio output devices from your system tray — 1. **Choose Device Count** — Open the **Settings** tab and pick how many devices to cycle through (2–6). 2. **Choose Interaction Mode** — Select **Click to Swap** (left-click the icon) or **Scroll to Swap** (mouse wheel over the icon). -3. **Choose Icons** — Pick an icon for each device slot. Icons for slots 3–6 are only used when the device count is set high enough. +3. **Choose Icons** — Pick an icon for each device slot. Icons for slots 3–6 are only used when the device count is set high enough. (select Custom and save to open the file explorer) 4. **Assign Devices** — Right-click the tray icon. Use **Set as Device 1 / 2 / 3...** to assign each slot from the live device list. 5. **Swap** — Click or scroll the tray icon to cycle through your assigned devices. From 4d8b3991e997b8d0cdee049376cd9c424fb5b63d Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Sat, 16 May 2026 22:39:28 +0300 Subject: [PATCH 24/56] Downgrade version from 1.5.0 to 1.4.0 --- mods/audioswap.wh.cpp | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index d5c3b1404e..53f27d77aa 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -2,7 +2,7 @@ // @id audioswap // @name AudioSwap // @description Tray icon to cycle between multiple preferred audio outputs. Supports up to 6 devices with click or scroll to swap. -// @version 1.5.0 +// @version 1.4.0 // @author BlackPaw // @github https://github.com/BlackPaw21 // @include windhawk.exe @@ -41,15 +41,10 @@ Instantly cycle between multiple audio output devices from your system tray — ## Changelog -### v1.5.0 +### v1.4.0 - Custom .ico support — selecting "Custom Icon" in settings now auto-opens a file picker; also available via right-click → "Custom Icon for Device X..." - Custom icon paths stored internally (no separate text setting in the UI). -### v1.4.0 -- Mouse hook only installed in scroll-to-swap mode (performance fix). -- Polling timer removed — device notifications use IMMNotificationClient instead. -- Known bug description corrected to reference elevated/admin windows. - ### v1.3.0 - Up to 6 devices; scroll-to-swap mode; dynamic right-click menu. - Left-click mutes in scroll mode; red dot overlay on tray icon. From 7f7f87505f8c93ee9158d726d27e970e8a859aa6 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Sat, 16 May 2026 22:42:08 +0300 Subject: [PATCH 25/56] Update known bugs section in audioswap.wh.cpp Clarified known bug description regarding Scroll to Swap functionality. --- mods/audioswap.wh.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index 53f27d77aa..d612623ed3 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -35,7 +35,7 @@ Instantly cycle between multiple audio output devices from your system tray — ## Known Bugs -- **Scroll to Swap may not respond over elevated windows.** A low-level mouse hook from a non-administrator process cannot see mouse events destined for elevated (administrator) windows, such as Task Manager or other admin-elevated applications. Switch focus away from an elevated window and scrolling will work normally. +- **Scroll to Swap may not respond over elevated windows.** such as Task Manager, Windhawk or other admin-elevated applications. Switch focus away from an elevated window and scrolling will work normally. --- From da7f9ca3348f43a248f9281937fd06360b04ba4b Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Mon, 18 May 2026 03:18:30 +0300 Subject: [PATCH 26/56] Add MicroManager mod for CPU/GPU monitoring Initial implementation of MicroManager mod with CPU/GPU usage tracking and tray icon functionality. --- mods/micromanager.wh.cpp | 1011 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 1011 insertions(+) create mode 100644 mods/micromanager.wh.cpp diff --git a/mods/micromanager.wh.cpp b/mods/micromanager.wh.cpp new file mode 100644 index 0000000000..80bd79a848 --- /dev/null +++ b/mods/micromanager.wh.cpp @@ -0,0 +1,1011 @@ +// ==WindhawkMod== +// @id micromanager +// @name MicroManager +// @description Mini task manager tray icon showing CPU/GPU usage and top consumers. +// @version 1.1.0 +// @author BlackPaw +// @github https://github.com/BlackPaw21 +// @include windhawk.exe +// @compilerOptions -lpdh -lshell32 -lgdi32 -luser32 -lole32 -luuid -DUNICODE -D_UNICODE +// ==/WindhawkMod== + +// ==WindhawkModSettings== +/* +- updateInterval: 2 + $name: Update Interval (s) + $description: How often to refresh CPU/GPU stats + $options: + - 1: Fast (1s) + - 2: Normal (2s) + - 3: Relaxed (3s) + - 5: Slow (5s) +*/ +// ==/WindhawkModSettings== + +// ==WindhawkModReadme== +/* +# MicroManager + +A lightweight tray icon that shows a mini task manager popup with live CPU and GPU usage stats. + +## How to Use + +1. **Left-click** the tray icon to open the popup showing: + - Total CPU usage and the top CPU-consuming process + - Total GPU usage and the top GPU-consuming process +2. **Click again** to close it +3. **Right-click** the tray icon for options + +## Configuration + +The update interval can be adjusted in the Settings tab. + +## Changelog + +### v1.1.0 +- Fixed: boilerplate corrected so the mod actually runs +- Fixed: "Open WindHawk" right-click item now works +- Fixed: popup destroyed on the correct thread during shutdown +- Fixed: CoInitialize/CoUninitialize now properly paired on tray thread +- Fixed: tray icon reappears after Explorer restart (TaskbarCreated) +- Fixed: tray window style changed to WS_POPUP / WS_EX_TOOLWINDOW +- Fixed: AUMID set so icon doesn't group with Windhawk's own icon +- Fixed: popup class unregistered on mod uninit for clean reload +- Fixed: ddores.dll loaded from full system32 path +- Fixed: GPU PDH counter primed on init so first tick shows real data + +### v1.0.0 +- Initial release. +*/ +// ==/WindhawkModReadme== + +#define NOMINMAX +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// PDH constants that may not be defined in all SDK versions +#ifndef PDH_MORE_DATA +#define PDH_MORE_DATA ((PDH_STATUS)0x800007D2L) +#endif +#ifndef PDH_CSTATUS_VALID_DATA +#define PDH_CSTATUS_VALID_DATA ((LONG)0x00000000L) +#endif + +#ifndef MIN +#define MIN(a, b) (((a) < (b)) ? (a) : (b)) +#endif + +// ─── Constants ──────────────────────────────────────────────────────────────── + +#define TRAY_ICON_ID 1 +#define WM_TRAY_CALLBACK (WM_USER + 1) +#define WM_REFRESH_DATA (WM_USER + 2) +#define WM_UPDATE_POPUP (WM_USER + 3) +#define WM_TIMER_ID 1 + +#define POPUP_WIDTH 360 +#define POPUP_HEIGHT 80 + +#define MAX_PROCESSES 1024 +#define PROCESS_BUF_SIZE (512 * 1024) + +#define MENU_OPEN_WINDHAWK 9000 + +// ─── NtQuerySystemInformation via dynamic load ──────────────────────────────── + +#ifndef UNICODE_STRING +typedef struct _UNICODE_STRING { + USHORT Length; + USHORT MaximumLength; + PWSTR Buffer; +} UNICODE_STRING; +#endif + +typedef LONG NTSTATUS; + +#define STATUS_INFO_LENGTH_MISMATCH ((NTSTATUS)0xC0000004L) +#define SystemProcessInformation 5 + +typedef struct { + ULONG NextEntryOffset; + ULONG NumberOfThreads; + LARGE_INTEGER WorkingSetPrivateSize; + ULONG HardFaultCount; + ULONG NumberOfThreadsHighWatermark; + LARGE_INTEGER CycleTime; + LARGE_INTEGER CreateTime; + LARGE_INTEGER UserTime; + LARGE_INTEGER KernelTime; + UNICODE_STRING ImageName; + LONG BasePriority; + HANDLE UniqueProcessId; + HANDLE InheritedFromUniqueProcessId; + ULONG HandleCount; + ULONG SessionId; + ULONG_PTR Reserved1; + SIZE_T PeakVirtualSize; + SIZE_T VirtualSize; + ULONG PageFaultCount; + SIZE_T PeakWorkingSetSize; + SIZE_T WorkingSetSize; + SIZE_T QuotaPeakPagedPoolUsage; + SIZE_T QuotaPagedPoolUsage; + SIZE_T QuotaPeakNonPagedPoolUsage; + SIZE_T QuotaNonPagedPoolUsage; + SIZE_T PagefileUsage; + SIZE_T PeakPagefileUsage; + SIZE_T PrivatePageCount; + LARGE_INTEGER ReadOperationCount; + LARGE_INTEGER WriteOperationCount; + LARGE_INTEGER OtherOperationCount; + LARGE_INTEGER ReadTransferCount; + LARGE_INTEGER WriteTransferCount; + LARGE_INTEGER OtherTransferCount; +} MY_SYSTEM_PROCESS_INFO; + +typedef NTSTATUS (WINAPI *NtQuerySystemInformation_t)(ULONG, PVOID, ULONG, PULONG); + +// ─── Globals ────────────────────────────────────────────────────────────────── + +static HANDLE g_trayThread = nullptr; +static volatile HWND g_trayHwnd = nullptr; +static HWND g_popupHwnd = nullptr; +static HINSTANCE g_hInstance = nullptr; +static WCHAR g_windhawkPath[MAX_PATH] = {}; +static WCHAR g_ddoresDllPath[MAX_PATH] = {}; + +static DWORD g_updateMs = 2000; + +// Cached stats (updated on timer, read on popup paint) +static CRITICAL_SECTION g_statsLock; +static int g_totalCpu = -1; +static int g_topCpuPct = 0; +static WCHAR g_topCpuName[64] = {}; +static int g_totalGpu = -1; +static int g_topGpuPct = 0; +static WCHAR g_topGpuName[64] = {}; + +// GPU PDH handles +static PDH_HQUERY g_gpuQuery = nullptr; +static PDH_HCOUNTER g_gpuCounter = nullptr; +static BOOL g_gpuFailed = FALSE; + +// Previous sample for CPU delta +static FILETIME g_prevIdle = {}; +static FILETIME g_prevKernel = {}; +static FILETIME g_prevUser = {}; +static struct { + DWORD pid; + LONGLONG time; + WCHAR name[64]; +} g_prevProcs[MAX_PROCESSES]; +static int g_prevProcCount = 0; +static BOOL g_hasPrevSample = FALSE; + +static HICON g_iconEnabled = nullptr; +static HFONT g_hPopupFont = nullptr; + +static UINT g_taskbarCreatedMsg = 0; + +// ─── Font Helpers ───────────────────────────────────────────────────────────── + +static void EnsureFont() { + if (!g_hPopupFont) { + g_hPopupFont = CreateFontW(-13, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, + ANSI_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, + DEFAULT_QUALITY, DEFAULT_PITCH | FF_DONTCARE, L"Segoe UI"); + } +} + +// ─── CPU Sampling ───────────────────────────────────────────────────────────── + +static NtQuerySystemInformation_t LoadNtQuery() { + HMODULE h = GetModuleHandleW(L"ntdll.dll"); + if (!h) return nullptr; + return (NtQuerySystemInformation_t)GetProcAddress(h, "NtQuerySystemInformation"); +} + +static int CollectProcessInfo(MY_SYSTEM_PROCESS_INFO** outBuf) { + NtQuerySystemInformation_t NtQuery = LoadNtQuery(); + if (!NtQuery) return 0; + + ULONG bufSize = PROCESS_BUF_SIZE; + *outBuf = (MY_SYSTEM_PROCESS_INFO*)malloc(bufSize); + if (!*outBuf) return 0; + + NTSTATUS status = NtQuery(SystemProcessInformation, *outBuf, bufSize, &bufSize); + if (status == STATUS_INFO_LENGTH_MISMATCH) { + free(*outBuf); + bufSize *= 2; + *outBuf = (MY_SYSTEM_PROCESS_INFO*)malloc(bufSize); + if (!*outBuf) return 0; + status = NtQuery(SystemProcessInformation, *outBuf, bufSize, &bufSize); + } + if (status < 0) { + free(*outBuf); + *outBuf = nullptr; + return 0; + } + return 1; +} + +// ─── GPU Sampling (PDH) ─────────────────────────────────────────────────────── + +static void InitGpuQuery() { + if (g_gpuQuery) return; + if (g_gpuFailed) return; + + PDH_STATUS ps = PdhOpenQueryW(nullptr, 0, &g_gpuQuery); + if (ps != ERROR_SUCCESS) { g_gpuFailed = TRUE; return; } + + ps = PdhAddEnglishCounterW(g_gpuQuery, + L"\\GPU Engine(*)\\Utilization Percentage", 0, &g_gpuCounter); + if (ps != ERROR_SUCCESS) { + PdhCloseQuery(g_gpuQuery); + g_gpuQuery = nullptr; + g_gpuFailed = TRUE; + return; + } + + // Prime the baseline — PDH rate counters return 0 on the very first collection + // without a prior sample to delta against. One collection here means the first + // real tick in RefreshData produces accurate data instead of 0%. + PdhCollectQueryData(g_gpuQuery); +} + +static void CollectGpuStats(int* outTotal, int* outTopPct, WCHAR* outTopName, int nameLen) { + *outTotal = -1; + *outTopPct = 0; + outTopName[0] = L'\0'; + + if (!g_gpuQuery || g_gpuFailed) return; + + PdhCollectQueryData(g_gpuQuery); + + DWORD bufSize = 0; + DWORD itemCount = 0; + PDH_STATUS ps = PdhGetFormattedCounterArrayW(g_gpuCounter, PDH_FMT_DOUBLE, + &bufSize, &itemCount, nullptr); + if (ps != PDH_MORE_DATA || bufSize == 0 || itemCount == 0) return; + + PDH_FMT_COUNTERVALUE_ITEM_W* items = (PDH_FMT_COUNTERVALUE_ITEM_W*)malloc(bufSize); + if (!items) return; + + ps = PdhGetFormattedCounterArrayW(g_gpuCounter, PDH_FMT_DOUBLE, + &bufSize, &itemCount, items); + if (ps != ERROR_SUCCESS) { free(items); return; } + + // Aggregate per PID + struct GpuProc { DWORD pid; double total; }; + GpuProc procs[MAX_PROCESSES]; + int procCount = 0; + double grandTotal = 0; + + for (DWORD i = 0; i < itemCount; i++) { + if (items[i].FmtValue.CStatus != PDH_CSTATUS_VALID_DATA) continue; + + double val = items[i].FmtValue.doubleValue; + grandTotal += val; + + // Parse instance: "pid_1234_luid_0x00000000_phys_0_eng_0_enum_1" + PCWSTR s = items[i].szName; + if (wcsncmp(s, L"pid_", 4) != 0) continue; + DWORD pid = 0; + for (s += 4; *s >= L'0' && *s <= L'9'; s++) + pid = pid * 10 + (*s - L'0'); + + int j; + for (j = 0; j < procCount; j++) { + if (procs[j].pid == pid) { procs[j].total += val; break; } + } + if (j >= procCount && procCount < MAX_PROCESSES) { + procs[procCount].pid = pid; + procs[procCount].total = val; + procCount++; + } + } + free(items); + + *outTotal = (int)(grandTotal + 0.5); + + // Find top GPU process + int topIdx = -1; + double topVal = 0; + for (int i = 0; i < procCount; i++) { + if (procs[i].total > topVal) { + topVal = procs[i].total; + topIdx = i; + } + } + if (topIdx >= 0) { + *outTopPct = (int)(topVal + 0.5); + + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap != INVALID_HANDLE_VALUE) { + PROCESSENTRY32W pe = {sizeof(pe)}; + if (Process32FirstW(snap, &pe)) { + do { + if (pe.th32ProcessID == procs[topIdx].pid) { + wcscpy_s(outTopName, nameLen, pe.szExeFile); + break; + } + } while (Process32NextW(snap, &pe)); + } + CloseHandle(snap); + } + } +} + +// ─── Data Refresh ───────────────────────────────────────────────────────────── + +static void RefreshData() { + int newTotalCpu = -1, newTopCpuPct = 0; + WCHAR newTopCpuName[64] = {}; + int newTotalGpu = -1, newTopGpuPct = 0; + WCHAR newTopGpuName[64] = {}; + + // Collect CPU + FILETIME nowIdle, nowKernel, nowUser; + GetSystemTimes(&nowIdle, &nowKernel, &nowUser); + + ULARGE_INTEGER ui, uk, uu, pi, pk, pu; + ui.LowPart = nowIdle.dwLowDateTime; ui.HighPart = nowIdle.dwHighDateTime; + uk.LowPart = nowKernel.dwLowDateTime; uk.HighPart = nowKernel.dwHighDateTime; + uu.LowPart = nowUser.dwLowDateTime; uu.HighPart = nowUser.dwHighDateTime; + pi.LowPart = g_prevIdle.dwLowDateTime; pi.HighPart = g_prevIdle.dwHighDateTime; + pk.LowPart = g_prevKernel.dwLowDateTime; pk.HighPart = g_prevKernel.dwHighDateTime; + pu.LowPart = g_prevUser.dwLowDateTime; pu.HighPart = g_prevUser.dwHighDateTime; + + double totalDelta = (double)(uk.QuadPart - pk.QuadPart) + (double)(uu.QuadPart - pu.QuadPart); + double idleDelta = (double)(ui.QuadPart - pi.QuadPart); + + if (g_hasPrevSample && totalDelta > 0) { + newTotalCpu = (int)(100.0 - 100.0 * idleDelta / totalDelta + 0.5); + + MY_SYSTEM_PROCESS_INFO* buf = nullptr; + if (CollectProcessInfo(&buf) && buf) { + NtQuerySystemInformation_t NtQuery = LoadNtQuery(); + if (NtQuery) { + MY_SYSTEM_PROCESS_INFO* p = buf; + LONGLONG bestTime = 0; + WCHAR bestName[64] = {}; + int count = 0; + + while (true) { + LONGLONG curTime = p->KernelTime.QuadPart + p->UserTime.QuadPart; + + LONGLONG prevTime = 0; + for (int i = 0; i < g_prevProcCount; i++) { + if (g_prevProcs[i].pid == (DWORD)(ULONG_PTR)p->UniqueProcessId) { + prevTime = g_prevProcs[i].time; + break; + } + } + LONGLONG delta = curTime - prevTime; + if (delta > bestTime && p->ImageName.Buffer) { + bestTime = delta; + wcsncpy_s(bestName, p->ImageName.Buffer, + MIN(p->ImageName.Length / sizeof(WCHAR), 63)); + bestName[63] = L'\0'; + } + + if (count < MAX_PROCESSES) { + g_prevProcs[count].pid = (DWORD)(ULONG_PTR)p->UniqueProcessId; + g_prevProcs[count].time = curTime; + if (p->ImageName.Buffer) { + wcsncpy_s(g_prevProcs[count].name, p->ImageName.Buffer, + MIN(p->ImageName.Length / sizeof(WCHAR), 63)); + g_prevProcs[count].name[63] = L'\0'; + } else { + g_prevProcs[count].name[0] = L'\0'; + } + count++; + } + + if (p->NextEntryOffset == 0) break; + p = (MY_SYSTEM_PROCESS_INFO*)((BYTE*)p + p->NextEntryOffset); + } + g_prevProcCount = count; + + if (bestTime > 0) { + double pct = 100.0 * (double)bestTime / totalDelta; + if (pct >= 0.5) { + newTopCpuPct = (int)(pct + 0.5); + wcscpy_s(newTopCpuName, bestName); + } + } + } + free(buf); + } + } + + g_prevIdle = nowIdle; g_prevKernel = nowKernel; g_prevUser = nowUser; + g_hasPrevSample = TRUE; + + CollectGpuStats(&newTotalGpu, &newTopGpuPct, newTopGpuName, 64); + + EnterCriticalSection(&g_statsLock); + g_totalCpu = newTotalCpu; + g_topCpuPct = newTopCpuPct; + wcscpy_s(g_topCpuName, newTopCpuName); + g_totalGpu = newTotalGpu; + g_topGpuPct = newTopGpuPct; + wcscpy_s(g_topGpuName, newTopGpuName); + LeaveCriticalSection(&g_statsLock); + + if (g_popupHwnd && IsWindowVisible(g_popupHwnd)) { + InvalidateRect(g_popupHwnd, nullptr, TRUE); + } +} + +// ─── Popup Window Procedure ─────────────────────────────────────────────────── + +static LRESULT CALLBACK PopupWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { + switch (msg) { + case WM_ACTIVATE: + if (LOWORD(wParam) == WA_INACTIVE) { + ShowWindow(hWnd, SW_HIDE); + } + return 0; + + case WM_PAINT: { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hWnd, &ps); + + HBRUSH bgBrush = CreateSolidBrush(RGB(32, 32, 32)); + RECT rc; + GetClientRect(hWnd, &rc); + FillRect(hdc, &rc, bgBrush); + DeleteObject(bgBrush); + + HPEN borderPen = CreatePen(PS_SOLID, 1, RGB(64, 64, 64)); + HPEN oldPen = (HPEN)SelectObject(hdc, borderPen); + HBRUSH oldBrush = (HBRUSH)SelectObject(hdc, GetStockObject(NULL_BRUSH)); + Rectangle(hdc, 0, 0, rc.right, rc.bottom); + SelectObject(hdc, oldPen); + SelectObject(hdc, oldBrush); + DeleteObject(borderPen); + + SetBkMode(hdc, TRANSPARENT); + EnsureFont(); + SelectObject(hdc, g_hPopupFont); + + EnterCriticalSection(&g_statsLock); + + for (int row = 0; row < 2; row++) { + int y = 12 + row * 32; + BOOL isCpu = (row == 0); + + PCWSTR label = isCpu ? L"CPU" : L"GPU"; + int totalVal = isCpu ? g_totalCpu : g_totalGpu; + int topPctVal = isCpu ? g_topCpuPct : g_topGpuPct; + PCWSTR topName = isCpu ? g_topCpuName : g_topGpuName; + + COLORREF labelColor = RGB(140, 140, 140); + COLORREF totalColor = isCpu ? RGB(0, 200, 255) : RGB(0, 220, 100); + COLORREF valueColor = RGB(220, 220, 220); + + SetTextColor(hdc, labelColor); + WCHAR labelBuf[16]; + swprintf_s(labelBuf, L"%s:", label); + TextOutW(hdc, 12, y, labelBuf, (int)wcslen(labelBuf)); + + SetTextColor(hdc, totalColor); + WCHAR totalBuf[16]; + if (totalVal < 0) + swprintf_s(totalBuf, L"..%%"); + else + swprintf_s(totalBuf, L"%d%%", totalVal); + TextOutW(hdc, 60, y, totalBuf, (int)wcslen(totalBuf)); + + SetTextColor(hdc, valueColor); + if (topName[0] != L'\0' && topPctVal > 0) { + WCHAR lineBuf[256]; + swprintf_s(lineBuf, L"%s %d%%", topName, topPctVal); + TextOutW(hdc, 130, y, lineBuf, (int)wcslen(lineBuf)); + } else if (totalVal >= 0) { + PCWSTR idle = L"Idle"; + TextOutW(hdc, 130, y, idle, (int)wcslen(idle)); + } else { + PCWSTR collecting = L"Collecting..."; + TextOutW(hdc, 130, y, collecting, (int)wcslen(collecting)); + } + } + + LeaveCriticalSection(&g_statsLock); + + EndPaint(hWnd, &ps); + return 0; + } + + case WM_NCHITTEST: { + LRESULT hit = DefWindowProcW(hWnd, msg, wParam, lParam); + if (hit == HTCLIENT) return HTCAPTION; + return hit; + } + + case WM_KEYDOWN: + if (wParam == VK_ESCAPE) ShowWindow(hWnd, SW_HIDE); + break; + + case WM_DESTROY: + break; + } + return DefWindowProcW(hWnd, msg, wParam, lParam); +} + +// ─── Show/Hide Popup ────────────────────────────────────────────────────────── + +static void ShowPopup(HWND hTrayWnd) { + if (!g_popupHwnd) { + WNDCLASSW wc = {0}; + wc.lpfnWndProc = PopupWndProc; + wc.hInstance = g_hInstance; + wc.hCursor = LoadCursorW(nullptr, IDC_ARROW); + wc.lpszClassName = L"MicroManagerPopupClass"; + wc.hbrBackground = (HBRUSH)GetStockObject(NULL_BRUSH); + RegisterClassW(&wc); + + g_popupHwnd = CreateWindowExW( + WS_EX_TOOLWINDOW | WS_EX_TOPMOST, + wc.lpszClassName, L"MicroManager", + WS_POPUP | WS_BORDER, + 0, 0, POPUP_WIDTH, POPUP_HEIGHT, + nullptr, nullptr, g_hInstance, nullptr); + } + + if (!g_popupHwnd) return; + + NOTIFYICONIDENTIFIER nii = {sizeof(nii)}; + nii.hWnd = hTrayWnd; + nii.uID = TRAY_ICON_ID; + RECT iconRect; + int x, y; + + if (SUCCEEDED(Shell_NotifyIconGetRect(&nii, &iconRect))) { + x = iconRect.left - POPUP_WIDTH + (iconRect.right - iconRect.left) / 2; + y = iconRect.top - POPUP_HEIGHT - 4; + } else { + POINT pt; + GetCursorPos(&pt); + x = pt.x - POPUP_WIDTH / 2; + y = pt.y - POPUP_HEIGHT - 4; + } + + SetWindowPos(g_popupHwnd, HWND_TOPMOST, x, y, POPUP_WIDTH, POPUP_HEIGHT, + SWP_SHOWWINDOW); + SetFocus(g_popupHwnd); +} + +// ─── Tray Window Procedure ──────────────────────────────────────────────────── + +static LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { + switch (msg) { + case WM_CREATE: + RefreshData(); + SetTimer(hWnd, WM_TIMER_ID, g_updateMs, nullptr); + return 0; + + case WM_TIMER: + if (wParam == WM_TIMER_ID) { + RefreshData(); + } + return 0; + + case WM_TRAY_CALLBACK: + switch (LOWORD(lParam)) { + case WM_LBUTTONUP: + if (g_popupHwnd && IsWindowVisible(g_popupHwnd)) { + ShowWindow(g_popupHwnd, SW_HIDE); + } else { + ShowPopup(hWnd); + } + break; + + case WM_RBUTTONUP: { + HMENU hMenu = CreatePopupMenu(); + AppendMenuW(hMenu, MF_STRING, MENU_OPEN_WINDHAWK, L"Open WindHawk"); + AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); + + WCHAR statusText[128]; + EnterCriticalSection(&g_statsLock); + int cpu = g_totalCpu; + int gpu = g_totalGpu; + LeaveCriticalSection(&g_statsLock); + + if (cpu >= 0 && gpu >= 0) + swprintf_s(statusText, L"CPU: %d%% GPU: %d%%", cpu, gpu); + else + lstrcpyW(statusText, L"Collecting..."); + AppendMenuW(hMenu, MF_STRING | MF_GRAYED, 0, statusText); + + POINT pt; + GetCursorPos(&pt); + SetForegroundWindow(hWnd); + // TPM_RETURNCMD returns the selected ID directly — no WM_COMMAND is posted + int cmd = TrackPopupMenu(hMenu, TPM_RETURNCMD | TPM_RIGHTBUTTON | + TPM_BOTTOMALIGN | TPM_RIGHTALIGN, + pt.x, pt.y, 0, hWnd, nullptr); + PostMessageW(hWnd, WM_NULL, 0, 0); + DestroyMenu(hMenu); + + if (cmd == MENU_OPEN_WINDHAWK) { + SHELLEXECUTEINFOW sei = {sizeof(sei)}; + sei.lpFile = g_windhawkPath; + sei.nShow = SW_SHOWNORMAL; + ShellExecuteExW(&sei); + } + break; + } + } + return 0; + + case WM_CLOSE: + // Destroy popup on the tray thread (its owner) before destroying the tray window + if (g_popupHwnd) { DestroyWindow(g_popupHwnd); g_popupHwnd = nullptr; } + DestroyWindow(hWnd); + return 0; + + case WM_DESTROY: + KillTimer(hWnd, WM_TIMER_ID); + { + NOTIFYICONDATAW nid = {sizeof(nid)}; + nid.hWnd = hWnd; + nid.uID = TRAY_ICON_ID; + Shell_NotifyIconW(NIM_DELETE, &nid); + } + PostQuitMessage(0); + return 0; + } + + // Re-add tray icon after Explorer restarts + if (msg == g_taskbarCreatedMsg && g_taskbarCreatedMsg != 0) { + NOTIFYICONDATAW nid = {sizeof(nid)}; + nid.hWnd = hWnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP; + nid.uCallbackMessage = WM_TRAY_CALLBACK; + lstrcpynW(nid.szTip, L"MicroManager", 128); + nid.hIcon = g_iconEnabled ? g_iconEnabled : LoadIconW(nullptr, IDI_APPLICATION); + Shell_NotifyIconW(NIM_ADD, &nid); + return 0; + } + + return DefWindowProcW(hWnd, msg, wParam, lParam); +} + +// ─── Tray Thread ────────────────────────────────────────────────────────────── + +static DWORD WINAPI TrayThreadProc(LPVOID) { + CoInitialize(nullptr); + g_taskbarCreatedMsg = RegisterWindowMessageW(L"TaskbarCreated"); + + WNDCLASSW wc = {0}; + wc.lpfnWndProc = TrayWndProc; + wc.hInstance = g_hInstance; + wc.lpszClassName = L"MicroManagerTrayClass"; + RegisterClassW(&wc); + + g_trayHwnd = CreateWindowExW( + WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, + wc.lpszClassName, L"MicroManager", + WS_POPUP, + 0, 0, 1, 1, nullptr, nullptr, g_hInstance, nullptr); + if (!g_trayHwnd) { + CoUninitialize(); + return 1; + } + + // Unique AUMID so the OS doesn't group this icon with Windhawk's main window + IPropertyStore* pps = nullptr; + if (SUCCEEDED(SHGetPropertyStoreForWindow(g_trayHwnd, IID_PPV_ARGS(&pps)))) { + PROPVARIANT var; + PropVariantInit(&var); + var.vt = VT_LPWSTR; + var.pwszVal = (LPWSTR)CoTaskMemAlloc(MAX_PATH * sizeof(WCHAR)); + if (var.pwszVal) { + lstrcpyW(var.pwszVal, L"BlackPaw.MicroManager"); + pps->SetValue(PKEY_AppUserModel_ID, var); + CoTaskMemFree(var.pwszVal); + } + pps->Commit(); + pps->Release(); + } + + NOTIFYICONDATAW nid = {sizeof(nid)}; + nid.hWnd = g_trayHwnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP; + nid.uCallbackMessage = WM_TRAY_CALLBACK; + lstrcpynW(nid.szTip, L"MicroManager", 128); + nid.hIcon = g_iconEnabled ? g_iconEnabled : LoadIconW(nullptr, IDI_APPLICATION); + Shell_NotifyIconW(NIM_ADD, &nid); + + MSG msg; + while (GetMessageW(&msg, nullptr, 0, 0)) { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + + g_trayHwnd = nullptr; + CoUninitialize(); + return 0; +} + +// ─── Windhawk Callbacks ─────────────────────────────────────────────────────── + +BOOL WhTool_ModInit() { + Wh_Log(L"MicroManager Init"); + + InitializeCriticalSection(&g_statsLock); + + g_hInstance = GetModuleHandleW(nullptr); + GetModuleFileNameW(g_hInstance, g_windhawkPath, MAX_PATH); + + // Full path for ddores.dll — ExtractIconExW handles the .mun redirect on Win11 + UINT sysLen = GetSystemDirectoryW(g_ddoresDllPath, MAX_PATH); + if (sysLen > 0 && sysLen < MAX_PATH - 12) + lstrcatW(g_ddoresDllPath, L"\\ddores.dll"); + else + lstrcpyW(g_ddoresDllPath, L"ddores.dll"); + + ExtractIconExW(g_ddoresDllPath, 28, nullptr, &g_iconEnabled, 1); + + InitGpuQuery(); + + // Baseline CPU sample so the first timer tick has a delta to work from + GetSystemTimes(&g_prevIdle, &g_prevKernel, &g_prevUser); + MY_SYSTEM_PROCESS_INFO* initBuf = nullptr; + if (CollectProcessInfo(&initBuf) && initBuf) { + NtQuerySystemInformation_t NtQuery = LoadNtQuery(); + if (NtQuery) { + MY_SYSTEM_PROCESS_INFO* p = initBuf; + int count = 0; + while (count < MAX_PROCESSES) { + g_prevProcs[count].pid = (DWORD)(ULONG_PTR)p->UniqueProcessId; + g_prevProcs[count].time = p->KernelTime.QuadPart + p->UserTime.QuadPart; + if (p->ImageName.Buffer) { + wcsncpy_s(g_prevProcs[count].name, p->ImageName.Buffer, + MIN(p->ImageName.Length / sizeof(WCHAR), 63)); + g_prevProcs[count].name[63] = L'\0'; + } else { + g_prevProcs[count].name[0] = L'\0'; + } + count++; + if (p->NextEntryOffset == 0) break; + p = (MY_SYSTEM_PROCESS_INFO*)((BYTE*)p + p->NextEntryOffset); + } + g_prevProcCount = count; + } + free(initBuf); + } + + g_trayThread = CreateThread(nullptr, 0, TrayThreadProc, nullptr, 0, nullptr); + return TRUE; +} + +void WhTool_ModSettingsChanged() { + PCWSTR s = Wh_GetStringSetting(L"updateInterval"); + if (s) { + DWORD newSec = _wtoi(s); + Wh_FreeStringSetting(s); + DWORD newMs = newSec * 1000; + if (newMs >= 100 && newMs <= 60000) { + g_updateMs = newMs; + HWND hwnd = g_trayHwnd; + if (hwnd) { + KillTimer(hwnd, WM_TIMER_ID); + SetTimer(hwnd, WM_TIMER_ID, newMs, nullptr); + } + } + } +} + +void WhTool_ModUninit() { + Wh_Log(L"MicroManager Mod Uninit"); + + // WM_CLOSE handler on the tray thread destroys g_popupHwnd before the tray + // window itself — no cross-thread DestroyWindow needed here + if (g_trayHwnd) PostMessageW(g_trayHwnd, WM_CLOSE, 0, 0); + if (g_trayThread) { + WaitForSingleObject(g_trayThread, 3000); + CloseHandle(g_trayThread); + g_trayThread = nullptr; + } + + // Unregister popup class after the tray thread has fully exited so a + // subsequent mod reload can re-register it cleanly + UnregisterClassW(L"MicroManagerPopupClass", g_hInstance); + + if (g_iconEnabled) { DestroyIcon(g_iconEnabled); g_iconEnabled = nullptr; } + if (g_hPopupFont) { DeleteObject(g_hPopupFont); g_hPopupFont = nullptr; } + if (g_gpuQuery) { PdhCloseQuery(g_gpuQuery); g_gpuQuery = nullptr; g_gpuCounter = nullptr; } + + DeleteCriticalSection(&g_statsLock); +} + +//////////////////////////////////////////////////////////////////////////////// +// Windhawk tool mod implementation for mods which don't need to inject to other +// processes or hook other functions. Context: +// https://github.com/ramensoftware/windhawk/wiki/Mods-as-tools:-Running-mods-in-a-dedicated-process +// +// The mod will load and run in a dedicated windhawk.exe process. +// +// Paste the code below as part of the mod code, and use these callbacks: +// * WhTool_ModInit +// * WhTool_ModSettingsChanged +// * WhTool_ModUninit +// +// Currently, other callbacks are not supported. + +bool g_isToolModProcessLauncher; +HANDLE g_toolModProcessMutex; + +void WINAPI EntryPoint_Hook() { + Wh_Log(L">"); + ExitThread(0); +} + +BOOL Wh_ModInit() { + DWORD sessionId; + if (ProcessIdToSessionId(GetCurrentProcessId(), &sessionId) && + sessionId == 0) { + return FALSE; + } + + bool isExcluded = false; + bool isToolModProcess = false; + bool isCurrentToolModProcess = false; + int argc; + LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc); + if (!argv) { + Wh_Log(L"CommandLineToArgvW failed"); + return FALSE; + } + + for (int i = 1; i < argc; i++) { + if (wcscmp(argv[i], L"-service") == 0 || + wcscmp(argv[i], L"-service-start") == 0 || + wcscmp(argv[i], L"-service-stop") == 0) { + isExcluded = true; + break; + } + } + + for (int i = 1; i < argc - 1; i++) { + if (wcscmp(argv[i], L"-tool-mod") == 0) { + isToolModProcess = true; + if (wcscmp(argv[i + 1], WH_MOD_ID) == 0) { + isCurrentToolModProcess = true; + } + break; + } + } + + LocalFree(argv); + + if (isExcluded) { + return FALSE; + } + + if (isCurrentToolModProcess) { + g_toolModProcessMutex = + CreateMutexW(nullptr, TRUE, L"windhawk-tool-mod_" WH_MOD_ID); + if (!g_toolModProcessMutex) { + Wh_Log(L"CreateMutex failed"); + ExitProcess(1); + } + + if (GetLastError() == ERROR_ALREADY_EXISTS) { + Wh_Log(L"Tool mod already running (%s)", WH_MOD_ID); + ExitProcess(1); + } + + if (!WhTool_ModInit()) { + ExitProcess(1); + } + + IMAGE_DOS_HEADER* dosHeader = + (IMAGE_DOS_HEADER*)GetModuleHandleW(nullptr); + IMAGE_NT_HEADERS* ntHeaders = + (IMAGE_NT_HEADERS*)((BYTE*)dosHeader + dosHeader->e_lfanew); + + DWORD entryPointRVA = ntHeaders->OptionalHeader.AddressOfEntryPoint; + void* entryPoint = (BYTE*)dosHeader + entryPointRVA; + + Wh_SetFunctionHook(entryPoint, (void*)EntryPoint_Hook, nullptr); + return TRUE; + } + + if (isToolModProcess) { + return FALSE; + } + + g_isToolModProcessLauncher = true; + return TRUE; +} + +void Wh_ModAfterInit() { + if (!g_isToolModProcessLauncher) { + return; + } + + WCHAR currentProcessPath[MAX_PATH]; + switch (GetModuleFileNameW(nullptr, currentProcessPath, + ARRAYSIZE(currentProcessPath))) { + case 0: + case ARRAYSIZE(currentProcessPath): + Wh_Log(L"GetModuleFileName failed"); + return; + } + + WCHAR + commandLine[MAX_PATH + 2 + + (sizeof(L" -tool-mod \"" WH_MOD_ID "\"") / sizeof(WCHAR)) - 1]; + swprintf_s(commandLine, L"\"%s\" -tool-mod \"%s\"", currentProcessPath, + WH_MOD_ID); + + HMODULE kernelModule = GetModuleHandleW(L"kernelbase.dll"); + if (!kernelModule) { + kernelModule = GetModuleHandleW(L"kernel32.dll"); + if (!kernelModule) { + Wh_Log(L"No kernelbase.dll/kernel32.dll"); + return; + } + } + + using CreateProcessInternalW_t = BOOL(WINAPI*)( + HANDLE hUserToken, LPCWSTR lpApplicationName, LPWSTR lpCommandLine, + LPSECURITY_ATTRIBUTES lpProcessAttributes, + LPSECURITY_ATTRIBUTES lpThreadAttributes, WINBOOL bInheritHandles, + DWORD dwCreationFlags, LPVOID lpEnvironment, LPCWSTR lpCurrentDirectory, + LPSTARTUPINFOW lpStartupInfo, + LPPROCESS_INFORMATION lpProcessInformation, + PHANDLE hRestrictedUserToken); + CreateProcessInternalW_t pCreateProcessInternalW = + (CreateProcessInternalW_t)GetProcAddress(kernelModule, + "CreateProcessInternalW"); + if (!pCreateProcessInternalW) { + Wh_Log(L"No CreateProcessInternalW"); + return; + } + + STARTUPINFOW si{ + .cb = sizeof(STARTUPINFOW), + .dwFlags = STARTF_FORCEOFFFEEDBACK, + }; + PROCESS_INFORMATION pi; + if (!pCreateProcessInternalW(nullptr, currentProcessPath, commandLine, + nullptr, nullptr, FALSE, NORMAL_PRIORITY_CLASS, + nullptr, nullptr, &si, &pi, nullptr)) { + Wh_Log(L"CreateProcess failed"); + return; + } + + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); +} + +void Wh_ModSettingsChanged() { + if (g_isToolModProcessLauncher) { + return; + } + + WhTool_ModSettingsChanged(); +} + +void Wh_ModUninit() { + if (g_isToolModProcessLauncher) { + return; + } + + WhTool_ModUninit(); + ExitProcess(0); +} From 308df616970dd64326000e7ead2ca14fc2bf4182 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Mon, 18 May 2026 03:21:36 +0300 Subject: [PATCH 27/56] Delete mods/micromanager.wh.cpp --- mods/micromanager.wh.cpp | 1011 -------------------------------------- 1 file changed, 1011 deletions(-) delete mode 100644 mods/micromanager.wh.cpp diff --git a/mods/micromanager.wh.cpp b/mods/micromanager.wh.cpp deleted file mode 100644 index 80bd79a848..0000000000 --- a/mods/micromanager.wh.cpp +++ /dev/null @@ -1,1011 +0,0 @@ -// ==WindhawkMod== -// @id micromanager -// @name MicroManager -// @description Mini task manager tray icon showing CPU/GPU usage and top consumers. -// @version 1.1.0 -// @author BlackPaw -// @github https://github.com/BlackPaw21 -// @include windhawk.exe -// @compilerOptions -lpdh -lshell32 -lgdi32 -luser32 -lole32 -luuid -DUNICODE -D_UNICODE -// ==/WindhawkMod== - -// ==WindhawkModSettings== -/* -- updateInterval: 2 - $name: Update Interval (s) - $description: How often to refresh CPU/GPU stats - $options: - - 1: Fast (1s) - - 2: Normal (2s) - - 3: Relaxed (3s) - - 5: Slow (5s) -*/ -// ==/WindhawkModSettings== - -// ==WindhawkModReadme== -/* -# MicroManager - -A lightweight tray icon that shows a mini task manager popup with live CPU and GPU usage stats. - -## How to Use - -1. **Left-click** the tray icon to open the popup showing: - - Total CPU usage and the top CPU-consuming process - - Total GPU usage and the top GPU-consuming process -2. **Click again** to close it -3. **Right-click** the tray icon for options - -## Configuration - -The update interval can be adjusted in the Settings tab. - -## Changelog - -### v1.1.0 -- Fixed: boilerplate corrected so the mod actually runs -- Fixed: "Open WindHawk" right-click item now works -- Fixed: popup destroyed on the correct thread during shutdown -- Fixed: CoInitialize/CoUninitialize now properly paired on tray thread -- Fixed: tray icon reappears after Explorer restart (TaskbarCreated) -- Fixed: tray window style changed to WS_POPUP / WS_EX_TOOLWINDOW -- Fixed: AUMID set so icon doesn't group with Windhawk's own icon -- Fixed: popup class unregistered on mod uninit for clean reload -- Fixed: ddores.dll loaded from full system32 path -- Fixed: GPU PDH counter primed on init so first tick shows real data - -### v1.0.0 -- Initial release. -*/ -// ==/WindhawkModReadme== - -#define NOMINMAX -#include -#include -#include -#include -#include -#include -#include -#include -#include - -// PDH constants that may not be defined in all SDK versions -#ifndef PDH_MORE_DATA -#define PDH_MORE_DATA ((PDH_STATUS)0x800007D2L) -#endif -#ifndef PDH_CSTATUS_VALID_DATA -#define PDH_CSTATUS_VALID_DATA ((LONG)0x00000000L) -#endif - -#ifndef MIN -#define MIN(a, b) (((a) < (b)) ? (a) : (b)) -#endif - -// ─── Constants ──────────────────────────────────────────────────────────────── - -#define TRAY_ICON_ID 1 -#define WM_TRAY_CALLBACK (WM_USER + 1) -#define WM_REFRESH_DATA (WM_USER + 2) -#define WM_UPDATE_POPUP (WM_USER + 3) -#define WM_TIMER_ID 1 - -#define POPUP_WIDTH 360 -#define POPUP_HEIGHT 80 - -#define MAX_PROCESSES 1024 -#define PROCESS_BUF_SIZE (512 * 1024) - -#define MENU_OPEN_WINDHAWK 9000 - -// ─── NtQuerySystemInformation via dynamic load ──────────────────────────────── - -#ifndef UNICODE_STRING -typedef struct _UNICODE_STRING { - USHORT Length; - USHORT MaximumLength; - PWSTR Buffer; -} UNICODE_STRING; -#endif - -typedef LONG NTSTATUS; - -#define STATUS_INFO_LENGTH_MISMATCH ((NTSTATUS)0xC0000004L) -#define SystemProcessInformation 5 - -typedef struct { - ULONG NextEntryOffset; - ULONG NumberOfThreads; - LARGE_INTEGER WorkingSetPrivateSize; - ULONG HardFaultCount; - ULONG NumberOfThreadsHighWatermark; - LARGE_INTEGER CycleTime; - LARGE_INTEGER CreateTime; - LARGE_INTEGER UserTime; - LARGE_INTEGER KernelTime; - UNICODE_STRING ImageName; - LONG BasePriority; - HANDLE UniqueProcessId; - HANDLE InheritedFromUniqueProcessId; - ULONG HandleCount; - ULONG SessionId; - ULONG_PTR Reserved1; - SIZE_T PeakVirtualSize; - SIZE_T VirtualSize; - ULONG PageFaultCount; - SIZE_T PeakWorkingSetSize; - SIZE_T WorkingSetSize; - SIZE_T QuotaPeakPagedPoolUsage; - SIZE_T QuotaPagedPoolUsage; - SIZE_T QuotaPeakNonPagedPoolUsage; - SIZE_T QuotaNonPagedPoolUsage; - SIZE_T PagefileUsage; - SIZE_T PeakPagefileUsage; - SIZE_T PrivatePageCount; - LARGE_INTEGER ReadOperationCount; - LARGE_INTEGER WriteOperationCount; - LARGE_INTEGER OtherOperationCount; - LARGE_INTEGER ReadTransferCount; - LARGE_INTEGER WriteTransferCount; - LARGE_INTEGER OtherTransferCount; -} MY_SYSTEM_PROCESS_INFO; - -typedef NTSTATUS (WINAPI *NtQuerySystemInformation_t)(ULONG, PVOID, ULONG, PULONG); - -// ─── Globals ────────────────────────────────────────────────────────────────── - -static HANDLE g_trayThread = nullptr; -static volatile HWND g_trayHwnd = nullptr; -static HWND g_popupHwnd = nullptr; -static HINSTANCE g_hInstance = nullptr; -static WCHAR g_windhawkPath[MAX_PATH] = {}; -static WCHAR g_ddoresDllPath[MAX_PATH] = {}; - -static DWORD g_updateMs = 2000; - -// Cached stats (updated on timer, read on popup paint) -static CRITICAL_SECTION g_statsLock; -static int g_totalCpu = -1; -static int g_topCpuPct = 0; -static WCHAR g_topCpuName[64] = {}; -static int g_totalGpu = -1; -static int g_topGpuPct = 0; -static WCHAR g_topGpuName[64] = {}; - -// GPU PDH handles -static PDH_HQUERY g_gpuQuery = nullptr; -static PDH_HCOUNTER g_gpuCounter = nullptr; -static BOOL g_gpuFailed = FALSE; - -// Previous sample for CPU delta -static FILETIME g_prevIdle = {}; -static FILETIME g_prevKernel = {}; -static FILETIME g_prevUser = {}; -static struct { - DWORD pid; - LONGLONG time; - WCHAR name[64]; -} g_prevProcs[MAX_PROCESSES]; -static int g_prevProcCount = 0; -static BOOL g_hasPrevSample = FALSE; - -static HICON g_iconEnabled = nullptr; -static HFONT g_hPopupFont = nullptr; - -static UINT g_taskbarCreatedMsg = 0; - -// ─── Font Helpers ───────────────────────────────────────────────────────────── - -static void EnsureFont() { - if (!g_hPopupFont) { - g_hPopupFont = CreateFontW(-13, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, - ANSI_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, - DEFAULT_QUALITY, DEFAULT_PITCH | FF_DONTCARE, L"Segoe UI"); - } -} - -// ─── CPU Sampling ───────────────────────────────────────────────────────────── - -static NtQuerySystemInformation_t LoadNtQuery() { - HMODULE h = GetModuleHandleW(L"ntdll.dll"); - if (!h) return nullptr; - return (NtQuerySystemInformation_t)GetProcAddress(h, "NtQuerySystemInformation"); -} - -static int CollectProcessInfo(MY_SYSTEM_PROCESS_INFO** outBuf) { - NtQuerySystemInformation_t NtQuery = LoadNtQuery(); - if (!NtQuery) return 0; - - ULONG bufSize = PROCESS_BUF_SIZE; - *outBuf = (MY_SYSTEM_PROCESS_INFO*)malloc(bufSize); - if (!*outBuf) return 0; - - NTSTATUS status = NtQuery(SystemProcessInformation, *outBuf, bufSize, &bufSize); - if (status == STATUS_INFO_LENGTH_MISMATCH) { - free(*outBuf); - bufSize *= 2; - *outBuf = (MY_SYSTEM_PROCESS_INFO*)malloc(bufSize); - if (!*outBuf) return 0; - status = NtQuery(SystemProcessInformation, *outBuf, bufSize, &bufSize); - } - if (status < 0) { - free(*outBuf); - *outBuf = nullptr; - return 0; - } - return 1; -} - -// ─── GPU Sampling (PDH) ─────────────────────────────────────────────────────── - -static void InitGpuQuery() { - if (g_gpuQuery) return; - if (g_gpuFailed) return; - - PDH_STATUS ps = PdhOpenQueryW(nullptr, 0, &g_gpuQuery); - if (ps != ERROR_SUCCESS) { g_gpuFailed = TRUE; return; } - - ps = PdhAddEnglishCounterW(g_gpuQuery, - L"\\GPU Engine(*)\\Utilization Percentage", 0, &g_gpuCounter); - if (ps != ERROR_SUCCESS) { - PdhCloseQuery(g_gpuQuery); - g_gpuQuery = nullptr; - g_gpuFailed = TRUE; - return; - } - - // Prime the baseline — PDH rate counters return 0 on the very first collection - // without a prior sample to delta against. One collection here means the first - // real tick in RefreshData produces accurate data instead of 0%. - PdhCollectQueryData(g_gpuQuery); -} - -static void CollectGpuStats(int* outTotal, int* outTopPct, WCHAR* outTopName, int nameLen) { - *outTotal = -1; - *outTopPct = 0; - outTopName[0] = L'\0'; - - if (!g_gpuQuery || g_gpuFailed) return; - - PdhCollectQueryData(g_gpuQuery); - - DWORD bufSize = 0; - DWORD itemCount = 0; - PDH_STATUS ps = PdhGetFormattedCounterArrayW(g_gpuCounter, PDH_FMT_DOUBLE, - &bufSize, &itemCount, nullptr); - if (ps != PDH_MORE_DATA || bufSize == 0 || itemCount == 0) return; - - PDH_FMT_COUNTERVALUE_ITEM_W* items = (PDH_FMT_COUNTERVALUE_ITEM_W*)malloc(bufSize); - if (!items) return; - - ps = PdhGetFormattedCounterArrayW(g_gpuCounter, PDH_FMT_DOUBLE, - &bufSize, &itemCount, items); - if (ps != ERROR_SUCCESS) { free(items); return; } - - // Aggregate per PID - struct GpuProc { DWORD pid; double total; }; - GpuProc procs[MAX_PROCESSES]; - int procCount = 0; - double grandTotal = 0; - - for (DWORD i = 0; i < itemCount; i++) { - if (items[i].FmtValue.CStatus != PDH_CSTATUS_VALID_DATA) continue; - - double val = items[i].FmtValue.doubleValue; - grandTotal += val; - - // Parse instance: "pid_1234_luid_0x00000000_phys_0_eng_0_enum_1" - PCWSTR s = items[i].szName; - if (wcsncmp(s, L"pid_", 4) != 0) continue; - DWORD pid = 0; - for (s += 4; *s >= L'0' && *s <= L'9'; s++) - pid = pid * 10 + (*s - L'0'); - - int j; - for (j = 0; j < procCount; j++) { - if (procs[j].pid == pid) { procs[j].total += val; break; } - } - if (j >= procCount && procCount < MAX_PROCESSES) { - procs[procCount].pid = pid; - procs[procCount].total = val; - procCount++; - } - } - free(items); - - *outTotal = (int)(grandTotal + 0.5); - - // Find top GPU process - int topIdx = -1; - double topVal = 0; - for (int i = 0; i < procCount; i++) { - if (procs[i].total > topVal) { - topVal = procs[i].total; - topIdx = i; - } - } - if (topIdx >= 0) { - *outTopPct = (int)(topVal + 0.5); - - HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); - if (snap != INVALID_HANDLE_VALUE) { - PROCESSENTRY32W pe = {sizeof(pe)}; - if (Process32FirstW(snap, &pe)) { - do { - if (pe.th32ProcessID == procs[topIdx].pid) { - wcscpy_s(outTopName, nameLen, pe.szExeFile); - break; - } - } while (Process32NextW(snap, &pe)); - } - CloseHandle(snap); - } - } -} - -// ─── Data Refresh ───────────────────────────────────────────────────────────── - -static void RefreshData() { - int newTotalCpu = -1, newTopCpuPct = 0; - WCHAR newTopCpuName[64] = {}; - int newTotalGpu = -1, newTopGpuPct = 0; - WCHAR newTopGpuName[64] = {}; - - // Collect CPU - FILETIME nowIdle, nowKernel, nowUser; - GetSystemTimes(&nowIdle, &nowKernel, &nowUser); - - ULARGE_INTEGER ui, uk, uu, pi, pk, pu; - ui.LowPart = nowIdle.dwLowDateTime; ui.HighPart = nowIdle.dwHighDateTime; - uk.LowPart = nowKernel.dwLowDateTime; uk.HighPart = nowKernel.dwHighDateTime; - uu.LowPart = nowUser.dwLowDateTime; uu.HighPart = nowUser.dwHighDateTime; - pi.LowPart = g_prevIdle.dwLowDateTime; pi.HighPart = g_prevIdle.dwHighDateTime; - pk.LowPart = g_prevKernel.dwLowDateTime; pk.HighPart = g_prevKernel.dwHighDateTime; - pu.LowPart = g_prevUser.dwLowDateTime; pu.HighPart = g_prevUser.dwHighDateTime; - - double totalDelta = (double)(uk.QuadPart - pk.QuadPart) + (double)(uu.QuadPart - pu.QuadPart); - double idleDelta = (double)(ui.QuadPart - pi.QuadPart); - - if (g_hasPrevSample && totalDelta > 0) { - newTotalCpu = (int)(100.0 - 100.0 * idleDelta / totalDelta + 0.5); - - MY_SYSTEM_PROCESS_INFO* buf = nullptr; - if (CollectProcessInfo(&buf) && buf) { - NtQuerySystemInformation_t NtQuery = LoadNtQuery(); - if (NtQuery) { - MY_SYSTEM_PROCESS_INFO* p = buf; - LONGLONG bestTime = 0; - WCHAR bestName[64] = {}; - int count = 0; - - while (true) { - LONGLONG curTime = p->KernelTime.QuadPart + p->UserTime.QuadPart; - - LONGLONG prevTime = 0; - for (int i = 0; i < g_prevProcCount; i++) { - if (g_prevProcs[i].pid == (DWORD)(ULONG_PTR)p->UniqueProcessId) { - prevTime = g_prevProcs[i].time; - break; - } - } - LONGLONG delta = curTime - prevTime; - if (delta > bestTime && p->ImageName.Buffer) { - bestTime = delta; - wcsncpy_s(bestName, p->ImageName.Buffer, - MIN(p->ImageName.Length / sizeof(WCHAR), 63)); - bestName[63] = L'\0'; - } - - if (count < MAX_PROCESSES) { - g_prevProcs[count].pid = (DWORD)(ULONG_PTR)p->UniqueProcessId; - g_prevProcs[count].time = curTime; - if (p->ImageName.Buffer) { - wcsncpy_s(g_prevProcs[count].name, p->ImageName.Buffer, - MIN(p->ImageName.Length / sizeof(WCHAR), 63)); - g_prevProcs[count].name[63] = L'\0'; - } else { - g_prevProcs[count].name[0] = L'\0'; - } - count++; - } - - if (p->NextEntryOffset == 0) break; - p = (MY_SYSTEM_PROCESS_INFO*)((BYTE*)p + p->NextEntryOffset); - } - g_prevProcCount = count; - - if (bestTime > 0) { - double pct = 100.0 * (double)bestTime / totalDelta; - if (pct >= 0.5) { - newTopCpuPct = (int)(pct + 0.5); - wcscpy_s(newTopCpuName, bestName); - } - } - } - free(buf); - } - } - - g_prevIdle = nowIdle; g_prevKernel = nowKernel; g_prevUser = nowUser; - g_hasPrevSample = TRUE; - - CollectGpuStats(&newTotalGpu, &newTopGpuPct, newTopGpuName, 64); - - EnterCriticalSection(&g_statsLock); - g_totalCpu = newTotalCpu; - g_topCpuPct = newTopCpuPct; - wcscpy_s(g_topCpuName, newTopCpuName); - g_totalGpu = newTotalGpu; - g_topGpuPct = newTopGpuPct; - wcscpy_s(g_topGpuName, newTopGpuName); - LeaveCriticalSection(&g_statsLock); - - if (g_popupHwnd && IsWindowVisible(g_popupHwnd)) { - InvalidateRect(g_popupHwnd, nullptr, TRUE); - } -} - -// ─── Popup Window Procedure ─────────────────────────────────────────────────── - -static LRESULT CALLBACK PopupWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { - switch (msg) { - case WM_ACTIVATE: - if (LOWORD(wParam) == WA_INACTIVE) { - ShowWindow(hWnd, SW_HIDE); - } - return 0; - - case WM_PAINT: { - PAINTSTRUCT ps; - HDC hdc = BeginPaint(hWnd, &ps); - - HBRUSH bgBrush = CreateSolidBrush(RGB(32, 32, 32)); - RECT rc; - GetClientRect(hWnd, &rc); - FillRect(hdc, &rc, bgBrush); - DeleteObject(bgBrush); - - HPEN borderPen = CreatePen(PS_SOLID, 1, RGB(64, 64, 64)); - HPEN oldPen = (HPEN)SelectObject(hdc, borderPen); - HBRUSH oldBrush = (HBRUSH)SelectObject(hdc, GetStockObject(NULL_BRUSH)); - Rectangle(hdc, 0, 0, rc.right, rc.bottom); - SelectObject(hdc, oldPen); - SelectObject(hdc, oldBrush); - DeleteObject(borderPen); - - SetBkMode(hdc, TRANSPARENT); - EnsureFont(); - SelectObject(hdc, g_hPopupFont); - - EnterCriticalSection(&g_statsLock); - - for (int row = 0; row < 2; row++) { - int y = 12 + row * 32; - BOOL isCpu = (row == 0); - - PCWSTR label = isCpu ? L"CPU" : L"GPU"; - int totalVal = isCpu ? g_totalCpu : g_totalGpu; - int topPctVal = isCpu ? g_topCpuPct : g_topGpuPct; - PCWSTR topName = isCpu ? g_topCpuName : g_topGpuName; - - COLORREF labelColor = RGB(140, 140, 140); - COLORREF totalColor = isCpu ? RGB(0, 200, 255) : RGB(0, 220, 100); - COLORREF valueColor = RGB(220, 220, 220); - - SetTextColor(hdc, labelColor); - WCHAR labelBuf[16]; - swprintf_s(labelBuf, L"%s:", label); - TextOutW(hdc, 12, y, labelBuf, (int)wcslen(labelBuf)); - - SetTextColor(hdc, totalColor); - WCHAR totalBuf[16]; - if (totalVal < 0) - swprintf_s(totalBuf, L"..%%"); - else - swprintf_s(totalBuf, L"%d%%", totalVal); - TextOutW(hdc, 60, y, totalBuf, (int)wcslen(totalBuf)); - - SetTextColor(hdc, valueColor); - if (topName[0] != L'\0' && topPctVal > 0) { - WCHAR lineBuf[256]; - swprintf_s(lineBuf, L"%s %d%%", topName, topPctVal); - TextOutW(hdc, 130, y, lineBuf, (int)wcslen(lineBuf)); - } else if (totalVal >= 0) { - PCWSTR idle = L"Idle"; - TextOutW(hdc, 130, y, idle, (int)wcslen(idle)); - } else { - PCWSTR collecting = L"Collecting..."; - TextOutW(hdc, 130, y, collecting, (int)wcslen(collecting)); - } - } - - LeaveCriticalSection(&g_statsLock); - - EndPaint(hWnd, &ps); - return 0; - } - - case WM_NCHITTEST: { - LRESULT hit = DefWindowProcW(hWnd, msg, wParam, lParam); - if (hit == HTCLIENT) return HTCAPTION; - return hit; - } - - case WM_KEYDOWN: - if (wParam == VK_ESCAPE) ShowWindow(hWnd, SW_HIDE); - break; - - case WM_DESTROY: - break; - } - return DefWindowProcW(hWnd, msg, wParam, lParam); -} - -// ─── Show/Hide Popup ────────────────────────────────────────────────────────── - -static void ShowPopup(HWND hTrayWnd) { - if (!g_popupHwnd) { - WNDCLASSW wc = {0}; - wc.lpfnWndProc = PopupWndProc; - wc.hInstance = g_hInstance; - wc.hCursor = LoadCursorW(nullptr, IDC_ARROW); - wc.lpszClassName = L"MicroManagerPopupClass"; - wc.hbrBackground = (HBRUSH)GetStockObject(NULL_BRUSH); - RegisterClassW(&wc); - - g_popupHwnd = CreateWindowExW( - WS_EX_TOOLWINDOW | WS_EX_TOPMOST, - wc.lpszClassName, L"MicroManager", - WS_POPUP | WS_BORDER, - 0, 0, POPUP_WIDTH, POPUP_HEIGHT, - nullptr, nullptr, g_hInstance, nullptr); - } - - if (!g_popupHwnd) return; - - NOTIFYICONIDENTIFIER nii = {sizeof(nii)}; - nii.hWnd = hTrayWnd; - nii.uID = TRAY_ICON_ID; - RECT iconRect; - int x, y; - - if (SUCCEEDED(Shell_NotifyIconGetRect(&nii, &iconRect))) { - x = iconRect.left - POPUP_WIDTH + (iconRect.right - iconRect.left) / 2; - y = iconRect.top - POPUP_HEIGHT - 4; - } else { - POINT pt; - GetCursorPos(&pt); - x = pt.x - POPUP_WIDTH / 2; - y = pt.y - POPUP_HEIGHT - 4; - } - - SetWindowPos(g_popupHwnd, HWND_TOPMOST, x, y, POPUP_WIDTH, POPUP_HEIGHT, - SWP_SHOWWINDOW); - SetFocus(g_popupHwnd); -} - -// ─── Tray Window Procedure ──────────────────────────────────────────────────── - -static LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { - switch (msg) { - case WM_CREATE: - RefreshData(); - SetTimer(hWnd, WM_TIMER_ID, g_updateMs, nullptr); - return 0; - - case WM_TIMER: - if (wParam == WM_TIMER_ID) { - RefreshData(); - } - return 0; - - case WM_TRAY_CALLBACK: - switch (LOWORD(lParam)) { - case WM_LBUTTONUP: - if (g_popupHwnd && IsWindowVisible(g_popupHwnd)) { - ShowWindow(g_popupHwnd, SW_HIDE); - } else { - ShowPopup(hWnd); - } - break; - - case WM_RBUTTONUP: { - HMENU hMenu = CreatePopupMenu(); - AppendMenuW(hMenu, MF_STRING, MENU_OPEN_WINDHAWK, L"Open WindHawk"); - AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); - - WCHAR statusText[128]; - EnterCriticalSection(&g_statsLock); - int cpu = g_totalCpu; - int gpu = g_totalGpu; - LeaveCriticalSection(&g_statsLock); - - if (cpu >= 0 && gpu >= 0) - swprintf_s(statusText, L"CPU: %d%% GPU: %d%%", cpu, gpu); - else - lstrcpyW(statusText, L"Collecting..."); - AppendMenuW(hMenu, MF_STRING | MF_GRAYED, 0, statusText); - - POINT pt; - GetCursorPos(&pt); - SetForegroundWindow(hWnd); - // TPM_RETURNCMD returns the selected ID directly — no WM_COMMAND is posted - int cmd = TrackPopupMenu(hMenu, TPM_RETURNCMD | TPM_RIGHTBUTTON | - TPM_BOTTOMALIGN | TPM_RIGHTALIGN, - pt.x, pt.y, 0, hWnd, nullptr); - PostMessageW(hWnd, WM_NULL, 0, 0); - DestroyMenu(hMenu); - - if (cmd == MENU_OPEN_WINDHAWK) { - SHELLEXECUTEINFOW sei = {sizeof(sei)}; - sei.lpFile = g_windhawkPath; - sei.nShow = SW_SHOWNORMAL; - ShellExecuteExW(&sei); - } - break; - } - } - return 0; - - case WM_CLOSE: - // Destroy popup on the tray thread (its owner) before destroying the tray window - if (g_popupHwnd) { DestroyWindow(g_popupHwnd); g_popupHwnd = nullptr; } - DestroyWindow(hWnd); - return 0; - - case WM_DESTROY: - KillTimer(hWnd, WM_TIMER_ID); - { - NOTIFYICONDATAW nid = {sizeof(nid)}; - nid.hWnd = hWnd; - nid.uID = TRAY_ICON_ID; - Shell_NotifyIconW(NIM_DELETE, &nid); - } - PostQuitMessage(0); - return 0; - } - - // Re-add tray icon after Explorer restarts - if (msg == g_taskbarCreatedMsg && g_taskbarCreatedMsg != 0) { - NOTIFYICONDATAW nid = {sizeof(nid)}; - nid.hWnd = hWnd; - nid.uID = TRAY_ICON_ID; - nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP; - nid.uCallbackMessage = WM_TRAY_CALLBACK; - lstrcpynW(nid.szTip, L"MicroManager", 128); - nid.hIcon = g_iconEnabled ? g_iconEnabled : LoadIconW(nullptr, IDI_APPLICATION); - Shell_NotifyIconW(NIM_ADD, &nid); - return 0; - } - - return DefWindowProcW(hWnd, msg, wParam, lParam); -} - -// ─── Tray Thread ────────────────────────────────────────────────────────────── - -static DWORD WINAPI TrayThreadProc(LPVOID) { - CoInitialize(nullptr); - g_taskbarCreatedMsg = RegisterWindowMessageW(L"TaskbarCreated"); - - WNDCLASSW wc = {0}; - wc.lpfnWndProc = TrayWndProc; - wc.hInstance = g_hInstance; - wc.lpszClassName = L"MicroManagerTrayClass"; - RegisterClassW(&wc); - - g_trayHwnd = CreateWindowExW( - WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, - wc.lpszClassName, L"MicroManager", - WS_POPUP, - 0, 0, 1, 1, nullptr, nullptr, g_hInstance, nullptr); - if (!g_trayHwnd) { - CoUninitialize(); - return 1; - } - - // Unique AUMID so the OS doesn't group this icon with Windhawk's main window - IPropertyStore* pps = nullptr; - if (SUCCEEDED(SHGetPropertyStoreForWindow(g_trayHwnd, IID_PPV_ARGS(&pps)))) { - PROPVARIANT var; - PropVariantInit(&var); - var.vt = VT_LPWSTR; - var.pwszVal = (LPWSTR)CoTaskMemAlloc(MAX_PATH * sizeof(WCHAR)); - if (var.pwszVal) { - lstrcpyW(var.pwszVal, L"BlackPaw.MicroManager"); - pps->SetValue(PKEY_AppUserModel_ID, var); - CoTaskMemFree(var.pwszVal); - } - pps->Commit(); - pps->Release(); - } - - NOTIFYICONDATAW nid = {sizeof(nid)}; - nid.hWnd = g_trayHwnd; - nid.uID = TRAY_ICON_ID; - nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP; - nid.uCallbackMessage = WM_TRAY_CALLBACK; - lstrcpynW(nid.szTip, L"MicroManager", 128); - nid.hIcon = g_iconEnabled ? g_iconEnabled : LoadIconW(nullptr, IDI_APPLICATION); - Shell_NotifyIconW(NIM_ADD, &nid); - - MSG msg; - while (GetMessageW(&msg, nullptr, 0, 0)) { - TranslateMessage(&msg); - DispatchMessageW(&msg); - } - - g_trayHwnd = nullptr; - CoUninitialize(); - return 0; -} - -// ─── Windhawk Callbacks ─────────────────────────────────────────────────────── - -BOOL WhTool_ModInit() { - Wh_Log(L"MicroManager Init"); - - InitializeCriticalSection(&g_statsLock); - - g_hInstance = GetModuleHandleW(nullptr); - GetModuleFileNameW(g_hInstance, g_windhawkPath, MAX_PATH); - - // Full path for ddores.dll — ExtractIconExW handles the .mun redirect on Win11 - UINT sysLen = GetSystemDirectoryW(g_ddoresDllPath, MAX_PATH); - if (sysLen > 0 && sysLen < MAX_PATH - 12) - lstrcatW(g_ddoresDllPath, L"\\ddores.dll"); - else - lstrcpyW(g_ddoresDllPath, L"ddores.dll"); - - ExtractIconExW(g_ddoresDllPath, 28, nullptr, &g_iconEnabled, 1); - - InitGpuQuery(); - - // Baseline CPU sample so the first timer tick has a delta to work from - GetSystemTimes(&g_prevIdle, &g_prevKernel, &g_prevUser); - MY_SYSTEM_PROCESS_INFO* initBuf = nullptr; - if (CollectProcessInfo(&initBuf) && initBuf) { - NtQuerySystemInformation_t NtQuery = LoadNtQuery(); - if (NtQuery) { - MY_SYSTEM_PROCESS_INFO* p = initBuf; - int count = 0; - while (count < MAX_PROCESSES) { - g_prevProcs[count].pid = (DWORD)(ULONG_PTR)p->UniqueProcessId; - g_prevProcs[count].time = p->KernelTime.QuadPart + p->UserTime.QuadPart; - if (p->ImageName.Buffer) { - wcsncpy_s(g_prevProcs[count].name, p->ImageName.Buffer, - MIN(p->ImageName.Length / sizeof(WCHAR), 63)); - g_prevProcs[count].name[63] = L'\0'; - } else { - g_prevProcs[count].name[0] = L'\0'; - } - count++; - if (p->NextEntryOffset == 0) break; - p = (MY_SYSTEM_PROCESS_INFO*)((BYTE*)p + p->NextEntryOffset); - } - g_prevProcCount = count; - } - free(initBuf); - } - - g_trayThread = CreateThread(nullptr, 0, TrayThreadProc, nullptr, 0, nullptr); - return TRUE; -} - -void WhTool_ModSettingsChanged() { - PCWSTR s = Wh_GetStringSetting(L"updateInterval"); - if (s) { - DWORD newSec = _wtoi(s); - Wh_FreeStringSetting(s); - DWORD newMs = newSec * 1000; - if (newMs >= 100 && newMs <= 60000) { - g_updateMs = newMs; - HWND hwnd = g_trayHwnd; - if (hwnd) { - KillTimer(hwnd, WM_TIMER_ID); - SetTimer(hwnd, WM_TIMER_ID, newMs, nullptr); - } - } - } -} - -void WhTool_ModUninit() { - Wh_Log(L"MicroManager Mod Uninit"); - - // WM_CLOSE handler on the tray thread destroys g_popupHwnd before the tray - // window itself — no cross-thread DestroyWindow needed here - if (g_trayHwnd) PostMessageW(g_trayHwnd, WM_CLOSE, 0, 0); - if (g_trayThread) { - WaitForSingleObject(g_trayThread, 3000); - CloseHandle(g_trayThread); - g_trayThread = nullptr; - } - - // Unregister popup class after the tray thread has fully exited so a - // subsequent mod reload can re-register it cleanly - UnregisterClassW(L"MicroManagerPopupClass", g_hInstance); - - if (g_iconEnabled) { DestroyIcon(g_iconEnabled); g_iconEnabled = nullptr; } - if (g_hPopupFont) { DeleteObject(g_hPopupFont); g_hPopupFont = nullptr; } - if (g_gpuQuery) { PdhCloseQuery(g_gpuQuery); g_gpuQuery = nullptr; g_gpuCounter = nullptr; } - - DeleteCriticalSection(&g_statsLock); -} - -//////////////////////////////////////////////////////////////////////////////// -// Windhawk tool mod implementation for mods which don't need to inject to other -// processes or hook other functions. Context: -// https://github.com/ramensoftware/windhawk/wiki/Mods-as-tools:-Running-mods-in-a-dedicated-process -// -// The mod will load and run in a dedicated windhawk.exe process. -// -// Paste the code below as part of the mod code, and use these callbacks: -// * WhTool_ModInit -// * WhTool_ModSettingsChanged -// * WhTool_ModUninit -// -// Currently, other callbacks are not supported. - -bool g_isToolModProcessLauncher; -HANDLE g_toolModProcessMutex; - -void WINAPI EntryPoint_Hook() { - Wh_Log(L">"); - ExitThread(0); -} - -BOOL Wh_ModInit() { - DWORD sessionId; - if (ProcessIdToSessionId(GetCurrentProcessId(), &sessionId) && - sessionId == 0) { - return FALSE; - } - - bool isExcluded = false; - bool isToolModProcess = false; - bool isCurrentToolModProcess = false; - int argc; - LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc); - if (!argv) { - Wh_Log(L"CommandLineToArgvW failed"); - return FALSE; - } - - for (int i = 1; i < argc; i++) { - if (wcscmp(argv[i], L"-service") == 0 || - wcscmp(argv[i], L"-service-start") == 0 || - wcscmp(argv[i], L"-service-stop") == 0) { - isExcluded = true; - break; - } - } - - for (int i = 1; i < argc - 1; i++) { - if (wcscmp(argv[i], L"-tool-mod") == 0) { - isToolModProcess = true; - if (wcscmp(argv[i + 1], WH_MOD_ID) == 0) { - isCurrentToolModProcess = true; - } - break; - } - } - - LocalFree(argv); - - if (isExcluded) { - return FALSE; - } - - if (isCurrentToolModProcess) { - g_toolModProcessMutex = - CreateMutexW(nullptr, TRUE, L"windhawk-tool-mod_" WH_MOD_ID); - if (!g_toolModProcessMutex) { - Wh_Log(L"CreateMutex failed"); - ExitProcess(1); - } - - if (GetLastError() == ERROR_ALREADY_EXISTS) { - Wh_Log(L"Tool mod already running (%s)", WH_MOD_ID); - ExitProcess(1); - } - - if (!WhTool_ModInit()) { - ExitProcess(1); - } - - IMAGE_DOS_HEADER* dosHeader = - (IMAGE_DOS_HEADER*)GetModuleHandleW(nullptr); - IMAGE_NT_HEADERS* ntHeaders = - (IMAGE_NT_HEADERS*)((BYTE*)dosHeader + dosHeader->e_lfanew); - - DWORD entryPointRVA = ntHeaders->OptionalHeader.AddressOfEntryPoint; - void* entryPoint = (BYTE*)dosHeader + entryPointRVA; - - Wh_SetFunctionHook(entryPoint, (void*)EntryPoint_Hook, nullptr); - return TRUE; - } - - if (isToolModProcess) { - return FALSE; - } - - g_isToolModProcessLauncher = true; - return TRUE; -} - -void Wh_ModAfterInit() { - if (!g_isToolModProcessLauncher) { - return; - } - - WCHAR currentProcessPath[MAX_PATH]; - switch (GetModuleFileNameW(nullptr, currentProcessPath, - ARRAYSIZE(currentProcessPath))) { - case 0: - case ARRAYSIZE(currentProcessPath): - Wh_Log(L"GetModuleFileName failed"); - return; - } - - WCHAR - commandLine[MAX_PATH + 2 + - (sizeof(L" -tool-mod \"" WH_MOD_ID "\"") / sizeof(WCHAR)) - 1]; - swprintf_s(commandLine, L"\"%s\" -tool-mod \"%s\"", currentProcessPath, - WH_MOD_ID); - - HMODULE kernelModule = GetModuleHandleW(L"kernelbase.dll"); - if (!kernelModule) { - kernelModule = GetModuleHandleW(L"kernel32.dll"); - if (!kernelModule) { - Wh_Log(L"No kernelbase.dll/kernel32.dll"); - return; - } - } - - using CreateProcessInternalW_t = BOOL(WINAPI*)( - HANDLE hUserToken, LPCWSTR lpApplicationName, LPWSTR lpCommandLine, - LPSECURITY_ATTRIBUTES lpProcessAttributes, - LPSECURITY_ATTRIBUTES lpThreadAttributes, WINBOOL bInheritHandles, - DWORD dwCreationFlags, LPVOID lpEnvironment, LPCWSTR lpCurrentDirectory, - LPSTARTUPINFOW lpStartupInfo, - LPPROCESS_INFORMATION lpProcessInformation, - PHANDLE hRestrictedUserToken); - CreateProcessInternalW_t pCreateProcessInternalW = - (CreateProcessInternalW_t)GetProcAddress(kernelModule, - "CreateProcessInternalW"); - if (!pCreateProcessInternalW) { - Wh_Log(L"No CreateProcessInternalW"); - return; - } - - STARTUPINFOW si{ - .cb = sizeof(STARTUPINFOW), - .dwFlags = STARTF_FORCEOFFFEEDBACK, - }; - PROCESS_INFORMATION pi; - if (!pCreateProcessInternalW(nullptr, currentProcessPath, commandLine, - nullptr, nullptr, FALSE, NORMAL_PRIORITY_CLASS, - nullptr, nullptr, &si, &pi, nullptr)) { - Wh_Log(L"CreateProcess failed"); - return; - } - - CloseHandle(pi.hProcess); - CloseHandle(pi.hThread); -} - -void Wh_ModSettingsChanged() { - if (g_isToolModProcessLauncher) { - return; - } - - WhTool_ModSettingsChanged(); -} - -void Wh_ModUninit() { - if (g_isToolModProcessLauncher) { - return; - } - - WhTool_ModUninit(); - ExitProcess(0); -} From caeeed5c5fa85f715e5e6afdb3467b1ab7d6cbbe Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Mon, 18 May 2026 19:21:06 +0300 Subject: [PATCH 28/56] fixed startup issues fixed issue where scrolling does not work until the icon is click every reboot. Updated patch notes and improved icon handling. --- mods/audioswap.wh.cpp | 67 +++++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 15 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index d612623ed3..1c5bb15d4a 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -44,6 +44,11 @@ Instantly cycle between multiple audio output devices from your system tray — ### v1.4.0 - Custom .ico support — selecting "Custom Icon" in settings now auto-opens a file picker; also available via right-click → "Custom Icon for Device X..." - Custom icon paths stored internally (no separate text setting in the UI). +- Patch notes: Fixed an issue where Scroll to Swap didn't work after a reboot until the icon was clicked. +- Patch notes: Fixed a crash that occurred when using audio devices with very long names. +- Patch notes: Fixed a bug where adding more devices in settings didn't take effect immediately. +- Patch notes: Fixed a minor issue where cycling devices would sometimes skip the first slot. +- Patch notes: Improved reliability of the mute toggle and overall system stability. ### v1.3.0 - Up to 6 devices; scroll-to-swap mode; dynamic right-click menu. @@ -196,6 +201,7 @@ Instantly cycle between multiple audio output devices from your system tray — #define WM_UPDATE_HOOK_STATE (WM_USER + 4) #define WM_SHOW_FILE_PICKER (WM_USER + 5) // lParam = bitmask of slots needing pickers #define WM_RELOAD_ICONS (WM_USER + 6) // reload icons on tray thread (eliminates cross-thread handle race) +#define TRAY_RECT_INIT_TIMER 99 // one-shot retry timer for Shell_NotifyIconGetRect // Slot N uses menu IDs: N*100 + device_index (0..31). Max slot 6 → ID 631. #define MENU_SLOT_BASE 100 @@ -303,13 +309,8 @@ void LoadDeviceSelections() { WCHAR tmpIds[MAX_DEVICE_SLOTS][512] = {}; WCHAR tmpNames[MAX_DEVICE_SLOTS][256] = {}; - int count; - EnterCriticalSection(&g_stateLock); - count = g_deviceSlotCount; - LeaveCriticalSection(&g_stateLock); - WCHAR keyId[32], keyName[32]; - for (int i = 0; i < count; i++) { + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { swprintf_s(keyId, L"Device%dId", i + 1); swprintf_s(keyName, L"Device%dName", i + 1); Wh_GetStringValue(keyId, tmpIds[i], 512); @@ -419,7 +420,23 @@ static void RefreshTrayIconRect() { NOTIFYICONIDENTIFIER nii = {sizeof(nii)}; nii.hWnd = hwnd; nii.uID = TRAY_ICON_ID; - Shell_NotifyIconGetRect(&nii, &g_trayIconRect); + RECT newRect = {}; + if (SUCCEEDED(Shell_NotifyIconGetRect(&nii, &newRect)) && + newRect.right > newRect.left) { + g_trayIconRect = newRect; + KillTimer(hwnd, TRAY_RECT_INIT_TIMER); // rect valid — stop retrying + } else { + // Explorer hasn't assigned screen coordinates to the icon yet. + // Shell_NotifyIconGetRect is a query — it won't force Explorer to + // finish its layout pass. Send NIM_MODIFY to give Explorer the + // necessary nudge to compute the icon's position. + NOTIFYICONDATAW nid = {sizeof(nid)}; + nid.hWnd = hwnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_SHOWTIP; + Shell_NotifyIconW(NIM_MODIFY, &nid); + SetTimer(hwnd, TRAY_RECT_INIT_TIMER, 200, nullptr); + } } // ─── Mute helpers ───────────────────────────────────────────────────────────── @@ -774,11 +791,11 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { nid.uCallbackMessage = WM_TRAY_CALLBACK; if (configuredCount < 2) - swprintf_s(nid.szTip, L"AudioSwap: Right-click to configure"); + swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"AudioSwap: Right-click to configure"); else if (localMuted) - swprintf_s(nid.szTip, L"Audio: %s (Muted)", currentDev); + swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Audio: %.100s (Muted)", currentDev); else - swprintf_s(nid.szTip, L"Audio: %s", currentDev); + swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Audio: %.110s", currentDev); HICON hOverlay = localMuted ? CreateMutedOverlayIcon(currentIcon) : nullptr; nid.hIcon = hOverlay ? hOverlay : currentIcon; @@ -847,7 +864,7 @@ BOOL CycleAudioDevice(int direction) { } } - int startSlot = (currentSlot >= 0) ? currentSlot : 0; + int startSlot = (currentSlot >= 0) ? currentSlot : (localCount - 1); int validSlot = -1; for (int tries = 1; tries <= localCount; tries++) { @@ -1068,7 +1085,12 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) if (msg == WM_TRAY_CALLBACK) { UINT event = LOWORD(lParam); // NOTIFYICON_VERSION_4: event in LOWORD(lParam) - if (event == WM_RBUTTONUP) { + if (event == WM_MOUSEMOVE) { + POINT pt = { (short)LOWORD(wParam), (short)HIWORD(wParam) }; + if (!PtInRect(&g_trayIconRect, pt)) { + RefreshTrayIconRect(); + } + } else if (event == WM_RBUTTONUP) { BuildAndShowContextMenu(hWnd); } else if (event == WM_LBUTTONUP && @@ -1087,8 +1109,11 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) } else if (event == WM_LBUTTONUP && InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 1) { // Scroll mode: left-click toggles mute (COM initialized on tray thread) - ToggleMuteCurrentDevice(); - PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + if (InterlockedExchange(&g_isProcessingClick, 1) == 0) { + ToggleMuteCurrentDevice(); + InterlockedExchange(&g_isProcessingClick, 0); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + } } } else if (msg == WM_TRAY_SCROLL) { @@ -1101,6 +1126,13 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) SpawnCycleThread(direction); } + } else if (msg == WM_TIMER && wParam == TRAY_RECT_INIT_TIMER) { + // Fired by RefreshTrayIconRect when Shell_NotifyIconGetRect returned empty. + // By now Explorer has had time to complete its paint cycle and assign real + // screen coordinates to the icon. RefreshTrayIconRect kills the timer on + // success, or re-arms it for another 200ms if Explorer is still not ready. + RefreshTrayIconRect(); + } else if (msg == WM_UPDATE_TRAY_STATE) { UpdateTrayTip(hWnd, FALSE); @@ -1258,7 +1290,12 @@ BOOL WhTool_ModInit() { InitializeCriticalSection(&g_stateLock); g_hInstance = GetModuleHandleW(nullptr); - GetModuleFileNameW(nullptr, g_windhawkPath, ARRAYSIZE(g_windhawkPath)); + switch (GetModuleFileNameW(nullptr, g_windhawkPath, ARRAYSIZE(g_windhawkPath))) { + case 0: + case ARRAYSIZE(g_windhawkPath): + Wh_Log(L"GetModuleFileName failed"); + return FALSE; + } // Build the full system32 path for ddores.dll. UINT sysLen = GetSystemDirectoryW(g_ddoresDllPath, MAX_PATH); From 6c7f63c67ef915a9edc62b3e25cd47bb894c3646 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Thu, 21 May 2026 19:03:37 +0300 Subject: [PATCH 29/56] Major 2.0 Update a full re-work of the mod, introducing a brand new Windows native GUI with easier settings and more interactive menu and better performance. --- mods/audioswap.wh.cpp | 1022 ++++++++++++++++++++++++++++------------- 1 file changed, 710 insertions(+), 312 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index 1c5bb15d4a..8395e1b613 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -2,7 +2,7 @@ // @id audioswap // @name AudioSwap // @description Tray icon to cycle between multiple preferred audio outputs. Supports up to 6 devices with click or scroll to swap. -// @version 1.4.0 +// @version 2.0.0 // @author BlackPaw // @github https://github.com/BlackPaw21 // @include windhawk.exe @@ -18,11 +18,12 @@ Instantly cycle between multiple audio output devices from your system tray — ## How to Use -1. **Choose Device Count** — Open the **Settings** tab and pick how many devices to cycle through (2–6). -2. **Choose Interaction Mode** — Select **Click to Swap** (left-click the icon) or **Scroll to Swap** (mouse wheel over the icon). -3. **Choose Icons** — Pick an icon for each device slot. Icons for slots 3–6 are only used when the device count is set high enough. (select Custom and save to open the file explorer) -4. **Assign Devices** — Right-click the tray icon. Use **Set as Device 1 / 2 / 3...** to assign each slot from the live device list. -5. **Swap** — Click or scroll the tray icon to cycle through your assigned devices. +1. **Open Settings** — Right-click the tray icon and select **Mod Settings** to open the configuration dashboard. +2. **Assign Devices** — Use the dropdown in each slot to pick a device from the live list of active audio outputs. +3. **Choose Icons** — Select a preset icon per slot, or click **Browse...** to load a custom `.ico` file. +4. **Choose Mode** — Pick **Click to Swap** (left-click cycles devices) or **Scroll to Swap** (mouse wheel cycles; left-click mutes). +5. **Set Device Count** — Choose how many slots to cycle through (2–6). +6. Click **Save and Apply** — the tray icon updates immediately, no restart needed. ### Scroll to Swap mode extras @@ -35,20 +36,24 @@ Instantly cycle between multiple audio output devices from your system tray — ## Known Bugs -- **Scroll to Swap may not respond over elevated windows.** such as Task Manager, Windhawk or other admin-elevated applications. Switch focus away from an elevated window and scrolling will work normally. +- **Scroll to Swap may not respond over elevated windows** such as Task Manager, Windhawk, or other admin-elevated applications. Switch focus away from the elevated window and scrolling will work normally. --- ## Changelog +### v2.0.0 +- **New:** Native dashboard — right-click → **Mod Settings**. +- **New:** All configuration (device slots, icons, mode, count) lives in the dashboard; Windhawk's settings panel is no longer used. + + ### v1.4.0 -- Custom .ico support — selecting "Custom Icon" in settings now auto-opens a file picker; also available via right-click → "Custom Icon for Device X..." +- Custom .ico support — selecting "Custom Icon" in settings auto-opens a file picker; also available via right-click → "Custom Icon for Device X..." - Custom icon paths stored internally (no separate text setting in the UI). -- Patch notes: Fixed an issue where Scroll to Swap didn't work after a reboot until the icon was clicked. -- Patch notes: Fixed a crash that occurred when using audio devices with very long names. -- Patch notes: Fixed a bug where adding more devices in settings didn't take effect immediately. -- Patch notes: Fixed a minor issue where cycling devices would sometimes skip the first slot. -- Patch notes: Improved reliability of the mute toggle and overall system stability. +- Fixed: Scroll to Swap didn't work after a reboot until the icon was clicked. +- Fixed: Crash with audio devices that have very long names. +- Fixed: Adding more devices in settings didn't take effect immediately. +- Fixed: Cycling devices would sometimes skip the first slot. ### v1.3.0 - Up to 6 devices; scroll-to-swap mode; dynamic right-click menu. @@ -74,113 +79,10 @@ Instantly cycle between multiple audio output devices from your system tray — */ // ==/WindhawkModReadme== -// ==WindhawkModSettings== -/* -- deviceCount: 2 - $name: Number of Devices - $description: How many audio devices to cycle through (2 to 6) - $options: - - 2: 2 Devices - - 3: 3 Devices - - 4: 4 Devices - - 5: 5 Devices - - 6: 6 Devices -- swapMode: click_to_swap - $name: Interaction Mode - $description: How to cycle through devices - $options: - - click_to_swap: Click to Swap - - scroll_to_swap: Scroll to Swap -- icon1: speaker_normal - $name: Device 1 Icon - $options: - - speaker_normal: Normal Speaker - - speaker_square: Square Speaker - - speaker_modern: Modern Speaker - - speaker_modern_square: Modern Square Speaker - - audio_wave: Audio Wave - - headphones: Headphones - - headset_gaming: Gaming Headset - - headphones_modern: Modern Headphones - - headset_modern: Modern Gaming Headset - - earphones: Earphones - - custom: Custom Icon -- icon2: speaker_square - $name: Device 2 Icon - $options: - - speaker_normal: Normal Speaker - - speaker_square: Square Speaker - - speaker_modern: Modern Speaker - - speaker_modern_square: Modern Square Speaker - - audio_wave: Audio Wave - - headphones: Headphones - - headset_gaming: Gaming Headset - - headphones_modern: Modern Headphones - - headset_modern: Modern Gaming Headset - - earphones: Earphones - - custom: Custom Icon -- icon3: headphones - $name: Device 3 Icon - $description: Used when Number of Devices is 3 or more - $options: - - speaker_normal: Normal Speaker - - speaker_square: Square Speaker - - speaker_modern: Modern Speaker - - speaker_modern_square: Modern Square Speaker - - audio_wave: Audio Wave - - headphones: Headphones - - headset_gaming: Gaming Headset - - headphones_modern: Modern Headphones - - headset_modern: Modern Gaming Headset - - earphones: Earphones - - custom: Custom Icon -- icon4: headset_gaming - $name: Device 4 Icon - $description: Used when Number of Devices is 4 or more - $options: - - speaker_normal: Normal Speaker - - speaker_square: Square Speaker - - speaker_modern: Modern Speaker - - speaker_modern_square: Modern Square Speaker - - audio_wave: Audio Wave - - headphones: Headphones - - headset_gaming: Gaming Headset - - headphones_modern: Modern Headphones - - headset_modern: Modern Gaming Headset - - earphones: Earphones - - custom: Custom Icon -- icon5: headphones_modern - $name: Device 5 Icon - $description: Used when Number of Devices is 5 or more - $options: - - speaker_normal: Normal Speaker - - speaker_square: Square Speaker - - speaker_modern: Modern Speaker - - speaker_modern_square: Modern Square Speaker - - audio_wave: Audio Wave - - headphones: Headphones - - headset_gaming: Gaming Headset - - headphones_modern: Modern Headphones - - headset_modern: Modern Gaming Headset - - earphones: Earphones - - custom: Custom Icon -- icon6: headset_modern - $name: Device 6 Icon - $description: Used when Number of Devices is 6 - $options: - - speaker_normal: Normal Speaker - - speaker_square: Square Speaker - - speaker_modern: Modern Speaker - - speaker_modern_square: Modern Square Speaker - - audio_wave: Audio Wave - - headphones: Headphones - - headset_gaming: Gaming Headset - - headphones_modern: Modern Headphones - - headset_modern: Modern Gaming Headset - - earphones: Earphones - - custom: Custom Icon -*/ -// ==/WindhawkModSettings== +// All settings are managed via right-click → Mod Settings (dashboard). +// No WindhawkModSettings block: Wh_GetStringSetting reads a separate +// YAML store that Wh_SetStringValue cannot write to, so schema entries +// would silently conflict with dashboard saves. #include #include @@ -193,6 +95,8 @@ Instantly cycle between multiple audio output devices from your system tray — #include #include #include +#include +#include #define TRAY_ICON_ID 1 #define WM_TRAY_CALLBACK (WM_USER + 1) @@ -200,16 +104,26 @@ Instantly cycle between multiple audio output devices from your system tray — #define WM_TRAY_SCROLL (WM_USER + 3) // wParam = direction (+1 or -1) #define WM_UPDATE_HOOK_STATE (WM_USER + 4) #define WM_SHOW_FILE_PICKER (WM_USER + 5) // lParam = bitmask of slots needing pickers -#define WM_RELOAD_ICONS (WM_USER + 6) // reload icons on tray thread (eliminates cross-thread handle race) +#define WM_RELOAD_ICONS (WM_USER + 6) // reload icons on tray thread (eliminates cross-thread handle race) +#define WM_RELOAD_ALL (WM_USER + 7) // full reload after dashboard save #define TRAY_RECT_INIT_TIMER 99 // one-shot retry timer for Shell_NotifyIconGetRect -// Slot N uses menu IDs: N*100 + device_index (0..31). Max slot 6 → ID 631. -#define MENU_SLOT_BASE 100 -#define MENU_MAX_DEVICES 32 -#define MENU_OPEN_WINDHAWK 9000 +#define MENU_OPEN_SETTINGS 9001 +#define MENU_OPEN_WINDHAWK 9000 + +// With NOTIFYICON_VERSION_4 active Windows changes the tray notifications: +// left-click → NIN_SELECT (instead of / alongside WM_LBUTTONUP) +// right-click → WM_CONTEXTMENU (instead of / alongside WM_RBUTTONUP) +// keyboard → NIN_KEYSELECT +// We accept both old and new forms so the icon behaves the same in either mode. +#ifndef NIN_SELECT +#define NIN_SELECT (WM_USER + 0) +#endif +#ifndef NIN_KEYSELECT +#define NIN_KEYSELECT (NIN_SELECT | 0x1) +#endif #define MAX_DEVICE_SLOTS 6 -#define MENU_CUSTOM_ICON_BASE 8000 const DWORD CLICK_DEBOUNCE_MS = 500; const DWORD SCROLL_DEBOUNCE_MS = 300; @@ -240,8 +154,8 @@ static HBITMAP g_hIconDevBmp[MAX_DEVICE_SLOTS] = {}; // ── Shared state — protected by g_stateLock ────────────────────────────────── // Read by the worker thread (CycleAudioDevice) and tray thread (UpdateTrayTip, -// BuildAndShowContextMenu). Written by the main thread (LoadDeviceSelections, -// LoadUserIconsAndSettings, SaveDeviceSelection) and tray thread (mute toggle). +// BuildAndShowContextMenu). Written by LoadDeviceSelections/LoadUserIconsAndSettings +// (main thread or tray thread via WM_RELOAD_ALL) and ToggleMuteCurrentDevice (tray thread). static CRITICAL_SECTION g_stateLock; static WCHAR g_cachedDevId[MAX_DEVICE_SLOTS][512] = {}; static WCHAR g_cachedDevName[MAX_DEVICE_SLOTS][256] = {}; @@ -258,6 +172,12 @@ class AudioDeviceNotifier; static IMMDeviceEnumerator* g_notifEnum = nullptr; static AudioDeviceNotifier* g_deviceNotifier = nullptr; +// Dashboard GUI thread — written only from the tray thread (BuildAndShowContextMenu) +// and read only from the main thread (WhTool_ModUninit), which runs after the +// tray thread has been waited for. No concurrent access → no lock needed. +static HANDLE g_guiThread = nullptr; +static volatile LONG g_guiRunning = 0; // 1 while dashboard window is open + const CLSID CLSID_CPolicyConfigClient = { 0x870af99c, 0x171d, 0x4f9e, {0xaf, 0x0d, 0xe6, 0x3d, 0xf4, 0x0c, 0x2b, 0xc9} }; @@ -301,6 +221,603 @@ int GetIconIndex(PCWSTR s) { return 4; } +// ─── Settings dashboard ─────────────────────────────────────────────────────── + +namespace AudioSwapGui { + + // ── Palette ─────────────────────────────────────────────────────────────── + static const COLORREF kClrBg = RGB(24, 24, 24); + static const COLORREF kClrSurface = RGB(36, 36, 36); + static const COLORREF kClrBorder = RGB(56, 56, 56); + static const COLORREF kClrText = RGB(224, 224, 224); + static const COLORREF kClrDim = RGB(128, 128, 128); + static const COLORREF kClrInput = RGB(28, 28, 28); + static const COLORREF kClrAccent = RGB(0, 120, 212); + static const COLORREF kClrAccentP = RGB(0, 96, 170); // pressed + static const COLORREF kClrDarkBtn = RGB(44, 44, 44); + static const COLORREF kClrDarkBP = RGB(32, 32, 32); // pressed + + // ── Layout constants ────────────────────────────────────────────────────── + static const int kCW = 414; // client width + static const int kTopH = 72; // height of global-settings section + static const int kSlotH = 76; // height per slot row + static const int kBtnH = 52; // button-row height + static const int kIconSz= 28; // icon drawn size + + struct DeviceInfo { std::wstring id, name; }; + + struct State { + HWND hTrayHwnd = nullptr; + HWND hMainWnd = nullptr; + + HFONT hFont = nullptr; + HBRUSH hBgBrush = nullptr; // kClrBg — returned from WM_CTLCOLOR* + HBRUSH hInpBrush = nullptr; // kClrInput + HBRUSH hSurfBrush = nullptr; // kClrSurface + + int deviceCount = 2; + int swapMode = 0; + + std::vector activeDevices; + + struct Slot { + HWND hDevCombo = nullptr; + HWND hIconCombo = nullptr; + HWND hBrowseBtn = nullptr; + HICON hPreviewIcon = nullptr; + std::wstring id, name, iconKey; + WCHAR customPath[MAX_PATH] = {}; + } slots[6]; + + HWND hCountCombo = nullptr; + HWND hModeCombo = nullptr; + HWND hSaveBtn = nullptr; + HWND hCancelBtn = nullptr; + }; + + static const WCHAR* kIconKeys[] = { + L"speaker_normal", L"speaker_square", L"speaker_modern", + L"speaker_modern_square", L"audio_wave", L"headphones", + L"headset_gaming", L"headphones_modern", L"headset_modern", + L"earphones", L"custom" + }; + static const WCHAR* kIconLabels[] = { + L"Normal Speaker", L"Square Speaker", L"Modern Speaker", + L"Modern Square Speaker", L"Audio Wave", L"Headphones", + L"Gaming Headset", L"Modern Headphones", L"Modern Gaming Headset", + L"Earphones", L"Custom Icon..." + }; + static const int kIconCount = 11; + + // ── Helpers ─────────────────────────────────────────────────────────────── + + // y-coordinate of slot i's top edge in client space. + static int SlotY(int i) { return kTopH + i * kSlotH; } + + // Extract the preview icon for a slot (32px). Caller owns the HICON. + static HICON LoadSlotPreview(const std::wstring& key, const WCHAR* customPath) { + HICON h = nullptr; + if (key == L"custom" && customPath && customPath[0]) + h = (HICON)LoadImageW(NULL, customPath, IMAGE_ICON, + kIconSz, kIconSz, LR_LOADFROMFILE); + if (!h) ExtractIconExW(g_ddoresDllPath, + GetIconIndex(key.empty() ? nullptr : key.c_str()), + nullptr, &h, 1); + return h; + } + + static void RefreshPreview(State* s, int i) { + if (s->slots[i].hPreviewIcon) { DestroyIcon(s->slots[i].hPreviewIcon); } + s->slots[i].hPreviewIcon = LoadSlotPreview(s->slots[i].iconKey, s->slots[i].customPath); + } + + // Apply DarkMode_CFD theme to a combo so its dropdown renders dark on Win10+. + static void DarkCombo(HWND h) { + HMODULE ux = GetModuleHandleW(L"uxtheme.dll"); + if (!ux) ux = LoadLibraryW(L"uxtheme.dll"); + if (!ux) return; + using Fn = HRESULT(WINAPI*)(HWND, LPCWSTR, LPCWSTR); + auto fn = reinterpret_cast(GetProcAddress(ux, "SetWindowTheme")); + if (fn) fn(h, L"DarkMode_CFD", nullptr); + } + + // Move buttons and resize the window to fit exactly deviceCount slots. + static void UpdateLayout(State* s) { + if (!s->hMainWnd) return; + int btnY = kTopH + s->deviceCount * kSlotH + 12; + SetWindowPos(s->hSaveBtn, nullptr, 12, btnY, 148, 28, SWP_NOZORDER|SWP_NOACTIVATE); + SetWindowPos(s->hCancelBtn, nullptr, 168, btnY, 88, 28, SWP_NOZORDER|SWP_NOACTIVATE); + int clientH = kTopH + s->deviceCount * kSlotH + kBtnH; + RECT rc = {0, 0, kCW, clientH}; + AdjustWindowRectEx(&rc, + WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX, + FALSE, WS_EX_DLGMODALFRAME); + RECT wr; GetWindowRect(s->hMainWnd, &wr); + SetWindowPos(s->hMainWnd, nullptr, wr.left, wr.top, + rc.right - rc.left, rc.bottom - rc.top, + SWP_NOZORDER | SWP_NOACTIVATE); + } + + static void UpdateSlotVisibility(State* s) { + for (int i = 0; i < 6; i++) { + bool vis = (i < s->deviceCount); + ShowWindow(s->slots[i].hDevCombo, vis ? SW_SHOW : SW_HIDE); + ShowWindow(s->slots[i].hIconCombo, vis ? SW_SHOW : SW_HIDE); + ShowWindow(s->slots[i].hBrowseBtn, + (vis && s->slots[i].iconKey == L"custom") ? SW_SHOW : SW_HIDE); + } + if (s->hMainWnd) InvalidateRect(s->hMainWnd, nullptr, TRUE); + } + + static BOOL CALLBACK ApplyFontProc(HWND child, LPARAM hf) { + SendMessageW(child, WM_SETFONT, (WPARAM)hf, TRUE); + return TRUE; + } + + // ── State I/O ───────────────────────────────────────────────────────────── + + static void LoadState(State& s) { + IMMDeviceEnumerator* pEnum = nullptr; + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, + CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), + (void**)&pEnum))) { + IMMDeviceCollection* pColl = nullptr; + if (SUCCEEDED(pEnum->EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE, &pColl))) { + UINT count = 0; pColl->GetCount(&count); + for (UINT i = 0; i < count; i++) { + IMMDevice* pDev = nullptr; + if (SUCCEEDED(pColl->Item(i, &pDev))) { + LPWSTR pId = nullptr; + if (SUCCEEDED(pDev->GetId(&pId))) { + IPropertyStore* pStore = nullptr; + if (SUCCEEDED(pDev->OpenPropertyStore(STGM_READ, &pStore))) { + PROPVARIANT v; PropVariantInit(&v); + if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &v)) + && v.pwszVal) + s.activeDevices.push_back({pId, v.pwszVal}); + PropVariantClear(&v); pStore->Release(); + } + CoTaskMemFree(pId); + } + pDev->Release(); + } + } + pColl->Release(); + } + pEnum->Release(); + } + + { + WCHAR dcBuf[8] = {}; + Wh_GetStringValue(L"deviceCount", dcBuf, 8); + int n = 0; + for (const WCHAR* p = dcBuf; *p >= L'0' && *p <= L'9'; p++) n = n*10 + (*p-L'0'); + s.deviceCount = (n >= 2 && n <= 6) ? n : 2; + } + { + WCHAR modeBuf[32] = {}; + Wh_GetStringValue(L"swapMode", modeBuf, 32); + s.swapMode = (wcscmp(modeBuf, L"scroll_to_swap") == 0) ? 1 : 0; + } + + for (int i = 0; i < 6; i++) { + WCHAR kId[32], kNm[32], kPth[32], buf[512]; + swprintf_s(kId, L"Device%dId", i+1); + swprintf_s(kNm, L"Device%dName", i+1); + swprintf_s(kPth, L"icon%d_custom_path", i+1); + Wh_GetStringValue(kId, buf, 512); s.slots[i].id = buf; + Wh_GetStringValue(kNm, buf, 256); s.slots[i].name = buf; + WCHAR kIco[16]; swprintf_s(kIco, L"icon%d", i+1); + WCHAR ikBuf[32] = {}; + Wh_GetStringValue(kIco, ikBuf, 32); + s.slots[i].iconKey = ikBuf[0] ? ikBuf : L"speaker_normal"; + Wh_GetStringValue(kPth, s.slots[i].customPath, MAX_PATH); + s.slots[i].hPreviewIcon = LoadSlotPreview(s.slots[i].iconKey, s.slots[i].customPath); + } + } + + // ── Window procedure ────────────────────────────────────────────────────── + + static LRESULT CALLBACK DashboardWndProc(HWND hWnd, UINT msg, + WPARAM wParam, LPARAM lParam) { + State* s = reinterpret_cast(GetWindowLongPtrW(hWnd, GWLP_USERDATA)); + + switch (msg) { + + // ── Initialise ──────────────────────────────────────────────────────── + case WM_CREATE: { + auto* cs = reinterpret_cast(lParam); + s = reinterpret_cast(cs->lpCreateParams); + SetWindowLongPtrW(hWnd, GWLP_USERDATA, reinterpret_cast(s)); + s->hMainWnd = hWnd; + s->hFont = CreateFontW(-14, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, + DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, + CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_DONTCARE, L"Segoe UI"); + s->hBgBrush = CreateSolidBrush(kClrBg); + s->hInpBrush = CreateSolidBrush(kClrInput); + s->hSurfBrush = CreateSolidBrush(kClrSurface); + + HINSTANCE hInst = GetModuleHandleW(nullptr); + + // ── Top row: Devices count + Interaction Mode ───────────────────── + // All labels are painted in WM_PAINT; only combos are child HWNDs. + // Layout (x): "Devices:" @12 → combo @80 w=62 → "Mode:" @156 → combo @198 w=204 + s->hCountCombo = CreateWindowExW(WS_EX_CLIENTEDGE, L"COMBOBOX", nullptr, + WS_CHILD|WS_VISIBLE|CBS_DROPDOWNLIST, 80, 18, 62, 200, + hWnd, (HMENU)(UINT_PTR)100, hInst, nullptr); + for (int i = 2; i <= 6; i++) { + WCHAR b[4]; swprintf_s(b, L"%d", i); + SendMessageW(s->hCountCombo, CB_ADDSTRING, 0, (LPARAM)b); + } + SendMessageW(s->hCountCombo, CB_SETCURSEL, s->deviceCount - 2, 0); + DarkCombo(s->hCountCombo); + + s->hModeCombo = CreateWindowExW(WS_EX_CLIENTEDGE, L"COMBOBOX", nullptr, + WS_CHILD|WS_VISIBLE|CBS_DROPDOWNLIST, 198, 18, 204, 200, + hWnd, (HMENU)(UINT_PTR)101, hInst, nullptr); + SendMessageW(s->hModeCombo, CB_ADDSTRING, 0, (LPARAM)L"Click to Swap"); + SendMessageW(s->hModeCombo, CB_ADDSTRING, 0, (LPARAM)L"Scroll to Swap"); + SendMessageW(s->hModeCombo, CB_SETCURSEL, s->swapMode, 0); + DarkCombo(s->hModeCombo); + + // ── Per-slot controls ───────────────────────────────────────────── + // Slot i base y = kTopH + i*kSlotH. + // y+0 to y+20 : header band (painted in WM_PAINT), "Slot N" text + icon + // y+22 to y+46 : device combo (icon preview drawn left of combo at x=12) + // y+50 to y+74 : icon combo | browse btn + for (int i = 0; i < 6; i++) { + int y = SlotY(i); + + s->slots[i].hDevCombo = CreateWindowExW(WS_EX_CLIENTEDGE, L"COMBOBOX", nullptr, + WS_CHILD|CBS_DROPDOWNLIST, 46, y+22, 356, 300, + hWnd, (HMENU)(UINT_PTR)(200+i), hInst, nullptr); + for (int j = 0; j < (int)s->activeDevices.size(); j++) { + int idx = (int)SendMessageW(s->slots[i].hDevCombo, CB_ADDSTRING, + 0, (LPARAM)s->activeDevices[j].name.c_str()); + if (s->slots[i].id == s->activeDevices[j].id) + SendMessageW(s->slots[i].hDevCombo, CB_SETCURSEL, idx, 0); + } + DarkCombo(s->slots[i].hDevCombo); + + s->slots[i].hIconCombo = CreateWindowExW(WS_EX_CLIENTEDGE, L"COMBOBOX", nullptr, + WS_CHILD|CBS_DROPDOWNLIST, 46, y+50, 258, 300, + hWnd, (HMENU)(UINT_PTR)(300+i), hInst, nullptr); + int isel = 0; + for (int j = 0; j < kIconCount; j++) { + SendMessageW(s->slots[i].hIconCombo, CB_ADDSTRING, 0, (LPARAM)kIconLabels[j]); + if (s->slots[i].iconKey == kIconKeys[j]) isel = j; + } + SendMessageW(s->slots[i].hIconCombo, CB_SETCURSEL, isel, 0); + DarkCombo(s->slots[i].hIconCombo); + + // Browse btn — owner-drawn, shown only when iconKey == "custom" + s->slots[i].hBrowseBtn = CreateWindowExW(0, L"BUTTON", L"Browse...", + WS_CHILD|BS_OWNERDRAW, 310, y+48, 92, 26, + hWnd, (HMENU)(UINT_PTR)(400+i), hInst, nullptr); + } + + // ── Bottom buttons — owner-drawn, positioned for initial deviceCount ─ + int btnY = kTopH + s->deviceCount * kSlotH + 12; + s->hSaveBtn = CreateWindowExW(0, L"BUTTON", L"Save and Apply", + WS_CHILD|WS_VISIBLE|BS_OWNERDRAW, 12, btnY, 148, 28, + hWnd, (HMENU)IDOK, hInst, nullptr); + s->hCancelBtn = CreateWindowExW(0, L"BUTTON", L"Cancel", + WS_CHILD|WS_VISIBLE|BS_OWNERDRAW, 168, btnY, 88, 28, + hWnd, (HMENU)IDCANCEL, hInst, nullptr); + + EnumChildWindows(hWnd, ApplyFontProc, reinterpret_cast(s->hFont)); + UpdateSlotVisibility(s); + + // Request dark title bar from DWM (Win10 1903+, silently fails earlier) + { + HMODULE dwm = GetModuleHandleW(L"dwmapi.dll"); + if (!dwm) dwm = LoadLibraryW(L"dwmapi.dll"); + if (dwm) { + using DwmFn = HRESULT(WINAPI*)(HWND, DWORD, LPCVOID, DWORD); + auto fn = reinterpret_cast(GetProcAddress(dwm, "DwmSetWindowAttribute")); + if (fn) { BOOL dark = TRUE; fn(hWnd, 20, &dark, sizeof(dark)); } + } + } + return 0; + } + + // ── Background & painting ───────────────────────────────────────────── + case WM_ERASEBKGND: { + RECT rc; GetClientRect(hWnd, &rc); + FillRect((HDC)wParam, &rc, s ? s->hBgBrush : (HBRUSH)GetStockObject(BLACK_BRUSH)); + return 1; + } + + case WM_PAINT: { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hWnd, &ps); + SelectObject(hdc, s->hFont); + SetBkMode(hdc, TRANSPARENT); + RECT cr; GetClientRect(hWnd, &cr); + + // ── Top-section labels ──────────────────────────────────────────── + SetTextColor(hdc, kClrDim); + RECT r; + r = {12, 22, 78, 38}; DrawTextW(hdc, L"Devices:", -1, &r, DT_LEFT|DT_TOP); + r = {156, 22, 196, 38}; DrawTextW(hdc, L"Mode:", -1, &r, DT_LEFT|DT_TOP); + + // Separator between top controls and slot section + { + HPEN p = CreatePen(PS_SOLID, 1, kClrBorder); + HPEN op = (HPEN)SelectObject(hdc, p); + MoveToEx(hdc, 12, kTopH - 6, nullptr); + LineTo(hdc, cr.right - 12, kTopH - 6); + SelectObject(hdc, op); DeleteObject(p); + } + + // ── Per-slot headers, icons, and row labels ─────────────────────── + for (int i = 0; i < s->deviceCount; i++) { + int y = SlotY(i); + + // Slot header band + RECT hdr = {0, y, cr.right, y + 20}; + FillRect(hdc, &hdr, s->hSurfBrush); + + // "Slot N" label in header + SetTextColor(hdc, kClrDim); + RECT sl = {12, y + 2, 100, y + 18}; + WCHAR lbl[16]; swprintf_s(lbl, L"Slot %d", i + 1); + DrawTextW(hdc, lbl, -1, &sl, DT_LEFT|DT_TOP); + + // Slot icon (drawn left of the device combo, vertically centred) + if (s->slots[i].hPreviewIcon) + DrawIconEx(hdc, 12, y + 22, s->slots[i].hPreviewIcon, + kIconSz, kIconSz, 0, nullptr, DI_NORMAL); + + // "Icon:" row label + SetTextColor(hdc, kClrDim); + RECT il = {12, y + 54, 44, y + 68}; + DrawTextW(hdc, L"Icon:", -1, &il, DT_LEFT|DT_TOP); + + // Row separator (between slots, not after the last visible one) + if (i < s->deviceCount - 1) { + HPEN p = CreatePen(PS_SOLID, 1, kClrBorder); + HPEN op = (HPEN)SelectObject(hdc, p); + MoveToEx(hdc, 0, y + kSlotH - 1, nullptr); + LineTo(hdc, cr.right, y + kSlotH - 1); + SelectObject(hdc, op); DeleteObject(p); + } + } + + EndPaint(hWnd, &ps); + return 0; + } + + // ── Dark theming for child controls ─────────────────────────────────── + case WM_CTLCOLORSTATIC: + if (s) { + SetTextColor((HDC)wParam, kClrDim); + SetBkColor((HDC)wParam, kClrBg); + return (LRESULT)s->hBgBrush; + } + break; + case WM_CTLCOLOREDIT: + case WM_CTLCOLORLISTBOX: + if (s) { + SetTextColor((HDC)wParam, kClrText); + SetBkColor((HDC)wParam, kClrInput); + return (LRESULT)s->hInpBrush; + } + break; + case WM_CTLCOLORBTN: + if (s) return (LRESULT)s->hBgBrush; + break; + + // ── Owner-draw buttons ──────────────────────────────────────────────── + case WM_DRAWITEM: { + auto* dis = reinterpret_cast(lParam); + if (dis->CtlType != ODT_BUTTON || !s) break; + bool pressed = (dis->itemState & ODS_SELECTED) != 0; + bool isSave = (dis->CtlID == IDOK); + bool isBrowse= (dis->CtlID >= 400 && dis->CtlID < 406); + + COLORREF bg; + if (isSave) bg = pressed ? kClrAccentP : kClrAccent; + else if (isBrowse) bg = pressed ? kClrDarkBP : kClrSurface; + else bg = pressed ? kClrDarkBP : kClrDarkBtn; + + HBRUSH hFill = CreateSolidBrush(bg); + FillRect(dis->hDC, &dis->rcItem, hFill); + DeleteObject(hFill); + + // 1px border + HPEN hPen = CreatePen(PS_SOLID, 1, + isSave ? kClrAccentP : kClrBorder); + HPEN hOp = (HPEN)SelectObject(dis->hDC, hPen); + SelectObject(dis->hDC, GetStockObject(NULL_BRUSH)); + Rectangle(dis->hDC, dis->rcItem.left, dis->rcItem.top, + dis->rcItem.right, dis->rcItem.bottom); + SelectObject(dis->hDC, hOp); DeleteObject(hPen); + + // Button label + WCHAR txt[64] = {}; GetWindowTextW(dis->hwndItem, txt, 64); + SetTextColor(dis->hDC, kClrText); + SetBkMode(dis->hDC, TRANSPARENT); + SelectObject(dis->hDC, s->hFont); + DrawTextW(dis->hDC, txt, -1, &dis->rcItem, + DT_CENTER | DT_VCENTER | DT_SINGLELINE); + + if (dis->itemState & ODS_FOCUS) DrawFocusRect(dis->hDC, &dis->rcItem); + return TRUE; + } + + // ── User interaction ────────────────────────────────────────────────── + case WM_COMMAND: { + int id = LOWORD(wParam); + + if (id == 100 && HIWORD(wParam) == CBN_SELCHANGE) { + // Device count changed — resize window and show/hide slot rows + s->deviceCount = (int)SendMessageW(s->hCountCombo, CB_GETCURSEL, 0, 0) + 2; + UpdateSlotVisibility(s); + UpdateLayout(s); + + } else if (id >= 300 && id < 306 && HIWORD(wParam) == CBN_SELCHANGE) { + // Icon style changed for slot (id-300) — live preview update + int slot = id - 300; + int sel = (int)SendMessageW(s->slots[slot].hIconCombo, CB_GETCURSEL, 0, 0); + if (sel >= 0 && sel < kIconCount) s->slots[slot].iconKey = kIconKeys[sel]; + RefreshPreview(s, slot); + // Repaint the icon area only + RECT ir = {12, SlotY(slot)+20, 12+kIconSz+2, SlotY(slot)+20+kIconSz+2}; + InvalidateRect(hWnd, &ir, TRUE); + UpdateSlotVisibility(s); // toggles Browse button + + } else if (id >= 400 && id < 406) { + // Browse for custom .ico + int slot = id - 400; + WCHAR path[MAX_PATH] = {}; + wcscpy_s(path, s->slots[slot].customPath); + WCHAR title[64]; swprintf_s(title, L"Select Icon for Slot %d", slot + 1); + OPENFILENAMEW ofn = {sizeof(ofn)}; + ofn.hwndOwner = hWnd; + ofn.lpstrFilter = L"Icon Files (*.ico)\0*.ico\0All Files (*.*)\0*.*\0"; + ofn.nFilterIndex = 1; + ofn.lpstrFile = path; + ofn.nMaxFile = MAX_PATH; + ofn.lpstrTitle = title; + ofn.Flags = OFN_PATHMUSTEXIST|OFN_FILEMUSTEXIST|OFN_HIDEREADONLY|OFN_NOCHANGEDIR; + if (GetOpenFileNameW(&ofn)) { + wcscpy_s(s->slots[slot].customPath, path); + RefreshPreview(s, slot); + RECT ir = {12, SlotY(slot)+20, 12+kIconSz+2, SlotY(slot)+20+kIconSz+2}; + InvalidateRect(hWnd, &ir, TRUE); + } + + } else if (id == IDOK) { + s->deviceCount = (int)SendMessageW(s->hCountCombo, CB_GETCURSEL, 0, 0) + 2; + s->swapMode = (int)SendMessageW(s->hModeCombo, CB_GETCURSEL, 0, 0); + for (int i = 0; i < 6; i++) { + int ds = (int)SendMessageW(s->slots[i].hDevCombo, CB_GETCURSEL, 0, 0); + if (ds >= 0 && ds < (int)s->activeDevices.size()) { + s->slots[i].id = s->activeDevices[ds].id; + s->slots[i].name = s->activeDevices[ds].name; + } + int is = (int)SendMessageW(s->slots[i].hIconCombo, CB_GETCURSEL, 0, 0); + if (is >= 0 && is < kIconCount) s->slots[i].iconKey = kIconKeys[is]; + } + WCHAR num[4]; swprintf_s(num, L"%d", s->deviceCount); + Wh_SetStringValue(L"deviceCount", num); + Wh_SetStringValue(L"swapMode", s->swapMode ? L"scroll_to_swap" : L"click_to_swap"); + for (int i = 0; i < 6; i++) { + WCHAR kId[32], kNm[32], kIco[32], kPth[32]; + swprintf_s(kId, L"Device%dId", i+1); + swprintf_s(kNm, L"Device%dName", i+1); + swprintf_s(kIco, L"icon%d", i+1); + swprintf_s(kPth, L"icon%d_custom_path", i+1); + Wh_SetStringValue(kId, s->slots[i].id.c_str()); + Wh_SetStringValue(kNm, s->slots[i].name.c_str()); + Wh_SetStringValue(kIco, s->slots[i].iconKey.c_str()); + Wh_SetStringValue(kPth, s->slots[i].customPath); + } + if (s->hTrayHwnd) PostMessageW(s->hTrayHwnd, WM_RELOAD_ALL, 0, 0); + DestroyWindow(hWnd); + + } else if (id == IDCANCEL) { + DestroyWindow(hWnd); + } + return 0; + } + + // ── Cleanup ─────────────────────────────────────────────────────────── + case WM_DESTROY: + if (s) { + DeleteObject(s->hFont); s->hFont = nullptr; + DeleteObject(s->hBgBrush); s->hBgBrush = nullptr; + DeleteObject(s->hInpBrush); s->hInpBrush = nullptr; + DeleteObject(s->hSurfBrush);s->hSurfBrush= nullptr; + for (int i = 0; i < 6; i++) { + if (s->slots[i].hPreviewIcon) { + DestroyIcon(s->slots[i].hPreviewIcon); + s->slots[i].hPreviewIcon = nullptr; + } + } + } + PostQuitMessage(0); + return 0; + } + return DefWindowProcW(hWnd, msg, wParam, lParam); + } + + // ── GUI thread ──────────────────────────────────────────────────────────── + + static DWORD WINAPI GuiThreadProc(LPVOID lpParam) { + CoInitialize(nullptr); + + static const WCHAR kClass[] = L"AudioSwapDashboard"; + HINSTANCE hInst = GetModuleHandleW(nullptr); + + WNDCLASSEXW wc = {sizeof(wc)}; + wc.lpfnWndProc = DashboardWndProc; + wc.hInstance = hInst; + wc.hCursor = LoadCursorW(nullptr, IDC_ARROW); + wc.hbrBackground = nullptr; // WM_ERASEBKGND fills background + wc.lpszClassName = kClass; + RegisterClassExW(&wc); + + State state; + state.hTrayHwnd = reinterpret_cast(lpParam); + LoadState(state); + + // Size window to exactly fit the initial device count. + int clientH = kTopH + state.deviceCount * kSlotH + kBtnH; + RECT rc = {0, 0, kCW, clientH}; + AdjustWindowRectEx(&rc, + WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX, + FALSE, WS_EX_DLGMODALFRAME); + int winW = rc.right - rc.left; + int winH = rc.bottom - rc.top; + int sx = (GetSystemMetrics(SM_CXSCREEN) - winW) / 2; + int sy = (GetSystemMetrics(SM_CYSCREEN) - winH) / 2; + + HWND hWnd = CreateWindowExW( + WS_EX_DLGMODALFRAME, + kClass, L"AudioSwap Settings", + WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX, + sx, sy, winW, winH, + nullptr, nullptr, hInst, &state); + + if (hWnd) { + ShowWindow(hWnd, SW_SHOW); + UpdateWindow(hWnd); + MSG msg; + while (GetMessageW(&msg, nullptr, 0, 0)) { + if (!IsDialogMessageW(hWnd, &msg)) { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + } + + // Icons are destroyed in WM_DESTROY; clean up here only if window never opened. + for (int i = 0; i < 6; i++) { + if (state.slots[i].hPreviewIcon) { + DestroyIcon(state.slots[i].hPreviewIcon); + state.slots[i].hPreviewIcon = nullptr; + } + } + + UnregisterClassW(kClass, hInst); + CoUninitialize(); + InterlockedExchange(&g_guiRunning, 0); + return 0; + } + + HANDLE LaunchDashboard(HWND hTrayHwnd) { + if (InterlockedCompareExchange(&g_guiRunning, 1, 0) != 0) + return nullptr; + HANDLE h = CreateThread(nullptr, 0, GuiThreadProc, + reinterpret_cast(hTrayHwnd), 0, nullptr); + if (!h) InterlockedExchange(&g_guiRunning, 0); + return h; + } + +} // namespace AudioSwapGui + // ─── Device selection data ──────────────────────────────────────────────────── // All functions below acquire g_stateLock for writes to shared slot data. @@ -325,30 +842,13 @@ void LoadDeviceSelections() { LeaveCriticalSection(&g_stateLock); } -void SaveDeviceSelection(int slot, PCWSTR deviceId, PCWSTR friendlyName) { - if (slot < 1 || slot > MAX_DEVICE_SLOTS) return; - int idx = slot - 1; - WCHAR keyId[32], keyName[32]; - swprintf_s(keyId, L"Device%dId", slot); - swprintf_s(keyName, L"Device%dName", slot); - Wh_SetStringValue(keyId, deviceId); - Wh_SetStringValue(keyName, friendlyName); - - EnterCriticalSection(&g_stateLock); - lstrcpynW(g_cachedDevId[idx], deviceId, 512); - lstrcpynW(g_cachedDevName[idx], friendlyName, 256); - LeaveCriticalSection(&g_stateLock); -} static int ReadDeviceCountSetting() { - PCWSTR s = Wh_GetStringSetting(L"deviceCount"); - int n = 2; - if (s && s[0] >= L'2' && s[0] <= L'9') { - n = 0; - for (const WCHAR* p = s; *p >= L'0' && *p <= L'9'; p++) - n = n * 10 + (*p - L'0'); - } - if (s) Wh_FreeStringSetting(s); + WCHAR buf[8] = {}; + Wh_GetStringValue(L"deviceCount", buf, 8); + int n = 0; + for (const WCHAR* p = buf; *p >= L'0' && *p <= L'9'; p++) + n = n * 10 + (*p - L'0'); if (n < 2) n = 2; if (n > MAX_DEVICE_SLOTS) n = MAX_DEVICE_SLOTS; return n; @@ -362,9 +862,9 @@ void LoadUserIconsAndSettings() { int newCount = ReadDeviceCountSetting(); - PCWSTR swapMode = Wh_GetStringSetting(L"swapMode"); - LONG newScroll = (swapMode && wcscmp(swapMode, L"scroll_to_swap") == 0) ? 1 : 0; - if (swapMode) Wh_FreeStringSetting(swapMode); + WCHAR swapModeBuf[32] = {}; + Wh_GetStringValue(L"swapMode", swapModeBuf, 32); + LONG newScroll = (wcscmp(swapModeBuf, L"scroll_to_swap") == 0) ? 1 : 0; EnterCriticalSection(&g_stateLock); g_deviceSlotCount = newCount; @@ -375,9 +875,10 @@ void LoadUserIconsAndSettings() { WCHAR iconKey[16], pathKey[32]; for (int i = 0; i < newCount; i++) { swprintf_s(iconKey, L"icon%d", i + 1); - PCWSTR s = Wh_GetStringSetting(iconKey); + WCHAR iconVal[32] = {}; + Wh_GetStringValue(iconKey, iconVal, 32); - if (s && wcscmp(s, L"custom") == 0) { + if (wcscmp(iconVal, L"custom") == 0) { swprintf_s(pathKey, L"icon%d_custom_path", i + 1); WCHAR customPath[MAX_PATH] = {}; Wh_GetStringValue(pathKey, customPath, MAX_PATH); @@ -388,14 +889,10 @@ void LoadUserIconsAndSettings() { ExtractIconExW(g_ddoresDllPath, 4, nullptr, &g_iconDev[i], 1); } } else { - ExtractIconExW(g_ddoresDllPath, GetIconIndex(s), nullptr, &g_iconDev[i], 1); + ExtractIconExW(g_ddoresDllPath, GetIconIndex(iconVal), nullptr, &g_iconDev[i], 1); } - // Save current icon setting for transition detection in SettingsChanged. - if (s) wcscpy_s(g_lastIconSetting[i], 32, s); - else g_lastIconSetting[i][0] = L'\0'; - - if (s) Wh_FreeStringSetting(s); + wcscpy_s(g_lastIconSetting[i], 32, iconVal); if (g_iconDev[i]) { ICONINFO ii = {}; @@ -894,87 +1391,10 @@ BOOL CycleAudioDevice(int direction) { // ─── Context menu ───────────────────────────────────────────────────────────── -struct AudioDevice { WCHAR id[512]; WCHAR name[256]; }; - void BuildAndShowContextMenu(HWND hWnd) { - // Snapshot shared data under lock. - WCHAR localIds[MAX_DEVICE_SLOTS][512] = {}; - int localCount; - bool localMuted; - - EnterCriticalSection(&g_stateLock); - localCount = g_deviceSlotCount; - for (int i = 0; i < MAX_DEVICE_SLOTS; i++) - lstrcpynW(localIds[i], g_cachedDevId[i], 512); - localMuted = g_isMutedByUs; - LeaveCriticalSection(&g_stateLock); - - AudioDevice devices[MENU_MAX_DEVICES]; - int deviceCount = 0; - - IMMDeviceEnumerator* pEnum = nullptr; - if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, - __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { - IMMDeviceCollection* pDevices = nullptr; - if (SUCCEEDED(pEnum->EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE, &pDevices))) { - UINT count = 0; - pDevices->GetCount(&count); - if (count > MENU_MAX_DEVICES) count = MENU_MAX_DEVICES; - for (UINT i = 0; i < count; i++) { - IMMDevice* pDevice = nullptr; - if (FAILED(pDevices->Item(i, &pDevice))) continue; - LPWSTR pId = nullptr; - if (SUCCEEDED(pDevice->GetId(&pId))) { - lstrcpynW(devices[deviceCount].id, pId, 512); - CoTaskMemFree(pId); - IPropertyStore* pStore = nullptr; - if (SUCCEEDED(pDevice->OpenPropertyStore(STGM_READ, &pStore))) { - PROPVARIANT v; PropVariantInit(&v); - if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &v)) && v.pwszVal) { - lstrcpynW(devices[deviceCount].name, v.pwszVal, 256); - deviceCount++; - } - PropVariantClear(&v); - pStore->Release(); - } - } - pDevice->Release(); - } - pDevices->Release(); - } - pEnum->Release(); - } - HMENU hMenu = CreatePopupMenu(); - for (int slot = 0; slot < localCount; slot++) { - HMENU hSub = CreatePopupMenu(); - for (int i = 0; i < deviceCount; i++) { - UINT flags = MF_STRING | (wcscmp(devices[i].id, localIds[slot]) == 0 ? MF_CHECKED : 0); - AppendMenuW(hSub, flags, MENU_SLOT_BASE * (slot + 1) + i, devices[i].name); - } - WCHAR label[32]; - swprintf_s(label, L"Set as Device %d", slot + 1); - MENUITEMINFOW mii = {sizeof(mii)}; - mii.fMask = MIIM_SUBMENU | MIIM_STRING | MIIM_BITMAP; - mii.hSubMenu = hSub; - mii.dwTypeData = label; - mii.hbmpItem = g_hIconDevBmp[slot]; - InsertMenuItemW(hMenu, (UINT)-1, TRUE, &mii); - } - - AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); - - for (int slot = 0; slot < localCount; slot++) { - WCHAR label[48]; - swprintf_s(label, L"Custom Icon for Device %d...", slot + 1); - MENUITEMINFOW miiCI = {sizeof(miiCI)}; - miiCI.fMask = MIIM_ID | MIIM_STRING; - miiCI.wID = MENU_CUSTOM_ICON_BASE + slot; - miiCI.dwTypeData = label; - InsertMenuItemW(hMenu, (UINT)-1, TRUE, &miiCI); - } - + AppendMenuW(hMenu, MF_STRING, MENU_OPEN_SETTINGS, L"Mod Settings..."); AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); MENUITEMINFOW miiWH = {sizeof(miiWH)}; @@ -984,79 +1404,25 @@ void BuildAndShowContextMenu(HWND hWnd) { miiWH.hbmpItem = g_hWindHawkBmp; InsertMenuItemW(hMenu, (UINT)-1, TRUE, &miiWH); - AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); - - WCHAR statusText[300]; - int configuredCount = 0; - for (int i = 0; i < localCount; i++) if (localIds[i][0] != L'\0') configuredCount++; - if (configuredCount < 2) { - lstrcpyW(statusText, L"Right-click to configure devices"); - } else { - WCHAR activeName[256] = L"Unknown"; - IMMDeviceEnumerator* pEnum2 = nullptr; - if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, - __uuidof(IMMDeviceEnumerator), (void**)&pEnum2))) { - IMMDevice* pDef = nullptr; - if (SUCCEEDED(pEnum2->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDef))) { - IPropertyStore* pStore = nullptr; - if (SUCCEEDED(pDef->OpenPropertyStore(STGM_READ, &pStore))) { - PROPVARIANT v; PropVariantInit(&v); - if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &v)) && v.pwszVal) - lstrcpynW(activeName, v.pwszVal, 256); - PropVariantClear(&v); - pStore->Release(); - } - pDef->Release(); - } - pEnum2->Release(); - } - if (localMuted) - swprintf_s(statusText, L"Active: %s (Muted)", activeName); - else - swprintf_s(statusText, L"Active: %s", activeName); - } - AppendMenuW(hMenu, MF_STRING | MF_GRAYED, 0, statusText); - - POINT pt; - GetCursorPos(&pt); + POINT pt; GetCursorPos(&pt); SetForegroundWindow(hWnd); - int cmd = TrackPopupMenu(hMenu, TPM_RETURNCMD | TPM_RIGHTBUTTON | TPM_BOTTOMALIGN | TPM_RIGHTALIGN, - pt.x, pt.y, 0, hWnd, nullptr); + int cmd = TrackPopupMenu(hMenu, + TPM_RETURNCMD | TPM_RIGHTBUTTON | TPM_BOTTOMALIGN | TPM_RIGHTALIGN, + pt.x, pt.y, 0, hWnd, nullptr); PostMessageW(hWnd, WM_NULL, 0, 0); DestroyMenu(hMenu); - if (cmd >= MENU_SLOT_BASE && cmd < MENU_SLOT_BASE * (localCount + 1)) { - int slot = cmd / MENU_SLOT_BASE; - int deviceIdx = cmd % MENU_SLOT_BASE; - if (slot >= 1 && slot <= localCount && deviceIdx < deviceCount) { - SaveDeviceSelection(slot, devices[deviceIdx].id, devices[deviceIdx].name); - PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + if (cmd == MENU_OPEN_SETTINGS) { + HANDLE h = AudioSwapGui::LaunchDashboard(hWnd); + if (h) { + if (g_guiThread) CloseHandle(g_guiThread); + g_guiThread = h; } } else if (cmd == MENU_OPEN_WINDHAWK) { SHELLEXECUTEINFOW sei = {sizeof(sei)}; sei.lpFile = g_windhawkPath; sei.nShow = SW_SHOWNORMAL; ShellExecuteExW(&sei); - } else if (cmd >= MENU_CUSTOM_ICON_BASE && cmd < MENU_CUSTOM_ICON_BASE + localCount) { - int slot = cmd - MENU_CUSTOM_ICON_BASE + 1; - WCHAR path[MAX_PATH] = {}; - OPENFILENAMEW ofn = {sizeof(ofn)}; - ofn.hwndOwner = hWnd; - ofn.lpstrFilter = L"Icon Files (*.ico)\0*.ico\0All Files (*.*)\0*.*\0"; - ofn.nFilterIndex = 1; - ofn.lpstrFile = path; - ofn.nMaxFile = MAX_PATH; - ofn.Flags = OFN_PATHMUSTEXIST | OFN_FILEMUSTEXIST | OFN_HIDEREADONLY | OFN_NOCHANGEDIR; - WCHAR title[64]; - swprintf_s(title, L"Select Icon for Device %d", slot); - ofn.lpstrTitle = title; - if (GetOpenFileNameW(&ofn)) { - WCHAR customPathKey[32]; - swprintf_s(customPathKey, L"icon%d_custom_path", slot); - Wh_SetStringValue(customPathKey, path); - LoadUserIconsAndSettings(); - PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); - } } } @@ -1077,6 +1443,8 @@ static void SpawnCycleThread(int direction) { g_workerThread = CreateThread(nullptr, 0, WorkerThreadProc, reinterpret_cast(static_cast(direction)), 0, nullptr); + // If CreateThread fails the worker never resets the lock — do it here. + if (!g_workerThread) InterlockedExchange(&g_isProcessingClick, 0); } // ─── Tray window ────────────────────────────────────────────────────────────── @@ -1090,12 +1458,16 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) if (!PtInRect(&g_trayIconRect, pt)) { RefreshTrayIconRect(); } - } else if (event == WM_RBUTTONUP) { + } else if (event == WM_RBUTTONUP || event == WM_CONTEXTMENU) { + // Right-click — opens settings menu. + // v4 sends WM_CONTEXTMENU; pre-v4 sends WM_RBUTTONUP. BuildAndShowContextMenu(hWnd); - } else if (event == WM_LBUTTONUP && + } else if ((event == WM_LBUTTONUP || event == NIN_SELECT || + event == NIN_KEYSELECT) && InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 0) { - // Click mode: debounced forward cycle + // Click mode: debounced forward cycle. + // v4 sends NIN_SELECT (or NIN_KEYSELECT for keyboard); pre-v4 sends WM_LBUTTONUP. if (InterlockedExchange(&g_isProcessingClick, 1) == 0) { DWORD now = GetTickCount(); if (now - g_lastClickTime > CLICK_DEBOUNCE_MS) { @@ -1106,13 +1478,21 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) } } - } else if (event == WM_LBUTTONUP && + } else if ((event == WM_LBUTTONUP || event == NIN_SELECT || + event == NIN_KEYSELECT) && InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 1) { - // Scroll mode: left-click toggles mute (COM initialized on tray thread) + // Scroll mode: left-click toggles mute. + // Same debounce as click mode: WM_LBUTTONUP and NIN_SELECT are BOTH + // delivered for the same physical click under NOTIFYICON_VERSION_4. + // Without debouncing, mute is toggled on then immediately off (net nothing). if (InterlockedExchange(&g_isProcessingClick, 1) == 0) { - ToggleMuteCurrentDevice(); + DWORD now = GetTickCount(); + if (now - g_lastClickTime > CLICK_DEBOUNCE_MS) { + g_lastClickTime = now; + ToggleMuteCurrentDevice(); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + } InterlockedExchange(&g_isProcessingClick, 0); - PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); } } @@ -1158,10 +1538,10 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) for (int i = 0; i < g_deviceSlotCount; i++) { WCHAR key[16]; swprintf_s(key, L"icon%d", i + 1); - PCWSTR s = Wh_GetStringSetting(key); - BOOL isCustom = (s && wcscmp(s, L"custom") == 0); + WCHAR icoVal[32] = {}; + Wh_GetStringValue(key, icoVal, 32); + BOOL isCustom = (wcscmp(icoVal, L"custom") == 0); BOOL wasCustom = (wcscmp(prev[i], L"custom") == 0); - if (s) Wh_FreeStringSetting(s); if (isCustom && !wasCustom) pickerSlots |= (1u << i); } @@ -1195,6 +1575,14 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) } else if (msg == g_taskbarCreatedMsg && g_taskbarCreatedMsg != 0) { UpdateTrayTip(hWnd, TRUE); // re-add icon after explorer restart + } else if (msg == WM_RELOAD_ALL) { + // Full reload triggered by the settings dashboard after Save and Apply. + // Picks up new device assignments, icon choices, device count, and swap mode. + LoadDeviceSelections(); + LoadUserIconsAndSettings(); + UpdateTrayTip(hWnd, FALSE); + PostMessageW(hWnd, WM_UPDATE_HOOK_STATE, 0, 0); + } else if (msg == WM_CLOSE) { // Orderly shutdown: remove tray icon, then destroy window. NOTIFYICONDATAW nid = {sizeof(nid)}; @@ -1361,6 +1749,16 @@ void WhTool_ModUninit() { CloseHandle(g_trayThread); g_trayThread = nullptr; } + if (g_guiThread) { + // Ask the dashboard's message loop to quit, then wait for it. + // PostThreadMessageW with WM_QUIT causes GetMessageW to return 0, + // exiting the loop regardless of IsDialogMessageW. + DWORD guiId = GetThreadId(g_guiThread); + if (guiId) PostThreadMessageW(guiId, WM_QUIT, 0, 0); + WaitForSingleObject(g_guiThread, 2000); + CloseHandle(g_guiThread); + g_guiThread = nullptr; + } if (g_workerThread) { WaitForSingleObject(g_workerThread, 2000); CloseHandle(g_workerThread); From cc82f6d8470eaa86d9259a3c15c52467948685a4 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Sat, 23 May 2026 21:38:13 +0300 Subject: [PATCH 30/56] added advanced mode and device priority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New: Device Priority panel — rank your preferred devices; the highest-priority connected device is assigned to a swap slot automatically. - New: Advanced Mode toggle in Windhawk settings — shows the Device Priority panel in Mod Settings. - Fixed: Scroll to Swap conflict with Monitor Sleep Button mod. - Fixed: GUI sizing on high-DPI monitors. - Fixed: Context menu now follows your Windows dark/light theme. - Fixed: Context menu could sometimes stay open after clicking away. - Fixed: Tray icon reappears correctly after Explorer restarts. --- mods/audioswap.wh.cpp | 690 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 557 insertions(+), 133 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index 8395e1b613..6e6bc818bf 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -2,11 +2,11 @@ // @id audioswap // @name AudioSwap // @description Tray icon to cycle between multiple preferred audio outputs. Supports up to 6 devices with click or scroll to swap. -// @version 2.0.0 +// @version 2.1.0 // @author BlackPaw // @github https://github.com/BlackPaw21 // @include windhawk.exe -// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lcomdlg32 +// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lcomdlg32 -ladvapi32 // ==/WindhawkMod== // ==WindhawkModReadme== @@ -30,6 +30,15 @@ Instantly cycle between multiple audio output devices from your system tray — - **Left-click** mutes the current device. Click again to unmute. The tray tooltip shows *(Muted)* and the icon gains a red dot while active. - **Scrolling** to a different device automatically unmutes the previous one before switching. +### Device Priority (Advanced Mode) + +Enable **Advanced Mode** in the Windhawk settings panel (gear icon → Settings tab) to show the Device Priority section inside Mod Settings. + +- Rank up to 6 preferred devices in order of priority. +- When AudioSwap loads, the highest-priority device that is currently connected is automatically placed into a swap slot. +- Disconnected priority devices are remembered and restored to their slot when reconnected. +- Each priority entry has its own icon picker. + > The tray tooltip always shows the active device. On first run it reads *"Right-click to configure"* until at least two slots are assigned. --- @@ -42,11 +51,19 @@ Instantly cycle between multiple audio output devices from your system tray — ## Changelog +### v2.1.0 +- New: Device Priority panel — rank your preferred devices; the highest-priority connected device is assigned to a swap slot automatically. +- New: Advanced Mode toggle in Windhawk settings — shows the Device Priority panel in Mod Settings. +- Fixed: Scroll to Swap conflict with Monitor Sleep Button mod. +- Fixed: GUI sizing on high-DPI monitors. +- Fixed: Context menu now follows your Windows dark/light theme. +- Fixed: Context menu could sometimes stay open after clicking away. +- Fixed: Tray icon reappears correctly after Explorer restarts. + ### v2.0.0 - **New:** Native dashboard — right-click → **Mod Settings**. - **New:** All configuration (device slots, icons, mode, count) lives in the dashboard; Windhawk's settings panel is no longer used. - ### v1.4.0 - Custom .ico support — selecting "Custom Icon" in settings auto-opens a file picker; also available via right-click → "Custom Icon for Device X..." - Custom icon paths stored internally (no separate text setting in the UI). @@ -79,10 +96,18 @@ Instantly cycle between multiple audio output devices from your system tray — */ // ==/WindhawkModReadme== -// All settings are managed via right-click → Mod Settings (dashboard). -// No WindhawkModSettings block: Wh_GetStringSetting reads a separate -// YAML store that Wh_SetStringValue cannot write to, so schema entries -// would silently conflict with dashboard saves. +// ==WindhawkModSettings== +/* +- advancedMode: false + $name: Advanced Mode + $description: Shows the Device Priority panel in Mod Settings, letting you rank preferred devices for automatic slot assignment. +*/ +// ==/WindhawkModSettings== + +// Dashboard-level settings (device slots, icons, priority list) are managed via +// right-click → Mod Settings and persisted with Wh_SetStringValue / Wh_GetStringValue. +// The advancedMode flag above uses Wh_GetIntSetting, which reads from Windhawk's +// separate YAML store — no conflict with dashboard keys. #include #include @@ -105,11 +130,13 @@ Instantly cycle between multiple audio output devices from your system tray — #define WM_UPDATE_HOOK_STATE (WM_USER + 4) #define WM_SHOW_FILE_PICKER (WM_USER + 5) // lParam = bitmask of slots needing pickers #define WM_RELOAD_ICONS (WM_USER + 6) // reload icons on tray thread (eliminates cross-thread handle race) -#define WM_RELOAD_ALL (WM_USER + 7) // full reload after dashboard save +#define WM_RELOAD_ALL (WM_USER + 7) // full reload after dashboard save +#define WM_PRIORITY_DEVICE_ACTIVE (WM_USER + 8) // lParam = heap-alloc'd WCHAR* device ID #define TRAY_RECT_INIT_TIMER 99 // one-shot retry timer for Shell_NotifyIconGetRect #define MENU_OPEN_SETTINGS 9001 #define MENU_OPEN_WINDHAWK 9000 +#define IDC_BTN_KOFI 9002 // With NOTIFYICON_VERSION_4 active Windows changes the tray notifications: // left-click → NIN_SELECT (instead of / alongside WM_LBUTTONUP) @@ -144,8 +171,7 @@ static DWORD g_lastClickTime = 0; static DWORD g_lastScrollTime = 0; static UINT g_taskbarCreatedMsg = 0; -// Mouse hook -static HHOOK g_mouseHook = nullptr; +// Tray icon position (used by Raw Input scroll handler) static RECT g_trayIconRect = {}; // Per-slot icon handles @@ -164,6 +190,11 @@ static bool g_isMutedByUs = false; static WCHAR g_mutedDeviceId[512] = {}; // ───────────────────────────────────────────────────────────────────────────── +// Priority device list — up to MAX_DEVICE_SLOTS entries, index 0 = highest priority. +// Written on main/tray thread under no lock (only touched during reload or init). +static WCHAR g_priorityDevIds[MAX_DEVICE_SLOTS][512] = {}; +static int g_priorityCount = 0; + // Mode flag: set/read atomically with InterlockedExchange/InterlockedRead. static volatile LONG g_scrollToSwap = 0; // 1 = scroll mode @@ -238,11 +269,14 @@ namespace AudioSwapGui { static const COLORREF kClrDarkBP = RGB(32, 32, 32); // pressed // ── Layout constants ────────────────────────────────────────────────────── - static const int kCW = 414; // client width - static const int kTopH = 72; // height of global-settings section - static const int kSlotH = 76; // height per slot row - static const int kBtnH = 52; // button-row height - static const int kIconSz= 28; // icon drawn size + static const int kSW = 414; // slots panel width (right) + static const int kPW = 220; // priority panel width (left) + static const int kGap = 12; // gap between panels + static const int kCW = kSW + kGap + kPW; // total client width (646) + static const int kTopH = 72; // height of global-settings section + static const int kSlotH = 76; // height per slot row + static const int kPrioRowH = 52; // height per priority row (device + icon lines) + static const int kIconSz = 28; // icon drawn size struct DeviceInfo { std::wstring id, name; }; @@ -273,6 +307,20 @@ namespace AudioSwapGui { HWND hModeCombo = nullptr; HWND hSaveBtn = nullptr; HWND hCancelBtn = nullptr; + HWND hKoFiBtn = nullptr; + + UINT dpi = 96; + bool advancedMode = false; + + // Priority section + struct PrioSlot { + HWND hDevCombo = nullptr; + HWND hIconCombo = nullptr; + HWND hBrowseBtn = nullptr; + std::wstring id, name, iconKey; + WCHAR customPath[MAX_PATH] = {}; + bool isOffline = false; + } prioSlots[MAX_DEVICE_SLOTS]; }; static const WCHAR* kIconKeys[] = { @@ -291,8 +339,17 @@ namespace AudioSwapGui { // ── Helpers ─────────────────────────────────────────────────────────────── - // y-coordinate of slot i's top edge in client space. - static int SlotY(int i) { return kTopH + i * kSlotH; } + // Scale a base-96-DPI pixel value to the current DPI. + static int Sc(int px, UINT dpi) { return MulDiv(px, (int)dpi, 96); } + + // x-origin of the slots (right) panel. + static int SlotsX(UINT dpi) { return Sc(kPW + kGap, dpi); } + + // y-coordinate of slot i's top edge in client space (slots panel). + static int SlotY(int i, UINT dpi){ return Sc(kTopH,dpi) + i * Sc(kSlotH,dpi); } + + // y-coordinate of priority row i's top edge in client space (priority panel). + static int PrioRowY(int i, UINT dpi){ return Sc(32,dpi) + i * Sc(kPrioRowH,dpi); } // Extract the preview icon for a slot (32px). Caller owns the HICON. static HICON LoadSlotPreview(const std::wstring& key, const WCHAR* customPath) { @@ -321,14 +378,46 @@ namespace AudioSwapGui { if (fn) fn(h, L"DarkMode_CFD", nullptr); } - // Move buttons and resize the window to fit exactly deviceCount slots. - static void UpdateLayout(State* s) { + // Reposition all child controls and resize the window using the current DPI. + static void LayoutControls(State* s) { if (!s->hMainWnd) return; - int btnY = kTopH + s->deviceCount * kSlotH + 12; - SetWindowPos(s->hSaveBtn, nullptr, 12, btnY, 148, 28, SWP_NOZORDER|SWP_NOACTIVATE); - SetWindowPos(s->hCancelBtn, nullptr, 168, btnY, 88, 28, SWP_NOZORDER|SWP_NOACTIVATE); - int clientH = kTopH + s->deviceCount * kSlotH + kBtnH; - RECT rc = {0, 0, kCW, clientH}; + UINT d = s->dpi; + int sx = s->advancedMode ? SlotsX(d) : 0; + + // Top row (right panel) + SetWindowPos(s->hCountCombo, nullptr, sx+Sc(80,d), Sc(18,d), Sc(62,d), Sc(200,d), SWP_NOZORDER|SWP_NOACTIVATE); + SetWindowPos(s->hModeCombo, nullptr, sx+Sc(198,d), Sc(18,d), Sc(204,d), Sc(200,d), SWP_NOZORDER|SWP_NOACTIVATE); + + // Slot controls (right panel) + for (int i = 0; i < 6; i++) { + int y = SlotY(i, d); + SetWindowPos(s->slots[i].hDevCombo, nullptr, sx+Sc(46,d), y+Sc(22,d), Sc(356,d), Sc(300,d), SWP_NOZORDER|SWP_NOACTIVATE); + SetWindowPos(s->slots[i].hIconCombo, nullptr, sx+Sc(46,d), y+Sc(50,d), Sc(258,d), Sc(300,d), SWP_NOZORDER|SWP_NOACTIVATE); + SetWindowPos(s->slots[i].hBrowseBtn, nullptr, sx+Sc(310,d), y+Sc(48,d), Sc(92,d), Sc(26,d), SWP_NOZORDER|SWP_NOACTIVATE); + } + + // Priority controls (left panel) + // x=50 leaves room for the "1st:" etc. rank label to the left of each device combo. + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + int rowY = PrioRowY(i, d); + bool showBrowse = s->advancedMode && (s->prioSlots[i].iconKey == L"custom"); + int iconW = showBrowse ? Sc(106,d) : Sc(158,d); + SetWindowPos(s->prioSlots[i].hDevCombo, nullptr, Sc(50,d), rowY, Sc(158,d), Sc(200,d), SWP_NOZORDER|SWP_NOACTIVATE); + SetWindowPos(s->prioSlots[i].hIconCombo, nullptr, Sc(50,d), rowY+Sc(26,d), iconW, Sc(200,d), SWP_NOZORDER|SWP_NOACTIVATE); + SetWindowPos(s->prioSlots[i].hBrowseBtn, nullptr, Sc(158,d), rowY+Sc(24,d), Sc(50,d), Sc(24,d), SWP_NOZORDER|SWP_NOACTIVATE); + } + + // Button row (right panel, below visible slots) + int btnY = SlotY(s->deviceCount, d) + Sc(8,d); + SetWindowPos(s->hSaveBtn, nullptr, sx+Sc(12,d), btnY, Sc(148,d), Sc(28,d), SWP_NOZORDER|SWP_NOACTIVATE); + SetWindowPos(s->hCancelBtn, nullptr, sx+Sc(168,d), btnY, Sc(88,d), Sc(28,d), SWP_NOZORDER|SWP_NOACTIVATE); + SetWindowPos(s->hKoFiBtn, nullptr, sx+Sc(264,d), btnY, Sc(138,d), Sc(28,d), SWP_NOZORDER|SWP_NOACTIVATE); + + int prioPanelH = Sc(32,d) + MAX_DEVICE_SLOTS * Sc(kPrioRowH,d) + Sc(12,d); + int slotsPanelH = btnY + Sc(28,d) + Sc(12,d); + int clientH = prioPanelH > slotsPanelH ? prioPanelH : slotsPanelH; + + RECT rc = {0, 0, Sc(s->advancedMode ? kCW : kSW, d), clientH}; AdjustWindowRectEx(&rc, WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX, FALSE, WS_EX_DLGMODALFRAME); @@ -346,6 +435,13 @@ namespace AudioSwapGui { ShowWindow(s->slots[i].hBrowseBtn, (vis && s->slots[i].iconKey == L"custom") ? SW_SHOW : SW_HIDE); } + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + ShowWindow(s->prioSlots[i].hDevCombo, s->advancedMode ? SW_SHOW : SW_HIDE); + ShowWindow(s->prioSlots[i].hIconCombo, s->advancedMode ? SW_SHOW : SW_HIDE); + ShowWindow(s->prioSlots[i].hBrowseBtn, + (s->advancedMode && s->prioSlots[i].iconKey == L"custom") ? SW_SHOW : SW_HIDE); + } + LayoutControls(s); if (s->hMainWnd) InvalidateRect(s->hMainWnd, nullptr, TRUE); } @@ -357,6 +453,8 @@ namespace AudioSwapGui { // ── State I/O ───────────────────────────────────────────────────────────── static void LoadState(State& s) { + s.advancedMode = (Wh_GetIntSetting(L"advancedMode") != 0); + IMMDeviceEnumerator* pEnum = nullptr; if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), @@ -414,6 +512,27 @@ namespace AudioSwapGui { Wh_GetStringValue(kPth, s.slots[i].customPath, MAX_PATH); s.slots[i].hPreviewIcon = LoadSlotPreview(s.slots[i].iconKey, s.slots[i].customPath); } + + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + WCHAR kId[32], kNm[32], kIco[32], kPth[32], buf[512]; + swprintf_s(kId, L"priority%d", i+1); + swprintf_s(kNm, L"priority%d_name", i+1); + swprintf_s(kIco, L"priority%d_icon", i+1); + swprintf_s(kPth, L"priority%d_icon_custom_path", i+1); + Wh_GetStringValue(kId, buf, 512); s.prioSlots[i].id = buf; + Wh_GetStringValue(kNm, buf, 512); s.prioSlots[i].name = buf; + WCHAR ikBuf[32] = {}; + Wh_GetStringValue(kIco, ikBuf, 32); + s.prioSlots[i].iconKey = ikBuf[0] ? ikBuf : L"speaker_normal"; + Wh_GetStringValue(kPth, s.prioSlots[i].customPath, MAX_PATH); + s.prioSlots[i].isOffline = false; + if (!s.prioSlots[i].id.empty()) { + bool found = false; + for (auto& dev : s.activeDevices) + if (dev.id == s.prioSlots[i].id) { found = true; break; } + if (!found) s.prioSlots[i].isOffline = true; + } + } } // ── Window procedure ────────────────────────────────────────────────────── @@ -429,10 +548,17 @@ namespace AudioSwapGui { auto* cs = reinterpret_cast(lParam); s = reinterpret_cast(cs->lpCreateParams); SetWindowLongPtrW(hWnd, GWLP_USERDATA, reinterpret_cast(s)); - s->hMainWnd = hWnd; - s->hFont = CreateFontW(-14, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, - DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, - CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_DONTCARE, L"Segoe UI"); + s->hMainWnd = hWnd; + s->dpi = GetDpiForWindow(hWnd); + { + LOGFONTW lf = {}; + lf.lfHeight = -MulDiv(10, (int)s->dpi, 72); // 10pt at current DPI + lf.lfWeight = FW_NORMAL; + lf.lfQuality = CLEARTYPE_QUALITY; + lf.lfPitchAndFamily = DEFAULT_PITCH | FF_DONTCARE; + wcscpy_s(lf.lfFaceName, L"Segoe UI"); + s->hFont = CreateFontIndirectW(&lf); + } s->hBgBrush = CreateSolidBrush(kClrBg); s->hInpBrush = CreateSolidBrush(kClrInput); s->hSurfBrush = CreateSolidBrush(kClrSurface); @@ -461,15 +587,10 @@ namespace AudioSwapGui { DarkCombo(s->hModeCombo); // ── Per-slot controls ───────────────────────────────────────────── - // Slot i base y = kTopH + i*kSlotH. - // y+0 to y+20 : header band (painted in WM_PAINT), "Slot N" text + icon - // y+22 to y+46 : device combo (icon preview drawn left of combo at x=12) - // y+50 to y+74 : icon combo | browse btn + // Controls are created at (0,0) and repositioned by LayoutControls below. for (int i = 0; i < 6; i++) { - int y = SlotY(i); - s->slots[i].hDevCombo = CreateWindowExW(WS_EX_CLIENTEDGE, L"COMBOBOX", nullptr, - WS_CHILD|CBS_DROPDOWNLIST, 46, y+22, 356, 300, + WS_CHILD|CBS_DROPDOWNLIST, 0, 0, 10, 300, hWnd, (HMENU)(UINT_PTR)(200+i), hInst, nullptr); for (int j = 0; j < (int)s->activeDevices.size(); j++) { int idx = (int)SendMessageW(s->slots[i].hDevCombo, CB_ADDSTRING, @@ -480,7 +601,7 @@ namespace AudioSwapGui { DarkCombo(s->slots[i].hDevCombo); s->slots[i].hIconCombo = CreateWindowExW(WS_EX_CLIENTEDGE, L"COMBOBOX", nullptr, - WS_CHILD|CBS_DROPDOWNLIST, 46, y+50, 258, 300, + WS_CHILD|CBS_DROPDOWNLIST, 0, 0, 10, 300, hWnd, (HMENU)(UINT_PTR)(300+i), hInst, nullptr); int isel = 0; for (int j = 0; j < kIconCount; j++) { @@ -490,23 +611,66 @@ namespace AudioSwapGui { SendMessageW(s->slots[i].hIconCombo, CB_SETCURSEL, isel, 0); DarkCombo(s->slots[i].hIconCombo); - // Browse btn — owner-drawn, shown only when iconKey == "custom" s->slots[i].hBrowseBtn = CreateWindowExW(0, L"BUTTON", L"Browse...", - WS_CHILD|BS_OWNERDRAW, 310, y+48, 92, 26, + WS_CHILD|BS_OWNERDRAW, 0, 0, 10, 10, hWnd, (HMENU)(UINT_PTR)(400+i), hInst, nullptr); } - // ── Bottom buttons — owner-drawn, positioned for initial deviceCount ─ - int btnY = kTopH + s->deviceCount * kSlotH + 12; + // ── Priority controls (left panel) ──────────────────────────────── + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + // Device combo (500+i) + s->prioSlots[i].hDevCombo = CreateWindowExW(WS_EX_CLIENTEDGE, L"COMBOBOX", nullptr, + WS_CHILD|WS_VISIBLE|CBS_DROPDOWNLIST, 0, 0, 10, 300, + hWnd, (HMENU)(UINT_PTR)(500+i), hInst, nullptr); + SendMessageW(s->prioSlots[i].hDevCombo, CB_ADDSTRING, 0, (LPARAM)L"None"); + int psel = 0; + for (int j = 0; j < (int)s->activeDevices.size(); j++) { + int idx = (int)SendMessageW(s->prioSlots[i].hDevCombo, CB_ADDSTRING, + 0, (LPARAM)s->activeDevices[j].name.c_str()); + if (s->prioSlots[i].id == s->activeDevices[j].id) psel = idx; + } + if (s->prioSlots[i].isOffline && !s->prioSlots[i].name.empty()) { + WCHAR offLabel[320]; + swprintf_s(offLabel, L"%s (offline)", s->prioSlots[i].name.c_str()); + int idx = (int)SendMessageW(s->prioSlots[i].hDevCombo, CB_ADDSTRING, + 0, (LPARAM)offLabel); + psel = idx; + } + SendMessageW(s->prioSlots[i].hDevCombo, CB_SETCURSEL, psel, 0); + DarkCombo(s->prioSlots[i].hDevCombo); + + // Icon combo (600+i) + s->prioSlots[i].hIconCombo = CreateWindowExW(WS_EX_CLIENTEDGE, L"COMBOBOX", nullptr, + WS_CHILD|WS_VISIBLE|CBS_DROPDOWNLIST, 0, 0, 10, 300, + hWnd, (HMENU)(UINT_PTR)(600+i), hInst, nullptr); + int isel = 0; + for (int j = 0; j < kIconCount; j++) { + SendMessageW(s->prioSlots[i].hIconCombo, CB_ADDSTRING, 0, (LPARAM)kIconLabels[j]); + if (s->prioSlots[i].iconKey == kIconKeys[j]) isel = j; + } + SendMessageW(s->prioSlots[i].hIconCombo, CB_SETCURSEL, isel, 0); + DarkCombo(s->prioSlots[i].hIconCombo); + + // Browse button (700+i) — visible only when iconKey == "custom" + s->prioSlots[i].hBrowseBtn = CreateWindowExW(0, L"BUTTON", L"Browse...", + WS_CHILD|BS_OWNERDRAW|(s->prioSlots[i].iconKey == L"custom" ? WS_VISIBLE : 0), + 0, 0, 10, 10, + hWnd, (HMENU)(UINT_PTR)(700+i), hInst, nullptr); + } + + // ── Bottom buttons ──────────────────────────────────────────────── s->hSaveBtn = CreateWindowExW(0, L"BUTTON", L"Save and Apply", - WS_CHILD|WS_VISIBLE|BS_OWNERDRAW, 12, btnY, 148, 28, + WS_CHILD|WS_VISIBLE|BS_OWNERDRAW, 0, 0, 10, 10, hWnd, (HMENU)IDOK, hInst, nullptr); s->hCancelBtn = CreateWindowExW(0, L"BUTTON", L"Cancel", - WS_CHILD|WS_VISIBLE|BS_OWNERDRAW, 168, btnY, 88, 28, + WS_CHILD|WS_VISIBLE|BS_OWNERDRAW, 0, 0, 10, 10, hWnd, (HMENU)IDCANCEL, hInst, nullptr); + s->hKoFiBtn = CreateWindowExW(0, L"BUTTON", L"Buy Me Coffee", + WS_CHILD|WS_VISIBLE|BS_OWNERDRAW, 0, 0, 10, 10, + hWnd, (HMENU)IDC_BTN_KOFI, hInst, nullptr); EnumChildWindows(hWnd, ApplyFontProc, reinterpret_cast(s->hFont)); - UpdateSlotVisibility(s); + UpdateSlotVisibility(s); // also calls LayoutControls → positions everything // Request dark title bar from DWM (Win10 1903+, silently fails earlier) { @@ -534,52 +698,80 @@ namespace AudioSwapGui { SelectObject(hdc, s->hFont); SetBkMode(hdc, TRANSPARENT); RECT cr; GetClientRect(hWnd, &cr); + UINT d = s->dpi; + int sx = s->advancedMode ? SlotsX(d) : 0; + + if (s->advancedMode) { + // ── Left panel background + vertical separator ──────────────── + { + RECT lp = {0, 0, Sc(kPW,d), cr.bottom}; + FillRect(hdc, &lp, s->hSurfBrush); + HPEN p = CreatePen(PS_SOLID, 1, kClrBorder); + HPEN op = (HPEN)SelectObject(hdc, p); + MoveToEx(hdc, Sc(kPW,d), 0, nullptr); + LineTo(hdc, Sc(kPW,d), cr.bottom); + SelectObject(hdc, op); DeleteObject(p); + } + + // ── Left panel: Device Priority header + rank labels ────────── + static const WCHAR* kRankLabels[] = {L"1st:", L"2nd:", L"3rd:", L"4th:", L"5th:", L"6th:"}; + SetTextColor(hdc, kClrText); + { + RECT r2 = {Sc(12,d), Sc(10,d), Sc(kPW-4,d), Sc(28,d)}; + DrawTextW(hdc, L"Device Priority", -1, &r2, DT_LEFT|DT_TOP); + } + SetTextColor(hdc, kClrDim); + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + int rowY = PrioRowY(i, d); + RECT rk = {Sc(12,d), rowY + Sc(3,d), Sc(48,d), rowY + Sc(19,d)}; + RECT ri = {Sc(12,d), rowY + Sc(29,d), Sc(48,d), rowY + Sc(45,d)}; + DrawTextW(hdc, kRankLabels[i], -1, &rk, DT_LEFT|DT_TOP); + DrawTextW(hdc, L"icon:", -1, &ri, DT_LEFT|DT_TOP); + } + } - // ── Top-section labels ──────────────────────────────────────────── + // ── Right panel: top-section labels ─────────────────────────────── SetTextColor(hdc, kClrDim); RECT r; - r = {12, 22, 78, 38}; DrawTextW(hdc, L"Devices:", -1, &r, DT_LEFT|DT_TOP); - r = {156, 22, 196, 38}; DrawTextW(hdc, L"Mode:", -1, &r, DT_LEFT|DT_TOP); + r = {sx+Sc(12,d), Sc(22,d), sx+Sc(78,d), Sc(38,d)}; + DrawTextW(hdc, L"Devices:", -1, &r, DT_LEFT|DT_TOP); + r = {sx+Sc(156,d), Sc(22,d), sx+Sc(196,d), Sc(38,d)}; + DrawTextW(hdc, L"Mode:", -1, &r, DT_LEFT|DT_TOP); // Separator between top controls and slot section { HPEN p = CreatePen(PS_SOLID, 1, kClrBorder); HPEN op = (HPEN)SelectObject(hdc, p); - MoveToEx(hdc, 12, kTopH - 6, nullptr); - LineTo(hdc, cr.right - 12, kTopH - 6); + MoveToEx(hdc, sx+Sc(12,d), Sc(kTopH,d) - Sc(6,d), nullptr); + LineTo(hdc, cr.right - Sc(12,d), Sc(kTopH,d) - Sc(6,d)); SelectObject(hdc, op); DeleteObject(p); } // ── Per-slot headers, icons, and row labels ─────────────────────── for (int i = 0; i < s->deviceCount; i++) { - int y = SlotY(i); + int y = SlotY(i, d); - // Slot header band - RECT hdr = {0, y, cr.right, y + 20}; + RECT hdr = {sx, y, cr.right, y + Sc(20,d)}; FillRect(hdc, &hdr, s->hSurfBrush); - // "Slot N" label in header SetTextColor(hdc, kClrDim); - RECT sl = {12, y + 2, 100, y + 18}; + RECT sl = {sx+Sc(12,d), y + Sc(2,d), sx+Sc(100,d), y + Sc(18,d)}; WCHAR lbl[16]; swprintf_s(lbl, L"Slot %d", i + 1); DrawTextW(hdc, lbl, -1, &sl, DT_LEFT|DT_TOP); - // Slot icon (drawn left of the device combo, vertically centred) if (s->slots[i].hPreviewIcon) - DrawIconEx(hdc, 12, y + 22, s->slots[i].hPreviewIcon, - kIconSz, kIconSz, 0, nullptr, DI_NORMAL); + DrawIconEx(hdc, sx+Sc(12,d), y + Sc(22,d), s->slots[i].hPreviewIcon, + Sc(kIconSz,d), Sc(kIconSz,d), 0, nullptr, DI_NORMAL); - // "Icon:" row label SetTextColor(hdc, kClrDim); - RECT il = {12, y + 54, 44, y + 68}; + RECT il = {sx+Sc(12,d), y + Sc(54,d), sx+Sc(44,d), y + Sc(68,d)}; DrawTextW(hdc, L"Icon:", -1, &il, DT_LEFT|DT_TOP); - // Row separator (between slots, not after the last visible one) if (i < s->deviceCount - 1) { HPEN p = CreatePen(PS_SOLID, 1, kClrBorder); HPEN op = (HPEN)SelectObject(hdc, p); - MoveToEx(hdc, 0, y + kSlotH - 1, nullptr); - LineTo(hdc, cr.right, y + kSlotH - 1); + MoveToEx(hdc, sx, y + Sc(kSlotH,d) - 1, nullptr); + LineTo(hdc, cr.right, y + Sc(kSlotH,d) - 1); SelectObject(hdc, op); DeleteObject(p); } } @@ -614,7 +806,8 @@ namespace AudioSwapGui { if (dis->CtlType != ODT_BUTTON || !s) break; bool pressed = (dis->itemState & ODS_SELECTED) != 0; bool isSave = (dis->CtlID == IDOK); - bool isBrowse= (dis->CtlID >= 400 && dis->CtlID < 406); + bool isBrowse= (dis->CtlID >= 400 && dis->CtlID < 406) || + (dis->CtlID >= 700 && dis->CtlID < 706); COLORREF bg; if (isSave) bg = pressed ? kClrAccentP : kClrAccent; @@ -653,8 +846,7 @@ namespace AudioSwapGui { if (id == 100 && HIWORD(wParam) == CBN_SELCHANGE) { // Device count changed — resize window and show/hide slot rows s->deviceCount = (int)SendMessageW(s->hCountCombo, CB_GETCURSEL, 0, 0) + 2; - UpdateSlotVisibility(s); - UpdateLayout(s); + UpdateSlotVisibility(s); // calls LayoutControls internally } else if (id >= 300 && id < 306 && HIWORD(wParam) == CBN_SELCHANGE) { // Icon style changed for slot (id-300) — live preview update @@ -662,8 +854,10 @@ namespace AudioSwapGui { int sel = (int)SendMessageW(s->slots[slot].hIconCombo, CB_GETCURSEL, 0, 0); if (sel >= 0 && sel < kIconCount) s->slots[slot].iconKey = kIconKeys[sel]; RefreshPreview(s, slot); - // Repaint the icon area only - RECT ir = {12, SlotY(slot)+20, 12+kIconSz+2, SlotY(slot)+20+kIconSz+2}; + UINT d = s->dpi; + int sx = s->advancedMode ? SlotsX(d) : 0; + int y = SlotY(slot, d); + RECT ir = {sx+Sc(12,d), y+Sc(20,d), sx+Sc(12,d)+Sc(kIconSz,d)+2, y+Sc(20,d)+Sc(kIconSz,d)+2}; InvalidateRect(hWnd, &ir, TRUE); UpdateSlotVisibility(s); // toggles Browse button @@ -684,10 +878,39 @@ namespace AudioSwapGui { if (GetOpenFileNameW(&ofn)) { wcscpy_s(s->slots[slot].customPath, path); RefreshPreview(s, slot); - RECT ir = {12, SlotY(slot)+20, 12+kIconSz+2, SlotY(slot)+20+kIconSz+2}; + UINT d = s->dpi; + int sx = s->advancedMode ? SlotsX(d) : 0; + int y = SlotY(slot, d); + RECT ir = {sx+Sc(12,d), y+Sc(20,d), sx+Sc(12,d)+Sc(kIconSz,d)+2, y+Sc(20,d)+Sc(kIconSz,d)+2}; InvalidateRect(hWnd, &ir, TRUE); } + } else if (id >= 600 && id < 606 && HIWORD(wParam) == CBN_SELCHANGE) { + // Priority icon changed + int slot = id - 600; + int sel = (int)SendMessageW(s->prioSlots[slot].hIconCombo, CB_GETCURSEL, 0, 0); + if (sel >= 0 && sel < kIconCount) s->prioSlots[slot].iconKey = kIconKeys[sel]; + ShowWindow(s->prioSlots[slot].hBrowseBtn, + s->prioSlots[slot].iconKey == L"custom" ? SW_SHOW : SW_HIDE); + LayoutControls(s); + + } else if (id >= 700 && id < 706) { + // Browse for custom priority icon + int slot = id - 700; + WCHAR path[MAX_PATH] = {}; + wcscpy_s(path, s->prioSlots[slot].customPath); + WCHAR title[64]; swprintf_s(title, L"Select Icon for Priority %d", slot + 1); + OPENFILENAMEW ofn = {sizeof(ofn)}; + ofn.hwndOwner = hWnd; + ofn.lpstrFilter = L"Icon Files (*.ico)\0*.ico\0All Files (*.*)\0*.*\0"; + ofn.nFilterIndex = 1; + ofn.lpstrFile = path; + ofn.nMaxFile = MAX_PATH; + ofn.lpstrTitle = title; + ofn.Flags = OFN_PATHMUSTEXIST|OFN_FILEMUSTEXIST|OFN_HIDEREADONLY|OFN_NOCHANGEDIR; + if (GetOpenFileNameW(&ofn)) + wcscpy_s(s->prioSlots[slot].customPath, path); + } else if (id == IDOK) { s->deviceCount = (int)SendMessageW(s->hCountCombo, CB_GETCURSEL, 0, 0) + 2; s->swapMode = (int)SendMessageW(s->hModeCombo, CB_GETCURSEL, 0, 0); @@ -714,15 +937,66 @@ namespace AudioSwapGui { Wh_SetStringValue(kIco, s->slots[i].iconKey.c_str()); Wh_SetStringValue(kPth, s->slots[i].customPath); } + // Save priority list + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + WCHAR kId[32], kNm[32], kIco[32], kPth[32]; + swprintf_s(kId, L"priority%d", i+1); + swprintf_s(kNm, L"priority%d_name", i+1); + swprintf_s(kIco, L"priority%d_icon", i+1); + swprintf_s(kPth, L"priority%d_icon_custom_path", i+1); + int psel = (int)SendMessageW(s->prioSlots[i].hDevCombo, CB_GETCURSEL, 0, 0); + if (psel == 0) { + Wh_SetStringValue(kId, L""); Wh_SetStringValue(kNm, L""); + } else if (psel - 1 < (int)s->activeDevices.size()) { + Wh_SetStringValue(kId, s->activeDevices[psel-1].id.c_str()); + Wh_SetStringValue(kNm, s->activeDevices[psel-1].name.c_str()); + } else { + // offline entry selected — preserve saved ID/name + Wh_SetStringValue(kId, s->prioSlots[i].id.c_str()); + Wh_SetStringValue(kNm, s->prioSlots[i].name.c_str()); + } + int pisel = (int)SendMessageW(s->prioSlots[i].hIconCombo, CB_GETCURSEL, 0, 0); + if (pisel >= 0 && pisel < kIconCount) { + Wh_SetStringValue(kIco, kIconKeys[pisel]); + s->prioSlots[i].iconKey = kIconKeys[pisel]; + } + Wh_SetStringValue(kPth, s->prioSlots[i].customPath); + } if (s->hTrayHwnd) PostMessageW(s->hTrayHwnd, WM_RELOAD_ALL, 0, 0); DestroyWindow(hWnd); } else if (id == IDCANCEL) { DestroyWindow(hWnd); + } else if (id == IDC_BTN_KOFI) { + ShellExecuteW(nullptr, L"open", L"https://ko-fi.com/blackpaw21", + nullptr, nullptr, SW_SHOWNORMAL); } return 0; } + // ── DPI change ──────────────────────────────────────────────────────── + case WM_DPICHANGED: { + s->dpi = HIWORD(wParam); + // Rebuild font at new DPI. + DeleteObject(s->hFont); + LOGFONTW lf = {}; + lf.lfHeight = -MulDiv(10, (int)s->dpi, 72); + lf.lfWeight = FW_NORMAL; + lf.lfQuality = CLEARTYPE_QUALITY; + lf.lfPitchAndFamily = DEFAULT_PITCH | FF_DONTCARE; + wcscpy_s(lf.lfFaceName, L"Segoe UI"); + s->hFont = CreateFontIndirectW(&lf); + EnumChildWindows(hWnd, ApplyFontProc, (LPARAM)s->hFont); + // Move to OS-suggested rect (avoids visual jump on DPI boundary crossing). + const RECT* prc = reinterpret_cast(lParam); + SetWindowPos(hWnd, nullptr, prc->left, prc->top, + prc->right - prc->left, prc->bottom - prc->top, + SWP_NOZORDER | SWP_NOACTIVATE); + LayoutControls(s); + InvalidateRect(hWnd, nullptr, TRUE); + return 0; + } + // ── Cleanup ─────────────────────────────────────────────────────────── case WM_DESTROY: if (s) { @@ -748,6 +1022,12 @@ namespace AudioSwapGui { static DWORD WINAPI GuiThreadProc(LPVOID lpParam) { CoInitialize(nullptr); + // Set Per-Monitor DPI Awareness V2 so Windows doesn't bitmap-scale the window. + using SetTDACFn = DPI_AWARENESS_CONTEXT(WINAPI*)(DPI_AWARENESS_CONTEXT); + if (auto fn = (SetTDACFn)GetProcAddress( + GetModuleHandleW(L"user32.dll"), "SetThreadDpiAwarenessContext")) + fn(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + static const WCHAR kClass[] = L"AudioSwapDashboard"; HINSTANCE hInst = GetModuleHandleW(nullptr); @@ -763,22 +1043,18 @@ namespace AudioSwapGui { state.hTrayHwnd = reinterpret_cast(lpParam); LoadState(state); - // Size window to exactly fit the initial device count. - int clientH = kTopH + state.deviceCount * kSlotH + kBtnH; - RECT rc = {0, 0, kCW, clientH}; - AdjustWindowRectEx(&rc, - WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX, - FALSE, WS_EX_DLGMODALFRAME); - int winW = rc.right - rc.left; - int winH = rc.bottom - rc.top; - int sx = (GetSystemMetrics(SM_CXSCREEN) - winW) / 2; - int sy = (GetSystemMetrics(SM_CYSCREEN) - winH) / 2; + // Create at center of primary monitor (LayoutControls resizes in WM_CREATE + // before ShowWindow, so the initial size is just a placeholder). + int cxScr = GetSystemMetrics(SM_CXSCREEN); + int cyScr = GetSystemMetrics(SM_CYSCREEN); + int sx = cxScr / 4; + int sy = cyScr / 4; HWND hWnd = CreateWindowExW( WS_EX_DLGMODALFRAME, kClass, L"AudioSwap Settings", WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX, - sx, sy, winW, winH, + sx, sy, cxScr / 2, cyScr / 2, nullptr, nullptr, hInst, &state); if (hWnd) { @@ -821,6 +1097,22 @@ namespace AudioSwapGui { // ─── Device selection data ──────────────────────────────────────────────────── // All functions below acquire g_stateLock for writes to shared slot data. +void LoadPriorityList() { + WCHAR tmpIds[MAX_DEVICE_SLOTS][512] = {}; + int count = 0; + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + WCHAR key[32]; + swprintf_s(key, L"priority%d", i + 1); + Wh_GetStringValue(key, tmpIds[i], 512); + if (tmpIds[i][0]) count = i + 1; + } + EnterCriticalSection(&g_stateLock); + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) + lstrcpynW(g_priorityDevIds[i], tmpIds[i], 512); + g_priorityCount = count; + LeaveCriticalSection(&g_stateLock); +} + void LoadDeviceSelections() { // Read from Windhawk storage outside the lock (Wh_GetStringValue is thread-safe). WCHAR tmpIds[MAX_DEVICE_SLOTS][512] = {}; @@ -1078,49 +1370,53 @@ class AudioDeviceNotifier : public IMMNotificationClient { EDataFlow flow, ERole role, LPCWSTR) override { if (flow == eRender && role == eMultimedia) { - HWND hwnd = g_trayHwnd; + HWND hwnd = (HWND)InterlockedCompareExchangePointer( + (volatile PVOID*)&g_trayHwnd, nullptr, nullptr); if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); } return S_OK; } - HRESULT STDMETHODCALLTYPE OnDeviceAdded(LPCWSTR) override { - HWND hwnd = g_trayHwnd; - if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + HRESULT STDMETHODCALLTYPE OnDeviceAdded(LPCWSTR pwstrDeviceId) override { + HWND hwnd = (HWND)InterlockedCompareExchangePointer( + (volatile PVOID*)&g_trayHwnd, nullptr, nullptr); + if (hwnd) { + PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + if (pwstrDeviceId && pwstrDeviceId[0]) { + WCHAR* idCopy = new WCHAR[512]; + wcscpy_s(idCopy, 512, pwstrDeviceId); + if (!PostMessageW(hwnd, WM_PRIORITY_DEVICE_ACTIVE, 0, (LPARAM)idCopy)) + delete[] idCopy; + } + } return S_OK; } HRESULT STDMETHODCALLTYPE OnDeviceRemoved(LPCWSTR) override { - HWND hwnd = g_trayHwnd; + HWND hwnd = (HWND)InterlockedCompareExchangePointer( + (volatile PVOID*)&g_trayHwnd, nullptr, nullptr); if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); return S_OK; } - HRESULT STDMETHODCALLTYPE OnDeviceStateChanged(LPCWSTR, DWORD) override { - HWND hwnd = g_trayHwnd; - if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + HRESULT STDMETHODCALLTYPE OnDeviceStateChanged(LPCWSTR pwstrDeviceId, DWORD newState) override { + HWND hwnd = (HWND)InterlockedCompareExchangePointer( + (volatile PVOID*)&g_trayHwnd, nullptr, nullptr); + if (hwnd) { + PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + if (newState == DEVICE_STATE_ACTIVE && pwstrDeviceId && pwstrDeviceId[0]) { + WCHAR* idCopy = new WCHAR[512]; + wcscpy_s(idCopy, 512, pwstrDeviceId); + if (!PostMessageW(hwnd, WM_PRIORITY_DEVICE_ACTIVE, 0, (LPARAM)idCopy)) + delete[] idCopy; + } + } return S_OK; } HRESULT STDMETHODCALLTYPE OnPropertyValueChanged( LPCWSTR, const PROPERTYKEY) override { return S_OK; } }; -// ─── Mouse hook ─────────────────────────────────────────────────────────────── - -LRESULT CALLBACK LowLevelMouseProc(int nCode, WPARAM wParam, LPARAM lParam) { - if (nCode == HC_ACTION && wParam == WM_MOUSEWHEEL && - InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 1) - { - HWND hwnd = g_trayHwnd; - if (hwnd) { - const MSLLHOOKSTRUCT* ms = reinterpret_cast(lParam); - if (PtInRect(&g_trayIconRect, ms->pt)) { - short delta = static_cast(HIWORD(ms->mouseData)); - int direction = (delta > 0) ? 1 : -1; - PostMessageW(hwnd, WM_TRAY_SCROLL, - static_cast(static_cast(direction)), 0); - } - } - } - return CallNextHookEx(g_mouseHook, nCode, wParam, lParam); -} +// Scroll-to-swap uses Raw Input (RegisterRawInputDevices + WM_INPUT in TrayWndProc) +// instead of WH_MOUSE_LL. Raw Input is delivered outside the hook chain, so no +// other WH_MOUSE_LL hook (e.g., Monitor Sleep Button) can block it. // ─── Muted icon overlay ─────────────────────────────────────────────────────── @@ -1214,6 +1510,10 @@ static HICON CreateMutedOverlayIcon(HICON hBase) { // ─── Tray tip ───────────────────────────────────────────────────────────────── +static WCHAR s_lastDev[256] = {}; +static HICON s_lastIcon = nullptr; +static bool s_lastMuted = false; + void UpdateTrayTip(HWND hWnd, BOOL isAdd) { // Snapshot shared state under lock (no COM inside the lock). WCHAR localDevIds[MAX_DEVICE_SLOTS][512] = {}; @@ -1266,9 +1566,7 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { for (int i = 0; i < localSlotCount; i++) if (localDevIds[i][0] != L'\0') configuredCount++; - static WCHAR s_lastDev[256] = {}; - static HICON s_lastIcon = nullptr; - static bool s_lastMuted = false; + // s_lastDev / s_lastIcon / s_lastMuted are file-scope to allow external reset. if (!isAdd && wcscmp(currentDev, s_lastDev) == 0 && currentIcon == s_lastIcon && @@ -1391,6 +1689,25 @@ BOOL CycleAudioDevice(int direction) { // ─── Context menu ───────────────────────────────────────────────────────────── +static bool IsSystemDarkMode() { + DWORD value = 1, size = sizeof(value); + RegGetValueW(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + L"AppsUseLightTheme", RRF_RT_REG_DWORD, nullptr, &value, &size); + return value == 0; +} + +static void ApplyContextMenuTheme(HWND hWnd, bool dark) { + HMODULE ux = GetModuleHandleW(L"uxtheme.dll"); + if (!ux) return; + using Fn135 = int(WINAPI*)(int); + using Fn133 = bool(WINAPI*)(HWND, bool); + using Fn136 = void(WINAPI*)(); + if (auto f = (Fn135)GetProcAddress(ux, MAKEINTRESOURCEA(135))) f(dark ? 2 : 0); + if (auto f = (Fn133)GetProcAddress(ux, MAKEINTRESOURCEA(133))) f(hWnd, dark); + if (auto f = (Fn136)GetProcAddress(ux, MAKEINTRESOURCEA(136))) f(); +} + void BuildAndShowContextMenu(HWND hWnd) { HMENU hMenu = CreatePopupMenu(); @@ -1405,6 +1722,8 @@ void BuildAndShowContextMenu(HWND hWnd) { InsertMenuItemW(hMenu, (UINT)-1, TRUE, &miiWH); POINT pt; GetCursorPos(&pt); + bool dark = IsSystemDarkMode(); + ApplyContextMenuTheme(hWnd, dark); SetForegroundWindow(hWnd); int cmd = TrackPopupMenu(hMenu, TPM_RETURNCMD | TPM_RIGHTBUTTON | TPM_BOTTOMALIGN | TPM_RIGHTALIGN, @@ -1439,7 +1758,11 @@ DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { } static void SpawnCycleThread(int direction) { - if (g_workerThread) { CloseHandle(g_workerThread); g_workerThread = nullptr; } + if (g_workerThread) { + if (WaitForSingleObject(g_workerThread, 0) == WAIT_OBJECT_0) + CloseHandle(g_workerThread); + g_workerThread = nullptr; + } g_workerThread = CreateThread(nullptr, 0, WorkerThreadProc, reinterpret_cast(static_cast(direction)), 0, nullptr); @@ -1447,6 +1770,61 @@ static void SpawnCycleThread(int direction) { if (!g_workerThread) InterlockedExchange(&g_isProcessingClick, 0); } +// ─── Priority device routing ────────────────────────────────────────────────── + +static void HandlePriorityDeviceConnected(HWND hWnd, const WCHAR* devId) { + // Snapshot all shared state under the lock before doing any work. + EnterCriticalSection(&g_stateLock); + int priorityCount = g_priorityCount; + WCHAR localPrio[MAX_DEVICE_SLOTS][512] = {}; + for (int i = 0; i < priorityCount; i++) + lstrcpynW(localPrio[i], g_priorityDevIds[i], 512); + int slotCount = g_deviceSlotCount; + WCHAR localIds[MAX_DEVICE_SLOTS][512] = {}; + for (int i = 0; i < slotCount; i++) + lstrcpynW(localIds[i], g_cachedDevId[i], 512); + LeaveCriticalSection(&g_stateLock); + + if (!devId || !devId[0] || priorityCount == 0) return; + + // Find rank of the newly connected device in the priority list. + int newRank = -1; + for (int i = 0; i < priorityCount; i++) { + if (localPrio[i][0] && wcscmp(localPrio[i], devId) == 0) { + newRank = i; break; + } + } + if (newRank < 0) return; // not in the priority list + + // Find the slot currently occupied by the lowest-priority (highest index) device. + int worstSlot = -1, worstRank = -1; + for (int s = 0; s < slotCount; s++) { + int rank = INT_MAX; + for (int p = 0; p < priorityCount; p++) { + if (localPrio[p][0] && wcscmp(localPrio[p], localIds[s]) == 0) { + rank = p; break; + } + } + if (rank > worstRank) { worstRank = rank; worstSlot = s; } + } + + if (worstSlot < 0 || worstRank <= newRank) return; // new device is not higher priority + + // Replace the worst slot with the new priority device. + WCHAR kId[32], kNm[32]; + swprintf_s(kId, L"Device%dId", worstSlot + 1); + swprintf_s(kNm, L"Device%dName", worstSlot + 1); + Wh_SetStringValue(kId, devId); + Wh_SetStringValue(kNm, L""); + + EnterCriticalSection(&g_stateLock); + lstrcpynW(g_cachedDevId[worstSlot], devId, 512); + g_cachedDevName[worstSlot][0] = L'\0'; + LeaveCriticalSection(&g_stateLock); + + PostMessageW(hWnd, WM_RELOAD_ALL, 0, 0); +} + // ─── Tray window ────────────────────────────────────────────────────────────── LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { @@ -1496,8 +1874,32 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) } } + } else if (msg == WM_INPUT) { + if (InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 1) { + UINT sz = 0; + GetRawInputData((HRAWINPUT)lParam, RID_INPUT, nullptr, &sz, sizeof(RAWINPUTHEADER)); + if (sz > 0) { + std::vector buf(sz); + if (GetRawInputData((HRAWINPUT)lParam, RID_INPUT, buf.data(), &sz, + sizeof(RAWINPUTHEADER)) == sz) { + auto* raw = reinterpret_cast(buf.data()); + if (raw->header.dwType == RIM_TYPEMOUSE && + (raw->data.mouse.usButtonFlags & RI_MOUSE_WHEEL)) { + POINT pt; GetCursorPos(&pt); + if (PtInRect(&g_trayIconRect, pt)) { + short delta = (short)raw->data.mouse.usButtonData; + int dir = (delta > 0) ? 1 : -1; + PostMessageW(hWnd, WM_TRAY_SCROLL, + (WPARAM)(LONG_PTR)dir, 0); + } + } + } + } + } + return DefWindowProcW(hWnd, msg, wParam, lParam); + } else if (msg == WM_TRAY_SCROLL) { - // Posted by LowLevelMouseProc when the user scrolls over the icon + // Posted by Raw Input handler when the user scrolls over the icon DWORD now = GetTickCount(); if (now - g_lastScrollTime > SCROLL_DEBOUNCE_MS && InterlockedExchange(&g_isProcessingClick, 1) == 0) { @@ -1518,11 +1920,12 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) } else if (msg == WM_UPDATE_HOOK_STATE) { LONG isScroll = InterlockedCompareExchange(&g_scrollToSwap, 0, 0); - if (isScroll && !g_mouseHook) { - g_mouseHook = SetWindowsHookExW(WH_MOUSE_LL, LowLevelMouseProc, g_hInstance, 0); - } else if (!isScroll && g_mouseHook) { - UnhookWindowsHookEx(g_mouseHook); - g_mouseHook = nullptr; + if (isScroll) { + RAWINPUTDEVICE rid = { 1, 2, RIDEV_INPUTSINK, hWnd }; + RegisterRawInputDevices(&rid, 1, sizeof(rid)); + } else { + RAWINPUTDEVICE rid = { 1, 2, RIDEV_REMOVE, nullptr }; + RegisterRawInputDevices(&rid, 1, sizeof(rid)); } } else if (msg == WM_RELOAD_ICONS) { @@ -1572,14 +1975,24 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) } } + } else if (msg == WM_PRIORITY_DEVICE_ACTIVE) { + WCHAR* devId = reinterpret_cast(lParam); + if (devId) { + HandlePriorityDeviceConnected(hWnd, devId); + delete[] devId; + } + } else if (msg == g_taskbarCreatedMsg && g_taskbarCreatedMsg != 0) { UpdateTrayTip(hWnd, TRUE); // re-add icon after explorer restart + RefreshTrayIconRect(); // re-acquire icon screen rect after explorer restart } else if (msg == WM_RELOAD_ALL) { // Full reload triggered by the settings dashboard after Save and Apply. - // Picks up new device assignments, icon choices, device count, and swap mode. + // Picks up new device assignments, icon choices, device count, swap mode, and priority list. LoadDeviceSelections(); + LoadPriorityList(); LoadUserIconsAndSettings(); + s_lastIcon = nullptr; // icon handles were recreated; force UpdateTrayTip to refresh UpdateTrayTip(hWnd, FALSE); PostMessageW(hWnd, WM_UPDATE_HOOK_STATE, 0, 0); @@ -1627,20 +2040,25 @@ DWORD WINAPI TrayThreadProc(LPVOID) { return 1; } - // Set a unique AUMID so the OS doesn't group this with the main Windhawk icon. - IPropertyStore* pps = nullptr; - if (SUCCEEDED(SHGetPropertyStoreForWindow(g_trayHwnd, IID_PPV_ARGS(&pps)))) { - PROPVARIANT var; - PropVariantInit(&var); - var.vt = VT_LPWSTR; - var.pwszVal = (LPWSTR)CoTaskMemAlloc(MAX_PATH * sizeof(WCHAR)); - if (var.pwszVal) { - lstrcpyW(var.pwszVal, L"BlackPaw.AudioSwitcher"); - pps->SetValue(PKEY_AppUserModel_ID, var); - CoTaskMemFree(var.pwszVal); + // Set a unique AUMID so the OS doesn't group this icon with the main Windhawk entry. + // Use GetProcAddress so we don't need propkey.h / shell linking in the mod build. + { + static const PROPERTYKEY PKEY_AUMID = + {{0x9F4C2855,0x9F79,0x4B39,{0xA8,0xD0,0xE1,0xD4,0x2D,0xE1,0xD5,0xF3}},5}; + using SHGetPSFWFn = HRESULT(WINAPI*)(HWND, REFIID, void**); + auto pfn = (SHGetPSFWFn)GetProcAddress( + GetModuleHandleW(L"shell32.dll"), "SHGetPropertyStoreForWindow"); + if (pfn) { + IPropertyStore* pps = nullptr; + if (SUCCEEDED(pfn(g_trayHwnd, IID_IPropertyStore, (void**)&pps))) { + PROPVARIANT pv = {}; + pv.vt = VT_LPWSTR; + pv.pwszVal = const_cast(L"BlackPaw.AudioSwap"); + pps->SetValue(PKEY_AUMID, pv); + pps->Commit(); + pps->Release(); + } } - pps->Commit(); - pps->Release(); } // Register for instant device-change notifications. @@ -1650,9 +2068,10 @@ DWORD WINAPI TrayThreadProc(LPVOID) { g_notifEnum->RegisterEndpointNotificationCallback(g_deviceNotifier); } - // Global mouse hook for scroll-to-swap (only installed in scroll mode). + // Register Raw Input for scroll-to-swap (bypasses WH_MOUSE_LL hook chain). if (InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 1) { - g_mouseHook = SetWindowsHookExW(WH_MOUSE_LL, LowLevelMouseProc, g_hInstance, 0); + RAWINPUTDEVICE rid = { 1, 2, RIDEV_INPUTSINK, g_trayHwnd }; + RegisterRawInputDevices(&rid, 1, sizeof(rid)); } UpdateTrayTip(g_trayHwnd, TRUE); @@ -1666,7 +2085,11 @@ DWORD WINAPI TrayThreadProc(LPVOID) { g_deviceNotifier->Release(); g_deviceNotifier = nullptr; g_notifEnum->Release(); g_notifEnum = nullptr; } - if (g_mouseHook) { UnhookWindowsHookEx(g_mouseHook); g_mouseHook = nullptr; } + // Unregister Raw Input (harmless if not registered). + { + RAWINPUTDEVICE rid = { 1, 2, RIDEV_REMOVE, nullptr }; + RegisterRawInputDevices(&rid, 1, sizeof(rid)); + } CoUninitialize(); return 0; } @@ -1722,6 +2145,7 @@ BOOL WhTool_ModInit() { LoadUserIconsAndSettings(); // sets g_deviceSlotCount first LoadDeviceSelections(); + LoadPriorityList(); g_trayThread = CreateThread(nullptr, 0, TrayThreadProc, nullptr, 0, nullptr); return TRUE; } @@ -1729,6 +2153,7 @@ BOOL WhTool_ModInit() { void WhTool_ModSettingsChanged() { RestoreMuteExternal(); LoadDeviceSelections(); + LoadPriorityList(); HWND hwnd = g_trayHwnd; if (hwnd) { PostMessageW(hwnd, WM_RELOAD_ICONS, 0, 0); @@ -1764,7 +2189,6 @@ void WhTool_ModUninit() { CloseHandle(g_workerThread); g_workerThread = nullptr; } - if (g_mouseHook) { UnhookWindowsHookEx(g_mouseHook); g_mouseHook = nullptr; } if (g_notifEnum && g_deviceNotifier) { g_notifEnum->UnregisterEndpointNotificationCallback(g_deviceNotifier); g_deviceNotifier->Release(); g_deviceNotifier = nullptr; From 9768478b6fa32f59be96a347dcc38c11ecd50e51 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Sat, 23 May 2026 21:50:21 +0300 Subject: [PATCH 31/56] Implement stable GUID for AudioSwap tray icon Added a stable GUID for the AudioSwap tray icon to ensure it has a process-independent identity. Updated multiple sections to use this GUID for tray icon management. --- mods/audioswap.wh.cpp | 57 +++++++++++++++++-------------------------- 1 file changed, 22 insertions(+), 35 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index 6e6bc818bf..68d57e40e1 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -54,11 +54,9 @@ Enable **Advanced Mode** in the Windhawk settings panel (gear icon → Settings ### v2.1.0 - New: Device Priority panel — rank your preferred devices; the highest-priority connected device is assigned to a swap slot automatically. - New: Advanced Mode toggle in Windhawk settings — shows the Device Priority panel in Mod Settings. -- Fixed: Scroll to Swap conflict with Monitor Sleep Button mod. - Fixed: GUI sizing on high-DPI monitors. - Fixed: Context menu now follows your Windows dark/light theme. -- Fixed: Context menu could sometimes stay open after clicking away. -- Fixed: Tray icon reappears correctly after Explorer restarts. +- Fixed: AudioSwap icon is now independent — no longer linked to the Windhawk tray icon. ### v2.0.0 - **New:** Native dashboard — right-click → **Mod Settings**. @@ -123,6 +121,11 @@ Enable **Advanced Mode** in the Windhawk settings panel (gear icon → Settings #include #include +// Stable GUID that gives our tray icon a process-independent identity. +// Windows uses this to track pin/unpin separately from windhawk.exe. +static const GUID AUDIOSWAP_TRAY_GUID = + {0xC8E2F174, 0x3B5A, 0x4D9C, {0x8F, 0x2E, 0x7A, 0x1D, 0x6B, 0x4C, 0x9E, 0x3F}}; + #define TRAY_ICON_ID 1 #define WM_TRAY_CALLBACK (WM_USER + 1) #define WM_UPDATE_TRAY_STATE (WM_USER + 2) @@ -1207,8 +1210,7 @@ static void RefreshTrayIconRect() { HWND hwnd = g_trayHwnd; if (!hwnd) return; NOTIFYICONIDENTIFIER nii = {sizeof(nii)}; - nii.hWnd = hwnd; - nii.uID = TRAY_ICON_ID; + nii.guidItem = AUDIOSWAP_TRAY_GUID; RECT newRect = {}; if (SUCCEEDED(Shell_NotifyIconGetRect(&nii, &newRect)) && newRect.right > newRect.left) { @@ -1220,9 +1222,10 @@ static void RefreshTrayIconRect() { // finish its layout pass. Send NIM_MODIFY to give Explorer the // necessary nudge to compute the icon's position. NOTIFYICONDATAW nid = {sizeof(nid)}; - nid.hWnd = hwnd; - nid.uID = TRAY_ICON_ID; - nid.uFlags = NIF_SHOWTIP; + nid.hWnd = hwnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_SHOWTIP | NIF_GUID; + nid.guidItem = AUDIOSWAP_TRAY_GUID; Shell_NotifyIconW(NIM_MODIFY, &nid); SetTimer(hwnd, TRAY_RECT_INIT_TIMER, 200, nullptr); } @@ -1582,7 +1585,8 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { NOTIFYICONDATAW nid = {sizeof(nid)}; nid.hWnd = hWnd; nid.uID = TRAY_ICON_ID; - nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_ICON; + nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_ICON | NIF_GUID; + nid.guidItem = AUDIOSWAP_TRAY_GUID; nid.uCallbackMessage = WM_TRAY_CALLBACK; if (configuredCount < 2) @@ -1599,9 +1603,11 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { if (isAdd) { NOTIFYICONDATAW nidVer = {sizeof(nidVer)}; - nidVer.hWnd = hWnd; - nidVer.uID = TRAY_ICON_ID; - nidVer.uVersion = NOTIFYICON_VERSION_4; + nidVer.hWnd = hWnd; + nidVer.uID = TRAY_ICON_ID; + nidVer.uFlags = NIF_GUID; + nidVer.guidItem = AUDIOSWAP_TRAY_GUID; + nidVer.uVersion = NOTIFYICON_VERSION_4; Shell_NotifyIconW(NIM_SETVERSION, &nidVer); } @@ -1999,8 +2005,10 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) } else if (msg == WM_CLOSE) { // Orderly shutdown: remove tray icon, then destroy window. NOTIFYICONDATAW nid = {sizeof(nid)}; - nid.hWnd = hWnd; - nid.uID = TRAY_ICON_ID; + nid.hWnd = hWnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_GUID; + nid.guidItem = AUDIOSWAP_TRAY_GUID; Shell_NotifyIconW(NIM_DELETE, &nid); DestroyWindow(hWnd); return 0; @@ -2040,27 +2048,6 @@ DWORD WINAPI TrayThreadProc(LPVOID) { return 1; } - // Set a unique AUMID so the OS doesn't group this icon with the main Windhawk entry. - // Use GetProcAddress so we don't need propkey.h / shell linking in the mod build. - { - static const PROPERTYKEY PKEY_AUMID = - {{0x9F4C2855,0x9F79,0x4B39,{0xA8,0xD0,0xE1,0xD4,0x2D,0xE1,0xD5,0xF3}},5}; - using SHGetPSFWFn = HRESULT(WINAPI*)(HWND, REFIID, void**); - auto pfn = (SHGetPSFWFn)GetProcAddress( - GetModuleHandleW(L"shell32.dll"), "SHGetPropertyStoreForWindow"); - if (pfn) { - IPropertyStore* pps = nullptr; - if (SUCCEEDED(pfn(g_trayHwnd, IID_IPropertyStore, (void**)&pps))) { - PROPVARIANT pv = {}; - pv.vt = VT_LPWSTR; - pv.pwszVal = const_cast(L"BlackPaw.AudioSwap"); - pps->SetValue(PKEY_AUMID, pv); - pps->Commit(); - pps->Release(); - } - } - } - // Register for instant device-change notifications. if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&g_notifEnum))) { From 895fec3c8035027d27804475d2685885b6541768 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Sun, 24 May 2026 00:11:38 +0300 Subject: [PATCH 32/56] Fix memory allocation for device ID copy Updated memory allocation for device ID copy to use dynamic size based on the actual length of the device ID string. thread handle leak fixed. Added @donateUrl. --- mods/audioswap.wh.cpp | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index 68d57e40e1..92a44ac25c 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -5,6 +5,7 @@ // @version 2.1.0 // @author BlackPaw // @github https://github.com/BlackPaw21 +// @donateUrl https://ko-fi.com/blackpaw21 // @include windhawk.exe // @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lcomdlg32 -ladvapi32 // ==/WindhawkMod== @@ -1385,8 +1386,9 @@ class AudioDeviceNotifier : public IMMNotificationClient { if (hwnd) { PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); if (pwstrDeviceId && pwstrDeviceId[0]) { - WCHAR* idCopy = new WCHAR[512]; - wcscpy_s(idCopy, 512, pwstrDeviceId); + size_t idLen = wcslen(pwstrDeviceId) + 1; + WCHAR* idCopy = new WCHAR[idLen]; + wcscpy_s(idCopy, idLen, pwstrDeviceId); if (!PostMessageW(hwnd, WM_PRIORITY_DEVICE_ACTIVE, 0, (LPARAM)idCopy)) delete[] idCopy; } @@ -1405,8 +1407,9 @@ class AudioDeviceNotifier : public IMMNotificationClient { if (hwnd) { PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); if (newState == DEVICE_STATE_ACTIVE && pwstrDeviceId && pwstrDeviceId[0]) { - WCHAR* idCopy = new WCHAR[512]; - wcscpy_s(idCopy, 512, pwstrDeviceId); + size_t idLen = wcslen(pwstrDeviceId) + 1; + WCHAR* idCopy = new WCHAR[idLen]; + wcscpy_s(idCopy, idLen, pwstrDeviceId); if (!PostMessageW(hwnd, WM_PRIORITY_DEVICE_ACTIVE, 0, (LPARAM)idCopy)) delete[] idCopy; } @@ -1765,8 +1768,7 @@ DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { static void SpawnCycleThread(int direction) { if (g_workerThread) { - if (WaitForSingleObject(g_workerThread, 0) == WAIT_OBJECT_0) - CloseHandle(g_workerThread); + CloseHandle(g_workerThread); g_workerThread = nullptr; } g_workerThread = CreateThread(nullptr, 0, WorkerThreadProc, From 9f012d35eb04efec04ff52445ea9b4ad3058886c Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Mon, 25 May 2026 16:50:02 +0300 Subject: [PATCH 33/56] Update and rename microswap.wh.cpp to micswitch.wh.cpp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Custom icon support — pick your own image for each device via Settings or right-click. - Renamed from MicroSwap to MicSwitch. - Added donate button on the mod page. - Context menu now follows your Windows dark/light theme. - Active device shown at the top of the right-click menu. - Fixed: rare crash when switching devices rapidly. - Fixed: occasional hang when Windhawk unloads the mod. - Fixed: tray window appeared in Alt+Tab. - Fixed: tray icon failed to load on some Windows configurations. --- mods/microswap.wh.cpp | 730 --------------------------------- mods/micswitch.wh.cpp | 913 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 913 insertions(+), 730 deletions(-) delete mode 100644 mods/microswap.wh.cpp create mode 100644 mods/micswitch.wh.cpp diff --git a/mods/microswap.wh.cpp b/mods/microswap.wh.cpp deleted file mode 100644 index 79a29d576a..0000000000 --- a/mods/microswap.wh.cpp +++ /dev/null @@ -1,730 +0,0 @@ -// ==WindhawkMod== -// @id microswap -// @name MicroSwap -// @description Adds a tray icon to instantly toggle between two preferred audio inputs (microphones). -// @version 1.0.0 -// @author BlackPaw -// @github https://github.com/BlackPaw21 -// @include windhawk.exe -// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lshlwapi -// ==/WindhawkMod== - -// ==WindhawkModReadme== -/* -# MicSwap -Instantly toggle between two audio input devices (microphones) from your system tray — no diving into Sound settings. - ---- - -## How to Use - -1. **Choose Icons** — Open the **Settings** tab and pick an icon for each of your two devices. -2. **Select Your Devices** — Right-click the tray icon. Use **Set as Input 1** and **Set as Input 2** to assign your inputs from the live device list. -3. **Toggle** — Left-click the tray icon at any time to swap between the two devices instantly. - -> The tray tooltip always shows the active input. On first run it will read *"Right-click to configure"* until both devices are assigned. - ---- - -## Changelog - -### v1.0.0 -- Initial release. Mirrors AudioSwap but targets audio input/capture devices. -- Right-click context menu auto-detects all active audio inputs and lets you assign Input 1 and Input 2 from a live list. -- Device selections persist across restarts. -- Toggle matches devices by their unique system ID for reliable switching regardless of device naming. -*/ -// ==/WindhawkModReadme== - -// ==WindhawkModSettings== -/* -- icon1: mic_classic - $name: First Device Icon - $options: - - mic_classic: Classic Microphone - - mic_modern: Modern Microphone - - headset_gaming: Gaming Headset - - headset_modern: Modern Gaming Headset - - earphones: Earphones -- icon2: headset_gaming - $name: Second Device Icon - $options: - - mic_classic: Classic Microphone - - mic_modern: Modern Microphone - - headset_gaming: Gaming Headset - - headset_modern: Modern Gaming Headset - - earphones: Earphones -*/ -// ==/WindhawkModSettings== - -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -#define TRAY_ICON_ID 1 -#define WM_TRAY_CALLBACK (WM_USER + 1) -#define WM_UPDATE_TRAY_STATE (WM_USER + 2) -#define MENU_DEVICE1_BASE 1000 -#define MENU_DEVICE2_BASE 2000 -#define MENU_OPEN_WINDHAWK 3000 -#define MENU_MAX_DEVICES 32 - -const DWORD CLICK_DEBOUNCE_MS = 500; - -static volatile LONG g_isProcessingClick = 0; -static HANDLE g_trayThread = nullptr; -static HANDLE g_workerThread = nullptr; -static HWND g_trayHwnd = nullptr; -static HINSTANCE g_hInstance = nullptr; -static WCHAR g_windhawkPath[MAX_PATH] = {0}; -static HICON g_hWindHawkIcon = nullptr; -static HBITMAP g_hWindHawkBmp = nullptr; -static HICON g_iconDev1 = nullptr; -static HBITMAP g_hIconDev1Bmp = nullptr; -static HICON g_iconDev2 = nullptr; -static HBITMAP g_hIconDev2Bmp = nullptr; -static DWORD g_lastClickTime = 0; -static UINT g_taskbarCreatedMsg = 0; - -// Cached device selections (loaded from Windhawk storage) -static WCHAR g_cachedDev1Id[512] = {0}; -static WCHAR g_cachedDev1Name[256] = {0}; -static WCHAR g_cachedDev2Id[512] = {0}; -static WCHAR g_cachedDev2Name[256] = {0}; - -const CLSID CLSID_CPolicyConfigClient = { - 0x870af99c, 0x171d, 0x4f9e, {0xaf, 0x0d, 0xe6, 0x3d, 0xf4, 0x0c, 0x2b, 0xc9} -}; -const IID IID_IPolicyConfig_Win10_11 = { - 0xf8679f50, 0x850a, 0x41cf, {0x9c, 0x72, 0x43, 0x0f, 0x29, 0x02, 0x90, 0xc8} -}; - -MIDL_INTERFACE("f8679f50-850a-41cf-9c72-430f290290c8") -IPolicyConfig : public IUnknown -{ -public: - virtual HRESULT STDMETHODCALLTYPE GetMixFormat(PCWSTR, void**) = 0; - virtual HRESULT STDMETHODCALLTYPE GetDeviceFormat(PCWSTR, INT, void**) = 0; - virtual HRESULT STDMETHODCALLTYPE ResetDeviceFormat(PCWSTR) = 0; - virtual HRESULT STDMETHODCALLTYPE SetDeviceFormat(PCWSTR, void*, void*) = 0; - virtual HRESULT STDMETHODCALLTYPE GetProcessingPeriod(PCWSTR, INT, PINT, PINT) = 0; - virtual HRESULT STDMETHODCALLTYPE SetProcessingPeriod(PCWSTR, PINT) = 0; - virtual HRESULT STDMETHODCALLTYPE GetShareMode(PCWSTR, void*) = 0; - virtual HRESULT STDMETHODCALLTYPE SetShareMode(PCWSTR, void*) = 0; - virtual HRESULT STDMETHODCALLTYPE GetPropertyValue(PCWSTR, const PROPERTYKEY&, PROPVARIANT*) = 0; - virtual HRESULT STDMETHODCALLTYPE SetPropertyValue(PCWSTR, const PROPERTYKEY&, PROPVARIANT*) = 0; - virtual HRESULT STDMETHODCALLTYPE SetDefaultEndpoint(PCWSTR wszDeviceId, ERole eRole) = 0; - virtual HRESULT STDMETHODCALLTYPE SetEndpointVisibility(PCWSTR, INT) = 0; -}; - -int GetIconIndex(PCWSTR iconSetting) { - if (iconSetting) { - if (wcscmp(iconSetting, L"mic_classic") == 0) return 3; - if (wcscmp(iconSetting, L"mic_modern") == 0) return 92; - if (wcscmp(iconSetting, L"headset_gaming") == 0) return 8; - if (wcscmp(iconSetting, L"headset_modern") == 0) return 95; - if (wcscmp(iconSetting, L"earphones") == 0) return 6; - } - return 3; -} - -void LoadDeviceSelections() { - g_cachedDev1Id[0] = g_cachedDev1Name[0] = L'\0'; - g_cachedDev2Id[0] = g_cachedDev2Name[0] = L'\0'; - - Wh_GetStringValue(L"Device1Id", g_cachedDev1Id, ARRAYSIZE(g_cachedDev1Id)); - Wh_GetStringValue(L"Device1Name", g_cachedDev1Name, ARRAYSIZE(g_cachedDev1Name)); - Wh_GetStringValue(L"Device2Id", g_cachedDev2Id, ARRAYSIZE(g_cachedDev2Id)); - Wh_GetStringValue(L"Device2Name", g_cachedDev2Name, ARRAYSIZE(g_cachedDev2Name)); -} - -void SaveDeviceSelection(int slot, PCWSTR deviceId, PCWSTR friendlyName) { - if (slot == 1) { - Wh_SetStringValue(L"Device1Id", deviceId); - Wh_SetStringValue(L"Device1Name", friendlyName); - lstrcpynW(g_cachedDev1Id, deviceId, 512); - lstrcpynW(g_cachedDev1Name, friendlyName, 256); - } else { - Wh_SetStringValue(L"Device2Id", deviceId); - Wh_SetStringValue(L"Device2Name", friendlyName); - lstrcpynW(g_cachedDev2Id, deviceId, 512); - lstrcpynW(g_cachedDev2Name, friendlyName, 256); - } -} - -void LoadUserIconsAndSettings() { - if (g_iconDev1) DestroyIcon(g_iconDev1); - if (g_iconDev2) DestroyIcon(g_iconDev2); - if (g_hIconDev1Bmp) DeleteObject(g_hIconDev1Bmp); - if (g_hIconDev2Bmp) DeleteObject(g_hIconDev2Bmp); - g_hIconDev1Bmp = nullptr; - g_hIconDev2Bmp = nullptr; - - PCWSTR s1 = Wh_GetStringSetting(L"icon1"); - PCWSTR s2 = Wh_GetStringSetting(L"icon2"); - - ExtractIconExW(L"ddores.dll", GetIconIndex(s1), nullptr, &g_iconDev1, 1); - ExtractIconExW(L"ddores.dll", GetIconIndex(s2), nullptr, &g_iconDev2, 1); - - if (g_iconDev1) { - ICONINFO iconInfo = {0}; - if (GetIconInfo(g_iconDev1, &iconInfo)) { - g_hIconDev1Bmp = iconInfo.hbmColor; - if (!g_hIconDev1Bmp) { - g_hIconDev1Bmp = iconInfo.hbmMask; - } else if (iconInfo.hbmMask) { - DeleteObject(iconInfo.hbmMask); - } - } - } - - if (g_iconDev2) { - ICONINFO iconInfo = {0}; - if (GetIconInfo(g_iconDev2, &iconInfo)) { - g_hIconDev2Bmp = iconInfo.hbmColor; - if (!g_hIconDev2Bmp) { - g_hIconDev2Bmp = iconInfo.hbmMask; - } else if (iconInfo.hbmMask) { - DeleteObject(iconInfo.hbmMask); - } - } - } - - if (s1) Wh_FreeStringSetting(s1); - if (s2) Wh_FreeStringSetting(s2); -} - -void UpdateTrayTip(HWND hWnd, BOOL isAdd) { - WCHAR currentDev[256] = L"Unknown Device"; - WCHAR currentId[512] = {0}; - HICON currentIcon = g_iconDev1; - - IMMDeviceEnumerator* pEnum = nullptr; - if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, - __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { - IMMDevice* pDefaultDevice = nullptr; - if (SUCCEEDED(pEnum->GetDefaultAudioEndpoint(eCapture, eMultimedia, &pDefaultDevice))) { - LPWSTR pId = nullptr; - if (SUCCEEDED(pDefaultDevice->GetId(&pId))) { - lstrcpynW(currentId, pId, 512); - CoTaskMemFree(pId); - } - IPropertyStore* pStore = nullptr; - if (SUCCEEDED(pDefaultDevice->OpenPropertyStore(STGM_READ, &pStore))) { - PROPVARIANT varName; - PropVariantInit(&varName); - if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &varName)) && varName.pwszVal) - lstrcpynW(currentDev, varName.pwszVal, 256); - PropVariantClear(&varName); - pStore->Release(); - } - pDefaultDevice->Release(); - } - pEnum->Release(); - } - - if (g_cachedDev1Id[0] != L'\0' && wcscmp(currentId, g_cachedDev1Id) == 0) - currentIcon = g_iconDev1; - else if (g_cachedDev2Id[0] != L'\0' && wcscmp(currentId, g_cachedDev2Id) == 0) - currentIcon = g_iconDev2; - - static WCHAR s_lastDev[256] = {0}; - static HICON s_lastIcon = nullptr; - if (!isAdd && wcscmp(currentDev, s_lastDev) == 0 && currentIcon == s_lastIcon) - return; - lstrcpyW(s_lastDev, currentDev); - s_lastIcon = currentIcon; - - NOTIFYICONDATAW nid = {sizeof(nid)}; - nid.hWnd = hWnd; - nid.uID = TRAY_ICON_ID; - nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_ICON; - nid.uCallbackMessage = WM_TRAY_CALLBACK; - - if (g_cachedDev1Id[0] == L'\0' || g_cachedDev2Id[0] == L'\0') - swprintf_s(nid.szTip, L"MicSwap: Right-click to configure"); - else - swprintf_s(nid.szTip, L"Mic: %s", currentDev); - - nid.hIcon = currentIcon; - Shell_NotifyIconW(isAdd ? NIM_ADD : NIM_MODIFY, &nid); -} - -BOOL ToggleAudioDevice() { - if (g_cachedDev1Id[0] == L'\0' || g_cachedDev2Id[0] == L'\0') { - return FALSE; - } - - CoInitialize(nullptr); - IMMDeviceEnumerator* pEnum = nullptr; - if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, - __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { - CoUninitialize(); return FALSE; - } - - IMMDevice* pDefaultDevice = nullptr; - if (FAILED(pEnum->GetDefaultAudioEndpoint(eCapture, eMultimedia, &pDefaultDevice))) { - pEnum->Release(); CoUninitialize(); return FALSE; - } - - LPWSTR currentId = nullptr; - HRESULT hr = pDefaultDevice->GetId(¤tId); - pDefaultDevice->Release(); - if (FAILED(hr) || !currentId) { - pEnum->Release(); CoUninitialize(); return FALSE; - } - - PCWSTR targetId = (wcscmp(currentId, g_cachedDev1Id) == 0) - ? g_cachedDev2Id - : g_cachedDev1Id; - - if (!targetId || !targetId[0]) { - CoTaskMemFree(currentId); - pEnum->Release(); CoUninitialize(); return FALSE; - } - - if (currentId) CoTaskMemFree(currentId); - - IPolicyConfig* pPolicyConfig = nullptr; - if (SUCCEEDED(CoCreateInstance(CLSID_CPolicyConfigClient, nullptr, CLSCTX_ALL, - IID_IPolicyConfig_Win10_11, (void**)&pPolicyConfig))) { - pPolicyConfig->SetDefaultEndpoint(targetId, eConsole); - pPolicyConfig->SetDefaultEndpoint(targetId, eMultimedia); - pPolicyConfig->SetDefaultEndpoint(targetId, eCommunications); - pPolicyConfig->Release(); - } - - pEnum->Release(); - CoUninitialize(); - return TRUE; -} - -struct AudioDevice { - WCHAR id[512]; - WCHAR name[256]; -}; - -void BuildAndShowContextMenu(HWND hWnd) { - AudioDevice devices[MENU_MAX_DEVICES]; - int deviceCount = 0; - - IMMDeviceEnumerator* pEnum = nullptr; - if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, - __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { - IMMDeviceCollection* pDevices = nullptr; - if (SUCCEEDED(pEnum->EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE, &pDevices))) { - UINT count = 0; - pDevices->GetCount(&count); - if (count > MENU_MAX_DEVICES) count = MENU_MAX_DEVICES; - - for (UINT i = 0; i < count; i++) { - IMMDevice* pDevice = nullptr; - if (FAILED(pDevices->Item(i, &pDevice))) continue; - - LPWSTR pId = nullptr; - if (SUCCEEDED(pDevice->GetId(&pId))) { - lstrcpynW(devices[deviceCount].id, pId, 512); - CoTaskMemFree(pId); - - IPropertyStore* pStore = nullptr; - if (SUCCEEDED(pDevice->OpenPropertyStore(STGM_READ, &pStore))) { - PROPVARIANT varName; - PropVariantInit(&varName); - if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &varName)) && varName.pwszVal) { - lstrcpynW(devices[deviceCount].name, varName.pwszVal, 256); - deviceCount++; - } - PropVariantClear(&varName); - pStore->Release(); - } - } - pDevice->Release(); - } - pDevices->Release(); - } - pEnum->Release(); - } - - HMENU hSub1 = CreatePopupMenu(); - HMENU hSub2 = CreatePopupMenu(); - - for (int i = 0; i < deviceCount; i++) { - UINT flags1 = MF_STRING | (wcscmp(devices[i].id, g_cachedDev1Id) == 0 ? MF_CHECKED : 0); - UINT flags2 = MF_STRING | (wcscmp(devices[i].id, g_cachedDev2Id) == 0 ? MF_CHECKED : 0); - AppendMenuW(hSub1, flags1, MENU_DEVICE1_BASE + i, devices[i].name); - AppendMenuW(hSub2, flags2, MENU_DEVICE2_BASE + i, devices[i].name); - } - - HMENU hMenu = CreatePopupMenu(); - - MENUITEMINFOW mii1 = {sizeof(mii1)}; - mii1.fMask = MIIM_SUBMENU | MIIM_STRING | MIIM_BITMAP; - mii1.hSubMenu = hSub1; - mii1.dwTypeData = (LPWSTR)L"Set as Input 1"; - mii1.hbmpItem = g_hIconDev1Bmp; - InsertMenuItemW(hMenu, (UINT)-1, TRUE, &mii1); - - MENUITEMINFOW mii2 = {sizeof(mii2)}; - mii2.fMask = MIIM_SUBMENU | MIIM_STRING | MIIM_BITMAP; - mii2.hSubMenu = hSub2; - mii2.dwTypeData = (LPWSTR)L"Set as Input 2"; - mii2.hbmpItem = g_hIconDev2Bmp; - InsertMenuItemW(hMenu, (UINT)-1, TRUE, &mii2); - - AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); - - MENUITEMINFOW mii = {sizeof(mii)}; - mii.fMask = MIIM_ID | MIIM_STRING | MIIM_BITMAP; - mii.wID = MENU_OPEN_WINDHAWK; - mii.dwTypeData = (LPWSTR)L"Open WindHawk"; - mii.hbmpItem = g_hWindHawkBmp; - InsertMenuItemW(hMenu, (UINT)-1, TRUE, &mii); - - AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); - - WCHAR statusText[300]; - if (g_cachedDev1Id[0] == L'\0' || g_cachedDev2Id[0] == L'\0') { - lstrcpyW(statusText, L"Right-click to configure inputs"); - } else { - WCHAR activeName[256] = L"Unknown"; - IMMDeviceEnumerator* pEnum2 = nullptr; - if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, - __uuidof(IMMDeviceEnumerator), (void**)&pEnum2))) { - IMMDevice* pDefault = nullptr; - if (SUCCEEDED(pEnum2->GetDefaultAudioEndpoint(eCapture, eMultimedia, &pDefault))) { - IPropertyStore* pStore = nullptr; - if (SUCCEEDED(pDefault->OpenPropertyStore(STGM_READ, &pStore))) { - PROPVARIANT v; PropVariantInit(&v); - if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &v)) && v.pwszVal) - lstrcpynW(activeName, v.pwszVal, 256); - PropVariantClear(&v); - pStore->Release(); - } - pDefault->Release(); - } - pEnum2->Release(); - } - swprintf_s(statusText, L"Active: %s", activeName); - } - AppendMenuW(hMenu, MF_STRING | MF_GRAYED, 0, statusText); - - POINT pt; - GetCursorPos(&pt); - SetForegroundWindow(hWnd); - int cmd = TrackPopupMenu(hMenu, TPM_RETURNCMD | TPM_RIGHTBUTTON | TPM_BOTTOMALIGN | TPM_RIGHTALIGN, - pt.x, pt.y, 0, hWnd, nullptr); - PostMessageW(hWnd, WM_NULL, 0, 0); - - DestroyMenu(hMenu); - - if (cmd >= MENU_DEVICE1_BASE && cmd < MENU_DEVICE1_BASE + deviceCount) { - int idx = cmd - MENU_DEVICE1_BASE; - SaveDeviceSelection(1, devices[idx].id, devices[idx].name); - PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); - } else if (cmd >= MENU_DEVICE2_BASE && cmd < MENU_DEVICE2_BASE + deviceCount) { - int idx = cmd - MENU_DEVICE2_BASE; - SaveDeviceSelection(2, devices[idx].id, devices[idx].name); - PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); - } else if (cmd == MENU_OPEN_WINDHAWK) { - SHELLEXECUTEINFOW sei; - ZeroMemory(&sei, sizeof(sei)); - sei.cbSize = sizeof(sei); - sei.lpFile = g_windhawkPath; - sei.nShow = SW_SHOWNORMAL; - ShellExecuteExW(&sei); - } -} - -DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { - if (ToggleAudioDevice() && g_trayHwnd) { - PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, 0, 0); - } - InterlockedExchange(&g_isProcessingClick, 0); - return 0; -} - -LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { - if (msg == WM_TRAY_CALLBACK && lParam == WM_RBUTTONUP) { - BuildAndShowContextMenu(hWnd); - } else if (msg == WM_TRAY_CALLBACK && lParam == WM_LBUTTONUP) { - if (InterlockedExchange(&g_isProcessingClick, 1) == 0) { - DWORD now = GetTickCount(); - if (now - g_lastClickTime > CLICK_DEBOUNCE_MS) { - g_lastClickTime = now; - if (g_workerThread) { CloseHandle(g_workerThread); g_workerThread = nullptr; } - g_workerThread = CreateThread(nullptr, 0, WorkerThreadProc, nullptr, 0, nullptr); - } else InterlockedExchange(&g_isProcessingClick, 0); - } - } else if (msg == WM_UPDATE_TRAY_STATE || (msg == WM_TIMER && wParam == 1)) { - UpdateTrayTip(hWnd, FALSE); - } else if (msg == g_taskbarCreatedMsg && g_taskbarCreatedMsg != 0) { - UpdateTrayTip(hWnd, TRUE); - } else if (msg == WM_DESTROY) { - KillTimer(hWnd, 1); - NOTIFYICONDATAW nid = {sizeof(nid)}; - nid.hWnd = hWnd; - nid.uID = TRAY_ICON_ID; - Shell_NotifyIconW(NIM_DELETE, &nid); - PostQuitMessage(0); - } - return DefWindowProcW(hWnd, msg, wParam, lParam); -} - -DWORD WINAPI TrayThreadProc(LPVOID) { - CoInitialize(nullptr); - g_taskbarCreatedMsg = RegisterWindowMessageW(L"TaskbarCreated"); - WNDCLASSW wc = {0}; - wc.lpfnWndProc = TrayWndProc; - wc.hInstance = g_hInstance; - wc.lpszClassName = L"MicSwitcherWindowClass"; - RegisterClassW(&wc); - g_trayHwnd = CreateWindowExW(0, wc.lpszClassName, L"Mic Switcher", WS_OVERLAPPED, 0,0,1,1, nullptr, nullptr, g_hInstance, nullptr); - - IPropertyStore* pps = nullptr; - if (SUCCEEDED(SHGetPropertyStoreForWindow(g_trayHwnd, IID_PPV_ARGS(&pps)))) { - PROPVARIANT var; var.vt = VT_LPWSTR; var.pwszVal = (LPWSTR)CoTaskMemAlloc(MAX_PATH); - if (var.pwszVal) { - lstrcpyW(var.pwszVal, L"BlackPaw.MicSwitcher"); - pps->SetValue(PKEY_AppUserModel_ID, var); - CoTaskMemFree(var.pwszVal); - } - pps->Commit(); pps->Release(); - } - - SetTimer(g_trayHwnd, 1, 1500, nullptr); - UpdateTrayTip(g_trayHwnd, TRUE); - MSG msg; - while (GetMessageW(&msg, nullptr, 0, 0) > 0) { DispatchMessageW(&msg); } - CoUninitialize(); - return 0; -} - -BOOL WhTool_ModInit() { - Wh_Log(L"MicSwap Mod Init"); - g_hInstance = GetModuleHandle(nullptr); - GetModuleFileName(nullptr, g_windhawkPath, ARRAYSIZE(g_windhawkPath)); - ExtractIconExW(L"ddores.dll", 98, nullptr, &g_hWindHawkIcon, 1); - if (!g_hWindHawkIcon) ExtractIconExW(L"ddores.dll", 94, nullptr, &g_hWindHawkIcon, 1); - if (!g_hWindHawkIcon) ExtractIconExW(L"ddores.dll", 95, nullptr, &g_hWindHawkIcon, 1); - if (!g_hWindHawkIcon) ExtractIconExW(L"ddores.dll", 6, nullptr, &g_hWindHawkIcon, 1); - if (g_hWindHawkIcon) { - ICONINFO iconInfo = {0}; - GetIconInfo(g_hWindHawkIcon, &iconInfo); - g_hWindHawkBmp = iconInfo.hbmColor; - if (!g_hWindHawkBmp) { - g_hWindHawkBmp = iconInfo.hbmMask; - } else if (iconInfo.hbmMask) { - DeleteObject(iconInfo.hbmMask); - } - } - LoadUserIconsAndSettings(); - LoadDeviceSelections(); - g_trayThread = CreateThread(nullptr, 0, TrayThreadProc, nullptr, 0, nullptr); - return TRUE; -} - -void WhTool_ModSettingsChanged() { - LoadUserIconsAndSettings(); - LoadDeviceSelections(); - if (g_trayHwnd) PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, 0, 0); -} - -void WhTool_ModUninit() { - Wh_Log(L"MicSwap Mod Uninit"); - if (g_trayHwnd) PostMessageW(g_trayHwnd, WM_DESTROY, 0, 0); - if (g_trayThread) { WaitForSingleObject(g_trayThread, 2000); CloseHandle(g_trayThread); g_trayThread = nullptr; } - if (g_workerThread) { WaitForSingleObject(g_workerThread, 2000); CloseHandle(g_workerThread); g_workerThread = nullptr; } - if (g_iconDev1) { DestroyIcon(g_iconDev1); g_iconDev1 = nullptr; } - if (g_iconDev2) { DestroyIcon(g_iconDev2); g_iconDev2 = nullptr; } - if (g_hIconDev1Bmp) { DeleteObject(g_hIconDev1Bmp); g_hIconDev1Bmp = nullptr; } - if (g_hIconDev2Bmp) { DeleteObject(g_hIconDev2Bmp); g_hIconDev2Bmp = nullptr; } - if (g_hWindHawkIcon) { DestroyIcon(g_hWindHawkIcon); g_hWindHawkIcon = nullptr; } - if (g_hWindHawkBmp) { DeleteObject(g_hWindHawkBmp); g_hWindHawkBmp = nullptr; } -} - -//////////////////////////////////////////////////////////////////////////////// -// Windhawk tool mod implementation for mods which don't need to inject to other -// processes or hook other functions. Context: -// https://github.com/ramensoftware/windhawk/wiki/Mods-as-tools:-Running-mods-in-a-dedicated-process -// -// The mod will load and run in a dedicated windhawk.exe process. -// -// Paste the code below as part of the mod code, and use these callbacks: -// * WhTool_ModInit -// * WhTool_ModSettingsChanged -// * WhTool_ModUninit -// -// Currently, other callbacks are not supported. - -bool g_isToolModProcessLauncher; -HANDLE g_toolModProcessMutex; - -void WINAPI EntryPoint_Hook() { - Wh_Log(L">"); - ExitThread(0); -} - -BOOL Wh_ModInit() { - DWORD sessionId; - if (ProcessIdToSessionId(GetCurrentProcessId(), &sessionId) && - sessionId == 0) { - return FALSE; - } - - bool isExcluded = false; - bool isToolModProcess = false; - bool isCurrentToolModProcess = false; - int argc; - LPWSTR* argv = CommandLineToArgvW(GetCommandLine(), &argc); - if (!argv) { - Wh_Log(L"CommandLineToArgvW failed"); - return FALSE; - } - - for (int i = 1; i < argc; i++) { - if (wcscmp(argv[i], L"-service") == 0 || - wcscmp(argv[i], L"-service-start") == 0 || - wcscmp(argv[i], L"-service-stop") == 0) { - isExcluded = true; - break; - } - } - - for (int i = 1; i < argc - 1; i++) { - if (wcscmp(argv[i], L"-tool-mod") == 0) { - isToolModProcess = true; - if (wcscmp(argv[i + 1], WH_MOD_ID) == 0) { - isCurrentToolModProcess = true; - } - break; - } - } - - LocalFree(argv); - - if (isExcluded) { - return FALSE; - } - - if (isCurrentToolModProcess) { - g_toolModProcessMutex = - CreateMutex(nullptr, TRUE, L"windhawk-tool-mod_" WH_MOD_ID); - if (!g_toolModProcessMutex) { - Wh_Log(L"CreateMutex failed"); - ExitProcess(1); - } - - if (GetLastError() == ERROR_ALREADY_EXISTS) { - Wh_Log(L"Tool mod already running (%s)", WH_MOD_ID); - ExitProcess(1); - } - - if (!WhTool_ModInit()) { - ExitProcess(1); - } - - IMAGE_DOS_HEADER* dosHeader = - (IMAGE_DOS_HEADER*)GetModuleHandle(nullptr); - IMAGE_NT_HEADERS* ntHeaders = - (IMAGE_NT_HEADERS*)((BYTE*)dosHeader + dosHeader->e_lfanew); - - DWORD entryPointRVA = ntHeaders->OptionalHeader.AddressOfEntryPoint; - void* entryPoint = (BYTE*)dosHeader + entryPointRVA; - - Wh_SetFunctionHook(entryPoint, (void*)EntryPoint_Hook, nullptr); - return TRUE; - } - - if (isToolModProcess) { - return FALSE; - } - - g_isToolModProcessLauncher = true; - return TRUE; -} - -void Wh_ModAfterInit() { - if (!g_isToolModProcessLauncher) { - return; - } - - WCHAR currentProcessPath[MAX_PATH]; - switch (GetModuleFileName(nullptr, currentProcessPath, - ARRAYSIZE(currentProcessPath))) { - case 0: - case ARRAYSIZE(currentProcessPath): - Wh_Log(L"GetModuleFileName failed"); - return; - } - - WCHAR - commandLine[MAX_PATH + 2 + - (sizeof(L" -tool-mod \"" WH_MOD_ID "\"") / sizeof(WCHAR)) - 1]; - swprintf_s(commandLine, L"\"%s\" -tool-mod \"%s\"", currentProcessPath, - WH_MOD_ID); - - HMODULE kernelModule = GetModuleHandle(L"kernelbase.dll"); - if (!kernelModule) { - kernelModule = GetModuleHandle(L"kernel32.dll"); - if (!kernelModule) { - Wh_Log(L"No kernelbase.dll/kernel32.dll"); - return; - } - } - - using CreateProcessInternalW_t = BOOL(WINAPI*)( - HANDLE hUserToken, LPCWSTR lpApplicationName, LPWSTR lpCommandLine, - LPSECURITY_ATTRIBUTES lpProcessAttributes, - LPSECURITY_ATTRIBUTES lpThreadAttributes, WINBOOL bInheritHandles, - DWORD dwCreationFlags, LPVOID lpEnvironment, LPCWSTR lpCurrentDirectory, - LPSTARTUPINFOW lpStartupInfo, - LPPROCESS_INFORMATION lpProcessInformation, - PHANDLE hRestrictedUserToken); - CreateProcessInternalW_t pCreateProcessInternalW = - (CreateProcessInternalW_t)GetProcAddress(kernelModule, - "CreateProcessInternalW"); - if (!pCreateProcessInternalW) { - Wh_Log(L"No CreateProcessInternalW"); - return; - } - - STARTUPINFO si{ - .cb = sizeof(STARTUPINFO), - .dwFlags = STARTF_FORCEOFFFEEDBACK, - }; - PROCESS_INFORMATION pi; - if (!pCreateProcessInternalW(nullptr, currentProcessPath, commandLine, - nullptr, nullptr, FALSE, NORMAL_PRIORITY_CLASS, - nullptr, nullptr, &si, &pi, nullptr)) { - Wh_Log(L"CreateProcess failed"); - return; - } - - CloseHandle(pi.hProcess); - CloseHandle(pi.hThread); -} - -void Wh_ModSettingsChanged() { - if (g_isToolModProcessLauncher) { - return; - } - - WhTool_ModSettingsChanged(); -} - -void Wh_ModUninit() { - if (g_isToolModProcessLauncher) { - return; - } - - WhTool_ModUninit(); - ExitProcess(0); -} diff --git a/mods/micswitch.wh.cpp b/mods/micswitch.wh.cpp new file mode 100644 index 0000000000..1b15a02289 --- /dev/null +++ b/mods/micswitch.wh.cpp @@ -0,0 +1,913 @@ +// ==WindhawkMod== +// @id micswitch +// @name MicSwitch +// @description Tray icon to instantly toggle between two preferred audio inputs (microphones). +// @version 1.1.0 +// @author BlackPaw +// @github https://github.com/BlackPaw21 +// @donateUrl https://ko-fi.com/blackpaw21 +// @include windhawk.exe +// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lcomdlg32 -ladvapi32 +// ==/WindhawkMod== + +// ==WindhawkModReadme== +/* +# MicSwitch +Instantly toggle between two audio input devices (microphones) from your system tray — no diving into Sound settings. + +--- + +## How to Use + +1. **Choose Icons** — Open the **Settings** tab and pick an icon for each of your two devices. +2. **Select Your Devices** — Right-click the tray icon. Use **Set as Input 1** and **Set as Input 2** to assign your inputs from the live device list. +3. **Toggle** — Left-click the tray icon at any time to swap between the two devices instantly. + +> The tray tooltip always shows the active input. On first run it will read *"Right-click to configure"* until both devices are assigned. + +--- + +## Changelog + +### v1.1.0 +- Custom icon support — pick your own image for each device via Settings or right-click. +- Renamed from MicroSwap to MicSwitch. +- Added donate button on the mod page. +- Context menu now follows your Windows dark/light theme. +- Active device shown at the top of the right-click menu. +- Fixed: rare crash when switching devices rapidly. +- Fixed: occasional hang when Windhawk unloads the mod. +- Fixed: tray window appeared in Alt+Tab. +- Fixed: tray icon failed to load on some Windows configurations. + +### v1.0.0 +- Initial release. +- Right-click the tray icon to assign your two microphones. Left-click to swap between them. +- Device selections are remembered across restarts. +*/ +// ==/WindhawkModReadme== + +// ==WindhawkModSettings== +/* +- icon1: mic_classic + $name: First Device Icon + $options: + - mic_classic: Classic Microphone + - mic_modern: Modern Microphone + - headset_gaming: Gaming Headset + - headset_modern: Modern Gaming Headset + - earphones: Earphones + - custom: Custom Icon +- icon2: headset_gaming + $name: Second Device Icon + $options: + - mic_classic: Classic Microphone + - mic_modern: Modern Microphone + - headset_gaming: Gaming Headset + - headset_modern: Modern Gaming Headset + - earphones: Earphones + - custom: Custom Icon +*/ +// ==/WindhawkModSettings== + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#define TRAY_ICON_ID 1 +#define WM_TRAY_CALLBACK (WM_USER + 1) +#define WM_UPDATE_TRAY_STATE (WM_USER + 2) +#define WM_SHOW_FILE_PICKER (WM_USER + 5) +#define WM_RELOAD_ICONS (WM_USER + 6) +#define MENU_DEVICE1_BASE 1000 +#define MENU_DEVICE2_BASE 2000 +#define MENU_OPEN_WINDHAWK 3000 +#define MENU_CUSTOM_ICON_BASE 8000 +#define MENU_MAX_DEVICES 32 + +// Stable GUID that gives our tray icon a process-independent identity. +static const GUID MICSWITCH_TRAY_GUID = + {0x7174FA7E, 0x93F1, 0x4110, {0x8B, 0x83, 0xA4, 0xAD, 0x2C, 0x76, 0x9C, 0x3B}}; + +const DWORD CLICK_DEBOUNCE_MS = 500; + +static volatile LONG g_isProcessingClick = 0; +static HANDLE g_trayThread = nullptr; +static HANDLE g_workerThread = nullptr; +static volatile HWND g_trayHwnd = nullptr; +static HINSTANCE g_hInstance = nullptr; +static WCHAR g_windhawkPath[MAX_PATH] = {}; +static WCHAR g_ddoresDllPath[MAX_PATH] = {}; +static HICON g_hWindHawkIcon = nullptr; +static HBITMAP g_hWindHawkBmp = nullptr; +static HICON g_iconDev1 = nullptr; +static HBITMAP g_hIconDev1Bmp = nullptr; +static HICON g_iconDev2 = nullptr; +static HBITMAP g_hIconDev2Bmp = nullptr; +static DWORD g_lastClickTime = 0; +static UINT g_taskbarCreatedMsg = 0; +static WCHAR g_lastIconSetting[2][32] = {}; + +// ── Shared state — protected by g_stateLock ────────────────────────────────── +// Written by main thread (LoadDeviceSelections via WhTool_ModSettingsChanged) +// and tray thread (SaveDeviceSelection). Read by worker thread (ToggleAudioDevice). +static CRITICAL_SECTION g_stateLock; +static WCHAR g_cachedDev1Id[512] = {}; +static WCHAR g_cachedDev1Name[256] = {}; +static WCHAR g_cachedDev2Id[512] = {}; +static WCHAR g_cachedDev2Name[256] = {}; +// ───────────────────────────────────────────────────────────────────────────── + +const CLSID CLSID_CPolicyConfigClient = { + 0x870af99c, 0x171d, 0x4f9e, {0xaf, 0x0d, 0xe6, 0x3d, 0xf4, 0x0c, 0x2b, 0xc9} +}; +const IID IID_IPolicyConfig_Win10_11 = { + 0xf8679f50, 0x850a, 0x41cf, {0x9c, 0x72, 0x43, 0x0f, 0x29, 0x02, 0x90, 0xc8} +}; + +MIDL_INTERFACE("f8679f50-850a-41cf-9c72-430f290290c8") +IPolicyConfig : public IUnknown +{ +public: + virtual HRESULT STDMETHODCALLTYPE GetMixFormat(PCWSTR, void**) = 0; + virtual HRESULT STDMETHODCALLTYPE GetDeviceFormat(PCWSTR, INT, void**) = 0; + virtual HRESULT STDMETHODCALLTYPE ResetDeviceFormat(PCWSTR) = 0; + virtual HRESULT STDMETHODCALLTYPE SetDeviceFormat(PCWSTR, void*, void*) = 0; + virtual HRESULT STDMETHODCALLTYPE GetProcessingPeriod(PCWSTR, INT, PINT, PINT) = 0; + virtual HRESULT STDMETHODCALLTYPE SetProcessingPeriod(PCWSTR, PINT) = 0; + virtual HRESULT STDMETHODCALLTYPE GetShareMode(PCWSTR, void*) = 0; + virtual HRESULT STDMETHODCALLTYPE SetShareMode(PCWSTR, void*) = 0; + virtual HRESULT STDMETHODCALLTYPE GetPropertyValue(PCWSTR, const PROPERTYKEY&, PROPVARIANT*) = 0; + virtual HRESULT STDMETHODCALLTYPE SetPropertyValue(PCWSTR, const PROPERTYKEY&, PROPVARIANT*) = 0; + virtual HRESULT STDMETHODCALLTYPE SetDefaultEndpoint(PCWSTR wszDeviceId, ERole eRole) = 0; + virtual HRESULT STDMETHODCALLTYPE SetEndpointVisibility(PCWSTR, INT) = 0; +}; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +int GetIconIndex(PCWSTR iconSetting) { + if (iconSetting) { + if (wcscmp(iconSetting, L"mic_classic") == 0) return 3; + if (wcscmp(iconSetting, L"mic_modern") == 0) return 92; + if (wcscmp(iconSetting, L"headset_gaming") == 0) return 8; + if (wcscmp(iconSetting, L"headset_modern") == 0) return 95; + if (wcscmp(iconSetting, L"earphones") == 0) return 6; + } + return 3; +} + +// ─── Device selection data ──────────────────────────────────────────────────── + +void LoadDeviceSelections() { + // Read outside lock — Wh_GetStringValue is thread-safe. + WCHAR tmpId1[512] = {}; + WCHAR tmpName1[256] = {}; + WCHAR tmpId2[512] = {}; + WCHAR tmpName2[256] = {}; + + Wh_GetStringValue(L"Device1Id", tmpId1, 512); + Wh_GetStringValue(L"Device1Name", tmpName1, 256); + Wh_GetStringValue(L"Device2Id", tmpId2, 512); + Wh_GetStringValue(L"Device2Name", tmpName2, 256); + + EnterCriticalSection(&g_stateLock); + lstrcpynW(g_cachedDev1Id, tmpId1, 512); + lstrcpynW(g_cachedDev1Name, tmpName1, 256); + lstrcpynW(g_cachedDev2Id, tmpId2, 512); + lstrcpynW(g_cachedDev2Name, tmpName2, 256); + LeaveCriticalSection(&g_stateLock); +} + +void SaveDeviceSelection(int slot, PCWSTR deviceId, PCWSTR friendlyName) { + if (slot == 1) { + Wh_SetStringValue(L"Device1Id", deviceId); + Wh_SetStringValue(L"Device1Name", friendlyName); + EnterCriticalSection(&g_stateLock); + lstrcpynW(g_cachedDev1Id, deviceId, 512); + lstrcpynW(g_cachedDev1Name, friendlyName, 256); + LeaveCriticalSection(&g_stateLock); + } else { + Wh_SetStringValue(L"Device2Id", deviceId); + Wh_SetStringValue(L"Device2Name", friendlyName); + EnterCriticalSection(&g_stateLock); + lstrcpynW(g_cachedDev2Id, deviceId, 512); + lstrcpynW(g_cachedDev2Name, friendlyName, 256); + LeaveCriticalSection(&g_stateLock); + } +} + +// ─── Icon loading ───────────────────────────────────────────────────────────── + +static HICON LoadSingleIcon(PCWSTR iconSetting, int slotIndex) { + if (iconSetting && wcscmp(iconSetting, L"custom") == 0) { + WCHAR pathKey[32]; + swprintf_s(pathKey, L"icon%d_custom_path", slotIndex); + WCHAR customPath[MAX_PATH] = {}; + Wh_GetStringValue(pathKey, customPath, MAX_PATH); + if (customPath[0]) { + HICON hIcon = (HICON)LoadImageW(NULL, customPath, IMAGE_ICON, 32, 32, LR_LOADFROMFILE); + if (hIcon) return hIcon; + } + } + HICON hIcon = nullptr; + ExtractIconExW(g_ddoresDllPath, GetIconIndex(iconSetting), nullptr, &hIcon, 1); + return hIcon; +} + +void LoadUserIconsAndSettings() { + if (g_iconDev1) { DestroyIcon(g_iconDev1); g_iconDev1 = nullptr; } + if (g_iconDev2) { DestroyIcon(g_iconDev2); g_iconDev2 = nullptr; } + if (g_hIconDev1Bmp){ DeleteObject(g_hIconDev1Bmp); g_hIconDev1Bmp = nullptr; } + if (g_hIconDev2Bmp){ DeleteObject(g_hIconDev2Bmp); g_hIconDev2Bmp = nullptr; } + + PCWSTR s1 = Wh_GetStringSetting(L"icon1"); + PCWSTR s2 = Wh_GetStringSetting(L"icon2"); + g_iconDev1 = LoadSingleIcon(s1, 1); + g_iconDev2 = LoadSingleIcon(s2, 2); + + auto setupBmp = [](HICON hIcon, HBITMAP* pBmp) { + if (!hIcon) return; + ICONINFO ii = {}; + if (GetIconInfo(hIcon, &ii)) { + *pBmp = ii.hbmColor; + if (!*pBmp) { + *pBmp = ii.hbmMask; + } else if (ii.hbmMask) { + DeleteObject(ii.hbmMask); + } + } + }; + setupBmp(g_iconDev1, &g_hIconDev1Bmp); + setupBmp(g_iconDev2, &g_hIconDev2Bmp); + + if (s1) wcscpy_s(g_lastIconSetting[0], 32, s1); else g_lastIconSetting[0][0] = L'\0'; + if (s2) wcscpy_s(g_lastIconSetting[1], 32, s2); else g_lastIconSetting[1][0] = L'\0'; + + if (s1) Wh_FreeStringSetting(s1); + if (s2) Wh_FreeStringSetting(s2); +} + +// ─── Tray tip ───────────────────────────────────────────────────────────────── + +void UpdateTrayTip(HWND hWnd, BOOL isAdd) { + // Snapshot shared state under lock before any COM calls. + WCHAR localId1[512] = {}; + WCHAR localId2[512] = {}; + EnterCriticalSection(&g_stateLock); + lstrcpynW(localId1, g_cachedDev1Id, 512); + lstrcpynW(localId2, g_cachedDev2Id, 512); + LeaveCriticalSection(&g_stateLock); + + WCHAR currentDev[256] = L"Unknown Device"; + WCHAR currentId[512] = {}; + HICON currentIcon = g_iconDev1; + + IMMDeviceEnumerator* pEnum = nullptr; + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { + IMMDevice* pDev = nullptr; + if (SUCCEEDED(pEnum->GetDefaultAudioEndpoint(eCapture, eMultimedia, &pDev))) { + LPWSTR pId = nullptr; + if (SUCCEEDED(pDev->GetId(&pId))) { + lstrcpynW(currentId, pId, 512); + CoTaskMemFree(pId); + } + IPropertyStore* pStore = nullptr; + if (SUCCEEDED(pDev->OpenPropertyStore(STGM_READ, &pStore))) { + PROPVARIANT v; PropVariantInit(&v); + if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &v)) && v.pwszVal) + lstrcpynW(currentDev, v.pwszVal, 256); + PropVariantClear(&v); + pStore->Release(); + } + pDev->Release(); + } + pEnum->Release(); + } + + if (localId1[0] != L'\0' && wcscmp(currentId, localId1) == 0) + currentIcon = g_iconDev1; + else if (localId2[0] != L'\0' && wcscmp(currentId, localId2) == 0) + currentIcon = g_iconDev2; + + static WCHAR s_lastDev[256] = {}; + static HICON s_lastIcon = nullptr; + if (!isAdd && wcscmp(currentDev, s_lastDev) == 0 && currentIcon == s_lastIcon) + return; + lstrcpynW(s_lastDev, currentDev, ARRAYSIZE(s_lastDev)); + s_lastIcon = currentIcon; + + NOTIFYICONDATAW nid = {sizeof(nid)}; + nid.hWnd = hWnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_ICON | NIF_GUID; + nid.guidItem = MICSWITCH_TRAY_GUID; + nid.uCallbackMessage = WM_TRAY_CALLBACK; + + if (localId1[0] == L'\0' || localId2[0] == L'\0') + swprintf_s(nid.szTip, L"MicSwitch: Right-click to configure"); + else + swprintf_s(nid.szTip, L"Mic: %s", currentDev); + + nid.hIcon = currentIcon; + Shell_NotifyIconW(isAdd ? NIM_ADD : NIM_MODIFY, &nid); + + if (isAdd) { + NOTIFYICONDATAW nidVer = {sizeof(nidVer)}; + nidVer.hWnd = hWnd; + nidVer.uID = TRAY_ICON_ID; + nidVer.uFlags = NIF_GUID; + nidVer.guidItem = MICSWITCH_TRAY_GUID; + nidVer.uVersion = NOTIFYICON_VERSION_4; + Shell_NotifyIconW(NIM_SETVERSION, &nidVer); + } +} + +// ─── Audio toggle ───────────────────────────────────────────────────────────── + +BOOL ToggleAudioDevice() { + // Snapshot shared device IDs under lock before any COM calls. + WCHAR localId1[512] = {}; + WCHAR localId2[512] = {}; + EnterCriticalSection(&g_stateLock); + lstrcpynW(localId1, g_cachedDev1Id, 512); + lstrcpynW(localId2, g_cachedDev2Id, 512); + LeaveCriticalSection(&g_stateLock); + + if (localId1[0] == L'\0' || localId2[0] == L'\0') return FALSE; + + HRESULT comHr = CoInitialize(nullptr); + bool comOk = SUCCEEDED(comHr) || comHr == S_FALSE; + if (!comOk) return FALSE; + + IMMDeviceEnumerator* pEnum = nullptr; + if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { + CoUninitialize(); return FALSE; + } + + IMMDevice* pDef = nullptr; + if (FAILED(pEnum->GetDefaultAudioEndpoint(eCapture, eMultimedia, &pDef))) { + pEnum->Release(); CoUninitialize(); return FALSE; + } + + LPWSTR currentId = nullptr; + HRESULT hr = pDef->GetId(¤tId); + pDef->Release(); + if (FAILED(hr) || !currentId) { + pEnum->Release(); CoUninitialize(); return FALSE; + } + + // Toggle: if current is slot 1 go to slot 2, otherwise go to slot 1. + PCWSTR targetId = (wcscmp(currentId, localId1) == 0) ? localId2 : localId1; + CoTaskMemFree(currentId); + + IPolicyConfig* pPol = nullptr; + if (SUCCEEDED(CoCreateInstance(CLSID_CPolicyConfigClient, nullptr, CLSCTX_ALL, + IID_IPolicyConfig_Win10_11, (void**)&pPol))) { + pPol->SetDefaultEndpoint(targetId, eConsole); + pPol->SetDefaultEndpoint(targetId, eMultimedia); + pPol->SetDefaultEndpoint(targetId, eCommunications); + pPol->Release(); + } + + pEnum->Release(); + CoUninitialize(); + return TRUE; +} + +// ─── Context menu ───────────────────────────────────────────────────────────── + +static bool IsSystemDarkMode() { + DWORD value = 1, size = sizeof(value); + RegGetValueW(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + L"AppsUseLightTheme", RRF_RT_REG_DWORD, nullptr, &value, &size); + return value == 0; +} + +static void ApplyContextMenuTheme(HWND hWnd, bool dark) { + HMODULE ux = GetModuleHandleW(L"uxtheme.dll"); + if (!ux) return; + using Fn135 = int(WINAPI*)(int); + using Fn133 = bool(WINAPI*)(HWND, bool); + using Fn136 = void(WINAPI*)(); + if (auto f = (Fn135)GetProcAddress(ux, MAKEINTRESOURCEA(135))) f(dark ? 2 : 0); + if (auto f = (Fn133)GetProcAddress(ux, MAKEINTRESOURCEA(133))) f(hWnd, dark); + if (auto f = (Fn136)GetProcAddress(ux, MAKEINTRESOURCEA(136))) f(); +} + +struct AudioDevice { WCHAR id[512]; WCHAR name[256]; }; + +void BuildAndShowContextMenu(HWND hWnd) { + // Snapshot shared state under lock. + WCHAR localId1[512] = {}; + WCHAR localId2[512] = {}; + EnterCriticalSection(&g_stateLock); + lstrcpynW(localId1, g_cachedDev1Id, 512); + lstrcpynW(localId2, g_cachedDev2Id, 512); + LeaveCriticalSection(&g_stateLock); + + AudioDevice devices[MENU_MAX_DEVICES]; + int deviceCount = 0; + + IMMDeviceEnumerator* pEnum = nullptr; + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { + IMMDeviceCollection* pColl = nullptr; + if (SUCCEEDED(pEnum->EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE, &pColl))) { + UINT count = 0; + pColl->GetCount(&count); + if (count > MENU_MAX_DEVICES) count = MENU_MAX_DEVICES; + for (UINT i = 0; i < count; i++) { + IMMDevice* pDevice = nullptr; + if (FAILED(pColl->Item(i, &pDevice))) continue; + LPWSTR pId = nullptr; + if (SUCCEEDED(pDevice->GetId(&pId))) { + lstrcpynW(devices[deviceCount].id, pId, 512); + CoTaskMemFree(pId); + IPropertyStore* pStore = nullptr; + if (SUCCEEDED(pDevice->OpenPropertyStore(STGM_READ, &pStore))) { + PROPVARIANT v; PropVariantInit(&v); + if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &v)) && v.pwszVal) { + lstrcpynW(devices[deviceCount].name, v.pwszVal, 256); + deviceCount++; + } + PropVariantClear(&v); + pStore->Release(); + } + } + pDevice->Release(); + } + pColl->Release(); + } + pEnum->Release(); + } + + // Build status text before constructing the menu so it can go at the top. + WCHAR statusText[300]; + if (localId1[0] == L'\0' || localId2[0] == L'\0') { + lstrcpyW(statusText, L"Right-click to configure inputs"); + } else { + WCHAR activeName[256] = L"Unknown"; + IMMDeviceEnumerator* pEnum2 = nullptr; + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum2))) { + IMMDevice* pDef = nullptr; + if (SUCCEEDED(pEnum2->GetDefaultAudioEndpoint(eCapture, eMultimedia, &pDef))) { + IPropertyStore* pStore = nullptr; + if (SUCCEEDED(pDef->OpenPropertyStore(STGM_READ, &pStore))) { + PROPVARIANT v; PropVariantInit(&v); + if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &v)) && v.pwszVal) + lstrcpynW(activeName, v.pwszVal, 256); + PropVariantClear(&v); + pStore->Release(); + } + pDef->Release(); + } + pEnum2->Release(); + } + swprintf_s(statusText, L"Active: %s", activeName); + } + + HMENU hSub1 = CreatePopupMenu(); + HMENU hSub2 = CreatePopupMenu(); + for (int i = 0; i < deviceCount; i++) { + UINT f1 = MF_STRING | (wcscmp(devices[i].id, localId1) == 0 ? MF_CHECKED : 0); + UINT f2 = MF_STRING | (wcscmp(devices[i].id, localId2) == 0 ? MF_CHECKED : 0); + AppendMenuW(hSub1, f1, MENU_DEVICE1_BASE + i, devices[i].name); + AppendMenuW(hSub2, f2, MENU_DEVICE2_BASE + i, devices[i].name); + } + + HMENU hMenu = CreatePopupMenu(); + AppendMenuW(hMenu, MF_STRING | MF_GRAYED, 0, statusText); + AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); + + MENUITEMINFOW mii1 = {sizeof(mii1)}; + mii1.fMask = MIIM_SUBMENU | MIIM_STRING | MIIM_BITMAP; + mii1.hSubMenu = hSub1; + mii1.dwTypeData = (LPWSTR)L"Set as Input 1"; + mii1.hbmpItem = g_hIconDev1Bmp; + InsertMenuItemW(hMenu, (UINT)-1, TRUE, &mii1); + + MENUITEMINFOW mii2 = {sizeof(mii2)}; + mii2.fMask = MIIM_SUBMENU | MIIM_STRING | MIIM_BITMAP; + mii2.hSubMenu = hSub2; + mii2.dwTypeData = (LPWSTR)L"Set as Input 2"; + mii2.hbmpItem = g_hIconDev2Bmp; + InsertMenuItemW(hMenu, (UINT)-1, TRUE, &mii2); + + AppendMenuW(hMenu, MF_STRING, MENU_CUSTOM_ICON_BASE + 1, L"Custom Icon for Device 1..."); + AppendMenuW(hMenu, MF_STRING, MENU_CUSTOM_ICON_BASE + 2, L"Custom Icon for Device 2..."); + AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); + + MENUITEMINFOW miiWH = {sizeof(miiWH)}; + miiWH.fMask = MIIM_ID | MIIM_STRING | MIIM_BITMAP; + miiWH.wID = MENU_OPEN_WINDHAWK; + miiWH.dwTypeData = (LPWSTR)L"Open Windhawk"; + miiWH.hbmpItem = g_hWindHawkBmp; + InsertMenuItemW(hMenu, (UINT)-1, TRUE, &miiWH); + + POINT pt; + GetCursorPos(&pt); + SetForegroundWindow(hWnd); + bool dark = IsSystemDarkMode(); + ApplyContextMenuTheme(hWnd, dark); + int cmd = TrackPopupMenu(hMenu, TPM_RETURNCMD | TPM_RIGHTBUTTON | TPM_BOTTOMALIGN | TPM_RIGHTALIGN, + pt.x, pt.y, 0, hWnd, nullptr); + PostMessageW(hWnd, WM_NULL, 0, 0); + DestroyMenu(hMenu); + + if (cmd >= MENU_DEVICE1_BASE && cmd < MENU_DEVICE1_BASE + deviceCount) { + int idx = cmd - MENU_DEVICE1_BASE; + SaveDeviceSelection(1, devices[idx].id, devices[idx].name); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + } else if (cmd >= MENU_DEVICE2_BASE && cmd < MENU_DEVICE2_BASE + deviceCount) { + int idx = cmd - MENU_DEVICE2_BASE; + SaveDeviceSelection(2, devices[idx].id, devices[idx].name); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + } else if (cmd == MENU_OPEN_WINDHAWK) { + SHELLEXECUTEINFOW sei = {sizeof(sei)}; + sei.lpFile = g_windhawkPath; + sei.nShow = SW_SHOWNORMAL; + ShellExecuteExW(&sei); + } else if (cmd >= MENU_CUSTOM_ICON_BASE && cmd < MENU_CUSTOM_ICON_BASE + 2) { + int slot = cmd - MENU_CUSTOM_ICON_BASE + 1; + WCHAR path[MAX_PATH] = {}; + WCHAR title[64]; + swprintf_s(title, L"Select Icon for Device %d", slot); + OPENFILENAMEW ofn = {sizeof(ofn)}; + ofn.hwndOwner = hWnd; + ofn.lpstrFilter = L"Icon Files (*.ico)\0*.ico\0All Files (*.*)\0*.*\0"; + ofn.nFilterIndex = 1; + ofn.lpstrFile = path; + ofn.nMaxFile = MAX_PATH; + ofn.Flags = OFN_PATHMUSTEXIST | OFN_FILEMUSTEXIST | OFN_HIDEREADONLY | OFN_NOCHANGEDIR; + ofn.lpstrTitle = title; + if (GetOpenFileNameW(&ofn)) { + WCHAR pathKey[32]; + swprintf_s(pathKey, L"icon%d_custom_path", slot); + Wh_SetStringValue(pathKey, path); + LoadUserIconsAndSettings(); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + } + } +} + +// ─── Worker thread ──────────────────────────────────────────────────────────── + +DWORD WINAPI WorkerThreadProc(LPVOID) { + if (ToggleAudioDevice() && g_trayHwnd) + PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, 0, 0); + InterlockedExchange(&g_isProcessingClick, 0); + return 0; +} + +// ─── Tray window ────────────────────────────────────────────────────────────── + +LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { + if (msg == WM_TRAY_CALLBACK && LOWORD(lParam) == WM_RBUTTONUP) { + BuildAndShowContextMenu(hWnd); + } else if (msg == WM_TRAY_CALLBACK && LOWORD(lParam) == WM_LBUTTONUP) { + if (InterlockedExchange(&g_isProcessingClick, 1) == 0) { + DWORD now = GetTickCount(); + if (now - g_lastClickTime > CLICK_DEBOUNCE_MS) { + g_lastClickTime = now; + if (g_workerThread) { CloseHandle(g_workerThread); g_workerThread = nullptr; } + g_workerThread = CreateThread(nullptr, 0, WorkerThreadProc, nullptr, 0, nullptr); + } else { + InterlockedExchange(&g_isProcessingClick, 0); + } + } + } else if (msg == WM_UPDATE_TRAY_STATE || (msg == WM_TIMER && wParam == 1)) { + UpdateTrayTip(hWnd, FALSE); + } else if (msg == WM_RELOAD_ICONS) { + WCHAR prev[2][32] = {}; + wcscpy_s(prev[0], 32, g_lastIconSetting[0]); + wcscpy_s(prev[1], 32, g_lastIconSetting[1]); + LoadUserIconsAndSettings(); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + DWORD pickerSlots = 0; + for (int slot = 1; slot <= 2; slot++) { + WCHAR key[16]; + swprintf_s(key, L"icon%d", slot); + PCWSTR s = Wh_GetStringSetting(key); + BOOL isCustom = (s && wcscmp(s, L"custom") == 0); + BOOL wasCustom = (wcscmp(prev[slot - 1], L"custom") == 0); + if (s) Wh_FreeStringSetting(s); + if (isCustom && !wasCustom) + pickerSlots |= (1u << (slot - 1)); + } + if (pickerSlots) + PostMessageW(hWnd, WM_SHOW_FILE_PICKER, 0, (LPARAM)pickerSlots); + } else if (msg == WM_SHOW_FILE_PICKER) { + DWORD slots = (DWORD)lParam; + for (int slot = 1; slots; slot++, slots >>= 1) { + if (!(slots & 1)) continue; + WCHAR path[MAX_PATH] = {}; + WCHAR title[64]; + swprintf_s(title, L"Select Icon for Device %d", slot); + OPENFILENAMEW ofn = {sizeof(ofn)}; + ofn.hwndOwner = hWnd; + ofn.lpstrFilter = L"Icon Files (*.ico)\0*.ico\0All Files (*.*)\0*.*\0"; + ofn.nFilterIndex = 1; + ofn.lpstrFile = path; + ofn.nMaxFile = MAX_PATH; + ofn.Flags = OFN_PATHMUSTEXIST | OFN_FILEMUSTEXIST | OFN_HIDEREADONLY | OFN_NOCHANGEDIR; + ofn.lpstrTitle = title; + if (GetOpenFileNameW(&ofn)) { + WCHAR pathKey[32]; + swprintf_s(pathKey, L"icon%d_custom_path", slot); + Wh_SetStringValue(pathKey, path); + LoadUserIconsAndSettings(); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + } + } + } else if (msg == g_taskbarCreatedMsg && g_taskbarCreatedMsg != 0) { + UpdateTrayTip(hWnd, TRUE); + } else if (msg == WM_CLOSE) { + // Orderly shutdown: kill timer, remove tray icon, then destroy window. + KillTimer(hWnd, 1); + NOTIFYICONDATAW nid = {sizeof(nid)}; + nid.hWnd = hWnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_GUID; + nid.guidItem = MICSWITCH_TRAY_GUID; + Shell_NotifyIconW(NIM_DELETE, &nid); + DestroyWindow(hWnd); + return 0; + } else if (msg == WM_DESTROY) { + g_trayHwnd = nullptr; + PostQuitMessage(0); + } + return DefWindowProcW(hWnd, msg, wParam, lParam); +} + +// ─── Tray thread ────────────────────────────────────────────────────────────── + +DWORD WINAPI TrayThreadProc(LPVOID) { + CoInitialize(nullptr); + g_taskbarCreatedMsg = RegisterWindowMessageW(L"TaskbarCreated"); + + WNDCLASSW wc = {}; + wc.lpfnWndProc = TrayWndProc; + wc.hInstance = g_hInstance; + wc.lpszClassName = L"MicSwitchWindowClass"; + RegisterClassW(&wc); + + // WS_EX_TOOLWINDOW + WS_EX_NOACTIVATE: hidden utility window, never in Alt+Tab. + g_trayHwnd = CreateWindowExW( + WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, + wc.lpszClassName, L"MicSwitch", + WS_POPUP, + 0, 0, 1, 1, + nullptr, nullptr, g_hInstance, nullptr); + if (!g_trayHwnd) { CoUninitialize(); return 1; } + + // Unique AUMID so the OS doesn't group this icon with Windhawk's main window. + IPropertyStore* pps = nullptr; + if (SUCCEEDED(SHGetPropertyStoreForWindow(g_trayHwnd, IID_PPV_ARGS(&pps)))) { + PROPVARIANT var; + PropVariantInit(&var); + var.vt = VT_LPWSTR; + var.pwszVal = (LPWSTR)CoTaskMemAlloc(MAX_PATH * sizeof(WCHAR)); + if (var.pwszVal) { + lstrcpyW(var.pwszVal, L"BlackPaw.MicSwitch"); + pps->SetValue(PKEY_AppUserModel_ID, var); + CoTaskMemFree(var.pwszVal); + } + pps->Commit(); + pps->Release(); + } + + // 1500ms poll timer keeps tooltip in sync without IMMNotificationClient overhead. + SetTimer(g_trayHwnd, 1, 1500, nullptr); + UpdateTrayTip(g_trayHwnd, TRUE); + + MSG msg; + while (GetMessageW(&msg, nullptr, 0, 0) > 0) { DispatchMessageW(&msg); } + + CoUninitialize(); + return 0; +} + +// ─── Mod lifecycle ──────────────────────────────────────────────────────────── + +BOOL WhTool_ModInit() { + Wh_Log(L"MicSwitch Mod Init"); + + InitializeCriticalSection(&g_stateLock); + + g_hInstance = GetModuleHandleW(nullptr); + GetModuleFileNameW(nullptr, g_windhawkPath, ARRAYSIZE(g_windhawkPath)); + + // Full System32 path — ExtractIconExW handles the .mun redirect on Win11. + UINT sysLen = GetSystemDirectoryW(g_ddoresDllPath, MAX_PATH); + if (sysLen > 0 && sysLen < MAX_PATH - 12) + lstrcatW(g_ddoresDllPath, L"\\ddores.dll"); + else + lstrcpyW(g_ddoresDllPath, L"ddores.dll"); + + int whIconIndices[] = {98, 94, 95, 6}; + for (int idx : whIconIndices) { + ExtractIconExW(g_ddoresDllPath, idx, nullptr, &g_hWindHawkIcon, 1); + if (g_hWindHawkIcon) break; + } + if (g_hWindHawkIcon) { + ICONINFO ii = {}; + if (GetIconInfo(g_hWindHawkIcon, &ii)) { + g_hWindHawkBmp = ii.hbmColor; + if (!g_hWindHawkBmp) { + g_hWindHawkBmp = ii.hbmMask; + } else if (ii.hbmMask) { + DeleteObject(ii.hbmMask); + } + } + } + + LoadUserIconsAndSettings(); + LoadDeviceSelections(); + g_trayThread = CreateThread(nullptr, 0, TrayThreadProc, nullptr, 0, nullptr); + return TRUE; +} + +void WhTool_ModSettingsChanged() { + LoadDeviceSelections(); + HWND hwnd = g_trayHwnd; + if (hwnd) PostMessageW(hwnd, WM_RELOAD_ICONS, 0, 0); +} + +void WhTool_ModUninit() { + Wh_Log(L"MicSwitch Mod Uninit"); + + // Capture hwnd before posting — tray thread clears g_trayHwnd in WM_DESTROY. + HWND hwnd = g_trayHwnd; + if (hwnd) PostMessageW(hwnd, WM_CLOSE, 0, 0); + + if (g_trayThread) { + WaitForSingleObject(g_trayThread, 3000); + CloseHandle(g_trayThread); + g_trayThread = nullptr; + } + if (g_workerThread) { + WaitForSingleObject(g_workerThread, 2000); + CloseHandle(g_workerThread); + g_workerThread = nullptr; + } + + if (g_iconDev1) { DestroyIcon(g_iconDev1); g_iconDev1 = nullptr; } + if (g_iconDev2) { DestroyIcon(g_iconDev2); g_iconDev2 = nullptr; } + if (g_hIconDev1Bmp) { DeleteObject(g_hIconDev1Bmp); g_hIconDev1Bmp = nullptr; } + if (g_hIconDev2Bmp) { DeleteObject(g_hIconDev2Bmp); g_hIconDev2Bmp = nullptr; } + if (g_hWindHawkIcon){ DestroyIcon(g_hWindHawkIcon); g_hWindHawkIcon = nullptr; } + if (g_hWindHawkBmp) { DeleteObject(g_hWindHawkBmp); g_hWindHawkBmp = nullptr; } + + DeleteCriticalSection(&g_stateLock); +} + +//////////////////////////////////////////////////////////////////////////////// +// Windhawk tool mod boilerplate — do not modify. +// https://github.com/ramensoftware/windhawk/wiki/Mods-as-tools:-Running-mods-in-a-dedicated-process + +bool g_isToolModProcessLauncher; +HANDLE g_toolModProcessMutex; + +void WINAPI EntryPoint_Hook() { + Wh_Log(L">"); + ExitThread(0); +} + +BOOL Wh_ModInit() { + DWORD sessionId; + if (ProcessIdToSessionId(GetCurrentProcessId(), &sessionId) && + sessionId == 0) { + return FALSE; + } + + bool isExcluded = false; + bool isToolModProcess = false; + bool isCurrentToolModProcess = false; + int argc; + LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc); + if (!argv) { + Wh_Log(L"CommandLineToArgvW failed"); + return FALSE; + } + + for (int i = 1; i < argc; i++) { + if (wcscmp(argv[i], L"-service") == 0 || + wcscmp(argv[i], L"-service-start") == 0 || + wcscmp(argv[i], L"-service-stop") == 0) { + isExcluded = true; + break; + } + } + + for (int i = 1; i < argc - 1; i++) { + if (wcscmp(argv[i], L"-tool-mod") == 0) { + isToolModProcess = true; + if (wcscmp(argv[i + 1], WH_MOD_ID) == 0) { + isCurrentToolModProcess = true; + } + break; + } + } + + LocalFree(argv); + + if (isExcluded) return FALSE; + + if (isCurrentToolModProcess) { + g_toolModProcessMutex = + CreateMutexW(nullptr, TRUE, L"windhawk-tool-mod_" WH_MOD_ID); + if (!g_toolModProcessMutex) { + Wh_Log(L"CreateMutex failed"); + ExitProcess(1); + } + if (GetLastError() == ERROR_ALREADY_EXISTS) { + Wh_Log(L"Tool mod already running (%s)", WH_MOD_ID); + ExitProcess(1); + } + if (!WhTool_ModInit()) ExitProcess(1); + + IMAGE_DOS_HEADER* dosHeader = + (IMAGE_DOS_HEADER*)GetModuleHandleW(nullptr); + IMAGE_NT_HEADERS* ntHeaders = + (IMAGE_NT_HEADERS*)((BYTE*)dosHeader + dosHeader->e_lfanew); + DWORD entryPointRVA = ntHeaders->OptionalHeader.AddressOfEntryPoint; + void* entryPoint = (BYTE*)dosHeader + entryPointRVA; + Wh_SetFunctionHook(entryPoint, (void*)EntryPoint_Hook, nullptr); + return TRUE; + } + + if (isToolModProcess) return FALSE; + + g_isToolModProcessLauncher = true; + return TRUE; +} + +void Wh_ModAfterInit() { + if (!g_isToolModProcessLauncher) return; + + WCHAR currentProcessPath[MAX_PATH]; + switch (GetModuleFileNameW(nullptr, currentProcessPath, + ARRAYSIZE(currentProcessPath))) { + case 0: + case ARRAYSIZE(currentProcessPath): + Wh_Log(L"GetModuleFileName failed"); + return; + } + + WCHAR + commandLine[MAX_PATH + 2 + + (sizeof(L" -tool-mod \"" WH_MOD_ID "\"") / sizeof(WCHAR)) - 1]; + swprintf_s(commandLine, L"\"%s\" -tool-mod \"%s\"", currentProcessPath, WH_MOD_ID); + + HMODULE kernelModule = GetModuleHandleW(L"kernelbase.dll"); + if (!kernelModule) { + kernelModule = GetModuleHandleW(L"kernel32.dll"); + if (!kernelModule) { Wh_Log(L"No kernelbase.dll/kernel32.dll"); return; } + } + + using CreateProcessInternalW_t = BOOL(WINAPI*)( + HANDLE hUserToken, LPCWSTR lpApplicationName, LPWSTR lpCommandLine, + LPSECURITY_ATTRIBUTES lpProcessAttributes, + LPSECURITY_ATTRIBUTES lpThreadAttributes, WINBOOL bInheritHandles, + DWORD dwCreationFlags, LPVOID lpEnvironment, LPCWSTR lpCurrentDirectory, + LPSTARTUPINFOW lpStartupInfo, + LPPROCESS_INFORMATION lpProcessInformation, + PHANDLE hRestrictedUserToken); + CreateProcessInternalW_t pCreateProcessInternalW = + (CreateProcessInternalW_t)GetProcAddress(kernelModule, "CreateProcessInternalW"); + if (!pCreateProcessInternalW) { Wh_Log(L"No CreateProcessInternalW"); return; } + + STARTUPINFOW si{.cb = sizeof(STARTUPINFOW), .dwFlags = STARTF_FORCEOFFFEEDBACK}; + PROCESS_INFORMATION pi; + if (!pCreateProcessInternalW(nullptr, currentProcessPath, commandLine, + nullptr, nullptr, FALSE, NORMAL_PRIORITY_CLASS, + nullptr, nullptr, &si, &pi, nullptr)) { + Wh_Log(L"CreateProcess failed"); + return; + } + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); +} + +void Wh_ModSettingsChanged() { + if (g_isToolModProcessLauncher) return; + WhTool_ModSettingsChanged(); +} + +void Wh_ModUninit() { + if (g_isToolModProcessLauncher) return; + WhTool_ModUninit(); + ExitProcess(0); +} From af65913398061e5f046d6d3cfe11711a64501a84 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Mon, 25 May 2026 16:52:29 +0300 Subject: [PATCH 34/56] update changelog Make tray/taskbar icon independent from Windhawk icon. --- mods/micswitch.wh.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/mods/micswitch.wh.cpp b/mods/micswitch.wh.cpp index 1b15a02289..8afb197225 100644 --- a/mods/micswitch.wh.cpp +++ b/mods/micswitch.wh.cpp @@ -39,6 +39,7 @@ Instantly toggle between two audio input devices (microphones) from your system - Fixed: occasional hang when Windhawk unloads the mod. - Fixed: tray window appeared in Alt+Tab. - Fixed: tray icon failed to load on some Windows configurations. +- Tray / Taskbar icon is now independent, no longer linked to the Windhawk icon. ### v1.0.0 - Initial release. From f78299b817b235519d5a3ad1c6428d36e02f9bf8 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Mon, 25 May 2026 16:57:53 +0300 Subject: [PATCH 35/56] revert mod identifier from 'micswitch' to 'microswap' hopefully this will update existing mod with the new name --- mods/{micswitch.wh.cpp => microswap.wh.cpp} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename mods/{micswitch.wh.cpp => microswap.wh.cpp} (99%) diff --git a/mods/micswitch.wh.cpp b/mods/microswap.wh.cpp similarity index 99% rename from mods/micswitch.wh.cpp rename to mods/microswap.wh.cpp index 8afb197225..8bf6ae988b 100644 --- a/mods/micswitch.wh.cpp +++ b/mods/microswap.wh.cpp @@ -1,5 +1,5 @@ // ==WindhawkMod== -// @id micswitch +// @id microswap // @name MicSwitch // @description Tray icon to instantly toggle between two preferred audio inputs (microphones). // @version 1.1.0 From 6b7545675fd291e1f7fd5a6598799b82b371141d Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Fri, 29 May 2026 17:00:30 +0300 Subject: [PATCH 36/56] Major 2.0 update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Complete rebuild.** Full feature parity with AudioSwap: 6 device slots, configurable cycle order, native dark-themed settings dashboard, scroll-to-swap mode, push-to-mute, instant device-change updates via the MMDevice notification API, and Advanced Mode with priority routing. - New: Right-click → **Sound Settings...** opens Windows Sound dialog directly on the Recording tab. - New: Middle-click tray icon → compact volume slider popup with a custom dark design. Drag to adjust microphone volume live; click outside or press Escape to close. - New: Scroll wheel over the tray icon (Click to Swap mode) adjusts microphone volume, matching native Windows sound icon behaviour. --- mods/microswap.wh.cpp | 2828 +++++++++++++++++++++++++++++++++++------ 1 file changed, 2414 insertions(+), 414 deletions(-) diff --git a/mods/microswap.wh.cpp b/mods/microswap.wh.cpp index 8bf6ae988b..74d75b806c 100644 --- a/mods/microswap.wh.cpp +++ b/mods/microswap.wh.cpp @@ -1,132 +1,228 @@ // ==WindhawkMod== // @id microswap // @name MicSwitch -// @description Tray icon to instantly toggle between two preferred audio inputs (microphones). -// @version 1.1.0 +// @description Tray icon to cycle between multiple preferred microphones. Supports up to 6 devices with click or scroll to swap. +// @version 2.0.0 // @author BlackPaw // @github https://github.com/BlackPaw21 // @donateUrl https://ko-fi.com/blackpaw21 // @include windhawk.exe -// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lcomdlg32 -ladvapi32 +// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lcomdlg32 -ladvapi32 -lcomctl32 // ==/WindhawkMod== // ==WindhawkModReadme== /* # MicSwitch -Instantly toggle between two audio input devices (microphones) from your system tray — no diving into Sound settings. +Instantly cycle between multiple microphones from your system tray — no diving into Sound settings. + +> Works great alongside **[AudioSwap](https://windhawk.net/mods/audioswap)** — the companion mod that brings the same tray experience to audio output control. --- ## How to Use -1. **Choose Icons** — Open the **Settings** tab and pick an icon for each of your two devices. -2. **Select Your Devices** — Right-click the tray icon. Use **Set as Input 1** and **Set as Input 2** to assign your inputs from the live device list. -3. **Toggle** — Left-click the tray icon at any time to swap between the two devices instantly. +1. **Open Settings** — Right-click the tray icon and select **Mod Settings** to open the configuration dashboard. +2. **Assign Devices** — Use the dropdown in each slot to pick a device from the live list of active microphones. +3. **Choose Icons** — Select a preset icon per slot, or click **Browse...** to load a custom `.ico` file. +4. **Choose Mode** — Pick **Click to Swap** (left-click cycles mics) or **Scroll to Swap** (mouse wheel cycles; left-click mutes). +5. **Set Device Count** — Choose how many slots to cycle through (2–6). +6. Click **Save and Apply** — the tray icon updates immediately, no restart needed. + +### Volume Control + +- **Scroll wheel** over the tray icon (in Click to Swap mode) adjusts microphone volume — just like the native Windows sound icon. +- **Middle-click** the tray icon to open a compact volume slider. Drag or use the arrow keys to adjust microphone volume live — click anywhere outside or press Escape to close. + +### Scroll to Swap mode extras + +- **Left-click** mutes the current microphone. Click again to unmute. The tray tooltip shows *(Muted)* and the icon gains a red dot while active. +- **Scrolling** to a different microphone automatically unmutes the previous one before switching. -> The tray tooltip always shows the active input. On first run it will read *"Right-click to configure"* until both devices are assigned. +### Device Priority (Advanced Mode) + +Enable **Advanced Mode** in the Windhawk settings panel (gear icon → Settings tab) to show the Device Priority section inside Mod Settings. + +- Rank up to 6 preferred microphones in order of priority. +- When MicSwitch loads, the highest-priority microphone that is currently connected is automatically placed into a swap slot. +- Disconnected priority devices are remembered and restored to their slot when reconnected. +- Each priority entry has its own icon picker. + +> The tray tooltip always shows the active microphone. On first run it reads *"Right-click to configure"* until at least two slots are assigned. + +--- + +## Known Bugs + +- **Scroll to Swap may not respond over elevated windows** such as Task Manager, Windhawk, or other admin-elevated applications. Switch focus away from the elevated window and scrolling will work normally. --- ## Changelog -### v1.1.0 -- Custom icon support — pick your own image for each device via Settings or right-click. -- Renamed from MicroSwap to MicSwitch. -- Added donate button on the mod page. +### v2.0.0 +- **Complete rebuild.** Full feature parity with AudioSwap: 6 device slots, configurable cycle order, native dark-themed settings dashboard, scroll-to-swap mode, push-to-mute, instant device-change updates via the MMDevice notification API, and Advanced Mode with priority routing. +- New: Right-click → **Sound Settings...** opens Windows Sound dialog directly on the Recording tab. +- New: Middle-click tray icon → compact volume slider popup with a custom dark design. Drag to adjust microphone volume live; click outside or press Escape to close. +- New: Scroll wheel over the tray icon (Click to Swap mode) adjusts microphone volume, matching native Windows sound icon behaviour. + +### v1.4.0 - Context menu now follows your Windows dark/light theme. - Active device shown at the top of the right-click menu. + +### v1.3.0 +- Renamed from MicroSwap to MicSwitch. +- Added donate button on the mod page. + +### v1.2.0 - Fixed: rare crash when switching devices rapidly. - Fixed: occasional hang when Windhawk unloads the mod. - Fixed: tray window appeared in Alt+Tab. - Fixed: tray icon failed to load on some Windows configurations. -- Tray / Taskbar icon is now independent, no longer linked to the Windhawk icon. + +### v1.1.0 +- Custom icon support — pick your own image for each device via Settings or right-click. ### v1.0.0 - Initial release. -- Right-click the tray icon to assign your two microphones. Left-click to swap between them. -- Device selections are remembered across restarts. */ // ==/WindhawkModReadme== // ==WindhawkModSettings== /* -- icon1: mic_classic - $name: First Device Icon - $options: - - mic_classic: Classic Microphone - - mic_modern: Modern Microphone - - headset_gaming: Gaming Headset - - headset_modern: Modern Gaming Headset - - earphones: Earphones - - custom: Custom Icon -- icon2: headset_gaming - $name: Second Device Icon - $options: - - mic_classic: Classic Microphone - - mic_modern: Modern Microphone - - headset_gaming: Gaming Headset - - headset_modern: Modern Gaming Headset - - earphones: Earphones - - custom: Custom Icon +- advancedMode: false + $name: Advanced Mode + $description: Shows the Device Priority panel in Mod Settings, letting you rank preferred microphones for automatic slot assignment. +- _info: " " + $name: " " + $description: "Most settings live in the in-tray dashboard. Right-click the MicSwitch tray icon → Mod Settings." */ // ==/WindhawkModSettings== +// Dashboard-level settings (device slots, icons, priority list) are managed via +// right-click → Mod Settings and persisted with Wh_SetStringValue / Wh_GetStringValue. +// The advancedMode flag above uses Wh_GetIntSetting, which reads from Windhawk's +// separate YAML store — no conflict with dashboard keys. + +#define NOMINMAX #include #include #include -#include #include +#include +#include +#include #include #include -#include #include #include - -#define TRAY_ICON_ID 1 -#define WM_TRAY_CALLBACK (WM_USER + 1) -#define WM_UPDATE_TRAY_STATE (WM_USER + 2) -#define WM_SHOW_FILE_PICKER (WM_USER + 5) -#define WM_RELOAD_ICONS (WM_USER + 6) -#define MENU_DEVICE1_BASE 1000 -#define MENU_DEVICE2_BASE 2000 -#define MENU_OPEN_WINDHAWK 3000 -#define MENU_CUSTOM_ICON_BASE 8000 -#define MENU_MAX_DEVICES 32 +#include +#include +#include +#include // Stable GUID that gives our tray icon a process-independent identity. +// Windows uses this to track pin/unpin separately from windhawk.exe. static const GUID MICSWITCH_TRAY_GUID = {0x7174FA7E, 0x93F1, 0x4110, {0x8B, 0x83, 0xA4, 0xAD, 0x2C, 0x76, 0x9C, 0x3B}}; -const DWORD CLICK_DEBOUNCE_MS = 500; - -static volatile LONG g_isProcessingClick = 0; -static HANDLE g_trayThread = nullptr; -static HANDLE g_workerThread = nullptr; -static volatile HWND g_trayHwnd = nullptr; -static HINSTANCE g_hInstance = nullptr; -static WCHAR g_windhawkPath[MAX_PATH] = {}; -static WCHAR g_ddoresDllPath[MAX_PATH] = {}; -static HICON g_hWindHawkIcon = nullptr; -static HBITMAP g_hWindHawkBmp = nullptr; -static HICON g_iconDev1 = nullptr; -static HBITMAP g_hIconDev1Bmp = nullptr; -static HICON g_iconDev2 = nullptr; -static HBITMAP g_hIconDev2Bmp = nullptr; -static DWORD g_lastClickTime = 0; -static UINT g_taskbarCreatedMsg = 0; -static WCHAR g_lastIconSetting[2][32] = {}; +#define TRAY_ICON_ID 1 +#define WM_TRAY_CALLBACK (WM_USER + 1) +#define WM_UPDATE_TRAY_STATE (WM_USER + 2) +#define WM_TRAY_SCROLL (WM_USER + 3) // wParam = direction (+1 or -1) +#define WM_UPDATE_HOOK_STATE (WM_USER + 4) +#define WM_SHOW_FILE_PICKER (WM_USER + 5) // lParam = bitmask of slots needing pickers +#define WM_RELOAD_ICONS (WM_USER + 6) // reload icons on tray thread (eliminates cross-thread handle race) +#define WM_RELOAD_ALL (WM_USER + 7) // full reload after dashboard save +#define WM_PRIORITY_DEVICE_ACTIVE (WM_USER + 8) // lParam = heap-alloc'd WCHAR* device ID +#define WM_REBIND_VOLUME_CALLBACK (WM_USER + 9) // rebind IAudioEndpointVolumeCallback after default-device change +#define TRAY_RECT_INIT_TIMER 99 // one-shot retry timer for Shell_NotifyIconGetRect + +// Sentinel context GUID — distinguishes volume changes triggered by our own +// VolumePopup slider from external (volume keys / other apps) +// when IAudioEndpointVolumeCallback::OnNotify fires. +static const GUID kVolumeChangeCtx = + {0x4C3B2A1D, 0x9E8F, 0x4D7A, {0xB6, 0x5C, 0x3F, 0x2D, 0x1E, 0x4B, 0x5A, 0x6C}}; + +#define MENU_OPEN_SETTINGS 9001 +#define MENU_OPEN_WINDHAWK 9000 +#define IDC_BTN_KOFI 9002 +#define MENU_SOUND_SETTINGS 9003 + +// With NOTIFYICON_VERSION_4 active Windows changes the tray notifications: +// left-click → NIN_SELECT (instead of / alongside WM_LBUTTONUP) +// right-click → WM_CONTEXTMENU (instead of / alongside WM_RBUTTONUP) +// keyboard → NIN_KEYSELECT +// We accept both old and new forms so the icon behaves the same in either mode. +#ifndef NIN_SELECT +#define NIN_SELECT (WM_USER + 0) +#endif +#ifndef NIN_KEYSELECT +#define NIN_KEYSELECT (NIN_SELECT | 0x1) +#endif + +#define MAX_DEVICE_SLOTS 6 + +const DWORD CLICK_DEBOUNCE_MS = 500; +const DWORD SCROLL_DEBOUNCE_MS = 300; + +// ─── Globals ────────────────────────────────────────────────────────────────── + +static volatile LONG g_isProcessingClick = 0; +static HANDLE g_trayThread = nullptr; +static HANDLE g_workerThread = nullptr; +static volatile HWND g_trayHwnd = nullptr; +static HINSTANCE g_hInstance = nullptr; +static WCHAR g_windhawkPath[MAX_PATH] = {}; +static WCHAR g_ddoresDllPath[MAX_PATH] = {}; // full system32 path +static WCHAR g_lastIconSetting[MAX_DEVICE_SLOTS][32] = {}; +static HICON g_hWindHawkIcon = nullptr; +static HBITMAP g_hWindHawkBmp = nullptr; +static DWORD g_lastClickTime = 0; +static DWORD g_lastScrollTime = 0; +static UINT g_taskbarCreatedMsg = 0; + +// Tray icon position (used by Raw Input scroll handler) +static RECT g_trayIconRect = {}; + +// Per-slot icon handles +static HICON g_iconDev[MAX_DEVICE_SLOTS] = {}; +static HBITMAP g_hIconDevBmp[MAX_DEVICE_SLOTS] = {}; // ── Shared state — protected by g_stateLock ────────────────────────────────── -// Written by main thread (LoadDeviceSelections via WhTool_ModSettingsChanged) -// and tray thread (SaveDeviceSelection). Read by worker thread (ToggleAudioDevice). +// Read by the worker thread (CycleAudioDevice) and tray thread (UpdateTrayTip, +// BuildAndShowContextMenu). Written by LoadDeviceSelections/LoadUserIconsAndSettings +// (main thread or tray thread via WM_RELOAD_ALL) and ToggleMuteCurrentDevice (tray thread). static CRITICAL_SECTION g_stateLock; -static WCHAR g_cachedDev1Id[512] = {}; -static WCHAR g_cachedDev1Name[256] = {}; -static WCHAR g_cachedDev2Id[512] = {}; -static WCHAR g_cachedDev2Name[256] = {}; +static WCHAR g_cachedDevId[MAX_DEVICE_SLOTS][512] = {}; +static WCHAR g_cachedDevName[MAX_DEVICE_SLOTS][256] = {}; +static int g_deviceSlotCount = 2; +static bool g_isMutedByUs = false; +static WCHAR g_mutedDeviceId[512] = {}; // ───────────────────────────────────────────────────────────────────────────── +// Priority device list — up to MAX_DEVICE_SLOTS entries, index 0 = highest priority. +// Written on main/tray thread under no lock (only touched during reload or init). +static WCHAR g_priorityDevIds[MAX_DEVICE_SLOTS][512] = {}; +static int g_priorityCount = 0; + +// Mode flag: set/read atomically with InterlockedExchange/InterlockedRead. +static volatile LONG g_scrollToSwap = 0; // 1 = scroll mode + +// IMMNotificationClient registration +class MicDeviceNotifier; +class VolNotifier; +static IMMDeviceEnumerator* g_notifEnum = nullptr; +static MicDeviceNotifier* g_deviceNotifier = nullptr; +static IAudioEndpointVolume* g_pEndpointVol = nullptr; // tray-thread owned; current default endpoint +static VolNotifier* g_pVolNotifier = nullptr; // registered on g_pEndpointVol + +// Dashboard GUI thread — written only from the tray thread (BuildAndShowContextMenu) +// and read only from the main thread (WhTool_ModUninit), which runs after the +// tray thread has been waited for. No concurrent access → no lock needed. +static HANDLE g_guiThread = nullptr; +static volatile LONG g_guiRunning = 0; // 1 while dashboard window is open + const CLSID CLSID_CPolicyConfigClient = { 0x870af99c, 0x171d, 0x4f9e, {0xaf, 0x0d, 0xe6, 0x3d, 0xf4, 0x0c, 0x2b, 0xc9} }; @@ -154,123 +250,1378 @@ IPolicyConfig : public IUnknown // ─── Helpers ────────────────────────────────────────────────────────────────── -int GetIconIndex(PCWSTR iconSetting) { - if (iconSetting) { - if (wcscmp(iconSetting, L"mic_classic") == 0) return 3; - if (wcscmp(iconSetting, L"mic_modern") == 0) return 92; - if (wcscmp(iconSetting, L"headset_gaming") == 0) return 8; - if (wcscmp(iconSetting, L"headset_modern") == 0) return 95; - if (wcscmp(iconSetting, L"earphones") == 0) return 6; +int GetIconIndex(PCWSTR s) { + if (s) { + if (wcscmp(s, L"mic_classic") == 0) return 3; + if (wcscmp(s, L"mic_modern") == 0) return 92; + if (wcscmp(s, L"headphones") == 0) return 2; + if (wcscmp(s, L"headset_gaming") == 0) return 8; + if (wcscmp(s, L"headphones_modern") == 0) return 91; + if (wcscmp(s, L"headset_modern") == 0) return 95; + if (wcscmp(s, L"earphones") == 0) return 6; } return 3; } +// ─── Settings dashboard ─────────────────────────────────────────────────────── + +namespace MicSwitchGui { + + // ── Palette ─────────────────────────────────────────────────────────────── + static const COLORREF kClrBg = RGB(24, 24, 24); + static const COLORREF kClrSurface = RGB(36, 36, 36); + static const COLORREF kClrBorder = RGB(56, 56, 56); + static const COLORREF kClrText = RGB(224, 224, 224); + static const COLORREF kClrDim = RGB(128, 128, 128); + static const COLORREF kClrInput = RGB(28, 28, 28); + static const COLORREF kClrAccent = RGB(0, 120, 212); + static const COLORREF kClrAccentP = RGB(0, 96, 170); // pressed + static const COLORREF kClrDarkBtn = RGB(44, 44, 44); + static const COLORREF kClrDarkBP = RGB(32, 32, 32); // pressed + + // ── Layout constants ────────────────────────────────────────────────────── + static const int kSW = 414; // slots panel width (right) + static const int kPW = 220; // priority panel width (left) + static const int kGap = 12; // gap between panels + static const int kCW = kSW + kGap + kPW; // total client width (646) + static const int kTopH = 72; // height of global-settings section + static const int kSlotH = 76; // height per slot row + static const int kPrioRowH = 52; // height per priority row (device + icon lines) + static const int kIconSz = 28; // icon drawn size + + struct DeviceInfo { std::wstring id, name; }; + + struct State { + HWND hTrayHwnd = nullptr; + HWND hMainWnd = nullptr; + + HFONT hFont = nullptr; + HBRUSH hBgBrush = nullptr; // kClrBg — returned from WM_CTLCOLOR* + HBRUSH hInpBrush = nullptr; // kClrInput + HBRUSH hSurfBrush = nullptr; // kClrSurface + + int deviceCount = 2; + int swapMode = 0; + + std::vector activeDevices; + + struct Slot { + HWND hDevCombo = nullptr; + HWND hIconCombo = nullptr; + HWND hBrowseBtn = nullptr; + HICON hPreviewIcon = nullptr; + std::wstring id, name, iconKey; + WCHAR customPath[MAX_PATH] = {}; + } slots[6]; + + HWND hCountCombo = nullptr; + HWND hModeCombo = nullptr; + HWND hSaveBtn = nullptr; + HWND hCancelBtn = nullptr; + HWND hKoFiBtn = nullptr; + + UINT dpi = 96; + bool advancedMode = false; + + // Priority section + struct PrioSlot { + HWND hDevCombo = nullptr; + HWND hIconCombo = nullptr; + HWND hBrowseBtn = nullptr; + std::wstring id, name, iconKey; + WCHAR customPath[MAX_PATH] = {}; + bool isOffline = false; + } prioSlots[MAX_DEVICE_SLOTS]; + }; + + static const WCHAR* kIconKeys[] = { + L"mic_classic", L"mic_modern", L"headphones", + L"headset_gaming", L"headphones_modern", L"headset_modern", + L"earphones", L"custom" + }; + static const WCHAR* kIconLabels[] = { + L"Classic Microphone", L"Modern Microphone", L"Headphones", + L"Gaming Headset", L"Modern Headphones", L"Modern Gaming Headset", + L"Earphones", L"Custom Icon..." + }; + static const int kIconCount = 8; + + // ── Helpers ─────────────────────────────────────────────────────────────── + + // Scale a base-96-DPI pixel value to the current DPI. + static int Sc(int px, UINT dpi) { return MulDiv(px, (int)dpi, 96); } + + // x-origin of the slots (right) panel. + static int SlotsX(UINT dpi) { return Sc(kPW + kGap, dpi); } + + // y-coordinate of slot i's top edge in client space (slots panel). + static int SlotY(int i, UINT dpi){ return Sc(kTopH,dpi) + i * Sc(kSlotH,dpi); } + + // y-coordinate of priority row i's top edge in client space (priority panel). + static int PrioRowY(int i, UINT dpi){ return Sc(32,dpi) + i * Sc(kPrioRowH,dpi); } + + // Extract the preview icon for a slot (32px). Caller owns the HICON. + static HICON LoadSlotPreview(const std::wstring& key, const WCHAR* customPath) { + HICON h = nullptr; + if (key == L"custom" && customPath && customPath[0]) + h = (HICON)LoadImageW(NULL, customPath, IMAGE_ICON, + kIconSz, kIconSz, LR_LOADFROMFILE); + if (!h) ExtractIconExW(g_ddoresDllPath, + GetIconIndex(key.empty() ? nullptr : key.c_str()), + nullptr, &h, 1); + return h; + } + + static void RefreshPreview(State* s, int i) { + if (s->slots[i].hPreviewIcon) { DestroyIcon(s->slots[i].hPreviewIcon); } + s->slots[i].hPreviewIcon = LoadSlotPreview(s->slots[i].iconKey, s->slots[i].customPath); + } + + // Apply DarkMode_CFD theme to a combo so its dropdown renders dark on Win10+. + static void DarkCombo(HWND h) { + HMODULE ux = GetModuleHandleW(L"uxtheme.dll"); + if (!ux) ux = LoadLibraryW(L"uxtheme.dll"); + if (!ux) return; + using Fn = HRESULT(WINAPI*)(HWND, LPCWSTR, LPCWSTR); + auto fn = reinterpret_cast(GetProcAddress(ux, "SetWindowTheme")); + if (fn) fn(h, L"DarkMode_CFD", nullptr); + } + + // Reposition all child controls and resize the window using the current DPI. + static void LayoutControls(State* s) { + if (!s->hMainWnd) return; + UINT d = s->dpi; + int sx = s->advancedMode ? SlotsX(d) : 0; + + // Top row (right panel) + SetWindowPos(s->hCountCombo, nullptr, sx+Sc(80,d), Sc(18,d), Sc(62,d), Sc(200,d), SWP_NOZORDER|SWP_NOACTIVATE); + SetWindowPos(s->hModeCombo, nullptr, sx+Sc(198,d), Sc(18,d), Sc(204,d), Sc(200,d), SWP_NOZORDER|SWP_NOACTIVATE); + + // Slot controls (right panel) + for (int i = 0; i < 6; i++) { + int y = SlotY(i, d); + SetWindowPos(s->slots[i].hDevCombo, nullptr, sx+Sc(46,d), y+Sc(22,d), Sc(356,d), Sc(300,d), SWP_NOZORDER|SWP_NOACTIVATE); + SetWindowPos(s->slots[i].hIconCombo, nullptr, sx+Sc(46,d), y+Sc(50,d), Sc(258,d), Sc(300,d), SWP_NOZORDER|SWP_NOACTIVATE); + SetWindowPos(s->slots[i].hBrowseBtn, nullptr, sx+Sc(310,d), y+Sc(48,d), Sc(92,d), Sc(26,d), SWP_NOZORDER|SWP_NOACTIVATE); + } + + // Priority controls (left panel) + // x=50 leaves room for the "1st:" etc. rank label to the left of each device combo. + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + int rowY = PrioRowY(i, d); + bool showBrowse = s->advancedMode && (s->prioSlots[i].iconKey == L"custom"); + int iconW = showBrowse ? Sc(106,d) : Sc(158,d); + SetWindowPos(s->prioSlots[i].hDevCombo, nullptr, Sc(50,d), rowY, Sc(158,d), Sc(200,d), SWP_NOZORDER|SWP_NOACTIVATE); + SetWindowPos(s->prioSlots[i].hIconCombo, nullptr, Sc(50,d), rowY+Sc(26,d), iconW, Sc(200,d), SWP_NOZORDER|SWP_NOACTIVATE); + SetWindowPos(s->prioSlots[i].hBrowseBtn, nullptr, Sc(158,d), rowY+Sc(24,d), Sc(50,d), Sc(24,d), SWP_NOZORDER|SWP_NOACTIVATE); + } + + // Button row (right panel, below visible slots) + int btnY = SlotY(s->deviceCount, d) + Sc(8,d); + SetWindowPos(s->hSaveBtn, nullptr, sx+Sc(12,d), btnY, Sc(148,d), Sc(28,d), SWP_NOZORDER|SWP_NOACTIVATE); + SetWindowPos(s->hCancelBtn, nullptr, sx+Sc(168,d), btnY, Sc(88,d), Sc(28,d), SWP_NOZORDER|SWP_NOACTIVATE); + SetWindowPos(s->hKoFiBtn, nullptr, sx+Sc(264,d), btnY, Sc(138,d), Sc(28,d), SWP_NOZORDER|SWP_NOACTIVATE); + + int prioPanelH = Sc(32,d) + MAX_DEVICE_SLOTS * Sc(kPrioRowH,d) + Sc(12,d); + int slotsPanelH = btnY + Sc(28,d) + Sc(12,d); + int clientH = prioPanelH > slotsPanelH ? prioPanelH : slotsPanelH; + + RECT rc = {0, 0, Sc(s->advancedMode ? kCW : kSW, d), clientH}; + AdjustWindowRectEx(&rc, + WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX, + FALSE, WS_EX_DLGMODALFRAME); + RECT wr; GetWindowRect(s->hMainWnd, &wr); + SetWindowPos(s->hMainWnd, nullptr, wr.left, wr.top, + rc.right - rc.left, rc.bottom - rc.top, + SWP_NOZORDER | SWP_NOACTIVATE); + } + + static void UpdateSlotVisibility(State* s) { + for (int i = 0; i < 6; i++) { + bool vis = (i < s->deviceCount); + ShowWindow(s->slots[i].hDevCombo, vis ? SW_SHOW : SW_HIDE); + ShowWindow(s->slots[i].hIconCombo, vis ? SW_SHOW : SW_HIDE); + ShowWindow(s->slots[i].hBrowseBtn, + (vis && s->slots[i].iconKey == L"custom") ? SW_SHOW : SW_HIDE); + } + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + ShowWindow(s->prioSlots[i].hDevCombo, s->advancedMode ? SW_SHOW : SW_HIDE); + ShowWindow(s->prioSlots[i].hIconCombo, s->advancedMode ? SW_SHOW : SW_HIDE); + ShowWindow(s->prioSlots[i].hBrowseBtn, + (s->advancedMode && s->prioSlots[i].iconKey == L"custom") ? SW_SHOW : SW_HIDE); + } + LayoutControls(s); + if (s->hMainWnd) InvalidateRect(s->hMainWnd, nullptr, TRUE); + } + + static BOOL CALLBACK ApplyFontProc(HWND child, LPARAM hf) { + SendMessageW(child, WM_SETFONT, (WPARAM)hf, TRUE); + return TRUE; + } + + // ── State I/O ───────────────────────────────────────────────────────────── + + static void LoadState(State& s) { + s.advancedMode = (Wh_GetIntSetting(L"advancedMode") != 0); + + IMMDeviceEnumerator* pEnum = nullptr; + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, + CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), + (void**)&pEnum))) { + IMMDeviceCollection* pColl = nullptr; + if (SUCCEEDED(pEnum->EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE, &pColl))) { + UINT count = 0; pColl->GetCount(&count); + for (UINT i = 0; i < count; i++) { + IMMDevice* pDev = nullptr; + if (SUCCEEDED(pColl->Item(i, &pDev))) { + LPWSTR pId = nullptr; + if (SUCCEEDED(pDev->GetId(&pId))) { + IPropertyStore* pStore = nullptr; + if (SUCCEEDED(pDev->OpenPropertyStore(STGM_READ, &pStore))) { + PROPVARIANT v; PropVariantInit(&v); + if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &v)) + && v.pwszVal) + s.activeDevices.push_back({pId, v.pwszVal}); + PropVariantClear(&v); pStore->Release(); + } + CoTaskMemFree(pId); + } + pDev->Release(); + } + } + pColl->Release(); + } + pEnum->Release(); + } + + { + WCHAR dcBuf[8] = {}; + Wh_GetStringValue(L"deviceCount", dcBuf, 8); + int n = 0; + for (const WCHAR* p = dcBuf; *p >= L'0' && *p <= L'9'; p++) n = n*10 + (*p-L'0'); + s.deviceCount = (n >= 2 && n <= 6) ? n : 2; + } + { + WCHAR modeBuf[32] = {}; + Wh_GetStringValue(L"swapMode", modeBuf, 32); + s.swapMode = (wcscmp(modeBuf, L"scroll_to_swap") == 0) ? 1 : 0; + } + + for (int i = 0; i < 6; i++) { + WCHAR kId[32], kNm[32], kPth[32], buf[512]; + swprintf_s(kId, L"Device%dId", i+1); + swprintf_s(kNm, L"Device%dName", i+1); + swprintf_s(kPth, L"icon%d_custom_path", i+1); + Wh_GetStringValue(kId, buf, 512); s.slots[i].id = buf; + Wh_GetStringValue(kNm, buf, 256); s.slots[i].name = buf; + WCHAR kIco[16]; swprintf_s(kIco, L"icon%d", i+1); + WCHAR ikBuf[32] = {}; + Wh_GetStringValue(kIco, ikBuf, 32); + s.slots[i].iconKey = ikBuf[0] ? ikBuf : L"mic_classic"; + Wh_GetStringValue(kPth, s.slots[i].customPath, MAX_PATH); + s.slots[i].hPreviewIcon = LoadSlotPreview(s.slots[i].iconKey, s.slots[i].customPath); + } + + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + WCHAR kId[32], kNm[32], kIco[32], kPth[32], buf[512]; + swprintf_s(kId, L"priority%d", i+1); + swprintf_s(kNm, L"priority%d_name", i+1); + swprintf_s(kIco, L"priority%d_icon", i+1); + swprintf_s(kPth, L"priority%d_icon_custom_path", i+1); + Wh_GetStringValue(kId, buf, 512); s.prioSlots[i].id = buf; + Wh_GetStringValue(kNm, buf, 512); s.prioSlots[i].name = buf; + WCHAR ikBuf[32] = {}; + Wh_GetStringValue(kIco, ikBuf, 32); + s.prioSlots[i].iconKey = ikBuf[0] ? ikBuf : L"mic_classic"; + Wh_GetStringValue(kPth, s.prioSlots[i].customPath, MAX_PATH); + s.prioSlots[i].isOffline = false; + if (!s.prioSlots[i].id.empty()) { + bool found = false; + for (auto& dev : s.activeDevices) + if (dev.id == s.prioSlots[i].id) { found = true; break; } + if (!found) s.prioSlots[i].isOffline = true; + } + } + } + + // ── Window procedure ────────────────────────────────────────────────────── + + static LRESULT CALLBACK DashboardWndProc(HWND hWnd, UINT msg, + WPARAM wParam, LPARAM lParam) { + State* s = reinterpret_cast(GetWindowLongPtrW(hWnd, GWLP_USERDATA)); + + switch (msg) { + + // ── Initialise ──────────────────────────────────────────────────────── + case WM_CREATE: { + auto* cs = reinterpret_cast(lParam); + s = reinterpret_cast(cs->lpCreateParams); + SetWindowLongPtrW(hWnd, GWLP_USERDATA, reinterpret_cast(s)); + s->hMainWnd = hWnd; + s->dpi = GetDpiForWindow(hWnd); + { + LOGFONTW lf = {}; + lf.lfHeight = -MulDiv(10, (int)s->dpi, 72); // 10pt at current DPI + lf.lfWeight = FW_NORMAL; + lf.lfQuality = CLEARTYPE_QUALITY; + lf.lfPitchAndFamily = DEFAULT_PITCH | FF_DONTCARE; + wcscpy_s(lf.lfFaceName, L"Segoe UI"); + s->hFont = CreateFontIndirectW(&lf); + } + s->hBgBrush = CreateSolidBrush(kClrBg); + s->hInpBrush = CreateSolidBrush(kClrInput); + s->hSurfBrush = CreateSolidBrush(kClrSurface); + + HINSTANCE hInst = GetModuleHandleW(nullptr); + + // ── Top row: Devices count + Interaction Mode ───────────────────── + // All labels are painted in WM_PAINT; only combos are child HWNDs. + // Layout (x): "Devices:" @12 → combo @80 w=62 → "Mode:" @156 → combo @198 w=204 + s->hCountCombo = CreateWindowExW(WS_EX_CLIENTEDGE, L"COMBOBOX", nullptr, + WS_CHILD|WS_VISIBLE|CBS_DROPDOWNLIST, 80, 18, 62, 200, + hWnd, (HMENU)(UINT_PTR)100, hInst, nullptr); + for (int i = 2; i <= 6; i++) { + WCHAR b[4]; swprintf_s(b, L"%d", i); + SendMessageW(s->hCountCombo, CB_ADDSTRING, 0, (LPARAM)b); + } + SendMessageW(s->hCountCombo, CB_SETCURSEL, s->deviceCount - 2, 0); + DarkCombo(s->hCountCombo); + + s->hModeCombo = CreateWindowExW(WS_EX_CLIENTEDGE, L"COMBOBOX", nullptr, + WS_CHILD|WS_VISIBLE|CBS_DROPDOWNLIST, 198, 18, 204, 200, + hWnd, (HMENU)(UINT_PTR)101, hInst, nullptr); + SendMessageW(s->hModeCombo, CB_ADDSTRING, 0, (LPARAM)L"Click to Swap"); + SendMessageW(s->hModeCombo, CB_ADDSTRING, 0, (LPARAM)L"Scroll to Swap"); + SendMessageW(s->hModeCombo, CB_SETCURSEL, s->swapMode, 0); + DarkCombo(s->hModeCombo); + + // ── Per-slot controls ───────────────────────────────────────────── + // Controls are created at (0,0) and repositioned by LayoutControls below. + for (int i = 0; i < 6; i++) { + s->slots[i].hDevCombo = CreateWindowExW(WS_EX_CLIENTEDGE, L"COMBOBOX", nullptr, + WS_CHILD|CBS_DROPDOWNLIST, 0, 0, 10, 300, + hWnd, (HMENU)(UINT_PTR)(200+i), hInst, nullptr); + for (int j = 0; j < (int)s->activeDevices.size(); j++) { + int idx = (int)SendMessageW(s->slots[i].hDevCombo, CB_ADDSTRING, + 0, (LPARAM)s->activeDevices[j].name.c_str()); + if (s->slots[i].id == s->activeDevices[j].id) + SendMessageW(s->slots[i].hDevCombo, CB_SETCURSEL, idx, 0); + } + DarkCombo(s->slots[i].hDevCombo); + + s->slots[i].hIconCombo = CreateWindowExW(WS_EX_CLIENTEDGE, L"COMBOBOX", nullptr, + WS_CHILD|CBS_DROPDOWNLIST, 0, 0, 10, 300, + hWnd, (HMENU)(UINT_PTR)(300+i), hInst, nullptr); + int isel = 0; + for (int j = 0; j < kIconCount; j++) { + SendMessageW(s->slots[i].hIconCombo, CB_ADDSTRING, 0, (LPARAM)kIconLabels[j]); + if (s->slots[i].iconKey == kIconKeys[j]) isel = j; + } + SendMessageW(s->slots[i].hIconCombo, CB_SETCURSEL, isel, 0); + DarkCombo(s->slots[i].hIconCombo); + + s->slots[i].hBrowseBtn = CreateWindowExW(0, L"BUTTON", L"Browse...", + WS_CHILD|BS_OWNERDRAW, 0, 0, 10, 10, + hWnd, (HMENU)(UINT_PTR)(400+i), hInst, nullptr); + } + + // ── Priority controls (left panel) ──────────────────────────────── + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + // Device combo (500+i) + s->prioSlots[i].hDevCombo = CreateWindowExW(WS_EX_CLIENTEDGE, L"COMBOBOX", nullptr, + WS_CHILD|WS_VISIBLE|CBS_DROPDOWNLIST, 0, 0, 10, 300, + hWnd, (HMENU)(UINT_PTR)(500+i), hInst, nullptr); + SendMessageW(s->prioSlots[i].hDevCombo, CB_ADDSTRING, 0, (LPARAM)L"None"); + int psel = 0; + for (int j = 0; j < (int)s->activeDevices.size(); j++) { + int idx = (int)SendMessageW(s->prioSlots[i].hDevCombo, CB_ADDSTRING, + 0, (LPARAM)s->activeDevices[j].name.c_str()); + if (s->prioSlots[i].id == s->activeDevices[j].id) psel = idx; + } + if (s->prioSlots[i].isOffline && !s->prioSlots[i].name.empty()) { + WCHAR offLabel[320]; + swprintf_s(offLabel, L"%s (offline)", s->prioSlots[i].name.c_str()); + int idx = (int)SendMessageW(s->prioSlots[i].hDevCombo, CB_ADDSTRING, + 0, (LPARAM)offLabel); + psel = idx; + } + SendMessageW(s->prioSlots[i].hDevCombo, CB_SETCURSEL, psel, 0); + DarkCombo(s->prioSlots[i].hDevCombo); + + // Icon combo (600+i) + s->prioSlots[i].hIconCombo = CreateWindowExW(WS_EX_CLIENTEDGE, L"COMBOBOX", nullptr, + WS_CHILD|WS_VISIBLE|CBS_DROPDOWNLIST, 0, 0, 10, 300, + hWnd, (HMENU)(UINT_PTR)(600+i), hInst, nullptr); + int isel = 0; + for (int j = 0; j < kIconCount; j++) { + SendMessageW(s->prioSlots[i].hIconCombo, CB_ADDSTRING, 0, (LPARAM)kIconLabels[j]); + if (s->prioSlots[i].iconKey == kIconKeys[j]) isel = j; + } + SendMessageW(s->prioSlots[i].hIconCombo, CB_SETCURSEL, isel, 0); + DarkCombo(s->prioSlots[i].hIconCombo); + + // Browse button (700+i) — visible only when iconKey == "custom" + s->prioSlots[i].hBrowseBtn = CreateWindowExW(0, L"BUTTON", L"Browse...", + WS_CHILD|BS_OWNERDRAW|(s->prioSlots[i].iconKey == L"custom" ? WS_VISIBLE : 0), + 0, 0, 10, 10, + hWnd, (HMENU)(UINT_PTR)(700+i), hInst, nullptr); + } + + // ── Bottom buttons ──────────────────────────────────────────────── + s->hSaveBtn = CreateWindowExW(0, L"BUTTON", L"Save and Apply", + WS_CHILD|WS_VISIBLE|BS_OWNERDRAW, 0, 0, 10, 10, + hWnd, (HMENU)IDOK, hInst, nullptr); + s->hCancelBtn = CreateWindowExW(0, L"BUTTON", L"Cancel", + WS_CHILD|WS_VISIBLE|BS_OWNERDRAW, 0, 0, 10, 10, + hWnd, (HMENU)IDCANCEL, hInst, nullptr); + s->hKoFiBtn = CreateWindowExW(0, L"BUTTON", L"Buy Me Coffee", + WS_CHILD|WS_VISIBLE|BS_OWNERDRAW, 0, 0, 10, 10, + hWnd, (HMENU)IDC_BTN_KOFI, hInst, nullptr); + + EnumChildWindows(hWnd, ApplyFontProc, reinterpret_cast(s->hFont)); + UpdateSlotVisibility(s); // also calls LayoutControls → positions everything + + // Request dark title bar from DWM (Win10 1903+, silently fails earlier) + { + HMODULE dwm = GetModuleHandleW(L"dwmapi.dll"); + if (!dwm) dwm = LoadLibraryW(L"dwmapi.dll"); + if (dwm) { + using DwmFn = HRESULT(WINAPI*)(HWND, DWORD, LPCVOID, DWORD); + auto fn = reinterpret_cast(GetProcAddress(dwm, "DwmSetWindowAttribute")); + if (fn) { BOOL dark = TRUE; fn(hWnd, 20, &dark, sizeof(dark)); } + } + } + return 0; + } + + // ── Background & painting ───────────────────────────────────────────── + case WM_ERASEBKGND: { + RECT rc; GetClientRect(hWnd, &rc); + FillRect((HDC)wParam, &rc, s ? s->hBgBrush : (HBRUSH)GetStockObject(BLACK_BRUSH)); + return 1; + } + + case WM_PAINT: { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hWnd, &ps); + SelectObject(hdc, s->hFont); + SetBkMode(hdc, TRANSPARENT); + RECT cr; GetClientRect(hWnd, &cr); + UINT d = s->dpi; + int sx = s->advancedMode ? SlotsX(d) : 0; + + if (s->advancedMode) { + // ── Left panel background + vertical separator ──────────────── + { + RECT lp = {0, 0, Sc(kPW,d), cr.bottom}; + FillRect(hdc, &lp, s->hSurfBrush); + HPEN p = CreatePen(PS_SOLID, 1, kClrBorder); + HPEN op = (HPEN)SelectObject(hdc, p); + MoveToEx(hdc, Sc(kPW,d), 0, nullptr); + LineTo(hdc, Sc(kPW,d), cr.bottom); + SelectObject(hdc, op); DeleteObject(p); + } + + // ── Left panel: Device Priority header + rank labels ────────── + static const WCHAR* kRankLabels[] = {L"1st:", L"2nd:", L"3rd:", L"4th:", L"5th:", L"6th:"}; + SetTextColor(hdc, kClrText); + { + RECT r2 = {Sc(12,d), Sc(10,d), Sc(kPW-4,d), Sc(28,d)}; + DrawTextW(hdc, L"Device Priority", -1, &r2, DT_LEFT|DT_TOP); + } + SetTextColor(hdc, kClrDim); + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + int rowY = PrioRowY(i, d); + RECT rk = {Sc(12,d), rowY + Sc(3,d), Sc(48,d), rowY + Sc(19,d)}; + RECT ri = {Sc(12,d), rowY + Sc(29,d), Sc(48,d), rowY + Sc(45,d)}; + DrawTextW(hdc, kRankLabels[i], -1, &rk, DT_LEFT|DT_TOP); + DrawTextW(hdc, L"icon:", -1, &ri, DT_LEFT|DT_TOP); + } + } + + // ── Right panel: top-section labels ─────────────────────────────── + SetTextColor(hdc, kClrDim); + RECT r; + r = {sx+Sc(12,d), Sc(22,d), sx+Sc(78,d), Sc(38,d)}; + DrawTextW(hdc, L"Devices:", -1, &r, DT_LEFT|DT_TOP); + r = {sx+Sc(156,d), Sc(22,d), sx+Sc(196,d), Sc(38,d)}; + DrawTextW(hdc, L"Mode:", -1, &r, DT_LEFT|DT_TOP); + + // Separator between top controls and slot section + { + HPEN p = CreatePen(PS_SOLID, 1, kClrBorder); + HPEN op = (HPEN)SelectObject(hdc, p); + MoveToEx(hdc, sx+Sc(12,d), Sc(kTopH,d) - Sc(6,d), nullptr); + LineTo(hdc, cr.right - Sc(12,d), Sc(kTopH,d) - Sc(6,d)); + SelectObject(hdc, op); DeleteObject(p); + } + + // ── Per-slot headers, icons, and row labels ─────────────────────── + for (int i = 0; i < s->deviceCount; i++) { + int y = SlotY(i, d); + + RECT hdr = {sx, y, cr.right, y + Sc(20,d)}; + FillRect(hdc, &hdr, s->hSurfBrush); + + SetTextColor(hdc, kClrDim); + RECT sl = {sx+Sc(12,d), y + Sc(2,d), sx+Sc(100,d), y + Sc(18,d)}; + WCHAR lbl[16]; swprintf_s(lbl, L"Slot %d", i + 1); + DrawTextW(hdc, lbl, -1, &sl, DT_LEFT|DT_TOP); + + if (s->slots[i].hPreviewIcon) + DrawIconEx(hdc, sx+Sc(12,d), y + Sc(22,d), s->slots[i].hPreviewIcon, + Sc(kIconSz,d), Sc(kIconSz,d), 0, nullptr, DI_NORMAL); + + SetTextColor(hdc, kClrDim); + RECT il = {sx+Sc(12,d), y + Sc(54,d), sx+Sc(44,d), y + Sc(68,d)}; + DrawTextW(hdc, L"Icon:", -1, &il, DT_LEFT|DT_TOP); + + if (i < s->deviceCount - 1) { + HPEN p = CreatePen(PS_SOLID, 1, kClrBorder); + HPEN op = (HPEN)SelectObject(hdc, p); + MoveToEx(hdc, sx, y + Sc(kSlotH,d) - 1, nullptr); + LineTo(hdc, cr.right, y + Sc(kSlotH,d) - 1); + SelectObject(hdc, op); DeleteObject(p); + } + } + + EndPaint(hWnd, &ps); + return 0; + } + + // ── Dark theming for child controls ─────────────────────────────────── + case WM_CTLCOLORSTATIC: + if (s) { + SetTextColor((HDC)wParam, kClrDim); + SetBkColor((HDC)wParam, kClrBg); + return (LRESULT)s->hBgBrush; + } + break; + case WM_CTLCOLOREDIT: + case WM_CTLCOLORLISTBOX: + if (s) { + SetTextColor((HDC)wParam, kClrText); + SetBkColor((HDC)wParam, kClrInput); + return (LRESULT)s->hInpBrush; + } + break; + case WM_CTLCOLORBTN: + if (s) return (LRESULT)s->hBgBrush; + break; + + // ── Owner-draw buttons ──────────────────────────────────────────────── + case WM_DRAWITEM: { + auto* dis = reinterpret_cast(lParam); + if (dis->CtlType != ODT_BUTTON || !s) break; + bool pressed = (dis->itemState & ODS_SELECTED) != 0; + bool isSave = (dis->CtlID == IDOK); + bool isBrowse= (dis->CtlID >= 400 && dis->CtlID < 406) || + (dis->CtlID >= 700 && dis->CtlID < 706); + + COLORREF bg; + if (isSave) bg = pressed ? kClrAccentP : kClrAccent; + else if (isBrowse) bg = pressed ? kClrDarkBP : kClrSurface; + else bg = pressed ? kClrDarkBP : kClrDarkBtn; + + HBRUSH hFill = CreateSolidBrush(bg); + FillRect(dis->hDC, &dis->rcItem, hFill); + DeleteObject(hFill); + + // 1px border + HPEN hPen = CreatePen(PS_SOLID, 1, + isSave ? kClrAccentP : kClrBorder); + HPEN hOp = (HPEN)SelectObject(dis->hDC, hPen); + SelectObject(dis->hDC, GetStockObject(NULL_BRUSH)); + Rectangle(dis->hDC, dis->rcItem.left, dis->rcItem.top, + dis->rcItem.right, dis->rcItem.bottom); + SelectObject(dis->hDC, hOp); DeleteObject(hPen); + + // Button label + WCHAR txt[64] = {}; GetWindowTextW(dis->hwndItem, txt, 64); + SetTextColor(dis->hDC, kClrText); + SetBkMode(dis->hDC, TRANSPARENT); + SelectObject(dis->hDC, s->hFont); + DrawTextW(dis->hDC, txt, -1, &dis->rcItem, + DT_CENTER | DT_VCENTER | DT_SINGLELINE); + + if (dis->itemState & ODS_FOCUS) DrawFocusRect(dis->hDC, &dis->rcItem); + return TRUE; + } + + // ── User interaction ────────────────────────────────────────────────── + case WM_COMMAND: { + int id = LOWORD(wParam); + + if (id == 100 && HIWORD(wParam) == CBN_SELCHANGE) { + // Device count changed — resize window and show/hide slot rows + s->deviceCount = (int)SendMessageW(s->hCountCombo, CB_GETCURSEL, 0, 0) + 2; + UpdateSlotVisibility(s); // calls LayoutControls internally + + } else if (id >= 300 && id < 306 && HIWORD(wParam) == CBN_SELCHANGE) { + // Icon style changed for slot (id-300) — live preview update + int slot = id - 300; + int sel = (int)SendMessageW(s->slots[slot].hIconCombo, CB_GETCURSEL, 0, 0); + if (sel >= 0 && sel < kIconCount) s->slots[slot].iconKey = kIconKeys[sel]; + RefreshPreview(s, slot); + UINT d = s->dpi; + int sx = s->advancedMode ? SlotsX(d) : 0; + int y = SlotY(slot, d); + RECT ir = {sx+Sc(12,d), y+Sc(20,d), sx+Sc(12,d)+Sc(kIconSz,d)+2, y+Sc(20,d)+Sc(kIconSz,d)+2}; + InvalidateRect(hWnd, &ir, TRUE); + UpdateSlotVisibility(s); // toggles Browse button + + } else if (id >= 400 && id < 406) { + // Browse for custom .ico + int slot = id - 400; + WCHAR path[MAX_PATH] = {}; + wcscpy_s(path, s->slots[slot].customPath); + WCHAR title[64]; swprintf_s(title, L"Select Icon for Slot %d", slot + 1); + OPENFILENAMEW ofn = {sizeof(ofn)}; + ofn.hwndOwner = hWnd; + ofn.lpstrFilter = L"Icon Files (*.ico)\0*.ico\0All Files (*.*)\0*.*\0"; + ofn.nFilterIndex = 1; + ofn.lpstrFile = path; + ofn.nMaxFile = MAX_PATH; + ofn.lpstrTitle = title; + ofn.Flags = OFN_PATHMUSTEXIST|OFN_FILEMUSTEXIST|OFN_HIDEREADONLY|OFN_NOCHANGEDIR; + if (GetOpenFileNameW(&ofn)) { + wcscpy_s(s->slots[slot].customPath, path); + RefreshPreview(s, slot); + UINT d = s->dpi; + int sx = s->advancedMode ? SlotsX(d) : 0; + int y = SlotY(slot, d); + RECT ir = {sx+Sc(12,d), y+Sc(20,d), sx+Sc(12,d)+Sc(kIconSz,d)+2, y+Sc(20,d)+Sc(kIconSz,d)+2}; + InvalidateRect(hWnd, &ir, TRUE); + } + + } else if (id >= 600 && id < 606 && HIWORD(wParam) == CBN_SELCHANGE) { + // Priority icon changed + int slot = id - 600; + int sel = (int)SendMessageW(s->prioSlots[slot].hIconCombo, CB_GETCURSEL, 0, 0); + if (sel >= 0 && sel < kIconCount) s->prioSlots[slot].iconKey = kIconKeys[sel]; + ShowWindow(s->prioSlots[slot].hBrowseBtn, + s->prioSlots[slot].iconKey == L"custom" ? SW_SHOW : SW_HIDE); + LayoutControls(s); + + } else if (id >= 700 && id < 706) { + // Browse for custom priority icon + int slot = id - 700; + WCHAR path[MAX_PATH] = {}; + wcscpy_s(path, s->prioSlots[slot].customPath); + WCHAR title[64]; swprintf_s(title, L"Select Icon for Priority %d", slot + 1); + OPENFILENAMEW ofn = {sizeof(ofn)}; + ofn.hwndOwner = hWnd; + ofn.lpstrFilter = L"Icon Files (*.ico)\0*.ico\0All Files (*.*)\0*.*\0"; + ofn.nFilterIndex = 1; + ofn.lpstrFile = path; + ofn.nMaxFile = MAX_PATH; + ofn.lpstrTitle = title; + ofn.Flags = OFN_PATHMUSTEXIST|OFN_FILEMUSTEXIST|OFN_HIDEREADONLY|OFN_NOCHANGEDIR; + if (GetOpenFileNameW(&ofn)) + wcscpy_s(s->prioSlots[slot].customPath, path); + + } else if (id == IDOK) { + s->deviceCount = (int)SendMessageW(s->hCountCombo, CB_GETCURSEL, 0, 0) + 2; + s->swapMode = (int)SendMessageW(s->hModeCombo, CB_GETCURSEL, 0, 0); + for (int i = 0; i < 6; i++) { + int ds = (int)SendMessageW(s->slots[i].hDevCombo, CB_GETCURSEL, 0, 0); + if (ds >= 0 && ds < (int)s->activeDevices.size()) { + s->slots[i].id = s->activeDevices[ds].id; + s->slots[i].name = s->activeDevices[ds].name; + } + int is = (int)SendMessageW(s->slots[i].hIconCombo, CB_GETCURSEL, 0, 0); + if (is >= 0 && is < kIconCount) s->slots[i].iconKey = kIconKeys[is]; + } + WCHAR num[4]; swprintf_s(num, L"%d", s->deviceCount); + Wh_SetStringValue(L"deviceCount", num); + Wh_SetStringValue(L"swapMode", s->swapMode ? L"scroll_to_swap" : L"click_to_swap"); + for (int i = 0; i < 6; i++) { + WCHAR kId[32], kNm[32], kIco[32], kPth[32]; + swprintf_s(kId, L"Device%dId", i+1); + swprintf_s(kNm, L"Device%dName", i+1); + swprintf_s(kIco, L"icon%d", i+1); + swprintf_s(kPth, L"icon%d_custom_path", i+1); + Wh_SetStringValue(kId, s->slots[i].id.c_str()); + Wh_SetStringValue(kNm, s->slots[i].name.c_str()); + Wh_SetStringValue(kIco, s->slots[i].iconKey.c_str()); + Wh_SetStringValue(kPth, s->slots[i].customPath); + } + // Save priority list + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + WCHAR kId[32], kNm[32], kIco[32], kPth[32]; + swprintf_s(kId, L"priority%d", i+1); + swprintf_s(kNm, L"priority%d_name", i+1); + swprintf_s(kIco, L"priority%d_icon", i+1); + swprintf_s(kPth, L"priority%d_icon_custom_path", i+1); + int psel = (int)SendMessageW(s->prioSlots[i].hDevCombo, CB_GETCURSEL, 0, 0); + if (psel == 0) { + Wh_SetStringValue(kId, L""); Wh_SetStringValue(kNm, L""); + } else if (psel - 1 < (int)s->activeDevices.size()) { + Wh_SetStringValue(kId, s->activeDevices[psel-1].id.c_str()); + Wh_SetStringValue(kNm, s->activeDevices[psel-1].name.c_str()); + } else { + // offline entry selected — preserve saved ID/name + Wh_SetStringValue(kId, s->prioSlots[i].id.c_str()); + Wh_SetStringValue(kNm, s->prioSlots[i].name.c_str()); + } + int pisel = (int)SendMessageW(s->prioSlots[i].hIconCombo, CB_GETCURSEL, 0, 0); + if (pisel >= 0 && pisel < kIconCount) { + Wh_SetStringValue(kIco, kIconKeys[pisel]); + s->prioSlots[i].iconKey = kIconKeys[pisel]; + } + Wh_SetStringValue(kPth, s->prioSlots[i].customPath); + } + if (s->hTrayHwnd) PostMessageW(s->hTrayHwnd, WM_RELOAD_ALL, 0, 0); + DestroyWindow(hWnd); + + } else if (id == IDCANCEL) { + DestroyWindow(hWnd); + } else if (id == IDC_BTN_KOFI) { + ShellExecuteW(nullptr, L"open", L"https://ko-fi.com/blackpaw21", + nullptr, nullptr, SW_SHOWNORMAL); + } + return 0; + } + + // ── DPI change ──────────────────────────────────────────────────────── + case WM_DPICHANGED: { + s->dpi = HIWORD(wParam); + // Rebuild font at new DPI. + DeleteObject(s->hFont); + LOGFONTW lf = {}; + lf.lfHeight = -MulDiv(10, (int)s->dpi, 72); + lf.lfWeight = FW_NORMAL; + lf.lfQuality = CLEARTYPE_QUALITY; + lf.lfPitchAndFamily = DEFAULT_PITCH | FF_DONTCARE; + wcscpy_s(lf.lfFaceName, L"Segoe UI"); + s->hFont = CreateFontIndirectW(&lf); + EnumChildWindows(hWnd, ApplyFontProc, (LPARAM)s->hFont); + // Move to OS-suggested rect (avoids visual jump on DPI boundary crossing). + const RECT* prc = reinterpret_cast(lParam); + SetWindowPos(hWnd, nullptr, prc->left, prc->top, + prc->right - prc->left, prc->bottom - prc->top, + SWP_NOZORDER | SWP_NOACTIVATE); + LayoutControls(s); + InvalidateRect(hWnd, nullptr, TRUE); + return 0; + } + + // ── Cleanup ─────────────────────────────────────────────────────────── + case WM_DESTROY: + if (s) { + DeleteObject(s->hFont); s->hFont = nullptr; + DeleteObject(s->hBgBrush); s->hBgBrush = nullptr; + DeleteObject(s->hInpBrush); s->hInpBrush = nullptr; + DeleteObject(s->hSurfBrush);s->hSurfBrush= nullptr; + for (int i = 0; i < 6; i++) { + if (s->slots[i].hPreviewIcon) { + DestroyIcon(s->slots[i].hPreviewIcon); + s->slots[i].hPreviewIcon = nullptr; + } + } + } + PostQuitMessage(0); + return 0; + } + return DefWindowProcW(hWnd, msg, wParam, lParam); + } + + // ── GUI thread ──────────────────────────────────────────────────────────── + + static DWORD WINAPI GuiThreadProc(LPVOID lpParam) { + CoInitialize(nullptr); + + // Set Per-Monitor DPI Awareness V2 so Windows doesn't bitmap-scale the window. + using SetTDACFn = DPI_AWARENESS_CONTEXT(WINAPI*)(DPI_AWARENESS_CONTEXT); + if (auto fn = (SetTDACFn)GetProcAddress( + GetModuleHandleW(L"user32.dll"), "SetThreadDpiAwarenessContext")) + fn(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + + static const WCHAR kClass[] = L"MicSwitchDashboard"; + HINSTANCE hInst = GetModuleHandleW(nullptr); + + WNDCLASSEXW wc = {sizeof(wc)}; + wc.lpfnWndProc = DashboardWndProc; + wc.hInstance = hInst; + wc.hCursor = LoadCursorW(nullptr, IDC_ARROW); + wc.hbrBackground = nullptr; // WM_ERASEBKGND fills background + wc.lpszClassName = kClass; + RegisterClassExW(&wc); + + State state; + state.hTrayHwnd = reinterpret_cast(lpParam); + LoadState(state); + + // Create at center of primary monitor (LayoutControls resizes in WM_CREATE + // before ShowWindow, so the initial size is just a placeholder). + int cxScr = GetSystemMetrics(SM_CXSCREEN); + int cyScr = GetSystemMetrics(SM_CYSCREEN); + int sx = cxScr / 4; + int sy = cyScr / 4; + + HWND hWnd = CreateWindowExW( + WS_EX_DLGMODALFRAME, + kClass, L"MicSwitch Settings", + WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX, + sx, sy, cxScr / 2, cyScr / 2, + nullptr, nullptr, hInst, &state); + + if (hWnd) { + ShowWindow(hWnd, SW_SHOW); + UpdateWindow(hWnd); + MSG msg; + while (GetMessageW(&msg, nullptr, 0, 0)) { + if (!IsDialogMessageW(hWnd, &msg)) { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + } + + // Icons are destroyed in WM_DESTROY; clean up here only if window never opened. + for (int i = 0; i < 6; i++) { + if (state.slots[i].hPreviewIcon) { + DestroyIcon(state.slots[i].hPreviewIcon); + state.slots[i].hPreviewIcon = nullptr; + } + } + + UnregisterClassW(kClass, hInst); + CoUninitialize(); + InterlockedExchange(&g_guiRunning, 0); + return 0; + } + + HANDLE LaunchDashboard(HWND hTrayHwnd) { + if (InterlockedCompareExchange(&g_guiRunning, 1, 0) != 0) + return nullptr; + HANDLE h = CreateThread(nullptr, 0, GuiThreadProc, + reinterpret_cast(hTrayHwnd), 0, nullptr); + if (!h) InterlockedExchange(&g_guiRunning, 0); + return h; + } + +} // namespace MicSwitchGui + // ─── Device selection data ──────────────────────────────────────────────────── +// All functions below acquire g_stateLock for writes to shared slot data. + +void LoadPriorityList() { + WCHAR tmpIds[MAX_DEVICE_SLOTS][512] = {}; + int count = 0; + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + WCHAR key[32]; + swprintf_s(key, L"priority%d", i + 1); + Wh_GetStringValue(key, tmpIds[i], 512); + if (tmpIds[i][0]) count = i + 1; + } + EnterCriticalSection(&g_stateLock); + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) + lstrcpynW(g_priorityDevIds[i], tmpIds[i], 512); + g_priorityCount = count; + LeaveCriticalSection(&g_stateLock); +} void LoadDeviceSelections() { - // Read outside lock — Wh_GetStringValue is thread-safe. - WCHAR tmpId1[512] = {}; - WCHAR tmpName1[256] = {}; - WCHAR tmpId2[512] = {}; - WCHAR tmpName2[256] = {}; + // Read from Windhawk storage outside the lock (Wh_GetStringValue is thread-safe). + WCHAR tmpIds[MAX_DEVICE_SLOTS][512] = {}; + WCHAR tmpNames[MAX_DEVICE_SLOTS][256] = {}; + + WCHAR keyId[32], keyName[32]; + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + swprintf_s(keyId, L"Device%dId", i + 1); + swprintf_s(keyName, L"Device%dName", i + 1); + Wh_GetStringValue(keyId, tmpIds[i], 512); + Wh_GetStringValue(keyName, tmpNames[i], 256); + } + + EnterCriticalSection(&g_stateLock); + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + lstrcpynW(g_cachedDevId[i], tmpIds[i], 512); + lstrcpynW(g_cachedDevName[i], tmpNames[i], 256); + } + LeaveCriticalSection(&g_stateLock); +} + + +static int ReadDeviceCountSetting() { + WCHAR buf[8] = {}; + Wh_GetStringValue(L"deviceCount", buf, 8); + int n = 0; + for (const WCHAR* p = buf; *p >= L'0' && *p <= L'9'; p++) + n = n * 10 + (*p - L'0'); + if (n < 2) n = 2; + if (n > MAX_DEVICE_SLOTS) n = MAX_DEVICE_SLOTS; + return n; +} + +void LoadUserIconsAndSettings() { + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + if (g_iconDev[i]) { DestroyIcon(g_iconDev[i]); g_iconDev[i] = nullptr; } + if (g_hIconDevBmp[i]){ DeleteObject(g_hIconDevBmp[i]); g_hIconDevBmp[i] = nullptr; } + } + + int newCount = ReadDeviceCountSetting(); - Wh_GetStringValue(L"Device1Id", tmpId1, 512); - Wh_GetStringValue(L"Device1Name", tmpName1, 256); - Wh_GetStringValue(L"Device2Id", tmpId2, 512); - Wh_GetStringValue(L"Device2Name", tmpName2, 256); + WCHAR swapModeBuf[32] = {}; + Wh_GetStringValue(L"swapMode", swapModeBuf, 32); + LONG newScroll = (wcscmp(swapModeBuf, L"scroll_to_swap") == 0) ? 1 : 0; EnterCriticalSection(&g_stateLock); - lstrcpynW(g_cachedDev1Id, tmpId1, 512); - lstrcpynW(g_cachedDev1Name, tmpName1, 256); - lstrcpynW(g_cachedDev2Id, tmpId2, 512); - lstrcpynW(g_cachedDev2Name, tmpName2, 256); + g_deviceSlotCount = newCount; LeaveCriticalSection(&g_stateLock); + + InterlockedExchange(&g_scrollToSwap, newScroll); + + WCHAR iconKey[16], pathKey[32]; + for (int i = 0; i < newCount; i++) { + swprintf_s(iconKey, L"icon%d", i + 1); + WCHAR iconVal[32] = {}; + Wh_GetStringValue(iconKey, iconVal, 32); + + if (wcscmp(iconVal, L"custom") == 0) { + swprintf_s(pathKey, L"icon%d_custom_path", i + 1); + WCHAR customPath[MAX_PATH] = {}; + Wh_GetStringValue(pathKey, customPath, MAX_PATH); + if (customPath[0]) { + g_iconDev[i] = (HICON)LoadImageW(NULL, customPath, IMAGE_ICON, 32, 32, LR_LOADFROMFILE); + } + if (!g_iconDev[i]) { + ExtractIconExW(g_ddoresDllPath, 4, nullptr, &g_iconDev[i], 1); + } + } else { + ExtractIconExW(g_ddoresDllPath, GetIconIndex(iconVal), nullptr, &g_iconDev[i], 1); + } + + wcscpy_s(g_lastIconSetting[i], 32, iconVal); + + if (g_iconDev[i]) { + ICONINFO ii = {}; + if (GetIconInfo(g_iconDev[i], &ii)) { + g_hIconDevBmp[i] = ii.hbmColor; + if (!g_hIconDevBmp[i]) { + g_hIconDevBmp[i] = ii.hbmMask; + } else if (ii.hbmMask) { + DeleteObject(ii.hbmMask); + } + } + } + } } -void SaveDeviceSelection(int slot, PCWSTR deviceId, PCWSTR friendlyName) { - if (slot == 1) { - Wh_SetStringValue(L"Device1Id", deviceId); - Wh_SetStringValue(L"Device1Name", friendlyName); - EnterCriticalSection(&g_stateLock); - lstrcpynW(g_cachedDev1Id, deviceId, 512); - lstrcpynW(g_cachedDev1Name, friendlyName, 256); - LeaveCriticalSection(&g_stateLock); + +// ─── Tray icon rect ──────────────────────────────────────────────────────────── + +static void RefreshTrayIconRect() { + HWND hwnd = g_trayHwnd; + if (!hwnd) return; + NOTIFYICONIDENTIFIER nii = {sizeof(nii)}; + nii.guidItem = MICSWITCH_TRAY_GUID; + RECT newRect = {}; + if (SUCCEEDED(Shell_NotifyIconGetRect(&nii, &newRect)) && + newRect.right > newRect.left) { + g_trayIconRect = newRect; + KillTimer(hwnd, TRAY_RECT_INIT_TIMER); // rect valid — stop retrying } else { - Wh_SetStringValue(L"Device2Id", deviceId); - Wh_SetStringValue(L"Device2Name", friendlyName); - EnterCriticalSection(&g_stateLock); - lstrcpynW(g_cachedDev2Id, deviceId, 512); - lstrcpynW(g_cachedDev2Name, friendlyName, 256); - LeaveCriticalSection(&g_stateLock); + // Explorer hasn't assigned screen coordinates to the icon yet. + // Shell_NotifyIconGetRect is a query — it won't force Explorer to + // finish its layout pass. Send NIM_MODIFY to give Explorer the + // necessary nudge to compute the icon's position. + NOTIFYICONDATAW nid = {sizeof(nid)}; + nid.hWnd = hwnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_SHOWTIP | NIF_GUID; + nid.guidItem = MICSWITCH_TRAY_GUID; + Shell_NotifyIconW(NIM_MODIFY, &nid); + SetTimer(hwnd, TRAY_RECT_INIT_TIMER, 200, nullptr); } } -// ─── Icon loading ───────────────────────────────────────────────────────────── +// ─── Mute helpers ───────────────────────────────────────────────────────────── +// Every function requires COM already initialized on the calling thread. + +static BOOL ApplyMute(PCWSTR deviceId, BOOL mute) { + IMMDeviceEnumerator* pEnum = nullptr; + if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) + return FALSE; -static HICON LoadSingleIcon(PCWSTR iconSetting, int slotIndex) { - if (iconSetting && wcscmp(iconSetting, L"custom") == 0) { - WCHAR pathKey[32]; - swprintf_s(pathKey, L"icon%d_custom_path", slotIndex); - WCHAR customPath[MAX_PATH] = {}; - Wh_GetStringValue(pathKey, customPath, MAX_PATH); - if (customPath[0]) { - HICON hIcon = (HICON)LoadImageW(NULL, customPath, IMAGE_ICON, 32, 32, LR_LOADFROMFILE); - if (hIcon) return hIcon; + IMMDevice* pDevice = nullptr; + HRESULT hr = (deviceId && deviceId[0]) + ? pEnum->GetDevice(deviceId, &pDevice) + : pEnum->GetDefaultAudioEndpoint(eCapture, eMultimedia, &pDevice); + pEnum->Release(); + if (FAILED(hr) || !pDevice) return FALSE; + + IAudioEndpointVolume* pVol = nullptr; + hr = pDevice->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, nullptr, (void**)&pVol); + pDevice->Release(); + if (FAILED(hr) || !pVol) return FALSE; + + pVol->SetMute(mute, nullptr); + pVol->Release(); + return TRUE; +} + + +// Call from tray thread (COM already initialized) or worker thread (has its own COM). +static void RestoreMute() { + EnterCriticalSection(&g_stateLock); + if (!g_isMutedByUs) { LeaveCriticalSection(&g_stateLock); return; } + WCHAR localId[512]; + lstrcpynW(localId, g_mutedDeviceId, 512); + g_isMutedByUs = false; + g_mutedDeviceId[0] = L'\0'; + LeaveCriticalSection(&g_stateLock); + ApplyMute(localId, FALSE); + Wh_SetStringValue(L"MutedDeviceId", L""); +} + +// For callers that lack a COM apartment (main thread callbacks). +static void RestoreMuteExternal() { + EnterCriticalSection(&g_stateLock); + bool wasMuted = g_isMutedByUs; + WCHAR localId[512] = {}; + if (wasMuted) { + lstrcpynW(localId, g_mutedDeviceId, 512); + g_isMutedByUs = false; + g_mutedDeviceId[0] = L'\0'; + } + LeaveCriticalSection(&g_stateLock); + if (wasMuted) { + CoInitialize(nullptr); + ApplyMute(localId, FALSE); + CoUninitialize(); + Wh_SetStringValue(L"MutedDeviceId", L""); + } +} + +// Called from TrayWndProc — tray thread has COM initialized by TrayThreadProc. +static void ToggleMuteCurrentDevice() { + EnterCriticalSection(&g_stateLock); + bool already = g_isMutedByUs; + WCHAR localId[512] = {}; + if (already) { + lstrcpynW(localId, g_mutedDeviceId, 512); + g_isMutedByUs = false; + g_mutedDeviceId[0] = L'\0'; + } + LeaveCriticalSection(&g_stateLock); + + if (already) { + ApplyMute(localId, FALSE); + Wh_SetStringValue(L"MutedDeviceId", L""); + return; + } + + // Mute the current default device + IMMDeviceEnumerator* pEnum = nullptr; + if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) + return; + + IMMDevice* pDefault = nullptr; + if (SUCCEEDED(pEnum->GetDefaultAudioEndpoint(eCapture, eMultimedia, &pDefault))) { + LPWSTR pId = nullptr; + if (SUCCEEDED(pDefault->GetId(&pId))) { + if (ApplyMute(pId, TRUE)) { + EnterCriticalSection(&g_stateLock); + lstrcpynW(g_mutedDeviceId, pId, 512); + g_isMutedByUs = true; + LeaveCriticalSection(&g_stateLock); + Wh_SetStringValue(L"MutedDeviceId", pId); + } + CoTaskMemFree(pId); } + pDefault->Release(); } - HICON hIcon = nullptr; - ExtractIconExW(g_ddoresDllPath, GetIconIndex(iconSetting), nullptr, &hIcon, 1); - return hIcon; + pEnum->Release(); } -void LoadUserIconsAndSettings() { - if (g_iconDev1) { DestroyIcon(g_iconDev1); g_iconDev1 = nullptr; } - if (g_iconDev2) { DestroyIcon(g_iconDev2); g_iconDev2 = nullptr; } - if (g_hIconDev1Bmp){ DeleteObject(g_hIconDev1Bmp); g_hIconDev1Bmp = nullptr; } - if (g_hIconDev2Bmp){ DeleteObject(g_hIconDev2Bmp); g_hIconDev2Bmp = nullptr; } - - PCWSTR s1 = Wh_GetStringSetting(L"icon1"); - PCWSTR s2 = Wh_GetStringSetting(L"icon2"); - g_iconDev1 = LoadSingleIcon(s1, 1); - g_iconDev2 = LoadSingleIcon(s2, 2); - - auto setupBmp = [](HICON hIcon, HBITMAP* pBmp) { - if (!hIcon) return; - ICONINFO ii = {}; - if (GetIconInfo(hIcon, &ii)) { - *pBmp = ii.hbmColor; - if (!*pBmp) { - *pBmp = ii.hbmMask; - } else if (ii.hbmMask) { - DeleteObject(ii.hbmMask); +// ─── Device-state check ─────────────────────────────────────────────────────── + +static bool IsDeviceActive(IMMDeviceEnumerator* pEnum, PCWSTR deviceId) { + if (!deviceId || !deviceId[0]) return false; + IMMDevice* pDevice = nullptr; + if (FAILED(pEnum->GetDevice(deviceId, &pDevice))) return false; + DWORD state = 0; + bool active = SUCCEEDED(pDevice->GetState(&state)) && (state == DEVICE_STATE_ACTIVE); + pDevice->Release(); + return active; +} + +// ─── IMMNotificationClient ──────────────────────────────────────────────────── + +class MicDeviceNotifier : public IMMNotificationClient { + volatile LONG m_ref; +public: + MicDeviceNotifier() : m_ref(1) {} + virtual ~MicDeviceNotifier() = default; + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override { + if (riid == __uuidof(IUnknown) || riid == __uuidof(IMMNotificationClient)) { + *ppv = static_cast(this); + AddRef(); return S_OK; + } + *ppv = nullptr; return E_NOINTERFACE; + } + ULONG STDMETHODCALLTYPE AddRef() override { + return static_cast(InterlockedIncrement(&m_ref)); + } + ULONG STDMETHODCALLTYPE Release() override { + LONG r = InterlockedDecrement(&m_ref); + if (r == 0) delete this; + return static_cast(r); + } + + // Callbacks are fired on an OS thread — only PostMessageW is safe here. + HRESULT STDMETHODCALLTYPE OnDefaultDeviceChanged( + EDataFlow flow, ERole role, LPCWSTR) override + { + if (flow == eCapture && role == eMultimedia) { + HWND hwnd = (HWND)InterlockedCompareExchangePointer( + (volatile PVOID*)&g_trayHwnd, nullptr, nullptr); + if (hwnd) { + // Rebind the IAudioEndpointVolumeCallback before refreshing the + // tooltip — the live dB readout depends on tracking the *current* + // default endpoint, not the previous one. + PostMessageW(hwnd, WM_REBIND_VOLUME_CALLBACK, 0, 0); + PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); } } - }; - setupBmp(g_iconDev1, &g_hIconDev1Bmp); - setupBmp(g_iconDev2, &g_hIconDev2Bmp); + return S_OK; + } + HRESULT STDMETHODCALLTYPE OnDeviceAdded(LPCWSTR pwstrDeviceId) override { + HWND hwnd = (HWND)InterlockedCompareExchangePointer( + (volatile PVOID*)&g_trayHwnd, nullptr, nullptr); + if (hwnd) { + PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + if (pwstrDeviceId && pwstrDeviceId[0]) { + size_t idLen = wcslen(pwstrDeviceId) + 1; + WCHAR* idCopy = new WCHAR[idLen]; + wcscpy_s(idCopy, idLen, pwstrDeviceId); + if (!PostMessageW(hwnd, WM_PRIORITY_DEVICE_ACTIVE, 0, (LPARAM)idCopy)) + delete[] idCopy; + } + } + return S_OK; + } + HRESULT STDMETHODCALLTYPE OnDeviceRemoved(LPCWSTR) override { + HWND hwnd = (HWND)InterlockedCompareExchangePointer( + (volatile PVOID*)&g_trayHwnd, nullptr, nullptr); + if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + return S_OK; + } + HRESULT STDMETHODCALLTYPE OnDeviceStateChanged(LPCWSTR pwstrDeviceId, DWORD newState) override { + HWND hwnd = (HWND)InterlockedCompareExchangePointer( + (volatile PVOID*)&g_trayHwnd, nullptr, nullptr); + if (hwnd) { + PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + if (newState == DEVICE_STATE_ACTIVE && pwstrDeviceId && pwstrDeviceId[0]) { + size_t idLen = wcslen(pwstrDeviceId) + 1; + WCHAR* idCopy = new WCHAR[idLen]; + wcscpy_s(idCopy, idLen, pwstrDeviceId); + if (!PostMessageW(hwnd, WM_PRIORITY_DEVICE_ACTIVE, 0, (LPARAM)idCopy)) + delete[] idCopy; + } + } + return S_OK; + } + HRESULT STDMETHODCALLTYPE OnPropertyValueChanged( + LPCWSTR, const PROPERTYKEY) override { return S_OK; } +}; - if (s1) wcscpy_s(g_lastIconSetting[0], 32, s1); else g_lastIconSetting[0][0] = L'\0'; - if (s2) wcscpy_s(g_lastIconSetting[1], 32, s2); else g_lastIconSetting[1][0] = L'\0'; +// IAudioEndpointVolumeCallback — fires on gain / mute changes from ANY source +// (volume keys, slider popup, other apps). Posts WM_UPDATE_TRAY_STATE to refresh +// the tooltip's live dB readout. Self-triggered changes carry kVolumeChangeCtx — +// we still refresh on those so the tooltip reflects the new value. +class VolNotifier : public IAudioEndpointVolumeCallback { + volatile LONG m_ref; +public: + VolNotifier() : m_ref(1) {} + virtual ~VolNotifier() = default; + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override { + if (riid == __uuidof(IUnknown) || riid == __uuidof(IAudioEndpointVolumeCallback)) { + *ppv = static_cast(this); + AddRef(); return S_OK; + } + *ppv = nullptr; return E_NOINTERFACE; + } + ULONG STDMETHODCALLTYPE AddRef() override { + return static_cast(InterlockedIncrement(&m_ref)); + } + ULONG STDMETHODCALLTYPE Release() override { + LONG r = InterlockedDecrement(&m_ref); + if (r == 0) delete this; + return static_cast(r); + } - if (s1) Wh_FreeStringSetting(s1); - if (s2) Wh_FreeStringSetting(s2); + HRESULT STDMETHODCALLTYPE OnNotify(PAUDIO_VOLUME_NOTIFICATION_DATA) override { + HWND hwnd = (HWND)InterlockedCompareExchangePointer( + (volatile PVOID*)&g_trayHwnd, nullptr, nullptr); + if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + return S_OK; + } +}; + +// Acquire IAudioEndpointVolume on the current default capture endpoint and register +// VolNotifier. Idempotent — caller is responsible for first releasing the previous +// binding via UnbindEndpointVolume(). Tray-thread only. +static void BindEndpointVolume() { + if (!g_notifEnum) return; + IMMDevice* pDev = nullptr; + if (FAILED(g_notifEnum->GetDefaultAudioEndpoint(eCapture, eMultimedia, &pDev)) || !pDev) + return; + IAudioEndpointVolume* pVol = nullptr; + if (SUCCEEDED(pDev->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, + nullptr, (void**)&pVol)) && pVol) { + g_pEndpointVol = pVol; + if (!g_pVolNotifier) g_pVolNotifier = new VolNotifier(); + g_pEndpointVol->RegisterControlChangeNotify(g_pVolNotifier); + } + pDev->Release(); +} + +static void UnbindEndpointVolume() { + if (g_pEndpointVol && g_pVolNotifier) { + g_pEndpointVol->UnregisterControlChangeNotify(g_pVolNotifier); + } + if (g_pVolNotifier) { g_pVolNotifier->Release(); g_pVolNotifier = nullptr; } + if (g_pEndpointVol) { g_pEndpointVol->Release(); g_pEndpointVol = nullptr; } +} + +// Scroll-to-swap uses Raw Input (RegisterRawInputDevices + WM_INPUT in TrayWndProc) +// instead of WH_MOUSE_LL. Raw Input is delivered outside the hook chain, so no +// other WH_MOUSE_LL hook (e.g., Monitor Sleep Button) can block it. + +// ─── Muted icon overlay ─────────────────────────────────────────────────────── + +static HICON CreateMutedOverlayIcon(HICON hBase) { + if (!hBase) return nullptr; + + const int SZ = 32; + + HDC hScreen = GetDC(nullptr); + if (!hScreen) return nullptr; + + // 32bpp DIBSECTION so we can fix the alpha channel post-GDI. + // GDI's Ellipse sets RGB but leaves alpha = 0; CreateIconIndirect + // uses the alpha channel for 32bpp color bitmaps, making the dot + // invisible without this fixup. + BITMAPINFO bmi = {}; + bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bmi.bmiHeader.biWidth = SZ; + bmi.bmiHeader.biHeight = -SZ; + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB; + + void* pBits = nullptr; + HBITMAP hColor = CreateDIBSection(hScreen, &bmi, DIB_RGB_COLORS, &pBits, nullptr, 0); + HDC hColorDC = CreateCompatibleDC(hScreen); + HBITMAP hOldColor = (HBITMAP)SelectObject(hColorDC, hColor); + memset(pBits, 0, SZ * SZ * 4); + + HDC hMaskDC = CreateCompatibleDC(hScreen); + HBITMAP hMask = CreateBitmap(SZ, SZ, 1, 1, nullptr); + HBITMAP hOldMask = (HBITMAP)SelectObject(hMaskDC, hMask); + + PatBlt(hMaskDC, 0, 0, SZ, SZ, WHITENESS); + DrawIconEx(hColorDC, 0, 0, hBase, SZ, SZ, 0, nullptr, DI_NORMAL); + DrawIconEx(hMaskDC, 0, 0, hBase, SZ, SZ, 0, nullptr, DI_MASK); + + const int D = 20; + const int MG = 1; + const int X = SZ - D - MG; + const int Y = SZ - D - MG; + + HPEN hNoPen = (HPEN)GetStockObject(NULL_PEN); + SelectObject(hColorDC, hNoPen); + SelectObject(hMaskDC, hNoPen); + + HBRUSH hWhite = CreateSolidBrush(RGB(255, 255, 255)); + SelectObject(hColorDC, hWhite); + Ellipse(hColorDC, X - 1, Y - 1, X + D + 2, Y + D + 2); + SelectObject(hMaskDC, GetStockObject(WHITE_BRUSH)); + Ellipse(hMaskDC, X - 1, Y - 1, X + D + 2, Y + D + 2); + DeleteObject(hWhite); + + HBRUSH hRed = CreateSolidBrush(RGB(220, 30, 30)); + SelectObject(hColorDC, hRed); + Ellipse(hColorDC, X, Y, X + D, Y + D); + DeleteObject(hRed); + + // GDI drew RGB pixels with alpha = 0 — stamp alpha = 255 over the + // overlay dot region so it is actually visible on 32bpp icons. + { + int y0 = Y - 2; if (y0 < 0) y0 = 0; + int y1 = Y + D + 3; if (y1 > SZ) y1 = SZ; + int x0 = X - 2; if (x0 < 0) x0 = 0; + int x1 = X + D + 3; if (x1 > SZ) x1 = SZ; + DWORD* pixels = (DWORD*)pBits; + for (int y = y0; y < y1; y++) + for (int x = x0; x < x1; x++) { + DWORD pixel = pixels[y * SZ + x]; + if (pixel & 0x00FFFFFF) + pixels[y * SZ + x] = pixel | 0xFF000000; + } + } + + SelectObject(hColorDC, hOldColor); + SelectObject(hMaskDC, hOldMask); + + ICONINFO ii = {}; + ii.fIcon = TRUE; + ii.hbmColor = hColor; + ii.hbmMask = hMask; + HICON hResult = CreateIconIndirect(&ii); + + DeleteObject(hColor); + DeleteObject(hMask); + DeleteDC(hColorDC); + DeleteDC(hMaskDC); + ReleaseDC(nullptr, hScreen); + return hResult; } // ─── Tray tip ───────────────────────────────────────────────────────────────── +static WCHAR s_lastDev[256] = {}; +static HICON s_lastIcon = nullptr; +static bool s_lastMuted = false; +static int s_lastVolume = -1; + void UpdateTrayTip(HWND hWnd, BOOL isAdd) { - // Snapshot shared state under lock before any COM calls. - WCHAR localId1[512] = {}; - WCHAR localId2[512] = {}; + // Snapshot shared state under lock (no COM inside the lock). + WCHAR localDevIds[MAX_DEVICE_SLOTS][512] = {}; + int localSlotCount; + bool localMuted; + EnterCriticalSection(&g_stateLock); - lstrcpynW(localId1, g_cachedDev1Id, 512); - lstrcpynW(localId2, g_cachedDev2Id, 512); + localSlotCount = g_deviceSlotCount; + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) + lstrcpynW(localDevIds[i], g_cachedDevId[i], 512); + localMuted = g_isMutedByUs; LeaveCriticalSection(&g_stateLock); WCHAR currentDev[256] = L"Unknown Device"; WCHAR currentId[512] = {}; - HICON currentIcon = g_iconDev1; + int currentVolume = -1; // -1 sentinel = query failed; omit from tooltip + + // Fast path: read scalar + dB from the cached endpoint pointer without re-doing + // CoCreateInstance + Activate. Falls back to the slow per-call path if the + // tray thread hasn't bound an endpoint yet (e.g., startup race). + if (g_pEndpointVol) { + float scalar = 0.0f; + if (SUCCEEDED(g_pEndpointVol->GetMasterVolumeLevelScalar(&scalar))) { + currentVolume = (int)(scalar * 100.0f + 0.5f); + if (currentVolume < 0) currentVolume = 0; + if (currentVolume > 100) currentVolume = 100; + } + } + // Slow path: still needed for the device name + ID. Always run this block. IMMDeviceEnumerator* pEnum = nullptr; if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { @@ -289,95 +1640,217 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { PropVariantClear(&v); pStore->Release(); } + // Fallback: if the cached endpoint wasn't bound yet, query volume inline. + if (currentVolume < 0) { + IAudioEndpointVolume* pVol = nullptr; + if (SUCCEEDED(pDev->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, + nullptr, (void**)&pVol))) { + float scalar = 0.0f; + if (SUCCEEDED(pVol->GetMasterVolumeLevelScalar(&scalar))) { + currentVolume = (int)(scalar * 100.0f + 0.5f); + if (currentVolume < 0) currentVolume = 0; + if (currentVolume > 100) currentVolume = 100; + } + pVol->Release(); + } + } pDev->Release(); } pEnum->Release(); } - if (localId1[0] != L'\0' && wcscmp(currentId, localId1) == 0) - currentIcon = g_iconDev1; - else if (localId2[0] != L'\0' && wcscmp(currentId, localId2) == 0) - currentIcon = g_iconDev2; + HICON currentIcon = g_iconDev[0]; + for (int i = 0; i < localSlotCount; i++) { + if (localDevIds[i][0] != L'\0' && wcscmp(currentId, localDevIds[i]) == 0) { + currentIcon = g_iconDev[i]; + break; + } + } - static WCHAR s_lastDev[256] = {}; - static HICON s_lastIcon = nullptr; - if (!isAdd && wcscmp(currentDev, s_lastDev) == 0 && currentIcon == s_lastIcon) + // Count configured slots from local snapshot + int configuredCount = 0; + for (int i = 0; i < localSlotCount; i++) + if (localDevIds[i][0] != L'\0') configuredCount++; + + // s_lastDev / s_lastIcon / s_lastMuted / s_lastVolume are file-scope to allow external reset. + if (!isAdd && + wcscmp(currentDev, s_lastDev) == 0 && + currentIcon == s_lastIcon && + s_lastMuted == localMuted && + s_lastVolume == currentVolume) + { + RefreshTrayIconRect(); return; - lstrcpynW(s_lastDev, currentDev, ARRAYSIZE(s_lastDev)); - s_lastIcon = currentIcon; + } + lstrcpyW(s_lastDev, currentDev); + s_lastIcon = currentIcon; + s_lastMuted = localMuted; + s_lastVolume = currentVolume; NOTIFYICONDATAW nid = {sizeof(nid)}; nid.hWnd = hWnd; nid.uID = TRAY_ICON_ID; - nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_ICON | NIF_GUID; + // NIF_SHOWTIP is required under NOTIFYICON_VERSION_4 — without it the shell + // assumes the app draws its own tooltip and the standard one never appears on hover. + nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_SHOWTIP | NIF_ICON | NIF_GUID; nid.guidItem = MICSWITCH_TRAY_GUID; nid.uCallbackMessage = WM_TRAY_CALLBACK; - if (localId1[0] == L'\0' || localId2[0] == L'\0') - swprintf_s(nid.szTip, L"MicSwitch: Right-click to configure"); + if (configuredCount < 2) + swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"MicSwitch: Right-click to configure"); + else if (localMuted && currentVolume >= 0) + swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Mic: %.82s (%d%%, Muted)", + currentDev, currentVolume); + else if (localMuted) + swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Mic: %.102s (Muted)", currentDev); + else if (currentVolume >= 0) + swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Mic: %.92s (%d%%)", + currentDev, currentVolume); else - swprintf_s(nid.szTip, L"Mic: %s", currentDev); + swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Mic: %.112s", currentDev); - nid.hIcon = currentIcon; + HICON hOverlay = localMuted ? CreateMutedOverlayIcon(currentIcon) : nullptr; + nid.hIcon = hOverlay ? hOverlay : currentIcon; Shell_NotifyIconW(isAdd ? NIM_ADD : NIM_MODIFY, &nid); + if (hOverlay) { DestroyIcon(hOverlay); } if (isAdd) { NOTIFYICONDATAW nidVer = {sizeof(nidVer)}; - nidVer.hWnd = hWnd; - nidVer.uID = TRAY_ICON_ID; - nidVer.uFlags = NIF_GUID; - nidVer.guidItem = MICSWITCH_TRAY_GUID; - nidVer.uVersion = NOTIFYICON_VERSION_4; + nidVer.hWnd = hWnd; + nidVer.uID = TRAY_ICON_ID; + nidVer.uFlags = NIF_GUID; + nidVer.guidItem = MICSWITCH_TRAY_GUID; + nidVer.uVersion = NOTIFYICON_VERSION_4; Shell_NotifyIconW(NIM_SETVERSION, &nidVer); } + + RefreshTrayIconRect(); +} + +// ─── Volume helpers ─────────────────────────────────────────────────────────── + +static int GetCurrentVolumePct() { + int result = -1; + HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + if (FAILED(hr) && hr != S_FALSE) return result; + IMMDeviceEnumerator* pEnum = nullptr; + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { + IMMDevice* pDev = nullptr; + if (SUCCEEDED(pEnum->GetDefaultAudioEndpoint(eCapture, eMultimedia, &pDev))) { + IAudioEndpointVolume* pVol = nullptr; + if (SUCCEEDED(pDev->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, + nullptr, (void**)&pVol))) { + float scalar = 0.0f; + if (SUCCEEDED(pVol->GetMasterVolumeLevelScalar(&scalar))) { + result = (int)(scalar * 100.0f + 0.5f); + if (result < 0) result = 0; + if (result > 100) result = 100; + } + pVol->Release(); + } + pDev->Release(); + } + pEnum->Release(); + } + CoUninitialize(); + return result; +} + +static void SetCurrentDeviceVolume(float scalar) { + scalar = std::max(0.0f, std::min(1.0f, scalar)); + HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + if (FAILED(hr) && hr != S_FALSE) return; + IMMDeviceEnumerator* pEnum = nullptr; + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { + IMMDevice* pDev = nullptr; + if (SUCCEEDED(pEnum->GetDefaultAudioEndpoint(eCapture, eMultimedia, &pDev))) { + IAudioEndpointVolume* pVol = nullptr; + if (SUCCEEDED(pDev->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, + nullptr, (void**)&pVol))) { + pVol->SetMasterVolumeLevelScalar(scalar, nullptr); + pVol->Release(); + } + pDev->Release(); + } + pEnum->Release(); + } + CoUninitialize(); } -// ─── Audio toggle ───────────────────────────────────────────────────────────── +// ─── Audio cycling ──────────────────────────────────────────────────────────── + +BOOL CycleAudioDevice(int direction) { + // Snapshot shared device data under lock before any COM calls. + WCHAR localIds[MAX_DEVICE_SLOTS][512] = {}; + int localCount; -BOOL ToggleAudioDevice() { - // Snapshot shared device IDs under lock before any COM calls. - WCHAR localId1[512] = {}; - WCHAR localId2[512] = {}; EnterCriticalSection(&g_stateLock); - lstrcpynW(localId1, g_cachedDev1Id, 512); - lstrcpynW(localId2, g_cachedDev2Id, 512); + localCount = g_deviceSlotCount; + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) + lstrcpynW(localIds[i], g_cachedDevId[i], 512); LeaveCriticalSection(&g_stateLock); - if (localId1[0] == L'\0' || localId2[0] == L'\0') return FALSE; + int configuredCount = 0; + for (int i = 0; i < localCount; i++) + if (localIds[i][0] != L'\0') configuredCount++; + if (configuredCount < 2) return FALSE; HRESULT comHr = CoInitialize(nullptr); bool comOk = SUCCEEDED(comHr) || comHr == S_FALSE; if (!comOk) return FALSE; + // Undo click-mute before switching (COM is now available). + RestoreMute(); + HWND hwnd = g_trayHwnd; + if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + IMMDeviceEnumerator* pEnum = nullptr; if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { CoUninitialize(); return FALSE; } - IMMDevice* pDef = nullptr; - if (FAILED(pEnum->GetDefaultAudioEndpoint(eCapture, eMultimedia, &pDef))) { - pEnum->Release(); CoUninitialize(); return FALSE; + WCHAR currentId[512] = {}; + IMMDevice* pDefault = nullptr; + if (SUCCEEDED(pEnum->GetDefaultAudioEndpoint(eCapture, eMultimedia, &pDefault))) { + LPWSTR pId = nullptr; + if (SUCCEEDED(pDefault->GetId(&pId))) { + lstrcpynW(currentId, pId, 512); + CoTaskMemFree(pId); + } + pDefault->Release(); } - LPWSTR currentId = nullptr; - HRESULT hr = pDef->GetId(¤tId); - pDef->Release(); - if (FAILED(hr) || !currentId) { - pEnum->Release(); CoUninitialize(); return FALSE; + int currentSlot = -1; + for (int i = 0; i < localCount; i++) { + if (localIds[i][0] != L'\0' && wcscmp(currentId, localIds[i]) == 0) { + currentSlot = i; break; + } + } + + int startSlot = (currentSlot >= 0) ? currentSlot : (localCount - 1); + int validSlot = -1; + + for (int tries = 1; tries <= localCount; tries++) { + int candidate = ((startSlot + tries * direction) % localCount + localCount) % localCount; + if (IsDeviceActive(pEnum, localIds[candidate])) { + validSlot = candidate; break; + } } - // Toggle: if current is slot 1 go to slot 2, otherwise go to slot 1. - PCWSTR targetId = (wcscmp(currentId, localId1) == 0) ? localId2 : localId1; - CoTaskMemFree(currentId); + if (validSlot == -1) { + pEnum->Release(); CoUninitialize(); return FALSE; + } - IPolicyConfig* pPol = nullptr; + IPolicyConfig* pPolicyConfig = nullptr; if (SUCCEEDED(CoCreateInstance(CLSID_CPolicyConfigClient, nullptr, CLSCTX_ALL, - IID_IPolicyConfig_Win10_11, (void**)&pPol))) { - pPol->SetDefaultEndpoint(targetId, eConsole); - pPol->SetDefaultEndpoint(targetId, eMultimedia); - pPol->SetDefaultEndpoint(targetId, eCommunications); - pPol->Release(); + IID_IPolicyConfig_Win10_11, (void**)&pPolicyConfig))) { + pPolicyConfig->SetDefaultEndpoint(localIds[validSlot], eConsole); + pPolicyConfig->SetDefaultEndpoint(localIds[validSlot], eMultimedia); + pPolicyConfig->SetDefaultEndpoint(localIds[validSlot], eCommunications); + pPolicyConfig->Release(); } pEnum->Release(); @@ -406,209 +1879,586 @@ static void ApplyContextMenuTheme(HWND hWnd, bool dark) { if (auto f = (Fn136)GetProcAddress(ux, MAKEINTRESOURCEA(136))) f(); } -struct AudioDevice { WCHAR id[512]; WCHAR name[256]; }; - void BuildAndShowContextMenu(HWND hWnd) { - // Snapshot shared state under lock. - WCHAR localId1[512] = {}; - WCHAR localId2[512] = {}; - EnterCriticalSection(&g_stateLock); - lstrcpynW(localId1, g_cachedDev1Id, 512); - lstrcpynW(localId2, g_cachedDev2Id, 512); - LeaveCriticalSection(&g_stateLock); - - AudioDevice devices[MENU_MAX_DEVICES]; - int deviceCount = 0; - - IMMDeviceEnumerator* pEnum = nullptr; - if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, - __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { - IMMDeviceCollection* pColl = nullptr; - if (SUCCEEDED(pEnum->EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE, &pColl))) { - UINT count = 0; - pColl->GetCount(&count); - if (count > MENU_MAX_DEVICES) count = MENU_MAX_DEVICES; - for (UINT i = 0; i < count; i++) { - IMMDevice* pDevice = nullptr; - if (FAILED(pColl->Item(i, &pDevice))) continue; - LPWSTR pId = nullptr; - if (SUCCEEDED(pDevice->GetId(&pId))) { - lstrcpynW(devices[deviceCount].id, pId, 512); - CoTaskMemFree(pId); - IPropertyStore* pStore = nullptr; - if (SUCCEEDED(pDevice->OpenPropertyStore(STGM_READ, &pStore))) { - PROPVARIANT v; PropVariantInit(&v); - if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &v)) && v.pwszVal) { - lstrcpynW(devices[deviceCount].name, v.pwszVal, 256); - deviceCount++; - } - PropVariantClear(&v); - pStore->Release(); - } - } - pDevice->Release(); - } - pColl->Release(); - } - pEnum->Release(); - } - - // Build status text before constructing the menu so it can go at the top. - WCHAR statusText[300]; - if (localId1[0] == L'\0' || localId2[0] == L'\0') { - lstrcpyW(statusText, L"Right-click to configure inputs"); - } else { - WCHAR activeName[256] = L"Unknown"; - IMMDeviceEnumerator* pEnum2 = nullptr; - if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, - __uuidof(IMMDeviceEnumerator), (void**)&pEnum2))) { - IMMDevice* pDef = nullptr; - if (SUCCEEDED(pEnum2->GetDefaultAudioEndpoint(eCapture, eMultimedia, &pDef))) { - IPropertyStore* pStore = nullptr; - if (SUCCEEDED(pDef->OpenPropertyStore(STGM_READ, &pStore))) { - PROPVARIANT v; PropVariantInit(&v); - if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &v)) && v.pwszVal) - lstrcpynW(activeName, v.pwszVal, 256); - PropVariantClear(&v); - pStore->Release(); - } - pDef->Release(); - } - pEnum2->Release(); - } - swprintf_s(statusText, L"Active: %s", activeName); - } - - HMENU hSub1 = CreatePopupMenu(); - HMENU hSub2 = CreatePopupMenu(); - for (int i = 0; i < deviceCount; i++) { - UINT f1 = MF_STRING | (wcscmp(devices[i].id, localId1) == 0 ? MF_CHECKED : 0); - UINT f2 = MF_STRING | (wcscmp(devices[i].id, localId2) == 0 ? MF_CHECKED : 0); - AppendMenuW(hSub1, f1, MENU_DEVICE1_BASE + i, devices[i].name); - AppendMenuW(hSub2, f2, MENU_DEVICE2_BASE + i, devices[i].name); - } - HMENU hMenu = CreatePopupMenu(); - AppendMenuW(hMenu, MF_STRING | MF_GRAYED, 0, statusText); - AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); - MENUITEMINFOW mii1 = {sizeof(mii1)}; - mii1.fMask = MIIM_SUBMENU | MIIM_STRING | MIIM_BITMAP; - mii1.hSubMenu = hSub1; - mii1.dwTypeData = (LPWSTR)L"Set as Input 1"; - mii1.hbmpItem = g_hIconDev1Bmp; - InsertMenuItemW(hMenu, (UINT)-1, TRUE, &mii1); - - MENUITEMINFOW mii2 = {sizeof(mii2)}; - mii2.fMask = MIIM_SUBMENU | MIIM_STRING | MIIM_BITMAP; - mii2.hSubMenu = hSub2; - mii2.dwTypeData = (LPWSTR)L"Set as Input 2"; - mii2.hbmpItem = g_hIconDev2Bmp; - InsertMenuItemW(hMenu, (UINT)-1, TRUE, &mii2); - - AppendMenuW(hMenu, MF_STRING, MENU_CUSTOM_ICON_BASE + 1, L"Custom Icon for Device 1..."); - AppendMenuW(hMenu, MF_STRING, MENU_CUSTOM_ICON_BASE + 2, L"Custom Icon for Device 2..."); + AppendMenuW(hMenu, MF_STRING, MENU_OPEN_SETTINGS, L"Mod Settings..."); + AppendMenuW(hMenu, MF_STRING, MENU_SOUND_SETTINGS, L"Input Settings..."); AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); MENUITEMINFOW miiWH = {sizeof(miiWH)}; miiWH.fMask = MIIM_ID | MIIM_STRING | MIIM_BITMAP; miiWH.wID = MENU_OPEN_WINDHAWK; - miiWH.dwTypeData = (LPWSTR)L"Open Windhawk"; + miiWH.dwTypeData = (LPWSTR)L"Open WindHawk"; miiWH.hbmpItem = g_hWindHawkBmp; InsertMenuItemW(hMenu, (UINT)-1, TRUE, &miiWH); - POINT pt; - GetCursorPos(&pt); - SetForegroundWindow(hWnd); + POINT pt; GetCursorPos(&pt); bool dark = IsSystemDarkMode(); ApplyContextMenuTheme(hWnd, dark); - int cmd = TrackPopupMenu(hMenu, TPM_RETURNCMD | TPM_RIGHTBUTTON | TPM_BOTTOMALIGN | TPM_RIGHTALIGN, - pt.x, pt.y, 0, hWnd, nullptr); + SetForegroundWindow(hWnd); + int cmd = TrackPopupMenu(hMenu, + TPM_RETURNCMD | TPM_RIGHTBUTTON | TPM_BOTTOMALIGN | TPM_RIGHTALIGN, + pt.x, pt.y, 0, hWnd, nullptr); PostMessageW(hWnd, WM_NULL, 0, 0); DestroyMenu(hMenu); - if (cmd >= MENU_DEVICE1_BASE && cmd < MENU_DEVICE1_BASE + deviceCount) { - int idx = cmd - MENU_DEVICE1_BASE; - SaveDeviceSelection(1, devices[idx].id, devices[idx].name); - PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); - } else if (cmd >= MENU_DEVICE2_BASE && cmd < MENU_DEVICE2_BASE + deviceCount) { - int idx = cmd - MENU_DEVICE2_BASE; - SaveDeviceSelection(2, devices[idx].id, devices[idx].name); - PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + if (cmd == MENU_OPEN_SETTINGS) { + HANDLE h = MicSwitchGui::LaunchDashboard(hWnd); + if (h) { + if (g_guiThread) CloseHandle(g_guiThread); + g_guiThread = h; + } + } else if (cmd == MENU_SOUND_SETTINGS) { + ShellExecuteW(nullptr, L"open", L"control.exe", L"mmsys.cpl,,1", nullptr, SW_SHOWNORMAL); } else if (cmd == MENU_OPEN_WINDHAWK) { SHELLEXECUTEINFOW sei = {sizeof(sei)}; sei.lpFile = g_windhawkPath; sei.nShow = SW_SHOWNORMAL; ShellExecuteExW(&sei); - } else if (cmd >= MENU_CUSTOM_ICON_BASE && cmd < MENU_CUSTOM_ICON_BASE + 2) { - int slot = cmd - MENU_CUSTOM_ICON_BASE + 1; - WCHAR path[MAX_PATH] = {}; - WCHAR title[64]; - swprintf_s(title, L"Select Icon for Device %d", slot); - OPENFILENAMEW ofn = {sizeof(ofn)}; - ofn.hwndOwner = hWnd; - ofn.lpstrFilter = L"Icon Files (*.ico)\0*.ico\0All Files (*.*)\0*.*\0"; - ofn.nFilterIndex = 1; - ofn.lpstrFile = path; - ofn.nMaxFile = MAX_PATH; - ofn.Flags = OFN_PATHMUSTEXIST | OFN_FILEMUSTEXIST | OFN_HIDEREADONLY | OFN_NOCHANGEDIR; - ofn.lpstrTitle = title; - if (GetOpenFileNameW(&ofn)) { - WCHAR pathKey[32]; - swprintf_s(pathKey, L"icon%d_custom_path", slot); - Wh_SetStringValue(pathKey, path); - LoadUserIconsAndSettings(); - PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + } +} + +// ─── Volume slider popup ────────────────────────────────────────────────────── + +namespace VolumePopup { + +static constexpr WCHAR kClass[] = L"MicSwitchVolumePopup"; +static constexpr WCHAR kLabel[] = L"Mic Level"; +static constexpr int kW = 240; +static constexpr int kH = 72; +static constexpr int kPadX = 16; // left/right padding +static constexpr int kTitleY = 12; // title row top Y +static constexpr int kTitleH = 18; // title row height +static constexpr int kTrackY = 52; // track center Y +static constexpr int kTrackH = 6; // track height in px +static constexpr int kThumbR = 9; // thumb radius (normal) +static constexpr int kThumbRHov = 10; // thumb radius (hover / drag) +static constexpr UINT kVolTimerId = 102; // throttled audio commit +static constexpr COLORREF kBg = RGB(28, 28, 28 ); +static constexpr COLORREF kBorder = RGB(50, 50, 50 ); +static constexpr COLORREF kTrackBg = RGB(58, 58, 58 ); +static constexpr COLORREF kTrackFill = RGB(0, 120, 215); +static constexpr COLORREF kThumbCol = RGB(255, 255, 255); +static constexpr COLORREF kTextPri = RGB(235, 235, 235); +static constexpr COLORREF kTextSec = RGB(130, 130, 130); + +static HWND g_hwnd = nullptr; +static bool g_volumeDirty = false; +static HWND g_trayWnd = nullptr; +static int g_value = 0; +static bool g_dragging = false; +static bool g_hover = false; +static HFONT g_font = nullptr; + +static int ValueToThumbX() { + return kPadX + (g_value * (kW - 2 * kPadX)) / 100; +} + +static int XToValue(int x) { + int v = ((x - kPadX) * 100 + (kW - 2 * kPadX) / 2) / (kW - 2 * kPadX); + return v < 0 ? 0 : (v > 100 ? 100 : v); +} + +static bool IsOverThumb(int mx, int my) { + int dx = mx - ValueToThumbX(), dy = my - kTrackY; + int r = kThumbRHov + 4; + return dx * dx + dy * dy <= r * r; +} + +static void ApplyVolume() { + float scalar = g_value / 100.0f; + if (g_pEndpointVol) { + g_pEndpointVol->SetMasterVolumeLevelScalar(scalar, &kVolumeChangeCtx); + } else { + SetCurrentDeviceVolume(scalar); + } +} + +static LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { + switch (msg) { + + case WM_CREATE: { + int vol = (int)(INT_PTR)((CREATESTRUCTW*)lParam)->lpCreateParams; + g_value = vol < 0 ? 0 : (vol > 100 ? 100 : vol); + g_dragging = false; + g_hover = false; + g_font = CreateFontW(-14, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, + DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, + CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_DONTCARE, L"Segoe UI"); + // Win11 rounded corners (DWMWA_WINDOW_CORNER_PREFERENCE=33, DWMWCP_ROUNDSMALL=3). + // GetModuleHandleW — dwmapi is already loaded; LoadLibraryW would leak a refcount. + { + using PFN = HRESULT(WINAPI*)(HWND, DWORD, LPCVOID, DWORD); + HMODULE hDwm = GetModuleHandleW(L"dwmapi.dll"); + if (hDwm) { + if (auto pfn = (PFN)GetProcAddress(hDwm, "DwmSetWindowAttribute")) { + UINT pref = 3; + pfn(hWnd, 33, &pref, sizeof(pref)); + } + } + } + SetTimer(hWnd, kVolTimerId, 16, nullptr); + return 0; + } + + case WM_ERASEBKGND: + return 1; // suppress default erase — WM_PAINT covers the full client rect + + case WM_PAINT: { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hWnd, &ps); + + // Double-buffer to eliminate flicker + HDC hMem = CreateCompatibleDC(hdc); + HBITMAP hBmp = CreateCompatibleBitmap(hdc, kW, kH); + auto hOldBmp = (HBITMAP)SelectObject(hMem, hBmp); + + // ── Background ──────────────────────────────────────────────── + RECT rcAll = {0, 0, kW, kH}; + HBRUSH hbBg = CreateSolidBrush(kBg); + FillRect(hMem, &rcAll, hbBg); + DeleteObject(hbBg); + + // Border (1 px) + HBRUSH hbBdr = CreateSolidBrush(kBorder); + FrameRect(hMem, &rcAll, hbBdr); + DeleteObject(hbBdr); + + // ── Title row ───────────────────────────────────────────────── + HFONT hOldFont = (HFONT)SelectObject(hMem, g_font ? g_font + : GetStockObject(DEFAULT_GUI_FONT)); + SetBkMode(hMem, TRANSPARENT); + + RECT rcL = {kPadX, kTitleY, kW - kPadX, kTitleY + kTitleH}; + SetTextColor(hMem, kTextSec); + DrawTextW(hMem, kLabel, -1, &rcL, + DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); + + WCHAR valBuf[8]; + swprintf_s(valBuf, ARRAYSIZE(valBuf), L"%d%%", g_value); + RECT rcR = {kPadX, kTitleY, kW - kPadX, kTitleY + kTitleH}; + SetTextColor(hMem, kTextPri); + DrawTextW(hMem, valBuf, -1, &rcR, + DT_RIGHT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); + + SelectObject(hMem, hOldFont); + + // ── Track ───────────────────────────────────────────────────── + int tLeft = kPadX; + int tRight = kW - kPadX; + int tTop = kTrackY - kTrackH / 2; + int tBot = kTrackY + kTrackH / 2; + int tX = ValueToThumbX(); + + SelectObject(hMem, GetStockObject(NULL_PEN)); + + // Unfilled portion + HBRUSH hbTrack = CreateSolidBrush(kTrackBg); + SelectObject(hMem, hbTrack); + RoundRect(hMem, tLeft, tTop, tRight, tBot, kTrackH, kTrackH); + DeleteObject(hbTrack); + + // Accent-filled (left of thumb) + if (tX > tLeft) { + HBRUSH hbFill = CreateSolidBrush(kTrackFill); + SelectObject(hMem, hbFill); + RoundRect(hMem, tLeft, tTop, tX, tBot, kTrackH, kTrackH); + DeleteObject(hbFill); + } + + // Thumb + int r = (g_dragging || g_hover) ? kThumbRHov : kThumbR; + HBRUSH hbThumb = CreateSolidBrush(kThumbCol); + SelectObject(hMem, hbThumb); + Ellipse(hMem, tX - r, kTrackY - r, tX + r, kTrackY + r); + DeleteObject(hbThumb); + + // ── Blit ────────────────────────────────────────────────────── + BitBlt(hdc, 0, 0, kW, kH, hMem, 0, 0, SRCCOPY); + SelectObject(hMem, hOldBmp); + DeleteObject(hBmp); + DeleteDC(hMem); + + EndPaint(hWnd, &ps); + return 0; + } + + case WM_SETCURSOR: { + POINT pt; + GetCursorPos(&pt); + ScreenToClient(hWnd, &pt); + bool overTrack = (pt.y >= kTrackY - kThumbRHov * 2 && + pt.y <= kTrackY + kThumbRHov * 2 && + pt.x >= kPadX && pt.x <= kW - kPadX); + SetCursor(LoadCursor(nullptr, overTrack ? IDC_HAND : IDC_ARROW)); + return TRUE; + } + + case WM_LBUTTONDOWN: { + int mx = (short)LOWORD(lParam), my = (short)HIWORD(lParam); + bool onTrack = (my >= kTrackY - kThumbRHov * 2 && + my <= kTrackY + kThumbRHov * 2 && + mx >= kPadX && mx <= kW - kPadX); + if (onTrack || IsOverThumb(mx, my)) { + g_value = XToValue(mx); + g_dragging = true; + g_hover = true; + SetCapture(hWnd); + ApplyVolume(); + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + } + + case WM_MOUSEMOVE: { + int mx = (short)LOWORD(lParam), my = (short)HIWORD(lParam); + // Re-register for WM_MOUSELEAVE each time (TME_LEAVE is one-shot) + TRACKMOUSEEVENT tme = {sizeof(tme), TME_LEAVE, hWnd, 0}; + TrackMouseEvent(&tme); + if (g_dragging) { + int newVal = XToValue(mx); + if (newVal != g_value) { + g_value = newVal; + g_volumeDirty = true; // throttled — kVolTimerId commits to audio + InvalidateRect(hWnd, nullptr, FALSE); + } + } else { + bool wasHover = g_hover; + g_hover = IsOverThumb(mx, my); + if (g_hover != wasHover) + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + } + + case WM_LBUTTONUP: + if (g_dragging) { + g_dragging = false; + ReleaseCapture(); + g_value = XToValue((short)LOWORD(lParam)); + g_volumeDirty = false; + ApplyVolume(); // force-commit final drag position + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + + case WM_MOUSELEAVE: + if (!g_dragging && g_hover) { + g_hover = false; + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + + case WM_MOUSEWHEEL: { + int step = GET_WHEEL_DELTA_WPARAM(wParam) > 0 ? 3 : -3; + int newVal = std::max(0, std::min(100, g_value + step)); + if (newVal != g_value) { + g_value = newVal; + ApplyVolume(); + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + } + + case WM_ACTIVATE: + if (LOWORD(wParam) == WA_INACTIVE) + DestroyWindow(hWnd); + return 0; + + case WM_TIMER: + if (wParam == kVolTimerId) { + if (g_volumeDirty) { g_volumeDirty = false; ApplyVolume(); } } + return 0; + + case WM_KEYDOWN: { + int v = g_value; + switch (wParam) { + case VK_LEFT: v = std::max(0, v - 1); break; + case VK_RIGHT: v = std::min(100, v + 1); break; + case VK_HOME: v = 0; break; + case VK_END: v = 100; break; + case VK_ESCAPE: + DestroyWindow(hWnd); + return 0; + default: return 0; + } + if (v != g_value) { + g_value = v; + ApplyVolume(); + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + } + + case WM_DESTROY: + KillTimer(hWnd, kVolTimerId); + if (g_volumeDirty) { g_volumeDirty = false; ApplyVolume(); } + if (g_font) { DeleteObject(g_font); g_font = nullptr; } + g_hwnd = nullptr; + g_trayWnd = nullptr; + g_dragging = false; + g_hover = false; + return 0; + + } // switch + return DefWindowProcW(hWnd, msg, wParam, lParam); +} + +static void RegisterClass() { + WNDCLASSEXW wc = {sizeof(wc)}; + wc.lpfnWndProc = WndProc; + wc.hInstance = g_hInstance; + wc.lpszClassName = kClass; + wc.hCursor = LoadCursor(nullptr, IDC_ARROW); + RegisterClassExW(&wc); +} + +static void UnregisterClass() { + ::UnregisterClassW(kClass, g_hInstance); +} + +static void Show(HWND hTrayWnd, int volPct) { + if (g_hwnd) { SetForegroundWindow(g_hwnd); return; } + + int x = (g_trayIconRect.left + g_trayIconRect.right) / 2 - kW / 2; + int y = g_trayIconRect.top - kH - 8; + + POINT pt = { (g_trayIconRect.left + g_trayIconRect.right) / 2, g_trayIconRect.top }; + HMONITOR hm = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST); + MONITORINFO mi = { sizeof(mi) }; + if (GetMonitorInfo(hm, &mi)) { + RECT& wa = mi.rcWork; + if (x < wa.left) x = wa.left; + if (x + kW > wa.right) x = wa.right - kW; + if (y < wa.top) y = g_trayIconRect.bottom + 8; + } + + g_trayWnd = hTrayWnd; + g_hwnd = CreateWindowExW( + WS_EX_TOOLWINDOW | WS_EX_TOPMOST, + kClass, nullptr, + WS_POPUP, + x, y, kW, kH, + hTrayWnd, nullptr, g_hInstance, (LPVOID)(INT_PTR)volPct); + + if (g_hwnd) { + ShowWindow(g_hwnd, SW_SHOWNOACTIVATE); + SetForegroundWindow(g_hwnd); } } +} // namespace VolumePopup + // ─── Worker thread ──────────────────────────────────────────────────────────── -DWORD WINAPI WorkerThreadProc(LPVOID) { - if (ToggleAudioDevice() && g_trayHwnd) - PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, 0, 0); +DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { + int direction = static_cast(reinterpret_cast(lpParam)); + if (CycleAudioDevice(direction)) { + HWND hwnd = g_trayHwnd; + if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + } InterlockedExchange(&g_isProcessingClick, 0); return 0; } +static void SpawnCycleThread(int direction) { + if (g_workerThread) { + CloseHandle(g_workerThread); + g_workerThread = nullptr; + } + g_workerThread = CreateThread(nullptr, 0, WorkerThreadProc, + reinterpret_cast(static_cast(direction)), + 0, nullptr); + // If CreateThread fails the worker never resets the lock — do it here. + if (!g_workerThread) InterlockedExchange(&g_isProcessingClick, 0); +} + +// ─── Priority device routing ────────────────────────────────────────────────── + +static void HandlePriorityDeviceConnected(HWND hWnd, const WCHAR* devId) { + // Snapshot all shared state under the lock before doing any work. + EnterCriticalSection(&g_stateLock); + int priorityCount = g_priorityCount; + WCHAR localPrio[MAX_DEVICE_SLOTS][512] = {}; + for (int i = 0; i < priorityCount; i++) + lstrcpynW(localPrio[i], g_priorityDevIds[i], 512); + int slotCount = g_deviceSlotCount; + WCHAR localIds[MAX_DEVICE_SLOTS][512] = {}; + for (int i = 0; i < slotCount; i++) + lstrcpynW(localIds[i], g_cachedDevId[i], 512); + LeaveCriticalSection(&g_stateLock); + + if (!devId || !devId[0] || priorityCount == 0) return; + + // Find rank of the newly connected device in the priority list. + int newRank = -1; + for (int i = 0; i < priorityCount; i++) { + if (localPrio[i][0] && wcscmp(localPrio[i], devId) == 0) { + newRank = i; break; + } + } + if (newRank < 0) return; // not in the priority list + + // Find the slot currently occupied by the lowest-priority (highest index) device. + int worstSlot = -1, worstRank = -1; + for (int s = 0; s < slotCount; s++) { + int rank = INT_MAX; + for (int p = 0; p < priorityCount; p++) { + if (localPrio[p][0] && wcscmp(localPrio[p], localIds[s]) == 0) { + rank = p; break; + } + } + if (rank > worstRank) { worstRank = rank; worstSlot = s; } + } + + if (worstSlot < 0 || worstRank <= newRank) return; // new device is not higher priority + + // Replace the worst slot with the new priority device. + WCHAR kId[32], kNm[32]; + swprintf_s(kId, L"Device%dId", worstSlot + 1); + swprintf_s(kNm, L"Device%dName", worstSlot + 1); + Wh_SetStringValue(kId, devId); + Wh_SetStringValue(kNm, L""); + + EnterCriticalSection(&g_stateLock); + lstrcpynW(g_cachedDevId[worstSlot], devId, 512); + g_cachedDevName[worstSlot][0] = L'\0'; + LeaveCriticalSection(&g_stateLock); + + PostMessageW(hWnd, WM_RELOAD_ALL, 0, 0); +} + // ─── Tray window ────────────────────────────────────────────────────────────── LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { - if (msg == WM_TRAY_CALLBACK && LOWORD(lParam) == WM_RBUTTONUP) { - BuildAndShowContextMenu(hWnd); - } else if (msg == WM_TRAY_CALLBACK && LOWORD(lParam) == WM_LBUTTONUP) { - if (InterlockedExchange(&g_isProcessingClick, 1) == 0) { - DWORD now = GetTickCount(); - if (now - g_lastClickTime > CLICK_DEBOUNCE_MS) { - g_lastClickTime = now; - if (g_workerThread) { CloseHandle(g_workerThread); g_workerThread = nullptr; } - g_workerThread = CreateThread(nullptr, 0, WorkerThreadProc, nullptr, 0, nullptr); - } else { + if (msg == WM_TRAY_CALLBACK) { + UINT event = LOWORD(lParam); // NOTIFYICON_VERSION_4: event in LOWORD(lParam) + + if (event == WM_MOUSEMOVE) { + POINT pt = { (short)LOWORD(wParam), (short)HIWORD(wParam) }; + if (!PtInRect(&g_trayIconRect, pt)) { + RefreshTrayIconRect(); + } + } else if (event == WM_RBUTTONUP || event == WM_CONTEXTMENU) { + // Right-click — opens settings menu. + // v4 sends WM_CONTEXTMENU; pre-v4 sends WM_RBUTTONUP. + BuildAndShowContextMenu(hWnd); + + } else if ((event == WM_LBUTTONUP || event == NIN_SELECT || + event == NIN_KEYSELECT) && + InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 0) { + // Click mode: debounced forward cycle. + // v4 sends NIN_SELECT (or NIN_KEYSELECT for keyboard); pre-v4 sends WM_LBUTTONUP. + if (InterlockedExchange(&g_isProcessingClick, 1) == 0) { + DWORD now = GetTickCount(); + if (now - g_lastClickTime > CLICK_DEBOUNCE_MS) { + g_lastClickTime = now; + SpawnCycleThread(1); + } else { + InterlockedExchange(&g_isProcessingClick, 0); + } + } + + } else if ((event == WM_LBUTTONUP || event == NIN_SELECT || + event == NIN_KEYSELECT) && + InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 1) { + // Scroll mode: left-click toggles mute. + // Same debounce as click mode: WM_LBUTTONUP and NIN_SELECT are BOTH + // delivered for the same physical click under NOTIFYICON_VERSION_4. + // Without debouncing, mute is toggled on then immediately off (net nothing). + if (InterlockedExchange(&g_isProcessingClick, 1) == 0) { + DWORD now = GetTickCount(); + if (now - g_lastClickTime > CLICK_DEBOUNCE_MS) { + g_lastClickTime = now; + ToggleMuteCurrentDevice(); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + } InterlockedExchange(&g_isProcessingClick, 0); } + } else if (event == WM_MBUTTONUP) { + VolumePopup::Show(hWnd, GetCurrentVolumePct()); + } + + } else if (msg == WM_INPUT) { + UINT sz = 0; + GetRawInputData((HRAWINPUT)lParam, RID_INPUT, nullptr, &sz, sizeof(RAWINPUTHEADER)); + if (sz > 0) { + std::vector buf(sz); + if (GetRawInputData((HRAWINPUT)lParam, RID_INPUT, buf.data(), &sz, + sizeof(RAWINPUTHEADER)) == sz) { + auto* raw = reinterpret_cast(buf.data()); + if (raw->header.dwType == RIM_TYPEMOUSE && + (raw->data.mouse.usButtonFlags & RI_MOUSE_WHEEL)) { + POINT pt; GetCursorPos(&pt); + if (PtInRect(&g_trayIconRect, pt)) { + short delta = (short)raw->data.mouse.usButtonData; + int dir = (delta > 0) ? 1 : -1; + if (InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 1) { + PostMessageW(hWnd, WM_TRAY_SCROLL, + (WPARAM)(LONG_PTR)dir, 0); + } else if (g_pEndpointVol) { + float scalar = 0.0f; + if (SUCCEEDED(g_pEndpointVol->GetMasterVolumeLevelScalar(&scalar))) { + scalar = std::max(0.0f, std::min(1.0f, scalar + dir * 0.02f)); + g_pEndpointVol->SetMasterVolumeLevelScalar(scalar, nullptr); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + } + } + } + } + } + } + return DefWindowProcW(hWnd, msg, wParam, lParam); + + } else if (msg == WM_TRAY_SCROLL) { + // Posted by Raw Input handler when the user scrolls over the icon + DWORD now = GetTickCount(); + if (now - g_lastScrollTime > SCROLL_DEBOUNCE_MS && + InterlockedExchange(&g_isProcessingClick, 1) == 0) { + g_lastScrollTime = now; + int direction = static_cast(static_cast(wParam)); + SpawnCycleThread(direction); } - } else if (msg == WM_UPDATE_TRAY_STATE || (msg == WM_TIMER && wParam == 1)) { + + } else if (msg == WM_TIMER && wParam == TRAY_RECT_INIT_TIMER) { + // Fired by RefreshTrayIconRect when Shell_NotifyIconGetRect returned empty. + // By now Explorer has had time to complete its paint cycle and assign real + // screen coordinates to the icon. RefreshTrayIconRect kills the timer on + // success, or re-arms it for another 200ms if Explorer is still not ready. + RefreshTrayIconRect(); + + } else if (msg == WM_UPDATE_TRAY_STATE) { UpdateTrayTip(hWnd, FALSE); + + } else if (msg == WM_UPDATE_HOOK_STATE) { + RAWINPUTDEVICE rid = { 1, 2, RIDEV_INPUTSINK, hWnd }; + RegisterRawInputDevices(&rid, 1, sizeof(rid)); + } else if (msg == WM_RELOAD_ICONS) { - WCHAR prev[2][32] = {}; - wcscpy_s(prev[0], 32, g_lastIconSetting[0]); - wcscpy_s(prev[1], 32, g_lastIconSetting[1]); + // Run icon reload on tray thread to eliminate cross-thread handle race. + WCHAR prev[MAX_DEVICE_SLOTS][32] = {}; + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) + wcscpy_s(prev[i], 32, g_lastIconSetting[i]); + LoadUserIconsAndSettings(); PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + DWORD pickerSlots = 0; - for (int slot = 1; slot <= 2; slot++) { + for (int i = 0; i < g_deviceSlotCount; i++) { WCHAR key[16]; - swprintf_s(key, L"icon%d", slot); - PCWSTR s = Wh_GetStringSetting(key); - BOOL isCustom = (s && wcscmp(s, L"custom") == 0); - BOOL wasCustom = (wcscmp(prev[slot - 1], L"custom") == 0); - if (s) Wh_FreeStringSetting(s); + swprintf_s(key, L"icon%d", i + 1); + WCHAR icoVal[32] = {}; + Wh_GetStringValue(key, icoVal, 32); + BOOL isCustom = (wcscmp(icoVal, L"custom") == 0); + BOOL wasCustom = (wcscmp(prev[i], L"custom") == 0); if (isCustom && !wasCustom) - pickerSlots |= (1u << (slot - 1)); + pickerSlots |= (1u << i); } if (pickerSlots) PostMessageW(hWnd, WM_SHOW_FILE_PICKER, 0, (LPARAM)pickerSlots); + } else if (msg == WM_SHOW_FILE_PICKER) { DWORD slots = (DWORD)lParam; for (int slot = 1; slots; slot++, slots >>= 1) { @@ -617,36 +2467,62 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) WCHAR title[64]; swprintf_s(title, L"Select Icon for Device %d", slot); OPENFILENAMEW ofn = {sizeof(ofn)}; - ofn.hwndOwner = hWnd; - ofn.lpstrFilter = L"Icon Files (*.ico)\0*.ico\0All Files (*.*)\0*.*\0"; + ofn.hwndOwner = hWnd; + ofn.lpstrFilter = L"Icon Files (*.ico)\0*.ico\0All Files (*.*)\0*.*\0"; ofn.nFilterIndex = 1; - ofn.lpstrFile = path; - ofn.nMaxFile = MAX_PATH; - ofn.Flags = OFN_PATHMUSTEXIST | OFN_FILEMUSTEXIST | OFN_HIDEREADONLY | OFN_NOCHANGEDIR; - ofn.lpstrTitle = title; + ofn.lpstrFile = path; + ofn.nMaxFile = MAX_PATH; + ofn.Flags = OFN_PATHMUSTEXIST | OFN_FILEMUSTEXIST | OFN_HIDEREADONLY | OFN_NOCHANGEDIR; + ofn.lpstrTitle = title; if (GetOpenFileNameW(&ofn)) { - WCHAR pathKey[32]; - swprintf_s(pathKey, L"icon%d_custom_path", slot); - Wh_SetStringValue(pathKey, path); + WCHAR customPathKey[32]; + swprintf_s(customPathKey, L"icon%d_custom_path", slot); + Wh_SetStringValue(customPathKey, path); LoadUserIconsAndSettings(); PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); } } + + } else if (msg == WM_PRIORITY_DEVICE_ACTIVE) { + WCHAR* devId = reinterpret_cast(lParam); + if (devId) { + HandlePriorityDeviceConnected(hWnd, devId); + delete[] devId; + } + } else if (msg == g_taskbarCreatedMsg && g_taskbarCreatedMsg != 0) { - UpdateTrayTip(hWnd, TRUE); + UpdateTrayTip(hWnd, TRUE); // re-add icon after explorer restart + RefreshTrayIconRect(); // re-acquire icon screen rect after explorer restart + + } else if (msg == WM_REBIND_VOLUME_CALLBACK) { + // Default device changed — re-bind to new endpoint. + UnbindEndpointVolume(); + BindEndpointVolume(); + + } else if (msg == WM_RELOAD_ALL) { + // Full reload triggered by the settings dashboard after Save and Apply. + // Picks up new device assignments, icon choices, device count, swap mode, and priority list. + LoadDeviceSelections(); + LoadPriorityList(); + LoadUserIconsAndSettings(); + s_lastIcon = nullptr; // icon handles were recreated; force UpdateTrayTip to refresh + UpdateTrayTip(hWnd, FALSE); + PostMessageW(hWnd, WM_UPDATE_HOOK_STATE, 0, 0); + } else if (msg == WM_CLOSE) { - // Orderly shutdown: kill timer, remove tray icon, then destroy window. - KillTimer(hWnd, 1); + // Orderly shutdown: remove tray icon, then destroy window. NOTIFYICONDATAW nid = {sizeof(nid)}; nid.hWnd = hWnd; nid.uID = TRAY_ICON_ID; nid.uFlags = NIF_GUID; nid.guidItem = MICSWITCH_TRAY_GUID; Shell_NotifyIconW(NIM_DELETE, &nid); + if (VolumePopup::g_hwnd) DestroyWindow(VolumePopup::g_hwnd); DestroyWindow(hWnd); return 0; + } else if (msg == WM_DESTROY) { - g_trayHwnd = nullptr; + g_trayHwnd = nullptr; // prevent IMMNotificationClient callbacks from posting PostQuitMessage(0); } return DefWindowProcW(hWnd, msg, wParam, lParam); @@ -663,39 +2539,58 @@ DWORD WINAPI TrayThreadProc(LPVOID) { wc.hInstance = g_hInstance; wc.lpszClassName = L"MicSwitchWindowClass"; RegisterClassW(&wc); + VolumePopup::RegisterClass(); - // WS_EX_TOOLWINDOW + WS_EX_NOACTIVATE: hidden utility window, never in Alt+Tab. + // WS_EX_TOOLWINDOW: hidden popup window that Shell_NotifyIconW can track properly. + // We used HWND_MESSAGE originally, but message-only windows can lose their + // tray icon after reboot because the shell cannot reliably associate the icon with + // the window across session boundaries. g_trayHwnd = CreateWindowExW( WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, wc.lpszClassName, L"MicSwitch", WS_POPUP, 0, 0, 1, 1, - nullptr, nullptr, g_hInstance, nullptr); - if (!g_trayHwnd) { CoUninitialize(); return 1; } - - // Unique AUMID so the OS doesn't group this icon with Windhawk's main window. - IPropertyStore* pps = nullptr; - if (SUCCEEDED(SHGetPropertyStoreForWindow(g_trayHwnd, IID_PPV_ARGS(&pps)))) { - PROPVARIANT var; - PropVariantInit(&var); - var.vt = VT_LPWSTR; - var.pwszVal = (LPWSTR)CoTaskMemAlloc(MAX_PATH * sizeof(WCHAR)); - if (var.pwszVal) { - lstrcpyW(var.pwszVal, L"BlackPaw.MicSwitch"); - pps->SetValue(PKEY_AppUserModel_ID, var); - CoTaskMemFree(var.pwszVal); - } - pps->Commit(); - pps->Release(); - } - - // 1500ms poll timer keeps tooltip in sync without IMMNotificationClient overhead. - SetTimer(g_trayHwnd, 1, 1500, nullptr); + nullptr, nullptr, g_hInstance, nullptr + ); + if (!g_trayHwnd) { + CoUninitialize(); + return 1; + } + + // Register for instant device-change notifications. + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&g_notifEnum))) { + g_deviceNotifier = new MicDeviceNotifier(); + g_notifEnum->RegisterEndpointNotificationCallback(g_deviceNotifier); + BindEndpointVolume(); + } + // Register Raw Input for scroll over the tray icon (bypasses WH_MOUSE_LL hook chain). + // Always active: scroll-to-swap mode cycles devices; click mode adjusts volume. + { + RAWINPUTDEVICE rid = { 1, 2, RIDEV_INPUTSINK, g_trayHwnd }; + RegisterRawInputDevices(&rid, 1, sizeof(rid)); + } + UpdateTrayTip(g_trayHwnd, TRUE); MSG msg; while (GetMessageW(&msg, nullptr, 0, 0) > 0) { DispatchMessageW(&msg); } + // Unbind volume callback BEFORE unregistering the device-enumerator notifications, + // so OnNotify cannot fire against a half-torn-down object. + UnbindEndpointVolume(); + // Unregister notifications before releasing the enumerator. + if (g_notifEnum && g_deviceNotifier) { + g_notifEnum->UnregisterEndpointNotificationCallback(g_deviceNotifier); + g_deviceNotifier->Release(); g_deviceNotifier = nullptr; + g_notifEnum->Release(); g_notifEnum = nullptr; + } + // Unregister Raw Input (harmless if not registered). + { + RAWINPUTDEVICE rid = { 1, 2, RIDEV_REMOVE, nullptr }; + RegisterRawInputDevices(&rid, 1, sizeof(rid)); + } + VolumePopup::UnregisterClass(); CoUninitialize(); return 0; } @@ -704,19 +2599,41 @@ DWORD WINAPI TrayThreadProc(LPVOID) { BOOL WhTool_ModInit() { Wh_Log(L"MicSwitch Mod Init"); - InitializeCriticalSection(&g_stateLock); + // Enable dark mode app-wide via uxtheme ordinal 135 (SetPreferredAppMode), with a + // fall-back to ordinal 132 (AllowDarkModeForApp) on Win10 builds pre-1903. Graceful + // no-op when neither ordinal exists. Affects context menus, dialogs, and STATIC + // controls in the dashboard. + { + HMODULE ux = GetModuleHandleW(L"uxtheme.dll"); + if (ux) { + using FnSetMode = void(WINAPI*)(int); + using FnAllow = bool(WINAPI*)(bool); + if (auto f = (FnSetMode)GetProcAddress(ux, MAKEINTRESOURCEA(135))) { + f(1); // PreferredAppMode::AllowDark + } else if (auto f = (FnAllow)GetProcAddress(ux, MAKEINTRESOURCEA(132))) { + f(true); + } + } + } + g_hInstance = GetModuleHandleW(nullptr); - GetModuleFileNameW(nullptr, g_windhawkPath, ARRAYSIZE(g_windhawkPath)); + switch (GetModuleFileNameW(nullptr, g_windhawkPath, ARRAYSIZE(g_windhawkPath))) { + case 0: + case ARRAYSIZE(g_windhawkPath): + Wh_Log(L"GetModuleFileName failed"); + return FALSE; + } - // Full System32 path — ExtractIconExW handles the .mun redirect on Win11. + // Build the full system32 path for ddores.dll. UINT sysLen = GetSystemDirectoryW(g_ddoresDllPath, MAX_PATH); if (sysLen > 0 && sysLen < MAX_PATH - 12) lstrcatW(g_ddoresDllPath, L"\\ddores.dll"); else - lstrcpyW(g_ddoresDllPath, L"ddores.dll"); + lstrcpyW(g_ddoresDllPath, L"ddores.dll"); // safe fallback + // Load the Windhawk menu icon. int whIconIndices[] = {98, 94, 95, 6}; for (int idx : whIconIndices) { ExtractIconExW(g_ddoresDllPath, idx, nullptr, &g_hWindHawkIcon, 1); @@ -734,40 +2651,83 @@ BOOL WhTool_ModInit() { } } - LoadUserIconsAndSettings(); + // If a previous run muted a device and was killed before unmuting, clean it up. + WCHAR savedMutedId[512] = {}; + Wh_GetStringValue(L"MutedDeviceId", savedMutedId, 512); + if (savedMutedId[0] != L'\0') { + CoInitialize(nullptr); + ApplyMute(savedMutedId, FALSE); + CoUninitialize(); + Wh_SetStringValue(L"MutedDeviceId", L""); + } + + LoadUserIconsAndSettings(); // sets g_deviceSlotCount first LoadDeviceSelections(); + LoadPriorityList(); g_trayThread = CreateThread(nullptr, 0, TrayThreadProc, nullptr, 0, nullptr); return TRUE; } void WhTool_ModSettingsChanged() { + RestoreMuteExternal(); LoadDeviceSelections(); + LoadPriorityList(); HWND hwnd = g_trayHwnd; - if (hwnd) PostMessageW(hwnd, WM_RELOAD_ICONS, 0, 0); + if (hwnd) { + PostMessageW(hwnd, WM_RELOAD_ICONS, 0, 0); + PostMessageW(hwnd, WM_UPDATE_HOOK_STATE, 0, 0); + } } void WhTool_ModUninit() { Wh_Log(L"MicSwitch Mod Uninit"); + RestoreMuteExternal(); - // Capture hwnd before posting — tray thread clears g_trayHwnd in WM_DESTROY. + // Send WM_CLOSE so TrayWndProc can delete the tray icon + // cleanly before DestroyWindow → PostQuitMessage exits the message loop. HWND hwnd = g_trayHwnd; if (hwnd) PostMessageW(hwnd, WM_CLOSE, 0, 0); - if (g_trayThread) { - WaitForSingleObject(g_trayThread, 3000); - CloseHandle(g_trayThread); + DWORD wr = WaitForSingleObject(g_trayThread, 3000); + if (wr == WAIT_TIMEOUT) { + Wh_Log(L"MicSwitch: tray thread did not exit within 3 s — leaking handle to avoid race"); + } else { + CloseHandle(g_trayThread); + } g_trayThread = nullptr; } + if (g_guiThread) { + // Ask the dashboard's message loop to quit, then wait for it. + // PostThreadMessageW with WM_QUIT causes GetMessageW to return 0, + // exiting the loop regardless of IsDialogMessageW. + DWORD guiId = GetThreadId(g_guiThread); + if (guiId) PostThreadMessageW(guiId, WM_QUIT, 0, 0); + DWORD wr = WaitForSingleObject(g_guiThread, 2000); + if (wr == WAIT_TIMEOUT) { + Wh_Log(L"MicSwitch: GUI thread did not exit within 2 s — leaking handle to avoid race"); + } else { + CloseHandle(g_guiThread); + } + g_guiThread = nullptr; + } if (g_workerThread) { - WaitForSingleObject(g_workerThread, 2000); - CloseHandle(g_workerThread); + DWORD wr = WaitForSingleObject(g_workerThread, 2000); + if (wr == WAIT_TIMEOUT) { + Wh_Log(L"MicSwitch: worker thread did not exit within 2 s — leaking handle to avoid race"); + } else { + CloseHandle(g_workerThread); + } g_workerThread = nullptr; } - - if (g_iconDev1) { DestroyIcon(g_iconDev1); g_iconDev1 = nullptr; } - if (g_iconDev2) { DestroyIcon(g_iconDev2); g_iconDev2 = nullptr; } - if (g_hIconDev1Bmp) { DeleteObject(g_hIconDev1Bmp); g_hIconDev1Bmp = nullptr; } - if (g_hIconDev2Bmp) { DeleteObject(g_hIconDev2Bmp); g_hIconDev2Bmp = nullptr; } + if (g_notifEnum && g_deviceNotifier) { + g_notifEnum->UnregisterEndpointNotificationCallback(g_deviceNotifier); + g_deviceNotifier->Release(); g_deviceNotifier = nullptr; + g_notifEnum->Release(); g_notifEnum = nullptr; + } + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + if (g_iconDev[i]) { DestroyIcon(g_iconDev[i]); g_iconDev[i] = nullptr; } + if (g_hIconDevBmp[i]){ DeleteObject(g_hIconDevBmp[i]); g_hIconDevBmp[i] = nullptr; } + } if (g_hWindHawkIcon){ DestroyIcon(g_hWindHawkIcon); g_hWindHawkIcon = nullptr; } if (g_hWindHawkBmp) { DeleteObject(g_hWindHawkBmp); g_hWindHawkBmp = nullptr; } @@ -775,8 +2735,18 @@ void WhTool_ModUninit() { } //////////////////////////////////////////////////////////////////////////////// -// Windhawk tool mod boilerplate — do not modify. +// Windhawk tool mod implementation for mods which don't need to inject to other +// processes or hook other functions. Context: // https://github.com/ramensoftware/windhawk/wiki/Mods-as-tools:-Running-mods-in-a-dedicated-process +// +// The mod will load and run in a dedicated windhawk.exe process. +// +// Paste the code below as part of the mod code, and use these callbacks: +// * WhTool_ModInit +// * WhTool_ModSettingsChanged +// * WhTool_ModUninit +// +// Currently, other callbacks are not supported. bool g_isToolModProcessLauncher; HANDLE g_toolModProcessMutex; @@ -824,7 +2794,9 @@ BOOL Wh_ModInit() { LocalFree(argv); - if (isExcluded) return FALSE; + if (isExcluded) { + return FALSE; + } if (isCurrentToolModProcess) { g_toolModProcessMutex = @@ -833,30 +2805,40 @@ BOOL Wh_ModInit() { Wh_Log(L"CreateMutex failed"); ExitProcess(1); } + if (GetLastError() == ERROR_ALREADY_EXISTS) { Wh_Log(L"Tool mod already running (%s)", WH_MOD_ID); ExitProcess(1); } - if (!WhTool_ModInit()) ExitProcess(1); + + if (!WhTool_ModInit()) { + ExitProcess(1); + } IMAGE_DOS_HEADER* dosHeader = (IMAGE_DOS_HEADER*)GetModuleHandleW(nullptr); IMAGE_NT_HEADERS* ntHeaders = (IMAGE_NT_HEADERS*)((BYTE*)dosHeader + dosHeader->e_lfanew); + DWORD entryPointRVA = ntHeaders->OptionalHeader.AddressOfEntryPoint; void* entryPoint = (BYTE*)dosHeader + entryPointRVA; + Wh_SetFunctionHook(entryPoint, (void*)EntryPoint_Hook, nullptr); return TRUE; } - if (isToolModProcess) return FALSE; + if (isToolModProcess) { + return FALSE; + } g_isToolModProcessLauncher = true; return TRUE; } void Wh_ModAfterInit() { - if (!g_isToolModProcessLauncher) return; + if (!g_isToolModProcessLauncher) { + return; + } WCHAR currentProcessPath[MAX_PATH]; switch (GetModuleFileNameW(nullptr, currentProcessPath, @@ -870,12 +2852,16 @@ void Wh_ModAfterInit() { WCHAR commandLine[MAX_PATH + 2 + (sizeof(L" -tool-mod \"" WH_MOD_ID "\"") / sizeof(WCHAR)) - 1]; - swprintf_s(commandLine, L"\"%s\" -tool-mod \"%s\"", currentProcessPath, WH_MOD_ID); + swprintf_s(commandLine, L"\"%s\" -tool-mod \"%s\"", currentProcessPath, + WH_MOD_ID); HMODULE kernelModule = GetModuleHandleW(L"kernelbase.dll"); if (!kernelModule) { kernelModule = GetModuleHandleW(L"kernel32.dll"); - if (!kernelModule) { Wh_Log(L"No kernelbase.dll/kernel32.dll"); return; } + if (!kernelModule) { + Wh_Log(L"No kernelbase.dll/kernel32.dll"); + return; + } } using CreateProcessInternalW_t = BOOL(WINAPI*)( @@ -887,10 +2873,17 @@ void Wh_ModAfterInit() { LPPROCESS_INFORMATION lpProcessInformation, PHANDLE hRestrictedUserToken); CreateProcessInternalW_t pCreateProcessInternalW = - (CreateProcessInternalW_t)GetProcAddress(kernelModule, "CreateProcessInternalW"); - if (!pCreateProcessInternalW) { Wh_Log(L"No CreateProcessInternalW"); return; } + (CreateProcessInternalW_t)GetProcAddress(kernelModule, + "CreateProcessInternalW"); + if (!pCreateProcessInternalW) { + Wh_Log(L"No CreateProcessInternalW"); + return; + } - STARTUPINFOW si{.cb = sizeof(STARTUPINFOW), .dwFlags = STARTF_FORCEOFFFEEDBACK}; + STARTUPINFOW si{ + .cb = sizeof(STARTUPINFOW), + .dwFlags = STARTF_FORCEOFFFEEDBACK, + }; PROCESS_INFORMATION pi; if (!pCreateProcessInternalW(nullptr, currentProcessPath, commandLine, nullptr, nullptr, FALSE, NORMAL_PRIORITY_CLASS, @@ -898,17 +2891,24 @@ void Wh_ModAfterInit() { Wh_Log(L"CreateProcess failed"); return; } + CloseHandle(pi.hProcess); CloseHandle(pi.hThread); } void Wh_ModSettingsChanged() { - if (g_isToolModProcessLauncher) return; + if (g_isToolModProcessLauncher) { + return; + } + WhTool_ModSettingsChanged(); } void Wh_ModUninit() { - if (g_isToolModProcessLauncher) return; + if (g_isToolModProcessLauncher) { + return; + } + WhTool_ModUninit(); ExitProcess(0); } From f752f1a999110df5e3be045b54d53b82dc639a52 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Fri, 29 May 2026 17:01:50 +0300 Subject: [PATCH 37/56] Version 2.2.0 with new features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New: Right-click → **Sound Settings...** opens Windows Sound dialog directly on the Playback tab. - New: Middle-click tray icon → compact volume slider popup with a custom dark design. Drag to adjust output volume live; click outside or press Escape to close. - New: Scroll wheel over the tray icon (Click to Swap mode) adjusts output volume, matching native Windows sound icon behaviour. --- mods/audioswap.wh.cpp | 651 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 609 insertions(+), 42 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index 92a44ac25c..b440191ea8 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -2,12 +2,12 @@ // @id audioswap // @name AudioSwap // @description Tray icon to cycle between multiple preferred audio outputs. Supports up to 6 devices with click or scroll to swap. -// @version 2.1.0 +// @version 2.2.0 // @author BlackPaw // @github https://github.com/BlackPaw21 // @donateUrl https://ko-fi.com/blackpaw21 // @include windhawk.exe -// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lcomdlg32 -ladvapi32 +// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lcomdlg32 -ladvapi32 -lcomctl32 // ==/WindhawkMod== // ==WindhawkModReadme== @@ -15,6 +15,8 @@ # AudioSwap Instantly cycle between multiple audio output devices from your system tray — no diving into Sound settings. +> Works great alongside **[MicSwitch](https://windhawk.net/mods/microswap)** — the companion mod that brings the same tray experience to microphone input control. + --- ## How to Use @@ -26,6 +28,11 @@ Instantly cycle between multiple audio output devices from your system tray — 5. **Set Device Count** — Choose how many slots to cycle through (2–6). 6. Click **Save and Apply** — the tray icon updates immediately, no restart needed. +### Volume Control + +- **Scroll wheel** over the tray icon (in Click to Swap mode) adjusts output volume — just like the native Windows sound icon. +- **Middle-click** the tray icon to open a compact volume slider. Drag or use the arrow keys to adjust output volume live — click anywhere outside or press Escape to close. + ### Scroll to Swap mode extras - **Left-click** mutes the current device. Click again to unmute. The tray tooltip shows *(Muted)* and the icon gains a red dot while active. @@ -52,6 +59,11 @@ Enable **Advanced Mode** in the Windhawk settings panel (gear icon → Settings ## Changelog +### v2.2.0 +- New: Right-click → **Sound Settings...** opens Windows Sound dialog directly on the Playback tab. +- New: Middle-click tray icon → compact volume slider popup with a custom dark design. Drag to adjust output volume live; click outside or press Escape to close. +- New: Scroll wheel over the tray icon (Click to Swap mode) adjusts output volume, matching native Windows sound icon behaviour. + ### v2.1.0 - New: Device Priority panel — rank your preferred devices; the highest-priority connected device is assigned to a swap slot automatically. - New: Advanced Mode toggle in Windhawk settings — shows the Device Priority panel in Mod Settings. @@ -100,6 +112,9 @@ Enable **Advanced Mode** in the Windhawk settings panel (gear icon → Settings - advancedMode: false $name: Advanced Mode $description: Shows the Device Priority panel in Mod Settings, letting you rank preferred devices for automatic slot assignment. +- _info: " " + $name: " " + $description: "Most settings live in the in-tray dashboard. Right-click the AudioSwap tray icon → Mod Settings." */ // ==/WindhawkModSettings== @@ -108,17 +123,21 @@ Enable **Advanced Mode** in the Windhawk settings panel (gear icon → Settings // The advancedMode flag above uses Wh_GetIntSetting, which reads from Windhawk's // separate YAML store — no conflict with dashboard keys. +#define NOMINMAX #include #include #include #include #include +#include +#include #include #include #include #include #include +#include #include #include @@ -136,11 +155,19 @@ static const GUID AUDIOSWAP_TRAY_GUID = #define WM_RELOAD_ICONS (WM_USER + 6) // reload icons on tray thread (eliminates cross-thread handle race) #define WM_RELOAD_ALL (WM_USER + 7) // full reload after dashboard save #define WM_PRIORITY_DEVICE_ACTIVE (WM_USER + 8) // lParam = heap-alloc'd WCHAR* device ID +#define WM_REBIND_VOLUME_CALLBACK (WM_USER + 9) // rebind IAudioEndpointVolumeCallback after default-device change #define TRAY_RECT_INIT_TIMER 99 // one-shot retry timer for Shell_NotifyIconGetRect +// Sentinel context GUID — distinguishes volume changes triggered by our own +// VolumePopup slider from external (volume keys / Action Center / other apps) +// when IAudioEndpointVolumeCallback::OnNotify fires. +static const GUID kVolumeChangeCtx = + {0x9F2A1D8E, 0xC7B4, 0x4E63, {0x8A, 0x1F, 0x2D, 0x5B, 0x6E, 0x7C, 0x8D, 0x9F}}; + #define MENU_OPEN_SETTINGS 9001 #define MENU_OPEN_WINDHAWK 9000 #define IDC_BTN_KOFI 9002 +#define MENU_SOUND_SETTINGS 9003 // With NOTIFYICON_VERSION_4 active Windows changes the tray notifications: // left-click → NIN_SELECT (instead of / alongside WM_LBUTTONUP) @@ -204,8 +231,11 @@ static volatile LONG g_scrollToSwap = 0; // 1 = scroll mode // IMMNotificationClient registration class AudioDeviceNotifier; -static IMMDeviceEnumerator* g_notifEnum = nullptr; -static AudioDeviceNotifier* g_deviceNotifier = nullptr; +class VolNotifier; +static IMMDeviceEnumerator* g_notifEnum = nullptr; +static AudioDeviceNotifier* g_deviceNotifier = nullptr; +static IAudioEndpointVolume* g_pEndpointVol = nullptr; // tray-thread owned; current default endpoint +static VolNotifier* g_pVolNotifier = nullptr; // registered on g_pEndpointVol // Dashboard GUI thread — written only from the tray thread (BuildAndShowContextMenu) // and read only from the main thread (WhTool_ModUninit), which runs after the @@ -1376,7 +1406,13 @@ class AudioDeviceNotifier : public IMMNotificationClient { if (flow == eRender && role == eMultimedia) { HWND hwnd = (HWND)InterlockedCompareExchangePointer( (volatile PVOID*)&g_trayHwnd, nullptr, nullptr); - if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + if (hwnd) { + // Rebind the IAudioEndpointVolumeCallback before refreshing the + // tooltip — the live dB readout depends on tracking the *current* + // default endpoint, not the previous one. + PostMessageW(hwnd, WM_REBIND_VOLUME_CALLBACK, 0, 0); + PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + } } return S_OK; } @@ -1420,6 +1456,67 @@ class AudioDeviceNotifier : public IMMNotificationClient { LPCWSTR, const PROPERTYKEY) override { return S_OK; } }; +// IAudioEndpointVolumeCallback — fires on volume / mute changes from ANY source +// (volume keys, slider popup, Action Center, other apps). Posts WM_UPDATE_TRAY_STATE +// to refresh the tooltip's live dB readout. Self-triggered changes (slider popup) +// carry the kVolumeChangeCtx sentinel; we still refresh on those — the tooltip +// should reflect the new value regardless of who moved the slider. +class VolNotifier : public IAudioEndpointVolumeCallback { + volatile LONG m_ref; +public: + VolNotifier() : m_ref(1) {} + virtual ~VolNotifier() = default; + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override { + if (riid == __uuidof(IUnknown) || riid == __uuidof(IAudioEndpointVolumeCallback)) { + *ppv = static_cast(this); + AddRef(); return S_OK; + } + *ppv = nullptr; return E_NOINTERFACE; + } + ULONG STDMETHODCALLTYPE AddRef() override { + return static_cast(InterlockedIncrement(&m_ref)); + } + ULONG STDMETHODCALLTYPE Release() override { + LONG r = InterlockedDecrement(&m_ref); + if (r == 0) delete this; + return static_cast(r); + } + + HRESULT STDMETHODCALLTYPE OnNotify(PAUDIO_VOLUME_NOTIFICATION_DATA) override { + HWND hwnd = (HWND)InterlockedCompareExchangePointer( + (volatile PVOID*)&g_trayHwnd, nullptr, nullptr); + if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + return S_OK; + } +}; + +// Acquire IAudioEndpointVolume on the current default render endpoint and register +// VolNotifier. Idempotent — caller is responsible for first releasing the previous +// binding via UnbindEndpointVolume(). Tray-thread only. +static void BindEndpointVolume() { + if (!g_notifEnum) return; + IMMDevice* pDev = nullptr; + if (FAILED(g_notifEnum->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDev)) || !pDev) + return; + IAudioEndpointVolume* pVol = nullptr; + if (SUCCEEDED(pDev->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, + nullptr, (void**)&pVol)) && pVol) { + g_pEndpointVol = pVol; + if (!g_pVolNotifier) g_pVolNotifier = new VolNotifier(); + g_pEndpointVol->RegisterControlChangeNotify(g_pVolNotifier); + } + pDev->Release(); +} + +static void UnbindEndpointVolume() { + if (g_pEndpointVol && g_pVolNotifier) { + g_pEndpointVol->UnregisterControlChangeNotify(g_pVolNotifier); + } + if (g_pVolNotifier) { g_pVolNotifier->Release(); g_pVolNotifier = nullptr; } + if (g_pEndpointVol) { g_pEndpointVol->Release(); g_pEndpointVol = nullptr; } +} + // Scroll-to-swap uses Raw Input (RegisterRawInputDevices + WM_INPUT in TrayWndProc) // instead of WH_MOUSE_LL. Raw Input is delivered outside the hook chain, so no // other WH_MOUSE_LL hook (e.g., Monitor Sleep Button) can block it. @@ -1519,6 +1616,7 @@ static HICON CreateMutedOverlayIcon(HICON hBase) { static WCHAR s_lastDev[256] = {}; static HICON s_lastIcon = nullptr; static bool s_lastMuted = false; +static int s_lastVolume = -1; void UpdateTrayTip(HWND hWnd, BOOL isAdd) { // Snapshot shared state under lock (no COM inside the lock). @@ -1535,7 +1633,21 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { WCHAR currentDev[256] = L"Unknown Device"; WCHAR currentId[512] = {}; + int currentVolume = -1; // -1 sentinel = query failed; omit from tooltip + + // Fast path: read scalar + dB from the cached endpoint pointer without re-doing + // CoCreateInstance + Activate. Falls back to the slow per-call path if the + // tray thread hasn't bound an endpoint yet (e.g., startup race). + if (g_pEndpointVol) { + float scalar = 0.0f; + if (SUCCEEDED(g_pEndpointVol->GetMasterVolumeLevelScalar(&scalar))) { + currentVolume = (int)(scalar * 100.0f + 0.5f); + if (currentVolume < 0) currentVolume = 0; + if (currentVolume > 100) currentVolume = 100; + } + } + // Slow path: still needed for the device name + ID. Always run this block. IMMDeviceEnumerator* pEnum = nullptr; if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { @@ -1554,6 +1666,20 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { PropVariantClear(&v); pStore->Release(); } + // Fallback: if the cached endpoint wasn't bound yet, query volume inline. + if (currentVolume < 0) { + IAudioEndpointVolume* pVol = nullptr; + if (SUCCEEDED(pDev->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, + nullptr, (void**)&pVol))) { + float scalar = 0.0f; + if (SUCCEEDED(pVol->GetMasterVolumeLevelScalar(&scalar))) { + currentVolume = (int)(scalar * 100.0f + 0.5f); + if (currentVolume < 0) currentVolume = 0; + if (currentVolume > 100) currentVolume = 100; + } + pVol->Release(); + } + } pDev->Release(); } pEnum->Release(); @@ -1572,30 +1698,40 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { for (int i = 0; i < localSlotCount; i++) if (localDevIds[i][0] != L'\0') configuredCount++; - // s_lastDev / s_lastIcon / s_lastMuted are file-scope to allow external reset. + // s_lastDev / s_lastIcon / s_lastMuted / s_lastVolume are file-scope to allow external reset. if (!isAdd && wcscmp(currentDev, s_lastDev) == 0 && currentIcon == s_lastIcon && - s_lastMuted == localMuted) + s_lastMuted == localMuted && + s_lastVolume == currentVolume) { RefreshTrayIconRect(); return; } lstrcpyW(s_lastDev, currentDev); - s_lastIcon = currentIcon; - s_lastMuted = localMuted; + s_lastIcon = currentIcon; + s_lastMuted = localMuted; + s_lastVolume = currentVolume; NOTIFYICONDATAW nid = {sizeof(nid)}; nid.hWnd = hWnd; nid.uID = TRAY_ICON_ID; - nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_ICON | NIF_GUID; + // NIF_SHOWTIP is required under NOTIFYICON_VERSION_4 — without it the shell + // assumes the app draws its own tooltip and the standard one never appears on hover. + nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_SHOWTIP | NIF_ICON | NIF_GUID; nid.guidItem = AUDIOSWAP_TRAY_GUID; nid.uCallbackMessage = WM_TRAY_CALLBACK; if (configuredCount < 2) swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"AudioSwap: Right-click to configure"); + else if (localMuted && currentVolume >= 0) + swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Audio: %.80s (%d%%, Muted)", + currentDev, currentVolume); else if (localMuted) swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Audio: %.100s (Muted)", currentDev); + else if (currentVolume >= 0) + swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Audio: %.90s (%d%%)", + currentDev, currentVolume); else swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Audio: %.110s", currentDev); @@ -1617,6 +1753,58 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { RefreshTrayIconRect(); } +// ─── Volume helpers ─────────────────────────────────────────────────────────── + +static int GetCurrentVolumePct() { + int result = -1; + HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + if (FAILED(hr) && hr != S_FALSE) return result; + IMMDeviceEnumerator* pEnum = nullptr; + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { + IMMDevice* pDev = nullptr; + if (SUCCEEDED(pEnum->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDev))) { + IAudioEndpointVolume* pVol = nullptr; + if (SUCCEEDED(pDev->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, + nullptr, (void**)&pVol))) { + float scalar = 0.0f; + if (SUCCEEDED(pVol->GetMasterVolumeLevelScalar(&scalar))) { + result = (int)(scalar * 100.0f + 0.5f); + if (result < 0) result = 0; + if (result > 100) result = 100; + } + pVol->Release(); + } + pDev->Release(); + } + pEnum->Release(); + } + CoUninitialize(); + return result; +} + +static void SetCurrentDeviceVolume(float scalar) { + scalar = std::max(0.0f, std::min(1.0f, scalar)); + HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + if (FAILED(hr) && hr != S_FALSE) return; + IMMDeviceEnumerator* pEnum = nullptr; + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { + IMMDevice* pDev = nullptr; + if (SUCCEEDED(pEnum->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDev))) { + IAudioEndpointVolume* pVol = nullptr; + if (SUCCEEDED(pDev->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, + nullptr, (void**)&pVol))) { + pVol->SetMasterVolumeLevelScalar(scalar, nullptr); + pVol->Release(); + } + pDev->Release(); + } + pEnum->Release(); + } + CoUninitialize(); +} + // ─── Audio cycling ──────────────────────────────────────────────────────────── BOOL CycleAudioDevice(int direction) { @@ -1720,7 +1908,8 @@ static void ApplyContextMenuTheme(HWND hWnd, bool dark) { void BuildAndShowContextMenu(HWND hWnd) { HMENU hMenu = CreatePopupMenu(); - AppendMenuW(hMenu, MF_STRING, MENU_OPEN_SETTINGS, L"Mod Settings..."); + AppendMenuW(hMenu, MF_STRING, MENU_OPEN_SETTINGS, L"Mod Settings..."); + AppendMenuW(hMenu, MF_STRING, MENU_SOUND_SETTINGS, L"Playback Settings..."); AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); MENUITEMINFOW miiWH = {sizeof(miiWH)}; @@ -1746,6 +1935,8 @@ void BuildAndShowContextMenu(HWND hWnd) { if (g_guiThread) CloseHandle(g_guiThread); g_guiThread = h; } + } else if (cmd == MENU_SOUND_SETTINGS) { + ShellExecuteW(nullptr, L"open", L"control.exe", L"mmsys.cpl,,0", nullptr, SW_SHOWNORMAL); } else if (cmd == MENU_OPEN_WINDHAWK) { SHELLEXECUTEINFOW sei = {sizeof(sei)}; sei.lpFile = g_windhawkPath; @@ -1754,6 +1945,338 @@ void BuildAndShowContextMenu(HWND hWnd) { } } +// ─── Volume slider popup ────────────────────────────────────────────────────── + +namespace VolumePopup { + +static constexpr WCHAR kClass[] = L"AudioSwapVolumePopup"; +static constexpr WCHAR kLabel[] = L"Output Volume"; +static constexpr int kW = 240; +static constexpr int kH = 72; +static constexpr int kPadX = 16; // left/right padding +static constexpr int kTitleY = 12; // title row top Y +static constexpr int kTitleH = 18; // title row height +static constexpr int kTrackY = 52; // track center Y +static constexpr int kTrackH = 6; // track height in px +static constexpr int kThumbR = 9; // thumb radius (normal) +static constexpr int kThumbRHov = 10; // thumb radius (hover / drag) +static constexpr UINT kVolTimerId = 102; // throttled audio commit +static constexpr COLORREF kBg = RGB(28, 28, 28 ); +static constexpr COLORREF kBorder = RGB(50, 50, 50 ); +static constexpr COLORREF kTrackBg = RGB(58, 58, 58 ); +static constexpr COLORREF kTrackFill = RGB(0, 120, 215); +static constexpr COLORREF kThumbCol = RGB(255, 255, 255); +static constexpr COLORREF kTextPri = RGB(235, 235, 235); +static constexpr COLORREF kTextSec = RGB(130, 130, 130); + +static HWND g_hwnd = nullptr; +static bool g_volumeDirty = false; +static HWND g_trayWnd = nullptr; +static int g_value = 0; +static bool g_dragging = false; +static bool g_hover = false; +static HFONT g_font = nullptr; + +static int ValueToThumbX() { + return kPadX + (g_value * (kW - 2 * kPadX)) / 100; +} + +static int XToValue(int x) { + int v = ((x - kPadX) * 100 + (kW - 2 * kPadX) / 2) / (kW - 2 * kPadX); + return v < 0 ? 0 : (v > 100 ? 100 : v); +} + +static bool IsOverThumb(int mx, int my) { + int dx = mx - ValueToThumbX(), dy = my - kTrackY; + int r = kThumbRHov + 4; + return dx * dx + dy * dy <= r * r; +} + +static void ApplyVolume() { + float scalar = g_value / 100.0f; + if (g_pEndpointVol) { + g_pEndpointVol->SetMasterVolumeLevelScalar(scalar, &kVolumeChangeCtx); + } else { + SetCurrentDeviceVolume(scalar); + } +} + +static LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { + switch (msg) { + + case WM_CREATE: { + int vol = (int)(INT_PTR)((CREATESTRUCTW*)lParam)->lpCreateParams; + g_value = vol < 0 ? 0 : (vol > 100 ? 100 : vol); + g_dragging = false; + g_hover = false; + g_font = CreateFontW(-14, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, + DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, + CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_DONTCARE, L"Segoe UI"); + // Win11 rounded corners (DWMWA_WINDOW_CORNER_PREFERENCE=33, DWMWCP_ROUNDSMALL=3). + // GetModuleHandleW — dwmapi is already loaded; LoadLibraryW would leak a refcount. + { + using PFN = HRESULT(WINAPI*)(HWND, DWORD, LPCVOID, DWORD); + HMODULE hDwm = GetModuleHandleW(L"dwmapi.dll"); + if (hDwm) { + if (auto pfn = (PFN)GetProcAddress(hDwm, "DwmSetWindowAttribute")) { + UINT pref = 3; + pfn(hWnd, 33, &pref, sizeof(pref)); + } + } + } + SetTimer(hWnd, kVolTimerId, 16, nullptr); + return 0; + } + + case WM_ERASEBKGND: + return 1; // suppress default erase — WM_PAINT covers the full client rect + + case WM_PAINT: { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hWnd, &ps); + + // Double-buffer to eliminate flicker + HDC hMem = CreateCompatibleDC(hdc); + HBITMAP hBmp = CreateCompatibleBitmap(hdc, kW, kH); + auto hOldBmp = (HBITMAP)SelectObject(hMem, hBmp); + + // ── Background ──────────────────────────────────────────────── + RECT rcAll = {0, 0, kW, kH}; + HBRUSH hbBg = CreateSolidBrush(kBg); + FillRect(hMem, &rcAll, hbBg); + DeleteObject(hbBg); + + // Border (1 px) + HBRUSH hbBdr = CreateSolidBrush(kBorder); + FrameRect(hMem, &rcAll, hbBdr); + DeleteObject(hbBdr); + + // ── Title row ───────────────────────────────────────────────── + HFONT hOldFont = (HFONT)SelectObject(hMem, g_font ? g_font + : GetStockObject(DEFAULT_GUI_FONT)); + SetBkMode(hMem, TRANSPARENT); + + RECT rcL = {kPadX, kTitleY, kW - kPadX, kTitleY + kTitleH}; + SetTextColor(hMem, kTextSec); + DrawTextW(hMem, kLabel, -1, &rcL, + DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); + + WCHAR valBuf[8]; + swprintf_s(valBuf, ARRAYSIZE(valBuf), L"%d%%", g_value); + RECT rcR = {kPadX, kTitleY, kW - kPadX, kTitleY + kTitleH}; + SetTextColor(hMem, kTextPri); + DrawTextW(hMem, valBuf, -1, &rcR, + DT_RIGHT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); + + SelectObject(hMem, hOldFont); + + // ── Track ───────────────────────────────────────────────────── + int tLeft = kPadX; + int tRight = kW - kPadX; + int tTop = kTrackY - kTrackH / 2; + int tBot = kTrackY + kTrackH / 2; + int tX = ValueToThumbX(); + + SelectObject(hMem, GetStockObject(NULL_PEN)); + + // Unfilled portion + HBRUSH hbTrack = CreateSolidBrush(kTrackBg); + SelectObject(hMem, hbTrack); + RoundRect(hMem, tLeft, tTop, tRight, tBot, kTrackH, kTrackH); + DeleteObject(hbTrack); + + // Accent-filled (left of thumb) + if (tX > tLeft) { + HBRUSH hbFill = CreateSolidBrush(kTrackFill); + SelectObject(hMem, hbFill); + RoundRect(hMem, tLeft, tTop, tX, tBot, kTrackH, kTrackH); + DeleteObject(hbFill); + } + + // Thumb + int r = (g_dragging || g_hover) ? kThumbRHov : kThumbR; + HBRUSH hbThumb = CreateSolidBrush(kThumbCol); + SelectObject(hMem, hbThumb); + Ellipse(hMem, tX - r, kTrackY - r, tX + r, kTrackY + r); + DeleteObject(hbThumb); + + // ── Blit ────────────────────────────────────────────────────── + BitBlt(hdc, 0, 0, kW, kH, hMem, 0, 0, SRCCOPY); + SelectObject(hMem, hOldBmp); + DeleteObject(hBmp); + DeleteDC(hMem); + + EndPaint(hWnd, &ps); + return 0; + } + + case WM_SETCURSOR: { + POINT pt; + GetCursorPos(&pt); + ScreenToClient(hWnd, &pt); + bool overTrack = (pt.y >= kTrackY - kThumbRHov * 2 && + pt.y <= kTrackY + kThumbRHov * 2 && + pt.x >= kPadX && pt.x <= kW - kPadX); + SetCursor(LoadCursor(nullptr, overTrack ? IDC_HAND : IDC_ARROW)); + return TRUE; + } + + case WM_LBUTTONDOWN: { + int mx = (short)LOWORD(lParam), my = (short)HIWORD(lParam); + bool onTrack = (my >= kTrackY - kThumbRHov * 2 && + my <= kTrackY + kThumbRHov * 2 && + mx >= kPadX && mx <= kW - kPadX); + if (onTrack || IsOverThumb(mx, my)) { + g_value = XToValue(mx); + g_dragging = true; + g_hover = true; + SetCapture(hWnd); + ApplyVolume(); + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + } + + case WM_MOUSEMOVE: { + int mx = (short)LOWORD(lParam), my = (short)HIWORD(lParam); + // Re-register for WM_MOUSELEAVE each time (TME_LEAVE is one-shot) + TRACKMOUSEEVENT tme = {sizeof(tme), TME_LEAVE, hWnd, 0}; + TrackMouseEvent(&tme); + if (g_dragging) { + int newVal = XToValue(mx); + if (newVal != g_value) { + g_value = newVal; + g_volumeDirty = true; // throttled — kVolTimerId commits to audio + InvalidateRect(hWnd, nullptr, FALSE); + } + } else { + bool wasHover = g_hover; + g_hover = IsOverThumb(mx, my); + if (g_hover != wasHover) + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + } + + case WM_LBUTTONUP: + if (g_dragging) { + g_dragging = false; + ReleaseCapture(); + g_value = XToValue((short)LOWORD(lParam)); + g_volumeDirty = false; + ApplyVolume(); // force-commit final drag position + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + + case WM_MOUSELEAVE: + if (!g_dragging && g_hover) { + g_hover = false; + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + + case WM_MOUSEWHEEL: { + int step = GET_WHEEL_DELTA_WPARAM(wParam) > 0 ? 3 : -3; + int newVal = std::max(0, std::min(100, g_value + step)); + if (newVal != g_value) { + g_value = newVal; + ApplyVolume(); + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + } + + case WM_ACTIVATE: + if (LOWORD(wParam) == WA_INACTIVE) + DestroyWindow(hWnd); + return 0; + + case WM_TIMER: + if (wParam == kVolTimerId) { + if (g_volumeDirty) { g_volumeDirty = false; ApplyVolume(); } + } + return 0; + + case WM_KEYDOWN: { + int v = g_value; + switch (wParam) { + case VK_LEFT: v = std::max(0, v - 1); break; + case VK_RIGHT: v = std::min(100, v + 1); break; + case VK_HOME: v = 0; break; + case VK_END: v = 100; break; + case VK_ESCAPE: + DestroyWindow(hWnd); + return 0; + default: return 0; + } + if (v != g_value) { + g_value = v; + ApplyVolume(); + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + } + + case WM_DESTROY: + KillTimer(hWnd, kVolTimerId); + if (g_volumeDirty) { g_volumeDirty = false; ApplyVolume(); } + if (g_font) { DeleteObject(g_font); g_font = nullptr; } + g_hwnd = nullptr; + g_trayWnd = nullptr; + g_dragging = false; + g_hover = false; + return 0; + + } // switch + return DefWindowProcW(hWnd, msg, wParam, lParam); +} + +static void RegisterClass() { + WNDCLASSEXW wc = {sizeof(wc)}; + wc.lpfnWndProc = WndProc; + wc.hInstance = g_hInstance; + wc.lpszClassName = kClass; + wc.hCursor = LoadCursor(nullptr, IDC_ARROW); + RegisterClassExW(&wc); +} + +static void UnregisterClass() { + ::UnregisterClassW(kClass, g_hInstance); +} + +static void Show(HWND hTrayWnd, int volPct) { + if (g_hwnd) { SetForegroundWindow(g_hwnd); return; } + + int x = (g_trayIconRect.left + g_trayIconRect.right) / 2 - kW / 2; + int y = g_trayIconRect.top - kH - 8; + + POINT pt = { (g_trayIconRect.left + g_trayIconRect.right) / 2, g_trayIconRect.top }; + HMONITOR hm = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST); + MONITORINFO mi = { sizeof(mi) }; + if (GetMonitorInfo(hm, &mi)) { + RECT& wa = mi.rcWork; + if (x < wa.left) x = wa.left; + if (x + kW > wa.right) x = wa.right - kW; + if (y < wa.top) y = g_trayIconRect.bottom + 8; + } + + g_trayWnd = hTrayWnd; + g_hwnd = CreateWindowExW( + WS_EX_TOOLWINDOW | WS_EX_TOPMOST, + kClass, nullptr, + WS_POPUP, + x, y, kW, kH, + hTrayWnd, nullptr, g_hInstance, (LPVOID)(INT_PTR)volPct); + + if (g_hwnd) { + ShowWindow(g_hwnd, SW_SHOWNOACTIVATE); + SetForegroundWindow(g_hwnd); + } +} + +} // namespace VolumePopup + // ─── Worker thread ──────────────────────────────────────────────────────────── DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { @@ -1880,25 +2403,34 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) } InterlockedExchange(&g_isProcessingClick, 0); } + } else if (event == WM_MBUTTONUP) { + VolumePopup::Show(hWnd, GetCurrentVolumePct()); } } else if (msg == WM_INPUT) { - if (InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 1) { - UINT sz = 0; - GetRawInputData((HRAWINPUT)lParam, RID_INPUT, nullptr, &sz, sizeof(RAWINPUTHEADER)); - if (sz > 0) { - std::vector buf(sz); - if (GetRawInputData((HRAWINPUT)lParam, RID_INPUT, buf.data(), &sz, - sizeof(RAWINPUTHEADER)) == sz) { - auto* raw = reinterpret_cast(buf.data()); - if (raw->header.dwType == RIM_TYPEMOUSE && - (raw->data.mouse.usButtonFlags & RI_MOUSE_WHEEL)) { - POINT pt; GetCursorPos(&pt); - if (PtInRect(&g_trayIconRect, pt)) { - short delta = (short)raw->data.mouse.usButtonData; - int dir = (delta > 0) ? 1 : -1; + UINT sz = 0; + GetRawInputData((HRAWINPUT)lParam, RID_INPUT, nullptr, &sz, sizeof(RAWINPUTHEADER)); + if (sz > 0) { + std::vector buf(sz); + if (GetRawInputData((HRAWINPUT)lParam, RID_INPUT, buf.data(), &sz, + sizeof(RAWINPUTHEADER)) == sz) { + auto* raw = reinterpret_cast(buf.data()); + if (raw->header.dwType == RIM_TYPEMOUSE && + (raw->data.mouse.usButtonFlags & RI_MOUSE_WHEEL)) { + POINT pt; GetCursorPos(&pt); + if (PtInRect(&g_trayIconRect, pt)) { + short delta = (short)raw->data.mouse.usButtonData; + int dir = (delta > 0) ? 1 : -1; + if (InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 1) { PostMessageW(hWnd, WM_TRAY_SCROLL, (WPARAM)(LONG_PTR)dir, 0); + } else if (g_pEndpointVol) { + float scalar = 0.0f; + if (SUCCEEDED(g_pEndpointVol->GetMasterVolumeLevelScalar(&scalar))) { + scalar = std::max(0.0f, std::min(1.0f, scalar + dir * 0.02f)); + g_pEndpointVol->SetMasterVolumeLevelScalar(scalar, nullptr); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + } } } } @@ -1927,14 +2459,8 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) UpdateTrayTip(hWnd, FALSE); } else if (msg == WM_UPDATE_HOOK_STATE) { - LONG isScroll = InterlockedCompareExchange(&g_scrollToSwap, 0, 0); - if (isScroll) { - RAWINPUTDEVICE rid = { 1, 2, RIDEV_INPUTSINK, hWnd }; - RegisterRawInputDevices(&rid, 1, sizeof(rid)); - } else { - RAWINPUTDEVICE rid = { 1, 2, RIDEV_REMOVE, nullptr }; - RegisterRawInputDevices(&rid, 1, sizeof(rid)); - } + RAWINPUTDEVICE rid = { 1, 2, RIDEV_INPUTSINK, hWnd }; + RegisterRawInputDevices(&rid, 1, sizeof(rid)); } else if (msg == WM_RELOAD_ICONS) { // Run icon reload on tray thread to eliminate cross-thread handle race. @@ -1994,6 +2520,11 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) UpdateTrayTip(hWnd, TRUE); // re-add icon after explorer restart RefreshTrayIconRect(); // re-acquire icon screen rect after explorer restart + } else if (msg == WM_REBIND_VOLUME_CALLBACK) { + // Default device changed — re-bind to new endpoint. + UnbindEndpointVolume(); + BindEndpointVolume(); + } else if (msg == WM_RELOAD_ALL) { // Full reload triggered by the settings dashboard after Save and Apply. // Picks up new device assignments, icon choices, device count, swap mode, and priority list. @@ -2012,6 +2543,7 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) nid.uFlags = NIF_GUID; nid.guidItem = AUDIOSWAP_TRAY_GUID; Shell_NotifyIconW(NIM_DELETE, &nid); + if (VolumePopup::g_hwnd) DestroyWindow(VolumePopup::g_hwnd); DestroyWindow(hWnd); return 0; @@ -2033,6 +2565,7 @@ DWORD WINAPI TrayThreadProc(LPVOID) { wc.hInstance = g_hInstance; wc.lpszClassName = L"AudioSwitcherWindowClass"; RegisterClassW(&wc); + VolumePopup::RegisterClass(); // WS_EX_TOOLWINDOW: hidden popup window that Shell_NotifyIconW can track properly. // AudioSwap used HWND_MESSAGE originally, but message-only windows can lose their @@ -2055,10 +2588,11 @@ DWORD WINAPI TrayThreadProc(LPVOID) { __uuidof(IMMDeviceEnumerator), (void**)&g_notifEnum))) { g_deviceNotifier = new AudioDeviceNotifier(); g_notifEnum->RegisterEndpointNotificationCallback(g_deviceNotifier); + BindEndpointVolume(); } - - // Register Raw Input for scroll-to-swap (bypasses WH_MOUSE_LL hook chain). - if (InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 1) { + // Register Raw Input for scroll over the tray icon (bypasses WH_MOUSE_LL hook chain). + // Always active: scroll-to-swap mode cycles devices; click mode adjusts volume. + { RAWINPUTDEVICE rid = { 1, 2, RIDEV_INPUTSINK, g_trayHwnd }; RegisterRawInputDevices(&rid, 1, sizeof(rid)); } @@ -2068,6 +2602,9 @@ DWORD WINAPI TrayThreadProc(LPVOID) { MSG msg; while (GetMessageW(&msg, nullptr, 0, 0) > 0) { DispatchMessageW(&msg); } + // Unbind volume callback BEFORE unregistering the device-enumerator notifications, + // so OnNotify cannot fire against a half-torn-down object. + UnbindEndpointVolume(); // Unregister notifications before releasing the enumerator. if (g_notifEnum && g_deviceNotifier) { g_notifEnum->UnregisterEndpointNotificationCallback(g_deviceNotifier); @@ -2079,6 +2616,7 @@ DWORD WINAPI TrayThreadProc(LPVOID) { RAWINPUTDEVICE rid = { 1, 2, RIDEV_REMOVE, nullptr }; RegisterRawInputDevices(&rid, 1, sizeof(rid)); } + VolumePopup::UnregisterClass(); CoUninitialize(); return 0; } @@ -2089,6 +2627,23 @@ BOOL WhTool_ModInit() { Wh_Log(L"AudioSwap Mod Init"); InitializeCriticalSection(&g_stateLock); + // Enable dark mode app-wide via uxtheme ordinal 135 (SetPreferredAppMode), with a + // fall-back to ordinal 132 (AllowDarkModeForApp) on Win10 builds pre-1903. Graceful + // no-op when neither ordinal exists. Affects context menus, dialogs, and STATIC + // controls in the dashboard. + { + HMODULE ux = GetModuleHandleW(L"uxtheme.dll"); + if (ux) { + using FnSetMode = void(WINAPI*)(int); + using FnAllow = bool(WINAPI*)(bool); + if (auto f = (FnSetMode)GetProcAddress(ux, MAKEINTRESOURCEA(135))) { + f(1); // PreferredAppMode::AllowDark + } else if (auto f = (FnAllow)GetProcAddress(ux, MAKEINTRESOURCEA(132))) { + f(true); + } + } + } + g_hInstance = GetModuleHandleW(nullptr); switch (GetModuleFileNameW(nullptr, g_windhawkPath, ARRAYSIZE(g_windhawkPath))) { case 0: @@ -2159,8 +2714,12 @@ void WhTool_ModUninit() { HWND hwnd = g_trayHwnd; if (hwnd) PostMessageW(hwnd, WM_CLOSE, 0, 0); if (g_trayThread) { - WaitForSingleObject(g_trayThread, 3000); - CloseHandle(g_trayThread); + DWORD wr = WaitForSingleObject(g_trayThread, 3000); + if (wr == WAIT_TIMEOUT) { + Wh_Log(L"AudioSwap: tray thread did not exit within 3 s — leaking handle to avoid race"); + } else { + CloseHandle(g_trayThread); + } g_trayThread = nullptr; } if (g_guiThread) { @@ -2169,13 +2728,21 @@ void WhTool_ModUninit() { // exiting the loop regardless of IsDialogMessageW. DWORD guiId = GetThreadId(g_guiThread); if (guiId) PostThreadMessageW(guiId, WM_QUIT, 0, 0); - WaitForSingleObject(g_guiThread, 2000); - CloseHandle(g_guiThread); + DWORD wr = WaitForSingleObject(g_guiThread, 2000); + if (wr == WAIT_TIMEOUT) { + Wh_Log(L"AudioSwap: GUI thread did not exit within 2 s — leaking handle to avoid race"); + } else { + CloseHandle(g_guiThread); + } g_guiThread = nullptr; } if (g_workerThread) { - WaitForSingleObject(g_workerThread, 2000); - CloseHandle(g_workerThread); + DWORD wr = WaitForSingleObject(g_workerThread, 2000); + if (wr == WAIT_TIMEOUT) { + Wh_Log(L"AudioSwap: worker thread did not exit within 2 s — leaking handle to avoid race"); + } else { + CloseHandle(g_workerThread); + } g_workerThread = nullptr; } if (g_notifEnum && g_deviceNotifier) { From 5d557ef3443ca10b1e53535fc3844b8d3fe269c9 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Fri, 29 May 2026 17:09:07 +0300 Subject: [PATCH 38/56] Downgrade version from 2.2.0 to 2.1.0 for PR --- mods/audioswap.wh.cpp | 644 ++++-------------------------------------- 1 file changed, 49 insertions(+), 595 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index b440191ea8..43afb04ea0 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -2,12 +2,12 @@ // @id audioswap // @name AudioSwap // @description Tray icon to cycle between multiple preferred audio outputs. Supports up to 6 devices with click or scroll to swap. -// @version 2.2.0 +// @version 2.1.0 // @author BlackPaw // @github https://github.com/BlackPaw21 // @donateUrl https://ko-fi.com/blackpaw21 // @include windhawk.exe -// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lcomdlg32 -ladvapi32 -lcomctl32 +// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lcomdlg32 -ladvapi32 // ==/WindhawkMod== // ==WindhawkModReadme== @@ -15,8 +15,6 @@ # AudioSwap Instantly cycle between multiple audio output devices from your system tray — no diving into Sound settings. -> Works great alongside **[MicSwitch](https://windhawk.net/mods/microswap)** — the companion mod that brings the same tray experience to microphone input control. - --- ## How to Use @@ -28,11 +26,6 @@ Instantly cycle between multiple audio output devices from your system tray — 5. **Set Device Count** — Choose how many slots to cycle through (2–6). 6. Click **Save and Apply** — the tray icon updates immediately, no restart needed. -### Volume Control - -- **Scroll wheel** over the tray icon (in Click to Swap mode) adjusts output volume — just like the native Windows sound icon. -- **Middle-click** the tray icon to open a compact volume slider. Drag or use the arrow keys to adjust output volume live — click anywhere outside or press Escape to close. - ### Scroll to Swap mode extras - **Left-click** mutes the current device. Click again to unmute. The tray tooltip shows *(Muted)* and the icon gains a red dot while active. @@ -59,11 +52,6 @@ Enable **Advanced Mode** in the Windhawk settings panel (gear icon → Settings ## Changelog -### v2.2.0 -- New: Right-click → **Sound Settings...** opens Windows Sound dialog directly on the Playback tab. -- New: Middle-click tray icon → compact volume slider popup with a custom dark design. Drag to adjust output volume live; click outside or press Escape to close. -- New: Scroll wheel over the tray icon (Click to Swap mode) adjusts output volume, matching native Windows sound icon behaviour. - ### v2.1.0 - New: Device Priority panel — rank your preferred devices; the highest-priority connected device is assigned to a swap slot automatically. - New: Advanced Mode toggle in Windhawk settings — shows the Device Priority panel in Mod Settings. @@ -112,9 +100,6 @@ Enable **Advanced Mode** in the Windhawk settings panel (gear icon → Settings - advancedMode: false $name: Advanced Mode $description: Shows the Device Priority panel in Mod Settings, letting you rank preferred devices for automatic slot assignment. -- _info: " " - $name: " " - $description: "Most settings live in the in-tray dashboard. Right-click the AudioSwap tray icon → Mod Settings." */ // ==/WindhawkModSettings== @@ -123,21 +108,17 @@ Enable **Advanced Mode** in the Windhawk settings panel (gear icon → Settings // The advancedMode flag above uses Wh_GetIntSetting, which reads from Windhawk's // separate YAML store — no conflict with dashboard keys. -#define NOMINMAX #include #include #include #include #include -#include -#include #include #include #include #include #include -#include #include #include @@ -155,19 +136,11 @@ static const GUID AUDIOSWAP_TRAY_GUID = #define WM_RELOAD_ICONS (WM_USER + 6) // reload icons on tray thread (eliminates cross-thread handle race) #define WM_RELOAD_ALL (WM_USER + 7) // full reload after dashboard save #define WM_PRIORITY_DEVICE_ACTIVE (WM_USER + 8) // lParam = heap-alloc'd WCHAR* device ID -#define WM_REBIND_VOLUME_CALLBACK (WM_USER + 9) // rebind IAudioEndpointVolumeCallback after default-device change #define TRAY_RECT_INIT_TIMER 99 // one-shot retry timer for Shell_NotifyIconGetRect -// Sentinel context GUID — distinguishes volume changes triggered by our own -// VolumePopup slider from external (volume keys / Action Center / other apps) -// when IAudioEndpointVolumeCallback::OnNotify fires. -static const GUID kVolumeChangeCtx = - {0x9F2A1D8E, 0xC7B4, 0x4E63, {0x8A, 0x1F, 0x2D, 0x5B, 0x6E, 0x7C, 0x8D, 0x9F}}; - #define MENU_OPEN_SETTINGS 9001 #define MENU_OPEN_WINDHAWK 9000 #define IDC_BTN_KOFI 9002 -#define MENU_SOUND_SETTINGS 9003 // With NOTIFYICON_VERSION_4 active Windows changes the tray notifications: // left-click → NIN_SELECT (instead of / alongside WM_LBUTTONUP) @@ -231,11 +204,8 @@ static volatile LONG g_scrollToSwap = 0; // 1 = scroll mode // IMMNotificationClient registration class AudioDeviceNotifier; -class VolNotifier; -static IMMDeviceEnumerator* g_notifEnum = nullptr; -static AudioDeviceNotifier* g_deviceNotifier = nullptr; -static IAudioEndpointVolume* g_pEndpointVol = nullptr; // tray-thread owned; current default endpoint -static VolNotifier* g_pVolNotifier = nullptr; // registered on g_pEndpointVol +static IMMDeviceEnumerator* g_notifEnum = nullptr; +static AudioDeviceNotifier* g_deviceNotifier = nullptr; // Dashboard GUI thread — written only from the tray thread (BuildAndShowContextMenu) // and read only from the main thread (WhTool_ModUninit), which runs after the @@ -1406,13 +1376,7 @@ class AudioDeviceNotifier : public IMMNotificationClient { if (flow == eRender && role == eMultimedia) { HWND hwnd = (HWND)InterlockedCompareExchangePointer( (volatile PVOID*)&g_trayHwnd, nullptr, nullptr); - if (hwnd) { - // Rebind the IAudioEndpointVolumeCallback before refreshing the - // tooltip — the live dB readout depends on tracking the *current* - // default endpoint, not the previous one. - PostMessageW(hwnd, WM_REBIND_VOLUME_CALLBACK, 0, 0); - PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); - } + if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); } return S_OK; } @@ -1456,67 +1420,6 @@ class AudioDeviceNotifier : public IMMNotificationClient { LPCWSTR, const PROPERTYKEY) override { return S_OK; } }; -// IAudioEndpointVolumeCallback — fires on volume / mute changes from ANY source -// (volume keys, slider popup, Action Center, other apps). Posts WM_UPDATE_TRAY_STATE -// to refresh the tooltip's live dB readout. Self-triggered changes (slider popup) -// carry the kVolumeChangeCtx sentinel; we still refresh on those — the tooltip -// should reflect the new value regardless of who moved the slider. -class VolNotifier : public IAudioEndpointVolumeCallback { - volatile LONG m_ref; -public: - VolNotifier() : m_ref(1) {} - virtual ~VolNotifier() = default; - - HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override { - if (riid == __uuidof(IUnknown) || riid == __uuidof(IAudioEndpointVolumeCallback)) { - *ppv = static_cast(this); - AddRef(); return S_OK; - } - *ppv = nullptr; return E_NOINTERFACE; - } - ULONG STDMETHODCALLTYPE AddRef() override { - return static_cast(InterlockedIncrement(&m_ref)); - } - ULONG STDMETHODCALLTYPE Release() override { - LONG r = InterlockedDecrement(&m_ref); - if (r == 0) delete this; - return static_cast(r); - } - - HRESULT STDMETHODCALLTYPE OnNotify(PAUDIO_VOLUME_NOTIFICATION_DATA) override { - HWND hwnd = (HWND)InterlockedCompareExchangePointer( - (volatile PVOID*)&g_trayHwnd, nullptr, nullptr); - if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); - return S_OK; - } -}; - -// Acquire IAudioEndpointVolume on the current default render endpoint and register -// VolNotifier. Idempotent — caller is responsible for first releasing the previous -// binding via UnbindEndpointVolume(). Tray-thread only. -static void BindEndpointVolume() { - if (!g_notifEnum) return; - IMMDevice* pDev = nullptr; - if (FAILED(g_notifEnum->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDev)) || !pDev) - return; - IAudioEndpointVolume* pVol = nullptr; - if (SUCCEEDED(pDev->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, - nullptr, (void**)&pVol)) && pVol) { - g_pEndpointVol = pVol; - if (!g_pVolNotifier) g_pVolNotifier = new VolNotifier(); - g_pEndpointVol->RegisterControlChangeNotify(g_pVolNotifier); - } - pDev->Release(); -} - -static void UnbindEndpointVolume() { - if (g_pEndpointVol && g_pVolNotifier) { - g_pEndpointVol->UnregisterControlChangeNotify(g_pVolNotifier); - } - if (g_pVolNotifier) { g_pVolNotifier->Release(); g_pVolNotifier = nullptr; } - if (g_pEndpointVol) { g_pEndpointVol->Release(); g_pEndpointVol = nullptr; } -} - // Scroll-to-swap uses Raw Input (RegisterRawInputDevices + WM_INPUT in TrayWndProc) // instead of WH_MOUSE_LL. Raw Input is delivered outside the hook chain, so no // other WH_MOUSE_LL hook (e.g., Monitor Sleep Button) can block it. @@ -1633,21 +1536,8 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { WCHAR currentDev[256] = L"Unknown Device"; WCHAR currentId[512] = {}; - int currentVolume = -1; // -1 sentinel = query failed; omit from tooltip - - // Fast path: read scalar + dB from the cached endpoint pointer without re-doing - // CoCreateInstance + Activate. Falls back to the slow per-call path if the - // tray thread hasn't bound an endpoint yet (e.g., startup race). - if (g_pEndpointVol) { - float scalar = 0.0f; - if (SUCCEEDED(g_pEndpointVol->GetMasterVolumeLevelScalar(&scalar))) { - currentVolume = (int)(scalar * 100.0f + 0.5f); - if (currentVolume < 0) currentVolume = 0; - if (currentVolume > 100) currentVolume = 100; - } - } + int currentVolume = -1; // -1 sentinel = query failed; omit from tooltip - // Slow path: still needed for the device name + ID. Always run this block. IMMDeviceEnumerator* pEnum = nullptr; if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { @@ -1666,19 +1556,16 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { PropVariantClear(&v); pStore->Release(); } - // Fallback: if the cached endpoint wasn't bound yet, query volume inline. - if (currentVolume < 0) { - IAudioEndpointVolume* pVol = nullptr; - if (SUCCEEDED(pDev->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, - nullptr, (void**)&pVol))) { - float scalar = 0.0f; - if (SUCCEEDED(pVol->GetMasterVolumeLevelScalar(&scalar))) { - currentVolume = (int)(scalar * 100.0f + 0.5f); - if (currentVolume < 0) currentVolume = 0; - if (currentVolume > 100) currentVolume = 100; - } - pVol->Release(); + IAudioEndpointVolume* pVol = nullptr; + if (SUCCEEDED(pDev->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, + nullptr, (void**)&pVol))) { + float scalar = 0.0f; + if (SUCCEEDED(pVol->GetMasterVolumeLevelScalar(&scalar))) { + currentVolume = (int)(scalar * 100.0f + 0.5f); + if (currentVolume < 0) currentVolume = 0; + if (currentVolume > 100) currentVolume = 100; } + pVol->Release(); } pDev->Release(); } @@ -1725,13 +1612,11 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { if (configuredCount < 2) swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"AudioSwap: Right-click to configure"); else if (localMuted && currentVolume >= 0) - swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Audio: %.80s (%d%%, Muted)", - currentDev, currentVolume); + swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Audio: %.90s (%d%%, Muted)", currentDev, currentVolume); else if (localMuted) swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Audio: %.100s (Muted)", currentDev); else if (currentVolume >= 0) - swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Audio: %.90s (%d%%)", - currentDev, currentVolume); + swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Audio: %.100s (%d%%)", currentDev, currentVolume); else swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Audio: %.110s", currentDev); @@ -1753,58 +1638,6 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { RefreshTrayIconRect(); } -// ─── Volume helpers ─────────────────────────────────────────────────────────── - -static int GetCurrentVolumePct() { - int result = -1; - HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - if (FAILED(hr) && hr != S_FALSE) return result; - IMMDeviceEnumerator* pEnum = nullptr; - if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, - __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { - IMMDevice* pDev = nullptr; - if (SUCCEEDED(pEnum->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDev))) { - IAudioEndpointVolume* pVol = nullptr; - if (SUCCEEDED(pDev->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, - nullptr, (void**)&pVol))) { - float scalar = 0.0f; - if (SUCCEEDED(pVol->GetMasterVolumeLevelScalar(&scalar))) { - result = (int)(scalar * 100.0f + 0.5f); - if (result < 0) result = 0; - if (result > 100) result = 100; - } - pVol->Release(); - } - pDev->Release(); - } - pEnum->Release(); - } - CoUninitialize(); - return result; -} - -static void SetCurrentDeviceVolume(float scalar) { - scalar = std::max(0.0f, std::min(1.0f, scalar)); - HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - if (FAILED(hr) && hr != S_FALSE) return; - IMMDeviceEnumerator* pEnum = nullptr; - if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, - __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { - IMMDevice* pDev = nullptr; - if (SUCCEEDED(pEnum->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDev))) { - IAudioEndpointVolume* pVol = nullptr; - if (SUCCEEDED(pDev->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, - nullptr, (void**)&pVol))) { - pVol->SetMasterVolumeLevelScalar(scalar, nullptr); - pVol->Release(); - } - pDev->Release(); - } - pEnum->Release(); - } - CoUninitialize(); -} - // ─── Audio cycling ──────────────────────────────────────────────────────────── BOOL CycleAudioDevice(int direction) { @@ -1908,8 +1741,7 @@ static void ApplyContextMenuTheme(HWND hWnd, bool dark) { void BuildAndShowContextMenu(HWND hWnd) { HMENU hMenu = CreatePopupMenu(); - AppendMenuW(hMenu, MF_STRING, MENU_OPEN_SETTINGS, L"Mod Settings..."); - AppendMenuW(hMenu, MF_STRING, MENU_SOUND_SETTINGS, L"Playback Settings..."); + AppendMenuW(hMenu, MF_STRING, MENU_OPEN_SETTINGS, L"Mod Settings..."); AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); MENUITEMINFOW miiWH = {sizeof(miiWH)}; @@ -1935,8 +1767,6 @@ void BuildAndShowContextMenu(HWND hWnd) { if (g_guiThread) CloseHandle(g_guiThread); g_guiThread = h; } - } else if (cmd == MENU_SOUND_SETTINGS) { - ShellExecuteW(nullptr, L"open", L"control.exe", L"mmsys.cpl,,0", nullptr, SW_SHOWNORMAL); } else if (cmd == MENU_OPEN_WINDHAWK) { SHELLEXECUTEINFOW sei = {sizeof(sei)}; sei.lpFile = g_windhawkPath; @@ -1945,338 +1775,6 @@ void BuildAndShowContextMenu(HWND hWnd) { } } -// ─── Volume slider popup ────────────────────────────────────────────────────── - -namespace VolumePopup { - -static constexpr WCHAR kClass[] = L"AudioSwapVolumePopup"; -static constexpr WCHAR kLabel[] = L"Output Volume"; -static constexpr int kW = 240; -static constexpr int kH = 72; -static constexpr int kPadX = 16; // left/right padding -static constexpr int kTitleY = 12; // title row top Y -static constexpr int kTitleH = 18; // title row height -static constexpr int kTrackY = 52; // track center Y -static constexpr int kTrackH = 6; // track height in px -static constexpr int kThumbR = 9; // thumb radius (normal) -static constexpr int kThumbRHov = 10; // thumb radius (hover / drag) -static constexpr UINT kVolTimerId = 102; // throttled audio commit -static constexpr COLORREF kBg = RGB(28, 28, 28 ); -static constexpr COLORREF kBorder = RGB(50, 50, 50 ); -static constexpr COLORREF kTrackBg = RGB(58, 58, 58 ); -static constexpr COLORREF kTrackFill = RGB(0, 120, 215); -static constexpr COLORREF kThumbCol = RGB(255, 255, 255); -static constexpr COLORREF kTextPri = RGB(235, 235, 235); -static constexpr COLORREF kTextSec = RGB(130, 130, 130); - -static HWND g_hwnd = nullptr; -static bool g_volumeDirty = false; -static HWND g_trayWnd = nullptr; -static int g_value = 0; -static bool g_dragging = false; -static bool g_hover = false; -static HFONT g_font = nullptr; - -static int ValueToThumbX() { - return kPadX + (g_value * (kW - 2 * kPadX)) / 100; -} - -static int XToValue(int x) { - int v = ((x - kPadX) * 100 + (kW - 2 * kPadX) / 2) / (kW - 2 * kPadX); - return v < 0 ? 0 : (v > 100 ? 100 : v); -} - -static bool IsOverThumb(int mx, int my) { - int dx = mx - ValueToThumbX(), dy = my - kTrackY; - int r = kThumbRHov + 4; - return dx * dx + dy * dy <= r * r; -} - -static void ApplyVolume() { - float scalar = g_value / 100.0f; - if (g_pEndpointVol) { - g_pEndpointVol->SetMasterVolumeLevelScalar(scalar, &kVolumeChangeCtx); - } else { - SetCurrentDeviceVolume(scalar); - } -} - -static LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { - switch (msg) { - - case WM_CREATE: { - int vol = (int)(INT_PTR)((CREATESTRUCTW*)lParam)->lpCreateParams; - g_value = vol < 0 ? 0 : (vol > 100 ? 100 : vol); - g_dragging = false; - g_hover = false; - g_font = CreateFontW(-14, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, - DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, - CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_DONTCARE, L"Segoe UI"); - // Win11 rounded corners (DWMWA_WINDOW_CORNER_PREFERENCE=33, DWMWCP_ROUNDSMALL=3). - // GetModuleHandleW — dwmapi is already loaded; LoadLibraryW would leak a refcount. - { - using PFN = HRESULT(WINAPI*)(HWND, DWORD, LPCVOID, DWORD); - HMODULE hDwm = GetModuleHandleW(L"dwmapi.dll"); - if (hDwm) { - if (auto pfn = (PFN)GetProcAddress(hDwm, "DwmSetWindowAttribute")) { - UINT pref = 3; - pfn(hWnd, 33, &pref, sizeof(pref)); - } - } - } - SetTimer(hWnd, kVolTimerId, 16, nullptr); - return 0; - } - - case WM_ERASEBKGND: - return 1; // suppress default erase — WM_PAINT covers the full client rect - - case WM_PAINT: { - PAINTSTRUCT ps; - HDC hdc = BeginPaint(hWnd, &ps); - - // Double-buffer to eliminate flicker - HDC hMem = CreateCompatibleDC(hdc); - HBITMAP hBmp = CreateCompatibleBitmap(hdc, kW, kH); - auto hOldBmp = (HBITMAP)SelectObject(hMem, hBmp); - - // ── Background ──────────────────────────────────────────────── - RECT rcAll = {0, 0, kW, kH}; - HBRUSH hbBg = CreateSolidBrush(kBg); - FillRect(hMem, &rcAll, hbBg); - DeleteObject(hbBg); - - // Border (1 px) - HBRUSH hbBdr = CreateSolidBrush(kBorder); - FrameRect(hMem, &rcAll, hbBdr); - DeleteObject(hbBdr); - - // ── Title row ───────────────────────────────────────────────── - HFONT hOldFont = (HFONT)SelectObject(hMem, g_font ? g_font - : GetStockObject(DEFAULT_GUI_FONT)); - SetBkMode(hMem, TRANSPARENT); - - RECT rcL = {kPadX, kTitleY, kW - kPadX, kTitleY + kTitleH}; - SetTextColor(hMem, kTextSec); - DrawTextW(hMem, kLabel, -1, &rcL, - DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); - - WCHAR valBuf[8]; - swprintf_s(valBuf, ARRAYSIZE(valBuf), L"%d%%", g_value); - RECT rcR = {kPadX, kTitleY, kW - kPadX, kTitleY + kTitleH}; - SetTextColor(hMem, kTextPri); - DrawTextW(hMem, valBuf, -1, &rcR, - DT_RIGHT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); - - SelectObject(hMem, hOldFont); - - // ── Track ───────────────────────────────────────────────────── - int tLeft = kPadX; - int tRight = kW - kPadX; - int tTop = kTrackY - kTrackH / 2; - int tBot = kTrackY + kTrackH / 2; - int tX = ValueToThumbX(); - - SelectObject(hMem, GetStockObject(NULL_PEN)); - - // Unfilled portion - HBRUSH hbTrack = CreateSolidBrush(kTrackBg); - SelectObject(hMem, hbTrack); - RoundRect(hMem, tLeft, tTop, tRight, tBot, kTrackH, kTrackH); - DeleteObject(hbTrack); - - // Accent-filled (left of thumb) - if (tX > tLeft) { - HBRUSH hbFill = CreateSolidBrush(kTrackFill); - SelectObject(hMem, hbFill); - RoundRect(hMem, tLeft, tTop, tX, tBot, kTrackH, kTrackH); - DeleteObject(hbFill); - } - - // Thumb - int r = (g_dragging || g_hover) ? kThumbRHov : kThumbR; - HBRUSH hbThumb = CreateSolidBrush(kThumbCol); - SelectObject(hMem, hbThumb); - Ellipse(hMem, tX - r, kTrackY - r, tX + r, kTrackY + r); - DeleteObject(hbThumb); - - // ── Blit ────────────────────────────────────────────────────── - BitBlt(hdc, 0, 0, kW, kH, hMem, 0, 0, SRCCOPY); - SelectObject(hMem, hOldBmp); - DeleteObject(hBmp); - DeleteDC(hMem); - - EndPaint(hWnd, &ps); - return 0; - } - - case WM_SETCURSOR: { - POINT pt; - GetCursorPos(&pt); - ScreenToClient(hWnd, &pt); - bool overTrack = (pt.y >= kTrackY - kThumbRHov * 2 && - pt.y <= kTrackY + kThumbRHov * 2 && - pt.x >= kPadX && pt.x <= kW - kPadX); - SetCursor(LoadCursor(nullptr, overTrack ? IDC_HAND : IDC_ARROW)); - return TRUE; - } - - case WM_LBUTTONDOWN: { - int mx = (short)LOWORD(lParam), my = (short)HIWORD(lParam); - bool onTrack = (my >= kTrackY - kThumbRHov * 2 && - my <= kTrackY + kThumbRHov * 2 && - mx >= kPadX && mx <= kW - kPadX); - if (onTrack || IsOverThumb(mx, my)) { - g_value = XToValue(mx); - g_dragging = true; - g_hover = true; - SetCapture(hWnd); - ApplyVolume(); - InvalidateRect(hWnd, nullptr, FALSE); - } - return 0; - } - - case WM_MOUSEMOVE: { - int mx = (short)LOWORD(lParam), my = (short)HIWORD(lParam); - // Re-register for WM_MOUSELEAVE each time (TME_LEAVE is one-shot) - TRACKMOUSEEVENT tme = {sizeof(tme), TME_LEAVE, hWnd, 0}; - TrackMouseEvent(&tme); - if (g_dragging) { - int newVal = XToValue(mx); - if (newVal != g_value) { - g_value = newVal; - g_volumeDirty = true; // throttled — kVolTimerId commits to audio - InvalidateRect(hWnd, nullptr, FALSE); - } - } else { - bool wasHover = g_hover; - g_hover = IsOverThumb(mx, my); - if (g_hover != wasHover) - InvalidateRect(hWnd, nullptr, FALSE); - } - return 0; - } - - case WM_LBUTTONUP: - if (g_dragging) { - g_dragging = false; - ReleaseCapture(); - g_value = XToValue((short)LOWORD(lParam)); - g_volumeDirty = false; - ApplyVolume(); // force-commit final drag position - InvalidateRect(hWnd, nullptr, FALSE); - } - return 0; - - case WM_MOUSELEAVE: - if (!g_dragging && g_hover) { - g_hover = false; - InvalidateRect(hWnd, nullptr, FALSE); - } - return 0; - - case WM_MOUSEWHEEL: { - int step = GET_WHEEL_DELTA_WPARAM(wParam) > 0 ? 3 : -3; - int newVal = std::max(0, std::min(100, g_value + step)); - if (newVal != g_value) { - g_value = newVal; - ApplyVolume(); - InvalidateRect(hWnd, nullptr, FALSE); - } - return 0; - } - - case WM_ACTIVATE: - if (LOWORD(wParam) == WA_INACTIVE) - DestroyWindow(hWnd); - return 0; - - case WM_TIMER: - if (wParam == kVolTimerId) { - if (g_volumeDirty) { g_volumeDirty = false; ApplyVolume(); } - } - return 0; - - case WM_KEYDOWN: { - int v = g_value; - switch (wParam) { - case VK_LEFT: v = std::max(0, v - 1); break; - case VK_RIGHT: v = std::min(100, v + 1); break; - case VK_HOME: v = 0; break; - case VK_END: v = 100; break; - case VK_ESCAPE: - DestroyWindow(hWnd); - return 0; - default: return 0; - } - if (v != g_value) { - g_value = v; - ApplyVolume(); - InvalidateRect(hWnd, nullptr, FALSE); - } - return 0; - } - - case WM_DESTROY: - KillTimer(hWnd, kVolTimerId); - if (g_volumeDirty) { g_volumeDirty = false; ApplyVolume(); } - if (g_font) { DeleteObject(g_font); g_font = nullptr; } - g_hwnd = nullptr; - g_trayWnd = nullptr; - g_dragging = false; - g_hover = false; - return 0; - - } // switch - return DefWindowProcW(hWnd, msg, wParam, lParam); -} - -static void RegisterClass() { - WNDCLASSEXW wc = {sizeof(wc)}; - wc.lpfnWndProc = WndProc; - wc.hInstance = g_hInstance; - wc.lpszClassName = kClass; - wc.hCursor = LoadCursor(nullptr, IDC_ARROW); - RegisterClassExW(&wc); -} - -static void UnregisterClass() { - ::UnregisterClassW(kClass, g_hInstance); -} - -static void Show(HWND hTrayWnd, int volPct) { - if (g_hwnd) { SetForegroundWindow(g_hwnd); return; } - - int x = (g_trayIconRect.left + g_trayIconRect.right) / 2 - kW / 2; - int y = g_trayIconRect.top - kH - 8; - - POINT pt = { (g_trayIconRect.left + g_trayIconRect.right) / 2, g_trayIconRect.top }; - HMONITOR hm = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST); - MONITORINFO mi = { sizeof(mi) }; - if (GetMonitorInfo(hm, &mi)) { - RECT& wa = mi.rcWork; - if (x < wa.left) x = wa.left; - if (x + kW > wa.right) x = wa.right - kW; - if (y < wa.top) y = g_trayIconRect.bottom + 8; - } - - g_trayWnd = hTrayWnd; - g_hwnd = CreateWindowExW( - WS_EX_TOOLWINDOW | WS_EX_TOPMOST, - kClass, nullptr, - WS_POPUP, - x, y, kW, kH, - hTrayWnd, nullptr, g_hInstance, (LPVOID)(INT_PTR)volPct); - - if (g_hwnd) { - ShowWindow(g_hwnd, SW_SHOWNOACTIVATE); - SetForegroundWindow(g_hwnd); - } -} - -} // namespace VolumePopup - // ─── Worker thread ──────────────────────────────────────────────────────────── DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { @@ -2403,34 +1901,25 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) } InterlockedExchange(&g_isProcessingClick, 0); } - } else if (event == WM_MBUTTONUP) { - VolumePopup::Show(hWnd, GetCurrentVolumePct()); } } else if (msg == WM_INPUT) { - UINT sz = 0; - GetRawInputData((HRAWINPUT)lParam, RID_INPUT, nullptr, &sz, sizeof(RAWINPUTHEADER)); - if (sz > 0) { - std::vector buf(sz); - if (GetRawInputData((HRAWINPUT)lParam, RID_INPUT, buf.data(), &sz, - sizeof(RAWINPUTHEADER)) == sz) { - auto* raw = reinterpret_cast(buf.data()); - if (raw->header.dwType == RIM_TYPEMOUSE && - (raw->data.mouse.usButtonFlags & RI_MOUSE_WHEEL)) { - POINT pt; GetCursorPos(&pt); - if (PtInRect(&g_trayIconRect, pt)) { - short delta = (short)raw->data.mouse.usButtonData; - int dir = (delta > 0) ? 1 : -1; - if (InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 1) { + if (InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 1) { + UINT sz = 0; + GetRawInputData((HRAWINPUT)lParam, RID_INPUT, nullptr, &sz, sizeof(RAWINPUTHEADER)); + if (sz > 0) { + std::vector buf(sz); + if (GetRawInputData((HRAWINPUT)lParam, RID_INPUT, buf.data(), &sz, + sizeof(RAWINPUTHEADER)) == sz) { + auto* raw = reinterpret_cast(buf.data()); + if (raw->header.dwType == RIM_TYPEMOUSE && + (raw->data.mouse.usButtonFlags & RI_MOUSE_WHEEL)) { + POINT pt; GetCursorPos(&pt); + if (PtInRect(&g_trayIconRect, pt)) { + short delta = (short)raw->data.mouse.usButtonData; + int dir = (delta > 0) ? 1 : -1; PostMessageW(hWnd, WM_TRAY_SCROLL, (WPARAM)(LONG_PTR)dir, 0); - } else if (g_pEndpointVol) { - float scalar = 0.0f; - if (SUCCEEDED(g_pEndpointVol->GetMasterVolumeLevelScalar(&scalar))) { - scalar = std::max(0.0f, std::min(1.0f, scalar + dir * 0.02f)); - g_pEndpointVol->SetMasterVolumeLevelScalar(scalar, nullptr); - PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); - } } } } @@ -2459,8 +1948,14 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) UpdateTrayTip(hWnd, FALSE); } else if (msg == WM_UPDATE_HOOK_STATE) { - RAWINPUTDEVICE rid = { 1, 2, RIDEV_INPUTSINK, hWnd }; - RegisterRawInputDevices(&rid, 1, sizeof(rid)); + LONG isScroll = InterlockedCompareExchange(&g_scrollToSwap, 0, 0); + if (isScroll) { + RAWINPUTDEVICE rid = { 1, 2, RIDEV_INPUTSINK, hWnd }; + RegisterRawInputDevices(&rid, 1, sizeof(rid)); + } else { + RAWINPUTDEVICE rid = { 1, 2, RIDEV_REMOVE, nullptr }; + RegisterRawInputDevices(&rid, 1, sizeof(rid)); + } } else if (msg == WM_RELOAD_ICONS) { // Run icon reload on tray thread to eliminate cross-thread handle race. @@ -2520,11 +2015,6 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) UpdateTrayTip(hWnd, TRUE); // re-add icon after explorer restart RefreshTrayIconRect(); // re-acquire icon screen rect after explorer restart - } else if (msg == WM_REBIND_VOLUME_CALLBACK) { - // Default device changed — re-bind to new endpoint. - UnbindEndpointVolume(); - BindEndpointVolume(); - } else if (msg == WM_RELOAD_ALL) { // Full reload triggered by the settings dashboard after Save and Apply. // Picks up new device assignments, icon choices, device count, swap mode, and priority list. @@ -2543,7 +2033,6 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) nid.uFlags = NIF_GUID; nid.guidItem = AUDIOSWAP_TRAY_GUID; Shell_NotifyIconW(NIM_DELETE, &nid); - if (VolumePopup::g_hwnd) DestroyWindow(VolumePopup::g_hwnd); DestroyWindow(hWnd); return 0; @@ -2565,7 +2054,6 @@ DWORD WINAPI TrayThreadProc(LPVOID) { wc.hInstance = g_hInstance; wc.lpszClassName = L"AudioSwitcherWindowClass"; RegisterClassW(&wc); - VolumePopup::RegisterClass(); // WS_EX_TOOLWINDOW: hidden popup window that Shell_NotifyIconW can track properly. // AudioSwap used HWND_MESSAGE originally, but message-only windows can lose their @@ -2588,11 +2076,10 @@ DWORD WINAPI TrayThreadProc(LPVOID) { __uuidof(IMMDeviceEnumerator), (void**)&g_notifEnum))) { g_deviceNotifier = new AudioDeviceNotifier(); g_notifEnum->RegisterEndpointNotificationCallback(g_deviceNotifier); - BindEndpointVolume(); } - // Register Raw Input for scroll over the tray icon (bypasses WH_MOUSE_LL hook chain). - // Always active: scroll-to-swap mode cycles devices; click mode adjusts volume. - { + + // Register Raw Input for scroll-to-swap (bypasses WH_MOUSE_LL hook chain). + if (InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 1) { RAWINPUTDEVICE rid = { 1, 2, RIDEV_INPUTSINK, g_trayHwnd }; RegisterRawInputDevices(&rid, 1, sizeof(rid)); } @@ -2602,9 +2089,6 @@ DWORD WINAPI TrayThreadProc(LPVOID) { MSG msg; while (GetMessageW(&msg, nullptr, 0, 0) > 0) { DispatchMessageW(&msg); } - // Unbind volume callback BEFORE unregistering the device-enumerator notifications, - // so OnNotify cannot fire against a half-torn-down object. - UnbindEndpointVolume(); // Unregister notifications before releasing the enumerator. if (g_notifEnum && g_deviceNotifier) { g_notifEnum->UnregisterEndpointNotificationCallback(g_deviceNotifier); @@ -2616,7 +2100,6 @@ DWORD WINAPI TrayThreadProc(LPVOID) { RAWINPUTDEVICE rid = { 1, 2, RIDEV_REMOVE, nullptr }; RegisterRawInputDevices(&rid, 1, sizeof(rid)); } - VolumePopup::UnregisterClass(); CoUninitialize(); return 0; } @@ -2627,23 +2110,6 @@ BOOL WhTool_ModInit() { Wh_Log(L"AudioSwap Mod Init"); InitializeCriticalSection(&g_stateLock); - // Enable dark mode app-wide via uxtheme ordinal 135 (SetPreferredAppMode), with a - // fall-back to ordinal 132 (AllowDarkModeForApp) on Win10 builds pre-1903. Graceful - // no-op when neither ordinal exists. Affects context menus, dialogs, and STATIC - // controls in the dashboard. - { - HMODULE ux = GetModuleHandleW(L"uxtheme.dll"); - if (ux) { - using FnSetMode = void(WINAPI*)(int); - using FnAllow = bool(WINAPI*)(bool); - if (auto f = (FnSetMode)GetProcAddress(ux, MAKEINTRESOURCEA(135))) { - f(1); // PreferredAppMode::AllowDark - } else if (auto f = (FnAllow)GetProcAddress(ux, MAKEINTRESOURCEA(132))) { - f(true); - } - } - } - g_hInstance = GetModuleHandleW(nullptr); switch (GetModuleFileNameW(nullptr, g_windhawkPath, ARRAYSIZE(g_windhawkPath))) { case 0: @@ -2714,12 +2180,8 @@ void WhTool_ModUninit() { HWND hwnd = g_trayHwnd; if (hwnd) PostMessageW(hwnd, WM_CLOSE, 0, 0); if (g_trayThread) { - DWORD wr = WaitForSingleObject(g_trayThread, 3000); - if (wr == WAIT_TIMEOUT) { - Wh_Log(L"AudioSwap: tray thread did not exit within 3 s — leaking handle to avoid race"); - } else { - CloseHandle(g_trayThread); - } + WaitForSingleObject(g_trayThread, 3000); + CloseHandle(g_trayThread); g_trayThread = nullptr; } if (g_guiThread) { @@ -2728,21 +2190,13 @@ void WhTool_ModUninit() { // exiting the loop regardless of IsDialogMessageW. DWORD guiId = GetThreadId(g_guiThread); if (guiId) PostThreadMessageW(guiId, WM_QUIT, 0, 0); - DWORD wr = WaitForSingleObject(g_guiThread, 2000); - if (wr == WAIT_TIMEOUT) { - Wh_Log(L"AudioSwap: GUI thread did not exit within 2 s — leaking handle to avoid race"); - } else { - CloseHandle(g_guiThread); - } + WaitForSingleObject(g_guiThread, 2000); + CloseHandle(g_guiThread); g_guiThread = nullptr; } if (g_workerThread) { - DWORD wr = WaitForSingleObject(g_workerThread, 2000); - if (wr == WAIT_TIMEOUT) { - Wh_Log(L"AudioSwap: worker thread did not exit within 2 s — leaking handle to avoid race"); - } else { - CloseHandle(g_workerThread); - } + WaitForSingleObject(g_workerThread, 2000); + CloseHandle(g_workerThread); g_workerThread = nullptr; } if (g_notifEnum && g_deviceNotifier) { From ace42fc9970ca4877113c79127fb44df5306701a Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Fri, 29 May 2026 17:17:06 +0300 Subject: [PATCH 39/56] re-synced with released version for PR request --- mods/audioswap.wh.cpp | 31 +++++-------------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index 43afb04ea0..92a44ac25c 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -1519,7 +1519,6 @@ static HICON CreateMutedOverlayIcon(HICON hBase) { static WCHAR s_lastDev[256] = {}; static HICON s_lastIcon = nullptr; static bool s_lastMuted = false; -static int s_lastVolume = -1; void UpdateTrayTip(HWND hWnd, BOOL isAdd) { // Snapshot shared state under lock (no COM inside the lock). @@ -1536,7 +1535,6 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { WCHAR currentDev[256] = L"Unknown Device"; WCHAR currentId[512] = {}; - int currentVolume = -1; // -1 sentinel = query failed; omit from tooltip IMMDeviceEnumerator* pEnum = nullptr; if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, @@ -1556,17 +1554,6 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { PropVariantClear(&v); pStore->Release(); } - IAudioEndpointVolume* pVol = nullptr; - if (SUCCEEDED(pDev->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, - nullptr, (void**)&pVol))) { - float scalar = 0.0f; - if (SUCCEEDED(pVol->GetMasterVolumeLevelScalar(&scalar))) { - currentVolume = (int)(scalar * 100.0f + 0.5f); - if (currentVolume < 0) currentVolume = 0; - if (currentVolume > 100) currentVolume = 100; - } - pVol->Release(); - } pDev->Release(); } pEnum->Release(); @@ -1585,38 +1572,30 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { for (int i = 0; i < localSlotCount; i++) if (localDevIds[i][0] != L'\0') configuredCount++; - // s_lastDev / s_lastIcon / s_lastMuted / s_lastVolume are file-scope to allow external reset. + // s_lastDev / s_lastIcon / s_lastMuted are file-scope to allow external reset. if (!isAdd && wcscmp(currentDev, s_lastDev) == 0 && currentIcon == s_lastIcon && - s_lastMuted == localMuted && - s_lastVolume == currentVolume) + s_lastMuted == localMuted) { RefreshTrayIconRect(); return; } lstrcpyW(s_lastDev, currentDev); - s_lastIcon = currentIcon; - s_lastMuted = localMuted; - s_lastVolume = currentVolume; + s_lastIcon = currentIcon; + s_lastMuted = localMuted; NOTIFYICONDATAW nid = {sizeof(nid)}; nid.hWnd = hWnd; nid.uID = TRAY_ICON_ID; - // NIF_SHOWTIP is required under NOTIFYICON_VERSION_4 — without it the shell - // assumes the app draws its own tooltip and the standard one never appears on hover. - nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_SHOWTIP | NIF_ICON | NIF_GUID; + nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_ICON | NIF_GUID; nid.guidItem = AUDIOSWAP_TRAY_GUID; nid.uCallbackMessage = WM_TRAY_CALLBACK; if (configuredCount < 2) swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"AudioSwap: Right-click to configure"); - else if (localMuted && currentVolume >= 0) - swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Audio: %.90s (%d%%, Muted)", currentDev, currentVolume); else if (localMuted) swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Audio: %.100s (Muted)", currentDev); - else if (currentVolume >= 0) - swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Audio: %.100s (%d%%)", currentDev, currentVolume); else swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Audio: %.110s", currentDev); From 28241725ee41144477567feb75ff5f63cb620484 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Sat, 30 May 2026 21:53:08 +0300 Subject: [PATCH 40/56] Clean up microswap.wh.cpp Removed unused comments and cleaned up code. --- mods/microswap.wh.cpp | 3 --- 1 file changed, 3 deletions(-) diff --git a/mods/microswap.wh.cpp b/mods/microswap.wh.cpp index 74d75b806c..8cbaf25a06 100644 --- a/mods/microswap.wh.cpp +++ b/mods/microswap.wh.cpp @@ -92,9 +92,6 @@ Enable **Advanced Mode** in the Windhawk settings panel (gear icon → Settings - advancedMode: false $name: Advanced Mode $description: Shows the Device Priority panel in Mod Settings, letting you rank preferred microphones for automatic slot assignment. -- _info: " " - $name: " " - $description: "Most settings live in the in-tray dashboard. Right-click the MicSwitch tray icon → Mod Settings." */ // ==/WindhawkModSettings== From 4b73ad642d3fa5c6e2437ce306bbd526709c6f40 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Sat, 30 May 2026 22:05:27 +0300 Subject: [PATCH 41/56] Bump version to 2.2.0 and add new features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### v2.2.0 - New: Right-click → **Sound Settings...** opens Windows Sound dialog directly on the Playback tab. - New: Middle-click tray icon → compact volume slider popup with a custom dark design. Drag to adjust output volume live; click outside or press Escape to close. - New: Scroll wheel over the tray icon (Click to Swap mode) adjusts output volume, matching native Windows sound icon behaviour. --- mods/audioswap.wh.cpp | 648 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 606 insertions(+), 42 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index 92a44ac25c..6988a1a7a8 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -2,12 +2,12 @@ // @id audioswap // @name AudioSwap // @description Tray icon to cycle between multiple preferred audio outputs. Supports up to 6 devices with click or scroll to swap. -// @version 2.1.0 +// @version 2.2.0 // @author BlackPaw // @github https://github.com/BlackPaw21 // @donateUrl https://ko-fi.com/blackpaw21 // @include windhawk.exe -// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lcomdlg32 -ladvapi32 +// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lcomdlg32 -ladvapi32 -lcomctl32 // ==/WindhawkMod== // ==WindhawkModReadme== @@ -15,6 +15,8 @@ # AudioSwap Instantly cycle between multiple audio output devices from your system tray — no diving into Sound settings. +> Works great alongside **[MicSwitch](https://windhawk.net/mods/microswap)** — the companion mod that brings the same tray experience to microphone input control. + --- ## How to Use @@ -26,6 +28,11 @@ Instantly cycle between multiple audio output devices from your system tray — 5. **Set Device Count** — Choose how many slots to cycle through (2–6). 6. Click **Save and Apply** — the tray icon updates immediately, no restart needed. +### Volume Control + +- **Scroll wheel** over the tray icon (in Click to Swap mode) adjusts output volume — just like the native Windows sound icon. +- **Middle-click** the tray icon to open a compact volume slider. Drag or use the arrow keys to adjust output volume live — click anywhere outside or press Escape to close. + ### Scroll to Swap mode extras - **Left-click** mutes the current device. Click again to unmute. The tray tooltip shows *(Muted)* and the icon gains a red dot while active. @@ -52,6 +59,11 @@ Enable **Advanced Mode** in the Windhawk settings panel (gear icon → Settings ## Changelog +### v2.2.0 +- New: Right-click → **Sound Settings...** opens Windows Sound dialog directly on the Playback tab. +- New: Middle-click tray icon → compact volume slider popup with a custom dark design. Drag to adjust output volume live; click outside or press Escape to close. +- New: Scroll wheel over the tray icon (Click to Swap mode) adjusts output volume, matching native Windows sound icon behaviour. + ### v2.1.0 - New: Device Priority panel — rank your preferred devices; the highest-priority connected device is assigned to a swap slot automatically. - New: Advanced Mode toggle in Windhawk settings — shows the Device Priority panel in Mod Settings. @@ -108,17 +120,21 @@ Enable **Advanced Mode** in the Windhawk settings panel (gear icon → Settings // The advancedMode flag above uses Wh_GetIntSetting, which reads from Windhawk's // separate YAML store — no conflict with dashboard keys. +#define NOMINMAX #include #include #include #include #include +#include +#include #include #include #include #include #include +#include #include #include @@ -136,11 +152,19 @@ static const GUID AUDIOSWAP_TRAY_GUID = #define WM_RELOAD_ICONS (WM_USER + 6) // reload icons on tray thread (eliminates cross-thread handle race) #define WM_RELOAD_ALL (WM_USER + 7) // full reload after dashboard save #define WM_PRIORITY_DEVICE_ACTIVE (WM_USER + 8) // lParam = heap-alloc'd WCHAR* device ID +#define WM_REBIND_VOLUME_CALLBACK (WM_USER + 9) // rebind IAudioEndpointVolumeCallback after default-device change #define TRAY_RECT_INIT_TIMER 99 // one-shot retry timer for Shell_NotifyIconGetRect +// Sentinel context GUID — distinguishes volume changes triggered by our own +// VolumePopup slider from external (volume keys / Action Center / other apps) +// when IAudioEndpointVolumeCallback::OnNotify fires. +static const GUID kVolumeChangeCtx = + {0x9F2A1D8E, 0xC7B4, 0x4E63, {0x8A, 0x1F, 0x2D, 0x5B, 0x6E, 0x7C, 0x8D, 0x9F}}; + #define MENU_OPEN_SETTINGS 9001 #define MENU_OPEN_WINDHAWK 9000 #define IDC_BTN_KOFI 9002 +#define MENU_SOUND_SETTINGS 9003 // With NOTIFYICON_VERSION_4 active Windows changes the tray notifications: // left-click → NIN_SELECT (instead of / alongside WM_LBUTTONUP) @@ -204,8 +228,11 @@ static volatile LONG g_scrollToSwap = 0; // 1 = scroll mode // IMMNotificationClient registration class AudioDeviceNotifier; -static IMMDeviceEnumerator* g_notifEnum = nullptr; -static AudioDeviceNotifier* g_deviceNotifier = nullptr; +class VolNotifier; +static IMMDeviceEnumerator* g_notifEnum = nullptr; +static AudioDeviceNotifier* g_deviceNotifier = nullptr; +static IAudioEndpointVolume* g_pEndpointVol = nullptr; // tray-thread owned; current default endpoint +static VolNotifier* g_pVolNotifier = nullptr; // registered on g_pEndpointVol // Dashboard GUI thread — written only from the tray thread (BuildAndShowContextMenu) // and read only from the main thread (WhTool_ModUninit), which runs after the @@ -1376,7 +1403,13 @@ class AudioDeviceNotifier : public IMMNotificationClient { if (flow == eRender && role == eMultimedia) { HWND hwnd = (HWND)InterlockedCompareExchangePointer( (volatile PVOID*)&g_trayHwnd, nullptr, nullptr); - if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + if (hwnd) { + // Rebind the IAudioEndpointVolumeCallback before refreshing the + // tooltip — the live dB readout depends on tracking the *current* + // default endpoint, not the previous one. + PostMessageW(hwnd, WM_REBIND_VOLUME_CALLBACK, 0, 0); + PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + } } return S_OK; } @@ -1420,6 +1453,67 @@ class AudioDeviceNotifier : public IMMNotificationClient { LPCWSTR, const PROPERTYKEY) override { return S_OK; } }; +// IAudioEndpointVolumeCallback — fires on volume / mute changes from ANY source +// (volume keys, slider popup, Action Center, other apps). Posts WM_UPDATE_TRAY_STATE +// to refresh the tooltip's live dB readout. Self-triggered changes (slider popup) +// carry the kVolumeChangeCtx sentinel; we still refresh on those — the tooltip +// should reflect the new value regardless of who moved the slider. +class VolNotifier : public IAudioEndpointVolumeCallback { + volatile LONG m_ref; +public: + VolNotifier() : m_ref(1) {} + virtual ~VolNotifier() = default; + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override { + if (riid == __uuidof(IUnknown) || riid == __uuidof(IAudioEndpointVolumeCallback)) { + *ppv = static_cast(this); + AddRef(); return S_OK; + } + *ppv = nullptr; return E_NOINTERFACE; + } + ULONG STDMETHODCALLTYPE AddRef() override { + return static_cast(InterlockedIncrement(&m_ref)); + } + ULONG STDMETHODCALLTYPE Release() override { + LONG r = InterlockedDecrement(&m_ref); + if (r == 0) delete this; + return static_cast(r); + } + + HRESULT STDMETHODCALLTYPE OnNotify(PAUDIO_VOLUME_NOTIFICATION_DATA) override { + HWND hwnd = (HWND)InterlockedCompareExchangePointer( + (volatile PVOID*)&g_trayHwnd, nullptr, nullptr); + if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + return S_OK; + } +}; + +// Acquire IAudioEndpointVolume on the current default render endpoint and register +// VolNotifier. Idempotent — caller is responsible for first releasing the previous +// binding via UnbindEndpointVolume(). Tray-thread only. +static void BindEndpointVolume() { + if (!g_notifEnum) return; + IMMDevice* pDev = nullptr; + if (FAILED(g_notifEnum->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDev)) || !pDev) + return; + IAudioEndpointVolume* pVol = nullptr; + if (SUCCEEDED(pDev->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, + nullptr, (void**)&pVol)) && pVol) { + g_pEndpointVol = pVol; + if (!g_pVolNotifier) g_pVolNotifier = new VolNotifier(); + g_pEndpointVol->RegisterControlChangeNotify(g_pVolNotifier); + } + pDev->Release(); +} + +static void UnbindEndpointVolume() { + if (g_pEndpointVol && g_pVolNotifier) { + g_pEndpointVol->UnregisterControlChangeNotify(g_pVolNotifier); + } + if (g_pVolNotifier) { g_pVolNotifier->Release(); g_pVolNotifier = nullptr; } + if (g_pEndpointVol) { g_pEndpointVol->Release(); g_pEndpointVol = nullptr; } +} + // Scroll-to-swap uses Raw Input (RegisterRawInputDevices + WM_INPUT in TrayWndProc) // instead of WH_MOUSE_LL. Raw Input is delivered outside the hook chain, so no // other WH_MOUSE_LL hook (e.g., Monitor Sleep Button) can block it. @@ -1519,6 +1613,7 @@ static HICON CreateMutedOverlayIcon(HICON hBase) { static WCHAR s_lastDev[256] = {}; static HICON s_lastIcon = nullptr; static bool s_lastMuted = false; +static int s_lastVolume = -1; void UpdateTrayTip(HWND hWnd, BOOL isAdd) { // Snapshot shared state under lock (no COM inside the lock). @@ -1535,7 +1630,21 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { WCHAR currentDev[256] = L"Unknown Device"; WCHAR currentId[512] = {}; + int currentVolume = -1; // -1 sentinel = query failed; omit from tooltip + + // Fast path: read scalar + dB from the cached endpoint pointer without re-doing + // CoCreateInstance + Activate. Falls back to the slow per-call path if the + // tray thread hasn't bound an endpoint yet (e.g., startup race). + if (g_pEndpointVol) { + float scalar = 0.0f; + if (SUCCEEDED(g_pEndpointVol->GetMasterVolumeLevelScalar(&scalar))) { + currentVolume = (int)(scalar * 100.0f + 0.5f); + if (currentVolume < 0) currentVolume = 0; + if (currentVolume > 100) currentVolume = 100; + } + } + // Slow path: still needed for the device name + ID. Always run this block. IMMDeviceEnumerator* pEnum = nullptr; if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { @@ -1554,6 +1663,20 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { PropVariantClear(&v); pStore->Release(); } + // Fallback: if the cached endpoint wasn't bound yet, query volume inline. + if (currentVolume < 0) { + IAudioEndpointVolume* pVol = nullptr; + if (SUCCEEDED(pDev->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, + nullptr, (void**)&pVol))) { + float scalar = 0.0f; + if (SUCCEEDED(pVol->GetMasterVolumeLevelScalar(&scalar))) { + currentVolume = (int)(scalar * 100.0f + 0.5f); + if (currentVolume < 0) currentVolume = 0; + if (currentVolume > 100) currentVolume = 100; + } + pVol->Release(); + } + } pDev->Release(); } pEnum->Release(); @@ -1572,30 +1695,40 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { for (int i = 0; i < localSlotCount; i++) if (localDevIds[i][0] != L'\0') configuredCount++; - // s_lastDev / s_lastIcon / s_lastMuted are file-scope to allow external reset. + // s_lastDev / s_lastIcon / s_lastMuted / s_lastVolume are file-scope to allow external reset. if (!isAdd && wcscmp(currentDev, s_lastDev) == 0 && currentIcon == s_lastIcon && - s_lastMuted == localMuted) + s_lastMuted == localMuted && + s_lastVolume == currentVolume) { RefreshTrayIconRect(); return; } lstrcpyW(s_lastDev, currentDev); - s_lastIcon = currentIcon; - s_lastMuted = localMuted; + s_lastIcon = currentIcon; + s_lastMuted = localMuted; + s_lastVolume = currentVolume; NOTIFYICONDATAW nid = {sizeof(nid)}; nid.hWnd = hWnd; nid.uID = TRAY_ICON_ID; - nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_ICON | NIF_GUID; + // NIF_SHOWTIP is required under NOTIFYICON_VERSION_4 — without it the shell + // assumes the app draws its own tooltip and the standard one never appears on hover. + nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_SHOWTIP | NIF_ICON | NIF_GUID; nid.guidItem = AUDIOSWAP_TRAY_GUID; nid.uCallbackMessage = WM_TRAY_CALLBACK; if (configuredCount < 2) swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"AudioSwap: Right-click to configure"); + else if (localMuted && currentVolume >= 0) + swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Audio: %.80s (%d%%, Muted)", + currentDev, currentVolume); else if (localMuted) swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Audio: %.100s (Muted)", currentDev); + else if (currentVolume >= 0) + swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Audio: %.90s (%d%%)", + currentDev, currentVolume); else swprintf_s(nid.szTip, ARRAYSIZE(nid.szTip), L"Audio: %.110s", currentDev); @@ -1617,6 +1750,58 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { RefreshTrayIconRect(); } +// ─── Volume helpers ─────────────────────────────────────────────────────────── + +static int GetCurrentVolumePct() { + int result = -1; + HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + if (FAILED(hr) && hr != S_FALSE) return result; + IMMDeviceEnumerator* pEnum = nullptr; + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { + IMMDevice* pDev = nullptr; + if (SUCCEEDED(pEnum->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDev))) { + IAudioEndpointVolume* pVol = nullptr; + if (SUCCEEDED(pDev->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, + nullptr, (void**)&pVol))) { + float scalar = 0.0f; + if (SUCCEEDED(pVol->GetMasterVolumeLevelScalar(&scalar))) { + result = (int)(scalar * 100.0f + 0.5f); + if (result < 0) result = 0; + if (result > 100) result = 100; + } + pVol->Release(); + } + pDev->Release(); + } + pEnum->Release(); + } + CoUninitialize(); + return result; +} + +static void SetCurrentDeviceVolume(float scalar) { + scalar = std::max(0.0f, std::min(1.0f, scalar)); + HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + if (FAILED(hr) && hr != S_FALSE) return; + IMMDeviceEnumerator* pEnum = nullptr; + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { + IMMDevice* pDev = nullptr; + if (SUCCEEDED(pEnum->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDev))) { + IAudioEndpointVolume* pVol = nullptr; + if (SUCCEEDED(pDev->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, + nullptr, (void**)&pVol))) { + pVol->SetMasterVolumeLevelScalar(scalar, nullptr); + pVol->Release(); + } + pDev->Release(); + } + pEnum->Release(); + } + CoUninitialize(); +} + // ─── Audio cycling ──────────────────────────────────────────────────────────── BOOL CycleAudioDevice(int direction) { @@ -1720,7 +1905,8 @@ static void ApplyContextMenuTheme(HWND hWnd, bool dark) { void BuildAndShowContextMenu(HWND hWnd) { HMENU hMenu = CreatePopupMenu(); - AppendMenuW(hMenu, MF_STRING, MENU_OPEN_SETTINGS, L"Mod Settings..."); + AppendMenuW(hMenu, MF_STRING, MENU_OPEN_SETTINGS, L"Mod Settings..."); + AppendMenuW(hMenu, MF_STRING, MENU_SOUND_SETTINGS, L"Playback Settings..."); AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); MENUITEMINFOW miiWH = {sizeof(miiWH)}; @@ -1746,6 +1932,8 @@ void BuildAndShowContextMenu(HWND hWnd) { if (g_guiThread) CloseHandle(g_guiThread); g_guiThread = h; } + } else if (cmd == MENU_SOUND_SETTINGS) { + ShellExecuteW(nullptr, L"open", L"control.exe", L"mmsys.cpl,,0", nullptr, SW_SHOWNORMAL); } else if (cmd == MENU_OPEN_WINDHAWK) { SHELLEXECUTEINFOW sei = {sizeof(sei)}; sei.lpFile = g_windhawkPath; @@ -1754,6 +1942,338 @@ void BuildAndShowContextMenu(HWND hWnd) { } } +// ─── Volume slider popup ────────────────────────────────────────────────────── + +namespace VolumePopup { + +static constexpr WCHAR kClass[] = L"AudioSwapVolumePopup"; +static constexpr WCHAR kLabel[] = L"Output Volume"; +static constexpr int kW = 240; +static constexpr int kH = 72; +static constexpr int kPadX = 16; // left/right padding +static constexpr int kTitleY = 12; // title row top Y +static constexpr int kTitleH = 18; // title row height +static constexpr int kTrackY = 52; // track center Y +static constexpr int kTrackH = 6; // track height in px +static constexpr int kThumbR = 9; // thumb radius (normal) +static constexpr int kThumbRHov = 10; // thumb radius (hover / drag) +static constexpr UINT kVolTimerId = 102; // throttled audio commit +static constexpr COLORREF kBg = RGB(28, 28, 28 ); +static constexpr COLORREF kBorder = RGB(50, 50, 50 ); +static constexpr COLORREF kTrackBg = RGB(58, 58, 58 ); +static constexpr COLORREF kTrackFill = RGB(0, 120, 215); +static constexpr COLORREF kThumbCol = RGB(255, 255, 255); +static constexpr COLORREF kTextPri = RGB(235, 235, 235); +static constexpr COLORREF kTextSec = RGB(130, 130, 130); + +static HWND g_hwnd = nullptr; +static bool g_volumeDirty = false; +static HWND g_trayWnd = nullptr; +static int g_value = 0; +static bool g_dragging = false; +static bool g_hover = false; +static HFONT g_font = nullptr; + +static int ValueToThumbX() { + return kPadX + (g_value * (kW - 2 * kPadX)) / 100; +} + +static int XToValue(int x) { + int v = ((x - kPadX) * 100 + (kW - 2 * kPadX) / 2) / (kW - 2 * kPadX); + return v < 0 ? 0 : (v > 100 ? 100 : v); +} + +static bool IsOverThumb(int mx, int my) { + int dx = mx - ValueToThumbX(), dy = my - kTrackY; + int r = kThumbRHov + 4; + return dx * dx + dy * dy <= r * r; +} + +static void ApplyVolume() { + float scalar = g_value / 100.0f; + if (g_pEndpointVol) { + g_pEndpointVol->SetMasterVolumeLevelScalar(scalar, &kVolumeChangeCtx); + } else { + SetCurrentDeviceVolume(scalar); + } +} + +static LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { + switch (msg) { + + case WM_CREATE: { + int vol = (int)(INT_PTR)((CREATESTRUCTW*)lParam)->lpCreateParams; + g_value = vol < 0 ? 0 : (vol > 100 ? 100 : vol); + g_dragging = false; + g_hover = false; + g_font = CreateFontW(-14, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, + DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, + CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_DONTCARE, L"Segoe UI"); + // Win11 rounded corners (DWMWA_WINDOW_CORNER_PREFERENCE=33, DWMWCP_ROUNDSMALL=3). + // GetModuleHandleW — dwmapi is already loaded; LoadLibraryW would leak a refcount. + { + using PFN = HRESULT(WINAPI*)(HWND, DWORD, LPCVOID, DWORD); + HMODULE hDwm = GetModuleHandleW(L"dwmapi.dll"); + if (hDwm) { + if (auto pfn = (PFN)GetProcAddress(hDwm, "DwmSetWindowAttribute")) { + UINT pref = 3; + pfn(hWnd, 33, &pref, sizeof(pref)); + } + } + } + SetTimer(hWnd, kVolTimerId, 16, nullptr); + return 0; + } + + case WM_ERASEBKGND: + return 1; // suppress default erase — WM_PAINT covers the full client rect + + case WM_PAINT: { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hWnd, &ps); + + // Double-buffer to eliminate flicker + HDC hMem = CreateCompatibleDC(hdc); + HBITMAP hBmp = CreateCompatibleBitmap(hdc, kW, kH); + auto hOldBmp = (HBITMAP)SelectObject(hMem, hBmp); + + // ── Background ──────────────────────────────────────────────── + RECT rcAll = {0, 0, kW, kH}; + HBRUSH hbBg = CreateSolidBrush(kBg); + FillRect(hMem, &rcAll, hbBg); + DeleteObject(hbBg); + + // Border (1 px) + HBRUSH hbBdr = CreateSolidBrush(kBorder); + FrameRect(hMem, &rcAll, hbBdr); + DeleteObject(hbBdr); + + // ── Title row ───────────────────────────────────────────────── + HFONT hOldFont = (HFONT)SelectObject(hMem, g_font ? g_font + : GetStockObject(DEFAULT_GUI_FONT)); + SetBkMode(hMem, TRANSPARENT); + + RECT rcL = {kPadX, kTitleY, kW - kPadX, kTitleY + kTitleH}; + SetTextColor(hMem, kTextSec); + DrawTextW(hMem, kLabel, -1, &rcL, + DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); + + WCHAR valBuf[8]; + swprintf_s(valBuf, ARRAYSIZE(valBuf), L"%d%%", g_value); + RECT rcR = {kPadX, kTitleY, kW - kPadX, kTitleY + kTitleH}; + SetTextColor(hMem, kTextPri); + DrawTextW(hMem, valBuf, -1, &rcR, + DT_RIGHT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); + + SelectObject(hMem, hOldFont); + + // ── Track ───────────────────────────────────────────────────── + int tLeft = kPadX; + int tRight = kW - kPadX; + int tTop = kTrackY - kTrackH / 2; + int tBot = kTrackY + kTrackH / 2; + int tX = ValueToThumbX(); + + SelectObject(hMem, GetStockObject(NULL_PEN)); + + // Unfilled portion + HBRUSH hbTrack = CreateSolidBrush(kTrackBg); + SelectObject(hMem, hbTrack); + RoundRect(hMem, tLeft, tTop, tRight, tBot, kTrackH, kTrackH); + DeleteObject(hbTrack); + + // Accent-filled (left of thumb) + if (tX > tLeft) { + HBRUSH hbFill = CreateSolidBrush(kTrackFill); + SelectObject(hMem, hbFill); + RoundRect(hMem, tLeft, tTop, tX, tBot, kTrackH, kTrackH); + DeleteObject(hbFill); + } + + // Thumb + int r = (g_dragging || g_hover) ? kThumbRHov : kThumbR; + HBRUSH hbThumb = CreateSolidBrush(kThumbCol); + SelectObject(hMem, hbThumb); + Ellipse(hMem, tX - r, kTrackY - r, tX + r, kTrackY + r); + DeleteObject(hbThumb); + + // ── Blit ────────────────────────────────────────────────────── + BitBlt(hdc, 0, 0, kW, kH, hMem, 0, 0, SRCCOPY); + SelectObject(hMem, hOldBmp); + DeleteObject(hBmp); + DeleteDC(hMem); + + EndPaint(hWnd, &ps); + return 0; + } + + case WM_SETCURSOR: { + POINT pt; + GetCursorPos(&pt); + ScreenToClient(hWnd, &pt); + bool overTrack = (pt.y >= kTrackY - kThumbRHov * 2 && + pt.y <= kTrackY + kThumbRHov * 2 && + pt.x >= kPadX && pt.x <= kW - kPadX); + SetCursor(LoadCursor(nullptr, overTrack ? IDC_HAND : IDC_ARROW)); + return TRUE; + } + + case WM_LBUTTONDOWN: { + int mx = (short)LOWORD(lParam), my = (short)HIWORD(lParam); + bool onTrack = (my >= kTrackY - kThumbRHov * 2 && + my <= kTrackY + kThumbRHov * 2 && + mx >= kPadX && mx <= kW - kPadX); + if (onTrack || IsOverThumb(mx, my)) { + g_value = XToValue(mx); + g_dragging = true; + g_hover = true; + SetCapture(hWnd); + ApplyVolume(); + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + } + + case WM_MOUSEMOVE: { + int mx = (short)LOWORD(lParam), my = (short)HIWORD(lParam); + // Re-register for WM_MOUSELEAVE each time (TME_LEAVE is one-shot) + TRACKMOUSEEVENT tme = {sizeof(tme), TME_LEAVE, hWnd, 0}; + TrackMouseEvent(&tme); + if (g_dragging) { + int newVal = XToValue(mx); + if (newVal != g_value) { + g_value = newVal; + g_volumeDirty = true; // throttled — kVolTimerId commits to audio + InvalidateRect(hWnd, nullptr, FALSE); + } + } else { + bool wasHover = g_hover; + g_hover = IsOverThumb(mx, my); + if (g_hover != wasHover) + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + } + + case WM_LBUTTONUP: + if (g_dragging) { + g_dragging = false; + ReleaseCapture(); + g_value = XToValue((short)LOWORD(lParam)); + g_volumeDirty = false; + ApplyVolume(); // force-commit final drag position + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + + case WM_MOUSELEAVE: + if (!g_dragging && g_hover) { + g_hover = false; + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + + case WM_MOUSEWHEEL: { + int step = GET_WHEEL_DELTA_WPARAM(wParam) > 0 ? 3 : -3; + int newVal = std::max(0, std::min(100, g_value + step)); + if (newVal != g_value) { + g_value = newVal; + ApplyVolume(); + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + } + + case WM_ACTIVATE: + if (LOWORD(wParam) == WA_INACTIVE) + DestroyWindow(hWnd); + return 0; + + case WM_TIMER: + if (wParam == kVolTimerId) { + if (g_volumeDirty) { g_volumeDirty = false; ApplyVolume(); } + } + return 0; + + case WM_KEYDOWN: { + int v = g_value; + switch (wParam) { + case VK_LEFT: v = std::max(0, v - 1); break; + case VK_RIGHT: v = std::min(100, v + 1); break; + case VK_HOME: v = 0; break; + case VK_END: v = 100; break; + case VK_ESCAPE: + DestroyWindow(hWnd); + return 0; + default: return 0; + } + if (v != g_value) { + g_value = v; + ApplyVolume(); + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + } + + case WM_DESTROY: + KillTimer(hWnd, kVolTimerId); + if (g_volumeDirty) { g_volumeDirty = false; ApplyVolume(); } + if (g_font) { DeleteObject(g_font); g_font = nullptr; } + g_hwnd = nullptr; + g_trayWnd = nullptr; + g_dragging = false; + g_hover = false; + return 0; + + } // switch + return DefWindowProcW(hWnd, msg, wParam, lParam); +} + +static void RegisterClass() { + WNDCLASSEXW wc = {sizeof(wc)}; + wc.lpfnWndProc = WndProc; + wc.hInstance = g_hInstance; + wc.lpszClassName = kClass; + wc.hCursor = LoadCursor(nullptr, IDC_ARROW); + RegisterClassExW(&wc); +} + +static void UnregisterClass() { + ::UnregisterClassW(kClass, g_hInstance); +} + +static void Show(HWND hTrayWnd, int volPct) { + if (g_hwnd) { SetForegroundWindow(g_hwnd); return; } + + int x = (g_trayIconRect.left + g_trayIconRect.right) / 2 - kW / 2; + int y = g_trayIconRect.top - kH - 8; + + POINT pt = { (g_trayIconRect.left + g_trayIconRect.right) / 2, g_trayIconRect.top }; + HMONITOR hm = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST); + MONITORINFO mi = { sizeof(mi) }; + if (GetMonitorInfo(hm, &mi)) { + RECT& wa = mi.rcWork; + if (x < wa.left) x = wa.left; + if (x + kW > wa.right) x = wa.right - kW; + if (y < wa.top) y = g_trayIconRect.bottom + 8; + } + + g_trayWnd = hTrayWnd; + g_hwnd = CreateWindowExW( + WS_EX_TOOLWINDOW | WS_EX_TOPMOST, + kClass, nullptr, + WS_POPUP, + x, y, kW, kH, + hTrayWnd, nullptr, g_hInstance, (LPVOID)(INT_PTR)volPct); + + if (g_hwnd) { + ShowWindow(g_hwnd, SW_SHOWNOACTIVATE); + SetForegroundWindow(g_hwnd); + } +} + +} // namespace VolumePopup + // ─── Worker thread ──────────────────────────────────────────────────────────── DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { @@ -1880,25 +2400,34 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) } InterlockedExchange(&g_isProcessingClick, 0); } + } else if (event == WM_MBUTTONUP) { + VolumePopup::Show(hWnd, GetCurrentVolumePct()); } } else if (msg == WM_INPUT) { - if (InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 1) { - UINT sz = 0; - GetRawInputData((HRAWINPUT)lParam, RID_INPUT, nullptr, &sz, sizeof(RAWINPUTHEADER)); - if (sz > 0) { - std::vector buf(sz); - if (GetRawInputData((HRAWINPUT)lParam, RID_INPUT, buf.data(), &sz, - sizeof(RAWINPUTHEADER)) == sz) { - auto* raw = reinterpret_cast(buf.data()); - if (raw->header.dwType == RIM_TYPEMOUSE && - (raw->data.mouse.usButtonFlags & RI_MOUSE_WHEEL)) { - POINT pt; GetCursorPos(&pt); - if (PtInRect(&g_trayIconRect, pt)) { - short delta = (short)raw->data.mouse.usButtonData; - int dir = (delta > 0) ? 1 : -1; + UINT sz = 0; + GetRawInputData((HRAWINPUT)lParam, RID_INPUT, nullptr, &sz, sizeof(RAWINPUTHEADER)); + if (sz > 0) { + std::vector buf(sz); + if (GetRawInputData((HRAWINPUT)lParam, RID_INPUT, buf.data(), &sz, + sizeof(RAWINPUTHEADER)) == sz) { + auto* raw = reinterpret_cast(buf.data()); + if (raw->header.dwType == RIM_TYPEMOUSE && + (raw->data.mouse.usButtonFlags & RI_MOUSE_WHEEL)) { + POINT pt; GetCursorPos(&pt); + if (PtInRect(&g_trayIconRect, pt)) { + short delta = (short)raw->data.mouse.usButtonData; + int dir = (delta > 0) ? 1 : -1; + if (InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 1) { PostMessageW(hWnd, WM_TRAY_SCROLL, (WPARAM)(LONG_PTR)dir, 0); + } else if (g_pEndpointVol) { + float scalar = 0.0f; + if (SUCCEEDED(g_pEndpointVol->GetMasterVolumeLevelScalar(&scalar))) { + scalar = std::max(0.0f, std::min(1.0f, scalar + dir * 0.02f)); + g_pEndpointVol->SetMasterVolumeLevelScalar(scalar, nullptr); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + } } } } @@ -1927,14 +2456,8 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) UpdateTrayTip(hWnd, FALSE); } else if (msg == WM_UPDATE_HOOK_STATE) { - LONG isScroll = InterlockedCompareExchange(&g_scrollToSwap, 0, 0); - if (isScroll) { - RAWINPUTDEVICE rid = { 1, 2, RIDEV_INPUTSINK, hWnd }; - RegisterRawInputDevices(&rid, 1, sizeof(rid)); - } else { - RAWINPUTDEVICE rid = { 1, 2, RIDEV_REMOVE, nullptr }; - RegisterRawInputDevices(&rid, 1, sizeof(rid)); - } + RAWINPUTDEVICE rid = { 1, 2, RIDEV_INPUTSINK, hWnd }; + RegisterRawInputDevices(&rid, 1, sizeof(rid)); } else if (msg == WM_RELOAD_ICONS) { // Run icon reload on tray thread to eliminate cross-thread handle race. @@ -1994,6 +2517,11 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) UpdateTrayTip(hWnd, TRUE); // re-add icon after explorer restart RefreshTrayIconRect(); // re-acquire icon screen rect after explorer restart + } else if (msg == WM_REBIND_VOLUME_CALLBACK) { + // Default device changed — re-bind to new endpoint. + UnbindEndpointVolume(); + BindEndpointVolume(); + } else if (msg == WM_RELOAD_ALL) { // Full reload triggered by the settings dashboard after Save and Apply. // Picks up new device assignments, icon choices, device count, swap mode, and priority list. @@ -2012,6 +2540,7 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) nid.uFlags = NIF_GUID; nid.guidItem = AUDIOSWAP_TRAY_GUID; Shell_NotifyIconW(NIM_DELETE, &nid); + if (VolumePopup::g_hwnd) DestroyWindow(VolumePopup::g_hwnd); DestroyWindow(hWnd); return 0; @@ -2033,6 +2562,7 @@ DWORD WINAPI TrayThreadProc(LPVOID) { wc.hInstance = g_hInstance; wc.lpszClassName = L"AudioSwitcherWindowClass"; RegisterClassW(&wc); + VolumePopup::RegisterClass(); // WS_EX_TOOLWINDOW: hidden popup window that Shell_NotifyIconW can track properly. // AudioSwap used HWND_MESSAGE originally, but message-only windows can lose their @@ -2055,10 +2585,11 @@ DWORD WINAPI TrayThreadProc(LPVOID) { __uuidof(IMMDeviceEnumerator), (void**)&g_notifEnum))) { g_deviceNotifier = new AudioDeviceNotifier(); g_notifEnum->RegisterEndpointNotificationCallback(g_deviceNotifier); + BindEndpointVolume(); } - - // Register Raw Input for scroll-to-swap (bypasses WH_MOUSE_LL hook chain). - if (InterlockedCompareExchange(&g_scrollToSwap, 0, 0) == 1) { + // Register Raw Input for scroll over the tray icon (bypasses WH_MOUSE_LL hook chain). + // Always active: scroll-to-swap mode cycles devices; click mode adjusts volume. + { RAWINPUTDEVICE rid = { 1, 2, RIDEV_INPUTSINK, g_trayHwnd }; RegisterRawInputDevices(&rid, 1, sizeof(rid)); } @@ -2068,6 +2599,9 @@ DWORD WINAPI TrayThreadProc(LPVOID) { MSG msg; while (GetMessageW(&msg, nullptr, 0, 0) > 0) { DispatchMessageW(&msg); } + // Unbind volume callback BEFORE unregistering the device-enumerator notifications, + // so OnNotify cannot fire against a half-torn-down object. + UnbindEndpointVolume(); // Unregister notifications before releasing the enumerator. if (g_notifEnum && g_deviceNotifier) { g_notifEnum->UnregisterEndpointNotificationCallback(g_deviceNotifier); @@ -2079,6 +2613,7 @@ DWORD WINAPI TrayThreadProc(LPVOID) { RAWINPUTDEVICE rid = { 1, 2, RIDEV_REMOVE, nullptr }; RegisterRawInputDevices(&rid, 1, sizeof(rid)); } + VolumePopup::UnregisterClass(); CoUninitialize(); return 0; } @@ -2089,6 +2624,23 @@ BOOL WhTool_ModInit() { Wh_Log(L"AudioSwap Mod Init"); InitializeCriticalSection(&g_stateLock); + // Enable dark mode app-wide via uxtheme ordinal 135 (SetPreferredAppMode), with a + // fall-back to ordinal 132 (AllowDarkModeForApp) on Win10 builds pre-1903. Graceful + // no-op when neither ordinal exists. Affects context menus, dialogs, and STATIC + // controls in the dashboard. + { + HMODULE ux = GetModuleHandleW(L"uxtheme.dll"); + if (ux) { + using FnSetMode = void(WINAPI*)(int); + using FnAllow = bool(WINAPI*)(bool); + if (auto f = (FnSetMode)GetProcAddress(ux, MAKEINTRESOURCEA(135))) { + f(1); // PreferredAppMode::AllowDark + } else if (auto f = (FnAllow)GetProcAddress(ux, MAKEINTRESOURCEA(132))) { + f(true); + } + } + } + g_hInstance = GetModuleHandleW(nullptr); switch (GetModuleFileNameW(nullptr, g_windhawkPath, ARRAYSIZE(g_windhawkPath))) { case 0: @@ -2159,8 +2711,12 @@ void WhTool_ModUninit() { HWND hwnd = g_trayHwnd; if (hwnd) PostMessageW(hwnd, WM_CLOSE, 0, 0); if (g_trayThread) { - WaitForSingleObject(g_trayThread, 3000); - CloseHandle(g_trayThread); + DWORD wr = WaitForSingleObject(g_trayThread, 3000); + if (wr == WAIT_TIMEOUT) { + Wh_Log(L"AudioSwap: tray thread did not exit within 3 s — leaking handle to avoid race"); + } else { + CloseHandle(g_trayThread); + } g_trayThread = nullptr; } if (g_guiThread) { @@ -2169,13 +2725,21 @@ void WhTool_ModUninit() { // exiting the loop regardless of IsDialogMessageW. DWORD guiId = GetThreadId(g_guiThread); if (guiId) PostThreadMessageW(guiId, WM_QUIT, 0, 0); - WaitForSingleObject(g_guiThread, 2000); - CloseHandle(g_guiThread); + DWORD wr = WaitForSingleObject(g_guiThread, 2000); + if (wr == WAIT_TIMEOUT) { + Wh_Log(L"AudioSwap: GUI thread did not exit within 2 s — leaking handle to avoid race"); + } else { + CloseHandle(g_guiThread); + } g_guiThread = nullptr; } if (g_workerThread) { - WaitForSingleObject(g_workerThread, 2000); - CloseHandle(g_workerThread); + DWORD wr = WaitForSingleObject(g_workerThread, 2000); + if (wr == WAIT_TIMEOUT) { + Wh_Log(L"AudioSwap: worker thread did not exit within 2 s — leaking handle to avoid race"); + } else { + CloseHandle(g_workerThread); + } g_workerThread = nullptr; } if (g_notifEnum && g_deviceNotifier) { From 9982647d85de5302b72803855d3b4366c59a97db Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:35:16 +0300 Subject: [PATCH 42/56] Net-Toggled Major Update v2.0 (previously `Network Toggle`) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### v2.0.0 - **Complete rebuild.** Mod renamed to Net-Toggle. - New: **DNS Monitoring** — The icon monitors your connection and changes color if your internet drops out (configurable in Mod Settings). - New: **WiFi-style tray icon** with 5 color states (Red / Yellow / Blue / Green / Grey). - New: Middle-click → **Full Network Reset** (flush DNS, release/renew, winsock + IP reset). - New: Right-click context menu — toggle network, open Network Settings, or exit. - New: Donate button on the mod page. - Improved: Tray icon is now independent from the Windhawk app and no longer groups with it in the taskbar. - Improved: Tray icon persists reliably across Explorer restarts. - Improved: Icon stays in sync even if you disable your adapter directly in Windows Settings. - Fixed: Occasional hangs during network toggles or timeouts. - Fixed: Tray icon could show the wrong state if a toggle failed. --- mods/net-toggle.wh.cpp | 924 +++++++++++++++++++++++++++++++++++------ 1 file changed, 794 insertions(+), 130 deletions(-) diff --git a/mods/net-toggle.wh.cpp b/mods/net-toggle.wh.cpp index 4de7e0a64d..b6c6c822ab 100644 --- a/mods/net-toggle.wh.cpp +++ b/mods/net-toggle.wh.cpp @@ -1,76 +1,136 @@ // ==WindhawkMod== // @id net-toggle -// @name Network Toggle -// @description Adds a network toggle to the system tray - click the icon to enable/disable network adapters -// @version 1.0 +// @name Net-Toggle +// @description Network toggle + DNS reachability monitor in your system tray +// @version 2.0.0 // @author BlackPaw // @github https://github.com/BlackPaw21 +// @donateUrl https://ko-fi.com/blackpaw21 // @include windhawk.exe -// @compilerOptions -lshell32 -lgdi32 -luser32 +// @compilerOptions -DWIN32_LEAN_AND_MEAN -lshell32 -lgdi32 -luser32 -lole32 -luuid -liphlpapi -lws2_32 // ==/WindhawkMod== +// ==WindhawkModSettings== +/* +- dnsServer: "8.8.8.8" + $name: DNS Server IP + $description: IP address to ping for DNS reachability (leave blank to disable DNS monitoring) + +- pingInterval: 10 + $name: Ping Interval (seconds) + $description: How often to check DNS reachability +*/ +// ==/WindhawkModSettings== + // ==WindhawkModReadme== /* -# Network Toggle +# Net-Toggle -A lightning-fast internet kill switch right in your taskbar! ⚡ +A lightning-fast internet kill switch with DNS reachability monitoring — right in your taskbar! -Ever needed to quickly disconnect from the web without digging through Windows settings or ripping the ethernet cable out of the wall? Network Toggle adds a clean, native-looking button directly to your system tray. +Ever needed to quickly disconnect from the web without digging through Windows settings or ripping the ethernet cable out of the wall? Net-Toggle adds a clean, native-looking button directly to your system tray. One click drops your connection. Click it again, and you're back online. -## 📖 How to Use It -Using the toggle is incredibly simple: -1. 🔍 **Find the Icon:** - > **Look in your system tray (bottom right of your screen, next to the clock) for the little `^` arrow. hit it and look for the network icon.** -2. 🎯 **Click to Toggle:** - > **Give the icon a single click.** -3. ✅ **Approve the Prompt:** - > **Windows will pop up a quick UAC screen asking for permission. Click **Yes**.** -4. 🕒 **Wait a Sec:** - > **The tray icon will update instantly, but give Windows just a few seconds to actually power your network adapters down or back up in the background.** - -**Why do I need to accept the UAC?** - > Windows requires admin permission to physically turn off your network hardware. - > - > This is a built-in security feature to stop rogue background apps from disconnecting you secretly. +## Dot Legend (Tray Icon) + +| Dot | Meaning | +|------|---------| +| 🔴 Red | Network is OFF | +| 🟡 Yellow | Network toggle or reset is in progress | +| 🔵 Blue | Network is ON, no DNS server configured | +| 🟢 Green | Network is ON, DNS is reachable | +| ⚪ Grey | Network is ON, DNS is unreachable | -## ✨ Why You'll Love It -- **Instant Access:** Your network power switch is always exactly one click away. -- **Native Look & Feel:** Uses official Windows system icons, so it blends perfectly into your taskbar. -- **Smart & Safe:** Only toggles your actual, physical hardware. It completely ignores virtual networks (like WSL or VMs), so your local environments stay perfectly intact! +## How to Use It -## ⚠️ Known Issues -- **The UAC Popup:** You will get a User Account Control prompt *every time* you use the toggle. While it adds an extra click, it's an unavoidable Windows security rule for turning physical hardware on and off. -- **Icon Grouping on the Taskbar:** If you try to manually drag the icon out of the hidden `^` menu to drop it onto your main taskbar, Windows might get confused and group it with the main Windhawk app icon. this is still fully functional but showing 2 icons instead of 1. +1. **Find the Icon:** Look in your system tray (bottom right of your screen, next to the clock) for the little `^` arrow. Hit it and look for the network icon. +2. **Left-click to Toggle:** Give the icon a single click. +3. **Middle-click for Full Reset:** Middle-click the icon to run a full network reset (flush DNS, release, renew, winsock reset, IP reset). +4. **Right-click for Menu:** Disable/Enable network, open Network Settings, or exit. +5. **Approve the Prompt (on toggle/reset):** Windows will pop up a quick UAC screen asking for permission. Click **Yes**. + + > **Why do I need to accept the UAC?** + > + > Windows requires admin permission to physically turn off your network hardware or reset your connection. + > + > This is a built-in security feature to stop rogue background apps from doing this secretly. + +6. **Configure DNS Monitoring:** In Windhawk mod settings, enter a DNS server IP (e.g. `8.8.8.8`) to enable reachability monitoring. The dot turns green when reachable, grey when not. + +## Changelog + +### v2.0.0 +- **Complete rebuild.** Mod renamed to Net-Toggle. +- New: **DNS Monitoring** — The icon monitors your connection and changes color if your internet drops out (configurable in Mod Settings). +- New: **WiFi-style tray icon** with 5 color states (Red / Yellow / Blue / Green / Grey). +- New: Middle-click → **Full Network Reset** (flush DNS, release/renew, winsock + IP reset). +- New: Right-click context menu — toggle network, open Network Settings, or exit. +- New: Donate button on the mod page. +- Improved: Tray icon is now independent from the Windhawk app and no longer groups with it in the taskbar. +- Improved: Tray icon persists reliably across Explorer restarts. +- Improved: Icon stays in sync even if you disable your adapter directly in Windows Settings. +- Fixed: Occasional hangs during network toggles or timeouts. +- Fixed: Tray icon could show the wrong state if a toggle failed. + +### v1.0 +- Initial release. */ // ==/WindhawkModReadme== #include #include #include +#include +#include +#include +#include +#include +#include #define TRAY_ICON_ID 1 #define WM_TRAY_CALLBACK (WM_USER + 1) #define WM_UPDATE_TRAY_STATE (WM_USER + 2) +#define WM_UPDATE_DNS_STATE (WM_USER + 3) +#define WM_SETTINGS_CHANGED (WM_USER + 4) +#define WM_TRIGGER_PING (WM_USER + 5) +#define DNS_PING_TIMER_ID 2 +#define DNS_RECOVERY_TIMER_ID 3 + +// Stable GUID that gives our tray icon a process-independent identity. +static const GUID NETTOGGLE_TRAY_GUID = + {0x246764CF, 0xF857, 0x4399, {0x8D, 0x3D, 0x22, 0x76, 0x1A, 0x6A, 0xBD, 0x95}}; const DWORD CLICK_DEBOUNCE_MS = 2000; static volatile LONG g_isProcessingClick = 0; static volatile LONG g_trayIconInstalled = 0; -static volatile LONG g_networkIsUp = 1; // 1 = ON, 0 = OFF +static volatile LONG g_networkIsUp = 1; static HANDLE g_trayThread = nullptr; -static HWND g_trayHwnd = nullptr; +static volatile HWND g_trayHwnd = nullptr; static HINSTANCE g_hInstance = nullptr; -static HICON g_iconEnabled = nullptr; -static HICON g_iconDisabled = nullptr; -static DWORD g_lastClickTime = 0; +static volatile DWORD g_lastClickTime = 0; static UINT g_taskbarCreatedMsg = 0; +static HANDLE g_activeWorkerThread = nullptr; + +// DNS monitoring +static volatile DWORD g_dnsServerIp = 0; +static DWORD g_pingIntervalMs = 30000; +static volatile LONG g_dnsIsReachable = 0; + +// Network watch thread +static HANDLE g_netWatchThread = nullptr; +static HANDLE g_shutdownEvent = nullptr; + +// Current tray icon handle (destroy before replace) +static HICON g_currentIcon = nullptr; + +// helpers void LogLastError(LPCWSTR context) { DWORD error = GetLastError(); if (error == 0) return; - + LPWSTR errorMsg = nullptr; if (FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, error, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), @@ -96,17 +156,16 @@ BOOL CheckActualNetworkState() { si.hStdError = stdoutWrite; BOOL isUp = TRUE; - - // Use a mutable array for CreateProcessW to prevent Access Violations WCHAR cmdLine[] = L"powershell.exe -NoProfile -NonInteractive -Command \"(Get-NetAdapter -Physical | Where-Object Status -ne 'Disabled').Count\""; - if (CreateProcessW(L"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + if (CreateProcessW(nullptr, cmdLine, nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi)) { CloseHandle(stdoutWrite); - char buffer[128] = {0}; + char buffer[128] = {}; DWORD bytesRead; if (ReadFile(stdoutRead, buffer, sizeof(buffer) - 1, &bytesRead, nullptr) && bytesRead > 0) { + buffer[bytesRead] = 0; int count = atoi(buffer); isUp = (count > 0); } @@ -118,7 +177,7 @@ BOOL CheckActualNetworkState() { CloseHandle(stdoutRead); CloseHandle(stdoutWrite); } - + return isUp; } @@ -160,7 +219,7 @@ BOOL RunPowerShellCommand(LPCWSTR psCommand, BOOL targetState) { return FALSE; } - DWORD waitResult = WaitForSingleObject(sei.hProcess, 20000); + DWORD waitResult = WaitForSingleObject(sei.hProcess, 60000); if (waitResult == WAIT_TIMEOUT) { Wh_Log(L"Process timed out, terminating"); TerminateProcess(sei.hProcess, 1); @@ -177,53 +236,394 @@ BOOL RunPowerShellCommand(LPCWSTR psCommand, BOOL targetState) { CloseHandle(sei.hProcess); Wh_Log(L"Process exited with code: %d", exitCode); + if (exitCode != 0) { + LONG revertState = targetState ? 0 : 1; + InterlockedExchange(&g_networkIsUp, revertState); + if (g_trayHwnd) + PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)(revertState == 1), 0); + } return exitCode == 0; } +// ============================================================================== +// Feature B — ICMP DNS Ping +// ============================================================================== + +BOOL PingIp(DWORD ipAddr) { + // Uses a TCP connect to port 53 instead of IcmpSendEcho. + // IcmpCreateFile fails with ERROR_INVALID_HANDLE (6) inside this process + // after Disable-NetAdapter removes all adapters from the IP stack — the ICMP + // kernel device path doesn't recover until the process restarts. Winsock TCP + // sockets are not affected by adapter disable/enable cycles. + SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (sock == INVALID_SOCKET) { + Wh_Log(L"PingIp: socket() failed (%d)", WSAGetLastError()); + return FALSE; + } + + u_long nonBlocking = 1; + ioctlsocket(sock, FIONBIO, &nonBlocking); + + sockaddr_in addr = {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = ipAddr; // network byte order from InetPtonW + addr.sin_port = htons(53); + + int connectResult = connect(sock, reinterpret_cast(&addr), sizeof(addr)); + int connectErr = (connectResult == SOCKET_ERROR) ? WSAGetLastError() : 0; + + BOOL reachable = FALSE; + if (connectResult == 0) { + reachable = TRUE; + } else if (connectErr == WSAEWOULDBLOCK || connectErr == WSAEINPROGRESS) { + fd_set writeSet, exceptSet; + FD_ZERO(&writeSet); + FD_ZERO(&exceptSet); + FD_SET(sock, &writeSet); + FD_SET(sock, &exceptSet); + TIMEVAL tv = {2, 500000}; // 2.5s + int sel = select(0, nullptr, &writeSet, &exceptSet, &tv); + if (sel > 0 && FD_ISSET(sock, &writeSet)) { + int sockErr = 0; + int optLen = sizeof(sockErr); + getsockopt(sock, SOL_SOCKET, SO_ERROR, reinterpret_cast(&sockErr), &optLen); + // CONNREFUSED means the host is up but port 53 closed (extremely unlikely for 8.8.8.8) + reachable = (sockErr == 0 || sockErr == WSAECONNREFUSED); + } else { + Wh_Log(L"PingIp: TCP:53 timed out or select error (%d)", WSAGetLastError()); + } + } else { + Wh_Log(L"PingIp: connect() failed immediately (%d)", connectErr); + } + + closesocket(sock); + return reachable; +} + +// ============================================================================== +// Feature C — Wifi-Shape Icon +// ============================================================================== + +// Draws a wifi fan (3 arc bands + center dot) into a 32-bit pre-multiplied-alpha +// DIB. We work at 2× resolution then down-sample 2×2 → 1 for cheap anti-aliasing. +// +// Arc geometry: the fan is centred at the bottom-centre of the icon, spanning +// ±65° either side of straight-up (i.e. the sweep covers 130° of arc). +// Three rings at outer/mid/inner radii with a proportional pen width, plus a +// small filled dot at the origin. + +static void DrawWifiIcon(DWORD* px, int W, int H, BYTE r, BYTE g, BYTE b) { + // Arc parameters (in icon-pixel units) + float cx = W * 0.5f; + float cy = H * 0.88f - 3.0f; // origin sits near the bottom + + float outerR = W * 0.598f; + float midR = W * 0.403f; + float innerR = W * 0.221f; + float dotR = W * 0.098f; + float penW = W * 0.143f; // arc stroke width + + // Arc spans ±65° from straight-up (270° in standard coords) + const float PI = 3.14159265f; + float halfSweep = 65.0f * PI / 180.0f; + float baseAngle = 270.0f * PI / 180.0f; // pointing up + float a0 = baseAngle - halfSweep; + float a1 = baseAngle + halfSweep; + + for (int y = 0; y < H; y++) { + for (int x = 0; x < W; x++) { + float dx = x + 0.5f - cx; + float dy = y + 0.5f - cy; + float dist = sqrtf(dx * dx + dy * dy); + + // Determine if pixel centre is on one of the three arc bands + bool onArc = false; + if (dist > dotR) { + float radii[3] = { outerR, midR, innerR }; + for (int i = 0; i < 3; i++) { + if (fabsf(dist - radii[i]) <= penW * 0.5f) { + // Check angular range + float angle = atan2f(dy, dx); + // Normalise to [0, 2π) + if (angle < 0) angle += 2.0f * PI; + float a0n = a0, a1n = a1; + if (a0n < 0) a0n += 2.0f * PI; + if (a1n < 0) a1n += 2.0f * PI; + bool inSweep = (a0n <= a1n) + ? (angle >= a0n && angle <= a1n) + : (angle >= a0n || angle <= a1n); + if (inSweep) { onArc = true; break; } + } + } + } + + // Determine if pixel is in the centre dot + bool onDot = (dist <= dotR); + + if (onArc || onDot) { + // Simple coverage fraction for the outermost ring edge (cheap AA) + float alpha = 1.0f; + if (onArc) { + // Edge softening: ramp alpha over 1px at outer boundary + float nearestR = 0; + float radii[3] = { outerR, midR, innerR }; + float minDelta = 1e9f; + for (int i = 0; i < 3; i++) { + float d = fabsf(dist - radii[i]); + if (d < minDelta) { minDelta = d; nearestR = radii[i]; } + } + float edge = fabsf(dist - nearestR) - (penW * 0.5f - 1.0f); + if (edge > 0) alpha = 1.0f - edge; + if (alpha < 0) alpha = 0; + } + DWORD a8 = (DWORD)(alpha * 255.0f + 0.5f); + if (a8 > 255) a8 = 255; + px[y * W + x] = (a8 << 24) | ((DWORD)r << 16) | ((DWORD)g << 8) | b; + } + } + } +} + +HICON CreateColoredDotIcon(BOOL netUp, BOOL dnsUp, BOOL hasDns, BOOL pending) { + int cx = GetSystemMetrics(SM_CXSMICON); + int cy = GetSystemMetrics(SM_CYSMICON); + + COLORREF iconColor; + if (pending) iconColor = RGB(240, 180, 0); // Yellow + else if (!netUp) iconColor = RGB(220, 50, 50); // Red + else if (!hasDns) iconColor = RGB(70, 130, 255); // Blue + else if (dnsUp) iconColor = RGB(60, 220, 60); // Green + else iconColor = RGB(160, 160, 160); // Grey + + BITMAPINFO bmi = {}; + bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bmi.bmiHeader.biWidth = cx; + bmi.bmiHeader.biHeight = -cy; + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB; + + HDC hdcScreen = GetDC(nullptr); + void* bits = nullptr; + HBITMAP hBmp = CreateDIBSection(hdcScreen, &bmi, DIB_RGB_COLORS, &bits, nullptr, 0); + ReleaseDC(nullptr, hdcScreen); + if (!hBmp) return nullptr; + + if (bits) { + DWORD* pixels = (DWORD*)bits; + memset(pixels, 0, (size_t)cx * cy * 4); + DrawWifiIcon(pixels, cx, cy, + GetRValue(iconColor), GetGValue(iconColor), GetBValue(iconColor)); + } + + HBITMAP hMask = CreateBitmap(cx, cy, 1, 1, nullptr); + if (hMask) { + HDC hdcMask = CreateCompatibleDC(nullptr); + HGDIOBJ hOld = SelectObject(hdcMask, hMask); + PatBlt(hdcMask, 0, 0, cx, cy, WHITENESS); + SelectObject(hdcMask, hOld); + DeleteDC(hdcMask); + } + + ICONINFO ii = {}; + ii.fIcon = TRUE; + ii.hbmColor = hBmp; + ii.hbmMask = hMask ? hMask : hBmp; + HICON hResult = CreateIconIndirect(&ii); + + DeleteObject(hBmp); + if (hMask) DeleteObject(hMask); + + return hResult; +} + +// ============================================================================== +// Tray Icon +// ============================================================================== + +void AddOrUpdateTrayIcon(HWND hWnd, BOOL enabled, BOOL isAdd) { + BOOL dnsUp = (g_dnsIsReachable == 1); + BOOL hasDns = (g_dnsServerIp != 0); + BOOL pending = (g_isProcessingClick != 0); + + HICON hNewIcon = CreateColoredDotIcon(enabled, dnsUp, hasDns, pending); + if (!hNewIcon) return; + + // Destroy old icon to prevent handle leak + if (g_currentIcon) { + DestroyIcon(g_currentIcon); + g_currentIcon = nullptr; + } + + NOTIFYICONDATAW nid = {sizeof(nid)}; + nid.hWnd = hWnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_SHOWTIP | NIF_ICON; + nid.uCallbackMessage = WM_TRAY_CALLBACK; + + if (g_isProcessingClick == 1) { + wsprintfW(nid.szTip, L"Net-Toggle: toggling\u2026"); + } else if (g_isProcessingClick == 2) { + wsprintfW(nid.szTip, L"Net-Toggle: resetting\u2026"); + } else if (enabled) { + if (!hasDns) + wsprintfW(nid.szTip, L"Net-Toggle: ON"); + else if (dnsUp) + wsprintfW(nid.szTip, L"Net-Toggle: ON | DNS reachable"); + else + wsprintfW(nid.szTip, L"Net-Toggle: ON | DNS unreachable"); + } else { + wsprintfW(nid.szTip, L"Net-Toggle: OFF (click to enable)"); + } + + nid.hIcon = hNewIcon; + nid.uFlags |= NIF_GUID; + nid.guidItem = NETTOGGLE_TRAY_GUID; + + if (isAdd) { + if (!Shell_NotifyIconW(NIM_ADD, &nid)) { + LogLastError(L"Shell_NotifyIcon NIM_ADD"); + DestroyIcon(hNewIcon); + return; + } + InterlockedExchange(&g_trayIconInstalled, 1); + NOTIFYICONDATAW nidVer = {sizeof(nidVer)}; + nidVer.hWnd = hWnd; + nidVer.uID = TRAY_ICON_ID; + nidVer.uFlags = NIF_GUID; + nidVer.guidItem = NETTOGGLE_TRAY_GUID; + nidVer.uVersion = NOTIFYICON_VERSION_4; + Shell_NotifyIconW(NIM_SETVERSION, &nidVer); + } else { + if (!Shell_NotifyIconW(NIM_MODIFY, &nid)) { + LogLastError(L"Shell_NotifyIcon NIM_MODIFY"); + DestroyIcon(hNewIcon); + return; + } + } + + g_currentIcon = hNewIcon; +} + +// ============================================================================== +// Toggle Logic +// ============================================================================== + DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { + CoInitialize(nullptr); BOOL enable = (BOOL)(UINT_PTR)lpParam; - + Wh_Log(L"Toggling network adapters: %s", enable ? L"ENABLE" : L"DISABLE"); - LPCWSTR command = enable + LPCWSTR command = enable ? L"Get-NetAdapter -Physical | Enable-NetAdapter -Confirm:$false" : L"Get-NetAdapter -Physical | Disable-NetAdapter -Confirm:$false"; BOOL success = RunPowerShellCommand(command, enable); - + if (success) { + success = TRUE; Wh_Log(L"Network %s operation completed successfully", enable ? L"enable" : L"disable"); } else { Wh_Log(L"Network toggle operation failed or cancelled"); + success = FALSE; } InterlockedExchange(&g_isProcessingClick, 0); + + if (g_trayHwnd) { + PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)(g_networkIsUp == 1), 0); + // After a successful enable the NetWatch poll fallback may miss the state + // change (g_networkIsUp was already pre-set to 1 by RunPowerShellCommand). + // Trigger a recovery ping explicitly so DNS state updates promptly. + if (success && enable && g_networkIsUp) { + PostMessageW(g_trayHwnd, WM_TRIGGER_PING, 0, 0); + } + } + CoUninitialize(); return 0; } -void AddOrUpdateTrayIcon(HWND hWnd, BOOL enabled, BOOL isAdd) { - NOTIFYICONDATAW nid = {sizeof(nid)}; - nid.hWnd = hWnd; - nid.uID = TRAY_ICON_ID; - nid.uFlags = NIF_MESSAGE | NIF_TIP | NIF_ICON; - nid.uCallbackMessage = WM_TRAY_CALLBACK; - wsprintfW(nid.szTip, enabled ? L"Network Toggle: ON (Click to disable)" : L"Network Toggle: OFF (Click to enable)"); - nid.hIcon = enabled ? g_iconEnabled : g_iconDisabled; - - if (isAdd) { - if (!Shell_NotifyIconW(NIM_ADD, &nid)) { - LogLastError(L"Shell_NotifyIcon NIM_ADD"); - } else { - InterlockedExchange(&g_trayIconInstalled, 1); +DWORD WINAPI ResetWorkerThreadProc(LPVOID) { + CoInitialize(nullptr); + Wh_Log(L"Executing full network reset"); + + // Destructive ops run FIRST so that ipconfig /renew is the last action. + // This ensures the final addr-change notification (and recovery ping) fires + // after the adapter has a fresh DHCP lease, not after the stack reset. + WCHAR cmdArgs[] = L"-NoProfile -NonInteractive -WindowStyle Hidden -Command \"netsh winsock reset; netsh int ip reset; ipconfig /flushdns; ipconfig /release; ipconfig /renew; ipconfig /registerdns\""; + + SHELLEXECUTEINFOW sei = {sizeof(sei)}; + sei.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NO_CONSOLE; + sei.hwnd = nullptr; + sei.lpVerb = L"runas"; + sei.lpFile = L"powershell.exe"; + sei.lpParameters = cmdArgs; + sei.nShow = SW_HIDE; + + BOOL resetOk = FALSE; + if (ShellExecuteExW(&sei)) { + if (sei.hProcess) { + WaitForSingleObject(sei.hProcess, 60000); + CloseHandle(sei.hProcess); } + Wh_Log(L"Network reset completed"); + resetOk = TRUE; } else { - if (!Shell_NotifyIconW(NIM_MODIFY, &nid)) { - LogLastError(L"Shell_NotifyIcon NIM_MODIFY"); + Wh_Log(L"Network reset failed to start or was cancelled"); + } + + InterlockedExchange(&g_isProcessingClick, 0); + if (g_trayHwnd) { + PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)(g_networkIsUp == 1), 0); + if (resetOk && g_networkIsUp) { + // Belt-and-suspenders: directly trigger a post-reset recovery ping + // with wParam=1 so the handler uses a 12s settle (longer than the + // normal 6s used for a simple re-enable) to let DHCP and routing + // fully stabilise before we ping. + PostMessageW(g_trayHwnd, WM_TRIGGER_PING, 1, 0); } } + CoUninitialize(); + return 0; +} + +void ProcessNetworkReset() { + LONG prev = InterlockedCompareExchange(&g_isProcessingClick, 2, 0); + if (prev != 0) { + Wh_Log(L"Already processing a click, ignoring reset request"); + return; + } + + DWORD now = GetTickCount(); + if (now - g_lastClickTime < CLICK_DEBOUNCE_MS) { + Wh_Log(L"Click debounced (cooldown active)"); + InterlockedExchange(&g_isProcessingClick, 0); + return; + } + g_lastClickTime = now; + + Wh_Log(L"Processing network reset (Middle Click)"); + + // Show yellow immediately + if (g_trayHwnd) { + PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)g_networkIsUp, 0); + } + + if (g_activeWorkerThread) { + CloseHandle(g_activeWorkerThread); + g_activeWorkerThread = nullptr; + } + + DWORD threadId; + g_activeWorkerThread = CreateThread(nullptr, 0, ResetWorkerThreadProc, nullptr, 0, &threadId); + if (!g_activeWorkerThread) { + InterlockedExchange(&g_isProcessingClick, 0); + } } void ProcessTrayClick() { - if (InterlockedExchange(&g_isProcessingClick, 1) != 0) { + LONG prev = InterlockedCompareExchange(&g_isProcessingClick, 1, 0); + if (prev != 0) { Wh_Log(L"Already processing a click, ignoring"); return; } @@ -236,32 +636,146 @@ void ProcessTrayClick() { } g_lastClickTime = now; - BOOL targetState = (g_networkIsUp == 0); + BOOL targetState = (g_networkIsUp == 0); Wh_Log(L"Processing network toggle click. Target state: %s", targetState ? L"ON" : L"OFF"); + // Show yellow immediately + if (g_trayHwnd) { + PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)g_networkIsUp, 0); + } + + if (g_activeWorkerThread) { + CloseHandle(g_activeWorkerThread); + g_activeWorkerThread = nullptr; + } + DWORD threadId; - HANDLE hWorker = CreateThread(nullptr, 0, WorkerThreadProc, (LPVOID)(UINT_PTR)targetState, 0, &threadId); - if (hWorker) { - CloseHandle(hWorker); - } else { - InterlockedExchange(&g_isProcessingClick, 0); + g_activeWorkerThread = CreateThread(nullptr, 0, WorkerThreadProc, (LPVOID)(UINT_PTR)targetState, 0, &threadId); + if (!g_activeWorkerThread) { + InterlockedExchange(&g_isProcessingClick, 0); } } +// ============================================================================== +// DNS Ping Handler +// ============================================================================== + +void OnDnsPingTimer(HWND hWnd) { + if (g_dnsServerIp == 0) return; + + // Skip pings if we know the network is OFF + if (g_networkIsUp == 0) { + Wh_Log(L"Network is OFF, skipping DNS ping"); + InterlockedExchange(&g_dnsIsReachable, 0); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + return; + } + + Wh_Log(L"Triggering DNS reachability check..."); + BOOL reachable = PingIp(g_dnsServerIp); + InterlockedExchange(&g_dnsIsReachable, reachable ? 1 : 0); + + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, (WPARAM)g_networkIsUp, 0); +} + +// ============================================================================== +// Feature D — Right-Click Context Menu +// ============================================================================== + +void ShowContextMenu(HWND hWnd) { + POINT pt; + GetCursorPos(&pt); + SetForegroundWindow(hWnd); + HMENU hMenu = CreatePopupMenu(); + BOOL netUp = (g_networkIsUp == 1); + AppendMenuW(hMenu, MF_STRING, 1, netUp ? L"Disable Network" : L"Enable Network"); + AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); + AppendMenuW(hMenu, MF_STRING, 2, L"Open Network Settings"); + AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); + AppendMenuW(hMenu, MF_STRING, 3, L"Exit"); + int cmd = TrackPopupMenu(hMenu, TPM_RETURNCMD | TPM_RIGHTBUTTON, + pt.x, pt.y, 0, hWnd, nullptr); + DestroyMenu(hMenu); + PostMessageW(hWnd, WM_NULL, 0, 0); + switch (cmd) { + case 1: ProcessTrayClick(); break; + case 2: ShellExecuteW(nullptr, L"open", L"ms-settings:network", + nullptr, nullptr, SW_SHOW); break; + case 3: PostMessageW(hWnd, WM_CLOSE, 0, 0); break; + } +} + +// ============================================================================== +// Tray Window +// ============================================================================== + LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { if (msg == WM_TRAY_CALLBACK) { - if (lParam == WM_LBUTTONUP) { + if (LOWORD(lParam) == WM_LBUTTONUP) { ProcessTrayClick(); + } else if (LOWORD(lParam) == WM_RBUTTONUP) { + ShowContextMenu(hWnd); + } else if (LOWORD(lParam) == WM_MBUTTONUP) { + ProcessNetworkReset(); } return 0; } else if (msg == WM_UPDATE_TRAY_STATE) { AddOrUpdateTrayIcon(hWnd, (BOOL)wParam, FALSE); return 0; + } else if (msg == WM_UPDATE_DNS_STATE) { + AddOrUpdateTrayIcon(hWnd, (BOOL)wParam, FALSE); + return 0; + } else if (msg == WM_TRIGGER_PING) { + // wParam=0: normal re-enable — 6s settle for DHCP/init. + // wParam=1: post-full-reset — 12s settle; stack + routing need more time. + UINT settleMs = (wParam == 1) ? 12000 : 6000; + SetTimer(hWnd, DNS_RECOVERY_TIMER_ID, settleMs, nullptr); + return 0; + } else if (msg == WM_SETTINGS_CHANGED) { + // Re-read settings on the tray thread + PCWSTR pDnsIp = Wh_GetStringSetting(L"dnsServer"); + DWORD newIp = 0; + if (pDnsIp && pDnsIp[0]) { + InetPtonW(AF_INET, pDnsIp, &newIp); + } + if (pDnsIp) Wh_FreeStringSetting(pDnsIp); + + InterlockedExchange(&g_dnsServerIp, newIp); + int intervalSec = Wh_GetIntSetting(L"pingInterval"); + if (intervalSec < 5) intervalSec = 5; + g_pingIntervalMs = (DWORD)intervalSec * 1000; + + KillTimer(hWnd, DNS_PING_TIMER_ID); + if (newIp != 0) { + SetTimer(hWnd, DNS_PING_TIMER_ID, g_pingIntervalMs, nullptr); + // Immediate first ping + OnDnsPingTimer(hWnd); + } + AddOrUpdateTrayIcon(hWnd, (BOOL)(g_networkIsUp == 1), FALSE); + return 0; + } else if (msg == WM_TIMER) { + if (wParam == DNS_PING_TIMER_ID) { + OnDnsPingTimer(hWnd); + } else if (wParam == DNS_RECOVERY_TIMER_ID) { + KillTimer(hWnd, DNS_RECOVERY_TIMER_ID); + OnDnsPingTimer(hWnd); + } + return 0; + } else if (msg == WM_CLOSE) { + KillTimer(hWnd, DNS_PING_TIMER_ID); + KillTimer(hWnd, DNS_RECOVERY_TIMER_ID); + NOTIFYICONDATAW nid = {sizeof(nid)}; + nid.hWnd = hWnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_GUID; + nid.guidItem = NETTOGGLE_TRAY_GUID; + Shell_NotifyIconW(NIM_DELETE, &nid); + DestroyWindow(hWnd); + return 0; } else if (msg == WM_DESTROY) { PostQuitMessage(0); return 0; } else if (msg == g_taskbarCreatedMsg && g_taskbarCreatedMsg != 0) { - // Explorer restarted! Re-add the tray icon automatically. Wh_Log(L"Explorer restarted. Re-adding tray icon."); AddOrUpdateTrayIcon(hWnd, g_networkIsUp, TRUE); return 0; @@ -269,30 +783,109 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) return DefWindowProcW(hWnd, msg, wParam, lParam); } +// ============================================================================== +// Feature G — Event-Driven Adapter Watch Thread +// ============================================================================== + +DWORD WINAPI NetWatchThreadProc(LPVOID) { + Wh_Log(L"NetWatch thread started"); + HANDLE hEvent = CreateEventW(nullptr, FALSE, FALSE, nullptr); + if (!hEvent) { + Wh_Log(L"NetWatch: CreateEvent failed"); + return 1; + } + + while (true) { + HANDLE notifyHandle = nullptr; + OVERLAPPED ov = {}; + ov.hEvent = hEvent; + + DWORD nacRet = NotifyAddrChange(¬ifyHandle, &ov); + if (nacRet != ERROR_IO_PENDING && nacRet != NO_ERROR) { + Wh_Log(L"NetWatch: NotifyAddrChange failed (%d)", nacRet); + // Fallback: poll every 15 seconds instead + while (true) { + DWORD pollWait = WaitForSingleObject(g_shutdownEvent, 15000); + if (pollWait == WAIT_OBJECT_0) { + CloseHandle(hEvent); + return 0; + } + // Skip poll while a toggle/reset is in flight — CheckActualNetworkState + // can catch adapters in a transitional state and overwrite g_networkIsUp, + // causing Yellow → Red flicker when the adapters are still coming up. + if (g_isProcessingClick != 0) continue; + // Poll adapter state + BOOL newState = CheckActualNetworkState(); + LONG oldState = g_networkIsUp; + InterlockedExchange(&g_networkIsUp, newState ? 1 : 0); + if (newState != (oldState == 1) && g_trayHwnd) { + PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)newState, 0); + if (newState) { + PostMessageW(g_trayHwnd, WM_TRIGGER_PING, 0, 0); + } + } + } + } + + HANDLE waits[2] = { hEvent, g_shutdownEvent }; + DWORD r = WaitForMultipleObjects(2, waits, FALSE, INFINITE); + + if (r == WAIT_OBJECT_0) { + // Adapter state changed externally + CloseHandle(notifyHandle); + BOOL newState = CheckActualNetworkState(); + InterlockedExchange(&g_networkIsUp, newState ? 1 : 0); + if (g_trayHwnd) { + PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)newState, 0); + if (newState) { + // Send message to trigger a recovery ping + PostMessageW(g_trayHwnd, WM_TRIGGER_PING, 0, 0); + } + } + } else { + // Shutdown signal + if (notifyHandle) { + CancelIPChangeNotify(&ov); + // The handle is automatically closed by the system, no need to CloseHandle + } + break; + } + } + + CloseHandle(hEvent); + Wh_Log(L"NetWatch thread exiting"); + return 0; +} + +// ============================================================================== +// Tray Thread +// ============================================================================== + DWORD WINAPI TrayThreadProc(LPVOID) { Wh_Log(L"Tray thread started"); + CoInitialize(nullptr); g_taskbarCreatedMsg = RegisterWindowMessageW(L"TaskbarCreated"); BOOL initialState = CheckActualNetworkState(); InterlockedExchange(&g_networkIsUp, initialState ? 1 : 0); - WNDCLASSW wc = {0}; + WNDCLASSW wc = {}; wc.lpfnWndProc = TrayWndProc; wc.hInstance = g_hInstance; - wc.lpszClassName = L"NetworkToggleWindowClass"; - wc.hIcon = initialState ? g_iconEnabled : g_iconDisabled; + wc.lpszClassName = L"NetToggleWindowClass"; wc.hCursor = LoadCursor(nullptr, IDC_ARROW); if (!RegisterClassW(&wc)) { LogLastError(L"RegisterClassW"); + CoUninitialize(); return 1; } HWND hWnd = CreateWindowExW( WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, wc.lpszClassName, - L"Network Toggle", + L"Net-Toggle", WS_POPUP, 0, 0, 1, 1, nullptr, nullptr, g_hInstance, nullptr @@ -301,13 +894,37 @@ DWORD WINAPI TrayThreadProc(LPVOID) { if (!hWnd) { LogLastError(L"CreateWindowExW"); UnregisterClassW(wc.lpszClassName, g_hInstance); + CoUninitialize(); return 1; } g_trayHwnd = hWnd; + // Unique AUMID so the OS doesn't group this icon with Windhawk's main window. + IPropertyStore* pps = nullptr; + if (SUCCEEDED(SHGetPropertyStoreForWindow(hWnd, IID_PPV_ARGS(&pps)))) { + PROPVARIANT var; + PropVariantInit(&var); + var.vt = VT_LPWSTR; + var.pwszVal = (LPWSTR)CoTaskMemAlloc(MAX_PATH * sizeof(WCHAR)); + if (var.pwszVal) { + lstrcpyW(var.pwszVal, L"BlackPaw.NetToggle"); + pps->SetValue(PKEY_AppUserModel_ID, var); + CoTaskMemFree(var.pwszVal); + } + pps->Commit(); + pps->Release(); + } + AddOrUpdateTrayIcon(hWnd, initialState, TRUE); - Wh_Log(L"Tray icon installed successfully. Initial state: %s", initialState ? L"ON" : L"OFF"); + Wh_Log(L"Tray icon installed. Initial state: %s", initialState ? L"ON" : L"OFF"); + + // Start DNS ping timer if configured + if (g_dnsServerIp != 0) { + SetTimer(hWnd, DNS_PING_TIMER_ID, g_pingIntervalMs, nullptr); + // Immediate first ping + OnDnsPingTimer(hWnd); + } MSG msg; while (GetMessageW(&msg, nullptr, 0, 0) > 0) { @@ -317,26 +934,31 @@ DWORD WINAPI TrayThreadProc(LPVOID) { Wh_Log(L"Tray message loop ending"); - NOTIFYICONDATAW nid = {sizeof(nid)}; - nid.hWnd = hWnd; - nid.uID = TRAY_ICON_ID; - Shell_NotifyIconW(NIM_DELETE, &nid); - - DestroyWindow(hWnd); + HICON oldIcon = (HICON)InterlockedExchangePointer((PVOID*)&g_currentIcon, nullptr); + if (oldIcon) { + DestroyIcon(oldIcon); + } + UnregisterClassW(wc.lpszClassName, g_hInstance); InterlockedExchange(&g_trayIconInstalled, 0); g_trayHwnd = nullptr; + CoUninitialize(); return 0; } - // ============================================================================== -// TOOL MOD IMPLEMENTATION +// TOOL MOD IMPLEMENTATION // ============================================================================== BOOL WhTool_ModInit() { - Wh_Log(L"Network Toggle Mod Init"); + Wh_Log(L"Net-Toggle Mod Init"); + + WSADATA wsaData; + if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { + Wh_Log(L"WSAStartup failed"); + return FALSE; + } g_hInstance = GetModuleHandle(nullptr); if (!g_hInstance) { @@ -344,76 +966,118 @@ BOOL WhTool_ModInit() { return FALSE; } - ExtractIconExW(L"pnidui.dll", 4, nullptr, &g_iconEnabled, 1); - ExtractIconExW(L"pnidui.dll", 5, nullptr, &g_iconDisabled, 1); + Wh_Log(L"No system icon extraction needed — using colored dot icons"); + + // Read initial settings + PCWSTR pDnsIp = Wh_GetStringSetting(L"dnsServer"); + if (pDnsIp && pDnsIp[0]) { + DWORD newIp = 0; + InetPtonW(AF_INET, pDnsIp, &newIp); + InterlockedExchange(&g_dnsServerIp, newIp); + Wh_Log(L"DNS server configured: %s", pDnsIp); + } else { + g_dnsServerIp = 0; + Wh_Log(L"No DNS server configured — DNS monitoring disabled"); + } + if (pDnsIp) Wh_FreeStringSetting(pDnsIp); - if (!g_iconEnabled) ExtractIconExW(L"shell32.dll", 9, nullptr, &g_iconEnabled, 1); - if (!g_iconDisabled) ExtractIconExW(L"shell32.dll", 131, nullptr, &g_iconDisabled, 1); + int intervalSec = Wh_GetIntSetting(L"pingInterval"); + if (intervalSec < 5) intervalSec = 5; + g_pingIntervalMs = (DWORD)intervalSec * 1000; - Wh_Log(L"Icons loaded successfully."); + // Create shutdown event (manual-reset, initially non-signalled) + g_shutdownEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!g_shutdownEvent) { + LogLastError(L"CreateEvent(shutdownEvent)"); + return FALSE; + } + // Start tray thread DWORD threadId = 0; - // Removed CREATE_SUSPENDED and SetThreadPriority per developer feedback g_trayThread = CreateThread(nullptr, 0, TrayThreadProc, nullptr, 0, &threadId); - if (!g_trayThread) { - LogLastError(L"CreateThread"); + LogLastError(L"CreateThread(tray)"); + CloseHandle(g_shutdownEvent); + g_shutdownEvent = nullptr; return FALSE; } + // Start net watch thread + g_netWatchThread = CreateThread(nullptr, 0, NetWatchThreadProc, nullptr, 0, &threadId); + if (!g_netWatchThread) { + LogLastError(L"CreateThread(netWatch)"); + // Non-fatal — notify watch is a best-effort feature + g_netWatchThread = nullptr; + } + return TRUE; } void WhTool_ModSettingsChanged() { - // Empty, but required to exist for the boilerplate compiler. + if (g_trayHwnd) { + PostMessageW(g_trayHwnd, WM_SETTINGS_CHANGED, 0, 0); + } } void WhTool_ModUninit() { - Wh_Log(L"Network Toggle Mod Uninit"); + Wh_Log(L"Net-Toggle Mod Uninit"); + + // Step 1: signal shutdown + if (g_shutdownEvent) { + SetEvent(g_shutdownEvent); + } + // Step 2: close tray window (triggers PostQuitMessage) if (g_trayHwnd) { - PostMessageW(g_trayHwnd, WM_DESTROY, 0, 0); + PostMessageW(g_trayHwnd, WM_CLOSE, 0, 0); } - if (g_trayThread) { - Wh_Log(L"Waiting for tray thread to exit..."); - - DWORD waitResult = WaitForSingleObject(g_trayThread, 5000); + // Step 3: wait for threads + HANDLE waitThreads[3] = {}; + DWORD waitCount = 0; + if (g_trayThread) waitThreads[waitCount++] = g_trayThread; + if (g_netWatchThread) waitThreads[waitCount++] = g_netWatchThread; + if (g_activeWorkerThread) waitThreads[waitCount++] = g_activeWorkerThread; + + if (waitCount > 0) { + Wh_Log(L"Waiting for %d threads to exit...", waitCount); + DWORD waitResult = WaitForMultipleObjects(waitCount, waitThreads, TRUE, 5000); if (waitResult == WAIT_TIMEOUT) { - Wh_Log(L"Thread didn't exit in time, terminating"); - TerminateThread(g_trayThread, 1); + Wh_Log(L"Threads did not exit in time; ExitProcess will clean up"); } + } + // Step 4: cleanup handles + if (g_trayThread) { CloseHandle(g_trayThread); g_trayThread = nullptr; } - - if (g_iconEnabled) { - DestroyIcon(g_iconEnabled); - g_iconEnabled = nullptr; + if (g_netWatchThread) { + CloseHandle(g_netWatchThread); + g_netWatchThread = nullptr; } - if (g_iconDisabled) { - DestroyIcon(g_iconDisabled); - g_iconDisabled = nullptr; + if (g_shutdownEvent) { + CloseHandle(g_shutdownEvent); + g_shutdownEvent = nullptr; + } + if (g_activeWorkerThread) { + CloseHandle(g_activeWorkerThread); + g_activeWorkerThread = nullptr; } - Wh_Log(L"Network Toggle Mod Uninit complete"); -} + // Step 5: destroy current icon safely + HICON oldIcon = (HICON)InterlockedExchangePointer((PVOID*)&g_currentIcon, nullptr); + if (oldIcon) { + DestroyIcon(oldIcon); + } + WSACleanup(); + Wh_Log(L"Net-Toggle Mod Uninit complete"); +} -//////////////////////////////////////////////////////////////////////////////// -// Windhawk tool mod implementation for mods which don't need to inject to other -// processes or hook other functions. Context: -// https://github.com/ramensoftware/windhawk/wiki/Mods-as-tools:-Running-mods-in-a-dedicated-process -// -// The mod will load and run in a dedicated windhawk.exe process. -// -// Paste the code below as part of the mod code, and use these callbacks: -// * WhTool_ModInit -// * WhTool_ModSettingsChanged -// * WhTool_ModUninit -// -// Currently, other callbacks are not supported. +// ============================================================================== +// Windhawk tool mod boilerplate +// ============================================================================== bool g_isToolModProcessLauncher; HANDLE g_toolModProcessMutex; @@ -434,7 +1098,7 @@ BOOL Wh_ModInit() { bool isToolModProcess = false; bool isCurrentToolModProcess = false; int argc; - LPWSTR* argv = CommandLineToArgvW(GetCommandLine(), &argc); + LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc); if (!argv) { Wh_Log(L"CommandLineToArgvW failed"); return FALSE; @@ -467,7 +1131,7 @@ BOOL Wh_ModInit() { if (isCurrentToolModProcess) { g_toolModProcessMutex = - CreateMutex(nullptr, TRUE, L"windhawk-tool-mod_" WH_MOD_ID); + CreateMutexW(nullptr, TRUE, L"windhawk-tool-mod_" WH_MOD_ID); if (!g_toolModProcessMutex) { Wh_Log(L"CreateMutex failed"); ExitProcess(1); @@ -508,8 +1172,8 @@ void Wh_ModAfterInit() { } WCHAR currentProcessPath[MAX_PATH]; - switch (GetModuleFileName(nullptr, currentProcessPath, - ARRAYSIZE(currentProcessPath))) { + switch (GetModuleFileNameW(nullptr, currentProcessPath, + ARRAYSIZE(currentProcessPath))) { case 0: case ARRAYSIZE(currentProcessPath): Wh_Log(L"GetModuleFileName failed"); @@ -522,9 +1186,9 @@ void Wh_ModAfterInit() { swprintf_s(commandLine, L"\"%s\" -tool-mod \"%s\"", currentProcessPath, WH_MOD_ID); - HMODULE kernelModule = GetModuleHandle(L"kernelbase.dll"); + HMODULE kernelModule = GetModuleHandleW(L"kernelbase.dll"); if (!kernelModule) { - kernelModule = GetModuleHandle(L"kernel32.dll"); + kernelModule = GetModuleHandleW(L"kernel32.dll"); if (!kernelModule) { Wh_Log(L"No kernelbase.dll/kernel32.dll"); return; @@ -547,8 +1211,8 @@ void Wh_ModAfterInit() { return; } - STARTUPINFO si{ - .cb = sizeof(STARTUPINFO), + STARTUPINFOW si{ + .cb = sizeof(STARTUPINFOW), .dwFlags = STARTF_FORCEOFFFEEDBACK, }; PROCESS_INFORMATION pi; From 0888d6454fbb0170559e3d87e2b66edb92ead2b5 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:12:17 +0300 Subject: [PATCH 43/56] Refactor CoInitialize to CoInitializeEx for threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: production-readiness audit fixes (v2.0.0) - Offload DNS ping to worker thread (prevents UI thread block) - CoInitializeEx(COINIT_APARTMENTTHREADED) + HRESULT check in all 3 threads - Atomic g_networkIsUp read via InterlockedOr in ProcessTrayClick - Remove dead WM_UPDATE_DNS_STATE define and handler - lstrcpyW → wcscpy_s for AUMID string copy - GetModuleHandle → GetModuleHandleW - Update stale log message in ModInit --- mods/net-toggle.wh.cpp | 81 +++++++++++++++++++++++++++++------------- 1 file changed, 56 insertions(+), 25 deletions(-) diff --git a/mods/net-toggle.wh.cpp b/mods/net-toggle.wh.cpp index b6c6c822ab..4d00e72e91 100644 --- a/mods/net-toggle.wh.cpp +++ b/mods/net-toggle.wh.cpp @@ -7,7 +7,7 @@ // @github https://github.com/BlackPaw21 // @donateUrl https://ko-fi.com/blackpaw21 // @include windhawk.exe -// @compilerOptions -DWIN32_LEAN_AND_MEAN -lshell32 -lgdi32 -luser32 -lole32 -luuid -liphlpapi -lws2_32 +// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -liphlpapi -lws2_32 // ==/WindhawkMod== // ==WindhawkModSettings== @@ -78,6 +78,7 @@ One click drops your connection. Click it again, and you're back online. */ // ==/WindhawkModReadme== +#define WIN32_LEAN_AND_MEAN #include #include #include @@ -91,7 +92,6 @@ One click drops your connection. Click it again, and you're back online. #define TRAY_ICON_ID 1 #define WM_TRAY_CALLBACK (WM_USER + 1) #define WM_UPDATE_TRAY_STATE (WM_USER + 2) -#define WM_UPDATE_DNS_STATE (WM_USER + 3) #define WM_SETTINGS_CHANGED (WM_USER + 4) #define WM_TRIGGER_PING (WM_USER + 5) #define DNS_PING_TIMER_ID 2 @@ -117,6 +117,7 @@ static HANDLE g_activeWorkerThread = nullptr; static volatile DWORD g_dnsServerIp = 0; static DWORD g_pingIntervalMs = 30000; static volatile LONG g_dnsIsReachable = 0; +static volatile LONG g_dnsWorkerRunning = 0; // Network watch thread static HANDLE g_netWatchThread = nullptr; @@ -510,7 +511,11 @@ void AddOrUpdateTrayIcon(HWND hWnd, BOOL enabled, BOOL isAdd) { // ============================================================================== DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { - CoInitialize(nullptr); + HRESULT hrCo = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + if (FAILED(hrCo) && hrCo != RPC_E_CHANGED_MODE) { + Wh_Log(L"WorkerThread: CoInitializeEx failed (0x%X)", hrCo); + return 1; + } BOOL enable = (BOOL)(UINT_PTR)lpParam; Wh_Log(L"Toggling network adapters: %s", enable ? L"ENABLE" : L"DISABLE"); @@ -539,12 +544,16 @@ DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { PostMessageW(g_trayHwnd, WM_TRIGGER_PING, 0, 0); } } - CoUninitialize(); + if (hrCo != RPC_E_CHANGED_MODE) CoUninitialize(); return 0; } DWORD WINAPI ResetWorkerThreadProc(LPVOID) { - CoInitialize(nullptr); + HRESULT hrCo = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + if (FAILED(hrCo) && hrCo != RPC_E_CHANGED_MODE) { + Wh_Log(L"ResetWorkerThread: CoInitializeEx failed (0x%X)", hrCo); + return 1; + } Wh_Log(L"Executing full network reset"); // Destructive ops run FIRST so that ipconfig /renew is the last action. @@ -583,7 +592,7 @@ DWORD WINAPI ResetWorkerThreadProc(LPVOID) { PostMessageW(g_trayHwnd, WM_TRIGGER_PING, 1, 0); } } - CoUninitialize(); + if (hrCo != RPC_E_CHANGED_MODE) CoUninitialize(); return 0; } @@ -636,7 +645,7 @@ void ProcessTrayClick() { } g_lastClickTime = now; - BOOL targetState = (g_networkIsUp == 0); + BOOL targetState = (InterlockedOr(&g_networkIsUp, 0) == 0); Wh_Log(L"Processing network toggle click. Target state: %s", targetState ? L"ON" : L"OFF"); // Show yellow immediately @@ -660,22 +669,43 @@ void ProcessTrayClick() { // DNS Ping Handler // ============================================================================== +DWORD WINAPI DnsPingWorkerProc(LPVOID lpParam) { + DWORD ipAddr = (DWORD)(UINT_PTR)lpParam; + BOOL reachable = PingIp(ipAddr); + InterlockedExchange(&g_dnsIsReachable, reachable ? 1 : 0); + HWND hwnd = g_trayHwnd; + if (hwnd) { + PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, + (WPARAM)(InterlockedOr(&g_networkIsUp, 0) == 1), 0); + } + InterlockedExchange(&g_dnsWorkerRunning, 0); + return 0; +} + void OnDnsPingTimer(HWND hWnd) { if (g_dnsServerIp == 0) return; - - // Skip pings if we know the network is OFF - if (g_networkIsUp == 0) { + + if (InterlockedOr(&g_networkIsUp, 0) == 0) { Wh_Log(L"Network is OFF, skipping DNS ping"); InterlockedExchange(&g_dnsIsReachable, 0); PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); return; } - + + if (InterlockedCompareExchange(&g_dnsWorkerRunning, 1, 0) != 0) { + Wh_Log(L"DNS ping already in progress, skipping"); + return; + } + Wh_Log(L"Triggering DNS reachability check..."); - BOOL reachable = PingIp(g_dnsServerIp); - InterlockedExchange(&g_dnsIsReachable, reachable ? 1 : 0); - - PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, (WPARAM)g_networkIsUp, 0); + DWORD ipAddr = g_dnsServerIp; + HANDLE hThread = CreateThread(nullptr, 0, DnsPingWorkerProc, + (LPVOID)(UINT_PTR)ipAddr, 0, nullptr); + if (!hThread) { + InterlockedExchange(&g_dnsWorkerRunning, 0); + } else { + CloseHandle(hThread); + } } // ============================================================================== @@ -722,9 +752,6 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) } else if (msg == WM_UPDATE_TRAY_STATE) { AddOrUpdateTrayIcon(hWnd, (BOOL)wParam, FALSE); return 0; - } else if (msg == WM_UPDATE_DNS_STATE) { - AddOrUpdateTrayIcon(hWnd, (BOOL)wParam, FALSE); - return 0; } else if (msg == WM_TRIGGER_PING) { // wParam=0: normal re-enable — 6s settle for DHCP/init. // wParam=1: post-full-reset — 12s settle; stack + routing need more time. @@ -864,7 +891,11 @@ DWORD WINAPI NetWatchThreadProc(LPVOID) { DWORD WINAPI TrayThreadProc(LPVOID) { Wh_Log(L"Tray thread started"); - CoInitialize(nullptr); + HRESULT hrCo = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + if (FAILED(hrCo) && hrCo != RPC_E_CHANGED_MODE) { + Wh_Log(L"TrayThread: CoInitializeEx failed (0x%X)", hrCo); + return 1; + } g_taskbarCreatedMsg = RegisterWindowMessageW(L"TaskbarCreated"); BOOL initialState = CheckActualNetworkState(); @@ -878,7 +909,7 @@ DWORD WINAPI TrayThreadProc(LPVOID) { if (!RegisterClassW(&wc)) { LogLastError(L"RegisterClassW"); - CoUninitialize(); + if (hrCo != RPC_E_CHANGED_MODE) CoUninitialize(); return 1; } @@ -894,7 +925,7 @@ DWORD WINAPI TrayThreadProc(LPVOID) { if (!hWnd) { LogLastError(L"CreateWindowExW"); UnregisterClassW(wc.lpszClassName, g_hInstance); - CoUninitialize(); + if (hrCo != RPC_E_CHANGED_MODE) CoUninitialize(); return 1; } @@ -908,7 +939,7 @@ DWORD WINAPI TrayThreadProc(LPVOID) { var.vt = VT_LPWSTR; var.pwszVal = (LPWSTR)CoTaskMemAlloc(MAX_PATH * sizeof(WCHAR)); if (var.pwszVal) { - lstrcpyW(var.pwszVal, L"BlackPaw.NetToggle"); + wcscpy_s(var.pwszVal, MAX_PATH, L"BlackPaw.NetToggle"); pps->SetValue(PKEY_AppUserModel_ID, var); CoTaskMemFree(var.pwszVal); } @@ -942,7 +973,7 @@ DWORD WINAPI TrayThreadProc(LPVOID) { UnregisterClassW(wc.lpszClassName, g_hInstance); InterlockedExchange(&g_trayIconInstalled, 0); g_trayHwnd = nullptr; - CoUninitialize(); + if (hrCo != RPC_E_CHANGED_MODE) CoUninitialize(); return 0; } @@ -960,13 +991,13 @@ BOOL WhTool_ModInit() { return FALSE; } - g_hInstance = GetModuleHandle(nullptr); + g_hInstance = GetModuleHandleW(nullptr); if (!g_hInstance) { Wh_Log(L"Failed to get module handle"); return FALSE; } - Wh_Log(L"No system icon extraction needed — using colored dot icons"); + Wh_Log(L"Using WiFi-style arc icon"); // Read initial settings PCWSTR pDnsIp = Wh_GetStringSetting(L"dnsServer"); From 92ec96e4e8898640e6e9acbe3292c38f8283e123 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:37:33 +0300 Subject: [PATCH 44/56] Enhance network reset and context menu features Updated network reset functionality, improved context menu options and general patches. --- mods/net-toggle.wh.cpp | 275 +++++++++++++++++++++++++++++------------ 1 file changed, 195 insertions(+), 80 deletions(-) diff --git a/mods/net-toggle.wh.cpp b/mods/net-toggle.wh.cpp index 4d00e72e91..ce109ae6fa 100644 --- a/mods/net-toggle.wh.cpp +++ b/mods/net-toggle.wh.cpp @@ -7,7 +7,7 @@ // @github https://github.com/BlackPaw21 // @donateUrl https://ko-fi.com/blackpaw21 // @include windhawk.exe -// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -liphlpapi -lws2_32 +// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -liphlpapi -lws2_32 -ladvapi32 // ==/WindhawkMod== // ==WindhawkModSettings== @@ -46,7 +46,7 @@ One click drops your connection. Click it again, and you're back online. 1. **Find the Icon:** Look in your system tray (bottom right of your screen, next to the clock) for the little `^` arrow. Hit it and look for the network icon. 2. **Left-click to Toggle:** Give the icon a single click. -3. **Middle-click for Full Reset:** Middle-click the icon to run a full network reset (flush DNS, release, renew, winsock reset, IP reset). +3. **Middle-click for Full Reset:** Middle-click the icon to run a full network cycle reset — disables all adapters, flushes DNS, then re-enables them. This cuts all active connections. 4. **Right-click for Menu:** Disable/Enable network, open Network Settings, or exit. 5. **Approve the Prompt (on toggle/reset):** Windows will pop up a quick UAC screen asking for permission. Click **Yes**. @@ -64,14 +64,12 @@ One click drops your connection. Click it again, and you're back online. - **Complete rebuild.** Mod renamed to Net-Toggle. - New: **DNS Monitoring** — The icon monitors your connection and changes color if your internet drops out (configurable in Mod Settings). - New: **WiFi-style tray icon** with 5 color states (Red / Yellow / Blue / Green / Grey). -- New: Middle-click → **Full Network Reset** (flush DNS, release/renew, winsock + IP reset). +- New: Middle-click → **Full Network Reset** — disables all adapters, flushes DNS, re-enables them. Cuts all active connections. - New: Right-click context menu — toggle network, open Network Settings, or exit. - New: Donate button on the mod page. - Improved: Tray icon is now independent from the Windhawk app and no longer groups with it in the taskbar. - Improved: Tray icon persists reliably across Explorer restarts. - Improved: Icon stays in sync even if you disable your adapter directly in Windows Settings. -- Fixed: Occasional hangs during network toggles or timeouts. -- Fixed: Tray icon could show the wrong state if a toggle failed. ### v1.0 - Initial release. @@ -97,12 +95,23 @@ One click drops your connection. Click it again, and you're back online. #define DNS_PING_TIMER_ID 2 #define DNS_RECOVERY_TIMER_ID 3 +#define MENU_TOGGLE_NET 1 +#define MENU_NET_SETTINGS 2 +#define MENU_OPEN_WINDHAWK 9000 + // Stable GUID that gives our tray icon a process-independent identity. static const GUID NETTOGGLE_TRAY_GUID = {0x246764CF, 0xF857, 0x4399, {0x8D, 0x3D, 0x22, 0x76, 0x1A, 0x6A, 0xBD, 0x95}}; const DWORD CLICK_DEBOUNCE_MS = 2000; +static const DWORD POWERSHELL_TIMEOUT_MS = 60000; // 60s max for any PS command +static const DWORD NETWATCH_POLL_INTERVAL = 15000; // 15s per fallback poll tick +static const DWORD NETWATCH_POLL_RETRIES = 4; // 4 × 15s = 60s then retry NotifyAddrChange +static const int MIN_PING_INTERVAL_SEC = 5; +static const int DNS_TCP_TIMEOUT_SEC = 2; +static const int DNS_TCP_TIMEOUT_USEC = 500000; // 0.5s (total 2.5s) + static volatile LONG g_isProcessingClick = 0; static volatile LONG g_trayIconInstalled = 0; static volatile LONG g_networkIsUp = 1; @@ -115,7 +124,7 @@ static HANDLE g_activeWorkerThread = nullptr; // DNS monitoring static volatile DWORD g_dnsServerIp = 0; -static DWORD g_pingIntervalMs = 30000; +static DWORD g_pingIntervalMs = 10000; static volatile LONG g_dnsIsReachable = 0; static volatile LONG g_dnsWorkerRunning = 0; @@ -126,6 +135,11 @@ static HANDLE g_shutdownEvent = nullptr; // Current tray icon handle (destroy before replace) static HICON g_currentIcon = nullptr; +static WCHAR g_windhawkPath[MAX_PATH] = {}; +static WCHAR g_ddoresDllPath[MAX_PATH] = {}; +static HICON g_hWindHawkIcon = nullptr; +static HBITMAP g_hWindHawkBmp = nullptr; + // helpers void LogLastError(LPCWSTR context) { @@ -209,18 +223,14 @@ BOOL RunPowerShellCommand(LPCWSTR psCommand, BOOL targetState) { return FALSE; } - Wh_Log(L"UAC cleared. Updating UI to target state: %d", targetState); - InterlockedExchange(&g_networkIsUp, targetState ? 1 : 0); - if (g_trayHwnd) { - PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)targetState, 0); - } + Wh_Log(L"UAC cleared. Waiting for PowerShell to complete..."); if (!sei.hProcess) { Wh_Log(L"No process handle returned"); return FALSE; } - DWORD waitResult = WaitForSingleObject(sei.hProcess, 60000); + DWORD waitResult = WaitForSingleObject(sei.hProcess, POWERSHELL_TIMEOUT_MS); if (waitResult == WAIT_TIMEOUT) { Wh_Log(L"Process timed out, terminating"); TerminateProcess(sei.hProcess, 1); @@ -237,13 +247,13 @@ BOOL RunPowerShellCommand(LPCWSTR psCommand, BOOL targetState) { CloseHandle(sei.hProcess); Wh_Log(L"Process exited with code: %d", exitCode); - if (exitCode != 0) { - LONG revertState = targetState ? 0 : 1; - InterlockedExchange(&g_networkIsUp, revertState); - if (g_trayHwnd) - PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)(revertState == 1), 0); + if (exitCode == 0) { + InterlockedExchange(&g_networkIsUp, targetState ? 1 : 0); + return TRUE; + } else { + Wh_Log(L"PowerShell command failed (exit %d) — network state unchanged", exitCode); + return FALSE; } - return exitCode == 0; } // ============================================================================== @@ -282,7 +292,7 @@ BOOL PingIp(DWORD ipAddr) { FD_ZERO(&exceptSet); FD_SET(sock, &writeSet); FD_SET(sock, &exceptSet); - TIMEVAL tv = {2, 500000}; // 2.5s + TIMEVAL tv = {DNS_TCP_TIMEOUT_SEC, DNS_TCP_TIMEOUT_USEC}; int sel = select(0, nullptr, &writeSet, &exceptSet, &tv); if (sel > 0 && FD_ISSET(sock, &writeSet)) { int sockErr = 0; @@ -448,7 +458,10 @@ void AddOrUpdateTrayIcon(HWND hWnd, BOOL enabled, BOOL isAdd) { BOOL pending = (g_isProcessingClick != 0); HICON hNewIcon = CreateColoredDotIcon(enabled, dnsUp, hasDns, pending); - if (!hNewIcon) return; + if (!hNewIcon) { + Wh_Log(L"AddOrUpdateTrayIcon: CreateColoredDotIcon failed"); + return; + } // Destroy old icon to prevent handle leak if (g_currentIcon) { @@ -465,7 +478,7 @@ void AddOrUpdateTrayIcon(HWND hWnd, BOOL enabled, BOOL isAdd) { if (g_isProcessingClick == 1) { wsprintfW(nid.szTip, L"Net-Toggle: toggling\u2026"); } else if (g_isProcessingClick == 2) { - wsprintfW(nid.szTip, L"Net-Toggle: resetting\u2026"); + wsprintfW(nid.szTip, L"Net-Toggle: refreshing\u2026"); } else if (enabled) { if (!hasDns) wsprintfW(nid.szTip, L"Net-Toggle: ON"); @@ -554,12 +567,17 @@ DWORD WINAPI ResetWorkerThreadProc(LPVOID) { Wh_Log(L"ResetWorkerThread: CoInitializeEx failed (0x%X)", hrCo); return 1; } - Wh_Log(L"Executing full network reset"); + Wh_Log(L"Executing network blackout reset (disable adapters → flush DNS → re-enable)"); - // Destructive ops run FIRST so that ipconfig /renew is the last action. - // This ensures the final addr-change notification (and recovery ping) fires - // after the adapter has a fresh DHCP lease, not after the stack reset. - WCHAR cmdArgs[] = L"-NoProfile -NonInteractive -WindowStyle Hidden -Command \"netsh winsock reset; netsh int ip reset; ipconfig /flushdns; ipconfig /release; ipconfig /renew; ipconfig /registerdns\""; + // Full adapter cycle: kills all active connections, flushes DNS, restores adapters. + // ipconfig /release+renew omitted — adapter disable/enable already resets DHCP state. + // netsh winsock/ip reset excluded — they require a reboot to take effect. + WCHAR cmdArgs[] = + L"-NoProfile -NonInteractive -WindowStyle Hidden -Command \"" + L"Get-NetAdapter -Physical | Disable-NetAdapter -Confirm:$false; " + L"Start-Sleep -Seconds 2; " + L"ipconfig /flushdns; " + L"Get-NetAdapter -Physical | Enable-NetAdapter -Confirm:$false\""; SHELLEXECUTEINFOW sei = {sizeof(sei)}; sei.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NO_CONSOLE; @@ -572,7 +590,7 @@ DWORD WINAPI ResetWorkerThreadProc(LPVOID) { BOOL resetOk = FALSE; if (ShellExecuteExW(&sei)) { if (sei.hProcess) { - WaitForSingleObject(sei.hProcess, 60000); + WaitForSingleObject(sei.hProcess, POWERSHELL_TIMEOUT_MS); CloseHandle(sei.hProcess); } Wh_Log(L"Network reset completed"); @@ -585,10 +603,8 @@ DWORD WINAPI ResetWorkerThreadProc(LPVOID) { if (g_trayHwnd) { PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)(g_networkIsUp == 1), 0); if (resetOk && g_networkIsUp) { - // Belt-and-suspenders: directly trigger a post-reset recovery ping - // with wParam=1 so the handler uses a 12s settle (longer than the - // normal 6s used for a simple re-enable) to let DHCP and routing - // fully stabilise before we ping. + // Use 12s settle (wParam=1) to give DHCP/routing time to stabilise + // after the release/renew before we attempt the DNS ping. PostMessageW(g_trayHwnd, WM_TRIGGER_PING, 1, 0); } } @@ -712,26 +728,66 @@ void OnDnsPingTimer(HWND hWnd) { // Feature D — Right-Click Context Menu // ============================================================================== +static bool IsSystemDarkMode() { + DWORD value = 1, size = sizeof(value); + RegGetValueW(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + L"AppsUseLightTheme", RRF_RT_REG_DWORD, nullptr, &value, &size); + return value == 0; +} + +static void ApplyContextMenuTheme(HWND hWnd, bool dark) { + HMODULE ux = GetModuleHandleW(L"uxtheme.dll"); + if (!ux) return; + using Fn135 = int(WINAPI*)(int); + using Fn133 = bool(WINAPI*)(HWND, bool); + using Fn136 = void(WINAPI*)(); + if (auto f = (Fn135)GetProcAddress(ux, MAKEINTRESOURCEA(135))) f(dark ? 2 : 0); + if (auto f = (Fn133)GetProcAddress(ux, MAKEINTRESOURCEA(133))) f(hWnd, dark); + if (auto f = (Fn136)GetProcAddress(ux, MAKEINTRESOURCEA(136))) f(); +} + void ShowContextMenu(HWND hWnd) { - POINT pt; - GetCursorPos(&pt); - SetForegroundWindow(hWnd); HMENU hMenu = CreatePopupMenu(); BOOL netUp = (g_networkIsUp == 1); - AppendMenuW(hMenu, MF_STRING, 1, netUp ? L"Disable Network" : L"Enable Network"); + AppendMenuW(hMenu, MF_STRING, MENU_TOGGLE_NET, + netUp ? L"Disable Network" : L"Enable Network"); AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); - AppendMenuW(hMenu, MF_STRING, 2, L"Open Network Settings"); + AppendMenuW(hMenu, MF_STRING, MENU_NET_SETTINGS, L"Open Network Settings"); AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); - AppendMenuW(hMenu, MF_STRING, 3, L"Exit"); - int cmd = TrackPopupMenu(hMenu, TPM_RETURNCMD | TPM_RIGHTBUTTON, - pt.x, pt.y, 0, hWnd, nullptr); - DestroyMenu(hMenu); + + MENUITEMINFOW miiWH = {sizeof(miiWH)}; + miiWH.fMask = MIIM_ID | MIIM_STRING | MIIM_BITMAP; + miiWH.wID = MENU_OPEN_WINDHAWK; + miiWH.dwTypeData = (LPWSTR)L"Open Windhawk"; + miiWH.hbmpItem = g_hWindHawkBmp; + InsertMenuItemW(hMenu, (UINT)-1, TRUE, &miiWH); + + POINT pt; GetCursorPos(&pt); + bool dark = IsSystemDarkMode(); + ApplyContextMenuTheme(hWnd, dark); + SetForegroundWindow(hWnd); + int cmd = TrackPopupMenu(hMenu, + TPM_RETURNCMD | TPM_RIGHTBUTTON | TPM_BOTTOMALIGN | TPM_RIGHTALIGN, + pt.x, pt.y, 0, hWnd, nullptr); PostMessageW(hWnd, WM_NULL, 0, 0); + DestroyMenu(hMenu); + switch (cmd) { - case 1: ProcessTrayClick(); break; - case 2: ShellExecuteW(nullptr, L"open", L"ms-settings:network", - nullptr, nullptr, SW_SHOW); break; - case 3: PostMessageW(hWnd, WM_CLOSE, 0, 0); break; + case MENU_TOGGLE_NET: + ProcessTrayClick(); + break; + case MENU_NET_SETTINGS: + ShellExecuteW(nullptr, L"open", L"ms-settings:network", + nullptr, nullptr, SW_SHOW); + break; + case MENU_OPEN_WINDHAWK: { + SHELLEXECUTEINFOW sei = {sizeof(sei)}; + sei.lpFile = g_windhawkPath; + sei.nShow = SW_SHOWNORMAL; + ShellExecuteExW(&sei); + break; + } } } @@ -754,22 +810,26 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) return 0; } else if (msg == WM_TRIGGER_PING) { // wParam=0: normal re-enable — 6s settle for DHCP/init. - // wParam=1: post-full-reset — 12s settle; stack + routing need more time. - UINT settleMs = (wParam == 1) ? 12000 : 6000; + // wParam=1: post-blackout-reset — 15s settle; adapter cycle + DHCP needs more time. + UINT settleMs = (wParam == 1) ? 15000 : 6000; SetTimer(hWnd, DNS_RECOVERY_TIMER_ID, settleMs, nullptr); return 0; } else if (msg == WM_SETTINGS_CHANGED) { // Re-read settings on the tray thread - PCWSTR pDnsIp = Wh_GetStringSetting(L"dnsServer"); DWORD newIp = 0; - if (pDnsIp && pDnsIp[0]) { - InetPtonW(AF_INET, pDnsIp, &newIp); + { + auto dnsIp = WindhawkUtils::StringSetting::make(L"dnsServer"); + if (dnsIp.get()[0]) { + if (InetPtonW(AF_INET, dnsIp.get(), &newIp) != 1) { + Wh_Log(L"Invalid DNS server IP '%s' — DNS monitoring disabled", dnsIp.get()); + newIp = 0; + } + } } - if (pDnsIp) Wh_FreeStringSetting(pDnsIp); InterlockedExchange(&g_dnsServerIp, newIp); int intervalSec = Wh_GetIntSetting(L"pingInterval"); - if (intervalSec < 5) intervalSec = 5; + if (intervalSec < MIN_PING_INTERVAL_SEC) intervalSec = MIN_PING_INTERVAL_SEC; g_pingIntervalMs = (DWORD)intervalSec * 1000; KillTimer(hWnd, DNS_PING_TIMER_ID); @@ -803,8 +863,9 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) PostQuitMessage(0); return 0; } else if (msg == g_taskbarCreatedMsg && g_taskbarCreatedMsg != 0) { - Wh_Log(L"Explorer restarted. Re-adding tray icon."); - AddOrUpdateTrayIcon(hWnd, g_networkIsUp, TRUE); + Wh_Log(L"Explorer restarted — re-adding tray icon"); + AddOrUpdateTrayIcon(hWnd, (BOOL)(g_networkIsUp == 1), TRUE); + if (g_dnsServerIp != 0) OnDnsPingTimer(hWnd); // verify state asynchronously return 0; } return DefWindowProcW(hWnd, msg, wParam, lParam); @@ -829,29 +890,30 @@ DWORD WINAPI NetWatchThreadProc(LPVOID) { DWORD nacRet = NotifyAddrChange(¬ifyHandle, &ov); if (nacRet != ERROR_IO_PENDING && nacRet != NO_ERROR) { - Wh_Log(L"NetWatch: NotifyAddrChange failed (%d)", nacRet); - // Fallback: poll every 15 seconds instead - while (true) { - DWORD pollWait = WaitForSingleObject(g_shutdownEvent, 15000); - if (pollWait == WAIT_OBJECT_0) { - CloseHandle(hEvent); - return 0; - } - // Skip poll while a toggle/reset is in flight — CheckActualNetworkState - // can catch adapters in a transitional state and overwrite g_networkIsUp, - // causing Yellow → Red flicker when the adapters are still coming up. + Wh_Log(L"NetWatch: NotifyAddrChange failed (%d) — polling for %ds then retrying", + nacRet, (NETWATCH_POLL_RETRIES * NETWATCH_POLL_INTERVAL) / 1000); + // Bounded fallback: poll NETWATCH_POLL_RETRIES × NETWATCH_POLL_INTERVAL, + // then retry NotifyAddrChange so event-driven mode is restored after adapters recover. + // Skip polls while a toggle/reset is in flight to avoid Yellow→Red flicker. + bool shutdown = false; + for (DWORD i = 0; i < NETWATCH_POLL_RETRIES; i++) { + DWORD r = WaitForSingleObject(g_shutdownEvent, NETWATCH_POLL_INTERVAL); + if (r == WAIT_OBJECT_0) { shutdown = true; break; } if (g_isProcessingClick != 0) continue; - // Poll adapter state BOOL newState = CheckActualNetworkState(); LONG oldState = g_networkIsUp; InterlockedExchange(&g_networkIsUp, newState ? 1 : 0); if (newState != (oldState == 1) && g_trayHwnd) { PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)newState, 0); - if (newState) { - PostMessageW(g_trayHwnd, WM_TRIGGER_PING, 0, 0); - } + if (newState) PostMessageW(g_trayHwnd, WM_TRIGGER_PING, 0, 0); } } + if (shutdown) { + CloseHandle(hEvent); + return 0; + } + Wh_Log(L"NetWatch: retrying NotifyAddrChange after polling fallback"); + continue; } HANDLE waits[2] = { hEvent, g_shutdownEvent }; @@ -999,21 +1061,61 @@ BOOL WhTool_ModInit() { Wh_Log(L"Using WiFi-style arc icon"); + // Enable dark mode for context menus app-wide + { + HMODULE ux = GetModuleHandleW(L"uxtheme.dll"); + if (ux) { + using FnSetMode = void(WINAPI*)(int); + using FnAllow = bool(WINAPI*)(bool); + if (auto f = (FnSetMode)GetProcAddress(ux, MAKEINTRESOURCEA(135))) + f(1); + else if (auto f = (FnAllow)GetProcAddress(ux, MAKEINTRESOURCEA(132))) + f(true); + } + } + + // Windhawk executable path (for "Open Windhawk" menu item) + GetModuleFileNameW(nullptr, g_windhawkPath, ARRAYSIZE(g_windhawkPath)); + + // Load Windhawk icon from ddores.dll for the context menu + UINT sysLen = GetSystemDirectoryW(g_ddoresDllPath, MAX_PATH); + if (sysLen > 0 && sysLen < MAX_PATH - 12) + lstrcatW(g_ddoresDllPath, L"\\ddores.dll"); + else + lstrcpyW(g_ddoresDllPath, L"ddores.dll"); + + int whIconIndices[] = {98, 94, 95, 6}; + for (int idx : whIconIndices) { + ExtractIconExW(g_ddoresDllPath, idx, nullptr, &g_hWindHawkIcon, 1); + if (g_hWindHawkIcon) break; + } + if (g_hWindHawkIcon) { + ICONINFO ii = {}; + if (GetIconInfo(g_hWindHawkIcon, &ii)) { + g_hWindHawkBmp = ii.hbmColor ? ii.hbmColor : ii.hbmMask; + if (ii.hbmColor && ii.hbmMask) DeleteObject(ii.hbmMask); + } + } + // Read initial settings - PCWSTR pDnsIp = Wh_GetStringSetting(L"dnsServer"); - if (pDnsIp && pDnsIp[0]) { + { DWORD newIp = 0; - InetPtonW(AF_INET, pDnsIp, &newIp); + auto dnsIp = WindhawkUtils::StringSetting::make(L"dnsServer"); + if (dnsIp.get()[0]) { + if (InetPtonW(AF_INET, dnsIp.get(), &newIp) != 1) { + Wh_Log(L"Invalid DNS server IP '%s' — DNS monitoring disabled", dnsIp.get()); + newIp = 0; + } else { + Wh_Log(L"DNS server configured: %s", dnsIp.get()); + } + } else { + Wh_Log(L"No DNS server configured — DNS monitoring disabled"); + } InterlockedExchange(&g_dnsServerIp, newIp); - Wh_Log(L"DNS server configured: %s", pDnsIp); - } else { - g_dnsServerIp = 0; - Wh_Log(L"No DNS server configured — DNS monitoring disabled"); } - if (pDnsIp) Wh_FreeStringSetting(pDnsIp); int intervalSec = Wh_GetIntSetting(L"pingInterval"); - if (intervalSec < 5) intervalSec = 5; + if (intervalSec < MIN_PING_INTERVAL_SEC) intervalSec = MIN_PING_INTERVAL_SEC; g_pingIntervalMs = (DWORD)intervalSec * 1000; // Create shutdown event (manual-reset, initially non-signalled) @@ -1102,13 +1204,26 @@ void WhTool_ModUninit() { DestroyIcon(oldIcon); } + if (g_hWindHawkBmp) { DeleteObject(g_hWindHawkBmp); g_hWindHawkBmp = nullptr; } + if (g_hWindHawkIcon) { DestroyIcon(g_hWindHawkIcon); g_hWindHawkIcon = nullptr; } + WSACleanup(); Wh_Log(L"Net-Toggle Mod Uninit complete"); } -// ============================================================================== -// Windhawk tool mod boilerplate -// ============================================================================== +//////////////////////////////////////////////////////////////////////////////// +// Windhawk tool mod implementation for mods which don't need to inject to other +// processes or hook other functions. Context: +// https://github.com/ramensoftware/windhawk/wiki/Mods-as-tools:-Running-mods-in-a-dedicated-process +// +// The mod will load and run in a dedicated windhawk.exe process. +// +// Paste the code below as part of the mod code, and use these callbacks: +// * WhTool_ModInit +// * WhTool_ModSettingsChanged +// * WhTool_ModUninit +// +// Currently, other callbacks are not supported. bool g_isToolModProcessLauncher; HANDLE g_toolModProcessMutex; From 88148a707f3a932c6b6e634daca61b8a55a02f64 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:01:01 +0300 Subject: [PATCH 45/56] Replaced `powershell.exe` spawn fixes implemented (hopefully) and optional changes discarded due to functionality issues Replaced `powershell.exe` spawn in `CheckActualNetworkState` with native `GetAdaptersAddresses` API calls to eliminate startup blocking and latency. --- mods/net-toggle.wh.cpp | 85 ++++++++++++++++++++++++------------------ 1 file changed, 48 insertions(+), 37 deletions(-) diff --git a/mods/net-toggle.wh.cpp b/mods/net-toggle.wh.cpp index ce109ae6fa..c4724f93ff 100644 --- a/mods/net-toggle.wh.cpp +++ b/mods/net-toggle.wh.cpp @@ -77,6 +77,13 @@ One click drops your connection. Click it again, and you're back online. // ==/WindhawkModReadme== #define WIN32_LEAN_AND_MEAN +// winsock2.h + ws2tcpip.h MUST come before windows.h (pulled in by +// windhawk_utils.h). iphlpapi.h only declares the netioapi APIs +// (GetIfTable2 / MIB_IF_TABLE2 / FreeMibTable) when the winsock2 types are +// already in scope; including ws2tcpip.h after windows.h leaves them hidden +// and clang falls back to the legacy MIB_IFTABLE. +#include +#include #include #include #include @@ -84,7 +91,6 @@ One click drops your connection. Click it again, and you're back online. #include #include #include -#include #include #define TRAY_ICON_ID 1 @@ -158,42 +164,44 @@ void LogLastError(LPCWSTR context) { } BOOL CheckActualNetworkState() { - SECURITY_ATTRIBUTES sa = {sizeof(sa), nullptr, TRUE}; - HANDLE stdoutRead, stdoutWrite; - if (!CreatePipe(&stdoutRead, &stdoutWrite, &sa, 0)) return TRUE; - SetHandleInformation(stdoutRead, HANDLE_FLAG_INHERIT, 0); - - PROCESS_INFORMATION pi = {}; - STARTUPINFOW si = {sizeof(si)}; - si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW; - si.wShowWindow = SW_HIDE; - si.hStdOutput = stdoutWrite; - si.hStdError = stdoutWrite; - - BOOL isUp = TRUE; - WCHAR cmdLine[] = L"powershell.exe -NoProfile -NonInteractive -Command \"(Get-NetAdapter -Physical | Where-Object Status -ne 'Disabled').Count\""; - - if (CreateProcessW(nullptr, - cmdLine, - nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi)) { - CloseHandle(stdoutWrite); - char buffer[128] = {}; - DWORD bytesRead; - if (ReadFile(stdoutRead, buffer, sizeof(buffer) - 1, &bytesRead, nullptr) && bytesRead > 0) { - buffer[bytesRead] = 0; - int count = atoi(buffer); - isUp = (count > 0); - } - CloseHandle(stdoutRead); - WaitForSingleObject(pi.hProcess, 5000); - CloseHandle(pi.hProcess); - CloseHandle(pi.hThread); - } else { - CloseHandle(stdoutRead); - CloseHandle(stdoutWrite); + // GAA_FLAG_INCLUDE_ALL_INTERFACES includes disabled adapters so we can + // distinguish IfOperStatusNotPresent (disabled) from IfOperStatusDown + // (enabled but disconnected). GetAdaptersAddresses uses a separate internal + // code path from NotifyAddrChange/GetIfTable2, avoiding an iphlpapi.dll + // shared-handle contamination that causes GetIfTable2 to return + // ERROR_INVALID_HANDLE after NotifyAddrChange fails in injected contexts. + ULONG flags = GAA_FLAG_SKIP_UNICAST | GAA_FLAG_SKIP_ANYCAST | + GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER | + GAA_FLAG_INCLUDE_ALL_INTERFACES; + ULONG size = 16384; + BYTE* buf = (BYTE*)HeapAlloc(GetProcessHeap(), 0, size); + if (!buf) return TRUE; + + ULONG ret = GetAdaptersAddresses(AF_UNSPEC, flags, nullptr, + (IP_ADAPTER_ADDRESSES*)buf, &size); + if (ret == ERROR_BUFFER_OVERFLOW) { + HeapFree(GetProcessHeap(), 0, buf); + buf = (BYTE*)HeapAlloc(GetProcessHeap(), 0, size); + if (!buf) return TRUE; + ret = GetAdaptersAddresses(AF_UNSPEC, flags, nullptr, + (IP_ADAPTER_ADDRESSES*)buf, &size); + } + if (ret != NO_ERROR) { + Wh_Log(L"GetAdaptersAddresses failed (%lu), assuming ON", ret); + HeapFree(GetProcessHeap(), 0, buf); + return TRUE; } - return isUp; + int count = 0; + for (IP_ADAPTER_ADDRESSES* aa = (IP_ADAPTER_ADDRESSES*)buf; aa; aa = aa->Next) { + if (aa->IfType == IF_TYPE_SOFTWARE_LOOPBACK) continue; + if (aa->IfType == IF_TYPE_TUNNEL) continue; + if (aa->PhysicalAddressLength == 0) continue; + if (aa->OperStatus == IfOperStatusNotPresent) continue; + count++; + } + HeapFree(GetProcessHeap(), 0, buf); + return count > 0; } BOOL RunPowerShellCommand(LPCWSTR psCommand, BOOL targetState) { @@ -389,7 +397,10 @@ static void DrawWifiIcon(DWORD* px, int W, int H, BYTE r, BYTE g, BYTE b) { } DWORD a8 = (DWORD)(alpha * 255.0f + 0.5f); if (a8 > 255) a8 = 255; - px[y * W + x] = (a8 << 24) | ((DWORD)r << 16) | ((DWORD)g << 8) | b; + DWORD pr = (r * a8 + 127) / 255; + DWORD pg = (g * a8 + 127) / 255; + DWORD pb = (b * a8 + 127) / 255; + px[y * W + x] = (a8 << 24) | (pr << 16) | (pg << 8) | pb; } } } @@ -603,7 +614,7 @@ DWORD WINAPI ResetWorkerThreadProc(LPVOID) { if (g_trayHwnd) { PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)(g_networkIsUp == 1), 0); if (resetOk && g_networkIsUp) { - // Use 12s settle (wParam=1) to give DHCP/routing time to stabilise + // Use 15s settle (wParam=1) to give DHCP/routing time to stabilise // after the release/renew before we attempt the DNS ping. PostMessageW(g_trayHwnd, WM_TRIGGER_PING, 1, 0); } From 7977454d5012be887acd54e9d4f57c98644eb8ec Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:01:17 +0300 Subject: [PATCH 46/56] Modify compiler options and comments in net-toggle Updated compiler options to include WIN32_LEAN_AND_MEAN for better compatibility. --- mods/net-toggle.wh.cpp | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/mods/net-toggle.wh.cpp b/mods/net-toggle.wh.cpp index c4724f93ff..20a1decd4e 100644 --- a/mods/net-toggle.wh.cpp +++ b/mods/net-toggle.wh.cpp @@ -7,7 +7,7 @@ // @github https://github.com/BlackPaw21 // @donateUrl https://ko-fi.com/blackpaw21 // @include windhawk.exe -// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -liphlpapi -lws2_32 -ladvapi32 +// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -liphlpapi -lws2_32 -ladvapi32 -DWIN32_LEAN_AND_MEAN // ==/WindhawkMod== // ==WindhawkModSettings== @@ -76,12 +76,10 @@ One click drops your connection. Click it again, and you're back online. */ // ==/WindhawkModReadme== -#define WIN32_LEAN_AND_MEAN -// winsock2.h + ws2tcpip.h MUST come before windows.h (pulled in by -// windhawk_utils.h). iphlpapi.h only declares the netioapi APIs +// iphlpapi.h only declares the netioapi APIs // (GetIfTable2 / MIB_IF_TABLE2 / FreeMibTable) when the winsock2 types are -// already in scope; including ws2tcpip.h after windows.h leaves them hidden -// and clang falls back to the legacy MIB_IFTABLE. +// already in scope. -DWIN32_LEAN_AND_MEAN in compiler options prevents windows.h +// from including the legacy winsock.h. #include #include #include From 5248304886050cad305b01e0b936d33079626170 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:10:23 +0300 Subject: [PATCH 47/56] Clean up compiler options in net-toggle.wh.cpp -DWIN32_LEAN_AND_MEAN removed. -D flag broke the automatic PR validation --- mods/net-toggle.wh.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mods/net-toggle.wh.cpp b/mods/net-toggle.wh.cpp index 20a1decd4e..0cbcc822be 100644 --- a/mods/net-toggle.wh.cpp +++ b/mods/net-toggle.wh.cpp @@ -7,7 +7,7 @@ // @github https://github.com/BlackPaw21 // @donateUrl https://ko-fi.com/blackpaw21 // @include windhawk.exe -// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -liphlpapi -lws2_32 -ladvapi32 -DWIN32_LEAN_AND_MEAN +// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -liphlpapi -lws2_32 -ladvapi32 // ==/WindhawkMod== // ==WindhawkModSettings== From e0a10c5ed05b87be6f49cf68a3682447387cef2c Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Fri, 5 Jun 2026 20:06:27 +0300 Subject: [PATCH 48/56] Adjust debounce time and update comments in net-toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit added screenshots added -DWIN32_LEAN_AND_MEAN Fixed CheckActualNetworkState Raised debounce from 2s → 10s — Prevents the user from firing a second toggle before the first PowerShell command has had time to fully apply its changes --- mods/net-toggle.wh.cpp | 71 ++++++++++++++++++++++++++---------------- 1 file changed, 44 insertions(+), 27 deletions(-) diff --git a/mods/net-toggle.wh.cpp b/mods/net-toggle.wh.cpp index 0cbcc822be..8869cea5d8 100644 --- a/mods/net-toggle.wh.cpp +++ b/mods/net-toggle.wh.cpp @@ -7,7 +7,7 @@ // @github https://github.com/BlackPaw21 // @donateUrl https://ko-fi.com/blackpaw21 // @include windhawk.exe -// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -liphlpapi -lws2_32 -ladvapi32 +// @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -liphlpapi -lws2_32 -ladvapi32 -DWIN32_LEAN_AND_MEAN // ==/WindhawkMod== // ==WindhawkModSettings== @@ -32,15 +32,27 @@ Ever needed to quickly disconnect from the web without digging through Windows s One click drops your connection. Click it again, and you're back online. -## Dot Legend (Tray Icon) +## Colors Legend (Tray Icon) -| Dot | Meaning | -|------|---------| -| 🔴 Red | Network is OFF | -| 🟡 Yellow | Network toggle or reset is in progress | -| 🔵 Blue | Network is ON, no DNS server configured | -| 🟢 Green | Network is ON, DNS is reachable | -| ⚪ Grey | Network is ON, DNS is unreachable | +**🔴 Red** — Network is OFF + +![Red](https://i.imgur.com/ilyJM0R.png) + +**🟡 Yellow** — Network toggle or reset is in progress + +![Yellow](https://i.imgur.com/jBnpNRK.png) + +**🔵 Blue** — Network is ON, no DNS server configured + +![Blue](https://i.imgur.com/hMRbXyu.png) + +**🟢 Green** — Network is ON, DNS is reachable + +![Green](https://i.imgur.com/4KACqME.png) + +**⚪ Grey** — Network is ON, DNS is unreachable + +![Grey](https://i.imgur.com/xoZgdiH.png) ## How to Use It @@ -107,7 +119,7 @@ One click drops your connection. Click it again, and you're back online. static const GUID NETTOGGLE_TRAY_GUID = {0x246764CF, 0xF857, 0x4399, {0x8D, 0x3D, 0x22, 0x76, 0x1A, 0x6A, 0xBD, 0x95}}; -const DWORD CLICK_DEBOUNCE_MS = 2000; +const DWORD CLICK_DEBOUNCE_MS = 10000; static const DWORD POWERSHELL_TIMEOUT_MS = 60000; // 60s max for any PS command static const DWORD NETWATCH_POLL_INTERVAL = 15000; // 15s per fallback poll tick @@ -162,15 +174,18 @@ void LogLastError(LPCWSTR context) { } BOOL CheckActualNetworkState() { - // GAA_FLAG_INCLUDE_ALL_INTERFACES includes disabled adapters so we can - // distinguish IfOperStatusNotPresent (disabled) from IfOperStatusDown - // (enabled but disconnected). GetAdaptersAddresses uses a separate internal - // code path from NotifyAddrChange/GetIfTable2, avoiding an iphlpapi.dll - // shared-handle contamination that causes GetIfTable2 to return - // ERROR_INVALID_HANDLE after NotifyAddrChange fails in injected contexts. + // Without GAA_FLAG_INCLUDE_ALL_INTERFACES, GetAdaptersAddresses only returns + // adapters that have at least one IP address assigned. Disabled adapters have + // no IP address and therefore do not appear — they return a count of 0. + // (Disabled-NetAdapter sets IfOperStatusDown, not IfOperStatusNotPresent, so + // filtering on OperStatus is unreliable for detecting administratively disabled + // adapters. Relying on the OS to exclude address-less entries is cleaner.) + // GetAdaptersAddresses uses a separate internal code path from + // NotifyAddrChange/GetIfTable2, avoiding an iphlpapi.dll shared-handle + // contamination that causes GetIfTable2 to return ERROR_INVALID_HANDLE after + // NotifyAddrChange fails in injected contexts. ULONG flags = GAA_FLAG_SKIP_UNICAST | GAA_FLAG_SKIP_ANYCAST | - GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER | - GAA_FLAG_INCLUDE_ALL_INTERFACES; + GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER; ULONG size = 16384; BYTE* buf = (BYTE*)HeapAlloc(GetProcessHeap(), 0, size); if (!buf) return TRUE; @@ -195,7 +210,6 @@ BOOL CheckActualNetworkState() { if (aa->IfType == IF_TYPE_SOFTWARE_LOOPBACK) continue; if (aa->IfType == IF_TYPE_TUNNEL) continue; if (aa->PhysicalAddressLength == 0) continue; - if (aa->OperStatus == IfOperStatusNotPresent) continue; count++; } HeapFree(GetProcessHeap(), 0, buf); @@ -929,15 +943,18 @@ DWORD WINAPI NetWatchThreadProc(LPVOID) { DWORD r = WaitForMultipleObjects(2, waits, FALSE, INFINITE); if (r == WAIT_OBJECT_0) { - // Adapter state changed externally + // Adapter state changed externally. + // Skip while a toggle/reset is in flight — the worker owns g_networkIsUp + // during that window and will post the definitive state when done. CloseHandle(notifyHandle); - BOOL newState = CheckActualNetworkState(); - InterlockedExchange(&g_networkIsUp, newState ? 1 : 0); - if (g_trayHwnd) { - PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)newState, 0); - if (newState) { - // Send message to trigger a recovery ping - PostMessageW(g_trayHwnd, WM_TRIGGER_PING, 0, 0); + if (g_isProcessingClick == 0) { + BOOL newState = CheckActualNetworkState(); + InterlockedExchange(&g_networkIsUp, newState ? 1 : 0); + if (g_trayHwnd) { + PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)newState, 0); + if (newState) { + PostMessageW(g_trayHwnd, WM_TRIGGER_PING, 0, 0); + } } } } else { From 7209d25499931bad6569d9b828e484076400bb62 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Sat, 13 Jun 2026 08:03:43 +0300 Subject: [PATCH 49/56] Update Net-Toggle mod for DNS monitoring enhancements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add secondary (fallback) DNS server support - New default check: real DNS query (fixes false "unreachable" on networks that block TCP/53) — fixes #4288 - New per-server check methods: UDP DNS / TCP 53 / DoT 853 / DoH 443 - New Orange icon state: primary down, fallback still up - Tooltip shows per-server status - Minor reliability fixes (DNS state reads, shutdown handle cleanup) --- mods/net-toggle.wh.cpp | 490 ++++++++++++++++++++++++++++++----------- 1 file changed, 366 insertions(+), 124 deletions(-) diff --git a/mods/net-toggle.wh.cpp b/mods/net-toggle.wh.cpp index 8869cea5d8..c467214d47 100644 --- a/mods/net-toggle.wh.cpp +++ b/mods/net-toggle.wh.cpp @@ -1,8 +1,8 @@ // ==WindhawkMod== // @id net-toggle // @name Net-Toggle -// @description Network toggle + DNS reachability monitor in your system tray -// @version 2.0.0 +// @description Internet kill switch with primary/secondary DNS monitoring in your system tray +// @version 2.1.0 // @author BlackPaw // @github https://github.com/BlackPaw21 // @donateUrl https://ko-fi.com/blackpaw21 @@ -13,12 +13,35 @@ // ==WindhawkModSettings== /* - dnsServer: "8.8.8.8" - $name: DNS Server IP - $description: IP address to ping for DNS reachability (leave blank to disable DNS monitoring) + $name: Primary DNS server + $description: IPv4 address of the DNS server to monitor (leave blank to disable DNS monitoring) + +- dnsProbe: udp + $name: Primary DNS check method + $description: >- + Real DNS query is the most accurate. The TCP options only verify that the + port accepts connections — pick 853/443 for DoT/DoH endpoints like NextDNS. + $options: + - udp: Real DNS query (UDP 53) + - tcp: TCP connect (port 53) + - dot: DNS-over-TLS endpoint (TCP 853) + - doh: DNS-over-HTTPS endpoint (TCP 443) + +- dnsServer2: "" + $name: Secondary DNS server + $description: Optional fallback DNS server, monitored alongside the primary + +- dnsProbe2: udp + $name: Secondary DNS check method + $options: + - udp: Real DNS query (UDP 53) + - tcp: TCP connect (port 53) + - dot: DNS-over-TLS endpoint (TCP 853) + - doh: DNS-over-HTTPS endpoint (TCP 443) - pingInterval: 10 - $name: Ping Interval (seconds) - $description: How often to check DNS reachability + $name: Check interval (seconds) + $description: How often to check DNS reachability (minimum 5) */ // ==/WindhawkModSettings== @@ -36,30 +59,34 @@ One click drops your connection. Click it again, and you're back online. **🔴 Red** — Network is OFF -![Red](https://i.imgur.com/ilyJM0R.png) +![Red](https://i.imgur.com/1jOe8EE.png) **🟡 Yellow** — Network toggle or reset is in progress -![Yellow](https://i.imgur.com/jBnpNRK.png) +![Yellow](https://i.imgur.com/rvXLTas.png) -**🔵 Blue** — Network is ON, no DNS server configured +**🟢 Green** — Network is ON, DNS is reachable -![Blue](https://i.imgur.com/hMRbXyu.png) +![Green](https://i.imgur.com/nA2DWu9.png) -**🟢 Green** — Network is ON, DNS is reachable +**🟠 Orange** — Network is ON, primary DNS is down — the secondary (fallback) DNS is still answering -![Green](https://i.imgur.com/4KACqME.png) +![Orange](https://i.imgur.com/3H49bgH.png) **⚪ Grey** — Network is ON, DNS is unreachable -![Grey](https://i.imgur.com/xoZgdiH.png) +![Grey](https://i.imgur.com/EWrbzpv.png) + +**🔵 Blue** — Network is ON, no DNS server configured + +![Blue](https://i.imgur.com/sOQK6Cp.png) ## How to Use It 1. **Find the Icon:** Look in your system tray (bottom right of your screen, next to the clock) for the little `^` arrow. Hit it and look for the network icon. 2. **Left-click to Toggle:** Give the icon a single click. 3. **Middle-click for Full Reset:** Middle-click the icon to run a full network cycle reset — disables all adapters, flushes DNS, then re-enables them. This cuts all active connections. -4. **Right-click for Menu:** Disable/Enable network, open Network Settings, or exit. +4. **Right-click for Menu:** Disable/Enable network, open Network Settings, or open Windhawk. 5. **Approve the Prompt (on toggle/reset):** Windows will pop up a quick UAC screen asking for permission. Click **Yes**. > **Why do I need to accept the UAC?** @@ -68,16 +95,32 @@ One click drops your connection. Click it again, and you're back online. > > This is a built-in security feature to stop rogue background apps from doing this secretly. -6. **Configure DNS Monitoring:** In Windhawk mod settings, enter a DNS server IP (e.g. `8.8.8.8`) to enable reachability monitoring. The dot turns green when reachable, grey when not. +6. (optional) **Configure DNS Monitoring:** In Windhawk mod settings, enter your primary DNS server IP (e.g. `8.8.8.8`) — and optionally a secondary (e.g. `8.8.4.4`). Pick a check method per server: a **real DNS query** (default, most accurate), or a TCP reachability check on port 53, 853 (DNS-over-TLS) or 443 (DNS-over-HTTPS) for endpoints like NextDNS. + + > **Which check method should I pick?** + > + > - **Real DNS Query** *(default)* — Actually resolves a name; most accurate, right for almost everyone. + > - **TCP 53** — Just checks if the DNS port responds; use if your network blocks real queries but allows plain connections. + > - **DNS-over-TLS (853)** — Checks an encrypted DoT endpoint; use if your provider is set up for DoT. + > - **DNS-over-HTTPS (443)** — Checks a DoH endpoint; use when your provider is only reachable over HTTPS (e.g. NextDNS, Cloudflare). ## Changelog +### v2.1.0 +- New: **Secondary DNS server** — monitor a primary + fallback pair (Google, Cloudflare, NextDNS all publish two IPs). New **Orange** icon state when only the fallback is answering. +- New: **Check method per server** — real DNS query (default), TCP 53, or DNS-over-TLS (853) / DNS-over-HTTPS (443) endpoint reachability for providers like NextDNS. +- Improved: the default check now sends a **real DNS query** instead of a bare TCP connect — fixes false "DNS unreachable" results on networks that filter TCP port 53. +- Improved: tray tooltip shows per-server ✓ / ✗ status. +- Improved: right after the network comes back up, the icon shows green ("checking") instead of flashing grey until the first check completes. +- Fixed: a rare timing issue that could briefly show outdated DNS status right after changing settings. +- Fixed: a possible freeze when disabling the mod or restarting Explorer while a network toggle was in progress. + ### v2.0.0 - **Complete rebuild.** Mod renamed to Net-Toggle. - New: **DNS Monitoring** — The icon monitors your connection and changes color if your internet drops out (configurable in Mod Settings). - New: **WiFi-style tray icon** with 5 color states (Red / Yellow / Blue / Green / Grey). - New: Middle-click → **Full Network Reset** — disables all adapters, flushes DNS, re-enables them. Cuts all active connections. -- New: Right-click context menu — toggle network, open Network Settings, or exit. +- New: Right-click context menu — toggle network, open Network Settings, open Windhawk. - New: Donate button on the mod page. - Improved: Tray icon is now independent from the Windhawk app and no longer groups with it in the taskbar. - Improved: Tray icon persists reliably across Explorer restarts. @@ -125,8 +168,11 @@ static const DWORD POWERSHELL_TIMEOUT_MS = 60000; // 60s max for any PS comman static const DWORD NETWATCH_POLL_INTERVAL = 15000; // 15s per fallback poll tick static const DWORD NETWATCH_POLL_RETRIES = 4; // 4 × 15s = 60s then retry NotifyAddrChange static const int MIN_PING_INTERVAL_SEC = 5; -static const int DNS_TCP_TIMEOUT_SEC = 2; -static const int DNS_TCP_TIMEOUT_USEC = 500000; // 0.5s (total 2.5s) +static const int DNS_PROBE_TIMEOUT_SEC = 2; // TCP connect deadline… +static const int DNS_PROBE_TIMEOUT_USEC = 500000; // …2.5s total +static const int DNS_UDP_ATTEMPTS = 2; // query + 1 retransmit +static const int DNS_UDP_WAIT_SEC = 1; // per-attempt reply wait… +static const int DNS_UDP_WAIT_USEC = 250000; // …1.25s each, 2.5s worst case static volatile LONG g_isProcessingClick = 0; static volatile LONG g_trayIconInstalled = 0; @@ -138,11 +184,13 @@ static volatile DWORD g_lastClickTime = 0; static UINT g_taskbarCreatedMsg = 0; static HANDLE g_activeWorkerThread = nullptr; -// DNS monitoring -static volatile DWORD g_dnsServerIp = 0; +// DNS monitoring — two probe slots: [0] = primary, [1] = secondary. +static volatile LONG g_dnsIp[2] = {0, 0}; // IPv4, network byte order; 0 = unconfigured +static volatile LONG g_dnsProbe[2] = {0, 0}; // DnsProbeMethod per slot +static volatile LONG g_dnsUp[2] = {-1, -1}; // -1 = not checked yet, 0 = down, 1 = up static DWORD g_pingIntervalMs = 10000; -static volatile LONG g_dnsIsReachable = 0; static volatile LONG g_dnsWorkerRunning = 0; +static HANDLE g_dnsWorkerThread = nullptr; // Network watch thread static HANDLE g_netWatchThread = nullptr; @@ -277,18 +325,36 @@ BOOL RunPowerShellCommand(LPCWSTR psCommand, BOOL targetState) { } // ============================================================================== -// Feature B — ICMP DNS Ping +// Feature B — DNS Reachability Probes // ============================================================================== -BOOL PingIp(DWORD ipAddr) { - // Uses a TCP connect to port 53 instead of IcmpSendEcho. - // IcmpCreateFile fails with ERROR_INVALID_HANDLE (6) inside this process - // after Disable-NetAdapter removes all adapters from the IP stack — the ICMP - // kernel device path doesn't recover until the process restarts. Winsock TCP - // sockets are not affected by adapter disable/enable cycles. +enum DnsProbeMethod : LONG { + PROBE_UDP_DNS = 0, // real DNS query over UDP 53 (default) + PROBE_TCP_53 = 1, // bare TCP connect to port 53 + PROBE_TCP_853 = 2, // DNS-over-TLS endpoint reachability + PROBE_TCP_443 = 3, // DNS-over-HTTPS endpoint reachability +}; + +// Overall DNS health derived from the per-slot results. +enum DnsOverall { + DNS_NONE, // no server configured — monitoring disabled + DNS_CHECKING, // configured but no probe has completed yet + DNS_OK, // effective primary is answering + DNS_DEGRADED, // primary down, fallback answering + DNS_DOWN, // every configured server failed its probe +}; + +// TCP handshake to the given port. Success only when the connect completes — +// for DoT (853) / DoH (443) endpoints and explicit TCP:53 checks, a refused +// connection means the service is down. ICMP (IcmpSendEcho) is deliberately +// avoided: IcmpCreateFile fails with ERROR_INVALID_HANDLE (6) inside this +// process after Disable-NetAdapter removes all adapters from the IP stack — +// the ICMP kernel device path doesn't recover until the process restarts. +// Winsock sockets are not affected by adapter disable/enable cycles. +static BOOL ProbeTcpConnect(DWORD ipAddr, WORD port) { SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (sock == INVALID_SOCKET) { - Wh_Log(L"PingIp: socket() failed (%d)", WSAGetLastError()); + Wh_Log(L"ProbeTcpConnect: socket() failed (%d)", WSAGetLastError()); return FALSE; } @@ -298,7 +364,7 @@ BOOL PingIp(DWORD ipAddr) { sockaddr_in addr = {}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = ipAddr; // network byte order from InetPtonW - addr.sin_port = htons(53); + addr.sin_port = htons(port); int connectResult = connect(sock, reinterpret_cast(&addr), sizeof(addr)); int connectErr = (connectResult == SOCKET_ERROR) ? WSAGetLastError() : 0; @@ -312,25 +378,135 @@ BOOL PingIp(DWORD ipAddr) { FD_ZERO(&exceptSet); FD_SET(sock, &writeSet); FD_SET(sock, &exceptSet); - TIMEVAL tv = {DNS_TCP_TIMEOUT_SEC, DNS_TCP_TIMEOUT_USEC}; + TIMEVAL tv = {DNS_PROBE_TIMEOUT_SEC, DNS_PROBE_TIMEOUT_USEC}; int sel = select(0, nullptr, &writeSet, &exceptSet, &tv); if (sel > 0 && FD_ISSET(sock, &writeSet)) { int sockErr = 0; int optLen = sizeof(sockErr); getsockopt(sock, SOL_SOCKET, SO_ERROR, reinterpret_cast(&sockErr), &optLen); - // CONNREFUSED means the host is up but port 53 closed (extremely unlikely for 8.8.8.8) - reachable = (sockErr == 0 || sockErr == WSAECONNREFUSED); + reachable = (sockErr == 0); } else { - Wh_Log(L"PingIp: TCP:53 timed out or select error (%d)", WSAGetLastError()); + Wh_Log(L"ProbeTcpConnect: TCP:%u timed out or select error (%d)", port, WSAGetLastError()); } } else { - Wh_Log(L"PingIp: connect() failed immediately (%d)", connectErr); + Wh_Log(L"ProbeTcpConnect: connect() failed immediately (%d)", connectErr); + } + + closesocket(sock); + return reachable; +} + +// Sends a real DNS query (NS for the root zone) and waits for any reply with a +// matching transaction ID. Any response — even REFUSED — proves a resolver is +// alive at that address. This is more accurate than a TCP probe: many networks +// filter TCP:53 while UDP DNS works fine, which previously caused false +// "DNS unreachable" reports. One retransmit guards against packet loss. +static BOOL ProbeDnsUdp(DWORD ipAddr) { + SOCKET sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (sock == INVALID_SOCKET) { + Wh_Log(L"ProbeDnsUdp: socket() failed (%d)", WSAGetLastError()); + return FALSE; + } + + u_long nonBlocking = 1; + ioctlsocket(sock, FIONBIO, &nonBlocking); + + sockaddr_in addr = {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = ipAddr; // network byte order from InetPtonW + addr.sin_port = htons(53); + + // Connected UDP: the stack filters replies by peer address and surfaces + // ICMP port-unreachable as a recv error instead of silence. + if (connect(sock, reinterpret_cast(&addr), sizeof(addr)) == SOCKET_ERROR) { + Wh_Log(L"ProbeDnsUdp: connect() failed (%d)", WSAGetLastError()); + closesocket(sock); + return FALSE; + } + + WORD txnId = (WORD)(GetTickCount() ^ (GetCurrentThreadId() << 1)); + if (txnId == 0) txnId = 0x4E54; // any nonzero value + + // 12-byte header (RD set, QDCOUNT=1) + root QNAME + QTYPE=NS + QCLASS=IN. + BYTE query[17] = { + (BYTE)(txnId >> 8), (BYTE)(txnId & 0xFF), + 0x01, 0x00, // flags: recursion desired + 0x00, 0x01, // QDCOUNT = 1 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // ANCOUNT / NSCOUNT / ARCOUNT + 0x00, // QNAME = root + 0x00, 0x02, // QTYPE = NS + 0x00, 0x01, // QCLASS = IN + }; + + BOOL reachable = FALSE; + for (int attempt = 0; attempt < DNS_UDP_ATTEMPTS && !reachable; attempt++) { + if (send(sock, reinterpret_cast(query), sizeof(query), 0) == SOCKET_ERROR) { + Wh_Log(L"ProbeDnsUdp: send() failed (%d)", WSAGetLastError()); + break; + } + + fd_set readSet; + FD_ZERO(&readSet); + FD_SET(sock, &readSet); + TIMEVAL tv = {DNS_UDP_WAIT_SEC, DNS_UDP_WAIT_USEC}; + int sel = select(0, &readSet, nullptr, nullptr, &tv); + if (sel <= 0 || !FD_ISSET(sock, &readSet)) continue; // no reply yet — retransmit + + BYTE resp[512]; + int len = recv(sock, reinterpret_cast(resp), sizeof(resp), 0); + if (len == SOCKET_ERROR) { + if (WSAGetLastError() == WSAEMSGSIZE) { + // A larger-than-buffer datagram still arrived from the server. + len = sizeof(resp); + } else { + // e.g. WSAECONNRESET = ICMP port unreachable — try again. + continue; + } + } + // Valid reply: full header, matching transaction ID, QR bit set. + if (len >= 12 && + resp[0] == (BYTE)(txnId >> 8) && resp[1] == (BYTE)(txnId & 0xFF) && + (resp[2] & 0x80)) { + reachable = TRUE; + } } closesocket(sock); return reachable; } +// Routes a probe to the method configured for the slot. +static BOOL ProbeDnsServer(DWORD ipAddr, LONG method) { + switch (method) { + case PROBE_TCP_53: return ProbeTcpConnect(ipAddr, 53); + case PROBE_TCP_853: return ProbeTcpConnect(ipAddr, 853); + case PROBE_TCP_443: return ProbeTcpConnect(ipAddr, 443); + case PROBE_UDP_DNS: + default: return ProbeDnsUdp(ipAddr); + } +} + +static BOOL AnyDnsConfigured() { + return InterlockedOr(&g_dnsIp[0], 0) != 0 || InterlockedOr(&g_dnsIp[1], 0) != 0; +} + +static DnsOverall GetDnsOverall() { + int configured = 0; + int firstUp = -1; // position (0-based, configured-only) of the first answering server + bool anyUnknown = false; + for (int i = 0; i < 2; i++) { + if (InterlockedOr(&g_dnsIp[i], 0) == 0) continue; + LONG up = InterlockedOr(&g_dnsUp[i], 0); + int pos = configured++; + if (up == 1 && firstUp < 0) firstUp = pos; + if (up == -1) anyUnknown = true; + } + if (configured == 0) return DNS_NONE; + if (firstUp == 0) return DNS_OK; // effective primary answering + if (firstUp > 0) return DNS_DEGRADED; // only the fallback is answering + return anyUnknown ? DNS_CHECKING : DNS_DOWN; +} + // ============================================================================== // Feature C — Wifi-Shape Icon // ============================================================================== @@ -418,16 +594,18 @@ static void DrawWifiIcon(DWORD* px, int W, int H, BYTE r, BYTE g, BYTE b) { } } -HICON CreateColoredDotIcon(BOOL netUp, BOOL dnsUp, BOOL hasDns, BOOL pending) { +HICON CreateColoredDotIcon(BOOL netUp, DnsOverall dns, BOOL pending) { int cx = GetSystemMetrics(SM_CXSMICON); int cy = GetSystemMetrics(SM_CYSMICON); COLORREF iconColor; - if (pending) iconColor = RGB(240, 180, 0); // Yellow - else if (!netUp) iconColor = RGB(220, 50, 50); // Red - else if (!hasDns) iconColor = RGB(70, 130, 255); // Blue - else if (dnsUp) iconColor = RGB(60, 220, 60); // Green - else iconColor = RGB(160, 160, 160); // Grey + if (pending) iconColor = RGB(240, 180, 0); // Yellow — toggle/reset in progress + else if (!netUp) iconColor = RGB(220, 50, 50); // Red — network off + else if (dns == DNS_NONE) iconColor = RGB(70, 130, 255); // Blue — no DNS configured + else if (dns == DNS_OK || + dns == DNS_CHECKING) iconColor = RGB(60, 220, 60); // Green — healthy (or first check pending) + else if (dns == DNS_DEGRADED) iconColor = RGB(255, 120, 0); // Orange — only the fallback DNS answers + else iconColor = RGB(160, 160, 160); // Grey — DNS unreachable BITMAPINFO bmi = {}; bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); @@ -475,12 +653,28 @@ HICON CreateColoredDotIcon(BOOL netUp, BOOL dnsUp, BOOL hasDns, BOOL pending) { // Tray Icon // ============================================================================== +// Appends a "\nDNS1 8.8.8.8 ✓" status line to the tooltip (bounds-checked). +static void AppendDnsTipLine(WCHAR* tip, size_t cap, int slot, LPCWSTR label) { + LONG ip = InterlockedOr(&g_dnsIp[slot], 0); + if (ip == 0) return; + LONG up = InterlockedOr(&g_dnsUp[slot], 0); + + IN_ADDR ia = {}; + ia.s_addr = (ULONG)ip; + WCHAR ipStr[16] = L"?"; + InetNtopW(AF_INET, &ia, ipStr, ARRAYSIZE(ipStr)); + + LPCWSTR mark = (up == 1) ? L"✓" : (up == 0) ? L"✗" : L"…"; + size_t len = wcslen(tip); + if (len + 24 >= cap) return; // "\nDNS2 255.255.255.255 ✗" worst case + swprintf_s(tip + len, cap - len, L"\n%s %s %s", label, ipStr, mark); +} + void AddOrUpdateTrayIcon(HWND hWnd, BOOL enabled, BOOL isAdd) { - BOOL dnsUp = (g_dnsIsReachable == 1); - BOOL hasDns = (g_dnsServerIp != 0); + DnsOverall dns = GetDnsOverall(); BOOL pending = (g_isProcessingClick != 0); - HICON hNewIcon = CreateColoredDotIcon(enabled, dnsUp, hasDns, pending); + HICON hNewIcon = CreateColoredDotIcon(enabled, dns, pending); if (!hNewIcon) { Wh_Log(L"AddOrUpdateTrayIcon: CreateColoredDotIcon failed"); return; @@ -502,15 +696,21 @@ void AddOrUpdateTrayIcon(HWND hWnd, BOOL enabled, BOOL isAdd) { wsprintfW(nid.szTip, L"Net-Toggle: toggling\u2026"); } else if (g_isProcessingClick == 2) { wsprintfW(nid.szTip, L"Net-Toggle: refreshing\u2026"); - } else if (enabled) { - if (!hasDns) - wsprintfW(nid.szTip, L"Net-Toggle: ON"); - else if (dnsUp) - wsprintfW(nid.szTip, L"Net-Toggle: ON | DNS reachable"); - else - wsprintfW(nid.szTip, L"Net-Toggle: ON | DNS unreachable"); - } else { + } else if (!enabled) { wsprintfW(nid.szTip, L"Net-Toggle: OFF (click to enable)"); + } else { + switch (dns) { + case DNS_OK: wsprintfW(nid.szTip, L"Net-Toggle: ON | DNS OK"); break; + case DNS_DEGRADED: wsprintfW(nid.szTip, L"Net-Toggle: ON | DNS degraded"); break; + case DNS_DOWN: wsprintfW(nid.szTip, L"Net-Toggle: ON | DNS unreachable"); break; + case DNS_CHECKING: wsprintfW(nid.szTip, L"Net-Toggle: ON | checking DNS\u2026"); break; + case DNS_NONE: + default: wsprintfW(nid.szTip, L"Net-Toggle: ON"); break; + } + if (dns != DNS_NONE) { + AppendDnsTipLine(nid.szTip, ARRAYSIZE(nid.szTip), 0, L"DNS1"); + AppendDnsTipLine(nid.szTip, ARRAYSIZE(nid.szTip), 1, L"DNS2"); + } } nid.hIcon = hNewIcon; @@ -657,14 +857,13 @@ void ProcessNetworkReset() { PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)g_networkIsUp, 0); } - if (g_activeWorkerThread) { - CloseHandle(g_activeWorkerThread); - g_activeWorkerThread = nullptr; - } - DWORD threadId; - g_activeWorkerThread = CreateThread(nullptr, 0, ResetWorkerThreadProc, nullptr, 0, &threadId); - if (!g_activeWorkerThread) { + HANDLE hNewThread = CreateThread(nullptr, 0, ResetWorkerThreadProc, nullptr, 0, &threadId); + HANDLE hOldThread = (HANDLE)InterlockedExchangePointer((PVOID*)&g_activeWorkerThread, hNewThread); + if (hOldThread) { + CloseHandle(hOldThread); + } + if (!hNewThread) { InterlockedExchange(&g_isProcessingClick, 0); } } @@ -692,14 +891,13 @@ void ProcessTrayClick() { PostMessageW(g_trayHwnd, WM_UPDATE_TRAY_STATE, (WPARAM)g_networkIsUp, 0); } - if (g_activeWorkerThread) { - CloseHandle(g_activeWorkerThread); - g_activeWorkerThread = nullptr; - } - DWORD threadId; - g_activeWorkerThread = CreateThread(nullptr, 0, WorkerThreadProc, (LPVOID)(UINT_PTR)targetState, 0, &threadId); - if (!g_activeWorkerThread) { + HANDLE hNewThread = CreateThread(nullptr, 0, WorkerThreadProc, (LPVOID)(UINT_PTR)targetState, 0, &threadId); + HANDLE hOldThread = (HANDLE)InterlockedExchangePointer((PVOID*)&g_activeWorkerThread, hNewThread); + if (hOldThread) { + CloseHandle(hOldThread); + } + if (!hNewThread) { InterlockedExchange(&g_isProcessingClick, 0); } } @@ -708,10 +906,24 @@ void ProcessTrayClick() { // DNS Ping Handler // ============================================================================== -DWORD WINAPI DnsPingWorkerProc(LPVOID lpParam) { - DWORD ipAddr = (DWORD)(UINT_PTR)lpParam; - BOOL reachable = PingIp(ipAddr); - InterlockedExchange(&g_dnsIsReachable, reachable ? 1 : 0); +DWORD WINAPI DnsPingWorkerProc(LPVOID) { + // Probe each configured slot in priority order. Results publish per slot + // so the tooltip can show ✓/✗ per server and GetDnsOverall() can derive + // OK / DEGRADED / DOWN. + for (int i = 0; i < 2; i++) { + LONG ip = InterlockedOr(&g_dnsIp[i], 0); + if (ip == 0) { + InterlockedExchange(&g_dnsUp[i], -1); + continue; + } + // Bail out if the network dropped or the mod is unloading mid-pass. + if (InterlockedOr(&g_networkIsUp, 0) == 0) break; + if (g_shutdownEvent && WaitForSingleObject(g_shutdownEvent, 0) == WAIT_OBJECT_0) break; + + BOOL up = ProbeDnsServer((DWORD)ip, InterlockedOr(&g_dnsProbe[i], 0)); + InterlockedExchange(&g_dnsUp[i], up ? 1 : 0); + } + HWND hwnd = g_trayHwnd; if (hwnd) { PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, @@ -722,31 +934,83 @@ DWORD WINAPI DnsPingWorkerProc(LPVOID lpParam) { } void OnDnsPingTimer(HWND hWnd) { - if (g_dnsServerIp == 0) return; + if (!AnyDnsConfigured()) return; if (InterlockedOr(&g_networkIsUp, 0) == 0) { - Wh_Log(L"Network is OFF, skipping DNS ping"); - InterlockedExchange(&g_dnsIsReachable, 0); + Wh_Log(L"Network is OFF, skipping DNS check"); + // Status is unknowable while offline; mark unchecked so the icon shows + // "checking" (not a stale ✗) when the network comes back. + InterlockedExchange(&g_dnsUp[0], -1); + InterlockedExchange(&g_dnsUp[1], -1); PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); return; } if (InterlockedCompareExchange(&g_dnsWorkerRunning, 1, 0) != 0) { - Wh_Log(L"DNS ping already in progress, skipping"); + Wh_Log(L"DNS check already in progress, skipping"); return; } Wh_Log(L"Triggering DNS reachability check..."); - DWORD ipAddr = g_dnsServerIp; - HANDLE hThread = CreateThread(nullptr, 0, DnsPingWorkerProc, - (LPVOID)(UINT_PTR)ipAddr, 0, nullptr); + HANDLE hThread = CreateThread(nullptr, 0, DnsPingWorkerProc, nullptr, 0, nullptr); if (!hThread) { InterlockedExchange(&g_dnsWorkerRunning, 0); } else { - CloseHandle(hThread); + // Track the handle so mod unload can wait for an in-flight check. + HANDLE hOld = (HANDLE)InterlockedExchangePointer((PVOID*)&g_dnsWorkerThread, hThread); + if (hOld) CloseHandle(hOld); } } +// ============================================================================== +// Settings +// ============================================================================== + +// Parses one "server + check method" settings pair into a probe slot. +static void LoadDnsSlotSetting(int slot, LPCWSTR serverKey, LPCWSTR probeKey) { + DWORD newIp = 0; + auto server = WindhawkUtils::StringSetting::make(serverKey); + if (server.get()[0]) { + if (InetPtonW(AF_INET, server.get(), &newIp) != 1) { + Wh_Log(L"Invalid DNS server IP '%s' — slot %d disabled", server.get(), slot + 1); + newIp = 0; + } else { + Wh_Log(L"DNS slot %d: %s", slot + 1, server.get()); + } + } + + LONG method = PROBE_UDP_DNS; + auto probe = WindhawkUtils::StringSetting::make(probeKey); + if (wcscmp(probe.get(), L"tcp") == 0) method = PROBE_TCP_53; + else if (wcscmp(probe.get(), L"dot") == 0) method = PROBE_TCP_853; + else if (wcscmp(probe.get(), L"doh") == 0) method = PROBE_TCP_443; + + InterlockedExchange(&g_dnsIp[slot], (LONG)newIp); + InterlockedExchange(&g_dnsProbe[slot], method); + InterlockedExchange(&g_dnsUp[slot], -1); // force a fresh probe result +} + +static void LoadDnsSettings() { + LoadDnsSlotSetting(0, L"dnsServer", L"dnsProbe"); + LoadDnsSlotSetting(1, L"dnsServer2", L"dnsProbe2"); + + // An identical duplicate adds no signal — keep only the primary copy. + if (InterlockedOr(&g_dnsIp[1], 0) != 0 && + InterlockedOr(&g_dnsIp[1], 0) == InterlockedOr(&g_dnsIp[0], 0) && + InterlockedOr(&g_dnsProbe[1], 0) == InterlockedOr(&g_dnsProbe[0], 0)) { + Wh_Log(L"Secondary DNS duplicates primary — ignoring secondary"); + InterlockedExchange(&g_dnsIp[1], 0); + } + + if (!AnyDnsConfigured()) { + Wh_Log(L"No DNS server configured — DNS monitoring disabled"); + } + + int intervalSec = Wh_GetIntSetting(L"pingInterval"); + if (intervalSec < MIN_PING_INTERVAL_SEC) intervalSec = MIN_PING_INTERVAL_SEC; + g_pingIntervalMs = (DWORD)intervalSec * 1000; +} + // ============================================================================== // Feature D — Right-Click Context Menu // ============================================================================== @@ -839,29 +1103,15 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) return 0; } else if (msg == WM_SETTINGS_CHANGED) { // Re-read settings on the tray thread - DWORD newIp = 0; - { - auto dnsIp = WindhawkUtils::StringSetting::make(L"dnsServer"); - if (dnsIp.get()[0]) { - if (InetPtonW(AF_INET, dnsIp.get(), &newIp) != 1) { - Wh_Log(L"Invalid DNS server IP '%s' — DNS monitoring disabled", dnsIp.get()); - newIp = 0; - } - } - } - - InterlockedExchange(&g_dnsServerIp, newIp); - int intervalSec = Wh_GetIntSetting(L"pingInterval"); - if (intervalSec < MIN_PING_INTERVAL_SEC) intervalSec = MIN_PING_INTERVAL_SEC; - g_pingIntervalMs = (DWORD)intervalSec * 1000; + LoadDnsSettings(); KillTimer(hWnd, DNS_PING_TIMER_ID); - if (newIp != 0) { + if (AnyDnsConfigured()) { SetTimer(hWnd, DNS_PING_TIMER_ID, g_pingIntervalMs, nullptr); - // Immediate first ping + // Immediate first check OnDnsPingTimer(hWnd); } - AddOrUpdateTrayIcon(hWnd, (BOOL)(g_networkIsUp == 1), FALSE); + AddOrUpdateTrayIcon(hWnd, (BOOL)(InterlockedOr(&g_networkIsUp, 0) == 1), FALSE); return 0; } else if (msg == WM_TIMER) { if (wParam == DNS_PING_TIMER_ID) { @@ -888,7 +1138,7 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) } else if (msg == g_taskbarCreatedMsg && g_taskbarCreatedMsg != 0) { Wh_Log(L"Explorer restarted — re-adding tray icon"); AddOrUpdateTrayIcon(hWnd, (BOOL)(g_networkIsUp == 1), TRUE); - if (g_dnsServerIp != 0) OnDnsPingTimer(hWnd); // verify state asynchronously + if (AnyDnsConfigured()) OnDnsPingTimer(hWnd); // verify state asynchronously return 0; } return DefWindowProcW(hWnd, msg, wParam, lParam); @@ -1038,10 +1288,10 @@ DWORD WINAPI TrayThreadProc(LPVOID) { AddOrUpdateTrayIcon(hWnd, initialState, TRUE); Wh_Log(L"Tray icon installed. Initial state: %s", initialState ? L"ON" : L"OFF"); - // Start DNS ping timer if configured - if (g_dnsServerIp != 0) { + // Start DNS check timer if configured + if (AnyDnsConfigured()) { SetTimer(hWnd, DNS_PING_TIMER_ID, g_pingIntervalMs, nullptr); - // Immediate first ping + // Immediate first check OnDnsPingTimer(hWnd); } @@ -1124,25 +1374,7 @@ BOOL WhTool_ModInit() { } // Read initial settings - { - DWORD newIp = 0; - auto dnsIp = WindhawkUtils::StringSetting::make(L"dnsServer"); - if (dnsIp.get()[0]) { - if (InetPtonW(AF_INET, dnsIp.get(), &newIp) != 1) { - Wh_Log(L"Invalid DNS server IP '%s' — DNS monitoring disabled", dnsIp.get()); - newIp = 0; - } else { - Wh_Log(L"DNS server configured: %s", dnsIp.get()); - } - } else { - Wh_Log(L"No DNS server configured — DNS monitoring disabled"); - } - InterlockedExchange(&g_dnsServerIp, newIp); - } - - int intervalSec = Wh_GetIntSetting(L"pingInterval"); - if (intervalSec < MIN_PING_INTERVAL_SEC) intervalSec = MIN_PING_INTERVAL_SEC; - g_pingIntervalMs = (DWORD)intervalSec * 1000; + LoadDnsSettings(); // Create shutdown event (manual-reset, initially non-signalled) g_shutdownEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); @@ -1192,11 +1424,19 @@ void WhTool_ModUninit() { } // Step 3: wait for threads - HANDLE waitThreads[3] = {}; + // Atomically take ownership of the worker handles so a concurrent + // ProcessTrayClick/ProcessNetworkReset/OnDnsPingTimer on the tray thread + // can't be racing us on the same handle (each does its own + // InterlockedExchangePointer when spawning a new worker). + HANDLE hActiveWorker = (HANDLE)InterlockedExchangePointer((PVOID*)&g_activeWorkerThread, nullptr); + HANDLE hDnsWorker = (HANDLE)InterlockedExchangePointer((PVOID*)&g_dnsWorkerThread, nullptr); + + HANDLE waitThreads[4] = {}; DWORD waitCount = 0; if (g_trayThread) waitThreads[waitCount++] = g_trayThread; if (g_netWatchThread) waitThreads[waitCount++] = g_netWatchThread; - if (g_activeWorkerThread) waitThreads[waitCount++] = g_activeWorkerThread; + if (hActiveWorker) waitThreads[waitCount++] = hActiveWorker; + if (hDnsWorker) waitThreads[waitCount++] = hDnsWorker; if (waitCount > 0) { Wh_Log(L"Waiting for %d threads to exit...", waitCount); @@ -1219,9 +1459,11 @@ void WhTool_ModUninit() { CloseHandle(g_shutdownEvent); g_shutdownEvent = nullptr; } - if (g_activeWorkerThread) { - CloseHandle(g_activeWorkerThread); - g_activeWorkerThread = nullptr; + if (hActiveWorker) { + CloseHandle(hActiveWorker); + } + if (hDnsWorker) { + CloseHandle(hDnsWorker); } // Step 5: destroy current icon safely From a4e755bc2b0c27be54d11deba28e4b944604a45d Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Sat, 13 Jun 2026 14:05:28 +0300 Subject: [PATCH 50/56] Created MicroManager A lightweight tray icon that shows a mini task manager popup with live CPU, GPU and RAM usage, plus the single top-consuming process for each. --- mods/micromanager.wh.cpp | 1195 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 1195 insertions(+) create mode 100644 mods/micromanager.wh.cpp diff --git a/mods/micromanager.wh.cpp b/mods/micromanager.wh.cpp new file mode 100644 index 0000000000..ef9bce9de7 --- /dev/null +++ b/mods/micromanager.wh.cpp @@ -0,0 +1,1195 @@ +// ==WindhawkMod== +// @id micromanager +// @name MicroManager +// @description Mini task manager tray icon showing CPU, GPU and RAM usage with top consumers. +// @version 1.0.0 +// @author BlackPaw +// @github https://github.com/BlackPaw21 +// @donateUrl https://ko-fi.com/blackpaw21 +// @include windhawk.exe +// @compilerOptions -lpdh -lshell32 -lgdi32 -luser32 -lole32 -luuid -ladvapi32 -ldwmapi +// ==/WindhawkMod== + +// ==WindhawkModReadme== +/* +# MicroManager + +A lightweight tray icon that shows a mini task manager popup with live CPU, GPU +and RAM usage, plus the single top-consuming process for each. + +## How to Use + +1. **Left-click** the tray icon to open the popup showing: + - Total CPU usage and the top CPU-consuming process + - Total GPU usage and the top GPU-consuming process + - Total RAM usage and the top RAM-consuming process +2. **Click anywhere outside** the popup (or press **Esc**) to close it +3. **Hover** the tray icon to see a live `CPU / GPU / RAM` summary tooltip +4. **Right-click** the tray icon for options + +## Configuration + +Right-click the tray icon to change the refresh rate (0.3s / 0.5s / 1s / 3s). + +## Changelog + +### v1.0.0 +- Initial release. +- Left-click to see live CPU and GPU usage with the top process for each. +- Right-click for options. Update interval adjustable in Settings. +*/ +// ==/WindhawkModReadme== + +#define NOMINMAX +#include +#include +#include +#include +#include +#include +#include +#include +#include +// PDH constants that may not be defined in all SDK versions +#ifndef PDH_MORE_DATA +#define PDH_MORE_DATA ((PDH_STATUS)0x800007D2L) +#endif +#ifndef PDH_CSTATUS_VALID_DATA +#define PDH_CSTATUS_VALID_DATA ((LONG)0x00000000L) +#endif + +// DWM window corner preference (Windows 11). Declared here so the mod builds +// against older SDK headers; the call silently no-ops on Windows 10. +#ifndef DWMWA_WINDOW_CORNER_PREFERENCE +#define DWMWA_WINDOW_CORNER_PREFERENCE 33 +#endif +#ifndef DWMWCP_ROUND +#define DWMWCP_ROUND 2 +#endif + +#ifndef MIN +#define MIN(a, b) (((a) < (b)) ? (a) : (b)) +#endif + +// ─── Constants ──────────────────────────────────────────────────────────────── + +#define TRAY_ICON_ID 1 +#define WM_TRAY_CALLBACK (WM_USER + 1) +#define WM_TIMER_ID 1 + +// Base (96-DPI) popup geometry — scaled at runtime via Sc(). +#define POPUP_WIDTH 360 +#define POPUP_HEIGHT 112 +#define POPUP_ROWS 3 + +#define MAX_PROCESSES 1024 +#define PROCESS_BUF_SIZE (512 * 1024) + +// Re-attempt GPU (PDH) initialization roughly every this many ticks if it fails, +// instead of disabling GPU stats permanently for the session. +#define GPU_RETRY_TICKS 30 + +#define MENU_OPEN_WINDHAWK 9000 +#define MENU_INTERVAL_300MS 9100 +#define MENU_INTERVAL_500MS 9101 +#define MENU_INTERVAL_1S 9102 +#define MENU_INTERVAL_3S 9103 + +// Stable GUID that gives our tray icon a process-independent identity. +static const GUID MICROMANAGER_TRAY_GUID = + {0xFA6DAD73, 0xD350, 0x4BA8, {0x97, 0x3F, 0x5F, 0xA6, 0x0B, 0x15, 0x7B, 0x18}}; + +// ─── NtQuerySystemInformation via dynamic load ──────────────────────────────── + +#ifndef UNICODE_STRING +typedef struct _UNICODE_STRING { + USHORT Length; + USHORT MaximumLength; + PWSTR Buffer; +} UNICODE_STRING; +#endif + +typedef LONG NTSTATUS; + +#define STATUS_INFO_LENGTH_MISMATCH ((NTSTATUS)0xC0000004L) +#define SystemProcessInformation 5 + +typedef struct { + ULONG NextEntryOffset; + ULONG NumberOfThreads; + LARGE_INTEGER WorkingSetPrivateSize; + ULONG HardFaultCount; + ULONG NumberOfThreadsHighWatermark; + LARGE_INTEGER CycleTime; + LARGE_INTEGER CreateTime; + LARGE_INTEGER UserTime; + LARGE_INTEGER KernelTime; + UNICODE_STRING ImageName; + LONG BasePriority; + HANDLE UniqueProcessId; + HANDLE InheritedFromUniqueProcessId; + ULONG HandleCount; + ULONG SessionId; + ULONG_PTR Reserved1; + SIZE_T PeakVirtualSize; + SIZE_T VirtualSize; + ULONG PageFaultCount; + SIZE_T PeakWorkingSetSize; + SIZE_T WorkingSetSize; + SIZE_T QuotaPeakPagedPoolUsage; + SIZE_T QuotaPagedPoolUsage; + SIZE_T QuotaPeakNonPagedPoolUsage; + SIZE_T QuotaNonPagedPoolUsage; + SIZE_T PagefileUsage; + SIZE_T PeakPagefileUsage; + SIZE_T PrivatePageCount; + LARGE_INTEGER ReadOperationCount; + LARGE_INTEGER WriteOperationCount; + LARGE_INTEGER OtherOperationCount; + LARGE_INTEGER ReadTransferCount; + LARGE_INTEGER WriteTransferCount; + LARGE_INTEGER OtherTransferCount; +} MY_SYSTEM_PROCESS_INFO; + +typedef NTSTATUS (WINAPI *NtQuerySystemInformation_t)(ULONG, PVOID, ULONG, PULONG); + +// ─── Globals ────────────────────────────────────────────────────────────────── + +static HANDLE g_trayThread = nullptr; +static volatile HWND g_trayHwnd = nullptr; +static HWND g_popupHwnd = nullptr; +static HINSTANCE g_hInstance = nullptr; +static WCHAR g_windhawkPath[MAX_PATH] = {}; +static WCHAR g_ddoresDllPath[MAX_PATH] = {}; + +static DWORD g_updateMs = 1000; +static int g_dpi = 96; // popup DPI, refreshed per show +static ULONGLONG g_totalPhys = 0; // total physical RAM, bytes + +// Cached stats (updated on timer, read on popup paint) +static CRITICAL_SECTION g_statsLock; +static int g_totalCpu = -1; +static int g_topCpuPct = 0; +static WCHAR g_topCpuName[64] = {}; +static int g_totalGpu = -1; +static int g_topGpuPct = 0; +static WCHAR g_topGpuName[64] = {}; +static int g_totalRam = -1; +static int g_topRamPct = 0; +static WCHAR g_topRamName[64] = {}; + +// GPU PDH handles +static PDH_HQUERY g_gpuQuery = nullptr; +static PDH_HCOUNTER g_gpuCounter = nullptr; +static int g_gpuRetryIn = 0; // ticks until next init attempt + +// Cached NtQuerySystemInformation pointer (resolved once) +static NtQuerySystemInformation_t g_ntQuery = nullptr; + +// Per-PID GPU aggregation scratch (tray thread only — hoisted off the stack) +struct GpuProc { DWORD pid; double total; }; +static GpuProc g_gpuProcs[MAX_PROCESSES]; + +// Previous sample for CPU delta +static FILETIME g_prevIdle = {}; +static FILETIME g_prevKernel = {}; +static FILETIME g_prevUser = {}; +static struct { + DWORD pid; + LONGLONG time; + WCHAR name[64]; +} g_prevProcs[MAX_PROCESSES]; +static int g_prevProcCount = 0; +static BOOL g_hasPrevSample = FALSE; + +static HICON g_iconEnabled = nullptr; +static HFONT g_hPopupFont = nullptr; +static int g_fontDpi = 0; + +static UINT g_taskbarCreatedMsg = 0; +static WCHAR g_lastTip[128] = {}; + +// ─── DPI Helpers ────────────────────────────────────────────────────────────── + +static int Sc(int v) { return MulDiv(v, g_dpi, 96); } + +// ─── Font Helpers ───────────────────────────────────────────────────────────── + +static void EnsureFont() { + if (g_hPopupFont && g_fontDpi == g_dpi) return; + if (g_hPopupFont) { DeleteObject(g_hPopupFont); g_hPopupFont = nullptr; } + g_hPopupFont = CreateFontW(-Sc(13), 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, + ANSI_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, + DEFAULT_QUALITY, DEFAULT_PITCH | FF_DONTCARE, L"Segoe UI"); + g_fontDpi = g_dpi; +} + +// ─── CPU Sampling ───────────────────────────────────────────────────────────── + +static NtQuerySystemInformation_t GetNtQuery() { + if (!g_ntQuery) { + HMODULE h = GetModuleHandleW(L"ntdll.dll"); + if (h) + g_ntQuery = (NtQuerySystemInformation_t)GetProcAddress( + h, "NtQuerySystemInformation"); + } + return g_ntQuery; +} + +static int CollectProcessInfo(MY_SYSTEM_PROCESS_INFO** outBuf) { + NtQuerySystemInformation_t NtQuery = GetNtQuery(); + if (!NtQuery) return 0; + + ULONG bufSize = PROCESS_BUF_SIZE; + *outBuf = (MY_SYSTEM_PROCESS_INFO*)malloc(bufSize); + if (!*outBuf) return 0; + + NTSTATUS status = NtQuery(SystemProcessInformation, *outBuf, bufSize, &bufSize); + if (status == STATUS_INFO_LENGTH_MISMATCH) { + free(*outBuf); + bufSize *= 2; + *outBuf = (MY_SYSTEM_PROCESS_INFO*)malloc(bufSize); + if (!*outBuf) return 0; + status = NtQuery(SystemProcessInformation, *outBuf, bufSize, &bufSize); + } + if (status < 0) { + free(*outBuf); + *outBuf = nullptr; + return 0; + } + return 1; +} + +// ─── GPU Sampling (PDH) ─────────────────────────────────────────────────────── + +static void InitGpuQuery() { + if (g_gpuQuery) return; + + PDH_STATUS ps = PdhOpenQueryW(nullptr, 0, &g_gpuQuery); + if (ps != ERROR_SUCCESS) { + g_gpuQuery = nullptr; + g_gpuRetryIn = GPU_RETRY_TICKS; + return; + } + + ps = PdhAddEnglishCounterW(g_gpuQuery, + L"\\GPU Engine(*)\\Utilization Percentage", 0, &g_gpuCounter); + if (ps != ERROR_SUCCESS) { + PdhCloseQuery(g_gpuQuery); + g_gpuQuery = nullptr; + g_gpuCounter = nullptr; + g_gpuRetryIn = GPU_RETRY_TICKS; + return; + } + + // Prime the baseline — PDH rate counters return 0 on the very first collection + // without a prior sample to delta against. One collection here means the first + // real tick in CollectGpuStats produces accurate data instead of 0%. + PdhCollectQueryData(g_gpuQuery); +} + +static void CollectGpuStats(int* outTotal, int* outTopPct, WCHAR* outTopName, int nameLen) { + *outTotal = -1; + *outTopPct = 0; + outTopName[0] = L'\0'; + + // Lazy init with bounded retry (no permanent "GPU disabled" latch). + if (!g_gpuQuery) { + if (g_gpuRetryIn > 0) { g_gpuRetryIn--; return; } + InitGpuQuery(); + if (!g_gpuQuery) return; // still failing — try again after the backoff + } + + PdhCollectQueryData(g_gpuQuery); + + DWORD bufSize = 0; + DWORD itemCount = 0; + PDH_STATUS ps = PdhGetFormattedCounterArrayW(g_gpuCounter, PDH_FMT_DOUBLE, + &bufSize, &itemCount, nullptr); + if (ps != PDH_MORE_DATA || bufSize == 0 || itemCount == 0) return; + + PDH_FMT_COUNTERVALUE_ITEM_W* items = (PDH_FMT_COUNTERVALUE_ITEM_W*)malloc(bufSize); + if (!items) return; + + ps = PdhGetFormattedCounterArrayW(g_gpuCounter, PDH_FMT_DOUBLE, + &bufSize, &itemCount, items); + if (ps != ERROR_SUCCESS) { free(items); return; } + + // Aggregate per PID + int procCount = 0; + double grandTotal = 0; + + for (DWORD i = 0; i < itemCount; i++) { + if (items[i].FmtValue.CStatus != PDH_CSTATUS_VALID_DATA) continue; + + double val = items[i].FmtValue.doubleValue; + grandTotal += val; + + // Parse instance: "pid_1234_luid_0x00000000_phys_0_eng_0_enum_1" + PCWSTR s = items[i].szName; + if (wcsncmp(s, L"pid_", 4) != 0) continue; + DWORD pid = 0; + for (s += 4; *s >= L'0' && *s <= L'9'; s++) + pid = pid * 10 + (*s - L'0'); + + int j; + for (j = 0; j < procCount; j++) { + if (g_gpuProcs[j].pid == pid) { g_gpuProcs[j].total += val; break; } + } + if (j >= procCount && procCount < MAX_PROCESSES) { + g_gpuProcs[procCount].pid = pid; + g_gpuProcs[procCount].total = val; + procCount++; + } + } + free(items); + + *outTotal = (int)(grandTotal + 0.5); + + // Find top GPU process + int topIdx = -1; + double topVal = 0; + for (int i = 0; i < procCount; i++) { + if (g_gpuProcs[i].total > topVal) { + topVal = g_gpuProcs[i].total; + topIdx = i; + } + } + if (topIdx >= 0) { + *outTopPct = (int)(topVal + 0.5); + + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap != INVALID_HANDLE_VALUE) { + PROCESSENTRY32W pe = {sizeof(pe)}; + if (Process32FirstW(snap, &pe)) { + do { + if (pe.th32ProcessID == g_gpuProcs[topIdx].pid) { + wcscpy_s(outTopName, nameLen, pe.szExeFile); + break; + } + } while (Process32NextW(snap, &pe)); + } + CloseHandle(snap); + } + } +} + +// ─── Data Refresh ───────────────────────────────────────────────────────────── + +static void RefreshData() { + int newTotalCpu = -1, newTopCpuPct = 0; + WCHAR newTopCpuName[64] = {}; + int newTotalGpu = -1, newTopGpuPct = 0; + WCHAR newTopGpuName[64] = {}; + int newTotalRam = -1, newTopRamPct = 0; + WCHAR newTopRamName[64] = {}; + + // Total RAM load is cheap and needs no prior sample — read it every tick. + MEMORYSTATUSEX mem = {sizeof(mem)}; + if (GlobalMemoryStatusEx(&mem)) newTotalRam = (int)mem.dwMemoryLoad; + + // Collect CPU + FILETIME nowIdle, nowKernel, nowUser; + GetSystemTimes(&nowIdle, &nowKernel, &nowUser); + + ULARGE_INTEGER ui, uk, uu, pi, pk, pu; + ui.LowPart = nowIdle.dwLowDateTime; ui.HighPart = nowIdle.dwHighDateTime; + uk.LowPart = nowKernel.dwLowDateTime; uk.HighPart = nowKernel.dwHighDateTime; + uu.LowPart = nowUser.dwLowDateTime; uu.HighPart = nowUser.dwHighDateTime; + pi.LowPart = g_prevIdle.dwLowDateTime; pi.HighPart = g_prevIdle.dwHighDateTime; + pk.LowPart = g_prevKernel.dwLowDateTime; pk.HighPart = g_prevKernel.dwHighDateTime; + pu.LowPart = g_prevUser.dwLowDateTime; pu.HighPart = g_prevUser.dwHighDateTime; + + double totalDelta = (double)(uk.QuadPart - pk.QuadPart) + (double)(uu.QuadPart - pu.QuadPart); + double idleDelta = (double)(ui.QuadPart - pi.QuadPart); + + if (g_hasPrevSample && totalDelta > 0) { + newTotalCpu = (int)(100.0 - 100.0 * idleDelta / totalDelta + 0.5); + + MY_SYSTEM_PROCESS_INFO* buf = nullptr; + if (CollectProcessInfo(&buf) && buf) { + MY_SYSTEM_PROCESS_INFO* p = buf; + LONGLONG bestTime = 0; + WCHAR bestCpuName[64] = {}; + ULONGLONG bestWs = 0; + WCHAR bestRamName[64] = {}; + int count = 0; + + while (true) { + LONGLONG curTime = p->KernelTime.QuadPart + p->UserTime.QuadPart; + + LONGLONG prevTime = 0; + BOOL foundInPrev = FALSE; + for (int i = 0; i < g_prevProcCount; i++) { + if (g_prevProcs[i].pid == (DWORD)(ULONG_PTR)p->UniqueProcessId) { + prevTime = g_prevProcs[i].time; + foundInPrev = TRUE; + break; + } + } + // Only count delta for processes seen last tick; new/overflow processes + // would otherwise show their entire boot-time CPU as a single-tick spike. + LONGLONG delta = foundInPrev ? (curTime - prevTime) : 0; + if (delta > bestTime && p->ImageName.Buffer) { + bestTime = delta; + wcsncpy_s(bestCpuName, p->ImageName.Buffer, + MIN(p->ImageName.Length / sizeof(WCHAR), 63)); + bestCpuName[63] = L'\0'; + } + + // Top RAM consumer by working set (absolute — no prior sample needed). + if (p->ImageName.Buffer && (ULONGLONG)p->WorkingSetSize > bestWs) { + bestWs = (ULONGLONG)p->WorkingSetSize; + wcsncpy_s(bestRamName, p->ImageName.Buffer, + MIN(p->ImageName.Length / sizeof(WCHAR), 63)); + bestRamName[63] = L'\0'; + } + + if (count < MAX_PROCESSES) { + g_prevProcs[count].pid = (DWORD)(ULONG_PTR)p->UniqueProcessId; + g_prevProcs[count].time = curTime; + if (p->ImageName.Buffer) { + wcsncpy_s(g_prevProcs[count].name, p->ImageName.Buffer, + MIN(p->ImageName.Length / sizeof(WCHAR), 63)); + g_prevProcs[count].name[63] = L'\0'; + } else { + g_prevProcs[count].name[0] = L'\0'; + } + count++; + } + + if (p->NextEntryOffset == 0) break; + p = (MY_SYSTEM_PROCESS_INFO*)((BYTE*)p + p->NextEntryOffset); + } + g_prevProcCount = count; + + if (bestTime > 0) { + double pct = 100.0 * (double)bestTime / totalDelta; + if (pct >= 0.5) { + newTopCpuPct = (int)(pct + 0.5); + wcscpy_s(newTopCpuName, bestCpuName); + } + } + + if (bestWs > 0 && g_totalPhys > 0) { + int pct = (int)((bestWs * 100ULL) / g_totalPhys); + newTopRamPct = pct < 1 ? 1 : pct; // the top consumer is always shown + wcscpy_s(newTopRamName, bestRamName); + } + + free(buf); + } + } + + g_prevIdle = nowIdle; g_prevKernel = nowKernel; g_prevUser = nowUser; + g_hasPrevSample = TRUE; + + CollectGpuStats(&newTotalGpu, &newTopGpuPct, newTopGpuName, 64); + + EnterCriticalSection(&g_statsLock); + g_totalCpu = newTotalCpu; + g_topCpuPct = newTopCpuPct; + wcscpy_s(g_topCpuName, newTopCpuName); + g_totalGpu = newTotalGpu; + g_topGpuPct = newTopGpuPct; + wcscpy_s(g_topGpuName, newTopGpuName); + g_totalRam = newTotalRam; + g_topRamPct = newTopRamPct; + wcscpy_s(g_topRamName, newTopRamName); + LeaveCriticalSection(&g_statsLock); + + if (g_popupHwnd && IsWindowVisible(g_popupHwnd)) { + InvalidateRect(g_popupHwnd, nullptr, TRUE); + } + + // Live summary tooltip — refresh only when the displayed numbers change. + if (g_trayHwnd) { + WCHAR cpuS[8], gpuS[8], ramS[8], tip[128]; + if (newTotalCpu < 0) wcscpy_s(cpuS, L"--"); else swprintf_s(cpuS, L"%d%%", newTotalCpu); + if (newTotalGpu < 0) wcscpy_s(gpuS, L"--"); else swprintf_s(gpuS, L"%d%%", newTotalGpu); + if (newTotalRam < 0) wcscpy_s(ramS, L"--"); else swprintf_s(ramS, L"%d%%", newTotalRam); + swprintf_s(tip, L"CPU %s GPU %s RAM %s", cpuS, gpuS, ramS); + + if (wcscmp(tip, g_lastTip) != 0) { + wcscpy_s(g_lastTip, tip); + NOTIFYICONDATAW nid = {sizeof(nid)}; + nid.hWnd = (HWND)g_trayHwnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_TIP | NIF_GUID; + nid.guidItem = MICROMANAGER_TRAY_GUID; + lstrcpynW(nid.szTip, tip, 128); + Shell_NotifyIconW(NIM_MODIFY, &nid); + } + } +} + +// ─── Popup Window Procedure ─────────────────────────────────────────────────── + +static LRESULT CALLBACK PopupWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { + switch (msg) { + case WM_ACTIVATE: + if (LOWORD(wParam) == WA_INACTIVE) { + ShowWindow(hWnd, SW_HIDE); + } + return 0; + + case WM_PAINT: { + // Snapshot stats under the lock, then paint without holding it. + int totals[POPUP_ROWS], topPcts[POPUP_ROWS]; + WCHAR topNames[POPUP_ROWS][64]; + EnterCriticalSection(&g_statsLock); + totals[0] = g_totalCpu; topPcts[0] = g_topCpuPct; wcscpy_s(topNames[0], g_topCpuName); + totals[1] = g_totalGpu; topPcts[1] = g_topGpuPct; wcscpy_s(topNames[1], g_topGpuName); + totals[2] = g_totalRam; topPcts[2] = g_topRamPct; wcscpy_s(topNames[2], g_topRamName); + LeaveCriticalSection(&g_statsLock); + + static const PCWSTR kLabels[POPUP_ROWS] = { L"CPU", L"GPU", L"RAM" }; + static const PCWSTR kEmptyText[POPUP_ROWS] = { L"Idle", L"Idle", L"\x2014" }; + const COLORREF kTotalColors[POPUP_ROWS] = { + RGB(0, 200, 255), RGB(0, 220, 100), RGB(255, 180, 70) }; + + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hWnd, &ps); + + HBRUSH bgBrush = CreateSolidBrush(RGB(32, 32, 32)); + RECT rc; + GetClientRect(hWnd, &rc); + FillRect(hdc, &rc, bgBrush); + DeleteObject(bgBrush); + + HPEN borderPen = CreatePen(PS_SOLID, 1, RGB(64, 64, 64)); + HPEN oldPen = (HPEN)SelectObject(hdc, borderPen); + HBRUSH oldBrush = (HBRUSH)SelectObject(hdc, GetStockObject(NULL_BRUSH)); + Rectangle(hdc, 0, 0, rc.right, rc.bottom); + SelectObject(hdc, oldPen); + SelectObject(hdc, oldBrush); + DeleteObject(borderPen); + + SetBkMode(hdc, TRANSPARENT); + EnsureFont(); + SelectObject(hdc, g_hPopupFont); + + for (int row = 0; row < POPUP_ROWS; row++) { + int y = Sc(12) + row * Sc(32); + int totalVal = totals[row]; + int topPctVal = topPcts[row]; + PCWSTR topName = topNames[row]; + + SetTextColor(hdc, RGB(140, 140, 140)); + WCHAR labelBuf[16]; + swprintf_s(labelBuf, L"%s:", kLabels[row]); + TextOutW(hdc, Sc(12), y, labelBuf, (int)wcslen(labelBuf)); + + SetTextColor(hdc, kTotalColors[row]); + WCHAR totalBuf[16]; + if (totalVal < 0) + swprintf_s(totalBuf, L"..%%"); + else + swprintf_s(totalBuf, L"%d%%", totalVal); + TextOutW(hdc, Sc(60), y, totalBuf, (int)wcslen(totalBuf)); + + SetTextColor(hdc, RGB(220, 220, 220)); + if (topName[0] != L'\0' && topPctVal > 0) { + WCHAR lineBuf[256]; + swprintf_s(lineBuf, L"%s %d%%", topName, topPctVal); + TextOutW(hdc, Sc(130), y, lineBuf, (int)wcslen(lineBuf)); + } else if (totalVal >= 0) { + TextOutW(hdc, Sc(130), y, kEmptyText[row], (int)wcslen(kEmptyText[row])); + } else { + PCWSTR collecting = L"Collecting..."; + TextOutW(hdc, Sc(130), y, collecting, (int)wcslen(collecting)); + } + } + + EndPaint(hWnd, &ps); + return 0; + } + + case WM_NCHITTEST: { + LRESULT hit = DefWindowProcW(hWnd, msg, wParam, lParam); + if (hit == HTCLIENT) return HTCAPTION; + return hit; + } + + case WM_KEYDOWN: + if (wParam == VK_ESCAPE) ShowWindow(hWnd, SW_HIDE); + break; + + case WM_DESTROY: + break; + } + return DefWindowProcW(hWnd, msg, wParam, lParam); +} + +// ─── Show/Hide Popup ────────────────────────────────────────────────────────── + +static void ShowPopup(HWND hTrayWnd) { + if (!g_popupHwnd) { + WNDCLASSW wc = {0}; + wc.lpfnWndProc = PopupWndProc; + wc.hInstance = g_hInstance; + wc.hCursor = LoadCursorW(nullptr, IDC_ARROW); + wc.lpszClassName = L"MicroManagerPopupClass"; + wc.hbrBackground = (HBRUSH)GetStockObject(NULL_BRUSH); + if (!RegisterClassW(&wc) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) { + Wh_Log(L"Popup RegisterClassW failed (%u)", GetLastError()); + return; + } + + g_popupHwnd = CreateWindowExW( + WS_EX_TOOLWINDOW | WS_EX_TOPMOST, + wc.lpszClassName, L"MicroManager", + WS_POPUP | WS_BORDER, + 0, 0, Sc(POPUP_WIDTH), Sc(POPUP_HEIGHT), + nullptr, nullptr, g_hInstance, nullptr); + } + + if (!g_popupHwnd) return; + + // Refresh DPI for the monitor the icon lives on, then rebuild the font. + UINT dpi = GetDpiForWindow(g_popupHwnd); + g_dpi = dpi ? (int)dpi : 96; + EnsureFont(); + + NOTIFYICONIDENTIFIER nii = {sizeof(nii)}; + nii.guidItem = MICROMANAGER_TRAY_GUID; // icon is registered with NIF_GUID + RECT iconRect; + int w = Sc(POPUP_WIDTH), h = Sc(POPUP_HEIGHT); + int x, y; + + if (SUCCEEDED(Shell_NotifyIconGetRect(&nii, &iconRect))) { + x = iconRect.left - w + (iconRect.right - iconRect.left) / 2; + y = iconRect.top - h - Sc(4); + } else { + POINT pt; + GetCursorPos(&pt); + x = pt.x - w / 2; + y = pt.y - h - Sc(4); + } + + SetWindowPos(g_popupHwnd, HWND_TOPMOST, x, y, w, h, SWP_SHOWWINDOW); + + // Rounded corners on Windows 11 (silently ignored on Windows 10). + int corner = DWMWCP_ROUND; + DwmSetWindowAttribute(g_popupHwnd, DWMWA_WINDOW_CORNER_PREFERENCE, + &corner, sizeof(corner)); + + // Take foreground activation so a click anywhere outside the popup fires + // WM_ACTIVATE/WA_INACTIVE and auto-hides it. SetFocus alone does not cross + // the foreground boundary from the tray thread, so the popup would never + // become "active" and would only close after being clicked first. This + // mirrors AudioSwap's VolumePopup::Show. + SetForegroundWindow(g_popupHwnd); +} + +// ─── System Theme + Context Menu ───────────────────────────────────────────── + +static bool IsSystemDarkMode() { + DWORD value = 1, size = sizeof(value); + RegGetValueW(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + L"AppsUseLightTheme", RRF_RT_REG_DWORD, nullptr, &value, &size); + return value == 0; +} + +static void ApplyContextMenuTheme(HWND hWnd, bool dark) { + HMODULE ux = GetModuleHandleW(L"uxtheme.dll"); + if (!ux) return; + using Fn135 = int(WINAPI*)(int); + using Fn133 = bool(WINAPI*)(HWND, bool); + using Fn136 = void(WINAPI*)(); + if (auto f = (Fn135)GetProcAddress(ux, MAKEINTRESOURCEA(135))) f(dark ? 2 : 0); + if (auto f = (Fn133)GetProcAddress(ux, MAKEINTRESOURCEA(133))) f(hWnd, dark); + if (auto f = (Fn136)GetProcAddress(ux, MAKEINTRESOURCEA(136))) f(); +} + +static void SaveIntervalMs(DWORD ms) { + HKEY hKey; + if (RegCreateKeyExW(HKEY_CURRENT_USER, L"Software\\BlackPaw\\MicroManager", + 0, nullptr, 0, KEY_SET_VALUE, nullptr, &hKey, nullptr) == ERROR_SUCCESS) { + RegSetValueExW(hKey, L"UpdateIntervalMs", 0, REG_DWORD, (BYTE*)&ms, sizeof(ms)); + RegCloseKey(hKey); + } +} + +static DWORD LoadIntervalMs() { + DWORD ms = 1000, size = sizeof(ms); + RegGetValueW(HKEY_CURRENT_USER, L"Software\\BlackPaw\\MicroManager", + L"UpdateIntervalMs", RRF_RT_REG_DWORD, nullptr, &ms, &size); + // Clamp to valid options in case registry has a stale value + if (ms != 300 && ms != 500 && ms != 1000 && ms != 3000) ms = 1000; + return ms; +} + +// ─── Tray Window Procedure ──────────────────────────────────────────────────── + +static LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { + switch (msg) { + case WM_CREATE: + RefreshData(); + SetTimer(hWnd, WM_TIMER_ID, g_updateMs, nullptr); + return 0; + + case WM_TIMER: + if (wParam == WM_TIMER_ID) { + RefreshData(); + } + return 0; + + case WM_TRAY_CALLBACK: + switch (LOWORD(lParam)) { + case WM_LBUTTONUP: + if (g_popupHwnd && IsWindowVisible(g_popupHwnd)) { + ShowWindow(g_popupHwnd, SW_HIDE); + } else { + ShowPopup(hWnd); + } + break; + + case WM_RBUTTONUP: { + HMENU hMenu = CreatePopupMenu(); + + WCHAR statusText[128]; + EnterCriticalSection(&g_statsLock); + int cpu = g_totalCpu; + int gpu = g_totalGpu; + int ram = g_totalRam; + LeaveCriticalSection(&g_statsLock); + + if (cpu >= 0 && gpu >= 0 && ram >= 0) + swprintf_s(statusText, L"CPU: %d%% GPU: %d%% RAM: %d%%", cpu, gpu, ram); + else + lstrcpyW(statusText, L"Collecting..."); + AppendMenuW(hMenu, MF_STRING | MF_GRAYED, 0, statusText); + AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); + + UINT chk300 = (g_updateMs == 300) ? (MF_STRING | MF_CHECKED) : MF_STRING; + UINT chk500 = (g_updateMs == 500) ? (MF_STRING | MF_CHECKED) : MF_STRING; + UINT chk1s = (g_updateMs == 1000) ? (MF_STRING | MF_CHECKED) : MF_STRING; + UINT chk3s = (g_updateMs == 3000) ? (MF_STRING | MF_CHECKED) : MF_STRING; + AppendMenuW(hMenu, chk300, MENU_INTERVAL_300MS, L"Refresh: 0.3s"); + AppendMenuW(hMenu, chk500, MENU_INTERVAL_500MS, L"Refresh: 0.5s"); + AppendMenuW(hMenu, chk1s, MENU_INTERVAL_1S, L"Refresh: 1s"); + AppendMenuW(hMenu, chk3s, MENU_INTERVAL_3S, L"Refresh: 3s"); + AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr); + AppendMenuW(hMenu, MF_STRING, MENU_OPEN_WINDHAWK, L"Open Windhawk"); + + POINT pt; + GetCursorPos(&pt); + bool dark = IsSystemDarkMode(); + ApplyContextMenuTheme(hWnd, dark); + SetForegroundWindow(hWnd); + int cmd = TrackPopupMenu(hMenu, TPM_RETURNCMD | TPM_RIGHTBUTTON | + TPM_BOTTOMALIGN | TPM_RIGHTALIGN, + pt.x, pt.y, 0, hWnd, nullptr); + PostMessageW(hWnd, WM_NULL, 0, 0); + DestroyMenu(hMenu); + + DWORD newMs = 0; + if (cmd == MENU_INTERVAL_300MS) newMs = 300; + else if (cmd == MENU_INTERVAL_500MS) newMs = 500; + else if (cmd == MENU_INTERVAL_1S) newMs = 1000; + else if (cmd == MENU_INTERVAL_3S) newMs = 3000; + else if (cmd == MENU_OPEN_WINDHAWK) { + SHELLEXECUTEINFOW sei = {sizeof(sei)}; + sei.lpFile = g_windhawkPath; + sei.nShow = SW_SHOWNORMAL; + ShellExecuteExW(&sei); + } + + if (newMs > 0 && newMs != g_updateMs) { + g_updateMs = newMs; + KillTimer(hWnd, WM_TIMER_ID); + SetTimer(hWnd, WM_TIMER_ID, newMs, nullptr); + SaveIntervalMs(newMs); + } + break; + } + } + return 0; + + case WM_CLOSE: + // Destroy popup on the tray thread (its owner) before destroying the tray window + if (g_popupHwnd) { DestroyWindow(g_popupHwnd); g_popupHwnd = nullptr; } + DestroyWindow(hWnd); + return 0; + + case WM_DESTROY: + KillTimer(hWnd, WM_TIMER_ID); + { + NOTIFYICONDATAW nid = {sizeof(nid)}; + nid.hWnd = hWnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_GUID; + nid.guidItem = MICROMANAGER_TRAY_GUID; + Shell_NotifyIconW(NIM_DELETE, &nid); + } + PostQuitMessage(0); + return 0; + } + + // Re-add tray icon after Explorer restarts + if (msg == g_taskbarCreatedMsg && g_taskbarCreatedMsg != 0) { + g_lastTip[0] = L'\0'; // force the tooltip to be re-pushed on next refresh + NOTIFYICONDATAW nid = {sizeof(nid)}; + nid.hWnd = hWnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP | NIF_GUID; + nid.guidItem = MICROMANAGER_TRAY_GUID; + nid.uCallbackMessage = WM_TRAY_CALLBACK; + lstrcpynW(nid.szTip, L"MicroManager", 128); + if (!g_iconEnabled) + ExtractIconExW(g_ddoresDllPath, 28, nullptr, &g_iconEnabled, 1); + nid.hIcon = g_iconEnabled ? g_iconEnabled : LoadIconW(nullptr, IDI_APPLICATION); + if (Shell_NotifyIconW(NIM_ADD, &nid)) { + NOTIFYICONDATAW nidVer = {sizeof(nidVer)}; + nidVer.hWnd = hWnd; + nidVer.uID = TRAY_ICON_ID; + nidVer.uFlags = NIF_GUID; + nidVer.guidItem = MICROMANAGER_TRAY_GUID; + nidVer.uVersion = NOTIFYICON_VERSION_4; + Shell_NotifyIconW(NIM_SETVERSION, &nidVer); + } + return 0; + } + + return DefWindowProcW(hWnd, msg, wParam, lParam); +} + +// ─── Tray Thread ────────────────────────────────────────────────────────────── + +static DWORD WINAPI TrayThreadProc(LPVOID) { + // Per-monitor DPI awareness so the popup renders crisply on scaled displays. + SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + + HRESULT hrCo = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + if (FAILED(hrCo) && hrCo != RPC_E_CHANGED_MODE) { + Wh_Log(L"TrayThread: CoInitializeEx failed (0x%X)", hrCo); + return 1; + } + + g_taskbarCreatedMsg = RegisterWindowMessageW(L"TaskbarCreated"); + + WNDCLASSW wc = {0}; + wc.lpfnWndProc = TrayWndProc; + wc.hInstance = g_hInstance; + wc.lpszClassName = L"MicroManagerTrayClass"; + if (!RegisterClassW(&wc) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) { + Wh_Log(L"Tray RegisterClassW failed (%u)", GetLastError()); + if (hrCo != RPC_E_CHANGED_MODE) CoUninitialize(); + return 1; + } + + g_trayHwnd = CreateWindowExW( + WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, + wc.lpszClassName, L"MicroManager", + WS_POPUP, + 0, 0, 1, 1, nullptr, nullptr, g_hInstance, nullptr); + if (!g_trayHwnd) { + if (hrCo != RPC_E_CHANGED_MODE) CoUninitialize(); + return 1; + } + + // Unique AUMID so the OS doesn't group this icon with Windhawk's main window + IPropertyStore* pps = nullptr; + if (SUCCEEDED(SHGetPropertyStoreForWindow(g_trayHwnd, IID_PPV_ARGS(&pps)))) { + PROPVARIANT var; + PropVariantInit(&var); + var.vt = VT_LPWSTR; + var.pwszVal = (LPWSTR)CoTaskMemAlloc(MAX_PATH * sizeof(WCHAR)); + if (var.pwszVal) { + lstrcpyW(var.pwszVal, L"BlackPaw.MicroManager"); + pps->SetValue(PKEY_AppUserModel_ID, var); + CoTaskMemFree(var.pwszVal); + } + pps->Commit(); + pps->Release(); + } + + NOTIFYICONDATAW nid = {sizeof(nid)}; + nid.hWnd = g_trayHwnd; + nid.uID = TRAY_ICON_ID; + nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP | NIF_GUID; + nid.guidItem = MICROMANAGER_TRAY_GUID; + nid.uCallbackMessage = WM_TRAY_CALLBACK; + lstrcpynW(nid.szTip, L"MicroManager", 128); + nid.hIcon = g_iconEnabled ? g_iconEnabled : LoadIconW(nullptr, IDI_APPLICATION); + if (Shell_NotifyIconW(NIM_ADD, &nid)) { + NOTIFYICONDATAW nidVer = {sizeof(nidVer)}; + nidVer.hWnd = g_trayHwnd; + nidVer.uID = TRAY_ICON_ID; + nidVer.uFlags = NIF_GUID; + nidVer.guidItem = MICROMANAGER_TRAY_GUID; + nidVer.uVersion = NOTIFYICON_VERSION_4; + Shell_NotifyIconW(NIM_SETVERSION, &nidVer); + } + + MSG msg; + while (GetMessageW(&msg, nullptr, 0, 0)) { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + + g_trayHwnd = nullptr; + if (hrCo != RPC_E_CHANGED_MODE) CoUninitialize(); + return 0; +} + +// ─── Windhawk Callbacks ─────────────────────────────────────────────────────── + +BOOL WhTool_ModInit() { + Wh_Log(L"MicroManager Init"); + + InitializeCriticalSection(&g_statsLock); + g_updateMs = LoadIntervalMs(); + + MEMORYSTATUSEX mem = {sizeof(mem)}; + if (GlobalMemoryStatusEx(&mem)) g_totalPhys = mem.ullTotalPhys; + + g_hInstance = GetModuleHandleW(nullptr); + GetModuleFileNameW(g_hInstance, g_windhawkPath, MAX_PATH); + + // Full path for ddores.dll — ExtractIconExW handles the .mun redirect on Win11 + UINT sysLen = GetSystemDirectoryW(g_ddoresDllPath, MAX_PATH); + if (sysLen > 0 && sysLen < MAX_PATH - 12) + lstrcatW(g_ddoresDllPath, L"\\ddores.dll"); + else + lstrcpyW(g_ddoresDllPath, L"ddores.dll"); + + ExtractIconExW(g_ddoresDllPath, 28, nullptr, &g_iconEnabled, 1); + + InitGpuQuery(); + + // Baseline CPU sample so the first timer tick has a delta to work from + GetSystemTimes(&g_prevIdle, &g_prevKernel, &g_prevUser); + MY_SYSTEM_PROCESS_INFO* initBuf = nullptr; + if (CollectProcessInfo(&initBuf) && initBuf) { + MY_SYSTEM_PROCESS_INFO* p = initBuf; + int count = 0; + while (count < MAX_PROCESSES) { + g_prevProcs[count].pid = (DWORD)(ULONG_PTR)p->UniqueProcessId; + g_prevProcs[count].time = p->KernelTime.QuadPart + p->UserTime.QuadPart; + if (p->ImageName.Buffer) { + wcsncpy_s(g_prevProcs[count].name, p->ImageName.Buffer, + MIN(p->ImageName.Length / sizeof(WCHAR), 63)); + g_prevProcs[count].name[63] = L'\0'; + } else { + g_prevProcs[count].name[0] = L'\0'; + } + count++; + if (p->NextEntryOffset == 0) break; + p = (MY_SYSTEM_PROCESS_INFO*)((BYTE*)p + p->NextEntryOffset); + } + g_prevProcCount = count; + free(initBuf); + } + + g_trayThread = CreateThread(nullptr, 0, TrayThreadProc, nullptr, 0, nullptr); + return TRUE; +} + +void WhTool_ModSettingsChanged() { + // Refresh rate is managed via the right-click menu; nothing to read here. +} + +void WhTool_ModUninit() { + Wh_Log(L"MicroManager Mod Uninit"); + + // WM_CLOSE handler on the tray thread destroys g_popupHwnd before the tray + // window itself — no cross-thread DestroyWindow needed here + if (g_trayHwnd) PostMessageW((HWND)g_trayHwnd, WM_CLOSE, 0, 0); + if (g_trayThread) { + WaitForSingleObject(g_trayThread, 3000); + CloseHandle(g_trayThread); + g_trayThread = nullptr; + } + + // Unregister popup class after the tray thread has fully exited so a + // subsequent mod reload can re-register it cleanly + UnregisterClassW(L"MicroManagerPopupClass", g_hInstance); + + if (g_iconEnabled) { DestroyIcon(g_iconEnabled); g_iconEnabled = nullptr; } + if (g_hPopupFont) { DeleteObject(g_hPopupFont); g_hPopupFont = nullptr; } + if (g_gpuQuery) { PdhCloseQuery(g_gpuQuery); g_gpuQuery = nullptr; g_gpuCounter = nullptr; } + + DeleteCriticalSection(&g_statsLock); +} + +//////////////////////////////////////////////////////////////////////////////// +// Windhawk tool mod implementation for mods which don't need to inject to other +// processes or hook other functions. Context: +// https://github.com/ramensoftware/windhawk/wiki/Mods-as-tools:-Running-mods-in-a-dedicated-process +// +// The mod will load and run in a dedicated windhawk.exe process. +// +// Paste the code below as part of the mod code, and use these callbacks: +// * WhTool_ModInit +// * WhTool_ModSettingsChanged +// * WhTool_ModUninit +// +// Currently, other callbacks are not supported. + +bool g_isToolModProcessLauncher; +HANDLE g_toolModProcessMutex; + +void WINAPI EntryPoint_Hook() { + Wh_Log(L">"); + ExitThread(0); +} + +BOOL Wh_ModInit() { + DWORD sessionId; + if (ProcessIdToSessionId(GetCurrentProcessId(), &sessionId) && + sessionId == 0) { + return FALSE; + } + + bool isExcluded = false; + bool isToolModProcess = false; + bool isCurrentToolModProcess = false; + int argc; + LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc); + if (!argv) { + Wh_Log(L"CommandLineToArgvW failed"); + return FALSE; + } + + for (int i = 1; i < argc; i++) { + if (wcscmp(argv[i], L"-service") == 0 || + wcscmp(argv[i], L"-service-start") == 0 || + wcscmp(argv[i], L"-service-stop") == 0) { + isExcluded = true; + break; + } + } + + for (int i = 1; i < argc - 1; i++) { + if (wcscmp(argv[i], L"-tool-mod") == 0) { + isToolModProcess = true; + if (wcscmp(argv[i + 1], WH_MOD_ID) == 0) { + isCurrentToolModProcess = true; + } + break; + } + } + + LocalFree(argv); + + if (isExcluded) { + return FALSE; + } + + if (isCurrentToolModProcess) { + g_toolModProcessMutex = + CreateMutexW(nullptr, TRUE, L"windhawk-tool-mod_" WH_MOD_ID); + if (!g_toolModProcessMutex) { + Wh_Log(L"CreateMutex failed"); + ExitProcess(1); + } + + if (GetLastError() == ERROR_ALREADY_EXISTS) { + Wh_Log(L"Tool mod already running (%s)", WH_MOD_ID); + ExitProcess(1); + } + + if (!WhTool_ModInit()) { + ExitProcess(1); + } + + IMAGE_DOS_HEADER* dosHeader = + (IMAGE_DOS_HEADER*)GetModuleHandleW(nullptr); + IMAGE_NT_HEADERS* ntHeaders = + (IMAGE_NT_HEADERS*)((BYTE*)dosHeader + dosHeader->e_lfanew); + + DWORD entryPointRVA = ntHeaders->OptionalHeader.AddressOfEntryPoint; + void* entryPoint = (BYTE*)dosHeader + entryPointRVA; + + Wh_SetFunctionHook(entryPoint, (void*)EntryPoint_Hook, nullptr); + return TRUE; + } + + if (isToolModProcess) { + return FALSE; + } + + g_isToolModProcessLauncher = true; + return TRUE; +} + +void Wh_ModAfterInit() { + if (!g_isToolModProcessLauncher) { + return; + } + + WCHAR currentProcessPath[MAX_PATH]; + switch (GetModuleFileNameW(nullptr, currentProcessPath, + ARRAYSIZE(currentProcessPath))) { + case 0: + case ARRAYSIZE(currentProcessPath): + Wh_Log(L"GetModuleFileName failed"); + return; + } + + WCHAR + commandLine[MAX_PATH + 2 + + (sizeof(L" -tool-mod \"" WH_MOD_ID "\"") / sizeof(WCHAR)) - 1]; + swprintf_s(commandLine, L"\"%s\" -tool-mod \"%s\"", currentProcessPath, + WH_MOD_ID); + + HMODULE kernelModule = GetModuleHandleW(L"kernelbase.dll"); + if (!kernelModule) { + kernelModule = GetModuleHandleW(L"kernel32.dll"); + if (!kernelModule) { + Wh_Log(L"No kernelbase.dll/kernel32.dll"); + return; + } + } + + using CreateProcessInternalW_t = BOOL(WINAPI*)( + HANDLE hUserToken, LPCWSTR lpApplicationName, LPWSTR lpCommandLine, + LPSECURITY_ATTRIBUTES lpProcessAttributes, + LPSECURITY_ATTRIBUTES lpThreadAttributes, WINBOOL bInheritHandles, + DWORD dwCreationFlags, LPVOID lpEnvironment, LPCWSTR lpCurrentDirectory, + LPSTARTUPINFOW lpStartupInfo, + LPPROCESS_INFORMATION lpProcessInformation, + PHANDLE hRestrictedUserToken); + CreateProcessInternalW_t pCreateProcessInternalW = + (CreateProcessInternalW_t)GetProcAddress(kernelModule, + "CreateProcessInternalW"); + if (!pCreateProcessInternalW) { + Wh_Log(L"No CreateProcessInternalW"); + return; + } + + STARTUPINFOW si{ + .cb = sizeof(STARTUPINFOW), + .dwFlags = STARTF_FORCEOFFFEEDBACK, + }; + PROCESS_INFORMATION pi; + if (!pCreateProcessInternalW(nullptr, currentProcessPath, commandLine, + nullptr, nullptr, FALSE, NORMAL_PRIORITY_CLASS, + nullptr, nullptr, &si, &pi, nullptr)) { + Wh_Log(L"CreateProcess failed"); + return; + } + + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); +} + +void Wh_ModSettingsChanged() { + if (g_isToolModProcessLauncher) { + return; + } + + WhTool_ModSettingsChanged(); +} + +void Wh_ModUninit() { + if (g_isToolModProcessLauncher) { + return; + } + + WhTool_ModUninit(); + ExitProcess(0); +} From 96d0efd708ddfff25b47325f625329e2d88140eb Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:16:22 +0300 Subject: [PATCH 51/56] Refactor process handling and remove critical section 1. Removed persistent registry writes: Replaced RegCreateKeyExW / RegGetValueW with Windhawk's native Wh_SetIntValue and Wh_GetIntValue to store the refresh interval cleanly. 2. Fixed double-buffer for top CPU detection: Introduced g_curProcs alongside g_prevProcs to ensure that we don't read and write to the same buffer in a single pass, which caused the top process to intermittently drop out. 3. Optimized GPU Process Lookup: Removed the per-tick CreateToolhelp32Snapshot call for the GPU process name and reused the name that was already collected in the process loop. 4. Clamped GPU to 100%: Added limits to prevent grandTotal and topVal from displaying over 100% since PDH sums across all engines. 5. Debounced UI Popup Toggling: Added a 200ms debounce to the tray icon left-click to prevent the popup from immediately reopening when clicking the tray icon to close it. 6. Removed g_statsLock : Removed the CRITICAL_SECTION because all reads and writes to those stats run exclusively on the tray thread's message loop, making locking unnecessary. 7. README Screenshot: Added a screenshot to the README section. --- mods/micromanager.wh.cpp | 73 ++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 41 deletions(-) diff --git a/mods/micromanager.wh.cpp b/mods/micromanager.wh.cpp index ef9bce9de7..dc2e2a94c9 100644 --- a/mods/micromanager.wh.cpp +++ b/mods/micromanager.wh.cpp @@ -14,6 +14,8 @@ /* # MicroManager +![Screenshot](https://i.imgur.com/yDHoq9N.png) + A lightweight tray icon that shows a mini task manager popup with live CPU, GPU and RAM usage, plus the single top-consuming process for each. @@ -158,6 +160,7 @@ typedef NTSTATUS (WINAPI *NtQuerySystemInformation_t)(ULONG, PVOID, ULONG, PULON static HANDLE g_trayThread = nullptr; static volatile HWND g_trayHwnd = nullptr; static HWND g_popupHwnd = nullptr; +static ULONGLONG g_lastPopupCloseTime = 0; static HINSTANCE g_hInstance = nullptr; static WCHAR g_windhawkPath[MAX_PATH] = {}; static WCHAR g_ddoresDllPath[MAX_PATH] = {}; @@ -167,7 +170,6 @@ static int g_dpi = 96; // popup DPI, refreshed per sh static ULONGLONG g_totalPhys = 0; // total physical RAM, bytes // Cached stats (updated on timer, read on popup paint) -static CRITICAL_SECTION g_statsLock; static int g_totalCpu = -1; static int g_topCpuPct = 0; static WCHAR g_topCpuName[64] = {}; @@ -198,7 +200,7 @@ static struct { DWORD pid; LONGLONG time; WCHAR name[64]; -} g_prevProcs[MAX_PROCESSES]; +} g_prevProcs[MAX_PROCESSES], g_curProcs[MAX_PROCESSES]; static int g_prevProcCount = 0; static BOOL g_hasPrevSample = FALSE; @@ -344,6 +346,7 @@ static void CollectGpuStats(int* outTotal, int* outTopPct, WCHAR* outTopName, in } free(items); + if (grandTotal > 100.0) grandTotal = 100.0; *outTotal = (int)(grandTotal + 0.5); // Find top GPU process @@ -355,21 +358,15 @@ static void CollectGpuStats(int* outTotal, int* outTopPct, WCHAR* outTopName, in topIdx = i; } } + if (topVal > 100.0) topVal = 100.0; if (topIdx >= 0) { *outTopPct = (int)(topVal + 0.5); - HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); - if (snap != INVALID_HANDLE_VALUE) { - PROCESSENTRY32W pe = {sizeof(pe)}; - if (Process32FirstW(snap, &pe)) { - do { - if (pe.th32ProcessID == g_gpuProcs[topIdx].pid) { - wcscpy_s(outTopName, nameLen, pe.szExeFile); - break; - } - } while (Process32NextW(snap, &pe)); + for (int i = 0; i < g_prevProcCount; i++) { + if (g_prevProcs[i].pid == g_gpuProcs[topIdx].pid) { + wcscpy_s(outTopName, nameLen, g_prevProcs[i].name); + break; } - CloseHandle(snap); } } } @@ -446,14 +443,14 @@ static void RefreshData() { } if (count < MAX_PROCESSES) { - g_prevProcs[count].pid = (DWORD)(ULONG_PTR)p->UniqueProcessId; - g_prevProcs[count].time = curTime; + g_curProcs[count].pid = (DWORD)(ULONG_PTR)p->UniqueProcessId; + g_curProcs[count].time = curTime; if (p->ImageName.Buffer) { - wcsncpy_s(g_prevProcs[count].name, p->ImageName.Buffer, + wcsncpy_s(g_curProcs[count].name, p->ImageName.Buffer, MIN(p->ImageName.Length / sizeof(WCHAR), 63)); - g_prevProcs[count].name[63] = L'\0'; + g_curProcs[count].name[63] = L'\0'; } else { - g_prevProcs[count].name[0] = L'\0'; + g_curProcs[count].name[0] = L'\0'; } count++; } @@ -461,6 +458,7 @@ static void RefreshData() { if (p->NextEntryOffset == 0) break; p = (MY_SYSTEM_PROCESS_INFO*)((BYTE*)p + p->NextEntryOffset); } + memcpy(g_prevProcs, g_curProcs, count * sizeof(g_prevProcs[0])); g_prevProcCount = count; if (bestTime > 0) { @@ -486,7 +484,6 @@ static void RefreshData() { CollectGpuStats(&newTotalGpu, &newTopGpuPct, newTopGpuName, 64); - EnterCriticalSection(&g_statsLock); g_totalCpu = newTotalCpu; g_topCpuPct = newTopCpuPct; wcscpy_s(g_topCpuName, newTopCpuName); @@ -496,7 +493,6 @@ static void RefreshData() { g_totalRam = newTotalRam; g_topRamPct = newTopRamPct; wcscpy_s(g_topRamName, newTopRamName); - LeaveCriticalSection(&g_statsLock); if (g_popupHwnd && IsWindowVisible(g_popupHwnd)) { InvalidateRect(g_popupHwnd, nullptr, TRUE); @@ -530,6 +526,7 @@ static LRESULT CALLBACK PopupWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM case WM_ACTIVATE: if (LOWORD(wParam) == WA_INACTIVE) { ShowWindow(hWnd, SW_HIDE); + g_lastPopupCloseTime = GetTickCount64(); } return 0; @@ -537,11 +534,9 @@ static LRESULT CALLBACK PopupWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM // Snapshot stats under the lock, then paint without holding it. int totals[POPUP_ROWS], topPcts[POPUP_ROWS]; WCHAR topNames[POPUP_ROWS][64]; - EnterCriticalSection(&g_statsLock); totals[0] = g_totalCpu; topPcts[0] = g_topCpuPct; wcscpy_s(topNames[0], g_topCpuName); totals[1] = g_totalGpu; topPcts[1] = g_topGpuPct; wcscpy_s(topNames[1], g_topGpuName); totals[2] = g_totalRam; topPcts[2] = g_topRamPct; wcscpy_s(topNames[2], g_topRamName); - LeaveCriticalSection(&g_statsLock); static const PCWSTR kLabels[POPUP_ROWS] = { L"CPU", L"GPU", L"RAM" }; static const PCWSTR kEmptyText[POPUP_ROWS] = { L"Idle", L"Idle", L"\x2014" }; @@ -642,6 +637,10 @@ static void ShowPopup(HWND hTrayWnd) { WS_POPUP | WS_BORDER, 0, 0, Sc(POPUP_WIDTH), Sc(POPUP_HEIGHT), nullptr, nullptr, g_hInstance, nullptr); + if (!g_popupHwnd) { + UnregisterClassW(L"MicroManagerPopupClass", g_hInstance); + return; + } } if (!g_popupHwnd) return; @@ -652,6 +651,8 @@ static void ShowPopup(HWND hTrayWnd) { EnsureFont(); NOTIFYICONIDENTIFIER nii = {sizeof(nii)}; + nii.hWnd = hTrayWnd; + nii.uID = TRAY_ICON_ID; nii.guidItem = MICROMANAGER_TRAY_GUID; // icon is registered with NIF_GUID RECT iconRect; int w = Sc(POPUP_WIDTH), h = Sc(POPUP_HEIGHT); @@ -704,19 +705,11 @@ static void ApplyContextMenuTheme(HWND hWnd, bool dark) { } static void SaveIntervalMs(DWORD ms) { - HKEY hKey; - if (RegCreateKeyExW(HKEY_CURRENT_USER, L"Software\\BlackPaw\\MicroManager", - 0, nullptr, 0, KEY_SET_VALUE, nullptr, &hKey, nullptr) == ERROR_SUCCESS) { - RegSetValueExW(hKey, L"UpdateIntervalMs", 0, REG_DWORD, (BYTE*)&ms, sizeof(ms)); - RegCloseKey(hKey); - } + Wh_SetIntValue(L"UpdateIntervalMs", (int)ms); } static DWORD LoadIntervalMs() { - DWORD ms = 1000, size = sizeof(ms); - RegGetValueW(HKEY_CURRENT_USER, L"Software\\BlackPaw\\MicroManager", - L"UpdateIntervalMs", RRF_RT_REG_DWORD, nullptr, &ms, &size); - // Clamp to valid options in case registry has a stale value + DWORD ms = (DWORD)Wh_GetIntValue(L"UpdateIntervalMs", 1000); if (ms != 300 && ms != 500 && ms != 1000 && ms != 3000) ms = 1000; return ms; } @@ -741,8 +734,11 @@ static LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM l case WM_LBUTTONUP: if (g_popupHwnd && IsWindowVisible(g_popupHwnd)) { ShowWindow(g_popupHwnd, SW_HIDE); + g_lastPopupCloseTime = GetTickCount64(); } else { - ShowPopup(hWnd); + if (GetTickCount64() - g_lastPopupCloseTime > 200) { + ShowPopup(hWnd); + } } break; @@ -750,11 +746,9 @@ static LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM l HMENU hMenu = CreatePopupMenu(); WCHAR statusText[128]; - EnterCriticalSection(&g_statsLock); int cpu = g_totalCpu; int gpu = g_totalGpu; int ram = g_totalRam; - LeaveCriticalSection(&g_statsLock); if (cpu >= 0 && gpu >= 0 && ram >= 0) swprintf_s(statusText, L"CPU: %d%% GPU: %d%% RAM: %d%%", cpu, gpu, ram); @@ -876,7 +870,7 @@ static DWORD WINAPI TrayThreadProc(LPVOID) { wc.lpszClassName = L"MicroManagerTrayClass"; if (!RegisterClassW(&wc) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) { Wh_Log(L"Tray RegisterClassW failed (%u)", GetLastError()); - if (hrCo != RPC_E_CHANGED_MODE) CoUninitialize(); + if (SUCCEEDED(hrCo)) CoUninitialize(); return 1; } @@ -886,7 +880,7 @@ static DWORD WINAPI TrayThreadProc(LPVOID) { WS_POPUP, 0, 0, 1, 1, nullptr, nullptr, g_hInstance, nullptr); if (!g_trayHwnd) { - if (hrCo != RPC_E_CHANGED_MODE) CoUninitialize(); + if (SUCCEEDED(hrCo)) CoUninitialize(); return 1; } @@ -931,7 +925,7 @@ static DWORD WINAPI TrayThreadProc(LPVOID) { } g_trayHwnd = nullptr; - if (hrCo != RPC_E_CHANGED_MODE) CoUninitialize(); + if (SUCCEEDED(hrCo)) CoUninitialize(); return 0; } @@ -940,7 +934,6 @@ static DWORD WINAPI TrayThreadProc(LPVOID) { BOOL WhTool_ModInit() { Wh_Log(L"MicroManager Init"); - InitializeCriticalSection(&g_statsLock); g_updateMs = LoadIntervalMs(); MEMORYSTATUSEX mem = {sizeof(mem)}; @@ -1011,8 +1004,6 @@ void WhTool_ModUninit() { if (g_iconEnabled) { DestroyIcon(g_iconEnabled); g_iconEnabled = nullptr; } if (g_hPopupFont) { DeleteObject(g_hPopupFont); g_hPopupFont = nullptr; } if (g_gpuQuery) { PdhCloseQuery(g_gpuQuery); g_gpuQuery = nullptr; g_gpuCounter = nullptr; } - - DeleteCriticalSection(&g_statsLock); } //////////////////////////////////////////////////////////////////////////////// From bfef0e465dd2b684e652c13ab63921e25f0a6708 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:22:31 +0300 Subject: [PATCH 52/56] add persistent mute & extra patches Updated version to 2.2.1, added persistent mute feature and general performance patches. --- mods/audioswap.wh.cpp | 195 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 180 insertions(+), 15 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index 6988a1a7a8..03d14440cd 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -2,7 +2,7 @@ // @id audioswap // @name AudioSwap // @description Tray icon to cycle between multiple preferred audio outputs. Supports up to 6 devices with click or scroll to swap. -// @version 2.2.0 +// @version 2.2.1 // @author BlackPaw // @github https://github.com/BlackPaw21 // @donateUrl https://ko-fi.com/blackpaw21 @@ -153,6 +153,7 @@ static const GUID AUDIOSWAP_TRAY_GUID = #define WM_RELOAD_ALL (WM_USER + 7) // full reload after dashboard save #define WM_PRIORITY_DEVICE_ACTIVE (WM_USER + 8) // lParam = heap-alloc'd WCHAR* device ID #define WM_REBIND_VOLUME_CALLBACK (WM_USER + 9) // rebind IAudioEndpointVolumeCallback after default-device change +#define WM_REFRESH_DEVICE_LIST (WM_USER + 10) // refresh dashboard device combos after hot-plug #define TRAY_RECT_INIT_TIMER 99 // one-shot retry timer for Shell_NotifyIconGetRect // Sentinel context GUID — distinguishes volume changes triggered by our own @@ -165,6 +166,7 @@ static const GUID kVolumeChangeCtx = #define MENU_OPEN_WINDHAWK 9000 #define IDC_BTN_KOFI 9002 #define MENU_SOUND_SETTINGS 9003 +#define IDC_PERSISTENT_MUTE 9004 // With NOTIFYICON_VERSION_4 active Windows changes the tray notifications: // left-click → NIN_SELECT (instead of / alongside WM_LBUTTONUP) @@ -216,10 +218,11 @@ static WCHAR g_cachedDevName[MAX_DEVICE_SLOTS][256] = {}; static int g_deviceSlotCount = 2; static bool g_isMutedByUs = false; static WCHAR g_mutedDeviceId[512] = {}; +static bool g_persistentMute = false; // re-apply mute after device cycles // ───────────────────────────────────────────────────────────────────────────── // Priority device list — up to MAX_DEVICE_SLOTS entries, index 0 = highest priority. -// Written on main/tray thread under no lock (only touched during reload or init). +// Protected by g_stateLock (LoadPriorityList acquires it for writes). static WCHAR g_priorityDevIds[MAX_DEVICE_SLOTS][512] = {}; static int g_priorityCount = 0; @@ -239,6 +242,7 @@ static VolNotifier* g_pVolNotifier = nullptr; // registered on g_pEn // tray thread has been waited for. No concurrent access → no lock needed. static HANDLE g_guiThread = nullptr; static volatile LONG g_guiRunning = 0; // 1 while dashboard window is open +static volatile HWND g_dashboardHwnd = nullptr; // dashboard HWND, written/cleared on GUI thread const CLSID CLSID_CPolicyConfigClient = { 0x870af99c, 0x171d, 0x4f9e, {0xaf, 0x0d, 0xe6, 0x3d, 0xf4, 0x0c, 0x2b, 0xc9} @@ -339,6 +343,7 @@ namespace AudioSwapGui { HWND hSaveBtn = nullptr; HWND hCancelBtn = nullptr; HWND hKoFiBtn = nullptr; + HWND hPersistMuteBtn = nullptr; UINT dpi = 96; bool advancedMode = false; @@ -444,8 +449,11 @@ namespace AudioSwapGui { SetWindowPos(s->hCancelBtn, nullptr, sx+Sc(168,d), btnY, Sc(88,d), Sc(28,d), SWP_NOZORDER|SWP_NOACTIVATE); SetWindowPos(s->hKoFiBtn, nullptr, sx+Sc(264,d), btnY, Sc(138,d), Sc(28,d), SWP_NOZORDER|SWP_NOACTIVATE); + int chkY = btnY + Sc(28,d) + Sc(6,d); + SetWindowPos(s->hPersistMuteBtn, nullptr, sx+Sc(12,d), chkY, Sc(180,d), Sc(22,d), SWP_NOZORDER|SWP_NOACTIVATE); + int prioPanelH = Sc(32,d) + MAX_DEVICE_SLOTS * Sc(kPrioRowH,d) + Sc(12,d); - int slotsPanelH = btnY + Sc(28,d) + Sc(12,d); + int slotsPanelH = chkY + Sc(22,d) + Sc(12,d); int clientH = prioPanelH > slotsPanelH ? prioPanelH : slotsPanelH; RECT rc = {0, 0, Sc(s->advancedMode ? kCW : kSW, d), clientH}; @@ -700,6 +708,20 @@ namespace AudioSwapGui { WS_CHILD|WS_VISIBLE|BS_OWNERDRAW, 0, 0, 10, 10, hWnd, (HMENU)IDC_BTN_KOFI, hInst, nullptr); + s->hPersistMuteBtn = CreateWindowExW(0, L"BUTTON", L"Persistent Mute", + WS_CHILD|WS_VISIBLE|BS_AUTOCHECKBOX, 0, 0, 10, 10, + hWnd, (HMENU)IDC_PERSISTENT_MUTE, hInst, nullptr); + { + HMODULE ux = GetModuleHandleW(L"uxtheme.dll"); + if (ux) { + using Fn = HRESULT(WINAPI*)(HWND, LPCWSTR, LPCWSTR); + auto fn = (Fn)GetProcAddress(ux, "SetWindowTheme"); + if (fn) fn(s->hPersistMuteBtn, L"", L""); + } + } + if (g_persistentMute) + SendMessageW(s->hPersistMuteBtn, BM_SETCHECK, BST_CHECKED, 0); + EnumChildWindows(hWnd, ApplyFontProc, reinterpret_cast(s->hFont)); UpdateSlotVisibility(s); // also calls LayoutControls → positions everything @@ -814,7 +836,7 @@ namespace AudioSwapGui { // ── Dark theming for child controls ─────────────────────────────────── case WM_CTLCOLORSTATIC: if (s) { - SetTextColor((HDC)wParam, kClrDim); + SetTextColor((HDC)wParam, (HWND)lParam == s->hPersistMuteBtn ? kClrText : kClrDim); SetBkColor((HDC)wParam, kClrBg); return (LRESULT)s->hBgBrush; } @@ -828,7 +850,13 @@ namespace AudioSwapGui { } break; case WM_CTLCOLORBTN: - if (s) return (LRESULT)s->hBgBrush; + if (s) { + if ((HWND)lParam == s->hPersistMuteBtn) { + SetTextColor((HDC)wParam, kClrText); + SetBkColor((HDC)wParam, kClrBg); + } + return (LRESULT)s->hBgBrush; + } break; // ── Owner-draw buttons ──────────────────────────────────────────────── @@ -993,6 +1021,9 @@ namespace AudioSwapGui { } Wh_SetStringValue(kPth, s->prioSlots[i].customPath); } + BOOL pmChecked = SendMessageW(s->hPersistMuteBtn, BM_GETCHECK, 0, 0); + Wh_SetStringValue(L"persistentMute", pmChecked ? L"1" : L"0"); + if (s->hTrayHwnd) PostMessageW(s->hTrayHwnd, WM_RELOAD_ALL, 0, 0); DestroyWindow(hWnd); @@ -1028,8 +1059,89 @@ namespace AudioSwapGui { return 0; } + // ── Live device refresh (forwarded from IMMNotificationClient) ───────── + case WM_REFRESH_DEVICE_LIST: { + if (!s) break; + // Save current selections (index into the old device list) + std::vector savedSlotSel(6, -1); + std::vector savedPrioSel(MAX_DEVICE_SLOTS, -1); + for (int i = 0; i < 6; i++) + savedSlotSel[i] = (int)SendMessageW(s->slots[i].hDevCombo, CB_GETCURSEL, 0, 0); + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) + savedPrioSel[i] = (int)SendMessageW(s->prioSlots[i].hDevCombo, CB_GETCURSEL, 0, 0); + auto oldDevices = std::move(s->activeDevices); + + // Re-enumerate active render devices + IMMDeviceEnumerator* pEnum = nullptr; + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, + CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), + (void**)&pEnum))) { + IMMDeviceCollection* pColl = nullptr; + if (SUCCEEDED(pEnum->EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE, &pColl))) { + UINT count = 0; pColl->GetCount(&count); + for (UINT i = 0; i < count; i++) { + IMMDevice* pDev = nullptr; + if (SUCCEEDED(pColl->Item(i, &pDev))) { + LPWSTR pId = nullptr; + if (SUCCEEDED(pDev->GetId(&pId))) { + IPropertyStore* pStore = nullptr; + if (SUCCEEDED(pDev->OpenPropertyStore(STGM_READ, &pStore))) { + PROPVARIANT v; PropVariantInit(&v); + if (SUCCEEDED(pStore->GetValue(PKEY_Device_FriendlyName, &v)) && v.pwszVal) + s->activeDevices.push_back({pId, v.pwszVal}); + PropVariantClear(&v); pStore->Release(); + } + CoTaskMemFree(pId); + } + pDev->Release(); + } + } + pColl->Release(); + } + pEnum->Release(); + } + + // Helper: translate old-device index to new-device index by ID + auto findNewIdx = [&](int oldIdx) -> int { + if (oldIdx < 0 || oldIdx >= (int)oldDevices.size()) return -1; + for (size_t j = 0; j < s->activeDevices.size(); j++) + if (s->activeDevices[j].id == oldDevices[oldIdx].id) return (int)j; + return -1; + }; + + // Repopulate slot device combos + for (int i = 0; i < 6; i++) { + SendMessageW(s->slots[i].hDevCombo, CB_RESETCONTENT, 0, 0); + int newSel = findNewIdx(savedSlotSel[i]); + for (int j = 0; j < (int)s->activeDevices.size(); j++) { + int idx = (int)SendMessageW(s->slots[i].hDevCombo, CB_ADDSTRING, 0, + (LPARAM)s->activeDevices[j].name.c_str()); + if (s->slots[i].id == s->activeDevices[j].id) newSel = idx; + } + if (newSel >= 0) SendMessageW(s->slots[i].hDevCombo, CB_SETCURSEL, newSel, 0); + } + + // Repopulate priority device combos (index 0 = "None") + for (int i = 0; i < MAX_DEVICE_SLOTS; i++) { + SendMessageW(s->prioSlots[i].hDevCombo, CB_RESETCONTENT, 0, 0); + SendMessageW(s->prioSlots[i].hDevCombo, CB_ADDSTRING, 0, (LPARAM)L"None"); + int newSel = 0; // default "None" + if (savedPrioSel[i] > 0) { + int ns = findNewIdx(savedPrioSel[i] - 1); + if (ns >= 0) newSel = ns + 1; + } + for (int j = 0; j < (int)s->activeDevices.size(); j++) { + SendMessageW(s->prioSlots[i].hDevCombo, CB_ADDSTRING, 0, + (LPARAM)s->activeDevices[j].name.c_str()); + } + SendMessageW(s->prioSlots[i].hDevCombo, CB_SETCURSEL, newSel, 0); + } + return 0; + } + // ── Cleanup ─────────────────────────────────────────────────────────── case WM_DESTROY: + InterlockedExchangePointer((volatile PVOID*)&g_dashboardHwnd, nullptr); if (s) { DeleteObject(s->hFont); s->hFont = nullptr; DeleteObject(s->hBgBrush); s->hBgBrush = nullptr; @@ -1051,7 +1163,11 @@ namespace AudioSwapGui { // ── GUI thread ──────────────────────────────────────────────────────────── static DWORD WINAPI GuiThreadProc(LPVOID lpParam) { - CoInitialize(nullptr); + HRESULT hrCo = CoInitialize(nullptr); + if (FAILED(hrCo) && hrCo != RPC_E_CHANGED_MODE) { + Wh_Log(L"GuiThreadProc: CoInitialize failed (0x%X)", hrCo); + return 1; + } // Set Per-Monitor DPI Awareness V2 so Windows doesn't bitmap-scale the window. using SetTDACFn = DPI_AWARENESS_CONTEXT(WINAPI*)(DPI_AWARENESS_CONTEXT); @@ -1089,6 +1205,7 @@ namespace AudioSwapGui { nullptr, nullptr, hInst, &state); if (hWnd) { + InterlockedExchangePointer((volatile PVOID*)&g_dashboardHwnd, hWnd); ShowWindow(hWnd, SW_SHOW); UpdateWindow(hWnd); MSG msg; @@ -1109,7 +1226,7 @@ namespace AudioSwapGui { } UnregisterClassW(kClass, hInst); - CoUninitialize(); + if (hrCo != RPC_E_CHANGED_MODE) CoUninitialize(); InterlockedExchange(&g_guiRunning, 0); return 0; } @@ -1397,6 +1514,13 @@ class AudioDeviceNotifier : public IMMNotificationClient { } // Callbacks are fired on an OS thread — only PostMessageW is safe here. + // Common helper: forward device changes to the dashboard if open. + static void ForwardToDashboard() { + HWND dash = (HWND)InterlockedCompareExchangePointer( + (volatile PVOID*)&g_dashboardHwnd, nullptr, nullptr); + if (dash) PostMessageW(dash, WM_REFRESH_DEVICE_LIST, 0, 0); + } + HRESULT STDMETHODCALLTYPE OnDefaultDeviceChanged( EDataFlow flow, ERole role, LPCWSTR) override { @@ -1410,6 +1534,7 @@ class AudioDeviceNotifier : public IMMNotificationClient { PostMessageW(hwnd, WM_REBIND_VOLUME_CALLBACK, 0, 0); PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); } + ForwardToDashboard(); } return S_OK; } @@ -1426,12 +1551,14 @@ class AudioDeviceNotifier : public IMMNotificationClient { delete[] idCopy; } } + ForwardToDashboard(); return S_OK; } HRESULT STDMETHODCALLTYPE OnDeviceRemoved(LPCWSTR) override { HWND hwnd = (HWND)InterlockedCompareExchangePointer( (volatile PVOID*)&g_trayHwnd, nullptr, nullptr); if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + ForwardToDashboard(); return S_OK; } HRESULT STDMETHODCALLTYPE OnDeviceStateChanged(LPCWSTR pwstrDeviceId, DWORD newState) override { @@ -1447,6 +1574,7 @@ class AudioDeviceNotifier : public IMMNotificationClient { delete[] idCopy; } } + ForwardToDashboard(); return S_OK; } HRESULT STDMETHODCALLTYPE OnPropertyValueChanged( @@ -1645,9 +1773,16 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { } // Slow path: still needed for the device name + ID. Always run this block. - IMMDeviceEnumerator* pEnum = nullptr; - if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, - __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { + // Reuse g_notifEnum (already exists on the tray thread) instead of creating a + // fresh enumerator every call. Fall back to CoCreateInstance if not yet initialized. + IMMDeviceEnumerator* pEnum = g_notifEnum; + bool enumOwner = false; + if (!pEnum) { + if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) + enumOwner = true; + } + if (pEnum) { IMMDevice* pDev = nullptr; if (SUCCEEDED(pEnum->GetDefaultAudioEndpoint(eRender, eMultimedia, &pDev))) { LPWSTR pId = nullptr; @@ -1679,7 +1814,7 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { } pDev->Release(); } - pEnum->Release(); + if (enumOwner) pEnum->Release(); } HICON currentIcon = g_iconDev[0]; @@ -1825,7 +1960,11 @@ BOOL CycleAudioDevice(int direction) { if (!comOk) return FALSE; // Undo click-mute before switching (COM is now available). - RestoreMute(); + // If persistent mute is active, keep the mute — re-apply to the new device after switch. + bool keepMute = false; + { EnterCriticalSection(&g_stateLock); keepMute = g_persistentMute && g_isMutedByUs; LeaveCriticalSection(&g_stateLock); } + if (!keepMute) + RestoreMute(); HWND hwnd = g_trayHwnd; if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); @@ -1876,6 +2015,13 @@ BOOL CycleAudioDevice(int direction) { pPolicyConfig->Release(); } + // If persistent mute was active, re-apply mute to the newly defaulted device. + if (keepMute) { + ApplyMute(nullptr, TRUE); + HWND hw = g_trayHwnd; + if (hw) PostMessageW(hw, WM_UPDATE_TRAY_STATE, 0, 0); + } + pEnum->Release(); CoUninitialize(); return TRUE; @@ -2521,10 +2667,19 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) // Default device changed — re-bind to new endpoint. UnbindEndpointVolume(); BindEndpointVolume(); + // If persistent mute is on and we were muted, mute the new default device. + if (g_persistentMute && g_isMutedByUs) { + ApplyMute(nullptr, TRUE); + PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); + } } else if (msg == WM_RELOAD_ALL) { // Full reload triggered by the settings dashboard after Save and Apply. // Picks up new device assignments, icon choices, device count, swap mode, and priority list. + { + WCHAR pmBuf[8] = {}; + g_persistentMute = (Wh_GetStringValue(L"persistentMute", pmBuf, 8) && pmBuf[0] == L'1'); + } LoadDeviceSelections(); LoadPriorityList(); LoadUserIconsAndSettings(); @@ -2554,7 +2709,11 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) // ─── Tray thread ────────────────────────────────────────────────────────────── DWORD WINAPI TrayThreadProc(LPVOID) { - CoInitialize(nullptr); + HRESULT hrCo = CoInitialize(nullptr); + if (FAILED(hrCo) && hrCo != RPC_E_CHANGED_MODE) { + Wh_Log(L"TrayThread: CoInitialize failed (0x%X)", hrCo); + return 1; + } g_taskbarCreatedMsg = RegisterWindowMessageW(L"TaskbarCreated"); WNDCLASSW wc = {}; @@ -2576,7 +2735,7 @@ DWORD WINAPI TrayThreadProc(LPVOID) { nullptr, nullptr, g_hInstance, nullptr ); if (!g_trayHwnd) { - CoUninitialize(); + if (hrCo != RPC_E_CHANGED_MODE) CoUninitialize(); return 1; } @@ -2614,7 +2773,7 @@ DWORD WINAPI TrayThreadProc(LPVOID) { RegisterRawInputDevices(&rid, 1, sizeof(rid)); } VolumePopup::UnregisterClass(); - CoUninitialize(); + if (hrCo != RPC_E_CHANGED_MODE) CoUninitialize(); return 0; } @@ -2684,6 +2843,12 @@ BOOL WhTool_ModInit() { Wh_SetStringValue(L"MutedDeviceId", L""); } + // Load persistent mute setting. + { + WCHAR pmBuf[8] = {}; + g_persistentMute = (Wh_GetStringValue(L"persistentMute", pmBuf, 8) && pmBuf[0] == L'1'); + } + LoadUserIconsAndSettings(); // sets g_deviceSlotCount first LoadDeviceSelections(); LoadPriorityList(); From 09029b047802b6a37842344bce45d5940b2e8c3a Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:37:39 +0300 Subject: [PATCH 53/56] Fix formatting and add images to documentation --- mods/audioswap.wh.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index 03d14440cd..35020fbc18 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -17,6 +17,10 @@ Instantly cycle between multiple audio output devices from your system tray — > Works great alongside **[MicSwitch](https://windhawk.net/mods/microswap)** — the companion mod that brings the same tray experience to microphone input control. +![Settings Dashboard](https://i.imgur.com/gN7yFC0.png) + +![Tray Icon](https://i.imgur.com/DbhGcxI.png) + --- ## How to Use From 47a9098003f25797449c41bee4850a7f60eb3f50 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:29:45 +0300 Subject: [PATCH 54/56] hotfix 2.3.0 Updated version to 2.3.0 with new features and fixes. used new skills i made based on the entire Windhawk repo and wiki. --- mods/audioswap.wh.cpp | 241 +++++++++++++++++++++++++++--------------- 1 file changed, 155 insertions(+), 86 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index 35020fbc18..303c172f22 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -2,9 +2,10 @@ // @id audioswap // @name AudioSwap // @description Tray icon to cycle between multiple preferred audio outputs. Supports up to 6 devices with click or scroll to swap. -// @version 2.2.1 +// @version 2.3.0 // @author BlackPaw // @github https://github.com/BlackPaw21 +// @license MIT // @donateUrl https://ko-fi.com/blackpaw21 // @include windhawk.exe // @compilerOptions -lshell32 -lgdi32 -luser32 -lole32 -luuid -loleaut32 -lcomdlg32 -ladvapi32 -lcomctl32 @@ -63,29 +64,27 @@ Enable **Advanced Mode** in the Windhawk settings panel (gear icon → Settings ## Changelog +### v2.3.0 +- **New:** Persistent Mute toggle — mute state now survives device cycles (USB replug, sleep/wake, driver resets). +- **New:** Live dashboard device list refresh — device events (add/remove/default-change) update the dashboard combo without reopening. +- **Fixed:** Crash on reload — InterlockedCompareExchangePointer + IsWindow guard + UnregisterClassW for safe tray shutdown. +- **Fixed:** Crash on unload — same atomic handle read and class cleanup in shutdown path. +- **Fixed:** GUI thread CoInitialize return value not checked — now logs on unexpected failure. + ### v2.2.0 -- New: Right-click → **Sound Settings...** opens Windows Sound dialog directly on the Playback tab. -- New: Middle-click tray icon → compact volume slider popup with a custom dark design. Drag to adjust output volume live; click outside or press Escape to close. -- New: Scroll wheel over the tray icon (Click to Swap mode) adjusts output volume, matching native Windows sound icon behaviour. - -### v2.1.0 -- New: Device Priority panel — rank your preferred devices; the highest-priority connected device is assigned to a swap slot automatically. -- New: Advanced Mode toggle in Windhawk settings — shows the Device Priority panel in Mod Settings. -- Fixed: GUI sizing on high-DPI monitors. -- Fixed: Context menu now follows your Windows dark/light theme. -- Fixed: AudioSwap icon is now independent — no longer linked to the Windhawk tray icon. - -### v2.0.0 -- **New:** Native dashboard — right-click → **Mod Settings**. -- **New:** All configuration (device slots, icons, mode, count) lives in the dashboard; Windhawk's settings panel is no longer used. - -### v1.4.0 -- Custom .ico support — selecting "Custom Icon" in settings auto-opens a file picker; also available via right-click → "Custom Icon for Device X..." -- Custom icon paths stored internally (no separate text setting in the UI). -- Fixed: Scroll to Swap didn't work after a reboot until the icon was clicked. -- Fixed: Crash with audio devices that have very long names. -- Fixed: Adding more devices in settings didn't take effect immediately. -- Fixed: Cycling devices would sometimes skip the first slot. +- **New:** Native dashboard — right-click → **Mod Settings** (replaces Windhawk YAML panel). +- **New:** Device Priority panel — rank preferred devices; highest-priority connected device auto-assigned. +- **New:** Right-click → **Sound Settings...** opens Windows Sound dialog on Playback tab. +- **New:** Middle-click → compact VolumePopup slider. Drag to adjust volume; click outside or Escape to close. +- **New:** Scroll wheel over tray icon adjusts volume (Click to Swap mode). +- **New:** Custom .ico support per slot; also icons from `ddores.dll`. +- **New:** Advanced Mode toggle in Windhawk settings — shows Device Priority panel. +- **Fixed:** GUI sizing on high-DPI monitors. +- **Fixed:** Context menu follows Windows dark/light theme. +- **Fixed:** Tray icon independent from Windhawk — no longer grouped in taskbar. +- **Fixed:** Scroll to Swap works after reboot without clicking icon first. +- **Fixed:** Crash with very long audio device names. +- **Fixed:** Cycling devices no longer skips first slot. ### v1.3.0 - Up to 6 devices; scroll-to-swap mode; dynamic right-click menu. @@ -125,19 +124,28 @@ Enable **Advanced Mode** in the Windhawk settings panel (gear icon → Settings // separate YAML store — no conflict with dashboard keys. #define NOMINMAX -#include + +// 1. Core Windows #include + +// 2. Secondary Windows APIs #include #include #include #include #include - #include +#include +#include #include #include #include #include + +// 3. Windhawk Utilities +#include + +// 4. Standard C++ #include #include #include @@ -222,7 +230,7 @@ static WCHAR g_cachedDevName[MAX_DEVICE_SLOTS][256] = {}; static int g_deviceSlotCount = 2; static bool g_isMutedByUs = false; static WCHAR g_mutedDeviceId[512] = {}; -static bool g_persistentMute = false; // re-apply mute after device cycles +static LONG g_persistentMute = FALSE; // re-apply mute after device cycles (use Interlocked ops) // ───────────────────────────────────────────────────────────────────────────── // Priority device list — up to MAX_DEVICE_SLOTS entries, index 0 = highest priority. @@ -723,7 +731,7 @@ namespace AudioSwapGui { if (fn) fn(s->hPersistMuteBtn, L"", L""); } } - if (g_persistentMute) + if (InterlockedOr(&g_persistentMute, 0)) SendMessageW(s->hPersistMuteBtn, BM_SETCHECK, BST_CHECKED, 0); EnumChildWindows(hWnd, ApplyFontProc, reinterpret_cast(s->hFont)); @@ -1230,7 +1238,7 @@ namespace AudioSwapGui { } UnregisterClassW(kClass, hInst); - if (hrCo != RPC_E_CHANGED_MODE) CoUninitialize(); + if (hrCo == S_OK) CoUninitialize(); InterlockedExchange(&g_guiRunning, 0); return 0; } @@ -1406,6 +1414,33 @@ static BOOL ApplyMute(PCWSTR deviceId, BOOL mute) { return TRUE; } +// Mute/unmute every active render device — used by persistent mute (global toggle). +static void ApplyMuteAll(BOOL mute) { + IMMDeviceEnumerator* pEnum = nullptr; + if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) + return; + IMMDeviceCollection* pCol = nullptr; + if (FAILED(pEnum->EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE, &pCol))) { + pEnum->Release(); return; + } + UINT count = 0; + pCol->GetCount(&count); + for (UINT i = 0; i < count; i++) { + IMMDevice* pDev = nullptr; + if (SUCCEEDED(pCol->Item(i, &pDev))) { + IAudioEndpointVolume* pVol = nullptr; + if (SUCCEEDED(pDev->Activate(__uuidof(IAudioEndpointVolume), CLSCTX_ALL, + nullptr, (void**)&pVol))) { + pVol->SetMute(mute, nullptr); + pVol->Release(); + } + pDev->Release(); + } + } + pCol->Release(); + pEnum->Release(); +} // Call from tray thread (COM already initialized) or worker thread (has its own COM). static void RestoreMute() { @@ -1416,7 +1451,10 @@ static void RestoreMute() { g_isMutedByUs = false; g_mutedDeviceId[0] = L'\0'; LeaveCriticalSection(&g_stateLock); - ApplyMute(localId, FALSE); + if (InterlockedOr(&g_persistentMute, 0)) + ApplyMuteAll(FALSE); + else if (localId[0]) + ApplyMute(localId, FALSE); Wh_SetStringValue(L"MutedDeviceId", L""); } @@ -1432,32 +1470,53 @@ static void RestoreMuteExternal() { } LeaveCriticalSection(&g_stateLock); if (wasMuted) { - CoInitialize(nullptr); - ApplyMute(localId, FALSE); - CoUninitialize(); + HRESULT hrCo = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + if (FAILED(hrCo) && hrCo != RPC_E_CHANGED_MODE) return; + if (InterlockedOr(&g_persistentMute, 0)) + ApplyMuteAll(FALSE); + else if (localId[0]) + ApplyMute(localId, FALSE); + if (hrCo == S_OK) CoUninitialize(); Wh_SetStringValue(L"MutedDeviceId", L""); } } // Called from TrayWndProc — tray thread has COM initialized by TrayThreadProc. static void ToggleMuteCurrentDevice() { + if (InterlockedOr(&g_persistentMute, 0)) { + // Global mute: toggle ALL active render devices at once. + bool already; + { EnterCriticalSection(&g_stateLock); already = g_isMutedByUs; LeaveCriticalSection(&g_stateLock); } + ApplyMuteAll(already ? FALSE : TRUE); + EnterCriticalSection(&g_stateLock); + if (already) { + g_isMutedByUs = false; + g_mutedDeviceId[0] = L'\0'; + } else { + g_isMutedByUs = true; + } + LeaveCriticalSection(&g_stateLock); + Wh_SetStringValue(L"MutedDeviceId", already ? L"" : L"all"); + return; + } + + // Per-device mute: toggle only the current default device. EnterCriticalSection(&g_stateLock); bool already = g_isMutedByUs; - WCHAR localId[512] = {}; + WCHAR localId[512]; + lstrcpynW(localId, g_mutedDeviceId, 512); if (already) { - lstrcpynW(localId, g_mutedDeviceId, 512); g_isMutedByUs = false; g_mutedDeviceId[0] = L'\0'; } LeaveCriticalSection(&g_stateLock); if (already) { - ApplyMute(localId, FALSE); + ApplyMute(localId[0] ? localId : nullptr, FALSE); Wh_SetStringValue(L"MutedDeviceId", L""); return; } - // Mute the current default device IMMDeviceEnumerator* pEnum = nullptr; if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) @@ -1468,10 +1527,8 @@ static void ToggleMuteCurrentDevice() { LPWSTR pId = nullptr; if (SUCCEEDED(pDefault->GetId(&pId))) { if (ApplyMute(pId, TRUE)) { - EnterCriticalSection(&g_stateLock); lstrcpynW(g_mutedDeviceId, pId, 512); g_isMutedByUs = true; - LeaveCriticalSection(&g_stateLock); Wh_SetStringValue(L"MutedDeviceId", pId); } CoTaskMemFree(pId); @@ -1893,8 +1950,8 @@ void UpdateTrayTip(HWND hWnd, BOOL isAdd) { static int GetCurrentVolumePct() { int result = -1; - HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - if (FAILED(hr) && hr != S_FALSE) return result; + HRESULT hrCo = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + if (FAILED(hrCo) && hrCo != RPC_E_CHANGED_MODE) return result; IMMDeviceEnumerator* pEnum = nullptr; if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { @@ -1915,14 +1972,14 @@ static int GetCurrentVolumePct() { } pEnum->Release(); } - CoUninitialize(); + if (hrCo == S_OK) CoUninitialize(); return result; } static void SetCurrentDeviceVolume(float scalar) { scalar = std::max(0.0f, std::min(1.0f, scalar)); - HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - if (FAILED(hr) && hr != S_FALSE) return; + HRESULT hrCo = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + if (FAILED(hrCo) && hrCo != RPC_E_CHANGED_MODE) return; IMMDeviceEnumerator* pEnum = nullptr; if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { @@ -1938,7 +1995,7 @@ static void SetCurrentDeviceVolume(float scalar) { } pEnum->Release(); } - CoUninitialize(); + if (hrCo == S_OK) CoUninitialize(); } // ─── Audio cycling ──────────────────────────────────────────────────────────── @@ -1960,22 +2017,18 @@ BOOL CycleAudioDevice(int direction) { if (configuredCount < 2) return FALSE; HRESULT comHr = CoInitialize(nullptr); - bool comOk = SUCCEEDED(comHr) || comHr == S_FALSE; - if (!comOk) return FALSE; - - // Undo click-mute before switching (COM is now available). - // If persistent mute is active, keep the mute — re-apply to the new device after switch. - bool keepMute = false; - { EnterCriticalSection(&g_stateLock); keepMute = g_persistentMute && g_isMutedByUs; LeaveCriticalSection(&g_stateLock); } - if (!keepMute) + if (FAILED(comHr) && comHr != RPC_E_CHANGED_MODE) return FALSE; + + // When persistent mute is off, unmute the per-device muted device before swap. + if (!InterlockedOr(&g_persistentMute, 0)) RestoreMute(); - HWND hwnd = g_trayHwnd; - if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + HWND hwnd = (HWND)InterlockedCompareExchangePointer((PVOID*)&g_trayHwnd, nullptr, nullptr); + if (hwnd && IsWindow(hwnd)) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); IMMDeviceEnumerator* pEnum = nullptr; if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { - CoUninitialize(); return FALSE; + if (comHr == S_OK) CoUninitialize(); return FALSE; } WCHAR currentId[512] = {}; @@ -2007,7 +2060,7 @@ BOOL CycleAudioDevice(int direction) { } if (validSlot == -1) { - pEnum->Release(); CoUninitialize(); return FALSE; + pEnum->Release(); if (comHr == S_OK) CoUninitialize(); return FALSE; } IPolicyConfig* pPolicyConfig = nullptr; @@ -2019,15 +2072,8 @@ BOOL CycleAudioDevice(int direction) { pPolicyConfig->Release(); } - // If persistent mute was active, re-apply mute to the newly defaulted device. - if (keepMute) { - ApplyMute(nullptr, TRUE); - HWND hw = g_trayHwnd; - if (hw) PostMessageW(hw, WM_UPDATE_TRAY_STATE, 0, 0); - } - pEnum->Release(); - CoUninitialize(); + if (comHr == S_OK) CoUninitialize(); return TRUE; } @@ -2429,8 +2475,8 @@ static void Show(HWND hTrayWnd, int volPct) { DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { int direction = static_cast(reinterpret_cast(lpParam)); if (CycleAudioDevice(direction)) { - HWND hwnd = g_trayHwnd; - if (hwnd) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); + HWND hwnd = (HWND)InterlockedCompareExchangePointer((PVOID*)&g_trayHwnd, nullptr, nullptr); + if (hwnd && IsWindow(hwnd)) PostMessageW(hwnd, WM_UPDATE_TRAY_STATE, 0, 0); } InterlockedExchange(&g_isProcessingClick, 0); return 0; @@ -2619,7 +2665,10 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); DWORD pickerSlots = 0; - for (int i = 0; i < g_deviceSlotCount; i++) { + EnterCriticalSection(&g_stateLock); + int slotCount = g_deviceSlotCount; + LeaveCriticalSection(&g_stateLock); + for (int i = 0; i < slotCount; i++) { WCHAR key[16]; swprintf_s(key, L"icon%d", i + 1); WCHAR icoVal[32] = {}; @@ -2669,20 +2718,17 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) } else if (msg == WM_REBIND_VOLUME_CALLBACK) { // Default device changed — re-bind to new endpoint. + // No mute logic needed here: persistent mute already mutes ALL devices globally, + // and per-device mute stays on its original device by ID. UnbindEndpointVolume(); BindEndpointVolume(); - // If persistent mute is on and we were muted, mute the new default device. - if (g_persistentMute && g_isMutedByUs) { - ApplyMute(nullptr, TRUE); - PostMessageW(hWnd, WM_UPDATE_TRAY_STATE, 0, 0); - } } else if (msg == WM_RELOAD_ALL) { // Full reload triggered by the settings dashboard after Save and Apply. // Picks up new device assignments, icon choices, device count, swap mode, and priority list. { WCHAR pmBuf[8] = {}; - g_persistentMute = (Wh_GetStringValue(L"persistentMute", pmBuf, 8) && pmBuf[0] == L'1'); + InterlockedExchange(&g_persistentMute, (Wh_GetStringValue(L"persistentMute", pmBuf, 8) && pmBuf[0] == L'1') ? TRUE : FALSE); } LoadDeviceSelections(); LoadPriorityList(); @@ -2704,7 +2750,7 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) return 0; } else if (msg == WM_DESTROY) { - g_trayHwnd = nullptr; // prevent IMMNotificationClient callbacks from posting + InterlockedExchangePointer((PVOID*)&g_trayHwnd, nullptr); // prevent IMMNotificationClient callbacks from posting PostQuitMessage(0); } return DefWindowProcW(hWnd, msg, wParam, lParam); @@ -2731,18 +2777,34 @@ DWORD WINAPI TrayThreadProc(LPVOID) { // AudioSwap used HWND_MESSAGE originally, but message-only windows can lose their // tray icon after reboot because the shell cannot reliably associate the icon with // the window across session boundaries. - g_trayHwnd = CreateWindowExW( + HWND hTrayWnd = CreateWindowExW( WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, wc.lpszClassName, L"Audio Switcher", WS_POPUP, 0, 0, 1, 1, nullptr, nullptr, g_hInstance, nullptr ); - if (!g_trayHwnd) { - if (hrCo != RPC_E_CHANGED_MODE) CoUninitialize(); + InterlockedExchangePointer((PVOID*)&g_trayHwnd, hTrayWnd); + if (!hTrayWnd) { + if (hrCo == S_OK) CoUninitialize(); return 1; } + // Set AUMID for proper taskbar grouping + { + wchar_t aumid[] = L"BlackPaw.AudioSwap"; + PROPVARIANT pvAumid = {}; + pvAumid.vt = VT_LPWSTR; + pvAumid.pwszVal = aumid; + IPropertyStore* pps = nullptr; + if (SUCCEEDED(SHGetPropertyStoreForWindow(g_trayHwnd, IID_PPV_ARGS(&pps)))) { + if (SUCCEEDED(pps->SetValue(PKEY_AppUserModel_ID, pvAumid))) { + pps->Commit(); + } + pps->Release(); + } + } + // Register for instant device-change notifications. if (SUCCEEDED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&g_notifEnum))) { @@ -2777,7 +2839,7 @@ DWORD WINAPI TrayThreadProc(LPVOID) { RegisterRawInputDevices(&rid, 1, sizeof(rid)); } VolumePopup::UnregisterClass(); - if (hrCo != RPC_E_CHANGED_MODE) CoUninitialize(); + if (hrCo == S_OK) CoUninitialize(); return 0; } @@ -2841,16 +2903,22 @@ BOOL WhTool_ModInit() { WCHAR savedMutedId[512] = {}; Wh_GetStringValue(L"MutedDeviceId", savedMutedId, 512); if (savedMutedId[0] != L'\0') { - CoInitialize(nullptr); - ApplyMute(savedMutedId, FALSE); - CoUninitialize(); + HRESULT hrCo = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + if (FAILED(hrCo) && hrCo != RPC_E_CHANGED_MODE) + return FALSE; + if (wcscmp(savedMutedId, L"all") == 0) + ApplyMuteAll(FALSE); + else + ApplyMute(savedMutedId, FALSE); + if (hrCo == S_OK) + CoUninitialize(); Wh_SetStringValue(L"MutedDeviceId", L""); } // Load persistent mute setting. { WCHAR pmBuf[8] = {}; - g_persistentMute = (Wh_GetStringValue(L"persistentMute", pmBuf, 8) && pmBuf[0] == L'1'); + InterlockedExchange(&g_persistentMute, (Wh_GetStringValue(L"persistentMute", pmBuf, 8) && pmBuf[0] == L'1') ? TRUE : FALSE); } LoadUserIconsAndSettings(); // sets g_deviceSlotCount first @@ -2864,8 +2932,8 @@ void WhTool_ModSettingsChanged() { RestoreMuteExternal(); LoadDeviceSelections(); LoadPriorityList(); - HWND hwnd = g_trayHwnd; - if (hwnd) { + HWND hwnd = (HWND)InterlockedCompareExchangePointer((PVOID*)&g_trayHwnd, nullptr, nullptr); + if (hwnd && IsWindow(hwnd)) { PostMessageW(hwnd, WM_RELOAD_ICONS, 0, 0); PostMessageW(hwnd, WM_UPDATE_HOOK_STATE, 0, 0); } @@ -2877,8 +2945,8 @@ void WhTool_ModUninit() { // Send WM_CLOSE so TrayWndProc can delete the tray icon // cleanly before DestroyWindow → PostQuitMessage exits the message loop. - HWND hwnd = g_trayHwnd; - if (hwnd) PostMessageW(hwnd, WM_CLOSE, 0, 0); + HWND hwnd = (HWND)InterlockedCompareExchangePointer((PVOID*)&g_trayHwnd, nullptr, nullptr); + if (hwnd && IsWindow(hwnd)) PostMessageW(hwnd, WM_CLOSE, 0, 0); if (g_trayThread) { DWORD wr = WaitForSingleObject(g_trayThread, 3000); if (wr == WAIT_TIMEOUT) { @@ -2923,6 +2991,7 @@ void WhTool_ModUninit() { if (g_hWindHawkIcon){ DestroyIcon(g_hWindHawkIcon); g_hWindHawkIcon = nullptr; } if (g_hWindHawkBmp) { DeleteObject(g_hWindHawkBmp); g_hWindHawkBmp = nullptr; } + UnregisterClassW(L"AudioSwitcherWindowClass", GetModuleHandleW(nullptr)); DeleteCriticalSection(&g_stateLock); } From 358ef7ac1ed4be8a8e31d8fa3df8d86cd49dd969 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:19:38 +0300 Subject: [PATCH 55/56] persistent mute device-tracking fix persistent mute device-tracking fix and fixed various crash issues. --- mods/audioswap.wh.cpp | 85 +++++++++++++++++++++++++++++++------------ 1 file changed, 61 insertions(+), 24 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index 303c172f22..554ce0e9a4 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -64,14 +64,14 @@ Enable **Advanced Mode** in the Windhawk settings panel (gear icon → Settings ## Changelog -### v2.3.0 -- **New:** Persistent Mute toggle — mute state now survives device cycles (USB replug, sleep/wake, driver resets). -- **New:** Live dashboard device list refresh — device events (add/remove/default-change) update the dashboard combo without reopening. -- **Fixed:** Crash on reload — InterlockedCompareExchangePointer + IsWindow guard + UnregisterClassW for safe tray shutdown. -- **Fixed:** Crash on unload — same atomic handle read and class cleanup in shutdown path. -- **Fixed:** GUI thread CoInitialize return value not checked — now logs on unexpected failure. - -### v2.2.0 +# 2.3.0 +- **New:** Persistent Mute toggle — mute state now survives device cycles. +- **New:** Live dashboard device list refresh — device changes update the dashboard instantly without needing to reopen it. +- **Fixed:** Mod no longer crashes on reload or when interacting with the tray icon. +- **Fixed:** Mod no longer crashes when Windhawk shuts down. +- **Fixed:** Rare crash when opening the settings dashboard. + +# 2.2.0 - **New:** Native dashboard — right-click → **Mod Settings** (replaces Windhawk YAML panel). - **New:** Device Priority panel — rank preferred devices; highest-priority connected device auto-assigned. - **New:** Right-click → **Sound Settings...** opens Windows Sound dialog on Playback tab. @@ -86,26 +86,26 @@ Enable **Advanced Mode** in the Windhawk settings panel (gear icon → Settings - **Fixed:** Crash with very long audio device names. - **Fixed:** Cycling devices no longer skips first slot. -### v1.3.0 +# 1.3.0 - Up to 6 devices; scroll-to-swap mode; dynamic right-click menu. - Left-click mutes in scroll mode; red dot overlay on tray icon. - Cycling auto-unmutes previous device. - Inactive/disconnected devices skipped when cycling. - Mute state persisted across mod restarts. -### v1.2.0 +# 1.2.0 - **New:** Can now open WindHawk directly from the icon using right click - **New:** Added new icons to select from - **Improved:** added icons in the right click menu -### v1.1.0 +# 1.1.0 - **New:** Right-click context menu — auto-detects all active audio outputs and lets you assign Device 1 and Device 2 directly from a live list. No more typing device names manually. - **New:** Device selections persist across restarts. - **Improved:** Toggle now matches devices by their unique system ID instead of a name substring search — works correctly regardless of how Windows names your device. - **Improved:** Tray tooltip prompts you to configure on first run instead of showing "Unknown Device". - **Removed:** Device name text fields from the Settings tab (replaced by the right-click menu). -### v1.0.0 +# 1.0.0 - Initial release. */ // ==/WindhawkModReadme== @@ -220,10 +220,11 @@ static RECT g_trayIconRect = {}; static HICON g_iconDev[MAX_DEVICE_SLOTS] = {}; static HBITMAP g_hIconDevBmp[MAX_DEVICE_SLOTS] = {}; -// ── Shared state — protected by g_stateLock ────────────────────────────────── +// ── Shared state (protected by g_stateLock unless marked atomic) ───────────── // Read by the worker thread (CycleAudioDevice) and tray thread (UpdateTrayTip, // BuildAndShowContextMenu). Written by LoadDeviceSelections/LoadUserIconsAndSettings // (main thread or tray thread via WM_RELOAD_ALL) and ToggleMuteCurrentDevice (tray thread). +// g_persistentMute is volatile LONG with Interlocked ops — not under g_stateLock. static CRITICAL_SECTION g_stateLock; static WCHAR g_cachedDevId[MAX_DEVICE_SLOTS][512] = {}; static WCHAR g_cachedDevName[MAX_DEVICE_SLOTS][256] = {}; @@ -2073,6 +2074,29 @@ BOOL CycleAudioDevice(int direction) { } pEnum->Release(); + + // When persistent mute is on and the user is muted, keep the new device muted + // and retrack the device ID so unmute targets the correct device. + if (InterlockedOr(&g_persistentMute, 0)) { + EnterCriticalSection(&g_stateLock); + bool wasMuted = g_isMutedByUs; + WCHAR prevId[512]; + lstrcpynW(prevId, g_mutedDeviceId, 512); + LeaveCriticalSection(&g_stateLock); + if (wasMuted) { + // Unmute the previously tracked device (if different from new one). + if (prevId[0] && wcscmp(prevId, localIds[validSlot]) != 0) + ApplyMute(prevId, FALSE); + // Mute the new device and update tracking. + ApplyMute(localIds[validSlot], TRUE); + EnterCriticalSection(&g_stateLock); + lstrcpynW(g_mutedDeviceId, localIds[validSlot], 512); + g_isMutedByUs = true; + LeaveCriticalSection(&g_stateLock); + Wh_SetStringValue(L"MutedDeviceId", localIds[validSlot]); + } + } + if (comHr == S_OK) CoUninitialize(); return TRUE; } @@ -2381,8 +2405,14 @@ static LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lPara } case WM_ACTIVATE: - if (LOWORD(wParam) == WA_INACTIVE) - DestroyWindow(hWnd); + if (LOWORD(wParam) == WA_INACTIVE) { + static DWORD lastDeactivateTick = 0; + DWORD now = GetTickCount(); + if (now - lastDeactivateTick > 200) { + lastDeactivateTick = now; + DestroyWindow(hWnd); + } + } return 0; case WM_TIMER: @@ -2484,6 +2514,7 @@ DWORD WINAPI WorkerThreadProc(LPVOID lpParam) { static void SpawnCycleThread(int direction) { if (g_workerThread) { + WaitForSingleObject(g_workerThread, 3000); CloseHandle(g_workerThread); g_workerThread = nullptr; } @@ -2652,6 +2683,8 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) UpdateTrayTip(hWnd, FALSE); } else if (msg == WM_UPDATE_HOOK_STATE) { + RAWINPUTDEVICE rid_remove = { 1, 2, RIDEV_REMOVE, nullptr }; + RegisterRawInputDevices(&rid_remove, 1, sizeof(rid_remove)); RAWINPUTDEVICE rid = { 1, 2, RIDEV_INPUTSINK, hWnd }; RegisterRawInputDevices(&rid, 1, sizeof(rid)); @@ -2739,6 +2772,7 @@ LRESULT CALLBACK TrayWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) } else if (msg == WM_CLOSE) { // Orderly shutdown: remove tray icon, then destroy window. + KillTimer(hWnd, TRAY_RECT_INIT_TIMER); NOTIFYICONDATAW nid = {sizeof(nid)}; nid.hWnd = hWnd; nid.uID = TRAY_ICON_ID; @@ -2904,15 +2938,17 @@ BOOL WhTool_ModInit() { Wh_GetStringValue(L"MutedDeviceId", savedMutedId, 512); if (savedMutedId[0] != L'\0') { HRESULT hrCo = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - if (FAILED(hrCo) && hrCo != RPC_E_CHANGED_MODE) - return FALSE; - if (wcscmp(savedMutedId, L"all") == 0) - ApplyMuteAll(FALSE); - else - ApplyMute(savedMutedId, FALSE); - if (hrCo == S_OK) - CoUninitialize(); - Wh_SetStringValue(L"MutedDeviceId", L""); + if (FAILED(hrCo) && hrCo != RPC_E_CHANGED_MODE) { + Wh_Log(L"AudioSwap: Could not init COM for mute cleanup (0x%08X) — stale mute may persist", hrCo); + } else { + if (wcscmp(savedMutedId, L"all") == 0) + ApplyMuteAll(FALSE); + else + ApplyMute(savedMutedId, FALSE); + if (hrCo == S_OK) + CoUninitialize(); + Wh_SetStringValue(L"MutedDeviceId", L""); + } } // Load persistent mute setting. @@ -2993,6 +3029,7 @@ void WhTool_ModUninit() { UnregisterClassW(L"AudioSwitcherWindowClass", GetModuleHandleW(nullptr)); DeleteCriticalSection(&g_stateLock); + ExitProcess(0); } //////////////////////////////////////////////////////////////////////////////// From e64fdb21e9f9488de3364b79539eb21a352877b5 Mon Sep 17 00:00:00 2001 From: BlackPaw <141940171+BlackPaw21@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:03:09 +0300 Subject: [PATCH 56/56] Refactor CoUninitialize checks for HRESULT --- mods/audioswap.wh.cpp | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/mods/audioswap.wh.cpp b/mods/audioswap.wh.cpp index 554ce0e9a4..c9472da72e 100644 --- a/mods/audioswap.wh.cpp +++ b/mods/audioswap.wh.cpp @@ -1239,7 +1239,7 @@ namespace AudioSwapGui { } UnregisterClassW(kClass, hInst); - if (hrCo == S_OK) CoUninitialize(); + if (SUCCEEDED(hrCo)) CoUninitialize(); InterlockedExchange(&g_guiRunning, 0); return 0; } @@ -1477,7 +1477,7 @@ static void RestoreMuteExternal() { ApplyMuteAll(FALSE); else if (localId[0]) ApplyMute(localId, FALSE); - if (hrCo == S_OK) CoUninitialize(); + if (SUCCEEDED(hrCo)) CoUninitialize(); Wh_SetStringValue(L"MutedDeviceId", L""); } } @@ -1973,7 +1973,7 @@ static int GetCurrentVolumePct() { } pEnum->Release(); } - if (hrCo == S_OK) CoUninitialize(); + if (SUCCEEDED(hrCo)) CoUninitialize(); return result; } @@ -1996,7 +1996,7 @@ static void SetCurrentDeviceVolume(float scalar) { } pEnum->Release(); } - if (hrCo == S_OK) CoUninitialize(); + if (SUCCEEDED(hrCo)) CoUninitialize(); } // ─── Audio cycling ──────────────────────────────────────────────────────────── @@ -2029,7 +2029,7 @@ BOOL CycleAudioDevice(int direction) { IMMDeviceEnumerator* pEnum = nullptr; if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator), (void**)&pEnum))) { - if (comHr == S_OK) CoUninitialize(); return FALSE; + if (SUCCEEDED(comHr)) { CoUninitialize(); } return FALSE; } WCHAR currentId[512] = {}; @@ -2061,7 +2061,7 @@ BOOL CycleAudioDevice(int direction) { } if (validSlot == -1) { - pEnum->Release(); if (comHr == S_OK) CoUninitialize(); return FALSE; + pEnum->Release(); if (SUCCEEDED(comHr)) { CoUninitialize(); } return FALSE; } IPolicyConfig* pPolicyConfig = nullptr; @@ -2097,7 +2097,7 @@ BOOL CycleAudioDevice(int direction) { } } - if (comHr == S_OK) CoUninitialize(); + if (SUCCEEDED(comHr)) CoUninitialize(); return TRUE; } @@ -2820,7 +2820,7 @@ DWORD WINAPI TrayThreadProc(LPVOID) { ); InterlockedExchangePointer((PVOID*)&g_trayHwnd, hTrayWnd); if (!hTrayWnd) { - if (hrCo == S_OK) CoUninitialize(); + if (SUCCEEDED(hrCo)) CoUninitialize(); return 1; } @@ -2873,7 +2873,7 @@ DWORD WINAPI TrayThreadProc(LPVOID) { RegisterRawInputDevices(&rid, 1, sizeof(rid)); } VolumePopup::UnregisterClass(); - if (hrCo == S_OK) CoUninitialize(); + if (SUCCEEDED(hrCo)) CoUninitialize(); return 0; } @@ -2945,7 +2945,7 @@ BOOL WhTool_ModInit() { ApplyMuteAll(FALSE); else ApplyMute(savedMutedId, FALSE); - if (hrCo == S_OK) + if (SUCCEEDED(hrCo)) CoUninitialize(); Wh_SetStringValue(L"MutedDeviceId", L""); }