Skip to content

Commit 7bf7684

Browse files
committed
Introduce JsonManager for Auto-Save
1 parent 956a059 commit 7bf7684

15 files changed

Lines changed: 827 additions & 48 deletions

src/cascadia/TerminalApp/AppLogic.cpp

Lines changed: 34 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -302,38 +302,30 @@ namespace winrt::TerminalApp::implementation
302302
// - <none>
303303
void AppLogic::_RegisterSettingsChange()
304304
{
305-
const std::filesystem::path settingsPath{ std::wstring_view{ CascadiaSettings::SettingsPath() } };
306-
_reader.create(
307-
settingsPath.parent_path().c_str(),
308-
false,
309-
// We want file modifications, AND when files are renamed to be
310-
// settings.json. This second case will often happen with text
311-
// editors, who will write a temp file, then rename it to be the
312-
// actual file you wrote. So listen for that too.
313-
wil::FolderChangeEvents::FileName | wil::FolderChangeEvents::LastWriteTime,
314-
[this, settingsBasename = settingsPath.filename()](wil::FolderChangeEvent, PCWSTR fileModified) {
315-
// DO NOT create a static reference to ApplicationState::SharedInstance here.
316-
//
317-
// ApplicationState::SharedInstance already caches its own
318-
// static ref. If _we_ keep a static ref to the member in
319-
// AppState, then our reference will keep ApplicationState alive
320-
// after the `ActionToStringMap` gets cleaned up. Then, when we
321-
// try to persist the actions in the window state, we won't be
322-
// able to. We'll try to look up the action and the map just
323-
// won't exist. We'll explode, even though the Terminal is
324-
// tearing down anyways. So we'll just die, but still invoke
325-
// WinDBG's post-mortem debugger, who won't be able to attach to
326-
// the process that's already exiting.
327-
//
328-
// So DON'T ~give a mouse a cookie~ take a static ref here.
329-
330-
const auto modifiedBasename = std::filesystem::path{ fileModified }.filename();
331-
332-
if (modifiedBasename == settingsBasename)
333-
{
334-
ReloadSettingsThrottled();
335-
}
336-
});
305+
// The JsonManager owns the settings.json watcher and the auto-save write
306+
// path. It raises SettingsChangedExternally (on a background
307+
// thread) when it detects an edit it didn't make; we marshal a throttled
308+
// reload onto the UI thread in response.
309+
_jsonManager = Settings::Model::JsonManager{};
310+
_jsonManager.SettingsChangedExternally([weakSelf = get_weak()](auto&&, auto&&) {
311+
if (auto self{ weakSelf.get() })
312+
{
313+
self->ReloadSettingsThrottled();
314+
}
315+
});
316+
_jsonManager.Start();
317+
318+
// Bind the live tree for auto-save -- but only if the initial load
319+
// actually succeeded. We must never auto-save the defaults fallback over
320+
// a user's broken settings.json.
321+
if (SUCCEEDED(_settingsLoadedResult))
322+
{
323+
_jsonManager.SetLiveSettings(_settings);
324+
}
325+
else
326+
{
327+
_jsonManager.ClearLiveSettings();
328+
}
337329
}
338330

