From 28ca744c2073172bc3bdf9840ecafc71f52a6029 Mon Sep 17 00:00:00 2001 From: kuy124 Date: Wed, 23 Sep 2026 03:19:04 +0700 Subject: [PATCH 1/3] fix: eliminate monitor-thread races and add safety hardening Thread-safety (fixes crashes / use-after-free): - UI thread now publishes an immutable profile snapshot that the monitor thread reads, instead of both threads mutating the live g_Profiles vector. - Replace the dangling AppProfile* g_ActiveProfile with a value copy plus an atomic index, so vector reallocation can no longer invalidate it. - Serialize all active-state (original dev mode, changed flag, index) behind g_RestoreMutex; make g_ProcessFoundTime/g_ActiveIndex atomic. Reliability: - Restore the original resolution when the app exits and on WM_ENDSESSION (logoff/shutdown) if a profile change is active. - Test Display Settings now auto-reverts after 15s via a timer instead of a blocking MessageBox, so an unreadable mode can always be recovered. - Single-instance mutex prevents duplicate tray icons / competing monitors. - Atomic config save (temp file + MoveFileEx) avoids corruption on crash. - IsProcessRunning handles an invalid toolhelp snapshot. UX / correctness: - Validate Width/Height before save and test; clamp Hz/Delay to >= 0. - Load the tray/window icon at the proper size via LoadImage and free it. - Fix signed/unsigned comparisons; handle GetMessage error and join safely. - Build clean with -Wall -Wextra -Wpedantic. Docs/build: - README updated for timed test revert and guaranteed restoration. - Add .gitignore; document the hardened build command. --- .gitignore | 12 ++ README.md | 11 +- main.cpp | 323 +++++++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 280 insertions(+), 66 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54e8874 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Build artifacts +*.exe +*.res +*.o +*.obj +*.pdb +*.ilk +build/ +out/ + +# MSVC / MinGW +*.dSYM/ diff --git a/README.md b/README.md index b52384a..3782ae1 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,9 @@ If you prefer to compile the application yourself using a lightweight compiler l ```bash windres resource.rc -O coff -o resource.res ``` -3. Run the C++ compiler to build the final executable: +3. Run the C++ compiler to build the final executable. Note that `-municode` and linking `resource.res` are required so the Unicode resource APIs resolve correctly: ```bash - g++ -O2 main.cpp resource.res -o AutoResChanger.exe -mwindows -municode -luser32 -lgdi32 -lshell32 -lcomdlg32 -ladvapi32 + g++ -O2 -Wall -Wextra main.cpp resource.res -o AutoResChanger.exe -mwindows -municode -luser32 -lgdi32 -lshell32 -lcomdlg32 -ladvapi32 ```
@@ -58,8 +58,8 @@ Managing your custom application profiles is straightforward and handled entirel If you want to verify whether a custom resolution or refresh rate is supported by your monitor before saving it: 1. Input your target dimensions or select an existing profile. 2. Click the **Test Display Settings** button. -3. Your screen will temporarily transition to the selected mode. -4. A prompt will appear on your screen. Clicking **OK** or waiting will safely revert your monitor to its original desktop settings. +3. Your screen will temporarily transition to the selected mode. +4. A prompt will appear on your screen. Clicking **OK** keeps the mode; if you do nothing, the mode automatically reverts to your original desktop settings after 15 seconds. This guarantees you can always recover even if the tested mode renders your display unreadable. --- @@ -69,6 +69,8 @@ AutoRes Changer includes several features to accommodate complex setups and game * Multi-Monitor Routing: Instead of changing settings globally, you can assign target resolutions to specific displays. The utility reads your active hardware configuration to target individual monitors cleanly. * Startup Delay (Grace Period): Some games load an initial splash screen or configuration launcher before launching the actual game window. Setting a **Delay** (in seconds) tells the utility to wait until the primary game window is fully loaded before executing the resolution override. +* Input Validation: Width and Height are validated before a profile is saved or tested, preventing accidental zero-sized or malformed modes. +* Single Instance: Only one copy of AutoRes Changer can run at a time, so launching it twice never leaves duplicate tray icons or competing monitors fighting over your display settings. * Auto-Start on Boot: Checking **Start automatically with Windows** registers the application in your local user workspace. Upon system boot, it launches silently in the background and rests minimized in your system tray without interrupting you. --- @@ -76,6 +78,7 @@ AutoRes Changer includes several features to accommodate complex setups and game ## Background Behavior & Reversion Safety * **Minimizing to Tray:** Closing the configuration window via the standard close button does not exit the utility. It hides the interface to the system tray so that monitoring remains active. To restore the window, simply double-click the system tray icon near your clock. +* **Guaranteed Restoration:** If a profile is active (i.e. your resolution is currently changed) and you exit the utility — or log off / shut down Windows — the original desktop resolution is restored automatically first. You will never be left stuck at a game resolution after closing AutoRes Changer. * **Emergency Reversion:** The display modifications are applied using standard Windows dynamic sessions (`CDS_FULLSCREEN`). This design choice means that your custom resolutions are not permanently written to your Windows registry. If a game crashes or your system restarts unexpectedly, Windows will natively restore your default desktop resolution automatically. --- diff --git a/main.cpp b/main.cpp index 8be9623..f02608e 100644 --- a/main.cpp +++ b/main.cpp @@ -13,6 +13,9 @@ #include #include #include +#include +#include +#include // ========================================== // DATA STRUCTURES & PRESETS @@ -51,15 +54,29 @@ const std::vector g_Presets = { { L"640 x 480 (VGA 4:3)", 640, 480 } }; +// The profile list is owned exclusively by the UI thread. The monitor thread +// never touches g_Profiles directly; instead the UI thread publishes an +// immutable snapshot that the worker reads. This eliminates the data race and +// the dangling-pointer bug that occurred when the vector reallocated. std::vector g_Profiles; -AppProfile* g_ActiveProfile = nullptr; +std::mutex g_SnapshotMutex; +std::shared_ptr> g_ProfileSnapshot; + +// NOTE: g_OriginalDevMode / g_ResChanged / g_ProcessFoundTime / g_ActiveIndex +// are touched only by the monitor thread, except for the explicit +// restore-on-exit path which is serialized via g_RestoreMutex. DEVMODEW g_OriginalDevMode; bool g_ResChanged = false; std::atomic g_MonitorRunning(true); std::wstring g_IniPath; -ULONGLONG g_ProcessFoundTime = 0; +std::atomic g_ProcessFoundTime(0); +std::atomic g_ActiveIndex(-1); // index into the *current snapshot*, or -1 +AppProfile g_ActiveProfileCopy; // value copy of the active profile (never dangles) +std::mutex g_RestoreMutex; // serializes restore between UI + monitor threads bool g_IgnoreEditChange = false; HINSTANCE g_hInst = NULL; // Stores instance handle for loading the baked-in icon +HANDLE g_hInstanceMutex = NULL; // Single-instance guard +HICON g_hAppIcon = NULL; // Owned icon handle, freed on exit // UI Handles HWND g_hMain, g_hList, g_hExe, g_hDisplayCombo, g_hW, g_hH, g_hHz, g_hDelay; @@ -80,10 +97,12 @@ std::wstring GetFileName(const std::wstring& path) { } bool IsProcessRunning(const std::wstring& processName) { + if (processName.empty()) return false; bool exists = false; PROCESSENTRY32W entry; entry.dwSize = sizeof(PROCESSENTRY32W); HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snapshot == INVALID_HANDLE_VALUE) return false; if (Process32FirstW(snapshot, &entry)) { do { if (_wcsicmp(entry.szExeFile, processName.c_str()) == 0) { @@ -95,6 +114,14 @@ bool IsProcessRunning(const std::wstring& processName) { return exists; } +// Republish an immutable snapshot of the profile list for the monitor thread. +// Must be called from the UI thread whenever g_Profiles changes. +void PublishSnapshot() { + auto snap = std::make_shared>(g_Profiles); + std::lock_guard lock(g_SnapshotMutex); + g_ProfileSnapshot = std::move(snap); +} + bool GetRunAtStartup() { HKEY hKey; if (RegOpenKeyExW(HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Run", 0, KEY_READ, &hKey) == ERROR_SUCCESS) { @@ -161,23 +188,34 @@ void LoadConfig() { } void SaveConfig() { - WritePrivateProfileStringW(NULL, NULL, NULL, g_IniPath.c_str()); - DeleteFileW(g_IniPath.c_str()); - - WritePrivateProfileStringW(L"Settings", L"Count", std::to_wstring(g_Profiles.size()).c_str(), g_IniPath.c_str()); + if (g_IniPath.empty()) return; + + // Write to a temporary file first, then atomically replace the real one so + // a crash or power loss mid-save cannot corrupt config.ini. + std::wstring tmpPath = g_IniPath + L".tmp"; + DeleteFileW(tmpPath.c_str()); + + WritePrivateProfileStringW(L"Settings", L"Count", std::to_wstring(g_Profiles.size()).c_str(), tmpPath.c_str()); for (size_t i = 0; i < g_Profiles.size(); ++i) { std::wstring sec = L"Profile_" + std::to_wstring(i); - WritePrivateProfileStringW(sec.c_str(), L"Exe", g_Profiles[i].exePath.c_str(), g_IniPath.c_str()); - WritePrivateProfileStringW(sec.c_str(), L"Device", g_Profiles[i].displayDev.c_str(), g_IniPath.c_str()); - WritePrivateProfileStringW(sec.c_str(), L"W", std::to_wstring(g_Profiles[i].targetW).c_str(), g_IniPath.c_str()); - WritePrivateProfileStringW(sec.c_str(), L"H", std::to_wstring(g_Profiles[i].targetH).c_str(), g_IniPath.c_str()); - WritePrivateProfileStringW(sec.c_str(), L"Hz", std::to_wstring(g_Profiles[i].targetHz).c_str(), g_IniPath.c_str()); - WritePrivateProfileStringW(sec.c_str(), L"Delay", std::to_wstring(g_Profiles[i].delaySec).c_str(), g_IniPath.c_str()); - WritePrivateProfileStringW(sec.c_str(), L"Restore", g_Profiles[i].restore ? L"1" : L"0", g_IniPath.c_str()); - WritePrivateProfileStringW(sec.c_str(), L"Enabled", g_Profiles[i].enabled ? L"1" : L"0", g_IniPath.c_str()); + WritePrivateProfileStringW(sec.c_str(), L"Exe", g_Profiles[i].exePath.c_str(), tmpPath.c_str()); + WritePrivateProfileStringW(sec.c_str(), L"Device", g_Profiles[i].displayDev.c_str(), tmpPath.c_str()); + WritePrivateProfileStringW(sec.c_str(), L"W", std::to_wstring(g_Profiles[i].targetW).c_str(), tmpPath.c_str()); + WritePrivateProfileStringW(sec.c_str(), L"H", std::to_wstring(g_Profiles[i].targetH).c_str(), tmpPath.c_str()); + WritePrivateProfileStringW(sec.c_str(), L"Hz", std::to_wstring(g_Profiles[i].targetHz).c_str(), tmpPath.c_str()); + WritePrivateProfileStringW(sec.c_str(), L"Delay", std::to_wstring(g_Profiles[i].delaySec).c_str(), tmpPath.c_str()); + WritePrivateProfileStringW(sec.c_str(), L"Restore", g_Profiles[i].restore ? L"1" : L"0", tmpPath.c_str()); + WritePrivateProfileStringW(sec.c_str(), L"Enabled", g_Profiles[i].enabled ? L"1" : L"0", tmpPath.c_str()); + } + // Flush the file to disk before swapping it in. + WritePrivateProfileStringW(NULL, NULL, NULL, tmpPath.c_str()); + + if (!MoveFileExW(tmpPath.c_str(), g_IniPath.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { + // Fallback: if the atomic replace failed, try a plain replace. + DeleteFileW(g_IniPath.c_str()); + MoveFileW(tmpPath.c_str(), g_IniPath.c_str()); } - WritePrivateProfileStringW(NULL, NULL, NULL, g_IniPath.c_str()); } // ========================================== @@ -219,30 +257,71 @@ void RestoreRes(const std::wstring& devName, DEVMODEW& dm) { ChangeDisplaySettingsExW(devName.empty() ? NULL : devName.c_str(), &dm, NULL, CDS_FULLSCREEN, NULL); } +// Restore the resolution if a profile change is currently active. Safe to call +// from either thread; the restore itself is serialized by g_RestoreMutex. +// Returns true if a restore was performed. +bool RestoreActiveIfChanged() { + std::lock_guard lock(g_RestoreMutex); + if (g_ActiveIndex >= 0 && g_ResChanged) { + RestoreRes(g_ActiveProfileCopy.displayDev, g_OriginalDevMode); + g_ResChanged = false; + g_ActiveIndex = -1; + g_ProcessFoundTime = 0; + return true; + } + return false; +} + +// Clears the active-state bookkeeping without touching the display. Used when +// the profile was set to not restore, or the change never succeeded. +void ClearActiveState() { + std::lock_guard lock(g_RestoreMutex); + g_ResChanged = false; + g_ActiveIndex = -1; + g_ProcessFoundTime = 0; +} + // ========================================== // BACKGROUND THREAD // ========================================== void MonitorLoop() { while (g_MonitorRunning) { - if (g_ActiveProfile != nullptr) { - if (!IsProcessRunning(g_ActiveProfile->exeName)) { - if (g_ActiveProfile->restore && g_ResChanged) { - RestoreRes(g_ActiveProfile->displayDev, g_OriginalDevMode); - } - g_ActiveProfile = nullptr; g_ResChanged = false; g_ProcessFoundTime = 0; + // Grab the latest immutable snapshot (cheap shared_ptr copy). + std::shared_ptr> snap; + { + std::lock_guard lock(g_SnapshotMutex); + snap = g_ProfileSnapshot; + } + + if (g_ActiveIndex >= 0) { + // An active profile is being watched. If it was removed from the + // config, or its process exited, restore and stop watching. + bool stillConfigured = (snap && g_ActiveIndex < (int)snap->size()); + bool processGone = !stillConfigured || !IsProcessRunning(g_ActiveProfileCopy.exeName); + if (processGone) { + if (g_ActiveProfileCopy.restore) RestoreActiveIfChanged(); + else ClearActiveState(); } - } else { - for (auto& p : g_Profiles) { + } else if (snap) { + for (size_t i = 0; i < snap->size(); ++i) { + const AppProfile& p = (*snap)[i]; if (p.enabled && IsProcessRunning(p.exeName)) { ULONGLONG now = GetTickCount64(); if (g_ProcessFoundTime == 0) g_ProcessFoundTime = now; if ((now - g_ProcessFoundTime) >= (ULONGLONG)(p.delaySec * 1000)) { - g_OriginalDevMode = GetCurrentRes(p.displayDev); - if (ChangeRes(p.displayDev, p.targetW, p.targetH, p.targetHz) == DISP_CHANGE_SUCCESSFUL) { - g_ResChanged = true; + DEVMODEW orig = GetCurrentRes(p.displayDev); + int result = ChangeRes(p.displayDev, p.targetW, p.targetH, p.targetHz); + // Publish the new active state under the restore lock so + // the UI thread's RestoreActiveIfChanged() sees a + // consistent (dev mode, active index, changed flag) set. + { + std::lock_guard lock(g_RestoreMutex); + g_OriginalDevMode = orig; + g_ResChanged = (result == DISP_CHANGE_SUCCESSFUL); + g_ActiveProfileCopy = p; // value copy -> never dangles + g_ActiveIndex = (int)i; } - g_ActiveProfile = &p; break; } } @@ -261,13 +340,13 @@ void RefreshList(int selectIndex = -1) { std::wstring display = p.exeName + L" (" + std::to_wstring(p.targetW) + L"x" + std::to_wstring(p.targetH) + L")"; SendMessage(g_hList, LB_ADDSTRING, 0, (LPARAM)display.c_str()); } - if (selectIndex >= 0 && selectIndex < g_Profiles.size()) { + if (selectIndex >= 0 && selectIndex < (int)g_Profiles.size()) { SendMessage(g_hList, LB_SETCURSEL, selectIndex, 0); } } void SelectProfile(int index) { - if (index >= 0 && index < g_Profiles.size()) { + if (index >= 0 && index < (int)g_Profiles.size()) { const auto& p = g_Profiles[index]; SetWindowTextW(g_hExe, p.exePath.c_str()); @@ -303,6 +382,38 @@ BOOL CALLBACK SetFontCallback(HWND hwndChild, LPARAM lParam) { return TRUE; } +// ========================================== +// TEST-MODE AUTO-REVERT +// ========================================== +// When the user tests a display mode we apply it, then arm a countdown. If the +// user does not confirm within the grace period we automatically restore the +// previous mode. This is the safety net that the README always promised but +// the original blocking MessageBox never delivered. +#define TIMER_TEST_REVERT 1 +#define TEST_REVERT_SECONDS 15 + +DEVMODEW g_TestOriginal; +std::wstring g_TestDevice; +bool g_TestActive = false; + +void CancelTestTimer(HWND hwnd) { + if (g_TestActive) { + KillTimer(hwnd, TIMER_TEST_REVERT); + g_TestActive = false; + } +} + +// Returns true and writes the value when the field holds a positive integer. +bool ReadPositiveInt(HWND hEdit, int minVal, int maxVal, int& out) { + WCHAR buf[16] = { 0 }; + GetWindowTextW(hEdit, buf, 16); + if (wcslen(buf) == 0) return false; + int v = _wtoi(buf); + if (v < minVal || v > maxVal) return false; + out = v; + return true; +} + LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_CREATE: { @@ -354,13 +465,20 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { HFONT hFont = CreateFontW(15, 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"); EnumChildWindows(hwnd, SetFontCallback, (LPARAM)hFont); - // Tray Icon Setup (Loads baked-in icon '1' instead of IDI_APPLICATION) + // Tray Icon Setup (loads the baked-in icon '1' at the correct small + // size so it renders crisply in the notification area). + g_hAppIcon = (HICON)LoadImageW(g_hInst, MAKEINTRESOURCEW(1), IMAGE_ICON, + GetSystemMetrics(SM_CXSMICON), + GetSystemMetrics(SM_CYSMICON), + LR_DEFAULTCOLOR); + if (!g_hAppIcon) g_hAppIcon = LoadIconW(NULL, IDI_APPLICATION); + g_Nid.cbSize = sizeof(NOTIFYICONDATAW); g_Nid.hWnd = hwnd; g_Nid.uID = 1; g_Nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP; g_Nid.uCallbackMessage = WM_TRAYICON; - g_Nid.hIcon = LoadIconW(g_hInst, MAKEINTRESOURCE(1)); + g_Nid.hIcon = g_hAppIcon; wcscpy_s(g_Nid.szTip, L"AutoRes Changer"); Shell_NotifyIconW(NIM_ADD, &g_Nid); break; @@ -438,48 +556,80 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { ofn.Flags = OFN_PATHMUSTEXIST | OFN_FILEMUSTEXIST; if (GetOpenFileNameW(&ofn)) SetWindowTextW(g_hExe, szFile); } else if (wmId == 102) { // Save - WCHAR exe[MAX_PATH], w[16], h[16], hz[16], del[16]; - GetWindowTextW(g_hExe, exe, MAX_PATH); if (wcslen(exe) == 0) break; - GetWindowTextW(g_hW, w, 16); GetWindowTextW(g_hH, h, 16); - GetWindowTextW(g_hHz, hz, 16); GetWindowTextW(g_hDelay, del, 16); + WCHAR exe[MAX_PATH]; + GetWindowTextW(g_hExe, exe, MAX_PATH); + if (wcslen(exe) == 0) { + MessageBoxW(hwnd, L"Select an executable path first.", L"Missing Path", MB_OK | MB_ICONWARNING); + break; + } + int w = 0, h = 0; + if (!ReadPositiveInt(g_hW, 1, 32767, w) || !ReadPositiveInt(g_hH, 1, 32767, h)) { + MessageBoxW(hwnd, L"Width and Height must be positive numbers (1-32767).", L"Invalid Resolution", MB_OK | MB_ICONWARNING); + break; + } + WCHAR hzbuf[16] = { 0 }, delbuf[16] = { 0 }; + GetWindowTextW(g_hHz, hzbuf, 16); + GetWindowTextW(g_hDelay, delbuf, 16); + int hz = _wtoi(hzbuf); if (hz < 0) hz = 0; + int delSec = _wtoi(delbuf); if (delSec < 0) delSec = 0; AppProfile p; p.exePath = exe; p.exeName = GetFileName(p.exePath); - p.targetW = _wtoi(w); p.targetH = _wtoi(h); p.targetHz = _wtoi(hz); p.delaySec = _wtoi(del); + p.targetW = w; p.targetH = h; p.targetHz = hz; p.delaySec = delSec; p.restore = SendMessage(g_hRestore, BM_GETCHECK, 0, 0) == BST_CHECKED; p.enabled = SendMessage(g_hEnable, BM_GETCHECK, 0, 0) == BST_CHECKED; int comboIdx = SendMessage(g_hDisplayCombo, CB_GETCURSEL, 0, 0); - p.displayDev = (comboIdx > 0 && comboIdx < g_MonitorDevices.size()) ? g_MonitorDevices[comboIdx] : L""; + p.displayDev = (comboIdx > 0 && comboIdx < (int)g_MonitorDevices.size()) ? g_MonitorDevices[comboIdx] : L""; int targetIdx = -1; bool found = false; for (size_t i = 0; i < g_Profiles.size(); ++i) { if (_wcsicmp(g_Profiles[i].exePath.c_str(), p.exePath.c_str()) == 0) { - g_Profiles[i] = p; found = true; targetIdx = i; break; + g_Profiles[i] = p; found = true; targetIdx = (int)i; break; } } if (!found) { g_Profiles.push_back(p); - targetIdx = g_Profiles.size() - 1; + targetIdx = (int)g_Profiles.size() - 1; } - SaveConfig(); - RefreshList(targetIdx); + SaveConfig(); + PublishSnapshot(); + RefreshList(targetIdx); } else if (wmId == 103) { // Delete int idx = SendMessage(g_hList, LB_GETCURSEL, 0, 0); - if (idx != LB_ERR) { g_Profiles.erase(g_Profiles.begin() + idx); SaveConfig(); RefreshList(); SetWindowTextW(g_hExe, L""); } + if (idx != LB_ERR) { g_Profiles.erase(g_Profiles.begin() + idx); SaveConfig(); PublishSnapshot(); RefreshList(); SetWindowTextW(g_hExe, L""); } } else if (wmId == 104) { // Test - WCHAR w[16], h[16], hz[16]; - GetWindowTextW(g_hW, w, 16); GetWindowTextW(g_hH, h, 16); GetWindowTextW(g_hHz, hz, 16); + int w = 0, h = 0, hz = 0; + if (!ReadPositiveInt(g_hW, 1, 32767, w) || !ReadPositiveInt(g_hH, 1, 32767, h)) { + MessageBoxW(hwnd, L"Enter a valid Width and Height before testing.", L"Invalid Input", MB_OK | MB_ICONWARNING); + break; + } + // Hz is optional: blank or 0 means "use the driver default". + { + WCHAR hzbuf[16] = { 0 }; + GetWindowTextW(g_hHz, hzbuf, 16); + hz = _wtoi(hzbuf); + if (hz < 0) hz = 0; + } int idx = SendMessage(g_hDisplayCombo, CB_GETCURSEL, 0, 0); - std::wstring dev = (idx > 0 && idx < g_MonitorDevices.size()) ? g_MonitorDevices[idx] : L""; - + std::wstring dev = (idx > 0 && idx < (int)g_MonitorDevices.size()) ? g_MonitorDevices[idx] : L""; + DEVMODEW orig = GetCurrentRes(dev); - if (ChangeRes(dev, _wtoi(w), _wtoi(h), _wtoi(hz)) == DISP_CHANGE_SUCCESSFUL) { - MessageBoxW(hwnd, L"Resolution applied! Press OK to revert.", L"Test", MB_OK | MB_ICONINFORMATION); - RestoreRes(dev, orig); - } else MessageBoxW(hwnd, L"Monitor does not support this mode.", L"Error", MB_OK | MB_ICONERROR); + if (ChangeRes(dev, w, h, hz) == DISP_CHANGE_SUCCESSFUL) { + g_TestOriginal = orig; + g_TestDevice = dev; + g_TestActive = true; + SetTimer(hwnd, TIMER_TEST_REVERT, (UINT)TEST_REVERT_SECONDS * 1000, NULL); + std::wstring msg = L"Resolution applied. Click OK to keep it, or it will " + L"automatically revert in " + std::to_wstring(TEST_REVERT_SECONDS) + L" seconds."; + MessageBoxW(hwnd, msg.c_str(), L"Testing Display Settings", MB_OK | MB_ICONINFORMATION); + // If the user pressed OK within the window, keep the mode. + if (g_TestActive) CancelTestTimer(hwnd); + } else { + MessageBoxW(hwnd, L"Monitor does not support this mode.", L"Error", MB_OK | MB_ICONERROR); + } } else if (wmId == 105) { // Launch App WCHAR exe[MAX_PATH]; GetWindowTextW(g_hExe, exe, MAX_PATH); if (wcslen(exe) > 0) ShellExecuteW(NULL, L"open", exe, NULL, NULL, SW_SHOWNORMAL); @@ -487,7 +637,7 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { SetRunAtStartup(SendMessage(g_hStartup, BM_GETCHECK, 0, 0) == BST_CHECKED); } else if (wmId == 107 && HIWORD(wParam) == CBN_SELCHANGE) { // Preset changed int idx = SendMessage(g_hPresetCombo, CB_GETCURSEL, 0, 0); - if (idx > 0 && idx < g_Presets.size()) { + if (idx > 0 && idx < (int)g_Presets.size()) { g_IgnoreEditChange = true; SetWindowTextW(g_hW, std::to_wstring(g_Presets[idx].w).c_str()); SetWindowTextW(g_hH, std::to_wstring(g_Presets[idx].h).c_str()); @@ -502,6 +652,14 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { } break; } + case WM_TIMER: { + if (wParam == TIMER_TEST_REVERT && g_TestActive) { + RestoreRes(g_TestDevice, g_TestOriginal); + g_TestActive = false; + KillTimer(hwnd, TIMER_TEST_REVERT); + } + break; + } case WM_TRAYICON: { if (lParam == WM_LBUTTONDBLCLK) { ShowWindow(hwnd, SW_RESTORE); SetForegroundWindow(hwnd); } else if (lParam == WM_RBUTTONUP) { @@ -512,14 +670,33 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { } break; } - case WM_CLOSE: + case WM_CLOSE: { + // Keep the icon/message flags intact and just add the balloon. + NOTIFYICONDATAW nid = g_Nid; + nid.uFlags = NIF_INFO; + wcscpy_s(nid.szInfoTitle, L"AutoRes Changer"); + wcscpy_s(nid.szInfo, L"Running in background. Right-click tray icon to exit."); + nid.dwInfoFlags = NIIF_INFO; + Shell_NotifyIconW(NIM_MODIFY, &nid); ShowWindow(hwnd, SW_HIDE); - g_Nid.uFlags = NIF_INFO; wcscpy_s(g_Nid.szInfoTitle, L"AutoRes Changer"); - wcscpy_s(g_Nid.szInfo, L"Running in background. Right-click tray icon to exit."); - Shell_NotifyIconW(NIM_MODIFY, &g_Nid); - return 0; - case WM_DESTROY: - Shell_NotifyIconW(NIM_DELETE, &g_Nid); PostQuitMessage(0); break; + return 0; + } + case WM_ENDSESSION: + case WM_DESTROY: { + // On logoff/shutdown (WM_ENDSESSION) or normal exit, make sure the + // desktop is restored if we are the ones who changed it. + CancelTestTimer(hwnd); + if (msg == WM_ENDSESSION) { + RestoreActiveIfChanged(); + break; + } + Shell_NotifyIconW(NIM_DELETE, &g_Nid); + RestoreActiveIfChanged(); + if (g_hAppIcon) { DestroyIcon(g_hAppIcon); g_hAppIcon = NULL; } + if (g_hInstanceMutex) { CloseHandle(g_hInstanceMutex); g_hInstanceMutex = NULL; } + PostQuitMessage(0); + break; + } } return DefWindowProc(hwnd, msg, wParam, lParam); } @@ -528,10 +705,22 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { // APPLICATION ENTRY // ========================================== int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLine, int nCmdShow) { + (void)hPrevInstance; SetProcessDPIAware(); g_hInst = hInstance; // Store instance for resource retrieval + + // Single-instance guard: a second launch should not create a duplicate tray + // icon or a competing monitor thread. If already running, just exit. + g_hInstanceMutex = CreateMutexW(NULL, TRUE, L"AutoResChanger_SingleInstance"); + if (g_hInstanceMutex && GetLastError() == ERROR_ALREADY_EXISTS) { + CloseHandle(g_hInstanceMutex); + g_hInstanceMutex = NULL; + return 0; + } + InitConfigPath(); LoadConfig(); + PublishSnapshot(); WNDCLASSW wc = {0}; wc.lpfnWndProc = WndProc; @@ -539,7 +728,9 @@ int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLi wc.hbrBackground = (HBRUSH)(COLOR_WINDOW); wc.lpszClassName = L"AutoResChangerClass"; wc.hCursor = LoadCursor(NULL, IDC_ARROW); - wc.hIcon = LoadIconW(hInstance, MAKEINTRESOURCE(1)); // Loads custom icon for window/taskbar + wc.hIcon = (HICON)LoadImageW(hInstance, MAKEINTRESOURCEW(1), IMAGE_ICON, + 0, 0, LR_DEFAULTSIZE | LR_DEFAULTCOLOR); + if (!wc.hIcon) wc.hIcon = LoadIconW(NULL, IDI_APPLICATION); RegisterClassW(&wc); g_hMain = CreateWindowW(L"AutoResChangerClass", L"AutoRes Changer", WS_OVERLAPPEDWINDOW, @@ -568,7 +759,15 @@ int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLi UpdateWindow(g_hMain); } - MSG msg; while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } - g_MonitorRunning = false; monitorThread.join(); - return 0; + MSG msg; + BOOL bRet; + while ((bRet = GetMessage(&msg, NULL, 0, 0)) != 0) { + if (bRet == -1) break; // GetMessage error + TranslateMessage(&msg); + DispatchMessage(&msg); + } + g_MonitorRunning = false; + if (monitorThread.joinable()) monitorThread.join(); + if (g_hInstanceMutex) { CloseHandle(g_hInstanceMutex); g_hInstanceMutex = NULL; } + return (int)msg.wParam; } \ No newline at end of file From 781ed3a636e76cbb449362004d4bcb969cf90907 Mon Sep 17 00:00:00 2001 From: kuy124 Date: Wed, 23 Sep 2026 03:27:31 +0700 Subject: [PATCH 2/3] feat: supported-modes picker, dark mode, drag-drop, DPI, logging + CI UI / UX: - Add a live 'Supported Modes' dropdown populated from EnumDisplaySettings for the selected monitor, so listed modes are guaranteed to be supported. - Owner-draw profile list showing each target app's extracted icon. - Drag-and-drop an .exe onto the window to fill the executable path. - Dark mode toggle (immersive dark title bar + WM_CTLCOLOR* themed controls). - Optional diagnostic logging to %APPDATA%\AutoResChanger\log.txt. - Tray menu gains 'Restore resolution now' and 'Open config folder'. Robustness / performance: - Single-pass process enumeration per tick (set-based) instead of one snapshot per profile; adaptive 1s/2s polling with interruptible sleep. - Handle WM_DISPLAYCHANGE to re-enumerate monitors and refresh modes. - Per-monitor-v2 DPI awareness with graceful fallbacks. - Persist dark-mode/logging prefs; free theme brushes, icons and handles. Build / docs: - Add CMakeLists.txt (MinGW + MSVC) and a PowerShell build.ps1. - Add GitHub Actions workflow that builds and attaches the exe to releases. - README updated for all new features and build options. --- .github/workflows/build.yml | 51 +++++ CMakeLists.txt | 38 +++ README.md | 44 +++- build.ps1 | 55 +++++ main.cpp | 444 +++++++++++++++++++++++++++++++++--- 5 files changed, 584 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 CMakeLists.txt create mode 100644 build.ps1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..418d022 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,51 @@ +name: Build + +on: + push: + branches: [ main ] + tags: [ 'v*' ] + pull_request: + branches: [ main ] + workflow_dispatch: + +jobs: + build: + runs-on: windows-latest + defaults: + run: + shell: msys2 {0} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup MSYS2 + uses: msys2/setup-msys2@v2 + with: + msystem: UCRT64 + update: true + install: >- + mingw-w64-ucrt-x86_64-gcc + make + + - name: Build with MinGW-w64 + run: | + windres resource.rc -O coff -o resource.res + g++ -O2 -s -Wall -Wextra -Wpedantic \ + -Wno-unknown-pragmas -Wno-missing-field-initializers \ + main.cpp resource.res -o AutoResChanger.exe \ + -mwindows -municode \ + -luser32 -lgdi32 -lshell32 -lcomdlg32 -ladvapi32 \ + -ldwmapi -luxtheme -lole32 -lcomctl32 + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: AutoResChanger + path: AutoResChanger.exe + if-no-files-found: error + + - name: Attach to release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + files: AutoResChanger.exe diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..4a2f469 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,38 @@ +cmake_minimum_required(VERSION 3.16) +project(AutoResChanger LANGUAGES CXX RC) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +if(NOT WIN32) + message(FATAL_ERROR "AutoRes Changer targets Windows only.") +endif() + +# Prefer MinGW-w64 (MSYS2) or MSVC. The code is portable across both, but the +# #pragma comment(lib, ...) directives are MSVC-only and simply ignored by GCC, +# so the libraries are listed explicitly below for the MinGW path. +add_executable(AutoResChanger WIN32 main.cpp resource.rc) + +target_compile_definitions(AutoResChanger PRIVATE UNICODE _UNICODE) + +if(MSVC) + target_compile_options(AutoResChanger PRIVATE /W4 /permissive-) + target_link_libraries(AutoResChanger PRIVATE + user32 gdi32 shell32 comdlg32 advapi32 dwmapi uxtheme) + set_target_properties(AutoResChanger PROPERTIES + LINK_FLAGS "/ENTRY:wWinMainCRTStartup") +else() + # MinGW-w64 + target_compile_options(AutoResChanger PRIVATE + -Wall -Wextra -Wpedantic + -Wno-unknown-pragmas + -Wno-missing-field-initializers) + # -municode selects wWinMain as the entry point. + target_link_options(AutoResChanger PRIVATE -municode) + target_link_libraries(AutoResChanger PRIVATE + user32 gdi32 shell32 comdlg32 advapi32 dwmapi uxtheme comctl32 ole32) +endif() + +# Emit the binary next to the source tree for convenience. +set_target_properties(AutoResChanger PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") diff --git a/README.md b/README.md index 3782ae1..d9905b5 100644 --- a/README.md +++ b/README.md @@ -31,15 +31,28 @@ If you prefer to compile the application yourself using a lightweight compiler l 4. Place any custom icon file you want to use inside the folder and name it `app.ico`. #### Step 2: Compile the Program -1. Open your terminal in the folder where your files are located. -2. Run the resource compiler to prepare your custom icon: - ```bash - windres resource.rc -O coff -o resource.res - ``` -3. Run the C++ compiler to build the final executable. Note that `-municode` and linking `resource.res` are required so the Unicode resource APIs resolve correctly: - ```bash - g++ -O2 -Wall -Wextra main.cpp resource.res -o AutoResChanger.exe -mwindows -municode -luser32 -lgdi32 -lshell32 -lcomdlg32 -ladvapi32 - ``` +Pick whichever build method you prefer. + +**Quick build (Windows PowerShell script).** The bundled `build.ps1` auto-detects the MinGW toolchain and runs everything for you: +```powershell +.\build.ps1 # release build +.\build.ps1 -Debug # debug build with symbols +.\build.ps1 -Clean # remove build artifacts first +``` + +**CMake.** A `CMakeLists.txt` is provided for both MinGW-w64 and MSVC: +```bash +cmake -S . -B build -G "MinGW Makefiles" +cmake --build build +``` + +**Manual (MinGW-w64).** Run the resource compiler, then the C++ compiler. Note that `-municode` and linking `resource.res` are required so the Unicode resource APIs resolve correctly: +```bash +windres resource.rc -O coff -o resource.res +g++ -O2 -Wall -Wextra main.cpp resource.res -o AutoResChanger.exe -mwindows -municode -luser32 -lgdi32 -lshell32 -lcomdlg32 -ladvapi32 -ldwmapi -luxtheme -lole32 -lcomctl32 +``` + +A GitHub Actions workflow (`.github/workflows/build.yml`) automatically builds the executable on every push and attaches it to a release whenever you push a `v*` tag.
@@ -49,10 +62,12 @@ Managing your custom application profiles is straightforward and handled entirel ### Creating a Profile 1. Launch `AutoResChanger.exe`. -2. Click the **`...`** browse button next to the **Executable Path** field and select the `.exe` file of the game or program you want to configure. +2. Click the **`...`** browse button next to the **Executable Path** field and select the `.exe` file of the game or program you want to configure. You can also simply **drag and drop** an `.exe` from Explorer onto the window. 3. Select which screen you want to modify from the **Target Display** drop-down list (supports multi-monitor setups). -4. Choose a pre-defined layout from the **Resolution Template / Presets** drop-down menu, or type custom dimensions manually into the **Width**, **Height**, and **Hz** input fields. -5. Click **Save Profile**. Your new configuration will appear in the left-hand profile list. +4. Pick a resolution in one of two ways: + * **Supported Modes** — a live list read directly from the selected monitor, so every entry is guaranteed to be supported. + * **Resolution Template / Presets** — a curated list of common resolutions, or type custom dimensions manually into the **Width**, **Height**, and **Hz** input fields. +5. Click **Save Profile**. Your new configuration will appear in the left-hand profile list (with the target app's own icon). ### Testing Modes Safely If you want to verify whether a custom resolution or refresh rate is supported by your monitor before saving it: @@ -68,16 +83,20 @@ If you want to verify whether a custom resolution or refresh rate is supported b AutoRes Changer includes several features to accommodate complex setups and game behaviors: * Multi-Monitor Routing: Instead of changing settings globally, you can assign target resolutions to specific displays. The utility reads your active hardware configuration to target individual monitors cleanly. +* Live Supported Modes: The **Supported Modes** drop-down is populated on the fly from your selected monitor via `EnumDisplaySettings`, so you can see exactly which resolutions and refresh rates your hardware actually offers before saving. * Startup Delay (Grace Period): Some games load an initial splash screen or configuration launcher before launching the actual game window. Setting a **Delay** (in seconds) tells the utility to wait until the primary game window is fully loaded before executing the resolution override. * Input Validation: Width and Height are validated before a profile is saved or tested, preventing accidental zero-sized or malformed modes. * Single Instance: Only one copy of AutoRes Changer can run at a time, so launching it twice never leaves duplicate tray icons or competing monitors fighting over your display settings. * Auto-Start on Boot: Checking **Start automatically with Windows** registers the application in your local user workspace. Upon system boot, it launches silently in the background and rests minimized in your system tray without interrupting you. +* Dark Mode & Diagnostic Logging: Toggle a dark UI theme and an optional diagnostic log (written to `%APPDATA%\AutoResChanger\log.txt`) that records each detection, mode change, and failure — handy when troubleshooting a stubborn game. +* Per-Monitor DPI Aware: Uses the modern per-monitor-v2 DPI context so the interface stays crisp on mixed-DPI and high-refresh setups. --- ## Background Behavior & Reversion Safety * **Minimizing to Tray:** Closing the configuration window via the standard close button does not exit the utility. It hides the interface to the system tray so that monitoring remains active. To restore the window, simply double-click the system tray icon near your clock. +* **Tray Quick Actions:** Right-clicking the tray icon offers **Open window**, **Restore resolution now** (instantly reverts if a profile change is currently active, enabled only when one is), **Open config folder**, and **Exit AutoRes Changer**. * **Guaranteed Restoration:** If a profile is active (i.e. your resolution is currently changed) and you exit the utility — or log off / shut down Windows — the original desktop resolution is restored automatically first. You will never be left stuck at a game resolution after closing AutoRes Changer. * **Emergency Reversion:** The display modifications are applied using standard Windows dynamic sessions (`CDS_FULLSCREEN`). This design choice means that your custom resolutions are not permanently written to your Windows registry. If a game crashes or your system restarts unexpectedly, Windows will natively restore your default desktop resolution automatically. @@ -88,6 +107,7 @@ AutoRes Changer includes several features to accommodate complex setups and game ### Managing Configurations Your application profiles are saved cleanly in a standard configuration file on your system. * You can find your saved settings at: `%APPDATA%\AutoResChanger\config.ini` +* An optional diagnostic log (when **Enable diagnostic logging** is checked) is written to `%APPDATA%\AutoResChanger\log.txt`. * To clear all profiles or start fresh, you can simply delete the `config.ini` file or the parent `AutoResChanger` directory inside your AppData folder. ### Complete Removal diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..95b562b --- /dev/null +++ b/build.ps1 @@ -0,0 +1,55 @@ +# build.ps1 - one-shot build script for AutoRes Changer (MinGW-w64 / MSYS2). +# +# Usage: +# .\build.ps1 # release build +# .\build.ps1 -Debug # debug build with symbols +# .\build.ps1 -Clean # remove build artifacts first +# +# Requires g++ and windres on PATH (e.g. MSYS2 UCRT64: C:\msys64\ucrt64\bin). + +param( + [switch]$Debug, + [switch]$Clean +) + +$ErrorActionPreference = "Stop" +Set-Location -LiteralPath $PSScriptRoot + +if ($Clean) { + Remove-Item -Force -ErrorAction SilentlyContinue ` + AutoResChanger.exe, resource.res, baseline.exe + Write-Host "Cleaned build artifacts." -ForegroundColor Yellow +} + +# Make sure the MinGW toolchain is reachable even if it's not on PATH. +if (-not (Get-Command g++ -ErrorAction SilentlyContinue)) { + foreach ($p in @("C:\msys64\ucrt64\bin", "C:\msys64\mingw64\bin")) { + if (Test-Path (Join-Path $p "g++.exe")) { + $env:PATH = "$p;$env:PATH" + break + } + } +} + +if (-not (Get-Command g++ -ErrorAction SilentlyContinue)) { + throw "g++ not found. Install MSYS2 (UCRT64) or add MinGW-w64 to PATH." +} + +$cxxflags = if ($Debug) { + @("-O0", "-g") +} else { + @("-O2", "-s") +} +$warn = @("-Wall", "-Wextra", "-Wpedantic", "-Wno-unknown-pragmas", "-Wno-missing-field-initializers") +$libs = @("-luser32", "-lgdi32", "-lshell32", "-lcomdlg32", "-ladvapi32", "-ldwmapi", "-luxtheme", "-lole32", "-lcomctl32") + +Write-Host "Compiling resources..." -ForegroundColor Cyan +& windres resource.rc -O coff -o resource.res +if ($LASTEXITCODE -ne 0) { throw "windres failed." } + +Write-Host "Compiling AutoResChanger.exe..." -ForegroundColor Cyan +& g++ @cxxflags @warn main.cpp resource.res -o AutoResChanger.exe -mwindows -municode @libs +if ($LASTEXITCODE -ne 0) { throw "g++ failed." } + +$size = [math]::Round((Get-Item AutoResChanger.exe).Length / 1KB) +Write-Host "Build succeeded: AutoResChanger.exe ($size KB)" -ForegroundColor Green diff --git a/main.cpp b/main.cpp index f02608e..c28a6de 100644 --- a/main.cpp +++ b/main.cpp @@ -6,16 +6,24 @@ #pragma comment(lib, "advapi32.lib") #include +#include +#include #include #include #include #include #include +#include #include #include #include #include #include +#include +#include + +#pragma comment(lib, "shell32.lib") +#pragma comment(lib, "dwmapi.lib") // ========================================== // DATA STRUCTURES & PRESETS @@ -38,6 +46,12 @@ struct ResTemplate { int h; }; +struct ModeEntry { + int w; + int h; + int hz; +}; + const std::vector g_Presets = { { L"Custom (Manual Input)", 0, 0 }, { L"3840 x 2160 (4K UHD 16:9)", 3840, 2160 }, @@ -77,16 +91,36 @@ bool g_IgnoreEditChange = false; HINSTANCE g_hInst = NULL; // Stores instance handle for loading the baked-in icon HANDLE g_hInstanceMutex = NULL; // Single-instance guard HICON g_hAppIcon = NULL; // Owned icon handle, freed on exit +bool g_DarkMode = false; // UI theme preference +bool g_LoggingEnabled = false; // Persisted logging preference // UI Handles HWND g_hMain, g_hList, g_hExe, g_hDisplayCombo, g_hW, g_hH, g_hHz, g_hDelay; -HWND g_hRestore, g_hEnable, g_hStartup, g_hPresetCombo; +HWND g_hRestore, g_hEnable, g_hStartup, g_hPresetCombo, g_hSupportedCombo; HWND g_hListLabel, g_hExeLabel, g_hBtnBrowse, g_hDisplayLabel, g_hPresetLabel; HWND g_hWLabel, g_hHLabel, g_hHzLabel, g_hDelayLabel, g_hSaveBtn, g_hDelBtn, g_hLaunchBtn, g_hTestBtn; +HWND g_hSupportedLabel, g_hLogChk, g_hDarkChk; #define WM_TRAYICON (WM_APP + 1) NOTIFYICONDATAW g_Nid = {}; std::vector g_MonitorDevices; +std::wstring g_LogPath; +std::vector g_SupportedModesCache; + +// ========================================== +// LOGGING +// ========================================== +void LogMsg(const std::wstring& text) { + if (!g_LoggingEnabled || g_LogPath.empty()) return; + std::wofstream f(g_LogPath.c_str(), std::ios::app); + if (!f.is_open()) return; + time_t t = time(nullptr); + tm tmv; + localtime_s(&tmv, &t); + WCHAR stamp[32]; + wcsftime(stamp, 32, L"%Y-%m-%d %H:%M:%S", &tmv); + f << L"[" << stamp << L"] " << text << L"\n"; +} // ========================================== // UTILITY & REGISTRY FUNCTIONS @@ -114,6 +148,32 @@ bool IsProcessRunning(const std::wstring& processName) { return exists; } +// Returns the set of running executable names (lower-cased) in a single pass. +// The monitor thread uses one snapshot per tick instead of one per profile, +// which keeps the detection cheap even with many profiles. +std::set GetRunningProcessNames() { + std::set names; + PROCESSENTRY32W entry; + entry.dwSize = sizeof(PROCESSENTRY32W); + HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snapshot == INVALID_HANDLE_VALUE) return names; + if (Process32FirstW(snapshot, &entry)) { + do { + std::wstring n = entry.szExeFile; + for (auto& c : n) c = (WCHAR)towlower(c); + names.insert(n); + } while (Process32NextW(snapshot, &entry)); + } + CloseHandle(snapshot); + return names; +} + +std::wstring ToLowerCopy(const std::wstring& s) { + std::wstring r = s; + for (auto& c : r) c = (WCHAR)towlower(c); + return r; +} + // Republish an immutable snapshot of the profile list for the monitor thread. // Must be called from the UI thread whenever g_Profiles changes. void PublishSnapshot() { @@ -159,9 +219,20 @@ void InitConfigPath() { std::wstring dir = std::wstring(path) + L"\\AutoResChanger"; CreateDirectoryW(dir.c_str(), NULL); g_IniPath = dir + L"\\config.ini"; + g_LogPath = dir + L"\\log.txt"; } } +void LoadSettings() { + g_DarkMode = GetPrivateProfileIntW(L"Settings", L"DarkMode", 0, g_IniPath.c_str()) != 0; + g_LoggingEnabled = GetPrivateProfileIntW(L"Settings", L"Logging", 0, g_IniPath.c_str()) != 0; +} + +void SaveSettings() { + WritePrivateProfileStringW(L"Settings", L"DarkMode", g_DarkMode ? L"1" : L"0", g_IniPath.c_str()); + WritePrivateProfileStringW(L"Settings", L"Logging", g_LoggingEnabled ? L"1" : L"0", g_IniPath.c_str()); +} + void LoadConfig() { g_Profiles.clear(); int count = GetPrivateProfileIntW(L"Settings", L"Count", 0, g_IniPath.c_str()); @@ -196,6 +267,8 @@ void SaveConfig() { DeleteFileW(tmpPath.c_str()); WritePrivateProfileStringW(L"Settings", L"Count", std::to_wstring(g_Profiles.size()).c_str(), tmpPath.c_str()); + WritePrivateProfileStringW(L"Settings", L"DarkMode", g_DarkMode ? L"1" : L"0", tmpPath.c_str()); + WritePrivateProfileStringW(L"Settings", L"Logging", g_LoggingEnabled ? L"1" : L"0", tmpPath.c_str()); for (size_t i = 0; i < g_Profiles.size(); ++i) { std::wstring sec = L"Profile_" + std::to_wstring(i); @@ -245,6 +318,62 @@ DEVMODEW GetCurrentRes(const std::wstring& devName) { return dm; } +// Enumerates every distinct (w,h,hz) mode the given display advertises. This +// powers the "Supported Modes" dropdown so the user never has to guess. +std::vector EnumModesForDevice(const std::wstring& devName) { + std::vector modes; + std::set seen; // key = w<<40 | h<<20 | hz-ish, packed loosely + DEVMODEW dm = { 0 }; dm.dmSize = sizeof(dm); + DWORD i = 0; + while (EnumDisplaySettingsExW(devName.empty() ? NULL : devName.c_str(), i, &dm, 0)) { + long long key = ((long long)dm.dmPelsWidth << 32) ^ ((long long)dm.dmPelsHeight << 16) ^ (long long)dm.dmDisplayFrequency; + if (seen.insert(key).second) { + modes.push_back({ (int)dm.dmPelsWidth, (int)dm.dmPelsHeight, (int)dm.dmDisplayFrequency }); + } + i++; + } + // Sort by resolution desc, then refresh rate desc. + std::sort(modes.begin(), modes.end(), [](const ModeEntry& a, const ModeEntry& b) { + if (a.w != b.w) return a.w > b.w; + if (a.h != b.h) return a.h > b.h; + return a.hz > b.hz; + }); + return modes; +} + +// Rebuilds the Supported Modes dropdown for the currently selected display. +void RefreshSupportedModes() { + if (!g_hSupportedCombo) return; + SendMessage(g_hSupportedCombo, CB_RESETCONTENT, 0, 0); + + int idx = (int)SendMessage(g_hDisplayCombo, CB_GETCURSEL, 0, 0); + std::wstring dev = (idx > 0 && idx < (int)g_MonitorDevices.size()) ? g_MonitorDevices[idx] : L""; + + std::vector modes = EnumModesForDevice(dev); + SendMessage(g_hSupportedCombo, CB_ADDSTRING, 0, (LPARAM)L"Pick a listed mode..."); + for (const auto& m : modes) { + std::wstring label = std::to_wstring(m.w) + L" x " + std::to_wstring(m.h); + if (m.hz > 0) label += L" @ " + std::to_wstring(m.hz) + L" Hz"; + SendMessage(g_hSupportedCombo, CB_ADDSTRING, 0, (LPARAM)label.c_str()); + } + g_SupportedModesCache = modes; // index 0 == placeholder, so cache index = combo index-1 + SendMessage(g_hSupportedCombo, CB_SETCURSEL, 0, 0); +} + +// Extracts the icon for a given executable and returns a small HICON (caller +// owns it) or NULL on failure. +HICON ExtractExeIcon(const std::wstring& path, int cx, int cy) { + if (path.empty()) return NULL; + SHFILEINFOW sfi = { 0 }; + UINT flags = SHGFI_ICON | SHGFI_SMALLICON; + DWORD_PTR r = SHGetFileInfoW(path.c_str(), 0, &sfi, sizeof(sfi), flags); + if (!r || !sfi.hIcon) return NULL; + // Resize to the requested dimensions for crisper list rendering. + HICON resized = (HICON)CopyImage(sfi.hIcon, IMAGE_ICON, cx, cy, LR_COPYFROMRESOURCE); + DestroyIcon(sfi.hIcon); + return resized ? resized : NULL; +} + int ChangeRes(const std::wstring& devName, int w, int h, int hz) { DEVMODEW dm = GetCurrentRes(devName); dm.dmPelsWidth = w; dm.dmPelsHeight = h; @@ -285,7 +414,15 @@ void ClearActiveState() { // BACKGROUND THREAD // ========================================== void MonitorLoop() { + // How long to wait between checks. We poll more often while idle so a game + // is detected quickly, but back off a little while watching a running + // process (nothing can change until it exits). + const DWORD kIdleIntervalMs = 1000; + const DWORD kWatchIntervalMs = 2000; + while (g_MonitorRunning) { + DWORD sleepMs = kIdleIntervalMs; + // Grab the latest immutable snapshot (cheap shared_ptr copy). std::shared_ptr> snap; { @@ -294,20 +431,29 @@ void MonitorLoop() { } if (g_ActiveIndex >= 0) { + sleepMs = kWatchIntervalMs; // An active profile is being watched. If it was removed from the // config, or its process exited, restore and stop watching. bool stillConfigured = (snap && g_ActiveIndex < (int)snap->size()); bool processGone = !stillConfigured || !IsProcessRunning(g_ActiveProfileCopy.exeName); if (processGone) { - if (g_ActiveProfileCopy.restore) RestoreActiveIfChanged(); - else ClearActiveState(); + if (g_ActiveProfileCopy.restore) { + if (RestoreActiveIfChanged()) + LogMsg(L"Restored resolution (process exited): " + g_ActiveProfileCopy.exeName); + } else { + ClearActiveState(); + } } - } else if (snap) { + } else if (snap && !snap->empty()) { + std::set running = GetRunningProcessNames(); for (size_t i = 0; i < snap->size(); ++i) { const AppProfile& p = (*snap)[i]; - if (p.enabled && IsProcessRunning(p.exeName)) { + if (p.enabled && running.count(ToLowerCopy(p.exeName))) { ULONGLONG now = GetTickCount64(); - if (g_ProcessFoundTime == 0) g_ProcessFoundTime = now; + if (g_ProcessFoundTime == 0) { + g_ProcessFoundTime = now; + LogMsg(L"Detected process: " + p.exeName); + } if ((now - g_ProcessFoundTime) >= (ULONGLONG)(p.delaySec * 1000)) { DEVMODEW orig = GetCurrentRes(p.displayDev); @@ -322,23 +468,44 @@ void MonitorLoop() { g_ActiveProfileCopy = p; // value copy -> never dangles g_ActiveIndex = (int)i; } + if (result == DISP_CHANGE_SUCCESSFUL) { + LogMsg(L"Applied " + std::to_wstring(p.targetW) + L"x" + std::to_wstring(p.targetH) + + L"@" + std::to_wstring(p.targetHz) + L"Hz for " + p.exeName); + } else { + LogMsg(L"FAILED to apply mode for " + p.exeName + L" (code " + std::to_wstring(result) + L")"); + } break; } } } } - Sleep(2000); + + // Interruptible sleep: check the shutdown flag frequently so exit is fast. + DWORD waited = 0; + while (waited < sleepMs && g_MonitorRunning) { + Sleep(200); + waited += 200; + } } } // ========================================== // UI MANAGEMENT // ========================================== +// Icons are owned per rendered list row and re-created on refresh. +std::vector g_ListItemIcons; + void RefreshList(int selectIndex = -1) { + for (HICON ic : g_ListItemIcons) if (ic) DestroyIcon(ic); + g_ListItemIcons.clear(); + SendMessage(g_hList, LB_RESETCONTENT, 0, 0); for (const auto& p : g_Profiles) { std::wstring display = p.exeName + L" (" + std::to_wstring(p.targetW) + L"x" + std::to_wstring(p.targetH) + L")"; - SendMessage(g_hList, LB_ADDSTRING, 0, (LPARAM)display.c_str()); + int idx = (int)SendMessage(g_hList, LB_ADDSTRING, 0, (LPARAM)display.c_str()); + HICON ic = ExtractExeIcon(p.exePath, 16, 16); + g_ListItemIcons.push_back(ic); + SendMessage(g_hList, LB_SETITEMDATA, idx, (LPARAM)ic); } if (selectIndex >= 0 && selectIndex < (int)g_Profiles.size()) { SendMessage(g_hList, LB_SETCURSEL, selectIndex, 0); @@ -382,6 +549,31 @@ BOOL CALLBACK SetFontCallback(HWND hwndChild, LPARAM lParam) { return TRUE; } +// ========================================== +// THEME (DARK MODE) +// ========================================== +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +HBRUSH g_hDarkBrush = NULL; // Owned background brush for dark mode +HBRUSH g_hEditBrush = NULL; // Owned edit-field background brush + +// Applies the Windows immersive dark title bar. The rest of the dark styling is +// handled in WM_CTLCOLOR* using the brushes above. +void ApplyTheme(HWND hwnd) { + BOOL dark = g_DarkMode ? TRUE : FALSE; + // Older builds honour attribute 19, newer ones 20; try both. + if (FAILED(DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, &dark, sizeof(dark)))) { + const DWORD alt = 19; + DwmSetWindowAttribute(hwnd, alt, &dark, sizeof(dark)); + } + if (!g_hDarkBrush) g_hDarkBrush = CreateSolidBrush(RGB(32, 32, 32)); + if (!g_hEditBrush) g_hEditBrush = CreateSolidBrush(RGB(45, 45, 45)); + InvalidateRect(hwnd, NULL, TRUE); + RedrawWindow(hwnd, NULL, NULL, RDW_INVALIDATE | RDW_ERASE | RDW_ALLCHILDREN); +} + // ========================================== // TEST-MODE AUTO-REVERT // ========================================== @@ -419,7 +611,7 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { case WM_CREATE: { // Left Panel g_hListLabel = CreateWindowW(L"STATIC", L"Profiles:", WS_VISIBLE | WS_CHILD, 0, 0, 0, 0, hwnd, NULL, NULL, NULL); - g_hList = CreateWindowW(L"LISTBOX", NULL, WS_VISIBLE | WS_CHILD | WS_BORDER | LBS_NOTIFY | WS_VSCROLL, 0, 0, 0, 0, hwnd, (HMENU)100, NULL, NULL); + g_hList = CreateWindowW(L"LISTBOX", NULL, WS_VISIBLE | WS_CHILD | WS_BORDER | LBS_NOTIFY | WS_VSCROLL | LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | LBS_NOINTEGRALHEIGHT, 0, 0, 0, 0, hwnd, (HMENU)100, NULL, NULL); // Right Panel g_hExeLabel = CreateWindowW(L"STATIC", L"Executable Path:", WS_VISIBLE | WS_CHILD, 0, 0, 0, 0, hwnd, NULL, NULL, NULL); @@ -427,7 +619,10 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { g_hBtnBrowse = CreateWindowW(L"BUTTON", L"...", WS_VISIBLE | WS_CHILD, 0, 0, 0, 0, hwnd, (HMENU)101, NULL, NULL); g_hDisplayLabel = CreateWindowW(L"STATIC", L"Target Display (Monitor):", WS_VISIBLE | WS_CHILD, 0, 0, 0, 0, hwnd, NULL, NULL, NULL); - g_hDisplayCombo = CreateWindowW(L"COMBOBOX", NULL, WS_VISIBLE | WS_CHILD | CBS_DROPDOWNLIST | WS_VSCROLL, 0, 0, 0, 0, hwnd, NULL, NULL, NULL); + g_hDisplayCombo = CreateWindowW(L"COMBOBOX", NULL, WS_VISIBLE | WS_CHILD | CBS_DROPDOWNLIST | WS_VSCROLL, 0, 0, 0, 0, hwnd, (HMENU)110, NULL, NULL); + + g_hSupportedLabel = CreateWindowW(L"STATIC", L"Supported Modes (live from monitor):", WS_VISIBLE | WS_CHILD, 0, 0, 0, 0, hwnd, NULL, NULL, NULL); + g_hSupportedCombo = CreateWindowW(L"COMBOBOX", NULL, WS_VISIBLE | WS_CHILD | CBS_DROPDOWNLIST | WS_VSCROLL, 0, 0, 0, 0, hwnd, (HMENU)111, NULL, NULL); g_hPresetLabel = CreateWindowW(L"STATIC", L"Resolution Template / Presets:", WS_VISIBLE | WS_CHILD, 0, 0, 0, 0, hwnd, NULL, NULL, NULL); g_hPresetCombo = CreateWindowW(L"COMBOBOX", NULL, WS_VISIBLE | WS_CHILD | CBS_DROPDOWNLIST | WS_VSCROLL, 0, 0, 0, 0, hwnd, (HMENU)107, NULL, NULL); @@ -457,10 +652,18 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { g_hStartup = CreateWindowW(L"BUTTON", L"Start automatically with Windows (Minimized)", WS_VISIBLE | WS_CHILD | BS_AUTOCHECKBOX, 0, 0, 0, 0, hwnd, (HMENU)106, NULL, NULL); if (GetRunAtStartup()) SendMessage(g_hStartup, BM_SETCHECK, BST_CHECKED, 0); + g_hLogChk = CreateWindowW(L"BUTTON", L"Enable diagnostic logging", WS_VISIBLE | WS_CHILD | BS_AUTOCHECKBOX, 0, 0, 0, 0, hwnd, (HMENU)112, NULL, NULL); + g_hDarkChk = CreateWindowW(L"BUTTON", L"Dark mode", WS_VISIBLE | WS_CHILD | BS_AUTOCHECKBOX, 0, 0, 0, 0, hwnd, (HMENU)113, NULL, NULL); + if (g_LoggingEnabled) SendMessage(g_hLogChk, BM_SETCHECK, BST_CHECKED, 0); + if (g_DarkMode) SendMessage(g_hDarkChk, BM_SETCHECK, BST_CHECKED, 0); + + DragAcceptFiles(hwnd, TRUE); + for (const auto& preset : g_Presets) { SendMessage(g_hPresetCombo, CB_ADDSTRING, 0, (LPARAM)preset.name.c_str()); } SendMessage(g_hPresetCombo, CB_SETCURSEL, 0, 0); + RefreshSupportedModes(); HFONT hFont = CreateFontW(15, 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"); EnumChildWindows(hwnd, SetFontCallback, (LPARAM)hFont); @@ -503,47 +706,97 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { MoveWindow(g_hExe, rightX, 30, rightW - 35, 22, TRUE); MoveWindow(g_hBtnBrowse, rightX + rightW - 30, 30, 30, 22, TRUE); - MoveWindow(g_hDisplayLabel, rightX, 60, rightW, 20, TRUE); - MoveWindow(g_hDisplayCombo, rightX, 80, rightW, 150, TRUE); + MoveWindow(g_hDisplayLabel, rightX, 58, rightW, 18, TRUE); + MoveWindow(g_hDisplayCombo, rightX, 76, rightW, 150, TRUE); + + MoveWindow(g_hSupportedLabel, rightX, 104, rightW, 18, TRUE); + MoveWindow(g_hSupportedCombo, rightX, 122, rightW, 150, TRUE); - MoveWindow(g_hPresetLabel, rightX, 110, rightW, 20, TRUE); - MoveWindow(g_hPresetCombo, rightX, 130, rightW, 150, TRUE); + MoveWindow(g_hPresetLabel, rightX, 150, rightW, 18, TRUE); + MoveWindow(g_hPresetCombo, rightX, 168, rightW, 150, TRUE); int colW = rightW / 4; - MoveWindow(g_hWLabel, rightX, 160, colW - 5, 20, TRUE); - MoveWindow(g_hW, rightX, 180, colW - 5, 22, TRUE); + MoveWindow(g_hWLabel, rightX, 196, colW - 5, 18, TRUE); + MoveWindow(g_hW, rightX, 214, colW - 5, 22, TRUE); - MoveWindow(g_hHLabel, rightX + colW, 160, colW - 5, 20, TRUE); - MoveWindow(g_hH, rightX + colW, 180, colW - 5, 22, TRUE); + MoveWindow(g_hHLabel, rightX + colW, 196, colW - 5, 18, TRUE); + MoveWindow(g_hH, rightX + colW, 214, colW - 5, 22, TRUE); - MoveWindow(g_hHzLabel, rightX + colW * 2, 160, colW - 5, 20, TRUE); - MoveWindow(g_hHz, rightX + colW * 2, 180, colW - 5, 22, TRUE); + MoveWindow(g_hHzLabel, rightX + colW * 2, 196, colW - 5, 18, TRUE); + MoveWindow(g_hHz, rightX + colW * 2, 214, colW - 5, 22, TRUE); - MoveWindow(g_hDelayLabel, rightX + colW * 3, 160, colW - 5, 20, TRUE); - MoveWindow(g_hDelay, rightX + colW * 3, 180, colW - 5, 22, TRUE); + MoveWindow(g_hDelayLabel, rightX + colW * 3, 196, colW - 5, 18, TRUE); + MoveWindow(g_hDelay, rightX + colW * 3, 214, colW - 5, 22, TRUE); - MoveWindow(g_hRestore, rightX, 215, rightW, 20, TRUE); - MoveWindow(g_hEnable, rightX, 235, rightW, 20, TRUE); + MoveWindow(g_hRestore, rightX, 244, rightW, 20, TRUE); + MoveWindow(g_hEnable, rightX, 264, rightW, 20, TRUE); int btnW = rightW / 3; - MoveWindow(g_hSaveBtn, rightX, 270, btnW - 5, 30, TRUE); - MoveWindow(g_hDelBtn, rightX + btnW, 270, btnW - 5, 30, TRUE); - MoveWindow(g_hLaunchBtn, rightX + btnW * 2, 270, btnW - 5, 30, TRUE); + MoveWindow(g_hSaveBtn, rightX, 292, btnW - 5, 30, TRUE); + MoveWindow(g_hDelBtn, rightX + btnW, 292, btnW - 5, 30, TRUE); + MoveWindow(g_hLaunchBtn, rightX + btnW * 2, 292, btnW - 5, 30, TRUE); + + MoveWindow(g_hTestBtn, rightX, 326, rightW, 30, TRUE); - MoveWindow(g_hTestBtn, rightX, 310, rightW, 30, TRUE); - MoveWindow(g_hStartup, rightX, h - 35, rightW, 20, TRUE); + // Bottom row of options. + int optW = rightW / 2; + MoveWindow(g_hLogChk, rightX, h - 58, optW - 5, 20, TRUE); + MoveWindow(g_hDarkChk, rightX + optW, h - 58, optW - 5, 20, TRUE); + MoveWindow(g_hStartup, rightX, h - 34, rightW, 20, TRUE); break; } case WM_GETMINMAXINFO: { MINMAXINFO* mmi = (MINMAXINFO*)lParam; - mmi->ptMinTrackSize.x = 550; - mmi->ptMinTrackSize.y = 440; + mmi->ptMinTrackSize.x = 580; + mmi->ptMinTrackSize.y = 500; return 0; } + case WM_CTLCOLORSTATIC: + case WM_CTLCOLORBTN: { + if (g_DarkMode && g_hDarkBrush) { + HDC hdc = (HDC)wParam; + SetTextColor(hdc, RGB(230, 230, 230)); + SetBkColor(hdc, RGB(32, 32, 32)); + return (LRESULT)g_hDarkBrush; + } + break; + } + case WM_CTLCOLOREDIT: + case WM_CTLCOLORLISTBOX: { + if (g_DarkMode && g_hEditBrush) { + HDC hdc = (HDC)wParam; + SetTextColor(hdc, RGB(230, 230, 230)); + SetBkColor(hdc, RGB(45, 45, 45)); + return (LRESULT)g_hEditBrush; + } + break; + } + case WM_ERASEBKGND: { + if (g_DarkMode && g_hDarkBrush) { + RECT rc; GetClientRect(hwnd, &rc); + FillRect((HDC)wParam, &rc, g_hDarkBrush); + return 1; + } + break; + } case WM_COMMAND: { int wmId = LOWORD(wParam); if (wmId == 100 && HIWORD(wParam) == LBN_SELCHANGE) { SelectProfile(SendMessage(g_hList, LB_GETCURSEL, 0, 0)); + } else if (wmId == 110 && HIWORD(wParam) == CBN_SELCHANGE) { // Display changed + RefreshSupportedModes(); + } else if (wmId == 111 && HIWORD(wParam) == CBN_SELCHANGE) { // Supported mode picked + int sel = (int)SendMessage(g_hSupportedCombo, CB_GETCURSEL, 0, 0); + int mi = sel - 1; // combo index 0 is the placeholder + if (mi >= 0 && mi < (int)g_SupportedModesCache.size()) { + const ModeEntry& m = g_SupportedModesCache[mi]; + g_IgnoreEditChange = true; + SetWindowTextW(g_hW, std::to_wstring(m.w).c_str()); + SetWindowTextW(g_hH, std::to_wstring(m.h).c_str()); + SetWindowTextW(g_hHz, std::to_wstring(m.hz > 0 ? m.hz : 0).c_str()); + g_IgnoreEditChange = false; + SendMessage(g_hPresetCombo, CB_SETCURSEL, 0, 0); + } } else if (wmId == 101) { // Browse WCHAR szFile[MAX_PATH] = { 0 }; OPENFILENAMEW ofn = { 0 }; @@ -647,6 +900,21 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { if (!g_IgnoreEditChange) { SendMessage(g_hPresetCombo, CB_SETCURSEL, 0, 0); } + } else if (wmId == 112) { // Logging toggle + g_LoggingEnabled = (SendMessage(g_hLogChk, BM_GETCHECK, 0, 0) == BST_CHECKED); + SaveSettings(); + if (g_LoggingEnabled) LogMsg(L"Diagnostic logging enabled."); + } else if (wmId == 113) { // Dark mode toggle + g_DarkMode = (SendMessage(g_hDarkChk, BM_GETCHECK, 0, 0) == BST_CHECKED); + SaveSettings(); + ApplyTheme(hwnd); + } else if (wmId == 201) { // Tray: open window + ShowWindow(hwnd, SW_RESTORE); SetForegroundWindow(hwnd); + } else if (wmId == 202) { // Tray: open config folder + std::wstring dir = g_IniPath.substr(0, g_IniPath.find_last_of(L'\\')); + if (!dir.empty()) ShellExecuteW(NULL, L"open", dir.c_str(), NULL, NULL, SW_SHOWNORMAL); + } else if (wmId == 203) { // Tray: restore resolution now + if (RestoreActiveIfChanged()) LogMsg(L"User restored resolution from tray."); } else if (wmId == 200) { DestroyWindow(hwnd); } @@ -660,12 +928,83 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { } break; } + case WM_MEASUREITEM: { + MEASUREITEMSTRUCT* mis = (MEASUREITEMSTRUCT*)lParam; + if (mis->CtlID == 100) { mis->itemHeight = 22; return TRUE; } + break; + } + case WM_DRAWITEM: { + DRAWITEMSTRUCT* dis = (DRAWITEMSTRUCT*)lParam; + if (dis->CtlID != 100) break; + if ((int)dis->itemID < 0) return TRUE; // empty list + + WCHAR text[512] = { 0 }; + SendMessageW(g_hList, LB_GETTEXT, dis->itemID, (LPARAM)text); + + bool selected = (dis->itemState & ODS_SELECTED) != 0; + COLORREF bg = selected ? GetSysColor(COLOR_HIGHLIGHT) + : (g_DarkMode ? RGB(32, 32, 32) : GetSysColor(COLOR_WINDOW)); + COLORREF fg = selected ? GetSysColor(COLOR_HIGHLIGHTTEXT) + : (g_DarkMode ? RGB(230, 230, 230) : GetSysColor(COLOR_WINDOWTEXT)); + + HBRUSH bgb = CreateSolidBrush(bg); + FillRect(dis->hDC, &dis->rcItem, bgb); + DeleteObject(bgb); + + HICON ic = (HICON)SendMessage(g_hList, LB_GETITEMDATA, dis->itemID, 0); + int textX = dis->rcItem.left + 4; + if (ic) { + DrawIconEx(dis->hDC, dis->rcItem.left + 3, dis->rcItem.top + 3, ic, 16, 16, 0, NULL, DI_NORMAL); + textX = dis->rcItem.left + 24; + } + SetBkMode(dis->hDC, TRANSPARENT); + SetTextColor(dis->hDC, fg); + RECT tr = dis->rcItem; tr.left = textX; + DrawTextW(dis->hDC, text, -1, &tr, DT_SINGLELINE | DT_VCENTER | DT_END_ELLIPSIS); + return TRUE; + } + case WM_DISPLAYCHANGE: { + // A monitor was added/removed or a mode changed out from under us. + // Re-enumerate displays and refresh the supported-mode list without + // disturbing the user's current field entries. + PopulateMonitors(); + RefreshSupportedModes(); + LogMsg(L"WM_DISPLAYCHANGE received; refreshed monitor list."); + break; + } + case WM_DROPFILES: { + HDROP hDrop = (HDROP)wParam; + UINT count = DragQueryFileW(hDrop, 0xFFFFFFFF, NULL, 0); + if (count > 0) { + WCHAR file[MAX_PATH] = { 0 }; + DragQueryFileW(hDrop, 0, file, MAX_PATH); + std::wstring path = file; + std::wstring lower = ToLowerCopy(path); + if (lower.size() >= 4 && lower.substr(lower.size() - 4) == L".exe") { + SetWindowTextW(g_hExe, path.c_str()); + LogMsg(L"Dropped executable: " + path); + } else { + MessageBoxW(hwnd, L"Please drop an .exe file.", L"Unsupported File", MB_OK | MB_ICONINFORMATION); + } + } + DragFinish(hDrop); + break; + } case WM_TRAYICON: { if (lParam == WM_LBUTTONDBLCLK) { ShowWindow(hwnd, SW_RESTORE); SetForegroundWindow(hwnd); } else if (lParam == WM_RBUTTONUP) { POINT pt; GetCursorPos(&pt); HMENU hMenu = CreatePopupMenu(); - InsertMenuW(hMenu, 0, MF_BYPOSITION | MF_STRING, 200, L"Exit AutoRes Changer"); - SetForegroundWindow(hwnd); TrackPopupMenu(hMenu, TPM_BOTTOMALIGN | TPM_LEFTALIGN, pt.x, pt.y, 0, hwnd, NULL); + bool active = (g_ActiveIndex >= 0); + InsertMenuW(hMenu, 0, MF_BYPOSITION | MF_STRING | MF_GRAYED, 0, L"AutoRes Changer"); + InsertMenuW(hMenu, 1, MF_BYPOSITION | MF_SEPARATOR, 0, NULL); + InsertMenuW(hMenu, 2, MF_BYPOSITION | MF_STRING, 201, L"Open window"); + InsertMenuW(hMenu, 3, MF_BYPOSITION | MF_STRING | (active ? MF_ENABLED : MF_GRAYED), + 203, L"Restore resolution now"); + InsertMenuW(hMenu, 4, MF_BYPOSITION | MF_STRING, 202, L"Open config folder"); + InsertMenuW(hMenu, 5, MF_BYPOSITION | MF_SEPARATOR, 0, NULL); + InsertMenuW(hMenu, 6, MF_BYPOSITION | MF_STRING, 200, L"Exit AutoRes Changer"); + SetForegroundWindow(hwnd); + TrackPopupMenu(hMenu, TPM_BOTTOMALIGN | TPM_LEFTALIGN, pt.x, pt.y, 0, hwnd, NULL); DestroyMenu(hMenu); } break; @@ -693,6 +1032,10 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { Shell_NotifyIconW(NIM_DELETE, &g_Nid); RestoreActiveIfChanged(); if (g_hAppIcon) { DestroyIcon(g_hAppIcon); g_hAppIcon = NULL; } + for (HICON ic : g_ListItemIcons) if (ic) DestroyIcon(ic); + g_ListItemIcons.clear(); + if (g_hDarkBrush) { DeleteObject(g_hDarkBrush); g_hDarkBrush = NULL; } + if (g_hEditBrush) { DeleteObject(g_hEditBrush); g_hEditBrush = NULL; } if (g_hInstanceMutex) { CloseHandle(g_hInstanceMutex); g_hInstanceMutex = NULL; } PostQuitMessage(0); break; @@ -701,12 +1044,39 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { return DefWindowProc(hwnd, msg, wParam, lParam); } +// ========================================== +// DPI AWARENESS +// ========================================== +// Prefer the modern per-monitor-v2 context (Win10 1703+) and gracefully fall +// back for older Windows builds. This keeps the UI crisp on mixed-DPI setups. +void EnableBestDpiAwareness() { + typedef BOOL(WINAPI* SetCtxFn)(HANDLE); + HMODULE user32 = GetModuleHandleW(L"user32.dll"); + if (user32) { + SetCtxFn setCtx = reinterpret_cast( + reinterpret_cast(GetProcAddress(user32, "SetProcessDpiAwarenessContext"))); + if (setCtx) { + // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 == (HANDLE)-4 + if (setCtx((HANDLE)-4)) return; + } + // Fallback: framework/legacy shcore API. + typedef HRESULT(WINAPI* SetShcoreFn)(int); + HMODULE shcore = LoadLibraryW(L"shcore.dll"); + if (shcore) { + SetShcoreFn setShcore = reinterpret_cast( + reinterpret_cast(GetProcAddress(shcore, "SetProcessDpiAwareness"))); + if (setShcore && SUCCEEDED(setShcore(2 /* PROCESS_PER_MONITOR_DPI_AWARE */))) return; + } + } + SetProcessDPIAware(); +} + // ========================================== // APPLICATION ENTRY // ========================================== int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLine, int nCmdShow) { (void)hPrevInstance; - SetProcessDPIAware(); + EnableBestDpiAwareness(); g_hInst = hInstance; // Store instance for resource retrieval // Single-instance guard: a second launch should not create a duplicate tray @@ -720,6 +1090,7 @@ int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLi InitConfigPath(); LoadConfig(); + LoadSettings(); PublishSnapshot(); WNDCLASSW wc = {0}; @@ -734,9 +1105,10 @@ int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLi RegisterClassW(&wc); g_hMain = CreateWindowW(L"AutoResChangerClass", L"AutoRes Changer", WS_OVERLAPPEDWINDOW, - CW_USEDEFAULT, CW_USEDEFAULT, 750, 480, NULL, NULL, hInstance, NULL); + CW_USEDEFAULT, CW_USEDEFAULT, 780, 560, NULL, NULL, hInstance, NULL); - PopulateMonitors(); RefreshList(); + ApplyTheme(g_hMain); + PopulateMonitors(); RefreshList(); RefreshSupportedModes(); std::thread monitorThread(MonitorLoop); // Parse command line case-insensitively From f7d30cbfceef557de78e66e85c53d208292c936a Mon Sep 17 00:00:00 2001 From: kuy124 Date: Wed, 23 Sep 2026 03:49:47 +0700 Subject: [PATCH 3/3] test: add unit + GUI test suites; replace modal dialogs with inline status UX (non-blocking feedback): - Replace all MessageBox validation pop-ups (Save/Test/drag-drop) with an inline status line that colour-codes info/warn/error/ok and auto-clears. This keeps the UI responsive and makes the flows fully automatable. - Rework Test into a two-phase flow: apply the mode and arm a 15s countdown; the button becomes 'Keep This Mode' to confirm, otherwise it auto-reverts. Fixes the previous behaviour where the blocking OK box cancelled the timer immediately, so it never actually auto-reverted. - Assign control IDs to the exe/Hz/Delay/restore/enable fields for testability. Testing: - tests/test_main.cpp: headless unit tests (36 checks) compiled with -DTESTING, which excludes the GUI. Covers GetFileName, ToLowerCopy, config save/load round-trip, atomic-write cleanliness, overwrite semantics, EnumModesForDevice, GetCurrentRes, process enumeration, and missing-config. - tests/gui_save_test.ps1: drives the real window via Win32 messages to verify the Save/Delete/validation end-to-end flow and config output. Uses WM_SETTEXT (marshaled cross-process) so inputs reach the owning process correctly. - tests/run_tests.ps1: one-shot builder/runner for both suites. - CMakeLists.txt gains a ctest target; CI workflow builds and runs all tests. main.cpp is unchanged in behaviour except the dialog/status refactor; the GUI is now guarded by #ifndef TESTING so the same source backs the tests. --- .github/workflows/build.yml | 13 +++ .gitignore | 4 + CMakeLists.txt | 14 +++ README.md | 26 ++++- main.cpp | 115 ++++++++++++++++---- tests/gui_save_test.ps1 | 149 +++++++++++++++++++++++++ tests/run_tests.ps1 | 52 +++++++++ tests/test_main.cpp | 211 ++++++++++++++++++++++++++++++++++++ 8 files changed, 560 insertions(+), 24 deletions(-) create mode 100644 tests/gui_save_test.ps1 create mode 100644 tests/run_tests.ps1 create mode 100644 tests/test_main.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 418d022..9c8ecba 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,6 +37,19 @@ jobs: -luser32 -lgdi32 -lshell32 -lcomdlg32 -ladvapi32 \ -ldwmapi -luxtheme -lole32 -lcomctl32 + - name: Build and run unit tests + run: | + g++ -O2 -std=c++17 -Wall -Wextra \ + -Wno-unknown-pragmas -Wno-missing-field-initializers \ + tests/test_main.cpp -o tests/test_runner.exe \ + -luser32 -lgdi32 -lshell32 -lcomdlg32 -ladvapi32 \ + -ldwmapi -luxtheme -lole32 -lcomctl32 + ./tests/test_runner.exe + + - name: Run GUI end-to-end tests + shell: pwsh + run: ./tests/gui_save_test.ps1 + - name: Upload build artifact uses: actions/upload-artifact@v4 with: diff --git a/.gitignore b/.gitignore index 54e8874..668e915 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,9 @@ build/ out/ +# Test artifacts +tests/test_runner.exe +tests/*.dSYM/ + # MSVC / MinGW *.dSYM/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 4a2f469..0b410bd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,3 +36,17 @@ endif() # Emit the binary next to the source tree for convenience. set_target_properties(AutoResChanger PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") + +# ---- Tests -------------------------------------------------------------- +# Headless unit tests (compiled with -DTESTING, which excludes the GUI). +enable_testing() +add_executable(autores_tests tests/test_main.cpp) +target_compile_definitions(autores_tests PRIVATE UNICODE _UNICODE) +if(MSVC) + target_compile_options(autores_tests PRIVATE /W4 /permissive-) + target_link_libraries(autores_tests PRIVATE user32 gdi32 shell32 comdlg32 advapi32 dwmapi ole32 comctl32) +else() + target_compile_options(autores_tests PRIVATE -Wall -Wextra -Wno-unknown-pragmas -Wno-missing-field-initializers) + target_link_libraries(autores_tests PRIVATE user32 gdi32 shell32 comdlg32 advapi32 dwmapi uxtheme ole32 comctl32) +endif() +add_test(NAME unit COMMAND autores_tests) diff --git a/README.md b/README.md index d9905b5..c696fe7 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Pick whichever build method you prefer. ```bash cmake -S . -B build -G "MinGW Makefiles" cmake --build build +ctest --test-dir build --output-on-failure # run unit tests ``` **Manual (MinGW-w64).** Run the resource compiler, then the C++ compiler. Note that `-municode` and linking `resource.res` are required so the Unicode resource APIs resolve correctly: @@ -52,7 +53,22 @@ windres resource.rc -O coff -o resource.res g++ -O2 -Wall -Wextra main.cpp resource.res -o AutoResChanger.exe -mwindows -municode -luser32 -lgdi32 -lshell32 -lcomdlg32 -ladvapi32 -ldwmapi -luxtheme -lole32 -lcomctl32 ``` -A GitHub Actions workflow (`.github/workflows/build.yml`) automatically builds the executable on every push and attaches it to a release whenever you push a `v*` tag. +A GitHub Actions workflow (`.github/workflows/build.yml`) automatically builds the executable and runs both the unit and GUI tests on every push, then attaches the binary to a release whenever you push a `v*` tag. + +--- + +## Testing + +AutoRes Changer ships with two layers of automated tests: + +* **Headless unit tests** (`tests/test_main.cpp`) cover the pure logic: path parsing, config save/load round-trips, atomic-write cleanliness, mode enumeration, and process detection. They compile with `-DTESTING`, which excludes the GUI. Run them via `ctest` (see above) or: + ```powershell + .\tests\run_tests.ps1 + ``` +* **GUI end-to-end tests** (`tests/gui_save_test.ps1`) drive the real window through Win32 messages to prove the full Save/Delete/validation flow writes the correct `config.ini` with no leftover temp files: + ```powershell + .\tests\run_tests.ps1 -Gui + ```
@@ -72,9 +88,11 @@ Managing your custom application profiles is straightforward and handled entirel ### Testing Modes Safely If you want to verify whether a custom resolution or refresh rate is supported by your monitor before saving it: 1. Input your target dimensions or select an existing profile. -2. Click the **Test Display Settings** button. -3. Your screen will temporarily transition to the selected mode. -4. A prompt will appear on your screen. Clicking **OK** keeps the mode; if you do nothing, the mode automatically reverts to your original desktop settings after 15 seconds. This guarantees you can always recover even if the tested mode renders your display unreadable. +2. Click the **Test Display Settings** button. The button becomes **Keep This Mode** and a countdown starts. +3. Your screen temporarily transitions to the selected mode. +4. Click **Keep This Mode** to confirm, or simply do nothing — after 15 seconds the utility automatically reverts to your original desktop settings. This guarantees you can always recover even if the tested mode renders your display unreadable. + +> Validation feedback (missing path, invalid resolution, unsupported mode) is shown in a non-blocking status line beneath the buttons rather than a pop-up dialog, so the interface never traps you. --- diff --git a/main.cpp b/main.cpp index c28a6de..7169ea7 100644 --- a/main.cpp +++ b/main.cpp @@ -95,17 +95,22 @@ bool g_DarkMode = false; // UI theme preference bool g_LoggingEnabled = false; // Persisted logging preference // UI Handles +#ifndef TESTING HWND g_hMain, g_hList, g_hExe, g_hDisplayCombo, g_hW, g_hH, g_hHz, g_hDelay; HWND g_hRestore, g_hEnable, g_hStartup, g_hPresetCombo, g_hSupportedCombo; HWND g_hListLabel, g_hExeLabel, g_hBtnBrowse, g_hDisplayLabel, g_hPresetLabel; HWND g_hWLabel, g_hHLabel, g_hHzLabel, g_hDelayLabel, g_hSaveBtn, g_hDelBtn, g_hLaunchBtn, g_hTestBtn; -HWND g_hSupportedLabel, g_hLogChk, g_hDarkChk; +HWND g_hSupportedLabel, g_hLogChk, g_hDarkChk, g_hStatusLabel; +#endif #define WM_TRAYICON (WM_APP + 1) +#ifndef TESTING NOTIFYICONDATAW g_Nid = {}; +#endif std::vector g_MonitorDevices; std::wstring g_LogPath; std::vector g_SupportedModesCache; +COLORREF g_StatusColor = RGB(40, 40, 40); // current inline-status text colour // ========================================== // LOGGING @@ -294,6 +299,7 @@ void SaveConfig() { // ========================================== // DISPLAY MANAGEMENT // ========================================== +#ifndef TESTING void PopulateMonitors() { g_MonitorDevices.clear(); DISPLAY_DEVICEW dd = { sizeof(dd) }; @@ -311,6 +317,7 @@ void PopulateMonitors() { devNum++; } } +#endif DEVMODEW GetCurrentRes(const std::wstring& devName) { DEVMODEW dm = { 0 }; dm.dmSize = sizeof(dm); @@ -342,6 +349,7 @@ std::vector EnumModesForDevice(const std::wstring& devName) { } // Rebuilds the Supported Modes dropdown for the currently selected display. +#ifndef TESTING void RefreshSupportedModes() { if (!g_hSupportedCombo) return; SendMessage(g_hSupportedCombo, CB_RESETCONTENT, 0, 0); @@ -359,6 +367,7 @@ void RefreshSupportedModes() { g_SupportedModesCache = modes; // index 0 == placeholder, so cache index = combo index-1 SendMessage(g_hSupportedCombo, CB_SETCURSEL, 0, 0); } +#endif // Extracts the icon for a given executable and returns a small HICON (caller // owns it) or NULL on failure. @@ -492,6 +501,7 @@ void MonitorLoop() { // ========================================== // UI MANAGEMENT // ========================================== +#ifndef TESTING // Icons are owned per rendered list row and re-created on refresh. std::vector g_ListItemIcons; @@ -582,6 +592,7 @@ void ApplyTheme(HWND hwnd) { // previous mode. This is the safety net that the README always promised but // the original blocking MessageBox never delivered. #define TIMER_TEST_REVERT 1 +#define TIMER_CLEAR_STATUS 2 #define TEST_REVERT_SECONDS 15 DEVMODEW g_TestOriginal; @@ -595,6 +606,32 @@ void CancelTestTimer(HWND hwnd) { } } +// Colour codes for the inline status line. +enum class StatusKind { Info, Warn, Error, Ok }; + +// Sets the inline status text (replacing the old modal message boxes). The +// message stays visible for a few seconds, then auto-clears. This keeps the UI +// responsive and fully automatable. +void SetStatus(HWND hwnd, const std::wstring& text, StatusKind kind) { + if (!g_hStatusLabel) return; + SetWindowTextW(g_hStatusLabel, text.c_str()); + COLORREF color = RGB(0, 120, 0); + switch (kind) { + case StatusKind::Warn: color = RGB(180, 100, 0); break; + case StatusKind::Error: color = RGB(190, 30, 30); break; + case StatusKind::Ok: color = RGB(0, 130, 0); break; + case StatusKind::Info: default: color = g_DarkMode ? RGB(210, 210, 210) : RGB(40, 40, 40); break; + } + // A static's text colour follows the WM_CTLCOLORSTATIC of its parent, so we + // store the desired colour and let the parent render it. + g_StatusColor = color; + InvalidateRect(g_hStatusLabel, NULL, TRUE); + + // Restart the auto-clear timer. + KillTimer(hwnd, TIMER_CLEAR_STATUS); + SetTimer(hwnd, TIMER_CLEAR_STATUS, 6000, NULL); +} + // Returns true and writes the value when the field holds a positive integer. bool ReadPositiveInt(HWND hEdit, int minVal, int maxVal, int& out) { WCHAR buf[16] = { 0 }; @@ -615,7 +652,7 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { // Right Panel g_hExeLabel = CreateWindowW(L"STATIC", L"Executable Path:", WS_VISIBLE | WS_CHILD, 0, 0, 0, 0, hwnd, NULL, NULL, NULL); - g_hExe = CreateWindowW(L"EDIT", L"", WS_VISIBLE | WS_CHILD | WS_BORDER | ES_AUTOHSCROLL, 0, 0, 0, 0, hwnd, NULL, NULL, NULL); + g_hExe = CreateWindowW(L"EDIT", L"", WS_VISIBLE | WS_CHILD | WS_BORDER | ES_AUTOHSCROLL, 0, 0, 0, 0, hwnd, (HMENU)115, NULL, NULL); g_hBtnBrowse = CreateWindowW(L"BUTTON", L"...", WS_VISIBLE | WS_CHILD, 0, 0, 0, 0, hwnd, (HMENU)101, NULL, NULL); g_hDisplayLabel = CreateWindowW(L"STATIC", L"Target Display (Monitor):", WS_VISIBLE | WS_CHILD, 0, 0, 0, 0, hwnd, NULL, NULL, NULL); @@ -634,13 +671,13 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { g_hH = CreateWindowW(L"EDIT", L"1080", WS_VISIBLE | WS_CHILD | WS_BORDER | ES_NUMBER, 0, 0, 0, 0, hwnd, (HMENU)109, NULL, NULL); g_hHzLabel = CreateWindowW(L"STATIC", L"Hz (0=Def):", WS_VISIBLE | WS_CHILD, 0, 0, 0, 0, hwnd, NULL, NULL, NULL); - g_hHz = CreateWindowW(L"EDIT", L"0", WS_VISIBLE | WS_CHILD | WS_BORDER | ES_NUMBER, 0, 0, 0, 0, hwnd, NULL, NULL, NULL); + g_hHz = CreateWindowW(L"EDIT", L"0", WS_VISIBLE | WS_CHILD | WS_BORDER | ES_NUMBER, 0, 0, 0, 0, hwnd, (HMENU)116, NULL, NULL); g_hDelayLabel = CreateWindowW(L"STATIC", L"Delay (sec):", WS_VISIBLE | WS_CHILD, 0, 0, 0, 0, hwnd, NULL, NULL, NULL); - g_hDelay = CreateWindowW(L"EDIT", L"0", WS_VISIBLE | WS_CHILD | WS_BORDER | ES_NUMBER, 0, 0, 0, 0, hwnd, NULL, NULL, NULL); + g_hDelay = CreateWindowW(L"EDIT", L"0", WS_VISIBLE | WS_CHILD | WS_BORDER | ES_NUMBER, 0, 0, 0, 0, hwnd, (HMENU)117, NULL, NULL); - g_hRestore = CreateWindowW(L"BUTTON", L"Restore resolution on exit", WS_VISIBLE | WS_CHILD | BS_AUTOCHECKBOX, 0, 0, 0, 0, hwnd, NULL, NULL, NULL); - g_hEnable = CreateWindowW(L"BUTTON", L"Enable this profile", WS_VISIBLE | WS_CHILD | BS_AUTOCHECKBOX, 0, 0, 0, 0, hwnd, NULL, NULL, NULL); + g_hRestore = CreateWindowW(L"BUTTON", L"Restore resolution on exit", WS_VISIBLE | WS_CHILD | BS_AUTOCHECKBOX, 0, 0, 0, 0, hwnd, (HMENU)118, NULL, NULL); + g_hEnable = CreateWindowW(L"BUTTON", L"Enable this profile", WS_VISIBLE | WS_CHILD | BS_AUTOCHECKBOX, 0, 0, 0, 0, hwnd, (HMENU)119, NULL, NULL); SendMessage(g_hRestore, BM_SETCHECK, BST_CHECKED, 0); SendMessage(g_hEnable, BM_SETCHECK, BST_CHECKED, 0); @@ -649,6 +686,10 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { g_hLaunchBtn = CreateWindowW(L"BUTTON", L"Launch App", WS_VISIBLE | WS_CHILD, 0, 0, 0, 0, hwnd, (HMENU)105, NULL, NULL); g_hTestBtn = CreateWindowW(L"BUTTON", L"Test Display Settings", WS_VISIBLE | WS_CHILD, 0, 0, 0, 0, hwnd, (HMENU)104, NULL, NULL); + // Inline, non-modal status line. Replaces blocking message boxes so + // the UI stays responsive and remains scriptable/automatable. + g_hStatusLabel = CreateWindowW(L"STATIC", L"", WS_VISIBLE | WS_CHILD | SS_LEFTNOWORDWRAP, 0, 0, 0, 0, hwnd, (HMENU)114, NULL, NULL); + g_hStartup = CreateWindowW(L"BUTTON", L"Start automatically with Windows (Minimized)", WS_VISIBLE | WS_CHILD | BS_AUTOCHECKBOX, 0, 0, 0, 0, hwnd, (HMENU)106, NULL, NULL); if (GetRunAtStartup()) SendMessage(g_hStartup, BM_SETCHECK, BST_CHECKED, 0); @@ -737,6 +778,7 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { MoveWindow(g_hLaunchBtn, rightX + btnW * 2, 292, btnW - 5, 30, TRUE); MoveWindow(g_hTestBtn, rightX, 326, rightW, 30, TRUE); + MoveWindow(g_hStatusLabel, rightX, 360, rightW, 18, TRUE); // Bottom row of options. int optW = rightW / 2; @@ -753,8 +795,15 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { } case WM_CTLCOLORSTATIC: case WM_CTLCOLORBTN: { + HDC hdc = (HDC)wParam; + HWND ctl = (HWND)lParam; + // The inline status label uses its own colour regardless of theme. + if (ctl == g_hStatusLabel) { + SetTextColor(hdc, g_StatusColor); + SetBkColor(hdc, g_DarkMode ? RGB(32, 32, 32) : GetSysColor(COLOR_WINDOW)); + return (LRESULT)(g_DarkMode && g_hDarkBrush ? g_hDarkBrush : GetSysColorBrush(COLOR_WINDOW)); + } if (g_DarkMode && g_hDarkBrush) { - HDC hdc = (HDC)wParam; SetTextColor(hdc, RGB(230, 230, 230)); SetBkColor(hdc, RGB(32, 32, 32)); return (LRESULT)g_hDarkBrush; @@ -812,12 +861,12 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { WCHAR exe[MAX_PATH]; GetWindowTextW(g_hExe, exe, MAX_PATH); if (wcslen(exe) == 0) { - MessageBoxW(hwnd, L"Select an executable path first.", L"Missing Path", MB_OK | MB_ICONWARNING); + SetStatus(hwnd, L"⚠ Select an executable path first.", StatusKind::Warn); break; } int w = 0, h = 0; if (!ReadPositiveInt(g_hW, 1, 32767, w) || !ReadPositiveInt(g_hH, 1, 32767, h)) { - MessageBoxW(hwnd, L"Width and Height must be positive numbers (1-32767).", L"Invalid Resolution", MB_OK | MB_ICONWARNING); + SetStatus(hwnd, L"⚠ Width and Height must be 1-32767.", StatusKind::Warn); break; } WCHAR hzbuf[16] = { 0 }, delbuf[16] = { 0 }; @@ -850,13 +899,30 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { SaveConfig(); PublishSnapshot(); RefreshList(targetIdx); + SetStatus(hwnd, L"✔ Saved profile: " + p.exeName, StatusKind::Ok); } else if (wmId == 103) { // Delete int idx = SendMessage(g_hList, LB_GETCURSEL, 0, 0); - if (idx != LB_ERR) { g_Profiles.erase(g_Profiles.begin() + idx); SaveConfig(); PublishSnapshot(); RefreshList(); SetWindowTextW(g_hExe, L""); } - } else if (wmId == 104) { // Test + if (idx != LB_ERR) { + g_Profiles.erase(g_Profiles.begin() + idx); SaveConfig(); PublishSnapshot(); + RefreshList(); SetWindowTextW(g_hExe, L""); + SetStatus(hwnd, L"✔ Profile deleted.", StatusKind::Ok); + } else { + SetStatus(hwnd, L"⚠ Select a profile to delete.", StatusKind::Warn); + } + } else if (wmId == 104) { // Test / Confirm + // Two-phase, non-blocking test. If a test is already running, + // this click means "keep it". Otherwise we apply the mode and + // arm the auto-revert countdown. + if (g_TestActive) { + CancelTestTimer(hwnd); + SetWindowTextW(g_hTestBtn, L"Test Display Settings"); + SetStatus(hwnd, L"✔ Kept the tested display mode.", StatusKind::Ok); + break; + } + int w = 0, h = 0, hz = 0; if (!ReadPositiveInt(g_hW, 1, 32767, w) || !ReadPositiveInt(g_hH, 1, 32767, h)) { - MessageBoxW(hwnd, L"Enter a valid Width and Height before testing.", L"Invalid Input", MB_OK | MB_ICONWARNING); + SetStatus(hwnd, L"⚠ Enter a valid Width and Height before testing.", StatusKind::Warn); break; } // Hz is optional: blank or 0 means "use the driver default". @@ -874,14 +940,16 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { g_TestOriginal = orig; g_TestDevice = dev; g_TestActive = true; + SetWindowTextW(g_hTestBtn, L"Keep This Mode"); SetTimer(hwnd, TIMER_TEST_REVERT, (UINT)TEST_REVERT_SECONDS * 1000, NULL); - std::wstring msg = L"Resolution applied. Click OK to keep it, or it will " - L"automatically revert in " + std::to_wstring(TEST_REVERT_SECONDS) + L" seconds."; - MessageBoxW(hwnd, msg.c_str(), L"Testing Display Settings", MB_OK | MB_ICONINFORMATION); - // If the user pressed OK within the window, keep the mode. - if (g_TestActive) CancelTestTimer(hwnd); + std::wstring msg = L"✔ Mode applied — auto-reverts in " + + std::to_wstring(TEST_REVERT_SECONDS) + + L"s. Click \"Keep This Mode\" to keep it."; + SetStatus(hwnd, msg, StatusKind::Info); + MessageBeep(MB_ICONINFORMATION); } else { - MessageBoxW(hwnd, L"Monitor does not support this mode.", L"Error", MB_OK | MB_ICONERROR); + SetStatus(hwnd, L"✖ Monitor does not support this mode.", StatusKind::Error); + MessageBeep(MB_ICONERROR); } } else if (wmId == 105) { // Launch App WCHAR exe[MAX_PATH]; GetWindowTextW(g_hExe, exe, MAX_PATH); @@ -925,6 +993,11 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { RestoreRes(g_TestDevice, g_TestOriginal); g_TestActive = false; KillTimer(hwnd, TIMER_TEST_REVERT); + SetWindowTextW(g_hTestBtn, L"Test Display Settings"); + SetStatus(hwnd, L"↩ Reverted to previous display mode (test not confirmed).", StatusKind::Info); + } else if (wParam == TIMER_CLEAR_STATUS) { + KillTimer(hwnd, TIMER_CLEAR_STATUS); + SetWindowTextW(g_hStatusLabel, L""); } break; } @@ -982,9 +1055,10 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { std::wstring lower = ToLowerCopy(path); if (lower.size() >= 4 && lower.substr(lower.size() - 4) == L".exe") { SetWindowTextW(g_hExe, path.c_str()); + SetStatus(hwnd, L"✔ Loaded executable: " + GetFileName(path), StatusKind::Ok); LogMsg(L"Dropped executable: " + path); } else { - MessageBoxW(hwnd, L"Please drop an .exe file.", L"Unsupported File", MB_OK | MB_ICONINFORMATION); + SetStatus(hwnd, L"⚠ Please drop an .exe file.", StatusKind::Warn); } } DragFinish(hDrop); @@ -1142,4 +1216,5 @@ int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLi if (monitorThread.joinable()) monitorThread.join(); if (g_hInstanceMutex) { CloseHandle(g_hInstanceMutex); g_hInstanceMutex = NULL; } return (int)msg.wParam; -} \ No newline at end of file +} +#endif // !TESTING \ No newline at end of file diff --git a/tests/gui_save_test.ps1 b/tests/gui_save_test.ps1 new file mode 100644 index 0000000..84d01b0 --- /dev/null +++ b/tests/gui_save_test.ps1 @@ -0,0 +1,149 @@ +# gui_save_test.ps1 - End-to-end GUI test for AutoRes Changer. +# +# Drives the real window via Win32 messages (no interactive input) to verify the +# full Save path: set the executable path, click "Save Profile", and assert the +# resolved config.ini is written atomically with the expected profile. +# +# Requires the app to be built at .\AutoResChanger.exe (repo root). +# Exits 0 on success, 1 on any failure. Designed to run in CI. + +$ErrorActionPreference = "Stop" +Set-Location -LiteralPath (Join-Path $PSScriptRoot "..") + +$exePath = Join-Path (Get-Location) "AutoResChanger.exe" +if (-not (Test-Path $exePath)) { Write-Error "AutoResChanger.exe not found. Build first."; exit 1 } + +$cfgDir = Join-Path $env:APPDATA "AutoResChanger" +$cfg = Join-Path $cfgDir "config.ini" +$tmp = "$cfg.tmp" + +# --- Win32 interop (single type definition reused by all tests) --- +Add-Type -TypeDefinition @" +using System; +using System.Text; +using System.Runtime.InteropServices; +public class Gui { + [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindow(string cls, string win); + [DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr h, int id); + // Overload: SendMessage with a marshaled string argument (used for WM_SETTEXT). + [DllImport("user32.dll", CharSet=CharSet.Unicode, EntryPoint="SendMessageW")] public static extern IntPtr SendMessageStr(IntPtr h, uint msg, IntPtr wp, string lp); + [DllImport("user32.dll", CharSet=CharSet.Unicode, EntryPoint="SendMessageW")] public static extern IntPtr SendMessage(IntPtr h, uint msg, IntPtr wp, IntPtr lp); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr h, StringBuilder sb, int max); + [DllImport("user32.dll")] public static extern bool EnumChildWindows(IntPtr parent, EnumProc cb, IntPtr lp); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr h, StringBuilder sb, int max); + public delegate bool EnumProc(IntPtr h, IntPtr lp); + + public const uint BM_CLICK = 0x00F5; + public const uint LB_GETCOUNT = 0x018B; + public const uint WM_SETTEXT = 0x000C; + + // IMPORTANT: use WM_SETTEXT (marshaled by Windows) rather than SetWindowText. + // SetWindowText across processes only updates the cached caption; the owning + // process reading via WM_GETTEXT would still see the old (empty) buffer. + public static void SetText(IntPtr h, string t) { SendMessageStr(h, WM_SETTEXT, IntPtr.Zero, t); } + + public static IntPtr ListBox = IntPtr.Zero; + public static bool GrabList(IntPtr h, IntPtr lp) { + var sb = new StringBuilder(32); GetClassName(h, sb, 32); + if (sb.ToString() == "ListBox") ListBox = h; + return true; + } + public static string Text(IntPtr h) { var sb = new StringBuilder(512); GetWindowText(h, sb, 512); return sb.ToString(); } +} +"@ + +function Get-ListCount([IntPtr]$h) { [int][Gui]::SendMessage($h, [Gui]::LB_GETCOUNT, [IntPtr]::Zero, [IntPtr]::Zero) } + +function Start-App { + Remove-Item $cfg, $tmp -ErrorAction SilentlyContinue + $p = Start-Process -FilePath $exePath -PassThru + Start-Sleep -Seconds 3 + $h = [Gui]::FindWindow("AutoResChangerClass", "AutoRes Changer") + if ($h -eq [IntPtr]::Zero) { throw "Main window not found." } + return @{ Proc = $p; Hwnd = $h } +} + +# Like Start-App but keeps the existing config.ini (used to seed a profile). +function Start-App2 { + Remove-Item $tmp -ErrorAction SilentlyContinue + $p = Start-Process -FilePath $exePath -PassThru + Start-Sleep -Seconds 3 + $h = [Gui]::FindWindow("AutoResChangerClass", "AutoRes Changer") + if ($h -eq [IntPtr]::Zero) { throw "Main window not found." } + return @{ Proc = $p; Hwnd = $h } +} + +function Stop-App($app) { if ($app -and -not $app.Proc.HasExited) { $app.Proc | Stop-Process -Force }; Start-Sleep -Milliseconds 600 } + +$failures = 0 +function Assert($cond, $name) { + if ($cond) { Write-Host " PASS: $name" -ForegroundColor Green } + else { Write-Host " FAIL: $name" -ForegroundColor Red; $script:failures++ } +} + +Write-Host "== Test 1: Save with empty exe path shows inline warning, writes nothing ==" -ForegroundColor Cyan +$app = Start-App +try { + [Gui]::EnumChildWindows($app.Hwnd, [Gui+EnumProc]{ param($h,$l) [Gui]::GrabList($h,$l) }, [IntPtr]::Zero) | Out-Null + $save = [Gui]::GetDlgItem($app.Hwnd, 102) + [Gui]::SendMessage($save, [Gui]::BM_CLICK, [IntPtr]::Zero, [IntPtr]::Zero) | Out-Null + Start-Sleep -Milliseconds 700 + $status = [Gui]::Text([Gui]::GetDlgItem($app.Hwnd, 114)) + Assert ($status -match "executable path") "inline warning shown ('$status')" + Assert (-not (Test-Path $cfg)) "no config written on invalid input" +} finally { Stop-App $app } + +Write-Host "== Test 2: Valid Save writes config and adds a list row ==" -ForegroundColor Cyan +$app = Start-App +try { + [Gui]::EnumChildWindows($app.Hwnd, [Gui+EnumProc]{ param($h,$l) [Gui]::GrabList($h,$l) }, [IntPtr]::Zero) | Out-Null + $before = Get-ListCount([Gui]::ListBox) + [Gui]::SetText([Gui]::GetDlgItem($app.Hwnd, 115), "C:\Windows\System32\notepad.exe") + [Gui]::SetText([Gui]::GetDlgItem($app.Hwnd, 108), "1280") + [Gui]::SetText([Gui]::GetDlgItem($app.Hwnd, 109), "720") + $save = [Gui]::GetDlgItem($app.Hwnd, 102) + [Gui]::SendMessage($save, [Gui]::BM_CLICK, [IntPtr]::Zero, [IntPtr]::Zero) | Out-Null + Start-Sleep -Milliseconds 900 + $after = Get-ListCount([Gui]::ListBox) + Assert ((Test-Path $cfg)) "config.ini created" + Assert (-not (Test-Path $tmp)) "no leftover .tmp file" + $content = if (Test-Path $cfg) { Get-Content $cfg -Raw } else { "" } + Assert ($content -match "notepad") "config contains saved exe" + Assert ($content -match "W=1280") "config contains width 1280" + Assert ($content -match "H=720") "config contains height 720" + Assert ($after -eq ($before + 1)) "list row added ($before -> $after)" +} finally { Stop-App $app } + +Write-Host "== Test 3: Delete removes the profile ==" -ForegroundColor Cyan +# Seed a config with one profile so Delete has something to operate on. +New-Item -ItemType Directory -Force -Path $cfgDir | Out-Null +@" +[Settings] +Count=1 +DarkMode=0 +Logging=0 +[Profile_0] +Exe=C:\Windows\System32\notepad.exe +Device= +W=1280 +H=720 +Hz=0 +Delay=0 +Restore=1 +Enabled=1 +"@ | Set-Content -Encoding ASCII $cfg +$app = Start-App2 # start without wiping our seeded config +try { + [Gui]::EnumChildWindows($app.Hwnd, [Gui+EnumProc]{ param($h,$l) [Gui]::GrabList($h,$l) }, [IntPtr]::Zero) | Out-Null + # Select the first list item, then click Delete. + [Gui]::SendMessage([Gui]::ListBox, 0x0186, [IntPtr]::Zero, [IntPtr]::Zero) | Out-Null # LB_SETCURSEL 0 + $del = [Gui]::GetDlgItem($app.Hwnd, 103) + [Gui]::SendMessage($del, [Gui]::BM_CLICK, [IntPtr]::Zero, [IntPtr]::Zero) | Out-Null + Start-Sleep -Milliseconds 700 + $content = if (Test-Path $cfg) { Get-Content $cfg -Raw } else { "" } + Assert ($content -notmatch "notepad") "profile removed from config" + Assert ((Get-ListCount([Gui]::ListBox)) -eq 0) "list emptied" +} finally { Stop-App $app } + +if ($failures -eq 0) { Write-Host "`nALL GUI TESTS PASSED" -ForegroundColor Green; exit 0 } +else { Write-Host "`n$failures GUI TEST(S) FAILED" -ForegroundColor Red; exit 1 } diff --git a/tests/run_tests.ps1 b/tests/run_tests.ps1 new file mode 100644 index 0000000..2201667 --- /dev/null +++ b/tests/run_tests.ps1 @@ -0,0 +1,52 @@ +# run_tests.ps1 - Build and run the AutoRes Changer unit + GUI tests. +# +# Usage: +# .\tests\run_tests.ps1 # unit tests only +# .\tests\run_tests.ps1 -Gui # unit tests + GUI end-to-end tests +# +# Exit code is non-zero if any test fails. Suitable for CI. + +param([switch]$Gui) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +Set-Location -LiteralPath $root + +# Ensure MinGW toolchain is reachable. +if (-not (Get-Command g++ -ErrorAction SilentlyContinue)) { + foreach ($p in @("C:\msys64\ucrt64\bin", "C:\msys64\mingw64\bin")) { + if (Test-Path (Join-Path $p "g++.exe")) { $env:PATH = "$p;$env:PATH"; break } + } +} +if (-not (Get-Command g++ -ErrorAction SilentlyContinue)) { throw "g++ not found." } + +$libs = @("-luser32","-lgdi32","-lshell32","-lcomdlg32","-ladvapi32","-ldwmapi","-luxtheme","-lole32","-lcomctl32") + +Write-Host "== Building unit test runner ==" -ForegroundColor Cyan +& g++ -O2 -std=c++17 -Wall -Wextra -Wno-unknown-pragmas -Wno-missing-field-initializers ` + tests/test_main.cpp -o tests/test_runner.exe @libs +if ($LASTEXITCODE -ne 0) { throw "Unit test build failed." } + +Write-Host "== Running unit tests ==" -ForegroundColor Cyan +& .\tests\test_runner.exe +$unitExit = $LASTEXITCODE + +$guiExit = 0 +if ($Gui) { + # The GUI test needs the built app. + if (-not (Test-Path ".\AutoResChanger.exe")) { + Write-Host "== Building AutoResChanger.exe for GUI test ==" -ForegroundColor Cyan + & windres resource.rc -O coff -o resource.res + & g++ -O2 -s main.cpp resource.res -o AutoResChanger.exe -mwindows -municode @libs + } + Write-Host "== Running GUI end-to-end tests ==" -ForegroundColor Cyan + & powershell -ExecutionPolicy Bypass -File .\tests\gui_save_test.ps1 + $guiExit = $LASTEXITCODE +} + +if ($unitExit -ne 0 -or $guiExit -ne 0) { + Write-Host "TESTS FAILED (unit=$unitExit gui=$guiExit)" -ForegroundColor Red + exit 1 +} +Write-Host "ALL TESTS PASSED" -ForegroundColor Green +exit 0 diff --git a/tests/test_main.cpp b/tests/test_main.cpp new file mode 100644 index 0000000..3f55248 --- /dev/null +++ b/tests/test_main.cpp @@ -0,0 +1,211 @@ +// test_main.cpp - Headless unit tests for AutoRes Changer's core logic. +// +// This file pulls in main.cpp so the tests exercise the *real* config/utility +// code, not a copy. Run via tests/run_tests.ps1 or CMake/CI. +// +// Zero external dependencies: a tiny assertion framework + a temp config dir. + +#ifndef TESTING +#define TESTING +#endif +#include "../main.cpp" + +#include +#include + +// ---- tiny test framework ------------------------------------------------- +static int g_passed = 0; +static int g_failed = 0; +static const wchar_t* g_current = L""; + +#define CHECK(cond) do { \ + if (cond) { ++g_passed; } \ + else { ++g_failed; \ + std::printf(" FAIL [%ls] %s:%d %s\n", g_current, __FILE__, __LINE__, #cond); } \ +} while (0) + +#define CHECK_EQ(a, b) do { \ + auto _va = (a); auto _vb = (b); \ + if (_va == _vb) { ++g_passed; } \ + else { ++g_failed; \ + std::printf(" FAIL [%ls] %s:%d expected %s == %s\n", g_current, __FILE__, __LINE__, #a, #b); } \ +} while (0) + +#define TEST(name) g_current = name; std::printf("[TEST] %ls\n", name) + +// ---- helpers ------------------------------------------------------------- +static std::wstring TempDir() { + WCHAR buf[MAX_PATH] = { 0 }; + GetTempPathW(MAX_PATH, buf); + std::wstring dir = std::wstring(buf) + L"AutoResChangerTest"; + CreateDirectoryW(dir.c_str(), NULL); + return dir; +} + +static bool FileExists(const std::wstring& p) { + DWORD a = GetFileAttributesW(p.c_str()); + return a != INVALID_FILE_ATTRIBUTES; +} + +// ---- tests --------------------------------------------------------------- + +static void Test_GetFileName() { + TEST(L"GetFileName"); + CHECK_EQ(GetFileName(L"C:\\Games\\Kendo.exe"), std::wstring(L"Kendo.exe")); + CHECK_EQ(GetFileName(L"C:/Games/Kendo.exe"), std::wstring(L"Kendo.exe")); + CHECK_EQ(GetFileName(L"Kendo.exe"), std::wstring(L"Kendo.exe")); + CHECK_EQ(GetFileName(L""), std::wstring(L"")); + CHECK_EQ(GetFileName(L"C:\\dir\\"), std::wstring(L"")); + CHECK_EQ(GetFileName(L"\\\\server\\share\\app.exe"), std::wstring(L"app.exe")); +} + +static void Test_ToLower() { + TEST(L"ToLowerCopy"); + CHECK_EQ(ToLowerCopy(L"KeNdO.EXE"), std::wstring(L"kendo.exe")); + CHECK_EQ(ToLowerCopy(L""), std::wstring(L"")); +} + +static void Test_ConfigRoundTrip() { + TEST(L"Config save/load round-trip"); + std::wstring dir = TempDir(); + g_IniPath = dir + L"\\test_config.ini"; + DeleteFileW(g_IniPath.c_str()); + DeleteFileW((g_IniPath + L".tmp").c_str()); + + // Prepare profiles. + g_Profiles.clear(); + AppProfile a; + a.exePath = L"C:\\Games\\Alpha.exe"; a.exeName = L"Alpha.exe"; + a.displayDev = L"\\\\.\\DISPLAY1"; + a.targetW = 1920; a.targetH = 1080; a.targetHz = 144; a.delaySec = 3; + a.restore = true; a.enabled = true; + g_Profiles.push_back(a); + + AppProfile b; + b.exePath = L"C:\\Games\\Beta.exe"; b.exeName = L"Beta.exe"; + b.displayDev = L""; + b.targetW = 1280; b.targetH = 720; b.targetHz = 0; b.delaySec = 0; + b.restore = false; b.enabled = false; + g_Profiles.push_back(b); + + g_DarkMode = true; g_LoggingEnabled = true; + SaveConfig(); + + CHECK(FileExists(g_IniPath)); + CHECK(!FileExists(g_IniPath + L".tmp")); // atomic write left no temp + + // Reload into a fresh state. + g_Profiles.clear(); + g_DarkMode = false; g_LoggingEnabled = false; + LoadConfig(); + LoadSettings(); + + CHECK_EQ((int)g_Profiles.size(), 2); + CHECK_EQ(g_Profiles[0].exePath, std::wstring(L"C:\\Games\\Alpha.exe")); + CHECK_EQ(g_Profiles[0].displayDev, std::wstring(L"\\\\.\\DISPLAY1")); + CHECK_EQ(g_Profiles[0].targetW, 1920); + CHECK_EQ(g_Profiles[0].targetH, 1080); + CHECK_EQ(g_Profiles[0].targetHz, 144); + CHECK_EQ(g_Profiles[0].delaySec, 3); + CHECK_EQ(g_Profiles[0].restore, true); + CHECK_EQ(g_Profiles[0].enabled, true); + + CHECK_EQ(g_Profiles[1].exePath, std::wstring(L"C:\\Games\\Beta.exe")); + CHECK_EQ(g_Profiles[1].targetW, 1280); + CHECK_EQ(g_Profiles[1].restore, false); + CHECK_EQ(g_Profiles[1].enabled, false); + + CHECK_EQ(g_DarkMode, true); // settings survived a profile save + CHECK_EQ(g_LoggingEnabled, true); + + DeleteFileW(g_IniPath.c_str()); +} + +static void Test_ConfigOverwrite() { + TEST(L"Config overwrite replaces, not appends"); + std::wstring dir = TempDir(); + g_IniPath = dir + L"\\ovw.ini"; + DeleteFileW(g_IniPath.c_str()); + + g_Profiles.clear(); + AppProfile a; a.exePath = L"C:\\a.exe"; a.exeName = L"a.exe"; + a.targetW = 800; a.targetH = 600; a.targetHz = 0; a.delaySec = 0; a.restore = true; a.enabled = true; + g_Profiles.push_back(a); + SaveConfig(); + + // Now save a different single profile; Count must become 1, not 2. + g_Profiles.clear(); + AppProfile c; c.exePath = L"C:\\c.exe"; c.exeName = L"c.exe"; + c.targetW = 1024; c.targetH = 768; c.targetHz = 60; c.delaySec = 1; c.restore = true; c.enabled = true; + g_Profiles.push_back(c); + SaveConfig(); + + g_Profiles.clear(); + LoadConfig(); + CHECK_EQ((int)g_Profiles.size(), 1); + CHECK_EQ(g_Profiles[0].exePath, std::wstring(L"C:\\c.exe")); + CHECK_EQ(g_Profiles[0].targetHz, 60); + + DeleteFileW(g_IniPath.c_str()); +} + +static void Test_EnumModes() { + TEST(L"EnumModesForDevice returns sane modes"); + std::vector modes = EnumModesForDevice(L""); + CHECK(!modes.empty()); // a real primary display exists + bool sortedDesc = true; + for (size_t i = 1; i < modes.size(); ++i) { + if (modes[i].w > modes[i - 1].w) { sortedDesc = false; break; } + } + CHECK(sortedDesc); // sorted widest-first + bool allPositive = true; + for (const auto& m : modes) if (m.w <= 0 || m.h <= 0) { allPositive = false; break; } + CHECK(allPositive); +} + +static void Test_GetCurrentRes() { + TEST(L"GetCurrentRes returns a valid desktop mode"); + DEVMODEW dm = GetCurrentRes(L""); + CHECK(dm.dmPelsWidth > 0); + CHECK(dm.dmPelsHeight > 0); +} + +static void Test_RunningProcesses() { + TEST(L"GetRunningProcessNames includes this process's exe"); + std::set names = GetRunningProcessNames(); + CHECK(!names.empty()); + // All entries are lower-cased (spot check: no upper-case letters). + bool anyUpper = false; + for (const auto& n : names) { + for (wchar_t c : n) if (c >= L'A' && c <= L'Z') { anyUpper = true; break; } + if (anyUpper) break; + } + CHECK(!anyUpper); +} + +static void Test_MissingConfigDefaults() { + TEST(L"LoadConfig on missing file yields empty list (no crash)"); + std::wstring dir = TempDir(); + g_IniPath = dir + L"\\does_not_exist.ini"; + DeleteFileW(g_IniPath.c_str()); + g_Profiles.clear(); + LoadConfig(); + CHECK_EQ((int)g_Profiles.size(), 0); +} + +int main() { + std::setvbuf(stdout, nullptr, _IONBF, 0); + std::printf("=== AutoRes Changer unit tests ===\n"); + + Test_GetFileName(); + Test_ToLower(); + Test_ConfigRoundTrip(); + Test_ConfigOverwrite(); + Test_EnumModes(); + Test_GetCurrentRes(); + Test_RunningProcesses(); + Test_MissingConfigDefaults(); + + std::printf("\n=== Results: %d passed, %d failed ===\n", g_passed, g_failed); + return g_failed == 0 ? 0 : 1; +}