Nucs.JsonSettings 2.3.2

dotnet add package Nucs.JsonSettings --version 2.3.2
                    
NuGet\Install-Package Nucs.JsonSettings -Version 2.3.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Nucs.JsonSettings" Version="2.3.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Nucs.JsonSettings" Version="2.3.2" />
                    
Directory.Packages.props
<PackageReference Include="Nucs.JsonSettings" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Nucs.JsonSettings --version 2.3.2
                    
#r "nuget: Nucs.JsonSettings, 2.3.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Nucs.JsonSettings@2.3.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Nucs.JsonSettings&version=2.3.2
                    
Install as a Cake Addin
#tool nuget:?package=Nucs.JsonSettings&version=2.3.2
                    
Install as a Cake Tool

<img src="assets/icon.png" width="25" style="margin: 5px 0px 0px 10px"/> JsonSettings

Nuget version Nuget downloads GitHub license Documentation

This library aims to simplify the process of creating configuration for your C# app/service by utilizing the serialization capabilities of Json.NET to serialize nested (custom) objects, dictionaries and lists as simply as by creating a POCO and inheriting JsonSettings class.<br/>

📖 Full documentation & API reference: nucs.github.io/JsonSettings

Installation

dotnet add package Nucs.JsonSettings
dotnet add package Nucs.JsonSettings.Autosave        # optional, for [AutoSave] and EnableAutosave()
dotnet add package Nucs.JsonSettings.NotifyChanges   # optional, for [NotifyChanges] data binding

All packages are signed and supported runtimes are: netstandard2.0, net48, net6.0, net8.0 and net10.0.

Table of Contents

Features Overview

  • Initialized in a fluent static API <span style='font-size:11px; padding-left: 3px' >read more</span>
  • Cross-platform, multi-targeting netstandard2.0, net48, net6.0, net8.0 and net10.0
  • Modularity allowing easy extension and high control over behavior on a per-object level <span style='font-size:11px; padding-left: 3px' >read more</span>
  • Autosaving on changes <span style='font-size:11px; padding-left: 3px' >read more</span>
    • Via INotifyPropertyChanged/INotifyCollectionChanged allowing WPF binding <span style='font-size:11px; padding-left: 3px' >read more</span>
    • Via compile-time IL weaving of the property setters, marked with [Autosave] <span style='font-size:11px; padding-left: 3px' >read more</span>
  • Versioning control <span style='font-size:11px; padding-left: 3px' >read more</span>
    • Offers protection mechanisms such as renaming file and loading default
    • By changing version, it allows to introduce any kind of changes to the settings class
  • Customizable control over recovering from parsing exceptions <span style='font-size:11px; padding-left: 3px' >read more</span>
  • AES256 Encryption via a key <span style='font-size:11px; padding-left: 3px' >read more</span>
  • Fully extensible with Json.NET 's capabilities, attributes and settings
    • It'll be accurate to say that this library is built around Json.NET
  • SettingsBag, a dynamic option that uses a ConcurrentDictionary<string,object> eliminating the need for hardcoding POCO class <span style='font-size:11px; padding-left: 3px' >read more</span>

The Basics

Test project: https://github.com/Nucs/JsonSettings/tree/master/tests/JsonSettings.Tests <br> Serialization Guide: https://www.newtonsoft.com/json/help/html/SerializationGuide.htm </br>

JsonSettings is the base abstract class serving as the base class for all settings objects the user defines. <br> Creation, loading is done through static API where saving is through the settings object API.

Here is a self explanatory quicky of to how and what:

  • Hardcoded settings
//Step 1: create a class and inherit JsonSettings
class MySettings : JsonSettings {
    //Step 2: override a default FileName or keep it empty. Just make sure to specify it when calling Load!
    //This is used for default saving and loading so you won't have to specify the filename/path every time.
    //Putting just a filename without folder will put it inside the executing file's directory.
    public override string FileName { get; set; } = "TheDefaultFilename.extension"; //for loading and saving.

    #region Settings

    public string SomeProperty { get; set; }
    public Dictionary<string, object> Dictionary { get; set; } = new Dictionary<string, object>();
    public int SomeNumberWithDefaultValue { get; set; } = 1;
    [JsonIgnore] public char ImIgnoredAndIWontBeSavedOrLoaded { get; set; }
    
    #endregion
    
    //Step 3: Override parent's constructors
    public MySettings() { }
    public MySettings(string fileName) : base(fileName) { }
}

//Step 4: Load
public MySettings Settings = JsonSettings.Load<MySettings>("config.json"); //relative path to executing file.
//or create a new empty
public MySettings Settings = JsonSettings.Construct<MySettings>("config.json");

//Step 5: Introduce changes and save.
Settings.SomeProperty = "ok";
Settings.Save();
  • Dynamic settings
    • Dynamic settings will automatically create new keys.
    • Can accept any Type that Json.NET can serialize
    • ValueTypes are returned as Nullable<Type>, therefore if a key doesn't exist - a null is returned.
//Step 1: Just load it, it'll be created if doesn't exist.
public SettingsBag Settings = JsonSettings.Load<SettingsBag>("config.json");
//Step 2: use!
Settings["key"]  = "dat value tho";
Settings["key2"] = 123;
dynamic dyn = Settings.AsDynamic();
if ((int?)dyn.key2==123)
    Console.WriteLine("explode");
Settings.Save();
  • Encrypted settings
    • Uses AES via System.Security.Cryptography (the .NET BCL); optional AES-GCM, AES-CCM, ChaCha20-Poly1305 or authenticated AES-CBC-HMAC.
    • Can be applied to any settings class because it is a module.
    • The secret can be a text password, a binary password, or a raw key.
MySettings Settings = JsonSettings.Load<MySettings>("config.json", q=>q.WithEncryption("mysecretpassword"));
SettingsBag Settings = JsonSettings.Load<SettingsBag>("config.json", q=>q.WithEncryption("mysecretpassword"));
//or
MySettings Settings = JsonSettings.Configure<MySettings>("config.json")
                     .WithEncryption("mysecretpassword")
               //or: .WithModule<EncryptionModule>("pass");
                     .LoadNow();

SettingsBag Settings = JsonSettings.Configure<SettingsBag>("config.json")
                     .WithEncryption("mysecretpassword")
               //or: .WithModule<EncryptionModule>("pass");
                     .LoadNow();

The secret can also be supplied as bytes. A byte[] password is stretched into the key with the same PBKDF2 derivation as a text password (salted and iterated); a raw key is used verbatim and must be 16, 24 or 32 bytes (AES-128/192/256):

// binary password - PBKDF2-derived, like a text password but with arbitrary bytes.
// Note: NOT the same credential as the text password whose UTF-8 bytes equal these.
byte[] password = Encoding.UTF8.GetBytes("mysecretpassword");
var a = JsonSettings.Configure<MySettings>("config.json").WithEncryption(password).LoadNow();

// raw AES key - used as-is, no derivation. You own the key's quality.
byte[] key = RandomNumberGenerator.GetBytes(32); // or from an env var / HSM / another KDF
var b = JsonSettings.Configure<MySettings>("config.json").WithEncryptionRawKey(key).LoadNow();

// both accept a fetcher, incl. one that receives the instance:
var c = JsonSettings.Configure<MySettings>("config.json")
                    .WithEncryptionRawKey(() => LoadKeyFromVault())
                    .LoadNow();
  • Hardcoded Settings with Autosave
    • Automatic save will occur when any property changes
    • Works on any property — virtual is not required (as of 2.2.0); opt a property out with [IgnoreAutosave]
    • Requires package nucs.JsonSettings.Autosave and an [Autosave] attribute on the class.
Settings x  = JsonSettings.Load<Settings>().EnableAutosave(); //call after loading
//or:
ISettings x = JsonSettings.Load<Settings>().EnableIAutosave<Settings, ISettings>(); //Settings implements interface ISettings

