From 75aab99187445210df6e4b5b1940aa5e86b3a749 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:38:34 +0200 Subject: [PATCH 1/7] build: one :core_exe subroutine for both Wind.exe targets; check also compiles WindConfig (#470) The app and uiaccess targets differed only in /DWIND_UIACCESS and the manifest; they now share one compile/link block. check compiles src\config_ui\main.cpp into its own object folder. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- build.bat | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/build.bat b/build.bat index 0d6d3a42..b6bfedbc 100644 --- a/build.bat +++ b/build.bat @@ -29,16 +29,7 @@ if /i "%1"=="installer" goto :installer if /i "%1"=="tray" goto :tray rem --- App build (normal: uiAccess=false, runs from anywhere) ---------------- -rem Compile the app-icon resource (rc.exe ships with the Windows SDK, on PATH via vcvars). -rc /nologo /fo "%ROOT%src\wind.res" "%ROOT%src\wind.rc" -if errorlevel 1 (echo [build] rc.exe failed & exit /b 1) -cl /nologo /std:c++17 /EHsc /O2 /W4 /Zi /DUNICODE /D_UNICODE ^ - src\*.cpp src\wind.res ^ - /Fe:Wind.exe ^ - /link Magnification.lib Dwmapi.lib user32.lib shell32.lib gdi32.lib Dbghelp.lib ^ - d3d11.lib dxgi.lib dxguid.lib d3dcompiler.lib windowscodecs.lib ole32.lib oleaut32.lib uuid.lib advapi32.lib ^ - /MANIFEST:EMBED /MANIFESTUAC:NO /MANIFESTINPUT:Wind.manifest /SUBSYSTEM:WINDOWS ^ - /DEBUG /OPT:REF /OPT:ICF +call :core_exe "" Wind.manifest if errorlevel 1 exit /b 1 call :tray_exe exit /b %errorlevel% @@ -48,17 +39,24 @@ rem Embeds Wind.uiaccess.manifest so the overlay CAN use a high z-band (opt-i rem to cover the Start menu / taskbar / tray. Shipped default is 0 - see issue #162. rem Deploy via tools\uiaccess_setup.ps1. :uiaccess +call :core_exe /DWIND_UIACCESS Wind.uiaccess.manifest +if errorlevel 1 exit /b 1 +call :tray_exe +exit /b %errorlevel% + +rem --- Wind.exe itself, shared by the app and uiaccess targets. %1 = extra define ("" for none), +rem %2 = the manifest to embed. Everything else (sources, flags, libraries) is the same. +:core_exe +rem Compile the app-icon resource (rc.exe ships with the Windows SDK, on PATH via vcvars). rc /nologo /fo "%ROOT%src\wind.res" "%ROOT%src\wind.rc" if errorlevel 1 (echo [build] rc.exe failed & exit /b 1) -cl /nologo /std:c++17 /EHsc /O2 /W4 /Zi /DUNICODE /D_UNICODE /DWIND_UIACCESS ^ +cl /nologo /std:c++17 /EHsc /O2 /W4 /Zi /DUNICODE /D_UNICODE %~1 ^ src\*.cpp src\wind.res ^ /Fe:Wind.exe ^ /link Magnification.lib Dwmapi.lib user32.lib shell32.lib gdi32.lib Dbghelp.lib ^ d3d11.lib dxgi.lib dxguid.lib d3dcompiler.lib windowscodecs.lib ole32.lib oleaut32.lib uuid.lib advapi32.lib ^ - /MANIFEST:EMBED /MANIFESTUAC:NO /MANIFESTINPUT:Wind.uiaccess.manifest /SUBSYSTEM:WINDOWS ^ + /MANIFEST:EMBED /MANIFESTUAC:NO /MANIFESTINPUT:%~2 /SUBSYSTEM:WINDOWS ^ /DEBUG /OPT:REF /OPT:ICF -if errorlevel 1 exit /b 1 -call :tray_exe exit /b %errorlevel% rem --- Tray helper (WindTray.exe, issue #291). ALWAYS the plain manifest (asInvoker, NO uiAccess): @@ -124,6 +122,10 @@ cl /nologo /std:c++17 /EHsc /W4 /DUNICODE /D_UNICODE /c src\*.cpp if errorlevel 1 exit /b 1 rem WindTray.exe sources (issue #291); own object dir, its main.cpp would overwrite Wind's main.obj. cl /nologo /std:c++17 /EHsc /W4 /DUNICODE /D_UNICODE /c /Fo"%ROOT%src\tray_app\\" src\tray_app\*.cpp +if errorlevel 1 exit /b 1 +rem WindConfig.exe's host (same flags as :config); own object dir for the same reason. +cl /nologo /std:c++17 /EHsc /W4 /DUNICODE /D_UNICODE /c /I third_party\webview2\include ^ + /Fo"%ROOT%src\config_ui\\" src\config_ui\main.cpp exit /b %errorlevel% rem --- Installer (needs NSIS; winget install NSIS.NSIS) --------------------- From b74d57971638775e0c490def521e9cd44c6c23a2 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:38:34 +0200 Subject: [PATCH 2/7] refactor(render): one log macro per file and one D3DCompile wrapper (#470) RLog, ULog and DiagLog formatted each line twice (vsnprintf, then wind::Log). They are now macros over wind::Log, so no call site changes. render_engine and upscale_pass share CompileHlsl (hlsl_compile.h) with their own flags; a failed render shader now logs the compiler text too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/hlsl_compile.h | 26 ++++++++++++++++++++++++++ src/render_engine.cpp | 17 ++++------------- src/upscale_pass.cpp | 18 +++--------------- 3 files changed, 33 insertions(+), 28 deletions(-) create mode 100644 src/hlsl_compile.h diff --git a/src/hlsl_compile.h b/src/hlsl_compile.h new file mode 100644 index 00000000..6ce4e1b5 --- /dev/null +++ b/src/hlsl_compile.h @@ -0,0 +1,26 @@ +// The one D3DCompile wrapper of the render path (render_engine.cpp, upscale_pass.cpp). Win32/D3D: +// include after . A failed compile logs the compiler's error text under `logCat`. +#pragma once +#include +#include +#include +#include "logging.h" + +namespace wind { + +// `sourceName` names the source in compiler messages (may be null); `flags` are D3DCOMPILE_* flags. +inline Microsoft::WRL::ComPtr CompileHlsl(const char* src, size_t len, const char* sourceName, + const char* entry, const char* target, UINT flags, + const char* logCat) { + Microsoft::WRL::ComPtr code, err; + const HRESULT hr = D3DCompile(src, len, sourceName, nullptr, nullptr, entry, target, flags, 0, + code.GetAddressOf(), err.GetAddressOf()); + if (FAILED(hr)) { + wind::Log(wind::LogLevel::Info, logCat, "shader %s %s failed: %s", sourceName ? sourceName : "?", + entry, err ? static_cast(err->GetBufferPointer()) : "?"); + return nullptr; + } + return code; +} + +} // namespace wind diff --git a/src/render_engine.cpp b/src/render_engine.cpp index fa0771db..e5c24886 100644 --- a/src/render_engine.cpp +++ b/src/render_engine.cpp @@ -11,6 +11,7 @@ #include "mag_host.h" // shared Magnification-runtime refcount (both models use the API) #include "upscale_pass.h" // the xBR / NIS scaling filters (issue #456) #include "dwm_watch.h" // DwmGeneration: re-apply the overlay's exclusions after a DWM restart (#458) +#include "hlsl_compile.h" // CompileHlsl: the one D3DCompile wrapper #include #include #include @@ -40,21 +41,11 @@ namespace wind { using Microsoft::WRL::ComPtr; // Render-engine events route through the unified logger (category "render"). -static void RLog(const char* fmt, ...) { - char msg[1024]; - va_list ap; va_start(ap, fmt); - _vsnprintf_s(msg, sizeof(msg), _TRUNCATE, fmt, ap); - va_end(ap); - wind::Log(wind::LogLevel::Info, "render", "%s", msg); -} +#define RLog(...) wind::Log(wind::LogLevel::Info, "render", __VA_ARGS__) +// Default compile flags; the caller owns (and releases) the returned blob. static ID3DBlob* CompileShader(const char* src, const char* entry, const char* target) { - ID3DBlob* code = nullptr; ID3DBlob* err = nullptr; - HRESULT hr = D3DCompile(src, std::strlen(src), nullptr, nullptr, nullptr, - entry, target, 0, 0, &code, &err); - if (err) err->Release(); - if (FAILED(hr)) { SafeRelease(code); return nullptr; } - return code; + return CompileHlsl(src, std::strlen(src), nullptr, entry, target, 0, "render").Detach(); } // --------------------------------------------------------------------------- diff --git a/src/upscale_pass.cpp b/src/upscale_pass.cpp index c777e873..7d2836b8 100644 --- a/src/upscale_pass.cpp +++ b/src/upscale_pass.cpp @@ -5,6 +5,7 @@ #include "render_shaders.h" // MagCB #include "logging.h" #include +#include "hlsl_compile.h" // CompileHlsl: the one D3DCompile wrapper #include #include #include @@ -13,13 +14,7 @@ namespace wind { using Microsoft::WRL::ComPtr; -static void ULog(const char* fmt, ...) { - char msg[1024]; - va_list ap; va_start(ap, fmt); - _vsnprintf_s(msg, sizeof(msg), _TRUNCATE, fmt, ap); - va_end(ap); - wind::Log(wind::LogLevel::Info, "upscale", "%s", msg); -} +#define ULog(...) wind::Log(wind::LogLevel::Info, "upscale", __VA_ARGS__) namespace { constexpr int kFilters = 3; // RenderScaling values @@ -35,14 +30,7 @@ std::string Source(int id) { } ComPtr Compile(const std::string& src, const char* entry, const char* target, const char* tag) { - ComPtr code, err; - HRESULT hr = D3DCompile(src.data(), src.size(), tag, nullptr, nullptr, entry, target, - D3DCOMPILE_OPTIMIZATION_LEVEL3, 0, code.GetAddressOf(), err.GetAddressOf()); - if (FAILED(hr)) { - ULog("shader %s %s failed: %s", tag, entry, err ? (const char*)err->GetBufferPointer() : "?"); - return nullptr; - } - return code; + return CompileHlsl(src.data(), src.size(), tag, entry, target, D3DCOMPILE_OPTIMIZATION_LEVEL3, "upscale"); } struct TimerSlot { From fc81e5415b5b9af0d36881e982e09c6a92e4de35 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:38:45 +0200 Subject: [PATCH 3/7] refactor: one copy of the shared C++ helpers across the three binaries (#470) - config_path.h: cached ExeDir() and UserDataDir() replace the GetModuleFileNameW and %LOCALAPPDATA%\Wind copies in Wind, WindTray (AppDir) and WindConfig; session.keep and running.model use UserDataDir. - ini_edit.h: one pure TrimWs replaces five trim helpers. - uiaccess_token.h: ProcessHasUiAccess() is the one token read (startup gate, transform engine). - mpo.h: ReadOverlayTestMode() is the one OverlayTestMode read; DetectMpoDisabled uses it. - tray_ipc.h: kInstanceMutexName, kQuitEventName and kIniWriteMutexName; the NSIS and PowerShell copies point there. - profiles.h: kUiOnlyKeys is the one list behind IsGlobalProfileKey, MakeLiveText and StripUiOnlyKeys. tests/fixtures/global_keys.txt pins it and the UI's GLOBAL_KEYS together. - profile_switch.h: SwitchProfile, LaunchWindExe, RevertModelAfterFailedRelaunch and the session.keep helpers, shared by the tray profile switch, the Settings switch and the tray engine pick. The tray switch now also writes session.keep before a relaunch, as Settings did. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- installer/app.nsh | 2 +- src/config.cpp | 27 +++---- src/config_path.h | 58 ++++++++------ src/config_ui/main.cpp | 95 ++++++---------------- src/ini_edit.cpp | 16 ++-- src/ini_edit.h | 5 ++ src/main.cpp | 47 ++++------- src/mpo.h | 18 ++++- src/profile_switch.h | 129 ++++++++++++++++++++++++++++++ src/profiles.cpp | 22 ++--- src/profiles.h | 14 +++- src/profiles_io.h | 16 ++-- src/transform_model.cpp | 16 +--- src/tray_app/engine_dropdown.cpp | 29 ++----- src/tray_app/flyout_tools.h | 5 +- src/tray_app/main.cpp | 10 +-- src/tray_app/tray_app.h | 1 - src/tray_app/tray_menu.cpp | 79 ++++++------------ src/tray_ipc.h | 12 +++ src/tray_items.cpp | 9 +-- src/uiaccess_token.h | 22 +++++ tests/fixtures/global_keys.txt | 16 ++++ tests/test_profiles.cpp | 32 +++++++- tools/gpu_ab.ps1 | 1 + tools/testenv/lib.ps1 | 1 + tools/zoom_response_ab.ps1 | 1 + ui/package.json | 2 +- ui/src/session.js | 5 +- ui/tests/global-keys.nodetest.mjs | 17 ++++ 29 files changed, 406 insertions(+), 301 deletions(-) create mode 100644 src/profile_switch.h create mode 100644 src/uiaccess_token.h create mode 100644 tests/fixtures/global_keys.txt create mode 100644 ui/tests/global-keys.nodetest.mjs diff --git a/installer/app.nsh b/installer/app.nsh index e1d1a03d..85452ed2 100644 --- a/installer/app.nsh +++ b/installer/app.nsh @@ -14,7 +14,7 @@ ; Wind's own two kernel objects. Both are Local\, i.e. session-scoped, and UAC elevation ; stays inside the session, so the elevated installer and the user's Wind see the same -; objects. Names must match src\main.cpp:1911 and src\main.cpp:2199 exactly. +; objects. Names must match kInstanceMutexName and kQuitEventName in src\tray_ipc.h exactly. !define WIND_MUTEX "Local\Wind_Magnifier_SingleInstance" !define WIND_QUIT "Local\Wind_QuitRequest" diff --git a/src/config.cpp b/src/config.cpp index 22104cd6..2c765b2a 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -1,6 +1,7 @@ #include "config.h" #include "keybind_rules.h" // one safety rule set for every bind (#285) #include "ini_edit.h" // wheel migration (#318): pure text edits +#include "profiles.h" // kUiOnlyKeys: what StripUiOnlyKeys drops #include #include #include @@ -15,12 +16,6 @@ static double clampd(double v, double lo, double hi) { if (v != v) return lo; // NaN fails every comparison below and would pass through unclamped return v < lo ? lo : (v > hi ? hi : v); } -static std::string trim(const std::string& s) { - size_t a = s.find_first_not_of(" \t\r\n"); - if (a == std::string::npos) return ""; - size_t b = s.find_last_not_of(" \t\r\n"); - return s.substr(a, b - a + 1); -} // Number parsing for ini values: the whole value must be a plain decimal number. The std::sto* // calls accepted "12abc" as 12, "0x10" as 0, and strtod-based ones take "nan", "inf" and hex floats @@ -65,7 +60,7 @@ bool IsExeInList(const std::string& exeName, const std::string& list) { size_t comma = haystack.find(',', pos); std::string item = haystack.substr(pos, comma == std::string::npos ? std::string::npos : comma - pos); - item = trim(item); + item = TrimWs(item); if (!item.empty() && item == needle) return true; if (comma == std::string::npos) break; pos = comma + 1; @@ -135,12 +130,12 @@ Config ParseConfig(const std::string& text) { std::string line; bool haveRenderScaling = false; // issue #456: without the key, `bilinear` decides (migration) while (std::getline(in, line)) { - std::string t = trim(line); + std::string t = TrimWs(line); if (t.empty() || t[0] == ';' || t[0] == '#') continue; size_t eq = t.find('='); if (eq == std::string::npos) continue; - std::string key = trim(t.substr(0, eq)); - std::string val = trim(t.substr(eq + 1)); + std::string key = TrimWs(t.substr(0, eq)); + std::string val = TrimWs(t.substr(eq + 1)); if (key.empty()) continue; // The exe LISTS must accept an empty value: "key=" with nothing after it is how the user // clears one, and it is exactly what the config UI writes when the last entry is removed. @@ -404,11 +399,11 @@ std::string StripUiOnlyKeys(const std::string& iniText) { size_t b = line.find_first_not_of(" \t"); bool uiOnly = false; if (b != std::string::npos) { - // Keep in step with IsGlobalProfileKey (src/profiles.cpp) minus "profile": a test pins it. - for (const char* k : { "uiTheme=", "uiPalette=", "showAdvanced=", "onboarded=", - "trayPerf=", "traySliders=", "traySliderOrder=", "trayToggles=", - "trayToggleOrder=", "trayPinned=", "uiHighResNoticeOff=" }) { - if (line.compare(b, strlen(k), k) == 0) { uiOnly = true; break; } + // The global keys minus "profile" (kUiOnlyKeys, src/profiles.h): a profile switch must + // still reload the core. + for (const char* k : kUiOnlyKeys) { + const size_t n = strlen(k); + if (line.compare(b, n, k) == 0 && b + n < line.size() && line[b + n] == '=') { uiOnly = true; break; } } } if (!uiOnly) { out += line; out += '\n'; } @@ -422,7 +417,7 @@ namespace wind { static const char* const kUiPalettes[] = { "grey", "ember", "ocean", "hicon" }; const char* const* UiPaletteIds(int& count) { count = (int)(sizeof(kUiPalettes) / sizeof(kUiPalettes[0])); return kUiPalettes; } std::string NormalizeUiPalette(const std::string& value) { - const std::string v = trim(value); + const std::string v = TrimWs(value); for (const char* id : kUiPalettes) if (v == id) return v; return "grey"; } diff --git a/src/config_path.h b/src/config_path.h index cb3e32f6..8cc4a3aa 100644 --- a/src/config_path.h +++ b/src/config_path.h @@ -25,27 +25,46 @@ inline bool DirIsWritable(const std::wstring& dir) { return true; } +// The folder holding this exe, without a trailing backslash. Cached: it cannot change while the +// process runs. Wind.exe, WindTray.exe and WindConfig.exe sit in the same folder. +inline const std::wstring& ExeDir() { + static const std::wstring cached = [] { + wchar_t buf[MAX_PATH]; + if (!GetModuleFileNameW(nullptr, buf, MAX_PATH)) return std::wstring(); + wchar_t* slash = wcsrchr(buf, L'\\'); + if (slash) *slash = L'\0'; + return std::wstring(buf); + }(); + return cached; +} + +// %LOCALAPPDATA%\Wind (under %TEMP% when LOCALAPPDATA is unset), created on first use. No trailing +// backslash. Holds the ini and logs of a read-only install, session.keep, running.model and the +// WebView2 user-data folder. +inline const std::wstring& UserDataDir() { + static const std::wstring cached = [] { + wchar_t buf[MAX_PATH]; + DWORD n = GetEnvironmentVariableW(L"LOCALAPPDATA", buf, MAX_PATH); + if (n == 0 || n >= MAX_PATH) { GetTempPathW(MAX_PATH, buf); } + std::wstring dir = std::wstring(buf) + L"\\Wind"; + CreateDirectoryW(dir.c_str(), nullptr); + return dir; + }(); + return cached; +} + // Resolved once per process: the exe location and its writability do not change, and every caller // (tick reload, settings, tray) would otherwise repeat the probe and the seed check. inline std::wstring ResolveIniPath() { static const std::wstring cached = [] { - wchar_t exePathBuf[MAX_PATH]; - GetModuleFileNameW(nullptr, exePathBuf, MAX_PATH); - wchar_t* slash = wcsrchr(exePathBuf, L'\\'); - if (slash) *slash = L'\0'; - std::wstring exeDir(exePathBuf); + const std::wstring& exeDir = ExeDir(); std::wstring exeIni = exeDir + L"\\magnifier.ini"; // If the probe succeeds the exe dir is fine (dev / portable install). if (DirIsWritable(exeDir)) return exeIni; // Read-only install (typically C:\Program Files\Wind). Fall back to %LOCALAPPDATA%\Wind. - wchar_t buf[MAX_PATH]; - DWORD n = GetEnvironmentVariableW(L"LOCALAPPDATA", buf, MAX_PATH); - if (n == 0 || n >= MAX_PATH) { GetTempPathW(MAX_PATH, buf); } - std::wstring dir = std::wstring(buf) + L"\\Wind"; - CreateDirectoryW(dir.c_str(), nullptr); - std::wstring lapIni = dir + L"\\magnifier.ini"; + std::wstring lapIni = UserDataDir() + L"\\magnifier.ini"; // Seed the user copy from the install template on first run, so deploy-time defaults // (zorderBand, the user's previous bindings) carry over to the writable location. @@ -65,21 +84,8 @@ inline std::wstring ResolveLogDir() { // Resolve once: the exe location and its writability don't change over a process lifetime, so cache // the result (and the create+delete sentinel probe / CreateDirectory) instead of redoing it per call. static const std::wstring cached = [] { - wchar_t exePathBuf[MAX_PATH]; - GetModuleFileNameW(nullptr, exePathBuf, MAX_PATH); - wchar_t* slash = wcsrchr(exePathBuf, L'\\'); - if (slash) *slash = L'\0'; - std::wstring exeDir(exePathBuf); - - std::wstring base; - if (DirIsWritable(exeDir)) base = exeDir; - else { - wchar_t buf[MAX_PATH]; - DWORD n = GetEnvironmentVariableW(L"LOCALAPPDATA", buf, MAX_PATH); - if (n == 0 || n >= MAX_PATH) { GetTempPathW(MAX_PATH, buf); } - base = std::wstring(buf) + L"\\Wind"; - CreateDirectoryW(base.c_str(), nullptr); - } + const std::wstring& exeDir = ExeDir(); + const std::wstring base = DirIsWritable(exeDir) ? exeDir : UserDataDir(); std::wstring logs = base + L"\\logs"; CreateDirectoryW(logs.c_str(), nullptr); return logs; diff --git a/src/config_ui/main.cpp b/src/config_ui/main.cpp index 3c481fa5..bdf712c1 100644 --- a/src/config_ui/main.cpp +++ b/src/config_ui/main.cpp @@ -23,6 +23,8 @@ #include "../profiles.h" #include "../profiles_io.h" #include "../logging.h" +#include "../tray_ipc.h" // kQuitEventName +#include "../profile_switch.h" // SwitchProfile, LaunchWindExe, session.keep: shared with the tray #include #include "../resource.h" #pragma comment(lib, "shlwapi.lib") @@ -57,10 +59,6 @@ static HWND g_hwnd = nullptr; static bool g_dirty = false; static bool g_forceClose = false; -static std::wstring ExeDir() { - wchar_t p[MAX_PATH]; GetModuleFileNameW(nullptr, p, MAX_PATH); - PathRemoveFileSpecW(p); return p; -} // Is the Wind.exe magnifier process running? Uses a Toolhelp process-name scan, which works across // integrity levels (the deployed Wind.exe is UIAccess/higher IL than this normal-IL config host, so // opening its single-instance mutex could be access-denied; reading process names is not). Lets us @@ -86,15 +84,11 @@ static bool WindRunning() { CloseHandle(snap); return found; } -// Launch the magnifier that sits next to us. Returns false when ShellExecuteW failed (<= 32). -// Relaunching while Wind is ALREADY running is how a model switch restarts it: the new instance -// finds the single-instance mutex held, signals Local\Wind_QuitRequest to the incumbent, waits for -// it to exit cleanly, then takes over (src/main.cpp:801-817). No extra IPC is needed here. -static bool LaunchWind() { - std::wstring windExe = ExeDir() + L"\\Wind.exe"; - HINSTANCE r = ShellExecuteW(nullptr, L"open", windExe.c_str(), nullptr, nullptr, SW_SHOW); - return reinterpret_cast(r) > 32; -} +// Launch the magnifier that sits next to us (wind::LaunchWindExe). Returns false when Wind.exe is +// missing or ShellExecuteW failed (<= 32). Relaunching while Wind is ALREADY running is how a model +// switch restarts it: the new instance evicts the incumbent through the quit event (AcquireSingleInstance +// in src/main.cpp). No extra IPC is needed here. +static bool LaunchWind() { return wind::LaunchWindExe() > 32; } // Resolved at first call (and cached) so reads and writes always land on the same file the Wind // core uses. Falls back to %LOCALAPPDATA%\Wind\magnifier.ini when the exe dir is read-only. static std::wstring IniPath() { @@ -151,52 +145,22 @@ static void PostProfiles(ICoreWebView2* wv, bool ok, const std::string& err, boo // Names arriving over the bridge become file paths; anything the pure validator rejects (traversal // characters, reserved names, dots) must never reach ProfilePath. static bool SafeName(const std::string& n) { return wind::ProfileNameError(n).empty(); } -// session.keep tells the next Wind start to keep the unsaved session (see ResetSessionToProfile). -static void WriteSessionKeep() { - HANDLE h = CreateFileW(wind::SessionKeepPath().c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, - FILE_ATTRIBUTE_NORMAL, nullptr); - if (h != INVALID_HANDLE_VALUE) CloseHandle(h); -} -static void ClearSessionKeep() { DeleteFileW(wind::SessionKeepPath().c_str()); } -// Rewrite the live ini from a profile file (globals preserved). The core hot-reloads; a model -// change additionally relaunches Wind (LaunchWind: the new instance evicts the incumbent). -// `mirrorOutgoing` captures the live ini into the OUTGOING profile first (hand edits made via "Edit -// config file" would otherwise be lost). A create-from-current must pass false: its unsaved changes -// belong to the new profile, and mirroring them would save them into the old one unasked. +// Rewrite the live ini from a profile file (globals preserved). The steps, the relaunch for a +// single-engine Wind and the rollback are shared with the tray (wind::SwitchProfile, +// profile_switch.h); this maps the result to the page's messages. A create-from-current passes +// mirrorOutgoing=false: its unsaved changes belong to the new profile, not the old one. static std::string DoSwitchProfile(const std::string& name, bool mirrorOutgoing = true) { if (!SafeName(name)) return "Invalid profile name"; - const std::wstring pp = ProfilePath(name); - if (GetFileAttributesW(pp.c_str()) == INVALID_FILE_ATTRIBUTES) return "Profile file is missing"; - // A read failure must not masquerade as an empty profile (empty = factory defaults by design), - // and corrupt content must never be applied wholesale to the live ini. - std::string profText; - if (!wind::ReadTextFileOk(pp, profText)) return "Could not read the profile file"; - { std::string terr = wind::ProfileTextError(profText); if (!terr.empty()) return terr; } - // Held to the end: the whole read-modify-write (and the rollback) is one unit (review item 71). - wind::IniWriteLock iniLock; - std::string oldLive; - if (!wind::ReadLiveIni(IniPath(), oldLive)) return "Could not read the config file"; - // Capture hand edits (openIni) into the outgoing profile before the live ini is replaced. - if (mirrorOutgoing) wind::MirrorLiveToActiveProfile(IniPath(), oldLive); - const std::string newLive = wind::MakeLiveText(profText, oldLive, name); - // WriteTextFileAtomic checks the write and the replace itself; its result is the verdict (a - // re-read can fail on its own and would report a successful switch as a failed one). - if (!WriteFileAtomic(IniPath(), newLive)) return "Could not write the config file"; - // ParseConfig canonicalizes (unknown or retired models -> hybrid), same - // comparison as the tray path, so the two surfaces can never disagree about restarting. - const std::string oldModel = wind::ParseConfig(oldLive).model; - // A Wind that switches engines live (running.model "live") takes the new model on its reload. - const bool modelChanged = oldModel != wind::ParseConfig(newLive).model && !wind::EngineSwitchesLive(); - if (modelChanged) WriteSessionKeep(); // our own restart: the start must not reset the session - if (modelChanged && !LaunchWind()) { - ClearSessionKeep(); - // Keep "ini model == running model" (mirrors the Settings restartFailed handler). - // Re-read: Settings or the tray may have written since newLive was built. - std::string cur; - if (!ReadIni(cur)) cur = newLive; - WriteFileAtomic(IniPath(), wind::UpdateIniText(cur, "model", oldModel)); - return "Switched, but restarting Wind failed; kept the current model"; + const wind::ProfileSwitchResult r = wind::SwitchProfile(IniPath(), ProfilePath(name), name, mirrorOutgoing); + switch (r.error) { + case wind::ProfileSwitchError::None: break; + case wind::ProfileSwitchError::FileMissing: return "Profile file is missing"; + case wind::ProfileSwitchError::ReadFailed: return "Could not read the profile file"; + case wind::ProfileSwitchError::Corrupt: return r.reason; + case wind::ProfileSwitchError::LiveUnreadable: return "Could not read the config file"; + case wind::ProfileSwitchError::WriteFailed: return "Could not write the config file"; } + if (r.relaunchFailed) return "Switched, but restarting Wind failed; kept the current model"; return ""; } // New-profile validation: trimmed name must pass the pure rules and be unique. The @@ -404,7 +368,7 @@ static void HandleWebMessage(ICoreWebView2* wv, const std::wstring& jsonW) { // window. Signal the magnifier via a named event, NOT a window message: the deployed // Wind.exe is UIAccess and UIPI silently blocks PostMessage from this non-UIAccess // process. A kernel event isn't gated by UIPI and works in dev + deployed. Then close us. - HANDLE ev = OpenEventW(EVENT_MODIFY_STATE, FALSE, L"Local\\Wind_QuitRequest"); + HANDLE ev = OpenEventW(EVENT_MODIFY_STATE, FALSE, wind::kQuitEventName); if (ev) { SetEvent(ev); CloseHandle(ev); } PostMessageW(g_hwnd, WM_CLOSE, 0, 0); } @@ -416,8 +380,8 @@ static void HandleWebMessage(ICoreWebView2* wv, const std::wstring& jsonW) { // incumbent is untouched (it is only ever signalled BY a successfully started instance), // so report back rather than leaving the user with a silently ignored button. // session.keep first: this restart is ours, so the unsaved session must survive it. - WriteSessionKeep(); - if (!LaunchWind()) { ClearSessionKeep(); wv->PostWebMessageAsJson(L"{\"type\":\"restartFailed\"}"); } + wind::WriteSessionKeep(); + if (!LaunchWind()) { wind::ClearSessionKeep(); wv->PostWebMessageAsJson(L"{\"type\":\"restartFailed\"}"); } } } else if (type == "pickExe") { // "+" on the key-release app list: browse for a program and hand back just its FILE NAME. @@ -696,16 +660,9 @@ static void CreateWebView(HWND hwnd) { // (\WindConfig.exe.WebView2), which is fine in dev but read-only when the exe is // installed under Program Files - causing the environment to fail and the window to render // as an empty shell. Force it to %LOCALAPPDATA%\Wind\WebView2 so it always works. - std::wstring userData; - { - wchar_t buf[MAX_PATH]; - DWORD n = GetEnvironmentVariableW(L"LOCALAPPDATA", buf, MAX_PATH); - if (n == 0 || n >= MAX_PATH) { GetTempPathW(MAX_PATH, buf); } - userData = std::wstring(buf) + L"\\Wind\\WebView2"; - CreateDirectoryW((std::wstring(buf) + L"\\Wind").c_str(), nullptr); - CreateDirectoryW(userData.c_str(), nullptr); - } - const std::wstring uiDir = ExeDir() + L"\\ui\\dist"; + const std::wstring userData = wind::UserDataDir() + L"\\WebView2"; + CreateDirectoryW(userData.c_str(), nullptr); + const std::wstring uiDir = wind::ExeDir() + L"\\ui\\dist"; const bool onboard = g_onboard; CreateCoreWebView2EnvironmentWithOptions(nullptr, userData.c_str(), nullptr, Callback( diff --git a/src/ini_edit.cpp b/src/ini_edit.cpp index 59209615..3de67820 100644 --- a/src/ini_edit.cpp +++ b/src/ini_edit.cpp @@ -1,23 +1,17 @@ #include "ini_edit.h" #include namespace wind { -static std::string trim(const std::string& s) { - size_t a = s.find_first_not_of(" \t\r\n"); - if (a == std::string::npos) return ""; - size_t b = s.find_last_not_of(" \t\r\n"); - return s.substr(a, b - a + 1); -} std::map ReadIniValues(const std::string& text) { std::map out; std::istringstream in(text); std::string line; while (std::getline(in, line)) { - std::string t = trim(line); + std::string t = TrimWs(line); if (t.empty() || t[0] == ';' || t[0] == '#') continue; size_t eq = t.find('='); if (eq == std::string::npos) continue; - std::string k = trim(t.substr(0, eq)); - if (!k.empty()) out[k] = trim(t.substr(eq + 1)); + std::string k = TrimWs(t.substr(0, eq)); + if (!k.empty()) out[k] = TrimWs(t.substr(eq + 1)); } return out; } @@ -27,10 +21,10 @@ std::string UpdateIniText(const std::string& text, const std::string& key, const bool replaced = false; const bool endsWithNewline = !text.empty() && text.back() == '\n'; while (std::getline(in, line)) { - std::string t = trim(line); + std::string t = TrimWs(line); const bool comment = t.empty() || t[0] == ';' || t[0] == '#'; size_t eq = t.find('='); - if (!comment && eq != std::string::npos && trim(t.substr(0, eq)) == key) { + if (!comment && eq != std::string::npos && TrimWs(t.substr(0, eq)) == key) { // Replace the FIRST occurrence and DROP any later duplicates: every reader // (ReadIniValues, the core's ParseConfig) is last-wins, so leaving a later duplicate // untouched made this write invisible - the value visibly snapped back on the next diff --git a/src/ini_edit.h b/src/ini_edit.h index ac74aea9..fa946f01 100644 --- a/src/ini_edit.h +++ b/src/ini_edit.h @@ -2,6 +2,11 @@ #include #include namespace wind { +// The one whitespace trim (' ', tab, CR, LF at both ends) for ini keys, values and list items. Pure. +inline std::string TrimWs(const std::string& s) { + const size_t b = s.find_first_not_of(" \t\r\n"), e = s.find_last_not_of(" \t\r\n"); + return b == std::string::npos ? std::string() : s.substr(b, e - b + 1); +} // Parse INI text into key->value, skipping ';'/'#' comments and blank lines; keys/values trimmed. std::map ReadIniValues(const std::string& text); // Return INI text with `key`'s value replaced IN PLACE, preserving every other line (comments, diff --git a/src/main.cpp b/src/main.cpp index 3142aacb..040e1560 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -20,7 +20,7 @@ #include // __rdtsc: thread cycle calibration (#361) #include "mpo_boot.h" #include "ini_edit.h" // wind::UpdateIniText - flip the model key in place -#include "mpo.h" // wind::MpoDisabledInRegistry - the #242 restart-pending tell +#include "mpo.h" // the OverlayTestMode read: startup MPO state and the #242 restart-pending tell #include "logging.h" #pragma comment(lib, "Dwmapi.lib") #include "render_engine.h" @@ -62,6 +62,7 @@ #include "typing_key.h" // typing-key stamp for the click quiet period (#328) #include "dwm_watch.h" // StartDwmWatch (#396) #include "uiaccess_gate.h" // refuse to start without UIAccess (#460) +#include "uiaccess_token.h" // ProcessHasUiAccess: the token bit the gate reads #include "reload_gate.h" // DecideReload + bind-change predicates (pure, tested) #include "resource.h" @@ -80,11 +81,8 @@ static constexpr double kMaxSafeTxMagnitude = 32000.0; static bool g_mpoDisabled = false; // boot state of OverlayTestMode (read once at startup) static void DetectMpoDisabled() { - DWORD v = 0, sz = sizeof(v); - bool regDisabled = false; - if (RegGetValueW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\Microsoft\\Windows\\Dwm", - L"OverlayTestMode", RRF_RT_REG_DWORD, nullptr, &v, &sz) == ERROR_SUCCESS) - regDisabled = (v == 5); + DWORD v = 0; + const bool regDisabled = wind::ReadOverlayTestMode(v) && v == wind::kOverlayTestModeDisabled; g_mpoDisabled = regDisabled; // Persist the earliest post-boot reading (issue #164). No-ops if this boot is already recorded. wind::RecordMpoBootState(g_mpoDisabled); @@ -775,11 +773,7 @@ static void EndGameInspect(TickState& t) { // Frame-pacing diagnostics (diagnostics=1) as "diag" lines in wind-core.log. Formerly its own // fopen/append file in %TEMP% from the tick thread; now a queue push like every log line (#361). -static void DiagLog(const char* fmt, ...) { - char buf[1024]; - va_list ap; va_start(ap, fmt); _vsnprintf_s(buf, sizeof(buf), _TRUNCATE, fmt, ap); va_end(ap); - wind::Log(wind::LogLevel::Info, "diag", "%s", buf); -} +#define DiagLog(...) wind::Log(wind::LogLevel::Info, "diag", __VA_ARGS__) // Forward-declared so RunTick can re-register the hide-cursor hotkey on config hot-reload; // the definition (with the static state it manages) lives near WndProc / kHideCursorHotkeyId. @@ -2958,18 +2952,18 @@ static void TerminateOtherWind() { // quitEvent is created here, as soon as the mutex is ours, so a successor that finds us busy can // always signal it (it used to exist only after the whole startup). A null mutex fails closed. static bool AcquireSingleInstance(HANDLE& mtx, HANDLE& quitEvent) { - mtx = CreateMutexW(nullptr, FALSE, L"Local\\Wind_Magnifier_SingleInstance"); + mtx = CreateMutexW(nullptr, FALSE, wind::kInstanceMutexName); SiLog("createmutex err", GetLastError()); if (!mtx) { SiLog("mutex null - REFUSING TO START (cannot guard against a second instance)", 0); return false; } // Quit-request channel for WindConfig.exe (onboarding close) and for a successor Wind. A window // message can't be used: the deployed Wind.exe is UIAccess, and UIPI silently blocks PostMessage // from the non-UIAccess WindConfig. A named event is a kernel object (not gated by UIPI) and // both run as the same user in the same session. Auto-reset, initially unsignaled. - auto makeQuitEvent = [&] { quitEvent = CreateEventW(nullptr, FALSE, FALSE, L"Local\\Wind_QuitRequest"); }; + auto makeQuitEvent = [&] { quitEvent = CreateEventW(nullptr, FALSE, FALSE, wind::kQuitEventName); }; DWORD w = WaitForSingleObject(mtx, 0); // WAIT_ABANDONED = prior owner died holding it -> ours now if (w == WAIT_OBJECT_0 || w == WAIT_ABANDONED) { makeQuitEvent(); SiLog("acquired immediately w", w); return true; } SiLog("busy - signaling quit, w", w); - HANDLE ev = OpenEventW(EVENT_MODIFY_STATE, FALSE, L"Local\\Wind_QuitRequest"); + HANDLE ev = OpenEventW(EVENT_MODIFY_STATE, FALSE, wind::kQuitEventName); if (ev) { SetEvent(ev); CloseHandle(ev); SiLog("quit event set", 0); } else SiLog("quit event open err", GetLastError()); w = WaitForSingleObject(mtx, 3000); // wait for it to release on clean exit @@ -3003,21 +2997,16 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { // No UIAccess: refuse and say how to fix it (issue #460). Before the single-instance claim, so // a refused start never evicts a working Wind. { - DWORD uiAccess = 0, len = 0; - HANDLE tok = nullptr; - if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &tok)) { - if (!GetTokenInformation(tok, TokenUIAccess, &uiAccess, sizeof(uiAccess), &len)) uiAccess = 0; - CloseHandle(tok); - } + const bool uiAccess = wind::ProcessHasUiAccess(); const bool allow = GetEnvironmentVariableW(L"WIND_ALLOW_NO_UIACCESS", nullptr, 0) > 0; const bool selftest = GetEnvironmentVariableW(L"WIND_SELFTEST", nullptr, 0) > 0; - if (wind::MustRefuseWithoutUiAccess(uiAccess != 0, allow, selftest)) { + if (wind::MustRefuseWithoutUiAccess(uiAccess, allow, selftest)) { wind::Log(wind::LogLevel::Warn, "startup", "no UIAccess: refusing to start (issue #460)"); MessageBoxW(nullptr, wind::UiAccessRefusalText(), wind::UiAccessRefusalTitle(), MB_OK | MB_ICONERROR | MB_SETFOREGROUND); return 1; } - if (uiAccess == 0) + if (!uiAccess) wind::Log(wind::LogLevel::Warn, "startup", "no UIAccess: running anyway (developer override)"); } LoadChurnyApps(); // issue #148: learned cursor-churning apps (transform -> render for them) @@ -3038,11 +3027,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { SetUnhandledExceptionFilter(wind::CursorCrashFilter); // Resolve magnifier.ini next to the exe (not the launch cwd). - wchar_t exePath[MAX_PATH]; - if (GetModuleFileNameW(nullptr, exePath, MAX_PATH)) { - wchar_t* slash = wcsrchr(exePath, L'\\'); - if (slash) { *slash = L'\0'; SetCurrentDirectoryW(exePath); } - } + if (!wind::ExeDir().empty()) SetCurrentDirectoryW(wind::ExeDir().c_str()); // Resolve magnifier.ini's runtime path (exe-dir if writable, else %LOCALAPPDATA%\Wind\). Same // resolution is used by WindConfig.exe so both processes always touch the same file. @@ -3199,7 +3184,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { // The tray icon and menu live in WindTray.exe (issue #291): a UIAccess process's menu stacks // above the cursor and the Snipping Tool overlay, an ordinary process's menu does not. - g_trayBlock = wind::TrayHost::Start(exePath); + g_trayBlock = wind::TrayHost::Start(wind::ExeDir()); g_tint.capture(); // the scheme is clean here: RestoreInputState reloaded it at start-up (#288) TickState ts(model.get(), startupMon, cfg); @@ -3303,9 +3288,9 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { // First launch: open the guided setup once (at startup, before the tick loop). onboarded==0 // covers a freshly created ini too. Non-blocking: spawn WindConfig.exe --onboard, then continue - // to the tray. Resolve by full path (exePath is our own dir) so it works regardless of the cwd. + // to the tray. Resolve by full path (wind::ExeDir()) so it works regardless of the cwd. if (cfg.onboarded == 0) { - std::wstring configExe = std::wstring(exePath) + L"\\WindConfig.exe"; + std::wstring configExe = wind::ExeDir() + L"\\WindConfig.exe"; // ShellExecuteEx, never CreateProcess with our token: Wind.exe is UIAccess and a child made // with CreateProcess inherits it. A shell launch gives WindConfig an ordinary token (the // same way LaunchTray starts WindTray). @@ -3315,7 +3300,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { sei.lpVerb = L"open"; sei.lpFile = configExe.c_str(); sei.lpParameters = L"--onboard"; - sei.lpDirectory = exePath; + sei.lpDirectory = wind::ExeDir().c_str(); sei.nShow = SW_SHOWNORMAL; if (ShellExecuteExW(&sei) && sei.hProcess) CloseHandle(sei.hProcess); } diff --git a/src/mpo.h b/src/mpo.h index 77c00cff..7a835591 100644 --- a/src/mpo.h +++ b/src/mpo.h @@ -18,9 +18,10 @@ inline const wchar_t* kOverlayTestMode = L"OverlayTestMode"; // the Windows default) means MPO is enabled. inline constexpr DWORD kOverlayTestModeDisabled = 5; -// Is MPO currently disabled in the registry? This is the BOOT state, not necessarily what DWM is -// running: the value is read by DWM at boot, so a change needs a restart to take effect. -inline bool MpoDisabledInRegistry() { +// The one read of HKLM\...\Dwm\OverlayTestMode. False (and raw = 0) when the key or value is +// absent or the value is not a REG_DWORD. +inline bool ReadOverlayTestMode(DWORD& raw) { + raw = 0; HKEY key = nullptr; // KEY_WOW64_64KEY is deliberate: a 32-bit host build would otherwise be redirected to // Wow6432Node and read a key DWM never looks at, reporting "MPO enabled" forever. @@ -32,7 +33,16 @@ inline bool MpoDisabledInRegistry() { LSTATUS st = RegQueryValueExW(key, kOverlayTestMode, nullptr, &type, reinterpret_cast(&value), &size); RegCloseKey(key); - return st == ERROR_SUCCESS && type == REG_DWORD && value == kOverlayTestModeDisabled; + if (st != ERROR_SUCCESS || type != REG_DWORD) return false; + raw = value; + return true; +} + +// Is MPO currently disabled in the registry? This is the BOOT state, not necessarily what DWM is +// running: the value is read by DWM at boot, so a change needs a restart to take effect. +inline bool MpoDisabledInRegistry() { + DWORD value = 0; + return ReadOverlayTestMode(value) && value == kOverlayTestModeDisabled; } } // namespace wind diff --git a/src/profile_switch.h b/src/profile_switch.h new file mode 100644 index 00000000..9d0f4d0f --- /dev/null +++ b/src/profile_switch.h @@ -0,0 +1,129 @@ +// Profile switching and "relaunch Wind, put the model back if that fails", shared by the tray +// (WindTray.exe) and the settings host (WindConfig.exe). Win32: MUST be included AFTER . +// Each surface keeps only its own messages and logging; the steps and their order live here once. +#pragma once +#include +#include +#include "config.h" // ParseConfig: the canonical model of a text +#include "config_path.h" // ExeDir +#include "ini_edit.h" // UpdateIniText +#include "profiles.h" // MakeLiveText, ProfileTextError +#include "profiles_io.h" // IniWriteLock, ReadLiveIni, WriteTextFileAtomic, SessionKeepPath + +namespace wind { + +// session.keep tells the next Wind start to keep the unsaved session (see ResetSessionToProfile). +// Written right before a restart Wind's own tools trigger, removed again when that restart fails. +inline void WriteSessionKeep() { + HANDLE h = CreateFileW(SessionKeepPath().c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (h != INVALID_HANDLE_VALUE) CloseHandle(h); +} +inline void ClearSessionKeep() { DeleteFileW(SessionKeepPath().c_str()); } + +// Launch the Wind.exe next to this exe (never our own exe: this runs in WindTray and WindConfig). +// Launching while Wind is already running is how a restart works: the new instance finds the +// single-instance mutex held, sets the quit event and waits for the incumbent to exit cleanly. A +// shell launch, so the child gets an ordinary token. Returns ShellExecuteW's result (success is +// > 32), or 0 when Wind.exe is missing. +inline INT_PTR LaunchWindExe() { + const std::wstring exe = ExeDir() + L"\\Wind.exe"; + if (GetFileAttributesW(exe.c_str()) == INVALID_FILE_ATTRIBUTES) return 0; + return reinterpret_cast(ShellExecuteW(nullptr, L"open", exe.c_str(), nullptr, + ExeDir().c_str(), SW_SHOWNORMAL)); +} + +// A relaunch for a model change failed: keep "ini model == running model" by writing the old model +// back (an empty one means the key was unset, which reads as hybrid) and dropping session.keep. +// Re-reads the live ini, since another surface may have written since; `fallbackLive` (may be +// null) is used when that read fails. +inline void RevertModelAfterFailedRelaunch(const std::wstring& ini, const std::string& oldModel, + const std::string* fallbackLive) { + ClearSessionKeep(); + IniWriteLock iniLock; // review item 71; a mutex, so a caller already holding it is fine + std::string cur; + if (!ReadLiveIni(ini, cur)) { + if (!fallbackLive) return; + cur = *fallbackLive; + } + WriteTextFileAtomic(ini, UpdateIniText(cur, "model", oldModel.empty() ? "hybrid" : oldModel)); +} + +enum class ProfileSwitchError { + None, + FileMissing, // the profile's file does not exist + ReadFailed, // the profile's file exists but could not be read + Corrupt, // ProfileTextError rejected it (`reason` holds the text) + LiveUnreadable, // the live ini could not be read + WriteFailed, // the live ini could not be written +}; +struct ProfileSwitchResult { + ProfileSwitchError error = ProfileSwitchError::None; + std::string reason; // ProfileTextError's text when error == Corrupt + DWORD lastError = 0; // GetLastError() of the failed step + std::string oldModel, newModel; + bool relaunched = false; // the model changed on a single-engine Wind: Wind.exe was launched + bool relaunchFailed = false; // ...and that failed, so the model was put back (the switch stays) + INT_PTR launchRc = 0; // LaunchWindExe's result when relaunched +}; + +// Switch the live ini to the profile in `profPath` (named `nameUtf8`), globals preserved. The +// core's dir-watch hot-reloads everything, `model` included when both engines are up (running.model +// "live"). With one engine left `model` is read once at launch, so a model change relaunches Wind +// (session.keep first, so the restart keeps the session) and puts the old model back if that fails. +// +// A read failure never masquerades as an empty profile (empty means factory defaults by design), and +// corrupt content is never applied. `mirrorOutgoing` first captures the live ini into the OUTGOING +// profile (hand edits made via "Edit config file" would otherwise be lost); a create-from-current +// passes false, because its unsaved changes belong to the new profile. +inline ProfileSwitchResult SwitchProfile(const std::wstring& ini, const std::wstring& profPath, + const std::string& nameUtf8, bool mirrorOutgoing) { + ProfileSwitchResult r; + if (GetFileAttributesW(profPath.c_str()) == INVALID_FILE_ATTRIBUTES) { + r.error = ProfileSwitchError::FileMissing; + return r; + } + std::string profText; + if (!ReadTextFileOk(profPath, profText)) { + r.lastError = GetLastError(); + r.error = ProfileSwitchError::ReadFailed; + return r; + } + r.reason = ProfileTextError(profText); + if (!r.reason.empty()) { + r.error = ProfileSwitchError::Corrupt; + return r; + } + IniWriteLock iniLock; // held to the end, rollback included (review item 71) + std::string oldLive; + if (!ReadLiveIni(ini, oldLive)) { + r.lastError = GetLastError(); + r.error = ProfileSwitchError::LiveUnreadable; + return r; + } + if (mirrorOutgoing) MirrorLiveToActiveProfile(ini, oldLive); + const std::string newLive = MakeLiveText(profText, oldLive, nameUtf8); + // WriteTextFileAtomic checks the write and the replace itself; its result is the verdict (a + // re-read can fail on its own and would report a successful switch as a failed one). + if (!WriteTextFileAtomic(ini, newLive)) { + r.lastError = GetLastError(); + r.error = ProfileSwitchError::WriteFailed; + return r; + } + // ParseConfig canonicalizes (unknown or retired models -> hybrid), so every surface agrees on + // whether the model changed. + r.oldModel = ParseConfig(oldLive).model; + r.newModel = ParseConfig(newLive).model; + r.relaunched = r.oldModel != r.newModel && !EngineSwitchesLive(); + if (r.relaunched) { + WriteSessionKeep(); // our own restart: the start must not reset the session + r.launchRc = LaunchWindExe(); + if (r.launchRc <= 32) { + r.relaunchFailed = true; + RevertModelAfterFailedRelaunch(ini, r.oldModel, &newLive); + } + } + return r; +} + +} // namespace wind diff --git a/src/profiles.cpp b/src/profiles.cpp index 88e40d1e..d907a3d3 100644 --- a/src/profiles.cpp +++ b/src/profiles.cpp @@ -9,17 +9,10 @@ static std::string lower(const std::string& s) { for (auto& c : o) if (c >= 'A' && c <= 'Z') c += 32; return o; } -static std::string trim(const std::string& s) { - size_t a = s.find_first_not_of(" \t\r\n"); - if (a == std::string::npos) return ""; - size_t b = s.find_last_not_of(" \t\r\n"); - return s.substr(a, b - a + 1); -} bool IsGlobalProfileKey(const std::string& key) { - return key == "profile" || key == "onboarded" || key == "uiTheme" || key == "uiPalette" || key == "showAdvanced" || - key == "trayPerf" || key == "traySliders" || key == "traySliderOrder" || - key == "trayToggles" || key == "trayToggleOrder" || key == "trayPinned" || - key == "uiHighResNoticeOff"; + if (key == "profile") return true; + for (const char* k : kUiOnlyKeys) if (key == k) return true; + return false; } std::string ProfileNameError(const std::string& name) { if (name.empty()) return "Name cannot be empty"; @@ -53,11 +46,11 @@ static std::string StripGlobalKeyLines(const std::string& text) { std::istringstream in(text); std::string line, out; while (std::getline(in, line)) { - std::string t = trim(line); + std::string t = TrimWs(line); bool drop = false; if (!t.empty() && t[0] != ';' && t[0] != '#') { size_t eq = t.find('='); - if (eq != std::string::npos && IsGlobalProfileKey(trim(t.substr(0, eq)))) drop = true; + if (eq != std::string::npos && IsGlobalProfileKey(TrimWs(t.substr(0, eq)))) drop = true; } if (!drop) out += line + "\n"; } @@ -68,8 +61,7 @@ std::string MakeLiveText(const std::string& profileText, const std::string& oldL const std::string& name) { std::string out = StripGlobalKeyLines(profileText); auto oldVals = ReadIniValues(oldLiveText); - for (const char* k : {"onboarded", "uiTheme", "uiPalette", "showAdvanced", "trayPerf", "traySliders", - "traySliderOrder", "trayToggles", "trayToggleOrder", "trayPinned", "uiHighResNoticeOff"}) { + for (const char* k : kUiOnlyKeys) { auto it = oldVals.find(k); if (it != oldVals.end()) out = UpdateIniText(out, k, it->second); } @@ -84,7 +76,7 @@ std::string ProfileTextError(const std::string& text) { std::istringstream in(text); std::string line; while (std::getline(in, line)) { - std::string t = trim(line); + std::string t = TrimWs(line); if (!t.empty() && t[0] != ';' && t[0] != '#') return "Profile file is not a Wind profile"; } return ""; diff --git a/src/profiles.h b/src/profiles.h index dc8ecfa4..2c69ed1f 100644 --- a/src/profiles.h +++ b/src/profiles.h @@ -5,9 +5,17 @@ #include #include namespace wind { -// Machine/app state that never travels with a profile: the active-profile pointer itself, -// the onboarding flag, the UI-only theme/palette/advanced toggles (uiTheme, uiPalette, showAdvanced), the five tray-menu keys (#313), -// trayPinned (#436) and uiHighResNoticeOff (#441). +// THE list of UI-only keys: the core never reads them, they never travel with a profile, and a +// change to one never reloads the core (StripUiOnlyKeys). The onboarding flag, the theme/palette/ +// advanced toggles, the five tray-menu keys (#313), trayPinned (#436) and uiHighResNoticeOff (#441). +// Add a new UI-only key here and to tests/fixtures/global_keys.txt (which the UI's GLOBAL_KEYS in +// ui/src/session.js is tested against); every C++ site loops over this array. +inline constexpr const char* kUiOnlyKeys[] = { + "onboarded", "uiTheme", "uiPalette", "showAdvanced", "trayPerf", "traySliders", "traySliderOrder", + "trayToggles", "trayToggleOrder", "trayPinned", "uiHighResNoticeOff", +}; +// Machine/app state that never travels with a profile: the active-profile pointer `profile` itself +// plus every kUiOnlyKeys entry. bool IsGlobalProfileKey(const std::string& key); // "" when the (already-trimmed) name is a valid profile name, else a short user-facing reason. std::string ProfileNameError(const std::string& name); diff --git a/src/profiles_io.h b/src/profiles_io.h index 690b6482..9f040fe5 100644 --- a/src/profiles_io.h +++ b/src/profiles_io.h @@ -11,6 +11,8 @@ #include "profiles.h" #include "ini_edit.h" #include "config.h" // MigrateWheelMods (pure) +#include "config_path.h" // UserDataDir +#include "tray_ipc.h" // kIniWriteMutexName namespace wind { inline std::wstring ProfilesDirFromIni(const std::wstring& iniPath) { size_t slash = iniPath.find_last_of(L"\\/"); @@ -142,7 +144,7 @@ inline bool WriteTextFileAtomic(const std::wstring& path, const std::string& tex class IniWriteLock { public: explicit IniWriteLock(DWORD waitMs = 3000) { - h_ = CreateMutexW(nullptr, FALSE, L"Local\\Wind_IniWrite"); + h_ = CreateMutexW(nullptr, FALSE, kIniWriteMutexName); if (h_) { const DWORD r = WaitForSingleObject(h_, waitMs); owned_ = (r == WAIT_OBJECT_0 || r == WAIT_ABANDONED); @@ -255,20 +257,12 @@ inline IniMigrationResult MigrateIniFiles(const std::wstring& iniPath) { } // %LOCALAPPDATA%\Wind\session.keep: written by the host right before a restart Wind triggers itself // (engine change, profile switch with a model change) so the next start keeps the unsaved session. -inline std::wstring SessionKeepPath() { - wchar_t buf[MAX_PATH]; - DWORD n = GetEnvironmentVariableW(L"LOCALAPPDATA", buf, MAX_PATH); - std::wstring base = (n == 0 || n >= MAX_PATH) ? std::wstring(L".") : std::wstring(buf, n); - return base + L"\\Wind\\session.keep"; -} +inline std::wstring SessionKeepPath() { return UserDataDir() + L"\\session.keep"; } // %LOCALAPPDATA%\Wind\running.model: what the Wind process can run, written at its start. "live" = // both engines are up, so any `model` applies on the config reload; "render" / "transform" = only // that engine started (the other failed to init), so an engine change needs a relaunch. Read by the // settings host and the tray to decide whether a `model` change restarts Wind. -inline std::wstring RunningModelPath() { - std::wstring p = SessionKeepPath(); - return p.substr(0, p.size() - 12) + L"running.model"; // 12 = len("session.keep") -} +inline std::wstring RunningModelPath() { return UserDataDir() + L"\\running.model"; } // True when the running Wind applies a `model` change live (running.model says "live"). A stale // file from a Wind that has exited also reads live, which is right: the next start loads the ini. inline bool EngineSwitchesLive() { diff --git a/src/transform_model.cpp b/src/transform_model.cpp index 0215b788..0f17c686 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -4,6 +4,7 @@ #include "logging.h" #include "sprite_layer.h" // PickSpriteLayer (pure, tested): issue #269 #include "config_path.h" // ResolveLogDir +#include "uiaccess_token.h" // ProcessHasUiAccess #include "tick_span.h" // per-tick spans (#361) #include "native_cursor.h" // NudgeAfterWrite, HoldInputPublish (pure, tested): issue #369 #include "dwm_watch.h" // DwmGeneration: re-apply DWM-held state after a restart (#396) @@ -182,20 +183,11 @@ bool TransformModel::initialize(const MonitorTarget& monitor) { // Input-transform availability (issue #185): MagSetInputTransform's ENABLED publish needs // UIAccess, and the hybrid DESKTOP pick must know availability BEFORE any session exists (a // transform desktop session without it has the pointer-framework dead zones). Read the - // process token's UIAccess bit directly - ZERO Magnification calls at startup. Two probe - // shapes are BANNED here (self-review, rig-measured): a DISABLED MagSetInputTransform - // succeeds WITHOUT UIAccess (false positive), and any Mag acquire/release at startup runs - // MagHost::shutdown's identity transform WRITE - violating the no-warm-up law above and - // resetting a running native Magnifier's zoom. The in-session enabled publish remains the + // process token's UIAccess bit directly - ZERO Magnification calls at startup (the banned + // probe shapes are listed in uiaccess_token.h). The in-session enabled publish remains the // authority: its first failure clears this flag (self-heal in present()). { - HANDLE tok = nullptr; - if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &tok)) { - DWORD uiAccess = 0, len = 0; - if (GetTokenInformation(tok, TokenUIAccess, &uiAccess, sizeof(uiAccess), &len)) - inputTransformAvailable_ = uiAccessProbe_ = uiAccess != 0; - CloseHandle(tok); - } + inputTransformAvailable_ = uiAccessProbe_ = wind::ProcessHasUiAccess(); wind::Log(wind::LogLevel::Info, "transform", "input transform %s (token UIAccess=%d)", inputTransformAvailable_ ? "AVAILABLE" : "unavailable", inputTransformAvailable_ ? 1 : 0); diff --git a/src/tray_app/engine_dropdown.cpp b/src/tray_app/engine_dropdown.cpp index 12aebe65..b95dede7 100644 --- a/src/tray_app/engine_dropdown.cpp +++ b/src/tray_app/engine_dropdown.cpp @@ -15,20 +15,13 @@ #include "../logging.h" #include "../config_path.h" #include "../profiles_io.h" +#include "../profile_switch.h" // WriteSessionKeep, LaunchWindExe, RevertModelAfterFailedRelaunch #include "../tray_items.h" // IniValues #include "../ini_edit.h" #include namespace wind { namespace TrayApp { -namespace { -void WriteSessionKeep() { - HANDLE h = CreateFileW(wind::SessionKeepPath().c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, - FILE_ATTRIBUTE_NORMAL, nullptr); - if (h != INVALID_HANDLE_VALUE) CloseHandle(h); -} -} // namespace - bool SetMainEngine(const std::wstring& ini, int picked, const std::map& carry) { std::string oldModel; const char* value = Flyout::EngineValue(picked); @@ -64,25 +57,15 @@ bool SetMainEngine(const std::wstring& ini, int picked, const std::map %s (restarting Wind)", oldModel.empty() ? "(unset)" : oldModel.c_str(), value); - WriteSessionKeep(); // our own restart: the unsaved session (this very change) must survive it - const std::wstring exe = AppDir() + L"\\Wind.exe"; - const bool haveExe = GetFileAttributesW(exe.c_str()) != INVALID_FILE_ATTRIBUTES; - const INT_PTR rc = haveExe - ? reinterpret_cast(ShellExecuteW(nullptr, L"open", exe.c_str(), nullptr, AppDir().c_str(), - SW_SHOWNORMAL)) - : 0; + wind::WriteSessionKeep(); // our own restart: the unsaved session (this very change) must survive it + const INT_PTR rc = wind::LaunchWindExe(); if (rc > 32) return true; // Keep "ini model == running model" (the same rule as the Settings restartFailed path and the // profile switch): put the old value back and drop the marker. - wind::Log(wind::LogLevel::Warn, "tray", "engine pick: relaunch FAILED (rc=%lld haveExe=%d); reverting model", - static_cast(rc), (int)haveExe); - DeleteFileW(wind::SessionKeepPath().c_str()); - wind::IniWriteLock iniLock; // item 71 - std::string cur; - if (wind::ReadLiveIni(ini, cur)) - wind::WriteTextFileAtomic(ini, oldModel.empty() ? wind::UpdateIniText(cur, Flyout::kEngineKey, "hybrid") - : wind::UpdateIniText(cur, Flyout::kEngineKey, oldModel)); + wind::Log(wind::LogLevel::Warn, "tray", "engine pick: relaunch FAILED (rc=%lld); reverting model", + static_cast(rc)); + wind::RevertModelAfterFailedRelaunch(ini, oldModel, nullptr); Notify(L"Wind", L"Could not restart Wind; kept the current engine."); return false; } diff --git a/src/tray_app/flyout_tools.h b/src/tray_app/flyout_tools.h index b9a05195..f7f00930 100644 --- a/src/tray_app/flyout_tools.h +++ b/src/tray_app/flyout_tools.h @@ -5,6 +5,7 @@ // Spec: docs/specs/2026-10-02-tray-tools-design.md. #include #include +#include "../ini_edit.h" // TrimWs namespace wind { namespace Flyout { @@ -24,10 +25,6 @@ inline const wchar_t* EngineLabel(int i) { switch (i) { case 1: return L"Render"; case 2: return L"Transform"; default: return L"Auto"; } } -inline std::string TrimWs(const std::string& s) { - const size_t b = s.find_first_not_of(" \t\r\n"), e = s.find_last_not_of(" \t\r\n"); - return b == std::string::npos ? std::string() : s.substr(b, e - b + 1); -} // The option a `model` value means. Missing or unknown reads as Auto, exactly like the core's config // parse (src/config.cpp falls back to "hybrid"). inline int EngineIndex(const std::string& model) { diff --git a/src/tray_app/main.cpp b/src/tray_app/main.cpp index c391460d..d6be6143 100644 --- a/src/tray_app/main.cpp +++ b/src/tray_app/main.cpp @@ -18,15 +18,13 @@ namespace wind { namespace TrayApp { static TrayShared* g_block = nullptr; -static std::wstring g_appDir; TrayShared* Block() { return g_block; } -std::wstring AppDir() { return g_appDir; } void RequestWindQuit() { // The same clean-exit path the installer and Settings use: Wind restores cursor, clip and // Magnifier state as on any quit. A window message could not reach Wind anyway (UIPI). - HANDLE ev = OpenEventW(EVENT_MODIFY_STATE, FALSE, L"Local\\Wind_QuitRequest"); + HANDLE ev = OpenEventW(EVENT_MODIFY_STATE, FALSE, kQuitEventName); if (ev) { SetEvent(ev); CloseHandle(ev); } else wind::Log(wind::LogLevel::Warn, "tray", "quit: event open failed (err=%lu)", GetLastError()); } @@ -108,11 +106,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR cmdLine, int) { wind::LogInit(L"tray"); const DWORD windPid = ParseWindPid(cmdLine); - wchar_t exe[MAX_PATH]; - if (GetModuleFileNameW(nullptr, exe, MAX_PATH)) { - wchar_t* slash = wcsrchr(exe, L'\\'); - if (slash) { *slash = L'\0'; TrayApp::g_appDir = exe; SetCurrentDirectoryW(exe); } - } + if (!wind::ExeDir().empty()) SetCurrentDirectoryW(wind::ExeDir().c_str()); // The Wind we serve. No PID or a dead PID: there is nothing to show a tray for. HANDLE hWind = windPid ? OpenProcess(SYNCHRONIZE, FALSE, windPid) : nullptr; diff --git a/src/tray_app/tray_app.h b/src/tray_app/tray_app.h index 6f8cc55e..22fc76a3 100644 --- a/src/tray_app/tray_app.h +++ b/src/tray_app/tray_app.h @@ -49,7 +49,6 @@ void OpenSettings(); // main.cpp TrayShared* Block(); // the shared status block, or nullptr when Wind did not create one -std::wstring AppDir(); // the folder holding WindTray.exe, Wind.exe and WindConfig.exe void RequestWindQuit(); // sets Local\Wind_QuitRequest: Wind's clean-exit path } // namespace TrayApp diff --git a/src/tray_app/tray_menu.cpp b/src/tray_app/tray_menu.cpp index b3ab3460..1e77466e 100644 --- a/src/tray_app/tray_menu.cpp +++ b/src/tray_app/tray_menu.cpp @@ -6,6 +6,7 @@ #include "../logging.h" #include "../config_path.h" #include "../profiles_io.h" +#include "../profile_switch.h" // SwitchProfile: shared with Settings #include "../config.h" #include "../ini_edit.h" #include "../tray_ipc.h" @@ -25,8 +26,8 @@ int UsesPalette(const std::string& iniText) { } void OpenSettings() { - ShellExecuteW(nullptr, L"open", (AppDir() + L"\\WindConfig.exe").c_str(), nullptr, - AppDir().c_str(), SW_SHOW); + ShellExecuteW(nullptr, L"open", (ExeDir() + L"\\WindConfig.exe").c_str(), nullptr, + ExeDir().c_str(), SW_SHOW); } // Unsaved-settings guard for Quit and for a profile switch: the tray decides from the files (live @@ -93,77 +94,47 @@ static bool ConfirmUnsaved(const std::wstring& ini, bool forSwitch) { bool ConfirmQuit(const std::wstring& ini) { return ConfirmUnsaved(ini, false); } bool ConfirmSwitch(const std::wstring& ini) { return ConfirmUnsaved(ini, true); } -// Switch the active profile from the tray: rewrite the live ini from the profile file (globals -// preserved); the core's dir-watch hot-reloads everything, `model` included when both engines are up -// (running.model "live"). With one engine left `model` is read once at launch, and a model change relaunches Wind.exe from our folder (the new instance evicts the running -// one via the single-instance handshake, same as the swap-model path in Wind's main.cpp). +// Switch the active profile from the tray. The steps, the relaunch for a single-engine Wind and the +// rollback are shared with Settings (profile_switch.h); this keeps the tray's balloons and logs. void SwitchToProfile(const std::wstring& ini, const std::wstring& nameW) { // Every step logs (issue #184: a field switch failed with no trace - the balloon is // transient, the log is not). wind::Log(wind::LogLevel::Info, "profile", "tray switch -> %ls", nameW.c_str()); const std::wstring profPath = wind::ProfilesDirFromIni(ini) + L"\\" + nameW + L".ini"; - if (GetFileAttributesW(profPath.c_str()) == INVALID_FILE_ATTRIBUTES) { + const wind::ProfileSwitchResult r = wind::SwitchProfile(ini, profPath, wind::NarrowUtf8(nameW), true); + switch (r.error) { + case wind::ProfileSwitchError::None: break; + case wind::ProfileSwitchError::FileMissing: wind::Log(wind::LogLevel::Warn, "profile", "switch aborted: file missing"); Notify(L"Wind", L"That profile's file is missing; settings unchanged."); return; - } - // A read failure must NOT masquerade as an empty profile: empty legitimately means factory - // defaults, so silently treating a locked/corrupt file as empty would wipe the live settings. - std::string profText; - if (!wind::ReadTextFileOk(profPath, profText)) { - wind::Log(wind::LogLevel::Warn, "profile", "switch aborted: read failed (err=%lu)", GetLastError()); + case wind::ProfileSwitchError::ReadFailed: + wind::Log(wind::LogLevel::Warn, "profile", "switch aborted: read failed (err=%lu)", r.lastError); Notify(L"Wind", L"Could not read that profile's file; settings unchanged."); return; - } - { std::string terr = wind::ProfileTextError(profText); - if (!terr.empty()) { - wind::Log(wind::LogLevel::Warn, "profile", "switch aborted: %s", terr.c_str()); + case wind::ProfileSwitchError::Corrupt: + wind::Log(wind::LogLevel::Warn, "profile", "switch aborted: %s", r.reason.c_str()); Notify(L"Wind", L"That profile's file looks corrupt; settings unchanged."); return; - } } - wind::IniWriteLock iniLock; // held to the end, rollback included (review item 71) - std::string oldLive; - if (!wind::ReadLiveIni(ini, oldLive)) { + case wind::ProfileSwitchError::LiveUnreadable: wind::Log(wind::LogLevel::Warn, "profile", "switch aborted: live ini unreadable"); Notify(L"Wind", L"Could not switch profile (config file is locked)."); return; - } - // Capture hand edits (openIni) into the outgoing profile before the live ini is replaced. - wind::MirrorLiveToActiveProfile(ini, oldLive); - const std::string newLive = wind::MakeLiveText(profText, oldLive, wind::NarrowUtf8(nameW)); - if (!wind::WriteTextFileAtomic(ini, newLive)) { - wind::Log(wind::LogLevel::Warn, "profile", "switch aborted: live ini write failed (err=%lu)", GetLastError()); + case wind::ProfileSwitchError::WriteFailed: + wind::Log(wind::LogLevel::Warn, "profile", "switch aborted: live ini write failed (err=%lu)", r.lastError); Notify(L"Wind", L"Could not switch profile (config file is locked)."); return; } - // A single-engine Wind cannot swap the model: relaunch so the new instance boots on the profile's model. - const std::string oldModel = wind::ParseConfig(oldLive).model; - const std::string newModel = wind::ParseConfig(newLive).model; - const bool relaunch = oldModel != newModel && !wind::EngineSwitchesLive(); wind::Log(wind::LogLevel::Info, "profile", "switch applied: model %s -> %s%s", - oldModel.c_str(), newModel.c_str(), relaunch ? " (relaunching)" : " (hot)"); - if (relaunch) { - // Wind.exe, NOT our own exe (GetModuleFileNameW here is WindTray.exe). - const std::wstring exe = AppDir() + L"\\Wind.exe"; - const bool haveExe = GetFileAttributesW(exe.c_str()) != INVALID_FILE_ATTRIBUTES; - INT_PTR rc = haveExe - ? reinterpret_cast(ShellExecuteW(nullptr, L"open", exe.c_str(), nullptr, - AppDir().c_str(), SW_SHOWNORMAL)) - : 0; - if (rc > 32) { - Notify(L"Wind", (L"Switched to \"" + nameW + L"\" (restarting for its model).").c_str()); - } else { - // Keep the "ini model == running model" invariant (same rule as the Settings - // restartFailed path): the switch stays, only the model is kept. - wind::Log(wind::LogLevel::Warn, "profile", - "relaunch FAILED (rc=%lld haveExe=%d); reverting model to %s", - static_cast(rc), (int)haveExe, oldModel.c_str()); - // Re-read: Settings or the flyout may have written since newLive was built. - std::string cur; - if (!wind::ReadLiveIni(ini, cur)) cur = newLive; - wind::WriteTextFileAtomic(ini, wind::UpdateIniText(cur, "model", oldModel)); - Notify(L"Wind", L"Profile switched; kept the current model (restart failed)."); - } + r.oldModel.c_str(), r.newModel.c_str(), r.relaunched ? " (relaunching)" : " (hot)"); + if (!r.relaunched) return; + if (!r.relaunchFailed) { + Notify(L"Wind", (L"Switched to \"" + nameW + L"\" (restarting for its model).").c_str()); + } else { + // The switch stays, only the model is kept (the same rule as the Settings restartFailed path). + wind::Log(wind::LogLevel::Warn, "profile", "relaunch FAILED (rc=%lld); reverted model to %s", + static_cast(r.launchRc), r.oldModel.c_str()); + Notify(L"Wind", L"Profile switched; kept the current model (restart failed)."); } } diff --git a/src/tray_ipc.h b/src/tray_ipc.h index 8b4a1c7d..3ecadfa5 100644 --- a/src/tray_ipc.h +++ b/src/tray_ipc.h @@ -7,6 +7,7 @@ // popup menu above the cursor sprite and above the Snipping Tool overlay (measured 2026-09-29). // The tray icon and its menu live in WindTray.exe, which runs WITHOUT UIAccess, so its menu is an // ordinary app menu. This block is the only coupling between the two besides the quit event. +// The names of every kernel object the binaries share live here too (kQuitEventName and others). // // Writers: Wind writes everything except `menuOpen`; the tray writes only `menuOpen`. Every field // is a lock-free atomic, so a reader in the other process never needs a lock, and a torn pairing @@ -19,8 +20,19 @@ namespace wind { +// Every named kernel object Wind's binaries share. All are Local\ (session-scoped; UAC elevation +// stays in the session, so the elevated installer sees the user's objects). Copies outside C++ must +// match these exactly: installer/app.nsh (WIND_MUTEX, WIND_QUIT), tools/testenv/lib.ps1 (Stop-Wind), +// tools/gpu_ab.ps1 (QuitWind) and tools/zoom_response_ab.ps1 (the status block). inline constexpr const wchar_t* kTrayBlockName = L"Local\\Wind_TrayState_v1"; inline constexpr const wchar_t* kTrayMutexName = L"Local\\Wind_Tray"; +// Wind.exe's single-instance mutex. +inline constexpr const wchar_t* kInstanceMutexName = L"Local\\Wind_Magnifier_SingleInstance"; +// Auto-reset event Wind waits on: set it to make Wind quit cleanly (tray, Settings, installer, and a +// successor Wind evicting the incumbent). +inline constexpr const wchar_t* kQuitEventName = L"Local\\Wind_QuitRequest"; +// Serialises read-modify-write of the live ini across processes (profiles_io.h IniWriteLock). +inline constexpr const wchar_t* kIniWriteMutexName = L"Local\\Wind_IniWrite"; struct TrayShared { static constexpr uint32_t kMagic = 0x59525457u; // "WTRY" diff --git a/src/tray_items.cpp b/src/tray_items.cpp index 1efa3368..fed27dcb 100644 --- a/src/tray_items.cpp +++ b/src/tray_items.cpp @@ -1,4 +1,5 @@ #include "tray_items.h" +#include "ini_edit.h" // TrimWs #include namespace wind { const std::vector& EligibleSliders() { @@ -11,17 +12,13 @@ const std::vector& EligibleToggles() { return k; } -static std::string Trim(const std::string& s) { - size_t b = s.find_first_not_of(" \t\r\n"), e = s.find_last_not_of(" \t\r\n"); - return b == std::string::npos ? std::string() : s.substr(b, e - b + 1); -} static std::vector SplitList(const std::string& s) { std::vector out; size_t pos = 0; while (pos <= s.size()) { size_t c = s.find(',', pos); if (c == std::string::npos) c = s.size(); - std::string t = Trim(s.substr(pos, c - pos)); + std::string t = TrimWs(s.substr(pos, c - pos)); if (!t.empty()) out.push_back(t); pos = c + 1; } @@ -73,7 +70,7 @@ static std::string Join(const std::vector& items, bool onlyEnabled) { TrayLayout ParseTrayLayout(const IniValues& v) { TrayLayout l; const std::string* p = Find(v, "trayPerf"); - l.perf = !p || Trim(*p) == "1"; // shown by default (#329); an explicit 0 hides it + l.perf = !p || TrimWs(*p) == "1"; // shown by default (#329); an explicit 0 hides it l.sliders = ParseList(v, "traySliders", "traySliderOrder", EligibleSliders(), {"colorWarmPct", "colorDimPct"}, kMaxTraySliders); // Every toggle item is on by default (#329): follow caret, follow focus, keep centred, engine. diff --git a/src/uiaccess_token.h b/src/uiaccess_token.h new file mode 100644 index 00000000..09048701 --- /dev/null +++ b/src/uiaccess_token.h @@ -0,0 +1,22 @@ +// The process token's UIAccess bit (Win32). MUST be included AFTER . The pure start-up +// rule that uses it is uiaccess_gate.h; this is the one place Wind reads the bit (issues #185, #460). +// +// Two probe shapes are BANNED for this question (self-review, rig-measured): a DISABLED +// MagSetInputTransform succeeds WITHOUT UIAccess (false positive), and any Mag acquire/release at +// startup runs MagHost::shutdown's identity transform WRITE, which violates the no-warm-up rule and +// resets a running native Magnifier's zoom. Reading the token makes ZERO Magnification calls. +#pragma once +namespace wind { + +// True when this process runs with UIAccess. A token that cannot be opened or read counts as none. +inline bool ProcessHasUiAccess() { + DWORD uiAccess = 0, len = 0; + HANDLE tok = nullptr; + if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &tok)) { + if (!GetTokenInformation(tok, TokenUIAccess, &uiAccess, sizeof(uiAccess), &len)) uiAccess = 0; + CloseHandle(tok); + } + return uiAccess != 0; +} + +} // namespace wind diff --git a/tests/fixtures/global_keys.txt b/tests/fixtures/global_keys.txt new file mode 100644 index 00000000..c3ca163a --- /dev/null +++ b/tests/fixtures/global_keys.txt @@ -0,0 +1,16 @@ +# The profile-global ini keys, one per line: never stored in a profile, and every key but `profile` +# is UI-only (the core never reads it, StripUiOnlyKeys drops it so editing it never reloads Wind). +# Must equal kUiOnlyKeys + "profile" (src/profiles.h) and GLOBAL_KEYS (ui/src/session.js). +# Read by tests/test_profiles.cpp and ui/tests/global-keys.nodetest.mjs. +profile +onboarded +uiTheme +uiPalette +showAdvanced +trayPerf +traySliders +traySliderOrder +trayToggles +trayToggleOrder +trayPinned +uiHighResNoticeOff diff --git a/tests/test_profiles.cpp b/tests/test_profiles.cpp index 0228de5d..67430c73 100644 --- a/tests/test_profiles.cpp +++ b/tests/test_profiles.cpp @@ -2,8 +2,36 @@ #include "../src/profiles.h" #include "../src/ini_edit.h" #include "../src/config.h" +#include +#include +#include using namespace wind; +// tests/fixtures/global_keys.txt is the cross-language copy of the global keys: this test pins it to +// kUiOnlyKeys + "profile", and ui/tests/global-keys.nodetest.mjs pins the UI's GLOBAL_KEYS to it. +TEST_CASE("global keys: kUiOnlyKeys + profile match the shared fixture the UI is tested against") { + std::string here = __FILE__; + const size_t cut = here.find_last_of("/\\"); + std::vector tries; + if (cut != std::string::npos) tries.push_back(here.substr(0, cut) + "/fixtures/global_keys.txt"); + for (const char* up : { "", "../", "../../", "../../../" }) + tries.push_back(std::string(up) + "tests/fixtures/global_keys.txt"); + std::ifstream f; + for (const std::string& p : tries) { f.open(p); if (f.good()) break; f.clear(); } + REQUIRE(f.good()); + std::set fixture; + std::string line; + while (std::getline(f, line)) { + line = TrimWs(line); + if (line.empty() || line[0] == 0x23) continue; + CHECK(IsGlobalProfileKey(line)); + fixture.insert(line); + } + std::set code = { "profile" }; + for (const char* k : kUiOnlyKeys) code.insert(k); + CHECK(fixture == code); +} + TEST_CASE("global keys are exactly profile/onboarded/uiTheme/uiPalette/showAdvanced") { CHECK(IsGlobalProfileKey("profile")); CHECK(IsGlobalProfileKey("onboarded")); @@ -213,9 +241,7 @@ TEST_CASE("uiHighResNoticeOff is global: stripped from profiles, carried into th CHECK(live.find("a=2") != std::string::npos); } TEST_CASE("StripUiOnlyKeys strips every global key except profile (tray edits must not reload the core)") { - const char* globals[] = { "onboarded", "uiTheme", "uiPalette", "showAdvanced", "trayPerf", "traySliders", - "traySliderOrder", "trayToggles", "trayToggleOrder", "trayPinned", "uiHighResNoticeOff" }; - for (const char* k : globals) { + for (const char* k : kUiOnlyKeys) { REQUIRE(IsGlobalProfileKey(k)); const std::string line = std::string(k) + "=1\n"; CHECK(StripUiOnlyKeys("maxLevel=8\n" + line) == "maxLevel=8\n"); diff --git a/tools/gpu_ab.ps1 b/tools/gpu_ab.ps1 index e3adc938..d9a722a1 100644 --- a/tools/gpu_ab.ps1 +++ b/tools/gpu_ab.ps1 @@ -119,6 +119,7 @@ public static class GA { } public static void StopMotion() { _mrun = false; if (_mt != null) _mt.Join(1000); _mt = null; } public static bool QuitWind() { + // Must match kQuitEventName in src/tray_ipc.h. IntPtr h = OpenEvent(0x0002, false, "Local\\Wind_QuitRequest"); if (h == IntPtr.Zero) return false; bool ok = SetEvent(h); CloseHandle(h); return ok; diff --git a/tools/testenv/lib.ps1 b/tools/testenv/lib.ps1 index 73da81b1..55bcfff0 100644 --- a/tools/testenv/lib.ps1 +++ b/tools/testenv/lib.ps1 @@ -211,6 +211,7 @@ $script:WindExe = 'C:\Program Files\Wind\Wind.exe' function Stop-Wind { if (-not (Get-Process -Name Wind -ErrorAction SilentlyContinue)) { return } try { + # Must match kQuitEventName in src/tray_ipc.h. $ev = [System.Threading.EventWaitHandle]::OpenExisting('Local\Wind_QuitRequest') [void]$ev.Set() } catch { } diff --git a/tools/zoom_response_ab.ps1 b/tools/zoom_response_ab.ps1 index 69708e7d..da49284a 100644 --- a/tools/zoom_response_ab.ps1 +++ b/tools/zoom_response_ab.ps1 @@ -67,6 +67,7 @@ public static class ZR { [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] static extern IntPtr OpenFileMappingW(uint a, bool i, string n); [DllImport("kernel32.dll")] static extern IntPtr MapViewOfFile(IntPtr h, uint a, uint hi, uint lo, UIntPtr n); static IntPtr view = IntPtr.Zero; + // The name must match kTrayBlockName in src/tray_ipc.h; offset 16 is TrayShared::level. public static double Level() { if (view == IntPtr.Zero) { var m = OpenFileMappingW(4, false, "Local\\Wind_TrayState_v1"); view = MapViewOfFile(m, 4, 0, 0, UIntPtr.Zero); } return BitConverter.Int64BitsToDouble(Marshal.ReadInt64(view, 16)); diff --git a/ui/package.json b/ui/package.json index df0b5562..624eea93 100644 --- a/ui/package.json +++ b/ui/package.json @@ -2,7 +2,7 @@ "name": "wind-config-ui", "private": true, "type": "module", - "scripts": { "dev": "vite", "build": "vite build", "test": "playwright test", "test:rules": "node --test tests/keybind-rules.nodetest.mjs tests/config-defaults.nodetest.mjs" }, + "scripts": { "dev": "vite", "build": "vite build", "test": "playwright test", "test:rules": "node --test tests/keybind-rules.nodetest.mjs tests/config-defaults.nodetest.mjs tests/global-keys.nodetest.mjs" }, "devDependencies": { "@playwright/test": "^1.44.0", "@sveltejs/vite-plugin-svelte": "^5.1.1", diff --git a/ui/src/session.js b/ui/src/session.js index d4604d24..ca94c189 100644 --- a/ui/src/session.js +++ b/ui/src/session.js @@ -2,8 +2,9 @@ // profile file is the saved state. No bridge, no DOM, so Playwright can import it directly. import { groups, groupRows, bindKeys } from './settings-schema.js'; -// Mirrors IsGlobalProfileKey in src/profiles.cpp: these are never part of a profile, so they never -// count as an unsaved change. The five tray keys (#313) live in the live ini only. +// Mirrors IsGlobalProfileKey (kUiOnlyKeys + profile, src/profiles.h): these are never part of a +// profile, so they never count as an unsaved change. tests/global-keys.nodetest.mjs pins the two +// lists together through tests/fixtures/global_keys.txt. The five tray keys (#313) live in the live ini only. // uiHighResNoticeOff (#441) is the opt-out of the High resolution cursor notice. export const GLOBAL_KEYS = new Set(['profile', 'onboarded', 'uiTheme', 'uiPalette', 'showAdvanced', 'trayPerf', 'traySliders', 'traySliderOrder', 'trayToggles', 'trayToggleOrder', 'trayPinned', 'uiHighResNoticeOff']); diff --git a/ui/tests/global-keys.nodetest.mjs b/ui/tests/global-keys.nodetest.mjs new file mode 100644 index 00000000..31cd7a1e --- /dev/null +++ b/ui/tests/global-keys.nodetest.mjs @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { GLOBAL_KEYS } from '../src/session.js'; + +// Plain node:test, no browser: run with `npm run test:rules` (CI runs it too). +// GLOBAL_KEYS must be the same set as the C++ kUiOnlyKeys + "profile" (src/profiles.h); both sides +// are checked against this shared fixture. +test('UI global keys match the shared list', () => { + const here = dirname(fileURLToPath(import.meta.url)); + const keys = readFileSync(join(here, '..', '..', 'tests', 'fixtures', 'global_keys.txt'), 'utf8') + .split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#')); + assert.ok(keys.length > 10, `only ${keys.length} keys read`); + assert.deepEqual([...GLOBAL_KEYS].sort(), [...keys].sort()); +}); From 1399da330f1c3115e8c31f1b6b151785c925d0c1 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:40:15 +0200 Subject: [PATCH 4/7] refactor(tools): one version.h reader; WIND_VERSION_STR is tested against the numbers (#470) tools/version.ps1 defines Get-WindVersion. release.ps1, installer_check.ps1 and the release and alpha workflows dot-source it instead of carrying their own regex block. tests/test_version.cpp fails when WIND_VERSION_STR drifts from WIND_VER_MAJOR.MINOR.PATCH (the .rc files need the literal, so it is not stringized). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- .github/workflows/alpha.yml | 8 ++------ .github/workflows/release.yml | 8 ++------ src/version.h | 3 ++- tests/test_version.cpp | 11 +++++++++++ tools/installer_check.ps1 | 7 ++----- tools/release.ps1 | 9 +-------- tools/version.ps1 | 15 +++++++++++++++ 7 files changed, 35 insertions(+), 26 deletions(-) create mode 100644 tests/test_version.cpp create mode 100644 tools/version.ps1 diff --git a/.github/workflows/alpha.yml b/.github/workflows/alpha.yml index 7cc87fd5..c084bbd9 100644 --- a/.github/workflows/alpha.yml +++ b/.github/workflows/alpha.yml @@ -45,12 +45,8 @@ jobs: id: ver shell: pwsh run: | - $h = Get-Content src/version.h -Raw - $maj = [regex]::Match($h, '#define\s+WIND_VER_MAJOR\s+(\d+)').Groups[1].Value - $min = [regex]::Match($h, '#define\s+WIND_VER_MINOR\s+(\d+)').Groups[1].Value - $pat = [regex]::Match($h, '#define\s+WIND_VER_PATCH\s+(\d+)').Groups[1].Value - if (-not $maj) { throw 'could not read WIND_VER_MAJOR from src/version.h' } - $v = "$maj.$min.$pat" + . ./tools/version.ps1 # Get-WindVersion: the one src/version.h reader + $v = Get-WindVersion (Get-Location).Path $sha = '${{ github.sha }}'.Substring(0,7) "version=$v" >> $env:GITHUB_OUTPUT "alpha=$v-alpha.$sha" >> $env:GITHUB_OUTPUT diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab07d4bc..3a849168 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,12 +51,8 @@ jobs: id: ver shell: pwsh run: | - $h = Get-Content src/version.h -Raw - $maj = [regex]::Match($h, '#define\s+WIND_VER_MAJOR\s+(\d+)').Groups[1].Value - $min = [regex]::Match($h, '#define\s+WIND_VER_MINOR\s+(\d+)').Groups[1].Value - $pat = [regex]::Match($h, '#define\s+WIND_VER_PATCH\s+(\d+)').Groups[1].Value - if (-not $maj) { throw 'could not read WIND_VER_MAJOR from src/version.h' } - "version=$maj.$min.$pat" >> $env:GITHUB_OUTPUT + . ./tools/version.ps1 # Get-WindVersion: the one src/version.h reader + "version=$(Get-WindVersion (Get-Location).Path)" >> $env:GITHUB_OUTPUT - name: Install NSIS shell: pwsh diff --git a/src/version.h b/src/version.h index 69c8b6a8..546bfc29 100644 --- a/src/version.h +++ b/src/version.h @@ -6,5 +6,6 @@ #define WIND_VER_MINOR 29 #define WIND_VER_PATCH 3 -// String form for logs/snapshot/UI. Keep in sync with the numeric parts above. +// String form for logs/snapshot/UI. Keep in sync with the numeric parts above (tests/test_version.cpp +// fails when it is not). A literal, not stringized: wind.rc and wind_tray.rc consume it as one string. #define WIND_VERSION_STR "0.29.3" diff --git a/tests/test_version.cpp b/tests/test_version.cpp new file mode 100644 index 00000000..c358dcfa --- /dev/null +++ b/tests/test_version.cpp @@ -0,0 +1,11 @@ +// tests/test_version.cpp - src/version.h: WIND_VERSION_STR is written out by hand (the .rc files +// consume it as one literal, so it is not stringized from the numbers); this keeps it in step. +#include "doctest.h" +#include "../src/version.h" +#include + +TEST_CASE("WIND_VERSION_STR matches WIND_VER_MAJOR.MINOR.PATCH") { + const std::string want = std::to_string(WIND_VER_MAJOR) + "." + std::to_string(WIND_VER_MINOR) + "." + + std::to_string(WIND_VER_PATCH); + CHECK(std::string(WIND_VERSION_STR) == want); +} diff --git a/tools/installer_check.ps1 b/tools/installer_check.ps1 index e9dc9db4..45452378 100644 --- a/tools/installer_check.ps1 +++ b/tools/installer_check.ps1 @@ -97,11 +97,8 @@ if (Test-Path $over) { } # --- 3. silent install and uninstall ------------------------------------------ -$h = Get-Content (Join-Path $root 'src\version.h') -Raw -$version = '{0}.{1}.{2}' -f - [regex]::Match($h, 'WIND_VER_MAJOR\s+(\d+)').Groups[1].Value, - [regex]::Match($h, 'WIND_VER_MINOR\s+(\d+)').Groups[1].Value, - [regex]::Match($h, 'WIND_VER_PATCH\s+(\d+)').Groups[1].Value +. (Join-Path $PSScriptRoot 'version.ps1') # Get-WindVersion: the one src\version.h reader +$version = Get-WindVersion $root $setup = Join-Path $root "dist\Wind-Setup-x64-$version.exe" $elevated = ([Security.Principal.WindowsPrincipal] ` diff --git a/tools/release.ps1 b/tools/release.ps1 index de3b7b0a..8f0594d7 100644 --- a/tools/release.ps1 +++ b/tools/release.ps1 @@ -23,14 +23,7 @@ param([switch]$SkipBuild) $ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot -function Get-WindVersion { - $h = Get-Content "$root\src\version.h" -Raw - $maj = [regex]::Match($h, '#define\s+WIND_VER_MAJOR\s+(\d+)').Groups[1].Value - $min = [regex]::Match($h, '#define\s+WIND_VER_MINOR\s+(\d+)').Groups[1].Value - $pat = [regex]::Match($h, '#define\s+WIND_VER_PATCH\s+(\d+)').Groups[1].Value - if (-not $maj) { throw "could not read WIND_VER_MAJOR from src\version.h" } - "$maj.$min.$pat" -} +. (Join-Path $PSScriptRoot 'version.ps1') # Get-WindVersion: the one src\version.h reader function Get-SigningCert { if ($env:WIND_SIGN_THUMBPRINT) { diff --git a/tools/version.ps1 b/tools/version.ps1 new file mode 100644 index 00000000..bbecb350 --- /dev/null +++ b/tools/version.ps1 @@ -0,0 +1,15 @@ +# The one reader of src/version.h for scripts and CI (release.ps1, installer_check.ps1, the release +# and alpha workflows). Dot-source it, then call Get-WindVersion. installer/wind.nsi reads the same +# three defines with !searchparse. +# +# . "$PSScriptRoot\version.ps1"; Get-WindVersion # -> "0.29.3" + +function Get-WindVersion([string]$Root = (Split-Path -Parent $PSScriptRoot)) { + $h = Get-Content (Join-Path $Root 'src\version.h') -Raw + $parts = foreach ($n in 'MAJOR', 'MINOR', 'PATCH') { + $m = [regex]::Match($h, "#define\s+WIND_VER_$n\s+(\d+)") + if (-not $m.Success) { throw "could not read WIND_VER_$n from src\version.h" } + $m.Groups[1].Value + } + $parts -join '.' +} From f934a7a888f36d3be945feba8da56a1972b4c64a Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:40:15 +0200 Subject: [PATCH 5/7] refactor(testenv): sweep, shimmer_ab and ab_acryl use lib.ps1's Wind and ini helpers (#470) The local Set-Knobs / Set-Mode / Restart-Wind copies rewrote the ini in place and assumed Wind quit within a fixed 2 s. They now use Update-IniKnobs (sanity check, atomic replace), Restart-WindClean and Stop-Wind (wait for the clean exit), keeping the same settle time. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- tools/testenv/ab_acryl.ps1 | 2 +- tools/testenv/shimmer_ab.ps1 | 10 +++------- tools/testenv/sweep.ps1 | 16 +++++++--------- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/tools/testenv/ab_acryl.ps1 b/tools/testenv/ab_acryl.ps1 index b08c13a9..b1b84326 100644 --- a/tools/testenv/ab_acryl.ps1 +++ b/tools/testenv/ab_acryl.ps1 @@ -16,7 +16,7 @@ $allKeys = @($Configs | ForEach-Object { $_.Split(';') } | Where-Object { $_ } | function Use-Config([string]$spec) { Update-IniKnobs $ini $allKeys $spec - try { $ev = [System.Threading.EventWaitHandle]::OpenExisting('Local\Wind_QuitRequest'); [void]$ev.Set(); Start-Sleep 2 } catch {} + Stop-Wind # lib.ps1: waits for the clean exit (run.ps1 starts Wind for the suite) } $rows = @() diff --git a/tools/testenv/shimmer_ab.ps1 b/tools/testenv/shimmer_ab.ps1 index cf09e355..758e2dd2 100644 --- a/tools/testenv/shimmer_ab.ps1 +++ b/tools/testenv/shimmer_ab.ps1 @@ -23,13 +23,9 @@ $allKeys = @($Modes | ForEach-Object { $_.Split(';') } | Where-Object { $_ } | ForEach-Object { $_.Split('=')[0] } | Select-Object -Unique) function Set-Mode([string]$spec) { - $c = Get-Content $ini - foreach ($k in $allKeys) { $c = $c | Where-Object { $_ -notmatch "^$k=" } } - foreach ($kv in $spec.Split(';')) { if ($kv.Trim()) { $c += $kv } } - Set-Content $ini $c - try { $ev = [System.Threading.EventWaitHandle]::OpenExisting('Local\Wind_QuitRequest'); [void]$ev.Set(); Start-Sleep 2 } catch {} - Start-Process 'C:\Program Files\Wind\Wind.exe' - Start-Sleep 3 + Update-IniKnobs $ini $allKeys $spec # lib.ps1: safe read-modify-write, atomic replace + Restart-WindClean # lib.ps1: waits for the clean exit, then starts Wind + Start-Sleep 1 # with Start-Wind's 2 s, the same settle time as before } Start-Tone diff --git a/tools/testenv/sweep.ps1 b/tools/testenv/sweep.ps1 index 764d8751..1e43da22 100644 --- a/tools/testenv/sweep.ps1 +++ b/tools/testenv/sweep.ps1 @@ -20,18 +20,16 @@ $ErrorActionPreference = 'Stop' $ini = "$env:LOCALAPPDATA\Wind\magnifier.ini" $candidates = Get-Content $Plan -Raw | ConvertFrom-Json +# lib.ps1's safe read-modify-write: every knob is cleared, then the non-empty ones are set. function Set-Knobs($knobs) { - $c = Get-Content $ini - foreach ($k in $knobs.PSObject.Properties) { - $c = $c | Where-Object { $_ -notmatch "^$($k.Name)=" } - if ($k.Value -ne '') { $c += "$($k.Name)=$($k.Value)" } - } - Set-Content $ini $c + $names = @($knobs.PSObject.Properties | ForEach-Object { $_.Name }) + $spec = (@($knobs.PSObject.Properties | Where-Object { $_.Value -ne '' } | + ForEach-Object { "$($_.Name)=$($_.Value)" }) -join ';') + Update-IniKnobs $ini $names $spec } function Restart-Wind { - try { $ev = [System.Threading.EventWaitHandle]::OpenExisting('Local\Wind_QuitRequest'); [void]$ev.Set(); Start-Sleep 2 } catch {} - Start-Process 'C:\Program Files\Wind\Wind.exe' - Start-Sleep 3 + Restart-WindClean # lib.ps1: waits for the clean exit, then starts the installed Wind + Start-Sleep 1 # with Start-Wind's 2 s, the same settle time as before } # Pulls the numbers out of a suite's JSON so the log carries values, not just a verdict. function Read-Result($path) { From b7c876879e3dd411e5db65bd3f95fb5ead5f57b3 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:46:38 +0200 Subject: [PATCH 6/7] refactor(settings): window background comes from the generated palettes, tested against themes.css (#470) ThemeBackground hand-copied the four --bg colours. It now reads the generated flyout palette's menu colour (same values), and tests/test_flyout_palettes.cpp checks each one against that theme's --bg in ui/src/design/themes.css, so the two generators cannot drift apart unnoticed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/config_ui/main.cpp | 18 ++++++---------- tests/test_flyout_palettes.cpp | 36 ++++++++++++++++++++++++++++++++ ui/tools/gen-flyout-palettes.cjs | 2 ++ 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/config_ui/main.cpp b/src/config_ui/main.cpp index bdf712c1..76ca4247 100644 --- a/src/config_ui/main.cpp +++ b/src/config_ui/main.cpp @@ -25,6 +25,7 @@ #include "../logging.h" #include "../tray_ipc.h" // kQuitEventName #include "../profile_switch.h" // SwitchProfile, LaunchWindExe, session.keep: shared with the tray +#include "../tray_app/flyout_palettes.h" // the theme colours (generated): the window background #include #include "../resource.h" #pragma comment(lib, "shlwapi.lib") @@ -214,19 +215,12 @@ static std::string UiPaletteOf(const std::string& live) { return wind::NormalizeUiPalette(it == v.end() ? std::string() : it->second); } // Window/WebView background from uiPalette so first paint never flashes the wrong colour. Always dark (#324: -// light mode is gone; uiTheme is an ignored legacy key). Colours are the --bg tokens of -// ui/src/design/themes.css (generated; keep in step when a theme is added): grey, ember, ocean, hicon. +// light mode is gone; uiTheme is an ignored legacy key). The colour is the generated flyout palette's menu +// background, which equals the Settings --bg token (tests/test_flyout_palettes.cpp checks it against +// ui/src/design/themes.css). Unknown or empty is Wind grey. 0xRRGGBB -> COLORREF (0x00BBGGRR). static COLORREF ThemeBackground(const std::string& palette) { - static const COLORREF kBg[4] = { - RGB(0x00, 0x00, 0x00), // grey - RGB(0x0c, 0x09, 0x08), // ember - RGB(0x0c, 0x11, 0x17), // ocean - RGB(0x00, 0x00, 0x00), // hicon - }; - int n = 0; const char* const* ids = wind::UiPaletteIds(n); - int idx = 0; - for (int i = 0; i < n && i < 4; ++i) if (palette == ids[i]) idx = i; - return kBg[idx]; + const unsigned rgb = wind::Flyout::FindPalette(palette).dark.menu; + return RGB((rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff); } static void ApplyWindowTheme() { // A failed read keeps the palette last applied (grey before any), not a flash of the default. diff --git a/tests/test_flyout_palettes.cpp b/tests/test_flyout_palettes.cpp index 9ecbfe57..e7a7dd9c 100644 --- a/tests/test_flyout_palettes.cpp +++ b/tests/test_flyout_palettes.cpp @@ -4,10 +4,46 @@ #include "../third_party/doctest.h" #include "../src/tray_app/flyout_palettes.h" #include "../src/config.h" +#include +#include +#include #include +#include using namespace wind; using namespace wind::Flyout; +// The generated C++ themes and the generated Settings themes must not drift apart: WindConfig paints its +// window with kPalettes[i].dark.menu before the page loads, so it must equal that theme's --bg in +// ui/src/design/themes.css (both generators read the same mockup palettes). +TEST_CASE("flyout palettes: each menu background equals the Settings --bg of the same theme") { + std::string here = __FILE__; + const size_t cut = here.find_last_of("/\\"); + std::vector tries; + if (cut != std::string::npos) tries.push_back(here.substr(0, cut) + "/../ui/src/design/themes.css"); + for (const char* up : { "", "../", "../../", "../../../" }) + tries.push_back(std::string(up) + "ui/src/design/themes.css"); + std::ifstream f; + for (const std::string& p : tries) { f.open(p); if (f.good()) break; f.clear(); } + REQUIRE(f.good()); + std::stringstream ss; ss << f.rdbuf(); + const std::string css = ss.str(); + for (int i = 0; i < kPaletteCount; ++i) { + CAPTURE(kPalettes[i].id); + const size_t block = css.find(std::string(".wnd[data-palette=\"") + kPalettes[i].id + "\"]"); + REQUIRE(block != std::string::npos); + const size_t bg = css.find("--bg:", block); + REQUIRE(bg != std::string::npos); + const size_t hash = css.find('#', bg); + REQUIRE(hash != std::string::npos); + size_t end = hash + 1; + while (end < css.size() && std::isxdigit(static_cast(css[end]))) ++end; + std::string hex = css.substr(hash + 1, end - hash - 1); + if (hex.size() == 3) hex = std::string{hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]}; + REQUIRE(hex.size() == 6); + CHECK(std::stoul(hex, nullptr, 16) == kPalettes[i].dark.menu); + } +} + TEST_CASE("flyout palettes: one row per Settings palette, same ids in the same order") { int n = 0; const char* const* ids = UiPaletteIds(n); diff --git a/ui/tools/gen-flyout-palettes.cjs b/ui/tools/gen-flyout-palettes.cjs index ae8b4cf1..47826427 100644 --- a/ui/tools/gen-flyout-palettes.cjs +++ b/ui/tools/gen-flyout-palettes.cjs @@ -5,6 +5,8 @@ // // node tools/gen-flyout-palettes.cjs [mockup-ia-dir] // +// WindConfig paints its window background from kPalettes[i].dark.menu before the page loads; tests/test_flyout_palettes.cpp +// checks it against each theme's --bg in ui/src/design/themes.css (gen-themes.cjs), so rerun both generators together. // Wind grey keeps the values the flyout already drew (docs/design/tray-2026-10/j01), like Settings keeps today's tokens. const fs = require('fs'); const path = require('path'); From 047165c64d630ebb468685575e04ecb662268e64 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:46:38 +0200 Subject: [PATCH 7/7] docs: point the architecture docs and CLAUDE.md at the consolidated helpers (#470) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- CLAUDE.md | 5 ++-- docs/architecture/01-overview.md | 6 ++-- docs/architecture/02-tick-loop.md | 2 +- docs/architecture/05-transform-engine.md | 2 +- docs/architecture/08-config-profiles.md | 32 ++++++++++++++-------- docs/architecture/09-settings-ui.md | 4 ++- docs/architecture/11-build-test-release.md | 8 ++++-- 7 files changed, 36 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d8cd4696..d200bac6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ are frozen history cited from code, not kept in step with it. - `build.bat`: `Wind.exe` + `WindTray.exe` (uiAccess=false). Wind refuses to start without UIAccess; run a plain build with `WIND_ALLOW_NO_UIACCESS=1` (or `WIND_SELFTEST=1`). - `build.bat test`: builds and runs the doctest binary; exit 0 = pass. -- `build.bat check`: compile-only pass over `src\*.cpp`. +- `build.bat check`: compile-only pass over `src\*.cpp`, the tray sources and WindConfig's host. - `build.bat uiaccess`: the uiAccess=true build; works only signed and run from `C:\Program Files\Wind`. - `build.bat config`: builds the Svelte UI (`ui/` -> `ui/dist/`) and `WindConfig.exe`. - `build.bat tray`: `WindTray.exe` alone. It always uses the plain manifest; never make it uiAccess. @@ -58,7 +58,8 @@ Chapter numbers refer to `docs/architecture/NN-*.md`. Never add `QS_RAWINPUT`/`QS_INPUT` to the mask. - Tick counts depend on the refresh rate; derive new ones through `TicksAtHz`/`setTickRate`. - A config reload rebuilds `ZoomController`; UI-only keys are stripped by `StripUiOnlyKeys` so they - never reload. Add new UI-only keys there. + never reload. Add new UI-only keys to `kUiOnlyKeys` (`src/profiles.h`) and + `tests/fixtures/global_keys.txt` (the UI's `GLOBAL_KEYS` is tested against it). **Cursor (07)** - In a transform session the lock (`lockApps`, tells) applies only while the pointer is hidden diff --git a/docs/architecture/01-overview.md b/docs/architecture/01-overview.md index ef4ff03e..fe840384 100644 --- a/docs/architecture/01-overview.md +++ b/docs/architecture/01-overview.md @@ -79,17 +79,17 @@ build. See [11](11-build-test-release.md). | Area | Files | |---|---| -| Loop and session state | `main.cpp` (`wWinMain`, `RunTick`), `uiaccess_gate.h` (refuse to start without UIAccess), `idle_policy.h`, `sched_priority.h`, `tick_stats.h` | +| Loop and session state | `main.cpp` (`wWinMain`, `RunTick`), `uiaccess_gate.h` (refuse to start without UIAccess), `uiaccess_token.h`, `idle_policy.h`, `sched_priority.h`, `tick_stats.h` | | Engine contract and pick | `magnifier_model.h`, `engine_pick.h`, `shell_desktop.h`, `launch_quiesce.h` | -| Render engine | `render_engine.*`, `render_model.*`, `render_params.cpp`, `render_shaders.h`, `upscale_pass.*`, `upscale_shaders.h`, `upscale_nis.h`, `hdr_info.*`, `hdr_scale.h`, `band_window.h`, `png_dump.*` | +| Render engine | `render_engine.*`, `render_model.*`, `render_params.cpp`, `render_shaders.h`, `hlsl_compile.h`, `upscale_pass.*`, `upscale_shaders.h`, `upscale_nis.h`, `hdr_info.*`, `hdr_scale.h`, `band_window.h`, `png_dump.*` | | Transform engine | `transform_model.*`, `transform.*`, `mag_host.*`, `tx_warm.h`, `comp_pin.*`, `mpo.h`, `mpo_boot.h`, `mpo_boot_logic.h`, `mpo_guard.h`, `native_cursor.h`, `zoom_ladder.h`, `dwm_watch.*` | | Colour | `color_filter.*`, `color_matrix.h`, `cursor_tint.*`, `cursor_tint_pixels.h` | | Input | `input_router.*`, `keybind_rules.h`, `pointer_binds.h`, `keyboard_pan.h`, `typing_key.h`, `event_order.h`, `swallow_ledger.h` | | Cursor and lock | `cursor_mapper.*`, `lock_detector.*`, `drag_follow.h`, `gain_learner.h`, `cursor_sprite.*`, `cursor_blanker.*`, `cursor_decode.*`, `sprite_layer.h`, `crosshair.*`, `cursor_lock.*`, `inspect_focus.h`, `game_cursor.h`, `pan_glide.h` | | Tracking | `focus_track.*`, `view_target.h`, `view_glide.h`, `detached_view.h`, `edge_pan.h`, `caret_rect.h`, `track_filter.h`, `java_bridge*`, `focus_identity.h` | | Zoom | `zoom_controller.*` | -| Config and profiles | `config.*`, `config_path.h`, `ini_edit.*`, `profiles.*`, `profiles_io.h` | +| Config and profiles | `config.*`, `config_path.h`, `ini_edit.*`, `profiles.*`, `profiles_io.h`, `profile_switch.h` | | Tray | `tray_host.*`, `tray_ipc.h`, `tray_items.*`, `tray_status.h`, `tray_app/` | | Settings host | `config_ui/` (`main.cpp`, `bridge_json.h`, `wind_watchdog.h`, `webview_recover.h`) | | Logging and diagnostics | `logging.*`, `log_queue.h`, `test_telemetry.h`, `hitch_record.*`, `tick_span.h`, `com_util.h`, `reload_gate.h` | diff --git a/docs/architecture/02-tick-loop.md b/docs/architecture/02-tick-loop.md index 9975ac58..35cd3d0c 100644 --- a/docs/architecture/02-tick-loop.md +++ b/docs/architecture/02-tick-loop.md @@ -109,7 +109,7 @@ There is no settings IPC. `WindConfig.exe` writes `magnifier.ini` and the core n **UI-only writes never reload.** A reload rebuilds `ZoomController` and `CursorMapper`, which is wasted work and risks a visible hitch mid-zoom (the live level and mapper centre are carried over since #234, so the view no longer collapses to 1x). `StripUiOnlyKeys` (`src/config.cpp`) drops -`uiTheme`, `uiPalette`, +the `kUiOnlyKeys` list (`src/profiles.h`): `uiTheme`, `uiPalette`, `showAdvanced`, `onboarded`, `trayPinned`, `uiHighResNoticeOff` and the five tray layout keys (`trayPerf`, `traySliders`, `traySliderOrder`, `trayToggles`, `trayToggleOrder`), and the result is compared with the fingerprint of the last applied config (`t.lastCoreIni`). An identical fingerprint skips the reload. The fingerprint is seeded at startup; diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index d60b7be6..d01ccc70 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -300,7 +300,7 @@ welded cursor has hover dead zones. Identity or no publish both produce dead zon [../history/POINTER-HITTEST-FINDINGS.md](../history/POINTER-HITTEST-FINDINGS.md). - **It needs UIAccess.** Availability is read from the process token's `TokenUIAccess` bit at - `initialize`, with zero Magnification calls; an acquire/release probe would run an identity + `initialize` (`ProcessHasUiAccess`, `src/uiaccess_token.h`), with zero Magnification calls; an acquire/release probe would run an identity write. A verified-failed publish clears `inputTransformAvailable_`, which stops Auto picking Transform for the desktop. Only `ERROR_ACCESS_DENIED` is permanent; any other failure is re-armed from the token probe when the session ends. diff --git a/docs/architecture/08-config-profiles.md b/docs/architecture/08-config-profiles.md index 46d1640b..918e8856 100644 --- a/docs/architecture/08-config-profiles.md +++ b/docs/architecture/08-config-profiles.md @@ -33,9 +33,10 @@ others are not). `LoadConfig` creates the defaults only for a missing file; the hot-reload keeps its settings and retries (`config ini unreadable on reload`); writers that read the live ini first use `ReadLiveIni` and stop when it fails. -- The only cross-process kernel objects are the single-instance mutexes and the - `Local\Wind_QuitRequest` event (quit, restart handshake, installer). A window message would not - work: UIPI drops `PostMessage` from a normal process to a UIAccess one. +- The only cross-process kernel objects are the single-instance mutexes, the ini write lock and + the `Local\Wind_QuitRequest` event (quit, restart handshake, installer). A window message would not + work: UIPI drops `PostMessage` from a normal process to a UIAccess one. Their names are constants + in `src/tray_ipc.h` (`kQuitEventName` and the rest); the NSIS and PowerShell copies point there. - The tray's status block (`Local\Wind_TrayState_v1`) carries status, not settings ([01](01-overview.md)). @@ -59,6 +60,9 @@ flowchart LR - **Never hardcode `L"magnifier.ini"`.** Program Files is read-only for the non-admin runtime, and a write next to the exe fails silently. The same applies to logs (`ResolveLogDir`) and the WebView2 user-data folder (`%LOCALAPPDATA%\Wind\WebView2`). +- `config_path.h` also holds the two folder lookups every binary uses: `ExeDir()` (the folder of the + running exe, which also holds the other two) and `UserDataDir()` (`%LOCALAPPDATA%\Wind`, created + on first use; `session.keep` and `running.model` live there). ## Parsing @@ -74,8 +78,8 @@ as an initializer, so a missing or malformed key keeps the default; parsing neve The reload mechanics are in [02](02-tick-loop.md). A reload rebuilds `ZoomController`, so it collapses an active zoom; therefore `StripUiOnlyKeys` removes the eleven keys the core never reads -(`uiTheme`, `uiPalette`, `showAdvanced`, `onboarded`, `trayPinned`, `uiHighResNoticeOff` and -the five tray layout keys `trayPerf`, +(`kUiOnlyKeys` in `src/profiles.h`: `uiTheme`, `uiPalette`, `showAdvanced`, `onboarded`, `trayPinned`, +`uiHighResNoticeOff` and the five tray layout keys `trayPerf`, `traySliders`, `traySliderOrder`, `trayToggles`, `trayToggleOrder`) and the reload is skipped when the stripped text is unchanged. `profile` stays in the fingerprint, so a profile switch reloads. @@ -100,12 +104,14 @@ A profile is a named full snapshot of the settings, keybinds included, stored as `profiles\.ini` next to the resolved ini, with `profile=` in the live ini. Pure logic is in `src/profiles.*` (tested); I/O in `src/profiles_io.h`. -**Global keys never travel with a profile.** `IsGlobalProfileKey` covers `profile`, `onboarded`, -`uiTheme`, `uiPalette`, `showAdvanced`, `trayPinned`, `uiHighResNoticeOff` and the five tray -layout keys (`trayPerf`, `traySliders`, +**Global keys never travel with a profile.** `IsGlobalProfileKey` covers `profile` plus the +`kUiOnlyKeys` list (`src/profiles.h`): `onboarded`, `uiTheme`, `uiPalette`, `showAdvanced`, +`trayPinned`, `uiHighResNoticeOff` and the five tray layout keys (`trayPerf`, `traySliders`, `traySliderOrder`, `trayToggles`, `trayToggleOrder`). `MakeProfileText` strips them from profile files; `MakeLiveText` carries them over from the old live text. Both work line by line and keep -comments and order. +comments and order. `kUiOnlyKeys` is the only C++ copy of the list; the UI's `GLOBAL_KEYS` +(`ui/src/session.js`) is pinned to it through `tests/fixtures/global_keys.txt`, which a doctest and +a node test (`npm run test:rules`) both read. **Session model: the live ini is the session, the profile file is the saved state.** @@ -123,7 +129,8 @@ comments and order. consumed once. - Tray Quit compares the files and prompts Save, Discard or Cancel. -**Switching** (Settings or the tray flyout, the same sequence): +**Switching** (Settings or the tray flyout, one implementation: `wind::SwitchProfile` in +`src/profile_switch.h`; each surface only maps its result to its own messages): 1. Read and check the profile with `ProfileTextError`, which rejects binary, oversized or unparseable text. A read failure is distinct from an empty file; treating a locked file as empty @@ -133,8 +140,9 @@ comments and order. 3. Write `MakeLiveText(profile, oldLive, name)` over the live ini. 4. The core hot-reloads it, `model` included (`running.model` reads `live`). Only when Wind runs one engine (`running.model` names it) and the parsed `model` differs does the surface relaunch - `Wind.exe` (`EngineSwitchesLive`, `src/profiles_io.h`). If that relaunch fails, it writes the - old `model` back, so the ini always matches the running engine. + `Wind.exe` (`EngineSwitchesLive`, `src/profiles_io.h`), writing `session.keep` first. If that + relaunch fails, it writes the old `model` back (`RevertModelAfterFailedRelaunch`, also used by + the tray's engine pick), so the ini always matches the running engine. **That relaunch uses the eviction handshake, never a kill.** The new instance finds the single-instance mutex held, sets `Local\Wind_QuitRequest` and waits for the old one to exit. Only a diff --git a/docs/architecture/09-settings-ui.md b/docs/architecture/09-settings-ui.md index ea84200d..ce996d09 100644 --- a/docs/architecture/09-settings-ui.md +++ b/docs/architecture/09-settings-ui.md @@ -227,7 +227,9 @@ and offers New (defaults) or Duplicate current; Enter duplicates. There is no re - Components use tokens (`--sel`, `--onfill`, `--focus`, `--danger*`, `--scrim`, the radii), never fixed colours. - WindConfig's pre-paint window colour follows `uiPalette` (`ThemeBackground` in - `src/config_ui/main.cpp`); update it when a theme is added. + `src/config_ui/main.cpp`), read from the generated flyout palettes (`src/tray_app/flyout_palettes.h`). + `tests/test_flyout_palettes.cpp` checks each palette's menu colour against that theme's `--bg` in + `ui/src/design/themes.css`, so rerun both generators when a theme is added. ## Onboarding diff --git a/docs/architecture/11-build-test-release.md b/docs/architecture/11-build-test-release.md index f200378f..226e0047 100644 --- a/docs/architecture/11-build-test-release.md +++ b/docs/architecture/11-build-test-release.md @@ -13,7 +13,7 @@ deploy, the installer's elevation rules and the release workflows. |---|---|---| | (none) | `Wind.exe` + `WindTray.exe` | The app with the `uiAccess=false` manifest. It refuses to start without UIAccess (issue #460) unless `WIND_ALLOW_NO_UIACCESS=1` or `WIND_SELFTEST=1` is set | | `test` | `wind_tests.exe` | The doctest binary over the pure sources; runs it and returns its exit code | -| `check` | – | Compile-only pass over `src\*.cpp` | +| `check` | – | Compile-only pass over `src\*.cpp`, `src\tray_app\*.cpp` and `src\config_ui\main.cpp` | | `uiaccess` | `Wind.exe` + `WindTray.exe` | `Wind.uiaccess.manifest` and `/DWIND_UIACCESS`; useful only signed and in Program Files. `WindTray.exe` always keeps the plain manifest | | `tray` | `WindTray.exe` | The tray helper alone, objects in `src\tray_app\` | | `config` | `WindConfig.exe` | Builds the Svelte app to `ui/dist/`, then the WebView2 host against `third_party/webview2` | @@ -44,7 +44,8 @@ headlessly, so the unit tests are the only verification loop that runs everywher temp files. They need no Wind runtime, so they still run headless. - `tests/fixtures/keybind_cases.txt` is shared by the C++ and the JavaScript bind rules. The JS half is `ui/tests/keybind-rules.nodetest.mjs`, run by `npm run test:rules` in `ui/` (`node --test`, no - browser) and by CI. + browser) and by CI. `tests/fixtures/global_keys.txt` does the same for the profile-global keys + (`ui/tests/global-keys.nodetest.mjs`). - Config defaults are checked from both sides. `tests/test_config_defaults.cpp` walks every key of `DefaultIniText()` against `Config{}` (a new template key needs an `F(key)` line there) and compares `tests/fixtures/config_defaults.txt` (key, clamp min and max, default; generated from `Config{}` and @@ -122,7 +123,8 @@ flowchart TD - **`src/version.h` is the only version declaration.** Bumping it cuts a new release `v`; a push that leaves it alone refreshes the current release's assets in place with `--clobber`, in - this repo. + this repo. Scripts and both workflows read it through `Get-WindVersion` (`tools/version.ps1`); + `tests/test_version.cpp` fails when `WIND_VERSION_STR` drifts from the three numbers. - Each release carries the installer twice: `Wind-Setup-x64-.exe` and the stable name `Wind-Setup-x64.exe`, so `releases/latest/download/Wind-Setup-x64.exe` always serves the newest. - Release notes come from `.github/release-notes.md`; the workflow fills `__VERSION__`,