From 73824fdf9d478effad55674d3c4cf051e128b231 Mon Sep 17 00:00:00 2001 From: "NathanNeurotic (Ripto)" <109461996+NathanNeurotic@users.noreply.github.com> Date: Thu, 28 May 2026 23:52:35 -0700 Subject: [PATCH] v0.15.2: ARCH-1 step 2 - extract SettingsStore Second collaborator extracted from TrayContext partial. No user- visible changes. - New src/SettingsStore.cs owns settings.json read/write/cache/ atomic-write/backup/prune mechanics. Constructor takes the settings file path + log callbacks. - TrayContext.Settings.cs delegates ReadSettingsRoot, WriteSettingsRoot, BackupSettingsFile, BackupsDir to a lazy SettingsStore field. PERF-3 cache behavior preserved. - Profile-specific shape logic (LoadProfiles/SaveProfiles) stays in TrayContext as before. 53/53 tests green, FileVersion 0.15.2.0. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 9 +++ src/Pixelpipe.Settings.cs | 111 ++++------------------------- src/SettingsStore.cs | 144 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 96 deletions(-) create mode 100644 src/SettingsStore.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f2a764b..e35796c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.15.2 + +ARCH-1 step 2. Second collaborator extracted from `TrayContext`. No user-visible changes. + +Changed: + +- **New `SettingsStore` class** (`src/SettingsStore.cs`) owns the `settings.json` read / write / in-memory cache / atomic-write / timestamped backups / prune. Profile-specific shape logic (which keys land in each profile dict) stays in `TrayContext.Settings.cs`; `SettingsStore` is the layer below — give it the root dict, it handles disk. +- **`TrayContext.ReadSettingsRoot` / `WriteSettingsRoot` / `BackupSettingsFile` delegate** to a lazily-initialised `SettingsStore` field. Lock-protected cache (PERF-3 behavior) preserved. + ## 0.15.1 ARCH-1 step 1 from the audit — first collaborator extracted from the `TrayContext` partial. No user-visible changes; internal refactor only. diff --git a/src/Pixelpipe.Settings.cs b/src/Pixelpipe.Settings.cs index 72f2d69..6696044 100644 --- a/src/Pixelpipe.Settings.cs +++ b/src/Pixelpipe.Settings.cs @@ -110,74 +110,26 @@ private void SaveProfiles() catch (Exception ex) { LogUiIssue("save profiles", ex); } } - // PERF-3 (v0.13.4): cache the parsed settings root in memory so - // each SaveSetting / SaveProfiles doesn't re-read + re-parse the - // file. Atomic-write durability is preserved (WriteSettingsRoot - // still goes through WriteAllTextAtomic), we just skip the read + - // JavaScriptSerializer round-trip per write. Lock-protected so - // concurrent writes from worker threads don't tear the dict. - private Dictionary settingsRootCache; - private readonly object settingsRootCacheLock = new object(); - - private Dictionary ReadSettingsRoot() + // ARCH-1 step 2 (v0.15.2): the cache / read / write / lock mechanics + // moved into SettingsStore. TrayContext holds a lazily-initialised + // reference and delegates. PERF-3 cache behavior unchanged. + private SettingsStore _settingsStore; + private SettingsStore SettingsBackend { - lock (settingsRootCacheLock) + get { - if (settingsRootCache != null) return settingsRootCache; - Dictionary root; - if (TryReadSettingsRoot(settingsFile, out root)) - { - settingsRootCache = root; - return settingsRootCache; - } - string backupFile = settingsFile + ".bak"; - if (TryReadSettingsRoot(backupFile, out root)) + if (_settingsStore == null) { - LogUiWarn("read settings", "loaded backup settings file after primary file could not be read"); - settingsRootCache = root; - return settingsRootCache; + _settingsStore = new SettingsStore(settingsFile, LogUiIssue, + delegate(string area, string msg) { LogUiWarn(area, msg); }); } - settingsRootCache = new Dictionary(StringComparer.OrdinalIgnoreCase); - return settingsRootCache; + return _settingsStore; } } - private bool TryReadSettingsRoot(string path, out Dictionary root) - { - root = null; - try - { - if (!File.Exists(path)) return false; - string json = File.ReadAllText(path, Encoding.UTF8); - JavaScriptSerializer js = new JavaScriptSerializer(); - Dictionary parsed = js.DeserializeObject(json) as Dictionary; - if (parsed == null) return false; - root = new Dictionary(parsed, StringComparer.OrdinalIgnoreCase); - return true; - } - catch (Exception ex) { LogUiIssue("read settings " + Path.GetFileName(path), ex); } - return false; - } + private Dictionary ReadSettingsRoot() { return SettingsBackend.ReadRoot(); } - private void WriteSettingsRoot(Dictionary root) - { - try - { - Directory.CreateDirectory(settingsDir); - string json; - lock (settingsRootCacheLock) - { - // Caller may have built `root` from scratch (vs mutating - // the existing cache); replace the cache reference so - // the next ReadSettingsRoot returns the latest values. - settingsRootCache = root; - JavaScriptSerializer js = new JavaScriptSerializer(); - json = js.Serialize(root); - } - WriteAllTextAtomic(settingsFile, json, Encoding.UTF8); - } - catch (Exception ex) { LogUiIssue("write settings", ex); } - } + private void WriteSettingsRoot(Dictionary root) { SettingsBackend.WriteRoot(root); } // Pixelpipe already keeps the most recent settings.json as .bak via // WriteAllTextAtomic (it's overwritten on every save). Before any @@ -187,42 +139,9 @@ private void WriteSettingsRoot(Dictionary root) // recover a known-good state hours or days later. The directory is // pruned to the last `BackupRetentionCount` files so it doesn't // grow unbounded. - private const int BackupRetentionCount = 20; - - internal string BackupsDir { get { return Path.Combine(settingsDir, "backups"); } } - - private string BackupSettingsFile(string reason) - { - try - { - if (!File.Exists(settingsFile)) return null; - Directory.CreateDirectory(BackupsDir); - string stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss"); - string safeReason = SafeFileName(String.IsNullOrEmpty(reason) ? "manual" : reason); - string target = Path.Combine(BackupsDir, "settings-" + stamp + "-" + safeReason + ".json"); - File.Copy(settingsFile, target, false); - PruneOldBackups(); - LogUiWarn("settings backup", "wrote " + target); - return target; - } - catch (Exception ex) { LogUiIssue("settings backup " + reason, ex); return null; } - } - - private void PruneOldBackups() - { - try - { - if (!Directory.Exists(BackupsDir)) return; - string[] files = Directory.GetFiles(BackupsDir, "settings-*.json"); - if (files.Length <= BackupRetentionCount) return; - Array.Sort(files, delegate(string a, string b) { return File.GetCreationTimeUtc(b).CompareTo(File.GetCreationTimeUtc(a)); }); - for (int i = BackupRetentionCount; i < files.Length; i++) - { - try { File.Delete(files[i]); } catch { } - } - } - catch (Exception ex) { LogUiIssue("prune backups", ex); } - } + // ARCH-1 step 2 (v0.15.2): backups + prune live in SettingsStore. + internal string BackupsDir { get { return SettingsBackend.BackupsDir; } } + private string BackupSettingsFile(string reason) { return SettingsBackend.Backup(reason); } // Tools / diagnostics menu hook. private void OpenSettingsBackupsFolder() diff --git a/src/SettingsStore.cs b/src/SettingsStore.cs new file mode 100644 index 0000000..229ea51 --- /dev/null +++ b/src/SettingsStore.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Web.Script.Serialization; + +namespace Pixelpipe +{ + // ARCH-1 step 2 (v0.15.2, audit): second collaborator extracted from the + // TrayContext partial. Owns the settings.json read / write / cache / + // atomic-write / timestamped-backup machinery. + // + // Profile-specific shape logic (the LoadProfiles / SaveProfiles methods + // that know which keys live in each profile dict) stays in TrayContext; + // SettingsStore is the layer below that — give it the root dict, it + // hands you back the root dict, it handles disk. + // + // The locking + cache pattern from PERF-3 lives here unchanged: parsed + // root dict cached after first read, lock-protected so worker threads + // can write concurrently without tearing the dict. Atomic-write goes + // through WriteAllTextAtomic so durability is unchanged. + internal sealed class SettingsStore + { + private readonly string _settingsFile; + private readonly string _settingsDir; + private readonly string _backupsDir; + private readonly Action _logIssue; + private readonly Action _logWarn; + private readonly object _cacheLock = new object(); + private Dictionary _cache; + + public const int BackupRetentionCount = 20; + + public SettingsStore(string settingsFile, Action logIssue, Action logWarn) + { + _settingsFile = settingsFile; + _settingsDir = Path.GetDirectoryName(settingsFile) ?? ""; + _backupsDir = Path.Combine(_settingsDir, "backups"); + _logIssue = logIssue; + _logWarn = logWarn; + } + + public string SettingsFile { get { return _settingsFile; } } + public string BackupsDir { get { return _backupsDir; } } + + public Dictionary ReadRoot() + { + lock (_cacheLock) + { + if (_cache != null) return _cache; + Dictionary root; + if (TryReadFile(_settingsFile, out root)) + { + _cache = root; + return _cache; + } + string backupFile = _settingsFile + ".bak"; + if (TryReadFile(backupFile, out root)) + { + if (_logWarn != null) _logWarn("read settings", "loaded backup settings file after primary file could not be read"); + _cache = root; + return _cache; + } + _cache = new Dictionary(StringComparer.OrdinalIgnoreCase); + return _cache; + } + } + + public void WriteRoot(Dictionary root) + { + try + { + Directory.CreateDirectory(_settingsDir); + string json; + lock (_cacheLock) + { + _cache = root; + JavaScriptSerializer js = new JavaScriptSerializer(); + json = js.Serialize(root); + } + TrayContext.WriteAllTextAtomic(_settingsFile, json, Encoding.UTF8); + } + catch (Exception ex) { if (_logIssue != null) _logIssue("write settings", ex); } + } + + // Pass-through for the "is anything in settings.json" probe used by + // the welcome balloon path. Avoids parsing the file when callers only + // care about presence. + public bool Exists() { return File.Exists(_settingsFile); } + + // Timestamped backup before destructive operations. Returns the + // target path on success, null on failure (logged). Prunes the + // backups directory to BackupRetentionCount entries. + public string Backup(string reason) + { + try + { + if (!File.Exists(_settingsFile)) return null; + Directory.CreateDirectory(_backupsDir); + string stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss"); + string safeReason = TrayContext.SafeFileName(String.IsNullOrEmpty(reason) ? "manual" : reason); + string target = Path.Combine(_backupsDir, "settings-" + stamp + "-" + safeReason + ".json"); + File.Copy(_settingsFile, target, false); + Prune(); + if (_logWarn != null) _logWarn("settings backup", "wrote " + target); + return target; + } + catch (Exception ex) { if (_logIssue != null) _logIssue("settings backup " + reason, ex); return null; } + } + + private void Prune() + { + try + { + if (!Directory.Exists(_backupsDir)) return; + string[] files = Directory.GetFiles(_backupsDir, "settings-*.json"); + if (files.Length <= BackupRetentionCount) return; + Array.Sort(files, delegate (string a, string b) { return File.GetCreationTimeUtc(b).CompareTo(File.GetCreationTimeUtc(a)); }); + for (int i = BackupRetentionCount; i < files.Length; i++) + { + try { File.Delete(files[i]); } catch { } + } + } + catch (Exception ex) { if (_logIssue != null) _logIssue("prune backups", ex); } + } + + private bool TryReadFile(string path, out Dictionary root) + { + root = null; + try + { + if (!File.Exists(path)) return false; + string json = File.ReadAllText(path, Encoding.UTF8); + JavaScriptSerializer js = new JavaScriptSerializer(); + Dictionary parsed = js.DeserializeObject(json) as Dictionary; + if (parsed == null) return false; + root = new Dictionary(parsed, StringComparer.OrdinalIgnoreCase); + return true; + } + catch (Exception ex) { if (_logIssue != null) _logIssue("read settings " + Path.GetFileName(path), ex); } + return false; + } + } +}