x.Property = "value"; //Saved!
  • Dynamic Settings with Autosave
    • Automatic save will occur when changes detected
    • note: SettingsBag has it's own implementation of EnableAutosave().
//Step 1:
SettingsBag Settings = JsonSettings.Load<SettingsBag>("config.json").EnableAutosave(); //call after loading
//Step 2:
Settings.AsDynamic().key = "wow"; //Saved!
Settings["key"] = "wow two"; //Saved!

Recovery

RecoveryModule provides handling for JsonException when calling JsonSettings.LoadJson during the loading process. On a scenario of exception/failure, one of the following actions can take place:

  • RecoveryAction.Throw<br/> Will throw JsonSettingsRecoveryException with the real exception as inner exception.
  • RecoveryAction.LoadDefault<br/> Default settings will be loaded without touching the existing file until next save.
  • RecoveryAction.LoadDefaultAndSave<br/> Default settings will be loaded and saved to disk immediately.
  • RecoveryAction.RenameAndLoadDefault<br/> Will append the version to the end of the faulty file's name and load the default settings and save to disk.<br/> i.e. myfile.json versioned 1.0.0.5 will be renamed to myfile.1.0.0.5-0.json if it fails on parsing (the trailing -0 is a collision counter — a second archive becomes -1, and so on) and the new default settings will be saved as the original filename.

All recovery properties and methods are suited for inheritance so extending is quite easy.

using Nucs.JsonSettings;
using Nucs.JsonSettings.Modulation.Recovery;

//attach RecoveryModule via the fluent extension and pick what happens on a parse failure:
var settings = JsonSettings.Configure<MySettings>("config.json")
                           .WithRecovery(RecoveryAction.RenameAndLoadDefault)
                           .LoadNow();

settings.SomeProperty = "hello";
settings.Save();

//...later config.json is corrupted on disk (hand-edited, truncated, a half-finished write).
//Loading again does NOT throw: the corrupt file is renamed aside and defaults are loaded.
settings = JsonSettings.Configure<MySettings>("config.json")
                       .WithRecovery(RecoveryAction.RenameAndLoadDefault)
                       .LoadNow();
//config.json is now the freshly-saved default; the corrupt copy is preserved next to it as
//config.<version>-0.json (or config.0.json when the class is not IVersionable).

Recovery composes with versioning: versioning runs when the file parses, recovery catches the parse itself failing.

Versioning

VersioningModule<T> provides the ability to enforce a specific version so when new changes are introduced to your Settings class (scheme), a user-defined action can take place. Any of the following actions can be taken:

  • VersioningResultAction.DoNothing<br/> Will keep the old version if it was parsed by Json.NET successfully. otherwise RecoveryModule will handle the failure of loading.
  • VersioningResultAction.Throw<br/> Will throw JsonSettingsRecoveryException with the real exception as inner exception.
  • VersioningResultAction.LoadDefault<br/> Default settings will be loaded without touching the existing file until next save.
  • VersioningResultAction.LoadDefaultAndSave<br/> Default settings will be loaded and saved to disk immediately.
  • VersioningResultAction.RenameAndLoadDefault<br/> Will append the version to the end of the faulty file's name and load the default settings and save to disk.<br/> i.e. myfile.json versioned 1.0.0.5 will be renamed to myfile.1.0.0.5-0.json if it fails on parsing (the trailing -0 is a collision counter — a second archive becomes -1, and so on) and the new default settings will be saved as the original filename.

There are two ways to specify which version to enforce.

  1. Pass the version when calling WithVersioning.
  2. Add [EnforcedVersion("1.0.0.0")] attribute to your IVersionable.Version property definition.<br/> When dealing with inheritance/virtual override, the attribute of the lowest inherited class will be used.
using Nucs.JsonSettings;
using Nucs.JsonSettings.Modulation;

//The settings class must implement IVersionable (contributes `Version Version { get; set; }`).
class MySettings : JsonSettings, IVersionable {
    public override string FileName { get; set; } = "config.json";
    public virtual Version Version { get; set; } = new Version(1, 0, 0, 0);
    public string Theme { get; set; } = "dark";

    public MySettings() { }
    public MySettings(string fileName) : base(fileName) { }
}

//1) Pass the enforced version explicitly:
var settings = JsonSettings.Configure<MySettings>("config.json")
                           .WithVersioning("1.0.0.0", VersioningResultAction.RenameAndLoadDefault)
                           .LoadNow();

//Later you ship a new scheme and bump the enforced version. A file still written as 1.0.0.0 no
//longer matches, so it is renamed to config.1.0.0.0-0.json and a fresh default config.json is saved.
settings = JsonSettings.Configure<MySettings>("config.json")
                       .WithVersioning("2.0.0.0", VersioningResultAction.RenameAndLoadDefault)
                       .LoadNow();

//2) ...or bake the version into the class with [EnforcedVersion] and use the version-less overload:
//     [EnforcedVersion("2.0.0.0")]
//     public virtual Version Version { get; set; } = new Version(1, 0, 0, 0);
var byAttribute = JsonSettings.Configure<MySettings>("config.json")
                              .WithVersioning(VersioningResultAction.RenameAndLoadDefault)
                              .LoadNow();
Policy

A comparison between versions is done by the Policy which is a VersioningPolicyHandler delegate ((Version, Version) => bool) passed during the construction of VersioningModule<T> or falls back to static VersioningModule<T>.DefaultPolicy which can be changed.<br/> It is possible to change the static default policy by changing VersioningModule<T>.DefaultPolicy although each VersioningModule<T> can be assigned its own policy.<br/> By default the versions must match exactly:<br/>

static bool DefaultEqualPolicy(Version version, Version expectedVersion) {
    return expectedVersion?.Equals(version) != false;
}

Encryption

The default is AES-256-CBC over the serialized JSON (UTF-8 bytes), using only System.Security.Cryptography (the .NET base class library) — no third-party cryptography. The file holds a random IV followed by the AES-CBC ciphertext. Add WithBase64() to additionally store the result as copy-pasteable base64 text.

The secret comes in three forms:

Call Secret How it becomes the key
WithEncryption(string) / WithEncryption(SecureString) text password PBKDF2 (salted, iterated)
WithEncryption(byte[]) binary password the same PBKDF2 derivation, over the raw bytes
WithEncryptionRawKey(byte[]) raw AES key (16/24/32 bytes) used verbatim, no derivation

Each also has Func<...> and Func<T, ...> overloads for resolving the secret lazily (e.g. from a vault or an environment variable).

Notes:

  • A byte[] password is a different credential from the text password whose UTF-8 encoding equals those bytes — the text derivation folds in the string's character length, which raw bytes do not carry. Pick one form per file.
  • A raw key skips PBKDF2, so its strength is entirely the key you provide; supply high-entropy key material (e.g. RandomNumberGenerator.GetBytes(32)), not a low-entropy value.
  • The on-disk format is identical across all three (a random IV followed by AES-CBC blocks), and the text-password path is byte-for-byte compatible with every earlier version.
// text password (classic)
JsonSettings.Configure<MySettings>("config.json").WithEncryption("mysecretpassword").LoadNow();

// binary password (PBKDF2-derived)
JsonSettings.Configure<MySettings>("config.json").WithEncryption(passwordBytes).LoadNow();

// raw key (verbatim, 16/24/32 bytes for AES)
JsonSettings.Configure<MySettings>("config.json").WithEncryptionRawKey(key32).LoadNow();

The default AesCbc is unauthenticated and on-disk compatible with every earlier version. Pass an EncryptionAlgorithm to choose another — including authenticated algorithms that detect a tampered file, not only keep it confidential:

// authenticated AEAD (.NET 6.0+)
JsonSettings.Configure<MySettings>("config.json").WithEncryption("password", EncryptionAlgorithm.AesGcm).LoadNow();
JsonSettings.Configure<MySettings>("config.json").WithEncryptionRawKey(key32, EncryptionAlgorithm.ChaCha20Poly1305).LoadNow();

