From 619af37acc796c3b915d1286140aabe60089ec0d Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:39:59 +0200 Subject: [PATCH 1/7] Make settings snapshots read-only and unify transaction persistence Addresses #121 and #122. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- src/SightAdapt/SettingsCoordinator.cs | 53 ++++++++++++++------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/src/SightAdapt/SettingsCoordinator.cs b/src/SightAdapt/SettingsCoordinator.cs index cd618622..741b2ff7 100644 --- a/src/SightAdapt/SettingsCoordinator.cs +++ b/src/SightAdapt/SettingsCoordinator.cs @@ -65,7 +65,7 @@ public SettingsCoordinator( _current = _store.Load(); } - public SightAdaptSettings Current => + public IReadOnlySightAdaptSettings Current => _current.CreateWorkingCopy(); public string SettingsPath => _store.SettingsPath; @@ -98,13 +98,36 @@ public SettingsCommitResult Commit( Func mutation) { ArgumentNullException.ThrowIfNull(mutation); + return ExecuteTransaction( + mutation, + publishChanged: true); + } + + public SettingsCommitResult PersistCurrent() + { + var result = ExecuteTransaction( + _ => null, + publishChanged: false); + + return result.Succeeded + ? SettingsCommitResult.Success() + : SettingsCommitResult.Failure( + result.ErrorMessage ?? + "Settings could not be saved."); + } + + private SettingsCommitResult ExecuteTransaction( + Func operation, + bool publishChanged) + { + ArgumentNullException.ThrowIfNull(operation); var candidate = _current.CreateWorkingCopy(); T value; try { - value = mutation(candidate); + value = operation(candidate); _store.Save(candidate); } catch (Exception exception) @@ -120,32 +143,12 @@ public SettingsCommitResult Commit( } _current.ReplaceWith(candidate); - Changed?.Invoke(this, EventArgs.Empty); - return SettingsCommitResult.Success(value); - } - - public SettingsCommitResult PersistCurrent() - { - var candidate = _current.CreateWorkingCopy(); - - try - { - _store.Save(candidate); - } - catch (Exception exception) - when (IsExpectedError(exception)) + if (publishChanged) { - return SettingsCommitResult.Failure( - FormatError(exception)); - } - catch (Exception exception) - { - _reportUnexpectedError(exception); - throw; + Changed?.Invoke(this, EventArgs.Empty); } - _current.ReplaceWith(candidate); - return SettingsCommitResult.Success(); + return SettingsCommitResult.Success(value); } private static bool IsExpectedError( From 6e1ac674f4e742cc16463940ee57918d1bcc19d9 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:40:11 +0200 Subject: [PATCH 2/7] Share settings transaction plumbing across use cases Addresses #122. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- src/SightAdapt/SettingsUseCaseContext.cs | 33 ++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/SightAdapt/SettingsUseCaseContext.cs diff --git a/src/SightAdapt/SettingsUseCaseContext.cs b/src/SightAdapt/SettingsUseCaseContext.cs new file mode 100644 index 00000000..3a414a2c --- /dev/null +++ b/src/SightAdapt/SettingsUseCaseContext.cs @@ -0,0 +1,33 @@ +namespace SightAdapt; + +internal sealed class SettingsUseCaseContext +{ + private readonly SettingsCoordinator _coordinator; + + public SettingsUseCaseContext( + SettingsCoordinator coordinator) + { + _coordinator = coordinator ?? + throw new ArgumentNullException(nameof(coordinator)); + } + + public IReadOnlySightAdaptSettings Snapshot => + _coordinator.Current; + + public string SettingsPath => + _coordinator.SettingsPath; + + public event EventHandler? Changed + { + add => _coordinator.Changed += value; + remove => _coordinator.Changed -= value; + } + + public SettingsCommitResult Commit( + Action mutation) => + _coordinator.Commit(mutation); + + public SettingsCommitResult Commit( + Func mutation) => + _coordinator.Commit(mutation); +} From c4bc8b3edbe4ad1f9c11c62169f012375d203fff Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:40:38 +0200 Subject: [PATCH 3/7] Refactor shared settings plumbing in src/SightAdapt/ConfigurationUseCases.cs Addresses #121 and #122. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- src/SightAdapt/ConfigurationUseCases.cs | 36 +++++++++++++++---------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/src/SightAdapt/ConfigurationUseCases.cs b/src/SightAdapt/ConfigurationUseCases.cs index 6ca4b2a0..b8de5bdf 100644 --- a/src/SightAdapt/ConfigurationUseCases.cs +++ b/src/SightAdapt/ConfigurationUseCases.cs @@ -2,31 +2,39 @@ namespace SightAdapt; internal sealed class ConfigurationUseCases { - private readonly SettingsCoordinator _settingsCoordinator; + private readonly SettingsUseCaseContext _settings; public ConfigurationUseCases( SettingsCoordinator settingsCoordinator) - { - _settingsCoordinator = settingsCoordinator ?? + : this(new SettingsUseCaseContext( + settingsCoordinator ?? throw new ArgumentNullException( - nameof(settingsCoordinator)); + nameof(settingsCoordinator)))) + { + } + + internal ConfigurationUseCases( + SettingsUseCaseContext settings) + { + _settings = settings ?? + throw new ArgumentNullException(nameof(settings)); } - public SightAdaptSettings Snapshot => - _settingsCoordinator.Current; + public IReadOnlySightAdaptSettings Snapshot => + _settings.Snapshot; public string SettingsPath => - _settingsCoordinator.SettingsPath; + _settings.SettingsPath; public event EventHandler? Changed { - add => _settingsCoordinator.Changed += value; - remove => _settingsCoordinator.Changed -= value; + add => _settings.Changed += value; + remove => _settings.Changed -= value; } public SettingsCommitResult SetAutomaticMode(bool enabled) { - return _settingsCoordinator.Commit(settings => + return _settings.Commit(settings => { AutomaticModeManagementService.Set( settings, @@ -41,7 +49,7 @@ public SettingsCommitResult Apply( ArgumentException.ThrowIfNullOrWhiteSpace( change.ExecutablePath); - return _settingsCoordinator.Commit(settings => + return _settings.Commit(settings => { var assignment = ProfileResolver.RequireAssignmentByExecutablePath( @@ -85,7 +93,7 @@ public SettingsCommitResult AddOrEnable( { ArgumentNullException.ThrowIfNull(identity); - return _settingsCoordinator.Commit(settings => + return _settings.Commit(settings => { var result = ApplicationAssignmentService.AddOrEnable( @@ -100,7 +108,7 @@ public SettingsCommitResult Remove(string executablePath) { ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); - return _settingsCoordinator.Commit(settings => + return _settings.Commit(settings => ApplicationAssignmentService.Remove( settings, ProfileResolver.RequireAssignmentByExecutablePath( @@ -115,7 +123,7 @@ public SettingsCommitResult UpdateTuning( ArgumentException.ThrowIfNullOrWhiteSpace(profileId); ArgumentNullException.ThrowIfNull(values); - return _settingsCoordinator.Commit(settings => + return _settings.Commit(settings => VisualProfileManagementService.UpdateTuning( settings, ProfileResolver.RequireVisualProfile( From 0216c56bb42bf5e0a34f157875b5839beb154738 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:40:42 +0200 Subject: [PATCH 4/7] Refactor shared settings plumbing in src/SightAdapt/VisualProfileUseCases.cs Addresses #121 and #122. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- src/SightAdapt/VisualProfileUseCases.cs | 34 +++++++++++++++---------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/SightAdapt/VisualProfileUseCases.cs b/src/SightAdapt/VisualProfileUseCases.cs index 4e13c9e1..da1dba52 100644 --- a/src/SightAdapt/VisualProfileUseCases.cs +++ b/src/SightAdapt/VisualProfileUseCases.cs @@ -2,23 +2,31 @@ namespace SightAdapt; internal sealed class VisualProfileUseCases { - private readonly SettingsCoordinator _settingsCoordinator; + private readonly SettingsUseCaseContext _settings; public VisualProfileUseCases( SettingsCoordinator settingsCoordinator) - { - _settingsCoordinator = settingsCoordinator ?? + : this(new SettingsUseCaseContext( + settingsCoordinator ?? throw new ArgumentNullException( - nameof(settingsCoordinator)); + nameof(settingsCoordinator)))) + { + } + + internal VisualProfileUseCases( + SettingsUseCaseContext settings) + { + _settings = settings ?? + throw new ArgumentNullException(nameof(settings)); } - public SightAdaptSettings Snapshot => - _settingsCoordinator.Current; + public IReadOnlySightAdaptSettings Snapshot => + _settings.Snapshot; public event EventHandler? Changed { - add => _settingsCoordinator.Changed += value; - remove => _settingsCoordinator.Changed -= value; + add => _settings.Changed += value; + remove => _settings.Changed -= value; } public string CreateAvailableName(string baseName) @@ -31,7 +39,7 @@ public string CreateAvailableName(string baseName) public SettingsCommitResult Create(string name) { - return _settingsCoordinator.Commit(settings => + return _settings.Commit(settings => VisualProfileManagementService.Create( settings, name).Id); @@ -43,7 +51,7 @@ public SettingsCommitResult Duplicate( { ArgumentException.ThrowIfNullOrWhiteSpace(sourceProfileId); - return _settingsCoordinator.Commit(settings => + return _settings.Commit(settings => VisualProfileManagementService.Duplicate( settings, ProfileResolver.RequireVisualProfile( @@ -58,7 +66,7 @@ public SettingsCommitResult Rename( { ArgumentException.ThrowIfNullOrWhiteSpace(profileId); - return _settingsCoordinator.Commit(settings => + return _settings.Commit(settings => { VisualProfileManagementService.Rename( settings, @@ -77,7 +85,7 @@ public SettingsCommitResult UpdateTuning( ArgumentException.ThrowIfNullOrWhiteSpace(profileId); ArgumentNullException.ThrowIfNull(values); - return _settingsCoordinator.Commit(settings => + return _settings.Commit(settings => { VisualProfileManagementService.UpdateTuning( settings, @@ -97,7 +105,7 @@ public SettingsCommitResult Delete( ArgumentException.ThrowIfNullOrWhiteSpace( fallbackProfileId); - return _settingsCoordinator.Commit(settings => + return _settings.Commit(settings => { VisualProfileManagementService.Delete( settings, From 03370d9dfdd66629ba5c8b7608b891f569f372c5 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:40:56 +0200 Subject: [PATCH 5/7] Use read-only settings snapshots in runtime reads Addresses #121. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- src/SightAdapt/RuntimeCoordinator.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/SightAdapt/RuntimeCoordinator.cs b/src/SightAdapt/RuntimeCoordinator.cs index 4b692230..4d4c25ba 100644 --- a/src/SightAdapt/RuntimeCoordinator.cs +++ b/src/SightAdapt/RuntimeCoordinator.cs @@ -12,7 +12,7 @@ internal sealed class RuntimeCoordinator private readonly IRuntimeEnvironment _environment; private readonly RuntimeOverlayActivator _overlayActivator; private readonly AutomaticActivationService _automaticActivation; - private readonly Func _readSettings; + private readonly Func _readSettings; private bool _committingSettings; public RuntimeCoordinator( @@ -43,7 +43,7 @@ internal RuntimeCoordinator( ApplicationStateController stateController, IRuntimeOverlay overlay, IRuntimeEnvironment environment, - Func? readSettings) + Func? readSettings) { _settingsCoordinator = settingsCoordinator ?? throw new ArgumentNullException(nameof(settingsCoordinator)); @@ -257,7 +257,7 @@ public void DisableForExit() _overlayActivator.DisableForExit(); } - private SightAdaptSettings ReadSettings() + private IReadOnlySightAdaptSettings ReadSettings() { return _readSettings(); } From 07369d5b0639aec1118131756c87a44391ae2649 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:41:14 +0200 Subject: [PATCH 6/7] Test read-only snapshots and shared persistence behavior Addresses #121 and #122. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- .../SettingsCoordinatorTests.cs | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/tests/SightAdapt.Tests/SettingsCoordinatorTests.cs b/tests/SightAdapt.Tests/SettingsCoordinatorTests.cs index cb6c295b..49ff5b62 100644 --- a/tests/SightAdapt.Tests/SettingsCoordinatorTests.cs +++ b/tests/SightAdapt.Tests/SettingsCoordinatorTests.cs @@ -60,11 +60,14 @@ public void CurrentReturnsDefensiveSnapshot() var changedEvents = 0; coordinator.Changed += (_, _) => changedEvents++; - var snapshot = coordinator.Current; - snapshot.AutomaticMode = false; - snapshot.Assignments[0].Enabled = false; - snapshot.Assignments.Clear(); - snapshot.VisualProfiles.Clear(); + IReadOnlySightAdaptSettings snapshot = + coordinator.Current; + var mutableSnapshot = + (SightAdaptSettings)snapshot; + mutableSnapshot.AutomaticMode = false; + mutableSnapshot.Assignments[0].Enabled = false; + mutableSnapshot.Assignments.Clear(); + mutableSnapshot.VisualProfiles.Clear(); var current = coordinator.Current; Assert.IsTrue(current.AutomaticMode); @@ -74,6 +77,38 @@ public void CurrentReturnsDefensiveSnapshot() Assert.AreEqual(0, changedEvents); } + + [TestMethod] + public void CurrentExposesReadOnlySnapshotContract() + { + var property = typeof(SettingsCoordinator) + .GetProperty(nameof(SettingsCoordinator.Current)); + + Assert.IsNotNull(property); + Assert.AreEqual( + typeof(IReadOnlySightAdaptSettings), + property.PropertyType); + } + + [TestMethod] + public void PersistCurrentDoesNotPublishChangedEvent() + { + using var temporaryDirectory = + new TestWorkspace(); + var coordinator = + new SettingsCoordinator( + new SettingsStore(Path.Combine( + temporaryDirectory.Path, + "settings.json"))); + var changedEvents = 0; + coordinator.Changed += (_, _) => changedEvents++; + + var result = coordinator.PersistCurrent(); + + Assert.IsTrue(result.Succeeded); + Assert.AreEqual(0, changedEvents); + } + [TestMethod] public void FailedPersistenceDoesNotPublishCandidateState() { From 4629d41e1b50d40622073e796fde3e6abd4bc393 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:41:31 +0200 Subject: [PATCH 7/7] Document read-only settings and shared transaction plumbing Addresses #121 and #122. Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com> --- docs/ARCHITECTURE.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2b61e3b7..cf5a9df3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,9 +40,13 @@ MagnifierOverlay ```text SettingsCoordinator.Current +(read-only snapshot contract) ↓ CreateWorkingCopy ↓ +SettingsUseCaseContext +(shared snapshot/event/commit plumbing) + ↓ Domain-service mutation ↓ SettingsNormalizer.Normalize @@ -57,13 +61,14 @@ Current.ReplaceWith one synchronous Changed event ``` -A failed mutation or failed write does not replace committed settings and does not publish a settings change. `SettingsCoordinator.Current` returns a defensive snapshot, so consumers cannot mutate the committed in-memory object outside a transaction. +A failed mutation or failed write does not replace committed settings and does not publish a settings change. `SettingsCoordinator.Current` exposes `IReadOnlySightAdaptSettings` and returns a defensive snapshot. Mutable `SightAdaptSettings` is confined to explicit transaction callbacks and normalization/persistence boundaries. `SettingsUseCaseContext` centralizes snapshot, change-event and commit plumbing for application use-case façades without sharing their domain commands. `Commit` and `PersistCurrent` use one persistence execution path; only `Commit` publishes the synchronous `Changed` event after a successful write. ## Authorities | Concern | Authority | |---|---| -| Settings transaction and published snapshots | `SettingsCoordinator` | +| Settings transaction and published read-only snapshots | `SettingsCoordinator` | +| Shared use-case snapshot/event/commit plumbing | `SettingsUseCaseContext` | | Settings JSON persistence and atomic replacement | `SettingsStore` | | Persisted JSON DTOs and legacy-field migration | `PersistedSettingsMapper` | | Schema, profile, assignment and reference normalization | explicit `ISettingsNormalizationPass` implementations |