339331
void AppLogic::_ApplyLanguageSettingChange() noexcept
@@ -389,6 +381,11 @@ namespace winrt::TerminalApp::implementation
389381
if (initialLoad)
390382
{
391383
_settings = CascadiaSettings::LoadDefaults();
384+
// Don't auto-save the defaults over the user's broken file.
385+
if (_jsonManager)
386+
{
387+
_jsonManager.ClearLiveSettings();
388+
}
392389
}
393390
else
394391
{
@@ -409,6 +406,11 @@ namespace winrt::TerminalApp::implementation
409406
else
410407
{
411408
_settings.LogSettingChanges(true);
409+
// Rebind the freshly-loaded tree for auto-save.
410+
if (_jsonManager)
411+
{
412+
_jsonManager.SetLiveSettings(_settings);
413+
}
412414
}
413415

414416
_ApplyLanguageSettingChange();

src/cascadia/TerminalApp/AppLogic.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@ namespace winrt::TerminalApp::implementation
7272
// These fields invoke _reloadSettings and must be destroyed before _reloadSettings.
7373
// (C++ destroys members in reverse-declaration-order.)
7474
winrt::com_ptr<LanguageProfileNotifier> _languageProfileNotifier;
75-
wil::unique_folder_change_reader_nothrow _reader;
75+
// Owns the settings.json watcher + auto-save write path.
76+
// Raises SettingsChangedExternally, which we handle by reloading.
77+
Microsoft::Terminal::Settings::Model::JsonManager _jsonManager{ nullptr };
7678

7779
TerminalApp::ContentManager _contentManager{ winrt::make<implementation::ContentManager>() };
7880

src/cascadia/TerminalSettingsModel/CascadiaSettings.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,8 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation
170170
void ResetApplicationState() const;
171171
void ResetToDefaultSettings();
172172
bool WriteSettingsToDisk();
173+
void SetWriteHandler(std::function<void()> handler);
174+
173175
Json::Value ToJson() const;
174176
Model::Profile ProfileDefaults() const;
175177
Model::Profile CreateNewProfile();
@@ -208,6 +210,11 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation
208210
void _refreshDefaultTerminals();
209211
void _writeSettingsToDisk(std::string_view contents);
210212

213+
// Auto-save (GH#12424): the shared "request auto-save" sink installed on
214+
// every inheritable object in this live tree. Empty until SetWriteHandler.
215+
std::shared_ptr<std::function<void()>> _writeSink;
216+
void _installWriteSink();
217+
211218
void _resolveDefaultProfile() const;
212219
void _resolveNewTabMenuProfiles() const;
213220
void _resolveNewTabMenuProfilesSet(const winrt::Windows::Foundation::Collections::IVectorView<Model::NewTabMenuEntry>& entries, winrt::Windows::Foundation::Collections::IMap<int, Model::Profile>& remainingProfiles, Model::RemainingProfilesEntry& remainingProfilesEntry) const;

src/cascadia/TerminalSettingsModel/CascadiaSettingsSerialization.cpp

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1484,6 +1484,62 @@ CascadiaSettings::CascadiaSettings(SettingsLoader&& loader) :
14841484
_validateSettings();
14851485

14861486
ExpandCommands();
1487+
1488+
_installWriteSink();
1489+
}
1490+
1491+
// Auto-save: create one shared sink and install it on every
1492+
// inheritable object in the live tree so that mutating any setting requests a
1493+
// debounced save. The sink stays a no-op until SetWriteHandler() binds a
1494+
// handler (which JsonManager only does for a successfully-loaded live tree).
1495+
// Copy()/clone paths construct fresh objects without a sink, so editor clones
1496+
// and the defaults fallback never auto-save.
1497+
void CascadiaSettings::_installWriteSink()
1498+
{
1499+
_writeSink = std::make_shared<std::function<void()>>();
1500+
1501+
const auto installForProfile = [&](implementation::Profile* prof) {
1502+
if (!prof)
1503+
{
1504+
return;
1505+
}
1506+
prof->SetWriteSettingsSink(_writeSink);
1507+
if (const auto da = prof->DefaultAppearance())
1508+
{
1509+
winrt::get_self<AppearanceConfig>(da)->SetWriteSettingsSink(_writeSink);
1510+
}
1511+
if (const auto ua = prof->UnfocusedAppearance())
1512+
{
1513+
winrt::get_self<AppearanceConfig>(ua)->SetWriteSettingsSink(_writeSink);
1514+
}
1515+
if (const auto fi = prof->FontInfo())
1516+
{
1517+
winrt::get_self<FontConfig>(fi)->SetWriteSettingsSink(_writeSink);
1518+
}
1519+
};
1520+
1521+
if (_globals)
1522+
{
1523+
_globals->SetWriteSettingsSink(_writeSink);
1524+
if (const auto ntm = _globals->NewTabMenu())
1525+
{
1526+
winrt::get_self<implementation::NewTabMenu>(ntm)->SetWriteSettingsSink(_writeSink);
1527+
}
1528+
}
1529+
1530+
installForProfile(_baseLayerProfile.get());
1531+
for (const auto& p : _allProfiles)
1532+
{
1533+
installForProfile(winrt::get_self<implementation::Profile>(p));
1534+
}
1535+
}
1536+
1537+
void CascadiaSettings::SetWriteHandler(std::function<void()> handler)
1538+
{
1539+
if (_writeSink)
1540+
{
1541+
*_writeSink = std::move(handler);
1542+
}
14871543
}
14881544

14891545
// Method Description:
@@ -1513,10 +1569,7 @@ const std::filesystem::path& CascadiaSettings::_releaseSettingsPath()
15131569
// Returns a has (approximately) uniquely identifying the settings.json contents on disk.
15141570
winrt::hstring CascadiaSettings::_calculateHash(std::string_view settings, const FILETIME& lastWriteTime)
15151571
{
1516-
const auto fileHash = til::hash(settings);
1517-
const ULARGE_INTEGER fileTime{ lastWriteTime.dwLowDateTime, lastWriteTime.dwHighDateTime };
1518-
const auto hash = fmt::format(FMT_COMPILE(L"{:016x}-{:016x}"), fileHash, fileTime.QuadPart);
1519-
return winrt::hstring{ hash };
1572+
return CalculateSettingsHash(settings, lastWriteTime);
15201573
}
15211574