AesCbc and AesCbcHmac are available on every target framework; AesGcm, AesCcm and ChaCha20Poly1305 require .NET 6.0+. There is no algorithm marker in the file, so read it back with the same algorithm it was written with; only AesCbc reads files from older versions. Encryption runs entirely on System.Security.Cryptography — there is no third-party cryptographic dependency.

Autosave

Autosaving appends a save to the end of every property setter of a class marked [Autosave]. This happens at compile time, via IL weaving (AspectInjector), in the assembly that declares the class. Nothing is generated at runtime.

[Autosave]
public class MySettings : JsonSettings {
    public override string FileName { get; set; } = "config.json";
    public string Name { get; set; }        // no 'virtual' required
    public int    Count { get; set; }
}

var settings = JsonSettings.Load<MySettings>("config.json").EnableAutosave();
settings.Name = "changed";   // saved
What changed in 2.2.0

Autosave used to build a runtime proxy with Castle.Core, which forced three restrictions that are now gone:

Before (Castle.DynamicProxy) Now (compile-time weaving)
Every public property had to be virtual Ordinary properties work; virtual is irrelevant
The class could not be sealed sealed classes work
EnableAutosave() returned a different object, so a reference captured beforehand silently did not autosave Returns the same instance; every reference to it autosaves
Impossible under Native AOT (System.Reflection.Emit) No runtime codegen at all

In exchange there is one new requirement: the class must carry [Autosave]. Calling EnableAutosave() on a class without it throws JsonSettingsException rather than silently doing nothing.

[Autosave] is not inherited. A setter is woven where it is declared, so every class in a settings hierarchy that declares properties you want saved needs its own attribute.

Two smaller behavioural notes for anyone migrating from 2.1.0:

  • virtual is no longer an opt-out. Under the proxy, a non-virtual property was silently skipped; some code relied on that to keep a property out of autosaving. Every setter is now woven regardless of virtual, so a property that must not autosave has to say so with [IgnoreAutosave] (or [JsonIgnore]).
  • EnableAutosave() is idempotent. Calling it twice on the same instance returns that instance and does not attach a second autosave module.

The Castle-era JsonSettingsAutosaveExtensions.Options field (a Castle.DynamicProxy.ProxyGenerationOptions) is removed, since the type it exposed no longer exists in the dependency graph.

Attributes

Properties can be marked with IgnoreAutosaveAttribute (JsonIgnoreAttribute will also work) to be excluded from the monitored properties for changes. This applies to collections too: an [IgnoreAutosave] ObservableCollection does not save when its contents change.

Behaviour notes
  • Indexers are not monitored. Writing settings[key] = value does not autosave — an indexer is not a serializable property. Use a normal property or call Save().
  • Reentrancy is safe. Writing a monitored property from inside an AfterSave handler does not trigger another save (it would otherwise recurse); the value is kept in memory and persists on the next save.
  • SuspendAutosave nests. Nested suspension scopes are reference-counted and collapse into a single save when the outermost scope closes; an inner scope closing does not end suspension.
  • A failing save surfaces at the assignment. If the triggered Save() throws, the exception propagates out of the property assignment (the new value is already set in memory).
  • Disposing the settings unbinds autosave, including handlers attached to nested collections.
  • Loading does not autosave. Load(), LoadDefault() and a versioning reload populate the object from disk through its setters; those writes are not user edits and do not save back (autosave resumes normally afterward).
  • IVersionable.Version is not monitored. It is framework metadata managed by the versioning module and rides along in every ordinary save, so changing it does not by itself autosave. (A property named Version on a class that does not implement IVersionable is ordinary user data and is monitored.)
Requirements
  • Install nucs.JsonSettings.Autosave nuget package
  • Mark the settings class [Autosave]
  • Call mySettings.EnableAutosave() extension after calling Load
How the weave runs (out of process, since 2.3.0)

AspectInjector's stock in-process MSBuild task leaks file handles into the MSBuild node, which deterministically failed small executable consumers at the SDK's CreateAppHost step (MSB4018 / "The process cannot access the file '<App>.dll' because it is being used by another process") — merely referencing the package was enough. Since 2.3.0 the shipped build targets run the identical weaver task in a short-lived child MSBuild process instead, so every leaked handle is closed at child exit before CreateAppHost runs; weaving behaviour and incrementality are unchanged. Opt back into the in-process weave with <NucsJsonSettingsOutOfProcWeave>false</NucsJsonSettingsOutOfProcWeave>. See docs/aspectinjector-2.9.0-apphost-lock.md for the full forensics.

Strong-named consumers

IL weaving rewrites the assembly after the compiler has signed it, and AspectInjector 2.9.0 retired its re-signing feature. The package therefore ships MSBuild targets that re-sign the assembly with your own $(AssemblyOriginatorKeyFile) after the weave. If sn.exe cannot be found the build warns (NJS1001) rather than failing; opt out entirely with <NucsAutosaveResignAfterWeaving>false</NucsAutosaveResignAfterWeaving>.

Suspend Autosave

In some scenarios, there might be multiple close changes to the configuration object. Normally that would trigger multiple save calls.

To prevent that, the developer can create a SuspendAutosave object which will postpone the save to when SuspendAutosave will be disposed or Resume called. If there were no changes between the allocation of SuspendAutosave object and disposal/resume then save won't be called.

var settings = JsonSettings.Load<MySettings>("config.json").EnableAutosave();

using (settings.SuspendAutosave()) {
    settings.Width  = 800;    // does not save yet
    settings.Height = 600;    // does not save yet
    settings.Title  = "App";  // does not save yet
}                             // one save here on dispose — and only if something changed

//or drive it manually instead of a using-block:
var suspender = settings.SuspendAutosave();
settings.Width = 1024;
suspender.Resume();  // commits the single pending save (same as Dispose); a second call is a no-op

SuspendAutosave() resolves the object's suspension module — the AutosaveModule on a woven class, the bag's own SettingsBagAutosaveModule on a SettingsBag — so call EnableAutosave() first. Scopes are reference-counted and nest — only the outermost one commits, once.

WPF Support with INotifyPropertyChanged/INotifyCollectionChanged

Any settings class can turn into a ViewModel with full autosave support making window settings and state persistence much simpler.

When your settings class inherits INotifyPropertyChanged, upon calling EnableAutosave, a NotificationBinder is attached to the settings object that'll listen to the settings class's:

  • event PropertyChanged calls
  • All properties that implement INotifyPropertyChanged will bind to their event PropertyChanged
  • All properties that implement INotifyCollectionChanged such as ObservableCollection<T> will bind to their event CollectionChanged
  • All other properties save through their woven setter (virtual is not required as of 2.2.0).

So evidently, objects inside ObservableCollection or other nested properties that are not in the settings class are not monitored for changes.<br/><br/> Saving on a plain property write is handled by the woven setter, so a hand-written setter that raises OnPropertyChanged and an auto-implemented one behave identically. The NotificationBinder is what re-binds nested INotifyPropertyChanged / INotifyCollectionChanged objects when the property holding them is replaced.

Requirements
  • Settings class inherit INotifyPropertyChanged (e.g. by deriving NotifiyingJsonSettings)
  • Mark the settings class [Autosave]
  • Install nucs.JsonSettings.Autosave nuget package
  • Call mySettings.EnableAutosave() extension after calling Load
Producing notifications for the View — [NotifyChanges]

The above makes autosave react to PropertyChanged. To make a setter raise it — so a binding (WPF, WinForms, Avalonia, WinUI, MAUI, Uno) refreshes — without hand-writing OnPropertyChanged() in every setter, including on auto-properties (which otherwise save but never notify), install the separate Nucs.JsonSettings.NotifyChanges package and mark the class [NotifyChanges]:

[Autosave, NotifyChanges]                     // [Autosave] from Nucs.JsonSettings.Autosave,
public class WindowSettings : NotifiyingJsonSettings {   // [NotifyChanges] from Nucs.JsonSettings.NotifyChanges
    public override string FileName { get; set; } = "window.json";
    public double Width { get; set; }   // binds two-way, saves, and notifies — no boilerplate
    public string Title { get; set; }
}
  • Compile-time weave like [Autosave], not inherited, and composes with it (one write saves and notifies once). Put it on auto-properties — a hand-written setter that already calls OnPropertyChanged() would notify twice. Framework-neutral: depends only on System.ComponentModel, not on WPF.
  • NotificationGuard controls when it fires, per class or per property: OnlyChanged (default), SkipNullOrDefault, Always — and they combine ([Flags]).
  • Silence a property with [IgnoreNotify] (independent of [IgnoreAutosave] — a property can save without notifying, or the reverse); framework FileName/Modulation/Version never notify.
  • The class must own the event: NotifiyingJsonSettings, or an MVVM base recognised by convention (OnPropertyChanged / RaisePropertyChanged / NotifyOfPropertyChange). For a class with no base, [NotifyChangesMixin] injects INotifyPropertyChanged for you (per-instance; best for a single class — a hierarchy should use NotifiyingJsonSettings + [NotifyChanges]).
  • Also raises INotifyPropertyChanging before the change (on NotifiyingJsonSettings, a convention raiser, or the mixin), fans a change out to a computed property with [NotifyChangesFor(nameof(…))], and marshals notifications onto the UI thread for off-thread writes via EnableNotificationMarshaling().

See the Notifications & Data Binding guide for the guard details, the mixin, INotifyPropertyChanging, [NotifyChangesFor], SynchronizationContext marshalling, nested-collection autosave, threading, and a comparison with Fody PropertyChanged, CommunityToolkit.Mvvm and ReactiveUI.

For a runnable tour, examples/JsonSettings.Examples.UI is a WPF app in which every control is a bound settings property — the window's own position/size/title persist through the binding, and one tab per integration (guards, [NotifyChangesFor], the opt-outs, nested collections, the mixin, raiser conventions, EnableIAutosave, marshalling) shows its save/notification counters, an activity log and the JSON file on disk, live:

dotnet run --project examples/JsonSettings.Examples.UI -f net8.0-windows

Throttled Save

Upcoming feature...

Dynamic Settings Bag

SettingsBag internally stores a key-value dictionary. Any type of Value can be passed as long as Json.NET knows how to serialize it. <br/> SettingsBag has built-in feature for autosaving that can be enabled by calling EnableAutosave without WPF binding support. <br/>

var bag = JsonSettings.Load<SettingsBag>("bag.json").EnableAutosave();
bag["Name"] = "value";           // saved
bag.Remove("Name");              // saved
dynamic d = bag.AsDynamic();
d.Other = 42;                    // saved (routes through the bag)

This is a separate autosave from the [Autosave] weaving used for typed classes — it is dictionary-backed, needs no attribute, and is what SettingsBag.EnableAutosave() (the instance method) turns on. Its own SettingsBagAutosaveModule shares the SuspensionModule state machine with the woven path, so it inherits the same guarantees: SuspendAutosave() (including nesting), reentrancy safety (writing the bag inside an AfterSave handler does not recurse), and Remove/RemoveWhere autosave like an index write.

Notes:

  • Calling the EnableAutosave() extension on a JsonSettings-typed reference to a bag routes to the bag's own autosave, so it behaves the same as calling the instance method.
  • AsDynamic() returns a disposable wrapper; using it after Dispose() throws ObjectDisposedException.

Changing JsonSerializerSettings

The default settings are defined on static JsonSettings.SerializationSettings.

public static JsonSerializerSettings SerializationSettings { get; set; } = new JsonSerializerSettings {
    Formatting = Formatting.Indented, 
    ReferenceLoopHandling = ReferenceLoopHandling.Ignore, 
    NullValueHandling = NullValueHandling.Include, 
    ContractResolver = new FileNameIgnoreResolver(), 
    TypeNameHandling = TypeNameHandling.Auto, 
    MaxDepth = 128
};

To alter the JsonSerializerSettings, it's best to understand how the library is resolving which settings to use during serialization/deserialization as follows:

/// <summary>
///     Returns configuration based on the following fallback: <br/>
///     settings ?? this.OverrideSerializerSettings ?? JsonSettings.SerializationSettings ?? JsonConvert.DefaultSettings?.Invoke()
///              ?? throw new JsonSerializationException("Unable to resolve JsonSerializerSettings to serialize this JsonSettings");
/// </summary>
/// <param name="settings">If passed a non-null, This is the settings intended to use, not any of the fallbacks.</param>
/// <exception cref="JsonSerializationException">When no configuration valid was found.</exception>
protected virtual JsonSerializerSettings ResolveConfiguration(JsonSerializerSettings? settings = null) {
    return settings
           ?? this.OverrideSerializerSettings
           ?? JsonSettings.SerializationSettings
           ?? JsonConvert.DefaultSettings?.Invoke()
           ?? throw new JsonSerializationException("Unable to resolve JsonSerializerSettings to serialize this JsonSettings");
}
  1. settings parameter is an internal mechanism when handling defaults. If passed a non-null, This is the settings intended to use, not any of the following fallbacks.
  2. this.OverrideSerializerSettings is a property in every class inheriting JsonSettings allowing personalized settings per object. The OverrideSerializerSettings property and ResolveConfiguration method are both virtual and can be overriden to redirect the resolving to where ever you see fit or with what-ever predefined value.
  3. static JsonSettings.SerializationSettings is the default for all JsonSettings objects.
  4. static JsonConvert.DefaultSettings is the default settings defined on a Json.NET level.

Converters

Defining converters or changing the serialization settings globally can be done by adding a converter to static JsonSettings.SerializationSettings as follows:<br/>

//call during app startup
JsonSettings.SerializationSettings.Converters.Add(new Newtonsoft.Json.Converters.VersionConverter());

Alternatively per object setting can be done by setting or inheriting JsonSettings.OverrideSerializerSettings property but it is important to also specify the default configuration so JsonSettings behavior will remain persistent (see more) .

JsonConverterAttribute

By far the easiest way to specify a converter is by specifying a JsonConverterAttribute on the property and Json.NET will do the rest.

[JsonConverter(typeof(ExchangeConverter))]
public ExchangeType Exchange { get; set; }

JsonConverterAttribute can also be specified on an interface property as it is used in IVersionable and will apply to any class inheriting it. <br/>This is the best approach for other libraries because by specifying an attribute, no matter what JsonSerializerSettings will be specified by the developer, Json.NET will always serialize this property with the specified converter.

public interface IVersionable {
    [JsonConverter(typeof(Newtonsoft.Json.Converters.VersionConverter))]
    public Version Version { get; set; }
}

Modulation Api

Key points

  • All modules are stored inside JsonSettings.ModuleSocket Modulation { get; }.
  • ModuleSocket stores all modules attached to this JsonSettings object.
  • Every settings object gets a new module object allocated for every module configured.
  • Attaching modules is done via static extensions <span style='font-size:11px; padding-left: 3px' >read more </span>
  • All modules provided by the library have properties and methods that are suited for inheritance so extending is easy.
using Nucs.JsonSettings;
using Nucs.JsonSettings.Modulation;
using Nucs.JsonSettings.Modulation.Recovery;

//Attach a module fluently — by instance, or by type with constructor arguments:
var settings = JsonSettings.Configure<MySettings>("config.json")
                           .WithModule(new Base64Module())                                 // your own instance
                           .WithModule<MySettings, RecoveryModule>(RecoveryAction.LoadDefault) // constructed for you
                           .LoadNow();

WithEncryption, WithBase64, WithVersioning and WithRecovery are all thin wrappers over WithModule — e.g. WithRecovery(action) is exactly WithModule(new RecoveryModule(action)).

With Construct. Construct<T>(args) is the constructor-args sibling of Configure<T>(filename): both hand back a fresh, fully-configured instance that has not read the file yet, so you can wire up modules (or seed defaults in memory) before an explicit Load/Save:

//build a fresh instance, attach modules, then load explicitly:
var settings = JsonSettings.Construct<MySettings>("config.json") // ctor args go to your constructor
                           .WithModule(new Base64Module())
                           .WithEncryption("password")
                           .LoadNow();

//or use it purely in-memory — seed defaults and write them without reading an existing file:
var seeded = JsonSettings.Construct<MySettings>("config.json");
seeded.SomeProperty = "default";
seeded.Save();

Execution Order

The events are many to allow as much interception as possible.<br> The event handlers do not return any data but instead they receive a reference of the object that can be modified and will be used in the next stage.<br> Loading

event BeforeLoadHandler BeforeLoad(JsonSettings sender, ref string source); //source is the file that will be loaded.
event DecryptHandler Decrypt(JsonSettings sender, ref byte[] data);
event AfterDecryptHandler AfterDecrypt(JsonSettings sender, ref byte[] data);
event BeforeDeserializeHandler BeforeDeserialize(JsonSettings sender, ref string data);
event BeforeRepopulateHandler BeforeRepopulate(JsonSettings sender); //brackets the populate itself; fires on EVERY populate incl. LoadDefault and direct LoadJson
event AfterRepopulateHandler AfterRepopulate(JsonSettings sender, bool successfulPopulate); //from a finally; false when the populate threw halfway
event AfterDeserializeHandler AfterDeserialize(JsonSettings sender);
event AfterLoadHandler AfterLoad(JsonSettings sender, bool successfulLoad);

And in a case of JsonException during LoadJson

//recovered marks if a recovery from failure was successful, handled will prevent any further modules from attempting to recover.
//if recovered is returned false, JsonSettingsException will be thrown with the original exception as inner exception
event TryingRecoverHandler TryingRecover(JsonSettings sender, string fileName, JsonException? exception, ref bool recovered, ref bool handled);
event RecoveredHandler Recovered(JsonSettings sender);

Saving

event BeforeSaveHandler BeforeSave(JsonSettings sender, ref string destinition);
event BeforeSerializeHandler BeforeSerialize(JsonSettings sender);
event AfterSerializeHandler AfterSerialize(JsonSettings sender, ref string data);
event EncryptHandler Encrypt(JsonSettings sender, ref byte[] data);
event AfterEncryptHandler AfterEncrypt(JsonSettings sender, ref byte[] data);
event AfterSaveHandler AfterSave(JsonSettings sender, string destinition);
Cryptography / Encoding Decoding

When attaching to OnEncrypt event, it'll push to the end of the event queue - meaning it will receive the data after all the events/modules that were attached to it before.<br> When attaching to OnDecrypt, it is pushed to the beginning of the event queue.<br> Hence encryption/encoding and decryption/decoding is automatically in the right order.<br>

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 is compatible.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 is compatible.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (4)

Showing the top 4 NuGet packages that depend on Nucs.JsonSettings:

Package Downloads
Nucs.JsonSettings.Autosave

An extension to JsonSettings that saves automatically when a property changes. Mark your settings class [Autosave] and every setter commits a save - no proxy, no virtual requirement, and nothing generated at runtime, so it works under Native AOT.

Kitty.Common_framework

this library contains log config inject database

Kitty.Common

Package Description

Nucs.JsonSettings.NotifyChanges

An extension to JsonSettings that turns a settings class into an observable, bindable object. [NotifyChanges] and [NotifyChangesMixin] rewrite your property setters at compile time to raise INotifyPropertyChanged - no proxy, no virtual requirement, nothing generated at runtime, so it works under Native AOT. Optional INotifyPropertyChanging, dependent-property notification via [NotifyChangesFor], and opt-in SynchronizationContext marshalling for off-thread writes. Framework-neutral: depends only on System.ComponentModel, not on WPF.

GitHub repositories (3)

Showing the top 3 popular GitHub repositories that depend on Nucs.JsonSettings:

Repository Stars
HandyOrg/HandyWinGet
GUI for installing apps through WinGet and Creating Yaml file
ghost1372/DevWinUI
DevWinUI is a collection of useful classes, controls, styles, and codes for WinUI 3. Create a WinUI 3 app in less than a minute with the built-in project templates and scaffolding tools.
WinUICommunity/WinUICommunity
WinUICommunity is a collection of useful classes, controls, styles, and codes for WinUI 3. Create a WinUI 3 app in less than a minute with the built-in project templates and scaffolding tools.
Version Downloads Last Updated
2.3.2 37 8/7/2026
2.3.1 74 8/7/2026
2.3.0 94 8/6/2026
2.1.0 223 7/27/2026
2.0.2 34,206 4/5/2023
2.0.1 2,141 1/13/2023
2.0.0-alpha7 5,081 5/23/2021
2.0.0-alpha5 1,460 5/9/2021
2.0.0-alpha4 1,393 5/8/2021
2.0.0-alpha3 1,437 5/1/2021
2.0.0-alpha2 1,484 3/31/2021
2.0.0-alpha1 1,375 3/29/2021
1.0.7 11,152 6/3/2018
1.0.6 2,193 5/26/2018
1.0.5 2,249 5/11/2018
1.0.4 2,216 4/12/2018
1.0.3 2,161 4/12/2018
1.0.2 2,234 4/8/2018
Loading failed

2.3.2

Changed
- Public namespace corrections (source-breaking - update your usings, no behavior change):
 - NotifiyingJsonSettings moved from Nucs.JsonSettings.Examples to Nucs.JsonSettings. It is the
   shipped INotifyPropertyChanged base the Autosave and NotifyChanges packages bind to (production
   API, not a sample) and was the only library type in the .Examples namespace.
 - The fluent configuration extensions (WithFileName, WithModule, WithEncryption, WithBase64,
   WithVersioning, WithRecovery, WithDefaultValues, LoadNow) moved from Nucs.JsonSettings.Fluent to
   Nucs.JsonSettings, so one `using Nucs.JsonSettings;` now covers the whole configure-and-load call.
 Update imports: replace `using Nucs.JsonSettings.Examples;` and `using Nucs.JsonSettings.Fluent;`
 with `using Nucs.JsonSettings;`. Members, behavior and the on-disk format are unchanged; KeySize,
 EncryptionAlgorithm, ISavable and IEncryptedSavable remain in Nucs.JsonSettings.

2.3.1

Fixed
- Builds no longer fail on locales whose default calendar is not Gregorian - Persian (fa-IR),
 Umm al-Qura Arabic (ar-SA) and kin - with "error MSB3374: The last access/last write time on
 file '....aspectsinjected' cannot be set. Not a valid Win32 FileTime." (the follow-up report on
 issue #51). Both weave targets stamped their incrementality marker by feeding
 %(IntermediateAssembly.ModifiedTime) into the Touch task's Time parameter: MSBuild formats that
 metadata with the CURRENT CULTURE's calendar while Touch parses Time with the invariant culture,
 so under fa-IR August 2026 was rendered as Persian year 1405, re-read as Gregorian year 1405,
 and dates before 1601 cannot be represented as a Win32 FILETIME - every build died at the stamp,
 right after a successful weave. The timestamp now travels as a culture-invariant round-trip
 ("o") string (verified tick-exact under fa-IR against both the .NET and the Visual Studio
 MSBuild); the marker-equals-assembly-time up-to-date discipline is unchanged. Note that
 AspectInjector's stock in-process target carries the same defect on its own stamp line, so the
 out-of-process weave (the default since 2.3.0) is now also the only culture-safe path.
- The settings-file version/archive-counter pattern (VersioningModule.VersionMatcher) matches
 ASCII digits only ([0-9] instead of regex \d, which in .NET matches every Unicode digit): a
 settings file hand-named with native digits - natural on a Persian or Arabic keyboard - in its
 version or counter segment previously MATCHED the pattern and then crashed the load with a raw
 FormatException from the ASCII-only int.Parse of the archive counter; such names now simply
 parse as unversioned and are archived accordingly. The counter parse is also explicitly
 invariant-culture.
