diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..9c8ecba
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,64 @@
+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: 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:
+ 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/.gitignore b/.gitignore
new file mode 100644
index 0000000..668e915
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,16 @@
+# Build artifacts
+*.exe
+*.res
+*.o
+*.obj
+*.pdb
+*.ilk
+build/
+out/
+
+# Test artifacts
+tests/test_runner.exe
+tests/*.dSYM/
+
+# MSVC / MinGW
+*.dSYM/
diff --git a/CMakeLists.txt b/CMakeLists.txt
new file mode 100644
index 0000000..0b410bd
--- /dev/null
+++ b/CMakeLists.txt
@@ -0,0 +1,52 @@
+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")
+
+# ---- 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 b52384a..c696fe7 100644
--- a/README.md
+++ b/README.md
@@ -31,15 +31,44 @@ 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:
- ```bash
- g++ -O2 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
+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:
+```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 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
+ ```
@@ -49,17 +78,21 @@ 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:
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.
+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.
---
@@ -68,14 +101,21 @@ 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.
---
@@ -85,6 +125,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 8be9623..7169ea7 100644
--- a/main.cpp
+++ b/main.cpp
@@ -6,13 +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
@@ -35,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 },
@@ -51,25 +68,64 @@ 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
+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;
+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, 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
+// ==========================================
+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
@@ -80,10 +136,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 +153,40 @@ 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() {
+ 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) {
@@ -132,9 +224,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());
@@ -161,28 +264,42 @@ 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());
+ 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);
- 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());
}
// ==========================================
// DISPLAY MANAGEMENT
// ==========================================
+#ifndef TESTING
void PopulateMonitors() {
g_MonitorDevices.clear();
DISPLAY_DEVICEW dd = { sizeof(dd) };
@@ -200,6 +317,7 @@ void PopulateMonitors() {
devNum++;
}
}
+#endif
DEVMODEW GetCurrentRes(const std::wstring& devName) {
DEVMODEW dm = { 0 }; dm.dmSize = sizeof(dm);
@@ -207,6 +325,64 @@ 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.
+#ifndef TESTING
+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);
+}
+#endif
+
+// 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;
@@ -219,55 +395,135 @@ 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() {
+ // 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) {
- if (g_ActiveProfile != nullptr) {
- if (!IsProcessRunning(g_ActiveProfile->exeName)) {
- if (g_ActiveProfile->restore && g_ResChanged) {
- RestoreRes(g_ActiveProfile->displayDev, g_OriginalDevMode);
+ DWORD sleepMs = kIdleIntervalMs;
+
+ // 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) {
+ 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) {
+ if (RestoreActiveIfChanged())
+ LogMsg(L"Restored resolution (process exited): " + g_ActiveProfileCopy.exeName);
+ } else {
+ ClearActiveState();
}
- g_ActiveProfile = nullptr; g_ResChanged = false; g_ProcessFoundTime = 0;
}
- } else {
- for (auto& p : g_Profiles) {
- if (p.enabled && IsProcessRunning(p.exeName)) {
+ } 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 && 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)) {
- 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;
+ }
+ 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")");
}
- g_ActiveProfile = &p;
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
// ==========================================
+#ifndef TESTING
+// 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 < 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,20 +559,107 @@ 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
+// ==========================================
+// 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 TIMER_CLEAR_STATUS 2
+#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;
+ }
+}
+
+// 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 };
+ 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: {
// 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);
- 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);
- 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);
@@ -328,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);
@@ -343,24 +686,43 @@ 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);
+ 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);
- // 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;
@@ -385,47 +747,105 @@ 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_hTestBtn, rightX, 310, rightW, 30, TRUE);
- MoveWindow(g_hStartup, rightX, h - 35, rightW, 20, 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_hStatusLabel, rightX, 360, rightW, 18, 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: {
+ 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) {
+ 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 };
@@ -438,48 +858,99 @@ 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) {
+ 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)) {
+ SetStatus(hwnd, L"⚠ Width and Height must be 1-32767.", StatusKind::Warn);
+ 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);
+ 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(); 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);
+ 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)) {
+ 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".
+ {
+ 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;
+ SetWindowTextW(g_hTestBtn, L"Keep This Mode");
+ SetTimer(hwnd, TIMER_TEST_REVERT, (UINT)TEST_REVERT_SECONDS * 1000, NULL);
+ 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 {
+ 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);
if (wcslen(exe) > 0) ShellExecuteW(NULL, L"open", exe, NULL, NULL, SW_SHOWNORMAL);
@@ -487,7 +958,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());
@@ -497,41 +968,204 @@ 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);
}
break;
}
+ case WM_TIMER: {
+ if (wParam == TIMER_TEST_REVERT && g_TestActive) {
+ 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;
+ }
+ 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());
+ SetStatus(hwnd, L"✔ Loaded executable: " + GetFileName(path), StatusKind::Ok);
+ LogMsg(L"Dropped executable: " + path);
+ } else {
+ SetStatus(hwnd, L"⚠ Please drop an .exe file.", StatusKind::Warn);
+ }
+ }
+ 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;
}
- 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; }
+ 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;
+ }
}
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) {
- SetProcessDPIAware();
+ (void)hPrevInstance;
+ EnableBestDpiAwareness();
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();
+ LoadSettings();
+ PublishSnapshot();
WNDCLASSW wc = {0};
wc.lpfnWndProc = WndProc;
@@ -539,13 +1173,16 @@ 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,
- 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
@@ -568,7 +1205,16 @@ 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;
-}
\ No newline at end of file
+ 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;
+}
+#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;
+}