15221575
// This returns something akin to %LOCALAPPDATA%\Packages\WindowsTerminalDev_8wekyb3d8bbwe\LocalState
@@ -1613,7 +1666,9 @@ bool CascadiaSettings::WriteSettingsToDisk()
16131666
void CascadiaSettings::_writeSettingsToDisk(std::string_view contents)
16141667
{
16151668
FILETIME lastWriteTime{};
1616-
til::io::write_utf8_string_to_file_atomic(_settingsPath(), contents, &lastWriteTime);
1669+
// Auto-save (GH#12424): write atomically via a process-unique temp file and
1670+
// keep a best-effort "settings.json.bak" backup of the prior contents.
1671+
WriteSettingsFile(_settingsPath(), contents, /*makeBackup*/ true, &lastWriteTime);
16171672

16181673
_hash = _calculateHash(contents, lastWriteTime);
16191674

src/cascadia/TerminalSettingsModel/FileUtils.cpp

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
#include <shlobj.h>
99
#include <WtExeUtils.h>
1010

11+
#include <fmt/compile.h>
12+
#include <til/io.h>
13+
#include <til/hash.h>
14+
1115
static constexpr std::wstring_view UnpackagedSettingsFolderName{ L"Microsoft\\Windows Terminal\\" };
1216
static constexpr std::wstring_view ReleaseSettingsFolder{ L"Packages\\Microsoft.WindowsTerminal_8wekyb3d8bbwe\\LocalState\\" };
1317
static constexpr std::wstring_view PortableModeMarkerFile{ L".portable" };
@@ -84,4 +88,66 @@ namespace winrt::Microsoft::Terminal::Settings::Model
8488
}();
8589
return baseSettingsPath;
8690
}
91+
92+
// Computes the hash that (approximately) uniquely identifies a settings
93+
// file's contents on disk. Combines a content hash with the file's last
94+
// write time.
95+
winrt::hstring CalculateSettingsHash(std::string_view content, const FILETIME& lastWriteTime)
96+
{
97+
const auto fileHash = til::hash(content);
98+
const ULARGE_INTEGER fileTime{ lastWriteTime.dwLowDateTime, lastWriteTime.dwHighDateTime };
99+
const auto hash = fmt::format(FMT_COMPILE(L"{:016x}-{:016x}"), fileHash, fileTime.QuadPart);
100+
return winrt::hstring{ hash };
101+
}
102+
103+
// Atomically writes settings content to disk:
104+
// 1. (optional) best-effort backup of the current file to "<path>.bak"
105+
// 2. write to a process-unique temp file in the same directory
106+
// 3. atomically replace the target via rename
107+
// The unique temp name avoids cross-process temp clobbering when multiple
108+
// Terminal instances auto-save concurrently. The ".bak" is best-effort and
109+
// diagnostic only -- it is not a reliable conflict-resolution mechanism.
110+
void WriteSettingsFile(const std::filesystem::path& path, std::string_view content, bool makeBackup, FILETIME* lastWriteTime)
111+
{
112+
// GH#10787: rename() replaces symbolic links themselves, not the path
113+
// they point at. Resolve them first before generating the temp path.
114+
std::error_code ec;
115+
const auto resolvedPath = std::filesystem::is_symlink(path) ? std::filesystem::canonical(path, ec) : path;
116+
if (ec)
117+
{
118+
if (ec.value() != ERROR_FILE_NOT_FOUND)
119+
{
120+
THROW_WIN32_MSG(ec.value(), "failed to compute canonical settings path");
121+
}
122+
// The original file is a symbolic link whose target doesn't exist.
123+
// Fall back to a direct (non-atomic) write; this is an edge case.
124+
til::io::write_utf8_string_to_file(path, content, false, lastWriteTime);
125+
return;
126+
}
127+
128+
if (makeBackup)
129+
{
130+
std::error_code backupEc;
131+
auto backupPath = resolvedPath;
132+
backupPath += L".bak";
133+
// Best-effort: ignore failures (e.g. the file doesn't exist yet).
134+
std::filesystem::copy_file(resolvedPath, backupPath, std::filesystem::copy_options::overwrite_existing, backupEc);
135+
}
136+
137+
// Process- and call-unique temp name to avoid cross-process clobber.
138+
static std::atomic<uint32_t> counter{ 0 };
139+
auto tmpPath = resolvedPath;
140+
tmpPath += fmt::format(FMT_COMPILE(L".{}.{}.tmp"), GetCurrentProcessId(), counter.fetch_add(1, std::memory_order_relaxed));
141+
142+
til::io::write_utf8_string_to_file(tmpPath, content, false, lastWriteTime);
143+
144+
// renaming is (close enough to) atomic.
145+
std::filesystem::rename(tmpPath, resolvedPath, ec);
146+
if (ec)
147+
{
148+
std::error_code cleanupEc;
149+
std::filesystem::remove(tmpPath, cleanupEc);
150+
THROW_WIN32_MSG(ec.value(), "failed to write settings file");
151+
}
152+
}
87153
}

src/cascadia/TerminalSettingsModel/FileUtils.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,6 @@ namespace winrt::Microsoft::Terminal::Settings::Model
66
bool IsPortableMode();
77
std::filesystem::path GetBaseSettingsPath();
88
std::filesystem::path GetReleaseSettingsPath();
9+
winrt::hstring CalculateSettingsHash(std::string_view content, const FILETIME& lastWriteTime);
10+
void WriteSettingsFile(const std::filesystem::path& path, std::string_view content, bool makeBackup, FILETIME* lastWriteTime = nullptr);
911
}

0 commit comments

Comments
 (0)