- JsonSettings.Load's corrupt-file exception filter compares ordinally
 (StartsWith(..., StringComparison.Ordinal)) instead of with the current culture's collation.
- Verified: the full test suite (net10.0/net8.0/net6.0/net48/net472) runs green with the system
 culture switched to fa-IR and to tr-TR, the torture locales for non-Gregorian calendars and
 for casing/decimal rules respectively.

2.3.0

Fixed
- Executable consumers no longer fail in CreateAppHost with "error MSB4018 ... The process cannot
 access the file '<App>.dll' because it is being used by another process". AspectInjector 2.9.0's
 in-process MSBuild task leaks file handles into the MSBuild node that hosts it: a ReadWrite handle
 on obj\<App>.dll whenever the assembly has nothing to weave - exactly the case for an app that
 merely references Nucs.JsonSettings.Autosave or Nucs.JsonSettings.NotifyChanges without declaring
 an [Autosave]/[NotifyChanges] class yet - and read handles on every referenced assembly always (its
 reference resolver is never disposed). The SDK's next step for an executable, CreateAppHost, then
 cannot open the still-locked dll and the build dies: deterministically on small fast builds (a
 one-project console app, a stock WinUI app), not at all on heavy ones (MAUI) purely by timing. The
 leaked read handles also blocked REBUILDS of referenced projects for as long as a reused MSBuild
 worker node lived. Both weaving packages now suppress the in-process task and run the identical
 AspectInjectorTask in a short-lived child MSBuild process (a shipped .Weave.proj next to the build
 targets); the OS closes everything the weaver leaks when the child exits, before CreateAppHost
 runs. Weaving semantics, task parameters, incrementality (the .aspectsinjected stamp) and the
 strong-name re-sign step are unchanged, and a consumer referencing BOTH packages still weaves
 exactly once. Opt back into the stock in-process weave with
 <NucsJsonSettingsOutOfProcWeave>false</NucsJsonSettingsOutOfProcWeave> (one property, honoured by
 both packages); <AspectInjector_Enabled>false</AspectInjector_Enabled> still disables weaving
 entirely. New diagnostics NJS1005/NJS1006 (error) fire if AspectInjector.dll cannot be located for
 the out-of-process weave, with the remedies in the message.
- Incremental builds no longer stack the weave when another post-compile IL rewriter runs after it.
 .NET MAUI's XamlC target rewrites the assembly AFTER the weave and moves its timestamp past the
 .aspectsinjected marker, so the next incremental build re-wove the already-woven assembly - and
 re-weaving stacks the advice (measured: a setter that saved once per write saved twice after the
 next build, three times after the one after that). The packaged targets now re-stamp the marker
 right after XamlC, the same guard the re-sign step already applied for its own rewrite. Avalonia
 needs no guard by construction: its XAML compiler runs inside CoreCompile, before the weave.
- Re-weave stacking is now impossible whatever rewrites the assembly, not just for the XamlC case
 above: before invoking the weaver, the targets read the intermediate assembly and skip the weave
 when it already carries AspectInjector's injected IL (the '__a$_instance' aspect singleton, which
 no compiler emits from source). A Fody / ILRepack / obfuscator step that pushes the assembly past
 the .aspectsinjected stamp therefore costs one detection read on the next incremental build
 instead of silently doubling the advice per build. A semicolon in the intermediate output path now
 fails fast as NJS1007/NJS1008 with the actual reason (MSBuild splits child -property: values on
 ';', and .NET executables cannot start from such paths anyway) instead of a cryptic MSB1006 from
 the child process.
- A build that silently skipped the weave can no longer masquerade as working: the [Autosave] aspect
 now mixes the empty marker interface IAutosaveWoven into every class it processes, and
 EnableAutosave() throws a JsonSettingsException naming the likely causes (a direct AspectInjector
 reference with ExcludeAssets="build", a single-pass `msbuild -t:Restore;Build` that evaluates the
 project before the restored targets exist, AspectInjector_Enabled=false) when the attribute is
 present but the marker is not. Previously such a class compiled, EnableAutosave() succeeded, and
 every "saved" change was silently lost. For assemblies woven by versions older than 2.3.0 that
 cannot be rebuilt, set JsonSettingsAutosaveExtensions.RequireWeaveMarker=false once at startup.
- EnableAutosave() now binds nested collections on ANY settings class that implements
 INotifyPropertyChanged - [NotifyChangesMixin] classes and hand-written implementations included -
 where previously only a NotifiyingJsonSettings base qualified. A mixin class's
 ObservableCollection compiled, bound in the UI and silently never saved on in-place Add/Remove;
 the shipped Avalonia example demonstrated exactly that without knowing it. NotificationBinder
 gained a JsonSettings constructor overload (the NotifiyingJsonSettings one remains).
- Reloading no longer duplicates collection contents, and recovery genuinely resets them: writable
 collection properties deserialize with ObjectCreationHandling.Replace. Json.NET's default (Auto)
 reused the live collection and appended the file's items on every Load() (["a"] reloaded as
 ["a","a"]), grew default-seeded collections by one copy per application start, and let
 RenameAndLoadDefault keep - and re-save as the new "defaults" - the stale pre-corruption items.
 The NotificationBinder resyncs itself after every populate (it subscribes to the repopulate
 events the load pipeline raises), so the replacement instances stay bound and keep saving;
 get-only collections cannot be replaced and keep the append semantics.
- Nested-change saves now honour every gate the woven-setter path honours. The NotificationBinder's
 collection / nested-object handlers called Save() directly, so a populate that wrote a bound
 nested object or filled a get-only collection saved the half-loaded file from inside Load(); an
 AfterSave handler that mutated a bound collection re-entered Save without bound (a stack
 overflow, not a catchable exception); and an in-place Add inside a SuspendAutosave() scope saved
 immediately, once per Add, instead of batching into the resume commit - each contradicting the
 documented behaviour of loads, re-entrancy and suspension. A nested change now takes exactly the
 decision path a woven setter write takes (load suppression, re-entrancy guard, suspension
 accounting), whichever pipe reported it.

