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
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ Chapter numbers refer to `docs/architecture/NN-*.md`.
for the process, not `taskkill`.
- Program Files is read-only for the runtime: resolve the ini with `wind::ResolveIniPath()`, logs with
`ResolveLogDir`, and keep the explicit WebView2 user-data folder. Never write next to the exe.
- An unreadable ini is not a missing one (another process may be mid-replace). Read the live ini for
a read-modify-write with `wind::ReadLiveIni` and stop when it fails; never write defaults over an
existing file. See 08.

## Toolchain and workflow
- Visual Studio is a prerelease channel here; `build.bat` calls vswhere with `-all -prerelease`.
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture/02-tick-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ There is no settings IPC. `WindConfig.exe` writes `magnifier.ini` and the core n
kernel transition 144 times a second for a file a human changes. Without a watch handle the loop
falls back to a ~1 s timed poll.
- Only a changed mtime (`ConfigMTime`) proceeds to a reload.
- An unreadable ini (another process mid-replace) keeps the running settings: the mtime is not
taken and `t.configRetry` re-checks on the next poll. See [08](08-config-profiles.md).

**UI-only writes never reload.** A reload rebuilds `ZoomController`, which collapses an active zoom
to 1x. `StripUiOnlyKeys` (`src/config.cpp`) drops `uiTheme`, `uiPalette`, `showAdvanced` and
Expand Down
12 changes: 12 additions & 0 deletions docs/architecture/08-config-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ others are not).
- Every writer uses `wind::WriteTextFileAtomic` (`src/profiles_io.h`): write a temp file, then
`MoveFileExW(MOVEFILE_REPLACE_EXISTING)`. The temp name embeds the process id, so two writers
never clobber each other's temp file.
- **Around each replace the name briefly refuses opens.** Measured 2026-10-07: during a burst of
replaces ~1% of reads failed with `ERROR_ACCESS_DENIED` (the replaced file is delete-pending), and
a replace fails while a reader holds the file. So `ReadTextFileOk` and `WriteTextFileAtomic`
retry sharing and access errors until a deadline (250 ms; the core's tick reads with 20 ms and
re-checks on its next poll), and reads share delete so they never block a replace.
- **An unreadable ini is never a missing one.** Before this, a failed open in `LoadConfig` wrote the
defaults over the user's file, and a failed `ReadTextFile` returned "" to read-modify-write
callers. Field: dragging the tray's Night light slider mid-zoom made the core reload defaults
(zoom keys unbound, so the zoom stuck; `onboarded=0`, so the next start opened the setup).
`LoadConfig` creates the defaults only for a missing file; the hot-reload keeps its settings and
retries (`config ini unreadable on reload`); writers that read the live ini first use
`ReadLiveIni` and stop when it fails.
- The only cross-process kernel objects are the single-instance mutexes and the
`Local\Wind_QuitRequest` event (quit, restart handshake, installer). A window message would not
work: UIPI drops `PostMessage` from a normal process to a UIAccess one.
Expand Down
37 changes: 27 additions & 10 deletions src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -638,20 +638,37 @@ std::string DefaultIniText() {
// --- File I/O (excluded from the pure test build via WIND_TESTS) ------------
#include <windows.h>
#include <fstream>
#include "profiles_io.h" // ReadTextFileOk / WriteTextFileAtomic: retry through the replace window
#include "logging.h"
namespace wind {
Config LoadConfig(const std::wstring& path) {
std::ifstream f(path);
if (!f) {
// Write defaults so the user has something to edit, and run with exactly what was
bool TryLoadConfig(const std::wstring& path, Config& out) {
std::string text;
if (!ReadTextFileOk(path, text)) {
// NEVER write the defaults over an ini that exists (field 2026-10-07): an open that failed
// while the tray replaced the file took this branch and reset every setting mid-zoom.
if (GetFileAttributesW(path.c_str()) != INVALID_FILE_ATTRIBUTES) return false;
const DWORD e = GetLastError();
if (e != ERROR_FILE_NOT_FOUND && e != ERROR_PATH_NOT_FOUND) return false;
// Missing: write defaults so the user has something to edit, and run with exactly what was
// written (issue #274): returning Config{} here let the template and the struct
// defaults drift apart silently - the cursorScaleWithZoom trap in another form.
const std::string text = DefaultIniText();
std::ofstream out(path);
out << text;
return ParseConfig(text);
text = DefaultIniText();
WriteTextFileAtomic(path, text);
out = ParseConfig(text);
return true;
}
std::string text((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
return ParseConfig(text);
// An ini with no settings at all is a half-written or truncated file, not a user's choice.
if (text.find('=') == std::string::npos) return false;
out = ParseConfig(text);
return true;
}
Config LoadConfig(const std::wstring& path) {
Config c;
if (TryLoadConfig(path, c)) return c;
// Unreadable at startup: run on the defaults in memory, leave the file alone.
wind::Log(wind::LogLevel::Warn, "config", "magnifier.ini unreadable at load (err=%lu); running on defaults, file untouched",
GetLastError());
return ParseConfig(DefaultIniText());
}
unsigned long long ConfigMTime(const std::wstring& path) {
WIN32_FILE_ATTRIBUTE_DATA d{};
Expand Down
4 changes: 4 additions & 0 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,10 @@ double OutlineDwellSeconds(bool inBand, double prevSeconds, double dt, double th
// The first-run ini text; LoadConfig writes it and returns ParseConfig of it (issue #274).
std::string DefaultIniText();
Config LoadConfig(const std::wstring& path);
// Hot-reload form: false when the ini exists but could not be read (another process is replacing
// it, or it reads empty). The caller keeps its current Config and tries again later. Only a
// MISSING ini is ever (re)created with the defaults.
bool TryLoadConfig(const std::wstring& path, Config& out);
// I/O: last write time as a comparable tick count; 0 if missing.
unsigned long long ConfigMTime(const std::wstring& path);
}
13 changes: 8 additions & 5 deletions src/config_ui/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,8 @@ static std::string DoSwitchProfile(const std::string& name) {
std::string profText;
if (!wind::ReadTextFileOk(pp, profText)) return "Could not read the profile file";
{ std::string terr = wind::ProfileTextError(profText); if (!terr.empty()) return terr; }
const std::string oldLive = ReadFileUtf8(IniPath());
std::string oldLive;
if (!wind::ReadLiveIni(IniPath(), oldLive)) return "Could not read the config file";
// Capture hand edits (openIni) into the outgoing profile before the live ini is replaced.
wind::MirrorLiveToActiveProfile(IniPath(), oldLive);
const std::string newLive = wind::MakeLiveText(profText, oldLive, name);
Expand Down Expand Up @@ -346,8 +347,9 @@ static void HandleWebMessage(ICoreWebView2* wv, const std::wstring& jsonW) {
// 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.
// setConfig writes the live ini (the session) only; the profile file changes on Save.
if (!WriteFileAtomic(IniPath(),
wind::UpdateIniText(ReadFileUtf8(IniPath()), key, value))) {
std::string live;
if (!wind::ReadLiveIni(IniPath(), live) ||
!WriteFileAtomic(IniPath(), wind::UpdateIniText(live, key, value))) {
wind::Log(wind::LogLevel::Warn, "config", "setConfig: writing %s=%s failed",
key.c_str(), value.c_str());
wv->PostWebMessageAsJson(
Expand Down Expand Up @@ -523,8 +525,9 @@ static void HandleWebMessage(ICoreWebView2* wv, const std::wstring& jsonW) {
if (err.empty() && wind::SameProfileName(vals["profile"], from)) {
// The pointer update must land or the live ini names a file that no longer exists;
// verify the write and roll the rename back if it failed.
if (!wind::WriteTextFileAtomic(IniPath(),
wind::UpdateIniText(ReadFileUtf8(IniPath()), "profile", to))) {
std::string live;
if (!wind::ReadLiveIni(IniPath(), live) ||
!wind::WriteTextFileAtomic(IniPath(), wind::UpdateIniText(live, "profile", to))) {
MoveFileExW(ProfilePath(to).c_str(), ProfilePath(from).c_str(), 0);
err = "Could not update the config file; rename undone";
}
Expand Down
29 changes: 25 additions & 4 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ struct TickState {
LockDetector detector; // free vs game-locked cursor
bool prevDetLocked = false; // edge-log the detector state (issue #221)
std::string lastCoreIni; // stripped ini fingerprint (skip UI-only reloads)
bool configRetry = false; // last reload found the ini unreadable: check again
POINT lastSetVirtual{}; // MEASURED post-present pointer position (virtual px), the
// baseline for the next tick's hand delta (issue #169: never
// assume the weld landed - measure)
Expand Down Expand Up @@ -1102,6 +1103,7 @@ static void RunTick(TickState& t) {
t.sinceCheck += rawDt;
if (t.sinceCheck >= 0.25) {
t.sinceCheck = 0.0;
if (t.configRetry) checkConfig = true; // the change that failed to read is not re-notified
if (WaitForSingleObject(t.configWatch, 0) == WAIT_OBJECT_0) {
checkConfig = true;
// Re-arm for the next change. If that fails (e.g. the watched dir vanished), close
Expand All @@ -1120,14 +1122,33 @@ static void RunTick(TickState& t) {
if (checkConfig) {
unsigned long long m = ConfigMTime(t.iniPath);
if (m != t.lastMtime) {
t.lastMtime = m;
// Skip the reload when only UI-owned keys changed (uiTheme/uiPalette/showAdvanced/onboarded):
// the settings app writes those, the core never reads them, and the reload below
// resets the ZoomController - a theme toggle mid-zoom collapsed the zoom to 1x.
std::string stripped = wind::StripUiOnlyKeys(wind::ReadTextFile(t.iniPath));
if (t.lastCoreIni.empty() || stripped != t.lastCoreIni) {
// An UNREADABLE ini (another process mid-replace, see ReadTextFileOk) is not a change:
// keep the running settings and look again on the next check (lastMtime not taken).
// A MISSING ini is recreated with the defaults, as at startup (TryLoadConfig).
std::string raw;
// Short budget: this is the tick thread, and an unreadable ini is simply re-read next poll.
bool readOk = wind::ReadTextFileOk(t.iniPath, raw, 20) && raw.find('=') != std::string::npos;
if (!readOk && GetFileAttributesW(t.iniPath.c_str()) == INVALID_FILE_ATTRIBUTES) {
Config fresh;
readOk = wind::TryLoadConfig(t.iniPath, fresh) && wind::ReadTextFileOk(t.iniPath, raw, 20);
}
std::string stripped = readOk ? wind::StripUiOnlyKeys(raw) : std::string();
const bool loaded = readOk && (t.lastCoreIni.empty() || stripped != t.lastCoreIni);
Config nc;
if (loaded) nc = ParseConfig(raw);
if (!readOk) {
if (!t.configRetry)
wind::Log(wind::LogLevel::Warn, "config", "ini unreadable on reload, keeping the running settings");
t.configRetry = true;
} else {
t.lastMtime = m;
t.configRetry = false;
}
if (loaded) {
t.lastCoreIni = stripped;
Config nc = LoadConfig(t.iniPath);
// Issue #242: the high-res/MPO option is atomic at restart - while an MPO restart is
// pending (registry != boot) the BOOT state's look holds in both directions, and
// crisp never runs on an MPO-enabled boot (the 16-bit TDR combo; covers profile
Expand Down
68 changes: 59 additions & 9 deletions src/profiles_io.h
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,55 @@ inline std::vector<std::wstring> ListProfileFiles(const std::wstring& dir) {
[](const std::wstring& a, const std::wstring& b) { return _wcsicmp(a.c_str(), b.c_str()) < 0; });
return names;
}
// REPLACE WINDOW (field 2026-10-07: tray slider drags reset the whole ini to defaults). The writers
// replace the ini atomically (WriteTextFileAtomic), but for a moment around each MoveFileEx the
// name refuses opens: measured on this rig, ~1% of reads during a burst of replaces failed with
// ERROR_ACCESS_DENIED (the replaced file is delete-pending), and a replace fails while a reader
// holds the file. A failed read used to look like an EMPTY or MISSING ini to every caller - the
// core's hot-reload then wrote the defaults over it (unbound zoom keys mid-zoom, onboarded=0, the
// setup at the next start). So reads and replaces retry through that window (sharing violations
// and access-denied on a file that exists) until a DEADLINE, not a count: a background process's
// Sleep(1) can last a whole 15.6 ms timer tick on Windows 11, so a count is no time bound. The core's
// tick thread reads with a short budget (it re-checks on its next poll anyway); the settings apps
// and the tray use the default.
inline bool TransientFileError(DWORD e) {
return e == ERROR_SHARING_VIOLATION || e == ERROR_ACCESS_DENIED || e == ERROR_LOCK_VIOLATION;
}
// False when the file exists but could not be opened (locked, permissions) OR is missing; `out` is
// only written on success. Callers that must distinguish "missing" pre-check GetFileAttributesW.
inline bool ReadTextFileOk(const std::wstring& path, std::string& out) {
std::ifstream f(path, std::ios::binary);
if (!f) return false;
std::stringstream ss; ss << f.rdbuf(); out = ss.str(); return true;
inline bool ReadTextFileOk(const std::wstring& path, std::string& out, unsigned waitMs = 250) {
const ULONGLONG deadline = GetTickCount64() + waitMs;
for (;;) {
HANDLE h = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
if (h == INVALID_HANDLE_VALUE) {
if (TransientFileError(GetLastError()) && GetTickCount64() < deadline) { Sleep(1); continue; }
return false;
}
std::string text;
char buf[16384];
DWORD got = 0;
bool ok = true;
while ((ok = ReadFile(h, buf, sizeof(buf), &got, nullptr) != 0) && got > 0) text.append(buf, got);
CloseHandle(h);
if (!ok) {
if (GetTickCount64() < deadline) { Sleep(1); continue; }
return false;
}
out.swap(text);
return true;
}
}
// For READ-MODIFY-WRITE of the live ini: true with the text, or with "" when the file is MISSING;
// false when it exists but stays unreadable. A caller that writes back must stop on false - an
// unreadable ini read as "" and written back with one key changed is every other setting lost.
inline bool ReadLiveIni(const std::wstring& path, std::string& out) {
if (ReadTextFileOk(path, out)) return true;
if (GetFileAttributesW(path.c_str()) != INVALID_FILE_ATTRIBUTES) return false;
const DWORD e = GetLastError();
if (e != ERROR_FILE_NOT_FOUND && e != ERROR_PATH_NOT_FOUND) return false;
out.clear();
return true;
}
inline std::string ReadTextFile(const std::wstring& path) {
std::string out;
Expand All @@ -62,11 +105,16 @@ inline bool WriteTextFileAtomic(const std::wstring& path, const std::string& tex
{ std::ofstream f(tmp, std::ios::binary | std::ios::trunc);
if (!f) return false;
f.write(text.data(), (std::streamsize)text.size()); }
if (!MoveFileExW(tmp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) {
// A reader holding the ini open makes the replace fail for a moment (see ReadTextFileOk).
const ULONGLONG deadline = GetTickCount64() + 250;
for (;;) {
if (MoveFileExW(tmp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) return true;
const DWORD e = GetLastError();
if (TransientFileError(e) && GetTickCount64() < deadline) { Sleep(1); continue; }
DeleteFileW(tmp.c_str());
SetLastError(e);
return false;
}
return true;
}
// Live-bound contract, switch-time half: capture the CURRENT live settings into the OUTGOING
// profile's file before a switch overwrites the live ini. The setConfig mirror covers every write
Expand All @@ -91,8 +139,9 @@ inline void EnsureProfilesSeeded(const std::wstring& iniPath) {
std::wstring dir = ProfilesDirFromIni(iniPath);
if (GetFileAttributesW(dir.c_str()) != INVALID_FILE_ATTRIBUTES) return;
if (!CreateDirectoryW(dir.c_str(), nullptr)) return;
std::string live = ReadTextFile(iniPath);
if (!WriteTextFileAtomic(dir + L"\\Default.ini", MakeProfileText(live))) {
std::string live;
if (!ReadLiveIni(iniPath, live) ||
!WriteTextFileAtomic(dir + L"\\Default.ini", MakeProfileText(live))) {
RemoveDirectoryW(dir.c_str()); // dir is still empty; retry the whole seed next launch
return;
}
Expand Down Expand Up @@ -135,7 +184,8 @@ inline void ResetSessionToProfile(const std::wstring& iniPath) {
DeleteFileW(keep.c_str());
return;
}
std::string live = ReadTextFile(iniPath);
std::string live;
if (!ReadLiveIni(iniPath, live)) return;
auto vals = ReadIniValues(live);
auto it = vals.find("profile");
if (it == vals.end() || it->second.empty()) return;
Expand Down
7 changes: 4 additions & 3 deletions src/tray_app/engine_dropdown.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,10 @@ bool SetMainEngine(const std::wstring& ini, int picked) {
wind::Log(wind::LogLevel::Warn, "tray", "engine pick: relaunch FAILED (rc=%lld haveExe=%d); reverting model",
static_cast<long long>(rc), (int)haveExe);
DeleteFileW(wind::SessionKeepPath().c_str());
const std::string cur = wind::ReadTextFile(ini);
wind::WriteTextFileAtomic(ini, oldModel.empty() ? wind::UpdateIniText(cur, Flyout::kEngineKey, "hybrid")
: wind::UpdateIniText(cur, Flyout::kEngineKey, oldModel));
std::string cur;
if (wind::ReadLiveIni(ini, cur))
wind::WriteTextFileAtomic(ini, oldModel.empty() ? wind::UpdateIniText(cur, Flyout::kEngineKey, "hybrid")
: wind::UpdateIniText(cur, Flyout::kEngineKey, oldModel));
Notify(L"Wind", L"Could not restart Wind; kept the current engine.");
return false;
}
Expand Down
7 changes: 6 additions & 1 deletion src/tray_app/tray_menu.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,12 @@ void SwitchToProfile(const std::wstring& ini, const std::wstring& nameW) {
Notify(L"Wind", L"That profile's file looks corrupt; settings unchanged.");
return;
} }
const std::string oldLive = wind::ReadTextFile(ini);
std::string oldLive;
if (!wind::ReadLiveIni(ini, oldLive)) {
wind::Log(wind::LogLevel::Warn, "profile", "switch aborted: live ini unreadable");
Notify(L"Wind", L"Could not switch profile (config file is locked).");
return;
}
// Capture hand edits (openIni) into the outgoing profile before the live ini is replaced.
wind::MirrorLiveToActiveProfile(ini, oldLive);
const std::string newLive = wind::MakeLiveText(profText, oldLive, wind::NarrowUtf8(nameW));
Expand Down
Loading