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
7 changes: 2 additions & 5 deletions docs/architecture/09-settings-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.
Expand Down
8 changes: 5 additions & 3 deletions src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
14 changes: 14 additions & 0 deletions src/config_ui/ini_edit.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,18 @@ std::map<std::string, std::string> 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 <windows.h>).
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;
}
}
64 changes: 34 additions & 30 deletions src/config_ui/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<ICoreWebView2NavigationStartingEventHandler>(
[](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<ICoreWebView2NewWindowRequestedEventHandler>(
[](ICoreWebView2*, ICoreWebView2NewWindowRequestedEventArgs* args) -> HRESULT {
args->put_Handled(TRUE);
return S_OK;
}).Get(), &tok);
g_webview->add_NavigationCompleted(
Callback<ICoreWebView2NavigationCompletedEventHandler>(
[](ICoreWebView2*, ICoreWebView2NavigationCompletedEventArgs*) -> HRESULT {
Expand Down
51 changes: 4 additions & 47 deletions src/config_ui/mpo.h
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
#pragma once
#include <windows.h>
#include <shellapi.h>
#include <string>

// 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";
Expand All @@ -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
Expand All @@ -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
15 changes: 6 additions & 9 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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_);
}

Expand Down Expand Up @@ -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<TransformModel*>(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.
Expand Down
7 changes: 7 additions & 0 deletions src/profiles_io.h
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading
Loading