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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
111 changes: 15 additions & 96 deletions src/Pixelpipe.Settings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object> settingsRootCache;
private readonly object settingsRootCacheLock = new object();

private Dictionary<string, object> 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<string, object> 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<string, object>(StringComparer.OrdinalIgnoreCase);
return settingsRootCache;
return _settingsStore;
}
}

private bool TryReadSettingsRoot(string path, out Dictionary<string, object> root)
{
root = null;
try
{
if (!File.Exists(path)) return false;
string json = File.ReadAllText(path, Encoding.UTF8);
JavaScriptSerializer js = new JavaScriptSerializer();
Dictionary<string, object> parsed = js.DeserializeObject(json) as Dictionary<string, object>;
if (parsed == null) return false;
root = new Dictionary<string, object>(parsed, StringComparer.OrdinalIgnoreCase);
return true;
}
catch (Exception ex) { LogUiIssue("read settings " + Path.GetFileName(path), ex); }
return false;
}
private Dictionary<string, object> ReadSettingsRoot() { return SettingsBackend.ReadRoot(); }

private void WriteSettingsRoot(Dictionary<string, object> 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<string, object> root) { SettingsBackend.WriteRoot(root); }

// Pixelpipe already keeps the most recent settings.json as .bak via
// WriteAllTextAtomic (it's overwritten on every save). Before any
Expand All @@ -187,42 +139,9 @@ private void WriteSettingsRoot(Dictionary<string, object> 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()
Expand Down
144 changes: 144 additions & 0 deletions src/SettingsStore.cs
Original file line number Diff line number Diff line change
@@ -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<string, Exception> _logIssue;
private readonly Action<string, string> _logWarn;
private readonly object _cacheLock = new object();
private Dictionary<string, object> _cache;

public const int BackupRetentionCount = 20;

public SettingsStore(string settingsFile, Action<string, Exception> logIssue, Action<string, string> 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<string, object> ReadRoot()
{
lock (_cacheLock)
{
if (_cache != null) return _cache;
Dictionary<string, object> 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<string, object>(StringComparer.OrdinalIgnoreCase);
return _cache;
}
}

public void WriteRoot(Dictionary<string, object> 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<string, object> root)
{
root = null;
try
{
if (!File.Exists(path)) return false;
string json = File.ReadAllText(path, Encoding.UTF8);
JavaScriptSerializer js = new JavaScriptSerializer();
Dictionary<string, object> parsed = js.DeserializeObject(json) as Dictionary<string, object>;
if (parsed == null) return false;
root = new Dictionary<string, object>(parsed, StringComparer.OrdinalIgnoreCase);
return true;
}
catch (Exception ex) { if (_logIssue != null) _logIssue("read settings " + Path.GetFileName(path), ex); }
return false;
}
}
}
Loading