Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 150 additions & 61 deletions src/config_ui/main.cpp
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#include "webview_recover.h"
#include <windows.h>
#include <windowsx.h>
#include <shellapi.h>
Expand Down Expand Up @@ -28,6 +29,13 @@
using namespace Microsoft::WRL;
static ComPtr<ICoreWebView2Controller> g_controller;
static ComPtr<ICoreWebView2> 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
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
// (<exeDir>\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
Expand All @@ -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<ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler>(
[hwnd, uiDir, onboard](HRESULT hr, ICoreWebView2Environment* env) -> HRESULT {
Expand All @@ -604,8 +613,16 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR lpCmdLine, int) {
}
env->CreateCoreWebView2Controller(hwnd,
Callback<ICoreWebView2CreateCoreWebView2ControllerCompletedHandler>(
[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<ICoreWebView2Settings> s0;
Expand All @@ -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<ICoreWebView2ProcessFailedEventHandler>(
[](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<ICoreWebView2WebMessageReceivedEventHandler>(
[](ICoreWebView2* wv, ICoreWebView2WebMessageReceivedEventArgs* args) -> HRESULT {
Expand All @@ -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;
Expand Down
46 changes: 46 additions & 0 deletions src/config_ui/webview_recover.h
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions src/version.h
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading
Loading