diff --git a/src/config_ui/main.cpp b/src/config_ui/main.cpp index 1146d6a..805ab53 100644 --- a/src/config_ui/main.cpp +++ b/src/config_ui/main.cpp @@ -1,3 +1,4 @@ +#include "webview_recover.h" #include #include #include @@ -28,6 +29,13 @@ using namespace Microsoft::WRL; static ComPtr g_controller; static ComPtr g_webview; +// Crash recovery (see webview_recover.h). The page's unsaved edits live here too, so they survive +// the engine dying: the page posts them on every change and gets them back after a recovery. +static std::string g_draftJson; +static bool g_recovered = false; +static bool g_onboard = false; // launched with --onboard; cleared once onboarding is done (ini) +static const UINT WM_APP_WV_FAILED = WM_APP + 0x31; +static void CreateWebView(HWND hwnd); static HWND g_hwnd = nullptr; // Unsaved-changes guard (issue #164). The UI owns "dirty" (it knows what is staged vs saved), so it // mirrors the flag here and WM_CLOSE asks the UI to confirm instead of closing. Kept in the host @@ -229,6 +237,17 @@ static void HandleWebMessage(ICoreWebView2* wv, const std::wstring& jsonW) { out += "\"" + JsonEscape(kv.first) + "\":\"" + JsonEscape(kv.second) + "\""; } out += "}}"; wv->PostWebMessageAsJson(Widen(out).c_str()); + // After a crash recovery, hand the page back the edits it had not applied yet. It asks for + // the config first on every load, so this arrives right behind it. + if (g_recovered && wind::LooksLikeJsonObject(g_draftJson.c_str(), g_draftJson.size())) { + wv->PostWebMessageAsJson(Widen("{\"type\":\"restoreDraft\",\"values\":" + g_draftJson + "}").c_str()); + wind::Log(wind::LogLevel::Info, "config", "recovery: unsaved edits handed back to the page"); + } + g_recovered = false; + } else if (type == "draft") { + // While a recovery is pending the draft held here is the one to hand back; nothing the new + // page reports before its config arrives may replace it. + if (!g_recovered) g_draftJson = JsonField(j, "json"); } else if (type == "setConfig") { std::string key = JsonField(j, "key"), value = JsonField(j, "value"); if (!key.empty()) { @@ -470,6 +489,50 @@ static LRESULT CALLBACK WndProc(HWND h, UINT m, WPARAM w, LPARAM l) { if (pt.y < titleH && pt.x < rc.right - MulDiv(120, dpi, 96)) return HTCAPTION; return HTCLIENT; } + if (m == WM_APP_WV_FAILED) { + static wind::WvRecoverBudget budget; + static bool gaveUp = false; + const int kind = (int)w; + // Onboarding finishing is an in-page switch, so the URL still says ?mode=onboard: a recovery + // would otherwise re-run the guided setup, whose X quits Wind (review 2026-09-30). + const bool wasOnboard = g_onboard; + if (g_onboard) { + auto v = wind::ReadIniValues(ReadFileUtf8(IniPath())); + auto it = v.find("onboarded"); + if (it != v.end() && it->second == "1") g_onboard = false; + } + switch (wind::DecideWvRecovery(kind, budget, GetTickCount64())) { + case wind::WvRecovery::Reload: + wind::Log(wind::LogLevel::Warn, "config", "recovery: page process failed (kind=%d), reloading", kind); + g_recovered = true; + if (g_webview) { + if (wasOnboard && !g_onboard) g_webview->Navigate(L"https://wind.config/index.html"); + else g_webview->Reload(); + } + break; + case wind::WvRecovery::Recreate: + wind::Log(wind::LogLevel::Warn, "config", "recovery: WebView2 engine exited, recreating it"); + g_recovered = true; + if (g_controller) g_controller->Close(); + g_webview.Reset(); g_controller.Reset(); + CreateWebView(h); + break; + case wind::WvRecovery::GiveUp: + // The page is gone for good: the unsaved-changes guard would post its question to a dead + // page and the window could never close (review 2026-09-30). One message, not one per + // queued failure event. + g_forceClose = true; g_dirty = false; + if (gaveUp) break; + gaveUp = true; + wind::Log(wind::LogLevel::Error, "config", "recovery: WebView2 keeps failing (kind=%d); giving up", kind); + MessageBoxW(h, L"Wind Settings keeps crashing (its web engine exits on start).\n\n" + L"Close it and open Settings again. If it keeps happening, an overlay tool " + L"such as RivaTuner may be interfering.", L"Wind", MB_ICONWARNING | MB_OK); + break; + default: break; + } + return 0; + } if (m == WM_SIZE && g_controller) { RECT r; GetClientRect(h, &r); g_controller->put_Bounds(r); return 0; } if (m == WM_GETMINMAXINFO) { // enforce a minimum window size (DPI-scaled) UINT dpi = GetDpiForWindow(h); if (!dpi) dpi = 96; @@ -512,65 +575,9 @@ static LRESULT CALLBACK WndProc(HWND h, UINT m, WPARAM w, LPARAM l) { if (m == WM_DESTROY) { KillTimer(h, kWindWatchTimerId); PostQuitMessage(0); return 0; } return DefWindowProcW(h, m, w, l); } -int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR lpCmdLine, int) { - // Single-instance: opening Settings from the tray (or any second launch) focuses the existing - // window instead of stacking another WindConfig.exe with its own WebView2. - HANDLE mtx = CreateMutexW(nullptr, TRUE, L"WindConfig_SingleInstance"); - if (mtx && GetLastError() == ERROR_ALREADY_EXISTS) { - HWND existing = FindWindowW(L"WindConfigWnd", nullptr); - if (existing) { - if (IsIconic(existing)) ShowWindow(existing, SW_RESTORE); - SetForegroundWindow(existing); - } - CloseHandle(mtx); - return 0; - } - wind::LogInit(L"config"); - atexit(wind::LogShutdown); - wind::LogSystemSnapshot("config", ""); - - bool onboard = lpCmdLine && wcsstr(lpCmdLine, L"--onboard") != nullptr; - // Settings should never run without the magnifier, and never show the config page against a - // not-yet-set-up config. So, when launched as Settings (no --onboard): - // - NOT set up yet -> launch Wind.exe and exit. Wind sees onboarded==0 and runs the guided - // setup (re-spawning us with --onboard), so the user lands in onboarding, not the config page. - // If launching Wind.exe FAILS, show onboarding ourselves rather than the config page (never - // dead-end into the config UI against an unconfigured app). - // - set up, Wind not running -> launch Wind.exe, then show the config page. - // - set up, Wind already running -> just show the config page. - // The --onboard guard prevents a launch loop. - if (!onboard) { - auto vals = wind::ReadIniValues(ReadFileUtf8(IniPath())); - auto it = vals.find("onboarded"); - bool onboarded = (it != vals.end() && it->second == "1"); - if (!onboarded) { - if (LaunchWind()) { if (mtx) CloseHandle(mtx); return 0; } - onboard = true; // couldn't launch Wind - run onboarding in THIS window, not the config page - } else if (!WindRunning()) { - // Set up, but the magnifier isn't running: start it, then continue to the config page. - LaunchWind(); - } - } - // Per-monitor-V2 DPI awareness so WebView2 renders at native resolution (not bitmap-scaled, - // which looked low-res/blurry). Must be set before any window is created. - SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); - WNDCLASSW wc{}; wc.lpfnWndProc = WndProc; wc.hInstance = hInst; wc.lpszClassName = L"WindConfigWnd"; - wc.hIcon = LoadIconW(hInst, MAKEINTRESOURCEW(IDI_WIND)); // logo badge for taskbar/alt-tab - RegisterClassW(&wc); - HWND hwnd = CreateWindowExW(0, wc.lpszClassName, L"Wind Settings", - WS_POPUP | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_CLIPCHILDREN, - CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, nullptr, nullptr, hInst, nullptr); - SetTimer(hwnd, kWindWatchTimerId, kWindWatchPeriodMs, nullptr); - // Size to a sensible default (scaled for this monitor's DPI) and center on the work area. - UINT dpi = GetDpiForWindow(hwnd); if (!dpi) dpi = 96; - int ww = MulDiv(1040, dpi, 96), wh = MulDiv(740, dpi, 96); - RECT wa{}; SystemParametersInfoW(SPI_GETWORKAREA, 0, &wa, 0); - int wx = wa.left + ((wa.right - wa.left) - ww) / 2; - int wy = wa.top + ((wa.bottom - wa.top) - wh) / 2; - SetWindowPos(hwnd, nullptr, wx, wy, ww, wh, SWP_NOZORDER); - ShowWindow(hwnd, SW_SHOW); - g_hwnd = hwnd; - std::wstring uiDir = ExeDir() + L"\\ui\\dist"; +// Creates the WebView2 environment + controller and loads the UI. Called once at start and again +// by the crash recovery when the engine has exited (WM_APP_WV_FAILED). +static void CreateWebView(HWND hwnd) { // WebView2's user-data folder MUST be writable. The default sits next to the exe // (\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 @@ -584,6 +591,8 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR lpCmdLine, int) { CreateDirectoryW((std::wstring(buf) + L"\\Wind").c_str(), nullptr); CreateDirectoryW(userData.c_str(), nullptr); } + const std::wstring uiDir = ExeDir() + L"\\ui\\dist"; + const bool onboard = g_onboard; CreateCoreWebView2EnvironmentWithOptions(nullptr, userData.c_str(), nullptr, Callback( [hwnd, uiDir, onboard](HRESULT hr, ICoreWebView2Environment* env) -> HRESULT { @@ -604,8 +613,16 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR lpCmdLine, int) { } env->CreateCoreWebView2Controller(hwnd, Callback( - [hwnd, uiDir, onboard](HRESULT, ICoreWebView2Controller* controller) -> HRESULT { - if (!controller) return S_OK; + [hwnd, uiDir, onboard](HRESULT chr, ICoreWebView2Controller* controller) -> HRESULT { + if (FAILED(chr) || !controller) { + // Silent before (review 2026-09-30): log it and go through the same + // budgeted recreate as an engine crash, so a transient failure heals and a + // persistent one ends in the give-up message instead of a blank window. + wind::Log(wind::LogLevel::Error, "config", "WebView2 controller creation failed hr=0x%08lX", + (unsigned long)chr); + PostMessageW(hwnd, WM_APP_WV_FAILED, 0, 0); + return S_OK; + } g_controller = controller; g_controller->get_CoreWebView2(&g_webview); { ComPtr s0; @@ -620,6 +637,16 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR lpCmdLine, int) { wv3->SetVirtualHostNameToFolderMapping(L"wind.config", uiDir.c_str(), COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_ALLOW); } EventRegistrationToken tok; + // A dead engine or page is recovered on the UI thread (WndProc), never from + // inside this callback: the recreate path releases the objects raising it. + g_webview->add_ProcessFailed( + Callback( + [](ICoreWebView2*, ICoreWebView2ProcessFailedEventArgs* args) -> HRESULT { + COREWEBVIEW2_PROCESS_FAILED_KIND k = COREWEBVIEW2_PROCESS_FAILED_KIND_BROWSER_PROCESS_EXITED; + if (args) args->get_ProcessFailedKind(&k); + PostMessageW(g_hwnd, WM_APP_WV_FAILED, (WPARAM)k, 0); + return S_OK; + }).Get(), &tok); g_webview->add_WebMessageReceived( Callback( [](ICoreWebView2* wv, ICoreWebView2WebMessageReceivedEventArgs* args) -> HRESULT { @@ -634,6 +661,68 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR lpCmdLine, int) { }).Get()); return S_OK; }).Get()); +} + +int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR lpCmdLine, int) { + // Single-instance: opening Settings from the tray (or any second launch) focuses the existing + // window instead of stacking another WindConfig.exe with its own WebView2. + HANDLE mtx = CreateMutexW(nullptr, TRUE, L"WindConfig_SingleInstance"); + if (mtx && GetLastError() == ERROR_ALREADY_EXISTS) { + HWND existing = FindWindowW(L"WindConfigWnd", nullptr); + if (existing) { + if (IsIconic(existing)) ShowWindow(existing, SW_RESTORE); + SetForegroundWindow(existing); + } + CloseHandle(mtx); + return 0; + } + wind::LogInit(L"config"); + atexit(wind::LogShutdown); + wind::LogSystemSnapshot("config", ""); + + bool onboard = lpCmdLine && wcsstr(lpCmdLine, L"--onboard") != nullptr; + // Settings should never run without the magnifier, and never show the config page against a + // not-yet-set-up config. So, when launched as Settings (no --onboard): + // - NOT set up yet -> launch Wind.exe and exit. Wind sees onboarded==0 and runs the guided + // setup (re-spawning us with --onboard), so the user lands in onboarding, not the config page. + // If launching Wind.exe FAILS, show onboarding ourselves rather than the config page (never + // dead-end into the config UI against an unconfigured app). + // - set up, Wind not running -> launch Wind.exe, then show the config page. + // - set up, Wind already running -> just show the config page. + // The --onboard guard prevents a launch loop. + if (!onboard) { + auto vals = wind::ReadIniValues(ReadFileUtf8(IniPath())); + auto it = vals.find("onboarded"); + bool onboarded = (it != vals.end() && it->second == "1"); + if (!onboarded) { + if (LaunchWind()) { if (mtx) CloseHandle(mtx); return 0; } + onboard = true; // couldn't launch Wind - run onboarding in THIS window, not the config page + } else if (!WindRunning()) { + // Set up, but the magnifier isn't running: start it, then continue to the config page. + LaunchWind(); + } + } + // Per-monitor-V2 DPI awareness so WebView2 renders at native resolution (not bitmap-scaled, + // which looked low-res/blurry). Must be set before any window is created. + SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + WNDCLASSW wc{}; wc.lpfnWndProc = WndProc; wc.hInstance = hInst; wc.lpszClassName = L"WindConfigWnd"; + wc.hIcon = LoadIconW(hInst, MAKEINTRESOURCEW(IDI_WIND)); // logo badge for taskbar/alt-tab + RegisterClassW(&wc); + HWND hwnd = CreateWindowExW(0, wc.lpszClassName, L"Wind Settings", + WS_POPUP | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_CLIPCHILDREN, + CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, nullptr, nullptr, hInst, nullptr); + SetTimer(hwnd, kWindWatchTimerId, kWindWatchPeriodMs, nullptr); + // Size to a sensible default (scaled for this monitor's DPI) and center on the work area. + UINT dpi = GetDpiForWindow(hwnd); if (!dpi) dpi = 96; + int ww = MulDiv(1040, dpi, 96), wh = MulDiv(740, dpi, 96); + RECT wa{}; SystemParametersInfoW(SPI_GETWORKAREA, 0, &wa, 0); + int wx = wa.left + ((wa.right - wa.left) - ww) / 2; + int wy = wa.top + ((wa.bottom - wa.top) - wh) / 2; + SetWindowPos(hwnd, nullptr, wx, wy, ww, wh, SWP_NOZORDER); + ShowWindow(hwnd, SW_SHOW); + g_hwnd = hwnd; + g_onboard = onboard; + CreateWebView(hwnd); MSG msg; while (GetMessageW(&msg, nullptr, 0, 0)) { TranslateMessage(&msg); DispatchMessageW(&msg); } wind::LogShutdown(); return 0; diff --git a/src/config_ui/webview_recover.h b/src/config_ui/webview_recover.h new file mode 100644 index 0000000..d0abbd5 --- /dev/null +++ b/src/config_ui/webview_recover.h @@ -0,0 +1,46 @@ +#pragma once +// What Settings does when a WebView2 process dies (field 2026-09-29: RTSS's global hook crashes the +// WebView2 browser process at start-up, a use-after-unload of dxgi.dll inside RTSSHooks64, and the +// window went black). Pure; tests/test_webview_recover.cpp. +// +// The kinds are COREWEBVIEW2_PROCESS_FAILED_KIND values, kept as ints so this header needs no SDK: +// 0 BROWSER_PROCESS_EXITED the whole engine is gone: the controller is dead, recreate it +// 1 RENDER_PROCESS_EXITED the page's process died: reload +// 2 RENDER_PROCESS_UNRESPONSIVE: NOT recovered. A slow page (heavy system load) is not a dead +// one, and reloading it would throw away its state (review 2026-09-30); WebView2 keeps +// raising the event while it stays hung, and a page that really dies raises kind 1. +// others (GPU, utility, frame renderer): WebView2 restarts those itself. +namespace wind { + +enum class WvRecovery { None, Reload, Recreate, GiveUp }; + +// At most kWvMaxRecoveries in any kWvWindowMs: an engine that dies on every start must not loop. +inline constexpr int kWvMaxRecoveries = 3; +inline constexpr unsigned long long kWvWindowMs = 60000; + +struct WvRecoverBudget { unsigned long long at[kWvMaxRecoveries] = {}; int n = 0; }; + +inline WvRecovery DecideWvRecovery(int kind, WvRecoverBudget& b, unsigned long long nowMs) { + WvRecovery r = WvRecovery::None; + if (kind == 0) r = WvRecovery::Recreate; + else if (kind == 1) r = WvRecovery::Reload; + if (r == WvRecovery::None) return r; + // Forget recoveries older than the window. + int keep = 0; + for (int i = 0; i < b.n; ++i) + if (nowMs - b.at[i] < kWvWindowMs) b.at[keep++] = b.at[i]; + b.n = keep; + if (b.n >= kWvMaxRecoveries) return WvRecovery::GiveUp; + b.at[b.n++] = nowMs; + return r; +} + +// The page's unsaved edits travel as a JSON object of string values; only something shaped like one +// is ever echoed back into the page. +inline bool LooksLikeJsonObject(const char* s, unsigned long long len) { + unsigned long long a = 0, z = len; + while (a < z && (s[a] == ' ' || s[a] == '\n' || s[a] == '\r' || s[a] == '\t')) ++a; + while (z > a && (s[z - 1] == ' ' || s[z - 1] == '\n' || s[z - 1] == '\r' || s[z - 1] == '\t')) --z; + return z - a >= 2 && s[a] == '{' && s[z - 1] == '}'; +} +} // namespace wind diff --git a/src/version.h b/src/version.h index e6b203e..4a7f817 100644 --- a/src/version.h +++ b/src/version.h @@ -4,7 +4,7 @@ #define WIND_VER_MAJOR 0 #define WIND_VER_MINOR 13 -#define WIND_VER_PATCH 1 +#define WIND_VER_PATCH 2 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.13.1" +#define WIND_VERSION_STR "0.13.2" diff --git a/tests/test_webview_recover.cpp b/tests/test_webview_recover.cpp new file mode 100644 index 0000000..9e7a252 --- /dev/null +++ b/tests/test_webview_recover.cpp @@ -0,0 +1,33 @@ +#include "doctest.h" +#include "../src/config_ui/webview_recover.h" +#include +using namespace wind; + +TEST_CASE("a dead browser engine is recreated, a dead page reloaded, the rest left to WebView2") { + WvRecoverBudget b; + CHECK(DecideWvRecovery(0, b, 1000) == WvRecovery::Recreate); + CHECK(DecideWvRecovery(1, b, 2000) == WvRecovery::Reload); + CHECK(DecideWvRecovery(2, b, 3000) == WvRecovery::None); // merely slow: left alone, not counted + WvRecoverBudget c; + CHECK(DecideWvRecovery(3, c, 1000) == WvRecovery::None); // GPU process: WebView2 restarts it + CHECK(DecideWvRecovery(5, c, 1000) == WvRecovery::None); + CHECK(c.n == 0); // not counted against the budget +} +TEST_CASE("an engine that dies on every start gives up after three in a minute") { + WvRecoverBudget b; + CHECK(DecideWvRecovery(0, b, 0) == WvRecovery::Recreate); + CHECK(DecideWvRecovery(0, b, 1000) == WvRecovery::Recreate); + CHECK(DecideWvRecovery(0, b, 2000) == WvRecovery::Recreate); + CHECK(DecideWvRecovery(0, b, 3000) == WvRecovery::GiveUp); + CHECK(DecideWvRecovery(0, b, 59999) == WvRecovery::GiveUp); + CHECK(DecideWvRecovery(0, b, 60001) == WvRecovery::Recreate); // the first one aged out +} +TEST_CASE("only a JSON object is echoed back as the page's draft") { + auto ok = [](const char* s) { return LooksLikeJsonObject(s, std::strlen(s)); }; + CHECK(ok("{\"colorWarmPct\":\"80\"}")); + CHECK(ok(" {} ")); + CHECK_FALSE(ok("")); + CHECK_FALSE(ok("null")); + CHECK_FALSE(ok("[1]")); + CHECK_FALSE(ok("{")); +} diff --git a/ui/src/Settings.svelte b/ui/src/Settings.svelte index 5421570..a6dd39b 100644 --- a/ui/src/Settings.svelte +++ b/ui/src/Settings.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { sections } from './settings-schema.js'; import { getConfig, setConfig, openIni, exportDiagnostics, windowControl, onMessage, - getMpoState, setMpoDisabled, rebootNow, setDirty, + getMpoState, setMpoDisabled, rebootNow, setDirty, postDraft, listProfiles, switchProfile, createProfile, renameProfile, duplicateProfile, deleteProfile } from './bridge.js'; import ProfileMenu from './lib/ProfileMenu.svelte'; @@ -55,6 +55,7 @@ } onMount(async () => { await loadValues(); + loaded = true; profiles = await listProfiles(); // MPO lives in the registry, not the ini, so it is fetched separately and staged separately. const s = await getMpoState(); @@ -263,6 +264,20 @@ function toggleTheme() { theme = nextTheme(theme); setTheme(theme); } $: dirty = Object.keys(values).some(k => String(values[k]) !== String(saved[k])) || mpoDirty; $: setDirty(dirty); // keep the host's WM_CLOSE guard in step with the staged state + // Crash recovery: the host keeps the unapplied edits and returns them after recreating the engine. + // Only once the settings have loaded: a fresh page would otherwise report an empty draft before the + // host has handed the old one back, and overwrite it (measured in the first crash test). + let loaded = false; + $: if (loaded) postDraft(Object.fromEntries(Object.keys(values).filter(k => String(values[k]) !== String(saved[k])) + .map(k => [k, values[k]]))); + onMessage(m => { + if (m && m.type === 'restoreDraft' && m.values && typeof m.values === 'object') { + const back = {}; + for (const k of Object.keys(m.values)) if (k in values) back[k] = String(m.values[k]); + values = { ...values, ...back }; + if (Object.keys(back).length) announce('Settings recovered after a crash; your unsaved changes are kept.'); + } + }); // Advanced rows (schema `advanced:true`) are hidden unless "Show advanced settings" is on. Driven // by the live `values` so toggling it reveals/hides rows immediately (before Apply). $: advancedOn = Number(values.showAdvanced) === 1; diff --git a/ui/src/bridge.js b/ui/src/bridge.js index 9b3946f..395a33b 100644 --- a/ui/src/bridge.js +++ b/ui/src/bridge.js @@ -28,6 +28,9 @@ 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' }); } +// The staged (unapplied) edits, mirrored to the host on every change. If WebView2's engine dies, +// the host recreates it and hands them back ('restoreDraft'), so a crash costs no edits. +export function postDraft(changed) { post({ type: 'draft', json: JSON.stringify(changed || {}) }); } // 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. diff --git a/ui/tests/settings.spec.js b/ui/tests/settings.spec.js index dd8c6b1..71788a0 100644 --- a/ui/tests/settings.spec.js +++ b/ui/tests/settings.spec.js @@ -19,6 +19,7 @@ test.beforeEach(async ({ page }) => { listeners.forEach(fn => fn({ data: { type: 'config', values: { zoomInSpeed: '1.2', smoothZoom: '0', uiTheme: 'auto', showAdvanced: '1', model: 'render', zoomInButton: '2', zoomInVk: '33', zoomOutButton: '1', zoomOutVk: '34', cursorLockVk: '113', txSamplingMode: window.__cfgSampling !== undefined ? window.__cfgSampling : '0', ...(window.__cfgExtra || {}) } } })); if (msg.type === 'setConfig') window.__sets.push(msg); if (msg.type === 'openRepo') window.__sets.push(msg); + if (msg.type === 'draft') window.__sets.push(msg); // crash-recovery mirror of unapplied edits // MPO lives in the registry, not the ini. __mpoDisabled drives what the "registry" reports; // __mpoOk drives whether the elevated write is accepted (false = UAC dismissed). if (msg.type === 'mpoState') @@ -642,3 +643,17 @@ test('no notice when every stored bind is allowed (#285)', async ({ page }) => { await expect(page.getByText('Zoom-in speed')).toBeVisible(); await expect(page.getByRole('dialog', { name: 'Some keybinds were removed' })).toHaveCount(0); }); + +test('unapplied edits are mirrored to the host and restored after a crash recovery', async ({ page }) => { + await page.goto('/'); + const row = page.getByText('Zoom-in speed', { exact: true }).locator('xpath=../..'); + await row.locator('input[type=range]').fill('2'); + await expect.poll(async () => page.evaluate(() => { + const d = window.__sets.filter(m => m.type === 'draft').at(-1); + return d ? JSON.parse(d.json).zoomInSpeed : null; + })).toBe('2'); + // The host recreated the engine: a fresh page load gets its config, then the draft back. + await page.evaluate(() => window.__hostSend({ type: 'restoreDraft', values: { zoomInSpeed: '3.5', notARealKey: '1' } })); + await expect(row).toContainText('3.5'); + await expect(page.getByRole('button', { name: 'Apply' })).toBeEnabled(); +});