From 5a1002bb39efc542dd413810e0b2977b71186410 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:39:45 +0200 Subject: [PATCH] chore: bring #412 onto main as tested in the combined build; 0.24.15 The branch is main plus exactly the combined test build's state after this PR (conflicts with the PRs merged before it resolved as tested), version 0.24.15. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- docs/architecture/09-settings-ui.md | 7 +--- src/config.cpp | 8 ++-- src/config_ui/ini_edit.h | 14 +++++++ src/config_ui/main.cpp | 64 +++++++++++++++-------------- src/config_ui/mpo.h | 51 ++--------------------- src/main.cpp | 15 +++---- src/profiles_io.h | 7 ++++ src/tray_ipc.h | 16 ++------ src/tray_status.h | 14 ------- src/version.h | 4 +- tests/test_ini_edit.cpp | 16 ++++++++ tests/test_tray_ipc.cpp | 17 +------- tests/test_tray_status.cpp | 9 ---- ui/src/Settings.svelte | 12 ++---- ui/src/bridge.js | 36 +++------------- ui/src/controls/HighRes.svelte | 11 ++--- ui/src/controls/Preview.svelte | 4 +- ui/src/controls/SettingRow.svelte | 6 +-- ui/src/controls/Slider.svelte | 20 ++++++++- ui/src/session.js | 14 ++++++- ui/src/settings-schema.js | 8 ++-- 21 files changed, 148 insertions(+), 205 deletions(-) diff --git a/docs/architecture/09-settings-ui.md b/docs/architecture/09-settings-ui.md index 91f0b1c4..25d66e85 100644 --- a/docs/architecture/09-settings-ui.md +++ b/docs/architecture/09-settings-ui.md @@ -53,7 +53,7 @@ the reply invalid and the page waits forever. | Message | Kind | Effect | |---|---|---| -| `getConfig` | reply `config` | Every live key and value, plus the saved profile values. An ini that exists but cannot be read gets `configUnreadable` instead, and the page asks again (never an empty ini shown as all defaults) | +| `getConfig` | reply `config` | Every live key and value, plus the saved profile values, and `runningModel` (the engine the live Wind loaded, from `%LOCALAPPDATA%\Wind\running.model`; absent when Wind is not running, then the page uses the ini). An ini that exists but cannot be read gets `configUnreadable` instead, and the page asks again (never an empty ini shown as all defaults) | | `setConfig` | fire | Atomic write of one key to the live ini | | `setConfigPersist` | fire | Same, and the key in the active profile file (keybind captures) | | `saveSession` | reply `sessionSaved` | Write `MakeProfileText(live)` over the profile | @@ -64,10 +64,7 @@ the reply invalid and the page waits forever. | `openIni` | fire | Open `magnifier.ini` in the `.ini` handler or Notepad | | `exportDiagnostics` | fire | Zip `%LOCALAPPDATA%\Wind\logs` to the Desktop on a worker thread (the window stays responsive; a repeat click while it runs is ignored), then reveal it in Explorer | | `pickExe` | reply `exePicked` | File picker; replies with the bare exe name, because app lists match by name | -| `mpoState` | reply `mpoState` | Registry value and the state DWM loaded at boot | -| `setMpoDisabled` | reply `mpoApplied` | Elevated registry write; replies with the re-read state, so a cancelled UAC reverts | -| `rebootNow` | fire | `shutdown.exe /r /t 0`, without `/f` | -| `listProfiles`, `switchProfile`, `createProfile`, `renameProfile`, `duplicateProfile`, `deleteProfile` | reply `profiles` | Every reply carries the full `{names, active}`. The page uses switch, create and delete | +| `switchProfile`, `createProfile`, `deleteProfile` | reply `profiles` | Every reply carries the full `{names, active}` | - An unsolicited `profiles` message (tray switch) is marked `push:true`, so it is never taken for a pending reply. diff --git a/src/config.cpp b/src/config.cpp index cefa46b4..7f523917 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -509,10 +509,12 @@ std::string DefaultIniText() { "dwmFlush=0\n" "; diagnostics=1 logs frame timing as diag lines in wind-core.log (restart to apply)\n" "diagnostics=0\n" - "; cursorSensitivity: pan speed multiplier - free panning auto-matches the OS cursor\n" - "; (DPI+accel) then scales by this (1.0=exact match); also scales locked-game panning\n" + "; cursorSensitivity: pan speed multiplier - free render panning auto-matches the OS cursor\n" + "; (DPI+accel) then scales by this (1.0=exact match); also scales locked-game panning.\n" + "; No effect in a free transform session (the pointer itself drives the view)\n" "cursorSensitivity=1.0\n" - "; cursorSmoothing: light inertia on the pan (0=off, higher=smoother+laggier; 0.4 shipped: light)\n" + "; cursorSmoothing: light inertia on the pan (0=off, higher=smoother+laggier; 0.4 shipped: light);\n" + "; no effect in a free transform session\n" "cursorSmoothing=0.4\n" "; cursorConstantSize: 0=the cursor grows with the zoom (default); 1=keep it at normal\n" "; desktop size at every zoom (render engine only). Replaces cursorScaleWithZoom (ignored).\n" diff --git a/src/config_ui/ini_edit.h b/src/config_ui/ini_edit.h index 9e203c7e..ac74aea9 100644 --- a/src/config_ui/ini_edit.h +++ b/src/config_ui/ini_edit.h @@ -7,4 +7,18 @@ std::map ReadIniValues(const std::string& text); // Return INI text with `key`'s value replaced IN PLACE, preserving every other line (comments, // order, unknown keys). If `key` is absent, append "key=value". Pure (no I/O, no ). std::string UpdateIniText(const std::string& text, const std::string& key, const std::string& value); +// The settings bridge writes whatever key/value the page posts, so the host vets them first: a key +// is a plain identifier (letters, digits, '_'; starts with a letter; at most 64 chars), a value has +// no CR, LF or NUL (those would split the line and inject another key). Pure. +inline bool IsSafeIniKey(const std::string& k) { + if (k.empty() || k.size() > 64) return false; + if (!((k[0] >= 'a' && k[0] <= 'z') || (k[0] >= 'A' && k[0] <= 'Z'))) return false; + for (char c : k) + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_')) return false; + return true; +} +inline bool IsSafeIniValue(const std::string& v) { + for (char c : v) if (c == '\r' || c == '\n' || c == '\0') return false; + return true; +} } diff --git a/src/config_ui/main.cpp b/src/config_ui/main.cpp index ae10f87e..86b6a868 100644 --- a/src/config_ui/main.cpp +++ b/src/config_ui/main.cpp @@ -302,7 +302,15 @@ static std::string SessionPayload(const std::string& live) { std::string names = "["; bool first = true; for (const auto& n : ProfileNamesUtf8()) { if (!first) names += ","; first = false; names += "\"" + JsonEscape(n) + "\""; } names += "]"; + // The engine the live Wind process actually runs (it writes running.model at start). Only trusted + // while Wind is up; otherwise the next start loads the ini, so the page falls back to that. + std::string running; + if (WindRunning() && wind::ReadTextFileOk(wind::RunningModelPath(), running)) { + while (!running.empty() && (running.back() == '\r' || running.back() == '\n' || running.back() == ' ')) running.pop_back(); + if (running != "render" && running != "transform" && running != "hybrid") running.clear(); + } return "\"values\":" + JsonObjectOf(values) + ",\"saved\":" + JsonObjectOf(saved) + + (running.empty() ? std::string() : ",\"runningModel\":\"" + running + "\"") + ",\"profiles\":{\"names\":" + names + ",\"active\":\"" + JsonEscape(active) + "\"}"; } static std::string UiPaletteOf(const std::string& live) { @@ -393,7 +401,13 @@ static void HandleWebMessage(ICoreWebView2* wv, const std::wstring& jsonW) { PostConfig(wv); } else if (type == "setConfig" || type == "setConfigPersist") { std::string key = JsonField(j, "key"), value = JsonField(j, "value"); - if (!key.empty()) { + // The page is ours, but the bridge still refuses a key that is not a plain identifier and a + // value that would split the line (CR/LF/NUL): either would let a message plant another key. + if (!wind::IsSafeIniKey(key) || !wind::IsSafeIniValue(value)) { + wind::Log(wind::LogLevel::Warn, "config", "%s: refused an unsafe key or value", type.c_str()); + return; + } + { // Checked (issue #274): this is the path every slider, toggle and keybind takes, and a // failed write (AV lock, a sharing violation on the replace) used to vanish - the page // showed the new value while the ini kept the old one. Tell the page, which says so. @@ -443,33 +457,6 @@ static void HandleWebMessage(ICoreWebView2* wv, const std::wstring& jsonW) { WriteFileAtomic(IniPath(), wind::MakeLiveText(prof, live, active)); } PostConfig(wv); - } else if (type == "mpoState") { - // Read-only probe: HKLM reads do not need elevation, so the Advanced row can always show - // the true state without ever prompting. `atBoot` is what DWM actually loaded (see - // mpo_boot.h); bootKnown=false means no record for this boot, and the UI then falls back to - // comparing against the registry rather than inventing an answer. - bool atBoot = false; - const bool bootKnown = wind::MpoStateAtBoot(atBoot); - std::string out = std::string("{\"type\":\"mpoState\",\"disabled\":") + - (wind::MpoDisabledInRegistry() ? "true" : "false") + - ",\"bootKnown\":" + (bootKnown ? "true" : "false") + - ",\"atBoot\":" + (atBoot ? "true" : "false") + "}"; - wv->PostWebMessageAsJson(Widen(out).c_str()); - } else if (type == "setMpoDisabled") { - // Applied only from the Apply button, never on the toggle itself: this raises UAC and - // changes a system-wide display setting, so it follows the same staged model as every other - // setting. Reply with the RE-READ state, so a cancelled UAC prompt reverts the row instead - // of leaving it showing a change that never happened. - const bool want = JsonField(j, "value") == "1"; - const bool ok = wind::SetMpoDisabled(want, g_hwnd); - std::string out = std::string("{\"type\":\"mpoApplied\",\"ok\":") + (ok ? "true" : "false") + - ",\"disabled\":" + (wind::MpoDisabledInRegistry() ? "true" : "false") + "}"; - wv->PostWebMessageAsJson(Widen(out).c_str()); - } else if (type == "rebootNow") { - // Offered only after an MPO change, which DWM reads at boot. shutdown.exe rather than - // ExitWindowsEx: it handles acquiring SE_SHUTDOWN_NAME for us, and /t 0 with no /f lets - // other apps object so the user never loses unsaved work elsewhere. - ShellExecuteW(nullptr, L"open", L"shutdown.exe", L"/r /t 0", nullptr, SW_HIDE); } else if (type == "dirty") { g_dirty = JsonField(j, "value") == "1"; } else if (type == "window") { @@ -537,8 +524,6 @@ static void HandleWebMessage(ICoreWebView2* wv, const std::wstring& jsonW) { HANDLE th = CreateThread(nullptr, 0, ExportDiagnosticsThread, nullptr, 0, nullptr); if (th) CloseHandle(th); else g_exporting = false; } - } else if (type == "listProfiles") { - PostProfiles(wv, true, ""); } else if (type == "switchProfile") { const std::string err = DoSwitchProfile(JsonField(j, "name")); PostProfiles(wv, err.empty(), err); @@ -887,6 +872,25 @@ static void CreateWebView(HWND hwnd) { PostMessageW(g_hwnd, WM_APP_WV_FAILED, (WPARAM)k, 0); return S_OK; }).Get(), &tok); + // The page only ever lives on the mapped host: any other navigation or new window + // (a link, a script, injected content) is cancelled, so no outside page reaches + // the message bridge. + g_webview->add_NavigationStarting( + Callback( + [](ICoreWebView2*, ICoreWebView2NavigationStartingEventArgs* args) -> HRESULT { + LPWSTR uri = nullptr; + if (SUCCEEDED(args->get_Uri(&uri)) && uri) { + if (wcsncmp(uri, L"https://wind.config/", 20) != 0) args->put_Cancel(TRUE); + CoTaskMemFree(uri); + } + return S_OK; + }).Get(), &tok); + g_webview->add_NewWindowRequested( + Callback( + [](ICoreWebView2*, ICoreWebView2NewWindowRequestedEventArgs* args) -> HRESULT { + args->put_Handled(TRUE); + return S_OK; + }).Get(), &tok); g_webview->add_NavigationCompleted( Callback( [](ICoreWebView2*, ICoreWebView2NavigationCompletedEventArgs*) -> HRESULT { diff --git a/src/config_ui/mpo.h b/src/config_ui/mpo.h index e1151f8f..77c00cff 100644 --- a/src/config_ui/mpo.h +++ b/src/config_ui/mpo.h @@ -1,20 +1,15 @@ #pragma once #include -#include #include -// MPO (Multi-Plane Overlay) state, read and written for the Settings "Disable MPO" row (issue #164). +// MPO (Multi-Plane Overlay) state, read by the core at startup and for the restart-pending tell +// (issue #164). The Settings UI no longer writes it (the "Disable MPO" row is gone). // // WHY WIND CARES: with MPO enabled the driver packs DWM's magnification translation into a 16-bit // field when a game surface rides a hardware overlay plane, which is the issue #148 TDR trigger and // forces the transform model's pan wall. Measured 2026-08-02 on the dev rig: RDR2 transform zoom is // visibly choppier with MPO on and smooth with OverlayTestMode=5. The core already reads this at -// startup and logs it; this header is what lets the UI show it rather than burying it in a log. -// -// WHY reg.exe AND NOT RegSetValueEx: the value lives under HKLM and WindConfig.exe is deliberately -// a normal-integrity, non-elevated process (see the Program Files note in CLAUDE.md). Writing it -// therefore needs a separate elevated process, and `runas` on reg.exe is the smallest thing that -// does the job: no PowerShell execution policy, no helper binary to sign and deploy. +// startup and logs it. namespace wind { inline const wchar_t* kDwmKeyPath = L"SOFTWARE\\Microsoft\\Windows\\Dwm"; @@ -24,8 +19,7 @@ inline const wchar_t* kOverlayTestMode = L"OverlayTestMode"; 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. The UI -// says so rather than implying the toggle is instant. +// running: the value is read by DWM at boot, so a change needs a restart to take effect. inline bool MpoDisabledInRegistry() { HKEY key = nullptr; // KEY_WOW64_64KEY is deliberate: a 32-bit host build would otherwise be redirected to @@ -41,41 +35,4 @@ inline bool MpoDisabledInRegistry() { return st == ERROR_SUCCESS && type == REG_DWORD && value == kOverlayTestModeDisabled; } -// Write (disable=true) or remove (disable=false) the value via an elevated reg.exe, and WAIT for it -// so the caller can re-read the real state instead of reporting an optimistic guess. Returns false -// when the user dismisses the UAC prompt or reg.exe fails - the caller reports that honestly rather -// than leaving the toggle showing a state that was never applied. -inline bool SetMpoDisabled(bool disable, HWND owner) { - std::wstring args; - if (disable) { - args = L"add \"HKLM\\"; - args += kDwmKeyPath; - args += L"\" /v "; - args += kOverlayTestMode; - args += L" /t REG_DWORD /d 5 /f /reg:64"; - } else { - args = L"delete \"HKLM\\"; - args += kDwmKeyPath; - args += L"\" /v "; - args += kOverlayTestMode; - args += L" /f /reg:64"; - } - SHELLEXECUTEINFOW ei{}; - ei.cbSize = sizeof(ei); - ei.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI; // we report failures ourselves - ei.hwnd = owner; - ei.lpVerb = L"runas"; // the UAC prompt - ei.lpFile = L"reg.exe"; - ei.lpParameters = args.c_str(); - ei.nShow = SW_HIDE; - if (!ShellExecuteExW(&ei) || ei.hProcess == nullptr) return false; // cancelled or failed - WaitForSingleObject(ei.hProcess, 20000); - DWORD code = 1; - GetExitCodeProcess(ei.hProcess, &code); - CloseHandle(ei.hProcess); - // Removing a value that was already absent is reg.exe exit 1, which is the desired end state, - // so trust the re-read rather than the exit code alone. - return code == 0 || MpoDisabledInRegistry() == disable; -} - } // namespace wind diff --git a/src/main.cpp b/src/main.cpp index 1b24e09f..d96bb1ca 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1388,17 +1388,10 @@ static void RunTick(TickState& t) { } double lvl = t.zoom.level(); { - // Snapshot for the tray menu, published every tick into the shared block and read by - // WindTray.exe (relaxed atomics) while its menu is open. "Advanced" is - // the hybrid model: the mode that picks an engine per window type; renamed from "Auto" - // because Auto undersold what it does. + // Snapshot for the tray flyout, published every tick into the shared block and read by + // WindTray.exe (relaxed atomics) while it is open. wind::TrayStatus ts_; ts_.level = lvl; - const std::string& mdl = t.cfg.model; - ts_.engine = mdl == "transform" ? wind::TrayEngine::Transform - : mdl == "render" ? wind::TrayEngine::Render - : wind::TrayEngine::Advanced; - ts_.panning = lvl > 1.001; wind::PublishTrayStatus(g_trayBlock, ts_); } @@ -3110,6 +3103,10 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { wind::Log(wind::LogLevel::Warn, "startup", "hybrid: transform half failed to init; render-only"); model2.reset(); } + // Tell the settings host which engine this process actually runs (fixed until the next start). + // Written after the engine init, so a hybrid that fell back to one engine reports that one. + wind::WriteTextFileAtomic(wind::RunningModelPath(), + model2 ? "hybrid" : (dynamic_cast(model.get()) ? "transform" : "render")); // 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. diff --git a/src/profiles_io.h b/src/profiles_io.h index 852c175e..1cd67f31 100644 --- a/src/profiles_io.h +++ b/src/profiles_io.h @@ -261,6 +261,13 @@ inline std::wstring SessionKeepPath() { std::wstring base = (n == 0 || n >= MAX_PATH) ? std::wstring(L".") : std::wstring(buf, n); return base + L"\\Wind\\session.keep"; } +// %LOCALAPPDATA%\Wind\running.model: the engine the Wind process loaded at its start (`model` is read +// once at launch). Written by Wind.exe, read by the settings host so a model change that was never +// applied still shows as pending after the window is reopened, instead of looking applied. +inline std::wstring RunningModelPath() { + std::wstring p = SessionKeepPath(); + return p.substr(0, p.size() - 12) + L"running.model"; // 12 = len("session.keep") +} // Settings session model: the live ini is the session, the active profile file is the saved state. // A plain start discards unsaved changes by rewriting the live ini from the profile (globals kept). diff --git a/src/tray_ipc.h b/src/tray_ipc.h index 80db2e2c..8b4a1c7d 100644 --- a/src/tray_ipc.h +++ b/src/tray_ipc.h @@ -10,7 +10,7 @@ // // 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 -// (last tick's level with this tick's engine) is invisible in a menu. A freshly created mapping is +// (last tick's level with this tick's ring) is invisible in a menu. A freshly created mapping is // zero-filled, which is a valid "no live data" state (magic 0 fails the check). #include #include @@ -24,14 +24,12 @@ inline constexpr const wchar_t* kTrayMutexName = L"Local\\Wind_Tray"; struct TrayShared { static constexpr uint32_t kMagic = 0x59525457u; // "WTRY" - static constexpr uint32_t kVersion = 1; + static constexpr uint32_t kVersion = 2; // 2: engine/panning removed (the flyout never read them) std::atomic magic; std::atomic version; std::atomic windPid; // the running core std::atomic level; // 1.0 = not zoomed - std::atomic engine; // TrayEngine - std::atomic panning; std::atomic menuOpen; // tray -> Wind: the menu's modal loop is live TickStats ticks; // frame-pacing ring for the header's fps and sparkline }; @@ -39,7 +37,6 @@ struct TrayShared { // Cross-process atomics must not hide a lock inside the object (a lock would live in one // process's memory only). static_assert(std::atomic::is_always_lock_free, "shared block needs lock-free u32"); -static_assert(std::atomic::is_always_lock_free, "shared block needs lock-free i32"); static_assert(std::atomic::is_always_lock_free, "shared block needs lock-free double"); static_assert(std::atomic::is_always_lock_free, "TickStats head must be lock-free"); @@ -48,8 +45,6 @@ static_assert(std::atomic::is_always_lock_free, "TickStats head must b inline void InitTrayBlock(TrayShared& b, uint32_t pid) { b.windPid.store(pid, std::memory_order_relaxed); b.level.store(1.0, std::memory_order_relaxed); - b.engine.store(0, std::memory_order_relaxed); - b.panning.store(0, std::memory_order_relaxed); b.menuOpen.store(0, std::memory_order_relaxed); // a leftover block may carry the last tray's "open" b.ticks.reset(); // ...and the last Wind's pacing samples (review item 89) b.version.store(TrayShared::kVersion, std::memory_order_relaxed); @@ -66,18 +61,13 @@ inline bool TrayBlockValid(const TrayShared* b) { inline void PublishTrayStatus(TrayShared* b, const TrayStatus& s) { if (!b) return; b->level.store(s.level, std::memory_order_relaxed); - b->engine.store((int32_t)s.engine, std::memory_order_relaxed); - b->panning.store(s.panning ? 1u : 0u, std::memory_order_relaxed); } -// Default status (Idle, Advanced) when the block is missing or foreign. +// Default status (Idle) when the block is missing or foreign. inline TrayStatus ReadTrayStatus(const TrayShared* b) { TrayStatus s; if (!TrayBlockValid(b)) return s; s.level = b->level.load(std::memory_order_relaxed); - const int32_t e = b->engine.load(std::memory_order_relaxed); - s.engine = (e >= 0 && e <= (int32_t)TrayEngine::Render) ? (TrayEngine)e : TrayEngine::Advanced; - s.panning = b->panning.load(std::memory_order_relaxed) != 0; return s; } diff --git a/src/tray_status.h b/src/tray_status.h index 8e38561c..80dea480 100644 --- a/src/tray_status.h +++ b/src/tray_status.h @@ -7,28 +7,14 @@ namespace wind { -// Which engine is actually running this session - NOT what the ini asked for. "Advanced" is the -// hybrid model: it is the mode that picks per window type, which is what the name should say. -enum class TrayEngine { Advanced, Transform, Render }; - struct TrayStatus { double level = 1.0; // 1.0 = not zoomed - TrayEngine engine = TrayEngine::Advanced; - bool panning = false; }; // The transport (Wind.exe -> WindTray.exe) is the shared block in tray_ipc.h. // --- pure label logic, unit-tested --------------------------------------------------------- -inline const wchar_t* EngineLabel(TrayEngine e) { - switch (e) { - case TrayEngine::Transform: return L"TRANSFORM"; - case TrayEngine::Render: return L"RENDER"; - default: return L"ADVANCED"; - } -} - // The headline. Anything at or below 1.001x is idle - the same threshold the transform model uses // to decide a session is live, so the tray can never claim "1.0x zoomed" while the model calls it // idle. Zoomed levels read to one decimal: 7.4, not 7.43, which is noise at a glance. diff --git a/src/version.h b/src/version.h index 955ce687..b7310490 100644 --- a/src/version.h +++ b/src/version.h @@ -4,7 +4,7 @@ #define WIND_VER_MAJOR 0 #define WIND_VER_MINOR 24 -#define WIND_VER_PATCH 14 +#define WIND_VER_PATCH 15 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.24.14" +#define WIND_VERSION_STR "0.24.15" diff --git a/tests/test_ini_edit.cpp b/tests/test_ini_edit.cpp index 50ad80ed..658c1206 100644 --- a/tests/test_ini_edit.cpp +++ b/tests/test_ini_edit.cpp @@ -8,6 +8,22 @@ TEST_CASE("ReadIniValues parses key=value, skipping comments and blanks") { CHECK(m["zoomInSpeed"] == "1.2"); CHECK(m.count("c") == 0); } +TEST_CASE("the bridge accepts plain keys and refuses injection") { + CHECK(IsSafeIniKey("maxLevel")); + CHECK(IsSafeIniKey("panLeftVk2")); + CHECK_FALSE(IsSafeIniKey("")); + CHECK_FALSE(IsSafeIniKey("1abc")); + CHECK_FALSE(IsSafeIniKey("a=b")); + CHECK_FALSE(IsSafeIniKey("a b")); + CHECK_FALSE(IsSafeIniKey("[profile]")); + CHECK_FALSE(IsSafeIniKey("a\nb")); + CHECK_FALSE(IsSafeIniKey(std::string(65, 'a'))); + CHECK(IsSafeIniValue("RDR2.exe;GTA5.exe")); + CHECK(IsSafeIniValue("")); + CHECK_FALSE(IsSafeIniValue("1\nvsync=0")); + CHECK_FALSE(IsSafeIniValue("1\rx")); + CHECK_FALSE(IsSafeIniValue(std::string("a\0b", 3))); +} TEST_CASE("UpdateIniText replaces an existing key in place, preserving the rest") { std::string t = "; speed knob\nzoomInSpeed=1.0\nmaxLevel=8.0\n"; std::string r = UpdateIniText(t, "zoomInSpeed", "2.0"); diff --git a/tests/test_tray_ipc.cpp b/tests/test_tray_ipc.cpp index 59fa6f61..d0d0c0b1 100644 --- a/tests/test_tray_ipc.cpp +++ b/tests/test_tray_ipc.cpp @@ -12,8 +12,6 @@ TEST_CASE("a zero-filled block is not live data") { CHECK_FALSE(TrayBlockValid(b.get())); const TrayStatus s = ReadTrayStatus(b.get()); CHECK(s.level == doctest::Approx(1.0)); - CHECK(s.engine == TrayEngine::Advanced); - CHECK(s.panning == false); } TEST_CASE("a missing block reads as defaults, never crashes") { @@ -29,18 +27,16 @@ TEST_CASE("status survives a publish/read round trip through the block") { InitTrayBlock(*b, 4242); REQUIRE(TrayBlockValid(b.get())); CHECK(b->windPid.load() == 4242u); - TrayStatus s; s.level = 7.4; s.engine = TrayEngine::Transform; s.panning = true; + TrayStatus s; s.level = 7.4; PublishTrayStatus(b.get(), s); const TrayStatus r = ReadTrayStatus(b.get()); CHECK(r.level == doctest::Approx(7.4)); - CHECK(r.engine == TrayEngine::Transform); - CHECK(r.panning == true); } TEST_CASE("a different layout version is not read as live values") { auto b = Fresh(); InitTrayBlock(*b, 1); - PublishTrayStatus(b.get(), TrayStatus{ 5.0, TrayEngine::Render, true }); + PublishTrayStatus(b.get(), TrayStatus{ 5.0 }); b->version.store(TrayShared::kVersion + 1); CHECK_FALSE(TrayBlockValid(b.get())); CHECK(ReadTrayStatus(b.get()).level == doctest::Approx(1.0)); @@ -49,15 +45,6 @@ TEST_CASE("a different layout version is not read as live values") { CHECK_FALSE(TrayBlockValid(b.get())); } -TEST_CASE("an out-of-range engine value falls back to Advanced") { - auto b = Fresh(); - InitTrayBlock(*b, 1); - b->engine.store(99); - CHECK(ReadTrayStatus(b.get()).engine == TrayEngine::Advanced); - b->engine.store(-1); - CHECK(ReadTrayStatus(b.get()).engine == TrayEngine::Advanced); -} - TEST_CASE("menuOpen round-trips, and a foreign block reads as closed") { auto b = Fresh(); InitTrayBlock(*b, 1); diff --git a/tests/test_tray_status.cpp b/tests/test_tray_status.cpp index aed25c42..0c214521 100644 --- a/tests/test_tray_status.cpp +++ b/tests/test_tray_status.cpp @@ -34,15 +34,6 @@ TEST_CASE("a too-small buffer is refused rather than overrun") { CHECK(FormatZoom(7.4, b, 4) == false); } -// --- engine labels --------------------------------------------------------------------- - -TEST_CASE("hybrid reads Advanced, not Auto") { - // Renamed 2026-08-28: "Auto" undersold it - hybrid is the mode that picks per window type. - CHECK(std::wstring(EngineLabel(TrayEngine::Advanced)) == L"ADVANCED"); - CHECK(std::wstring(EngineLabel(TrayEngine::Transform)) == L"TRANSFORM"); - CHECK(std::wstring(EngineLabel(TrayEngine::Render)) == L"RENDER"); -} - // --- tick statistics ------------------------------------------------------------------- TEST_CASE("the ring returns the newest samples oldest-first") { diff --git a/ui/src/Settings.svelte b/ui/src/Settings.svelte index f2488b4b..177a903d 100644 --- a/ui/src/Settings.svelte +++ b/ui/src/Settings.svelte @@ -71,15 +71,11 @@ } values = f.values; saved = f.saved; if (s.profiles) prof = { names: s.profiles.names || [], active: s.profiles.active || '' }; - if (!runningModel) runningModel = String(f.values.model); + // The host reports the engine Wind really runs; the ini only says what it will run next start. + if (!runningModel) runningModel = s.runningModel || String(f.values.model); loaded = true; } - // High resolution cursor no longer touches MPO (issue #369): smooth sampling and the MPO guard - // (an invisible colour effect while zoomed at nearest) both keep apps off hardware planes, so - // either look is safe with MPO on and the toggle applies live, with no registry write or restart. - const mpoNeedsRestart = false; - // --- Changes -------------------------------------------------------------------------------- function change(key, val) { if (key === 'model') announce('Magnifier model set to ' + val + '. Some display options changed.'); @@ -248,12 +244,12 @@ else if (a === 'exportDiagnostics') exportDiagnostics(); else if (a === 'quitWind') requestQuit(); } - const visible = (r) => !r.showIf || String(values[r.showIf.key]) === String(r.showIf.eq); + const visible = (r) => !r.showIf || (r.showIf.ne !== undefined ? String(values[r.showIf.key]) !== String(r.showIf.ne) : String(values[r.showIf.key]) === String(r.showIf.eq)); // Advanced rows show on their page only while the global switch is on; search results show them always. const advOn = $derived(Number(values.showAdvanced) === 1); const shown = (r) => visible(r) && (!r.adv || advOn); const extra = $derived({ - mpoNeedsRestart, runningModel, version: VERSION, onRepo: openRepo, + runningModel, version: VERSION, onRepo: openRepo, onAction, pick: pickExe, onRestart: () => { restartError = false; windowControl('restartWind'); }, profiles: { diff --git a/ui/src/bridge.js b/ui/src/bridge.js index 8c239729..badf3297 100644 --- a/ui/src/bridge.js +++ b/ui/src/bridge.js @@ -8,7 +8,8 @@ export function post(msg) { else if (window.__windMock) window.__windMock(msg); } // Session model: the live ini is the session, the active profile file is the saved state. -// getSession() resolves { values, saved, profiles: {names, active} }. The first call after +// getSession() resolves { values, saved, runningModel, profiles: {names, active} } (runningModel: the +// engine the live Wind process runs, '' when the host cannot tell). The first call after // load is answered from window.__windInit (injected by the host before the page runs); it is // consumed once, so later calls (and a reload) always ask the host for fresh state. export function getSession() { @@ -16,7 +17,7 @@ export function getSession() { if (init) { delete window.__windInit; return Promise.resolve({ - values: init.values || {}, saved: init.saved || init.values || {}, + values: init.values || {}, saved: init.saved || init.values || {}, runningModel: init.runningModel || '', profiles: init.profiles || { names: [], active: '' }, }); } @@ -27,7 +28,7 @@ export function getSession() { const off = onMessage(m => { if (m && m.type === 'config') { off(); - resolve({ values: m.values || {}, saved: m.saved || m.values || {}, + resolve({ values: m.values || {}, saved: m.saved || m.values || {}, runningModel: m.runningModel || '', profiles: m.profiles || { names: [], active: '' } }); } else if (m && m.type === 'configUnreadable') { if (++tries < 8) setTimeout(() => post({ type: 'getConfig' }), 250); @@ -56,7 +57,7 @@ export function discardSession() { const off = onMessage(m => { if (m && m.type === 'config') { off(); - resolve({ values: m.values || {}, saved: m.saved || m.values || {}, + resolve({ values: m.values || {}, saved: m.saved || m.values || {}, runningModel: m.runningModel || '', profiles: m.profiles || { names: [], active: '' } }); } else if (m && m.type === 'configUnreadable') { off(); @@ -78,32 +79,6 @@ export function windowControl(action, force = false) { // Mirror the staged/unsaved state to the host so its WM_CLOSE can put up the guard for Alt+F4 and // the system menu too, not just our own title-bar button. export function setDirty(v) { post({ type: 'dirty', value: v ? '1' : '0' }); } -// MPO (Multi-Plane Overlay) lives in HKLM, so reading is free but writing needs elevation. -// getMpoState is a plain read; setMpoDisabled raises a UAC prompt in the host and resolves with the -// RE-READ state, so a cancelled prompt reverts the row rather than showing a change that never was. -// Resolves { disabled, bootKnown, atBoot }. `atBoot` is what DWM actually loaded at boot, which is -// the only honest thing to compare against when deciding whether a restart is required. -export function getMpoState() { - return new Promise(resolve => { - const off = onMessage(m => { - if (m && m.type === 'mpoState') { - off(); - resolve({ disabled: !!m.disabled, bootKnown: !!m.bootKnown, atBoot: !!m.atBoot }); - } - }); - post({ type: 'mpoState' }); - }); -} -export function setMpoDisabled(disabled) { - return new Promise(resolve => { - const off = onMessage(m => { - if (m && m.type === 'mpoApplied') { off(); resolve({ ok: !!m.ok, disabled: !!m.disabled }); } - }); - post({ type: 'setMpoDisabled', value: disabled ? '1' : '0' }); - }); -} -// Offered only after an MPO change lands: DWM reads OverlayTestMode at boot. -export function rebootNow() { post({ type: 'rebootNow' }); } // "Edit config file" -> host opens magnifier.ini with the registered .ini handler (Notepad fallback). export function openIni() { post({ type: 'openIni' }); } // "Export diagnostics" -> host zips %LOCALAPPDATA%\Wind\logs to the Desktop and reveals it. @@ -130,5 +105,4 @@ export const switchProfile = (name) => profileRequest({ type: 'switchProf // fromCurrent: "Duplicate current" - the host builds the profile from the live session as it stands. export const createProfile = (name, fromCurrent = false) => profileRequest({ type: 'createProfile', name, fromCurrent: fromCurrent ? '1' : '0' }); -export const renameProfile = (from, to) => profileRequest({ type: 'renameProfile', from, to }); export const deleteProfile = (name) => profileRequest({ type: 'deleteProfile', name }); diff --git a/ui/src/controls/HighRes.svelte b/ui/src/controls/HighRes.svelte index cb29b6c7..4ebdace5 100644 --- a/ui/src/controls/HighRes.svelte +++ b/ui/src/controls/HighRes.svelte @@ -1,18 +1,13 @@
- {#if needsRestart}Requires restart{/if} - +
diff --git a/ui/src/controls/Preview.svelte b/ui/src/controls/Preview.svelte index 624299b3..63855782 100644 --- a/ui/src/controls/Preview.svelte +++ b/ui/src/controls/Preview.svelte @@ -20,10 +20,10 @@ if (!r.key.startsWith('__') && !(r.key in values)) values[r.key] = r.def ?? ''; for (const k of bindKeys(r)) if (!(k in values)) values[k] = '0'; } - const visible = (r) => !r.showIf || String(values[r.showIf.key]) === String(r.showIf.eq); + const visible = (r) => !r.showIf || (r.showIf.ne !== undefined ? String(values[r.showIf.key]) !== String(r.showIf.ne) : String(values[r.showIf.key]) === String(r.showIf.eq)); const set = (key, val) => { values[key] = val; calls.push([key, val]); }; const extra = { - runningModel: 'hybrid', mpoNeedsRestart: false, version: '0.22.5', + runningModel: 'hybrid', version: '0.22.5', onRestart: () => calls.push('restart'), onAction: (a) => calls.push(['action', a]), onRepo: () => calls.push('repo'), pick: async () => 'RDR2.exe', diff --git a/ui/src/controls/SettingRow.svelte b/ui/src/controls/SettingRow.svelte index e420c848..9768f64a 100644 --- a/ui/src/controls/SettingRow.svelte +++ b/ui/src/controls/SettingRow.svelte @@ -7,7 +7,7 @@ // onSet onSet(key, val) for a sibling key (the extra-key switches) // live live(patch) for keybind captures (written immediately by the page) // tab optional quiet tab name shown beside the label (search results) - // extra { mpoNeedsRestart, runningModel, onRestart, + // extra { runningModel, onRestart, // profiles: {names, active, onSwitch, onNew, onDelete}, version, onRepo, onAction(name) } import Toggle from './Toggle.svelte'; import Slider from './Slider.svelte'; @@ -70,8 +70,8 @@ {:else if row.type === 'highres'} - + {:else if row.type === 'palette'} diff --git a/ui/src/controls/Slider.svelte b/ui/src/controls/Slider.svelte index bbc7c160..e394e89d 100644 --- a/ui/src/controls/Slider.svelte +++ b/ui/src/controls/Slider.svelte @@ -9,12 +9,30 @@ const SUFFIX = { times: 'x', '%': '%', ms: ' ms', seconds: ' s' }; const decimals = $derived((String(step).split('.')[1] || '').length); const shown = $derived(Number(value).toFixed(decimals) + (SUFFIX[unit] ?? '')); + + // A drag fires input on every pixel and each onChange is an ini write through the host. Send the + // first at once, then at most one per 50 ms (the latest value, trailing); release flushes. + const GAP = 50; + let last = 0, timer = null, pending = null; + function send(v) { last = performance.now(); pending = null; onChange(v); } + function flush() { + if (timer) { clearTimeout(timer); timer = null; } + if (pending !== null) send(pending); + } + function onInput(v) { + pending = v; + if (timer) return; + const wait = GAP - (performance.now() - last); + if (wait <= 0) send(v); + else timer = setTimeout(() => { timer = null; if (pending !== null) send(pending); }, wait); + } + $effect(() => () => flush());
onChange(e.currentTarget.value)} /> + oninput={(e) => onInput(e.currentTarget.value)} onchange={flush} onblur={flush} />
diff --git a/ui/src/session.js b/ui/src/session.js index 3412f996..0dde4648 100644 --- a/ui/src/session.js +++ b/ui/src/session.js @@ -37,12 +37,22 @@ export function fill(session) { return { values: { ...d, ...(session.values || {}) }, saved: { ...d, ...(session.saved || session.values || {}) } }; } -// Keys whose live value differs from the saved profile (global keys ignored, values compared as text). +// Same setting: identical trimmed text, or both plain numbers with the same value ("1.0" vs "1"), +// matching the host's SameIniValue so the page and the close guard agree. +function sameValue(a, b) { + const x = String(a ?? '').trim(), y = String(b ?? '').trim(); + if (x === y) return true; + if (x === '' || y === '') return false; + const nx = Number(x), ny = Number(y); + return Number.isFinite(nx) && Number.isFinite(ny) && nx === ny; +} + +// Keys whose live value differs from the saved profile (global keys ignored; text or numeric equality). export function changedKeys(values, saved) { const out = []; for (const k of Object.keys(values)) { if (GLOBAL_KEYS.has(k)) continue; - if (String(values[k] ?? '').trim() !== String(saved[k] ?? '').trim()) out.push(k); + if (!sameValue(values[k], saved[k])) out.push(k); } return out; } diff --git a/ui/src/settings-schema.js b/ui/src/settings-schema.js index ee3f1cc4..fa6c3eae 100644 --- a/ui/src/settings-schema.js +++ b/ui/src/settings-schema.js @@ -10,7 +10,7 @@ // Row types: keybind, slider, toggle, select, applist, highres, engine (the engine select with the // inline "Restart Wind"), palette (the Theme picker, `wide`: its control sits under the // text), profiles (dropdown + New), button, about. A row may carry showIf -// { key, eq } to hide it unless another setting has that value. A keybind row may carry `max` (how +// { key, eq } to hide it unless another setting has that value ({ key, ne } hides it when it does). A keybind row may carry `max` (how // many bindings it takes, default 1) and `onKey` (the on/off switch key of an extra key). // // Every labelled row carries `keywords`: synonyms and related words people might type (never displayed, @@ -20,6 +20,8 @@ // its label, no symbols or product names. const hybrid = { key: 'model', eq: 'hybrid' }; +// The Transform engine follows the pointer directly, so the mouse speed and smoothing sliders do nothing there. +const notTransform = { key: 'model', ne: 'transform' }; const engineOpts = ['auto', 'transform', 'render']; const engineLabels = { auto: 'Auto', transform: 'Transform', render: 'Render' }; @@ -98,8 +100,8 @@ export const groups = [ cards: [ { caption: 'Speed', rows: [ { key: 'panSpeed', type: 'slider', label: 'Arrow key speed', keywords: ['pan', 'pan speed', 'arrows', 'move', 'scroll', 'keyboard', 'how fast', 'velocity', 'shift view', 'rate'], desc: 'How fast the arrow keys move the view.', min: 0.25, max: 4, step: 0.05, def: 1.0, unit: 'times' }, - { key: 'cursorSensitivity', type: 'slider', label: 'Mouse speed', keywords: ['sensitivity', 'pointer speed', 'cursor speed', 'dpi', 'pan', 'follow', 'tracking speed', 'how fast', 'velocity', 'mouse sensitivity', 'rate'], desc: 'How fast the view follows the mouse.', min: 0.25, max: 4, step: 0.05, def: 1.0, unit: 'times' }, - { key: 'cursorSmoothing', type: 'slider', adv: true, label: 'Pan smoothing', keywords: ['inertia', 'smooth', 'smoothing', 'glide', 'momentum', 'lag', 'delay', 'damping', 'gentle', 'filter', 'mouse'], desc: 'Adds gentle inertia when the view moves.', min: 0, max: 0.95, step: 0.05, def: 0.4 }, + { key: 'cursorSensitivity', type: 'slider', label: 'Mouse speed', keywords: ['sensitivity', 'pointer speed', 'cursor speed', 'dpi', 'pan', 'follow', 'tracking speed', 'how fast', 'velocity', 'mouse sensitivity', 'rate'], desc: 'Speed of the view in the Render engine and in mouse-locked games. In the Transform engine the view follows the pointer directly, so this has no effect there.', min: 0.25, max: 4, step: 0.05, def: 1.0, unit: 'times', showIf: notTransform }, + { key: 'cursorSmoothing', type: 'slider', adv: true, label: 'Pan smoothing', keywords: ['inertia', 'smooth', 'smoothing', 'glide', 'momentum', 'lag', 'delay', 'damping', 'gentle', 'filter', 'mouse'], desc: 'Adds gentle inertia when the view moves in the Render engine. In the Transform engine the view follows the pointer directly, so this has no effect there.', min: 0, max: 0.95, step: 0.05, def: 0.4, showIf: notTransform }, ] }, { caption: 'Pointer', rows: [ { key: 'mouseAlign', type: 'select', label: 'Pointer position', keywords: ['mouse position', 'mouse', 'cursor', 'pointer', 'where', 'centred', 'centered', 'center', 'centre', 'middle', 'edge', 'edges', 'within the edges', 'align', 'alignment', 'lock to center', 'follow mode', 'placement'], desc: 'Where the mouse pointer sits while the view moves.', options: ['0', '1'], optionLabels: { '0': 'Centred', '1': 'Within the edges' }, def: '0' },