Examples (in the repository, not in the packages)
- A UI example gallery now exercises the packages from real apps on every framework the docs name:
 Avalonia (cross-platform: compiled bindings over the mixin-injected INotifyPropertyChanged,
 background writes marshalled via EnableNotificationMarshaling, and corrupt-file self-healing with
 WithRecovery(RenameAndLoadDefault) wired to a "corrupt the file" button), Windows Forms
 (net48 + net10.0-windows: the zero-code settings dialog PropertyGrid.SelectedObject = settings,
 BindingSource two-way binding kept in sync by the injected interface, and an encrypted vault via
 WithEncryption shown side by side with its ciphertext on disk), .NET MAUI (the settings class as
 the page's BindingContext, [NotifyChangesFor]-computed captions, batched SuspendAutosave reset)
 and the existing WPF notification tour.

2.2.0

Breaking changes
- Both shipped packages are now strong-named (PublicKeyToken=cc7b13ffcd2ddd51) AND their assemblies
 are renamed to match their package ids and namespaces: JsonSettings.dll -> Nucs.JsonSettings.dll and
 JsonSettings.Autosave.dll -> Nucs.JsonSettings.Autosave.dll. (The new Nucs.JsonSettings.NotifyChanges
 package follows the same rule but has no prior release to break.) Everything published up to and
 including 2.1.0 shipped unsigned, which meant a strong-named assembly could not reference this library
 at all - a strong-named consumer cannot reference a weak-named dependency, and there was no workaround
 short of repackaging the DLL. The key is Microsoft's published open-source key, the same one
 netstandard, System.Memory and System.Buffers carry; its private half is published by design, so this
 is assembly IDENTITY and not a security property. It does not attest that a file came from this
 project, and InternalsVisibleTo is not an access control. Verify origin with the SHA-256 checksums
 published on each GitHub release.
 UPGRADING: the assembly identity changes in two ways - a strong-name token where there was none, and a
 new simple name (Nucs.JsonSettings[.Autosave]). A bindingRedirect written against the old unsigned
 identity will not match the new one and should be removed rather than edited, and anything that
 hardcodes a full assembly display name needs the name and token updated. Recompiling is otherwise
 enough. See docs/SIGNING.md.
- Autosave now requires an [Autosave] attribute on the settings class. Nucs.JsonSettings.Autosave
 no longer proxies with Castle.DynamicProxy; it rewrites the property setters at compile time
 with AspectInjector. An existing EnableAutosave()/EnableIAutosave() call now throws until the
 class is marked [Autosave] - the missing attribute is checked at runtime rather than silently
 never saving. In exchange the old proxy restrictions are gone: properties no longer need to be
 virtual, sealed classes are supported, and EnableAutosave() returns the very instance it was
 given rather than a proxy, so a reference captured before the call autosaves too. SettingsBag is
 unaffected - its autosave is dictionary-backed, not woven. MIGRATION: add [Autosave] to the
 class; opt a property out with [IgnoreAutosave]. See docs/AOT.md.
- Encryption moved onto System.Security.Cryptography directly and the vendored third-party Rijndael256
 helper was removed. RijndaelModule was renamed to EncryptionModule; RijndaelModule is kept as an
 [Obsolete] shim that forwards to it, so existing code still compiles. The KeySize enum moved from the
 Rijndael256 namespace to Nucs.JsonSettings - source-compatible through a `using Nucs.JsonSettings;`,
 but a binary namespace change. The default algorithm, the PBKDF2-SHA1 key derivation and the on-disk
 format are unchanged; files from every earlier version stay readable, verified against a pre-migration
 ciphertext and an independent BCL-only reimplementation of the format.

New features
- New lifecycle events BeforeRepopulate and AfterRepopulate (JsonSettings and ISavable) bracket the
 JSON populate itself and are the pipeline's only per-populate signal: unlike
 BeforeDeserialize/AfterDeserialize (successful file loads only) and AfterLoad (once per Load),
 they also fire for LoadDefault(), versioning and recovery reloads, and direct LoadJson() calls.
 AfterRepopulate fires from a finally and carries successfulPopulate - false when the populate
 threw halfway, in which case the object may hold a mix of old and file values. Handlers must not
 save between the two; the autosave machinery itself rides this pair for load suppression and
 collection rebinding.
- Encryption accepts binary secrets, not only a text password:
 - WithEncryption(byte[]) - a binary password, PBKDF2-stretched exactly like a text password.
   It is a DIFFERENT credential from the text password whose UTF-8 bytes match it.
 - WithEncryptionRawKey(byte[]) - a raw AES key used verbatim, no derivation, 16/24/32 bytes,
   for callers who already hold key material.
 Each has value, Func<byte[]> and Func<T,byte[]> overloads. The on-disk format is
 unchanged and the text-password path is byte-for-byte compatible with every earlier version.
- Encryption can use algorithms beyond the default AES-256-CBC, all from System.Security.Cryptography:
 authenticated AES-CBC-HMAC (every target framework), and the AEAD ciphers AES-GCM, AES-CCM and
 ChaCha20-Poly1305 (.NET 6.0+). Select one by passing an EncryptionAlgorithm to WithEncryption,
 WithEncryption(byte[]) or WithEncryptionRawKey. The authenticated algorithms reject a tampered file
 outright rather than relying on the AES-CBC UTF-8 heuristic; the default stays AES-256-CBC, unchanged
 on disk.

Notifications and data binding (new package Nucs.JsonSettings.NotifyChanges)
- New package Nucs.JsonSettings.NotifyChanges produces INotifyPropertyChanged from your setters for data
 binding, split out of Nucs.JsonSettings.Autosave so a class can notify without autosaving or the
 reverse. Mark a class [NotifyChanges] (with a NotifiyingJsonSettings base or a convention raiser) or
 [NotifyChangesMixin] (no base - the interface is injected) and every setter raises PropertyChanged with
 no hand-written OnPropertyChanged, including on auto-properties. Compile-time IL weaving like
 [Autosave], Native AOT-safe, and framework-neutral: it depends only on System.ComponentModel, so the
 same class binds under WPF, WinForms, WinUI, MAUI, Avalonia and Uno. To both save and notify, reference
 both packages. These aspects never shipped in a release, so the move is not a breaking change.
- NotificationGuard (OnlyChanged default, SkipNullOrDefault, Always; [Flags]) decides when a setter
 notifies, per class or per property. [IgnoreNotify] silences a property, independent of [IgnoreAutosave].
- Also raises INotifyPropertyChanging before the assignment: NotifiyingJsonSettings now implements it, a
 convention OnPropertyChanging/RaisePropertyChanging is recognised, and [NotifyChangesMixin] injects it
 alongside INotifyPropertyChanged.
- [NotifyChangesFor(nameof(Other))] fans a change out to a computed property so its binding refreshes -
 the counterpart to CommunityToolkit.Mvvm's [NotifyPropertyChangedFor].
- EnableNotificationMarshaling() captures the UI thread's SynchronizationContext and posts notifications
 back to it, so a settings object written from a background thread still raises PropertyChanged on the UI
 thread. Opt-in, off by default, and depends on no UI framework.
- Performance: the reflection left on hot and per-instance paths is now cached through a new
 Nucs.JsonSettings.Reflection.ReflectionHelper accessor cache. Every woven [NotifyChanges] setter read the
 property's previous value with PropertyInfo.GetValue and called a convention raiser with MethodInfo.Invoke
 on each write; both now use a delegate compiled once per member (with a reflective fallback under Native
 AOT, so nothing changes there). The nested-notifier binder and the per-instance constructor discovery in
 Activation (HasDefaultConstructor / CreateInstance) are cached likewise. Behaviour is unchanged.

Autosave (see the breaking-change note above for the [Autosave] requirement)
- Native AOT compatible. AspectInjector emits nothing at runtime, so EnableAutosave() works under
 Native AOT, where the Castle.DynamicProxy path threw PlatformNotSupportedException and no
 annotation could fix it. Nucs.JsonSettings.Autosave no longer depends on Castle.Core.
- Removed the vestigial public ProxyGeneratedAttribute. It tagged Castle.DynamicProxy-generated
 types so the JsonSettings constructor could skip re-creating their ModuleSocket; with the proxy
 path gone nothing applies it and the constructor guard that read it was always taken, so the
 attribute and the guard were both deleted.
- Autosave machinery no longer ships inside the base package. IgnoreAutosaveAttribute,
 NotificationBinder, the SuspendAutosave struct and AutosaveModule itself, plus the property
 opt-in rules, moved from Nucs.JsonSettings.dll to Nucs.JsonSettings.Autosave.dll where they are
 actually wired up; the Nucs.JsonSettings.Autosave namespace is unchanged, so a project that
 references the Autosave package sees no difference while a base-only project no longer sees
 types that did nothing without it. The base package keeps only the neutral state the two
 autosave paths share - the new SuspensionModule (the IsSaving/IsLoading gates and the
 reference-counted suspension machine) - and SettingsBag now attaches its own
 SettingsBagAutosaveModule subclass rather than the woven path's module, so the is-AutosaveModule
 resolution scans in the woven advice and the binder can never mistake a bag's module for a woven
 one. The load pipeline stopped reaching into modules altogether: LoadJson raises the new
 BeforeRepopulate/AfterRepopulate events around every populate - AfterRepopulate fires from the
 finally and reports whether the populate ran to completion - SuspensionModule brackets its own
 IsLoading by subscribing to them on attach, and the NotificationBinder resyncs its nested-change
 subscriptions the same way; both deliberately act on success and failure alike, since a populate
 that threw halfway has still replaced values. SuspendAutosave targets the shared SuspensionModule base, so
 settings.SuspendAutosave() keeps working on woven classes and bags alike, and a stand-alone
 binder (constructed without EnableAutosave) is now resynced on loads too, which the old
 module-slot pattern-match never did. One visible edge: resolving a bag's module by the concrete
 AutosaveModule type no longer matches - a bag carries a SettingsBagAutosaveModule. The module's
 SuspendAutosave() method is removed (call the settings.SuspendAutosave() extension) and its
 NotificationsHandler is now typed IDisposable - honestly a pure lifetime slot, since nothing
 reaches through it any more.
- The package re-signs the woven assembly after the weave (shipped build/ and buildTransitive/
 targets), because weaving invalidates the strong-name signature the compiler applied and
 AspectInjector 2.9.0 retired its own re-signing. If sn.exe is unavailable the build warns
 (NJS1001) rather than failing; the assembly still loads on .NET 5+. See docs/SIGNING.md.
- Fixed: a load after EnableAutosave() wrote the object back to disk once per populated property,
 persisting a half-loaded object mid-load. Load, LoadDefault and versioning reloads no longer
 count as user writes, and the framework-written IVersionable.Version is no longer monitored.
- Fixed: nested SuspendAutosave is reference-counted - an inner scope's Dispose no longer ends the
 outer scope early and commits the save it was batching.
- Fixed: [IgnoreAutosave] is honoured for inline-initialised INotify* collections, which the
 binder previously subscribed and saved on regardless of the attribute.
- Fixed (SettingsBag/DynamicSettingsBag): a write made inside AfterSave no longer recurses to a
 stack overflow, and EnableAutosave() reached through a JsonSettings-typed reference now behaves
 the same as through a SettingsBag-typed one instead of throwing.

Bug fixes (regressions introduced in 2.1.0)
- A custom module inside the encryption layer is readable again. 2.1.0's wrong-password UTF-8
 check ran on that module's still-encoded output, so a compressing or otherwise binary module
 attached before WithEncryption rejected a correct password; the check now runs on the final
 plaintext. Files 2.0.x wrote through such a chain load again.
- RecoveryModule absorbs a short or zero-length encrypted file again. 2.1.0 threw from the
 decrypt stage before recovery could run, so a zero-byte file - the usual result of an
 interrupted save - was no longer recoverable.
- A file too short to hold an initialization vector is a JsonSettingsException again, rather
 than an EndOfStreamException escaping catch (JsonSettingsException).
- Serialization depth: Newtonsoft.Json 13 capped nesting at 64 by default, inherited silently
 in 2.1.0. MaxDepth is now set explicitly to 128 - past any realistic settings graph, yet low
 enough to remain a working backstop, since the reader is recursive and exhausts the stack
 before a larger limit could fire. Set SerializationSettings.MaxDepth = null for the pre-2.1.0
 unlimited behaviour. See docs/UPGRADE-2.0.x-to-2.1.0.md.

Testing
- Cross-version file compatibility is now pinned by a full producer x consumer matrix. One JSON
 fixture is captured per shipped target (net472 standing in for netstandard2.0, plus net48, net6.0,
 net8.0 and net10.0) holding the exact bytes that framework serializes, and every target loads all
 of them on every ordinary test run. A settings file written on .NET Framework is proven to read
 back identically on modern .NET and the reverse, including the floating-point formatting that
 differs between the two - net48 renders a round-trippable double with "R" while .NET Core renders
 the shortest round-trippable string. Regenerate the fixtures with JSONSETTINGS_REGEN_CROSSVERSION=1.
- Added targeted edge-case and unhappy-flow coverage across the core and modules, chosen from a line
 coverage pass: Save translating a read-only or exclusively-held file into a catchable
 JsonSettingsException (rather than an UnauthorizedAccessException/IOException or a null-stream NRE), a
 corrupt file surfacing as the same, the configure-state guard rolling back so an instance whose
 OnConfigure threw can be loaded again, attaching a versioning module to a non-IVersionable socket,
 the invalid-action defensive arms, the notification-marshalling argument guards, and
 AutosaveModule.TryTriggerSave.

2.1.0

Bug fixes
- SuspendAutosave: fixed silent data loss. A change made inside a suspension scope defers
 its save to the end of the scope, but the scope reached the settings object only through
 a weak reference. If a garbage collection ran while the scope was open the pending write
 was dropped with no exception and no partial file. Most likely to bite when the settings
 variable is not touched again before the scope closes, which the JIT may treat as the
 object being dead.
- Encryption: a wrong password is now reliably reported as a wrong password. Detection
 relied on the padding check alone, which accepts wrong-key output by chance about once in
 256 attempts; when it did, the failure surfaced as "Unable to parse file" with a JSON
 reader error, pointing at file corruption rather than at the password. The decrypted
 payload is now also checked for being valid UTF-8. This is a better diagnostic, not an
 integrity guarantee, and the on-disk format is unchanged.
- Encryption: fixed a truncated-read defect. The initialization vector was read without
 checking how many bytes came back, so a short read left part of the IV zeroed and
 produced garbage plaintext or a misleading padding error rather than failing. Files too
 short to contain an IV are now rejected outright.
- Encryption is unchanged on disk. Existing encrypted files stay readable: the cipher is
 the same (AES-128-block CBC, previously spelled RijndaelManaged), and the PBKDF2
 pseudorandom function is still SHA-1, now stated explicitly so it cannot be "modernised"
 by accident. A ciphertext written by 2.0.x is checked against every target in the test
 suite.
- Paths: MoveFileEx is no longer P/Invoked on non-Windows, where it raised
 DllNotFoundException instead of doing nothing. IsDirectoryWritable builds its probe path
 with Path.Combine (it previously concatenated, testing a sibling of the intended
 directory) and no longer collides between concurrent processes. Paths.ExecutingExe is
 now nullable rather than being left null behind a non-nullable declaration when the
 executable path cannot be determined, as under single-file publish.

Targets and packaging
- Multi-targeting: netstandard2.0, net48, net6.0, net8.0 and net10.0 for both
 Nucs.JsonSettings and Nucs.JsonSettings.Autosave (Autosave previously shipped
 netstandard2.0 only).
- Removed the System.Security.SecureString 4.3.0 dependency. Every type used is part of
 netstandard2.0 and in-box on .NET Framework and modern .NET, so it only added a
 dependency edge.
- Upgraded Newtonsoft.Json to 13.0.3 and Castle.Core to 5.1.1.
- Packages now ship the README, declare their license as an SPDX expression instead of a
 deprecated licenseUrl, and publish a .snupkg symbol package with Source Link instead of
 embedding .pdb files in lib/.

Testing
- The test suite now runs against every shipped asset rather than a subset. net472 was
 added specifically to cover lib/netstandard2.0, which is what consumers without an exact
 framework match receive and which previously had no test behind it at all.
- Tests also run on Linux, so the platform-conditional code in Paths is executed rather
 than only compiled. .NET Framework targets remain Windows-only.

2.0.2
- Added RecoveryModule and .WithRecovery to capture parsing errors
- Added JsonSettings.SuspendAutosave() extension
- Added proxy construction guard to prevent unncessary allocation on proxy creation.
- Fix non virtual built-in properties returning null after being wrapped with proxy.
- Added ProxyGeneratedAttribute that is attached to every proxy-generated class
- Module: made Dispose() inheritable.
- Module.Socket: changed to WeakReference<JsonSettings>
- SettingsBag: added support for SuspendAutosave #26
- VersioningModule: some internal fixes and renames #19
- Added RecoveryModule and WithRecovery api. #19
- JsonSettings: added TryingRecover and Recovered events.
- JsonSettings: Removed ThrowOnEmptyFile, this is handled by RecoveryModule now
- Made all module functions and variables protected or virtual.
- FluentJsonSettings: replaced dynamic creation with hardcoded creation (faster).
- Added proper Versioning example
- JsonSettings: add OnXXXX methods that trigger event changed to protected internal.
- Made all modules extensible
- SettingsBag: removed locking, optimized and replaced with ConcurrentDictionary for threadsafety
- Added documentation