Nucs.JsonSettings
2.3.2
dotnet add package Nucs.JsonSettings --version 2.3.2
NuGet\Install-Package Nucs.JsonSettings -Version 2.3.2
<PackageReference Include="Nucs.JsonSettings" Version="2.3.2" />
<PackageVersion Include="Nucs.JsonSettings" Version="2.3.2" />
<PackageReference Include="Nucs.JsonSettings" />
paket add Nucs.JsonSettings --version 2.3.2
#r "nuget: Nucs.JsonSettings, 2.3.2"
#:package Nucs.JsonSettings@2.3.2
#addin nuget:?package=Nucs.JsonSettings&version=2.3.2
#tool nuget:?package=Nucs.JsonSettings&version=2.3.2
<img src="assets/icon.png" width="25" style="margin: 5px 0px 0px 10px"/> JsonSettings
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
- 📖 Documentation Website
- Features Overview
- The Basics
- Modules
- Dynamic Settings Bag
- Changing JsonSerializerSettings
- Converters
- Modulation Api
- Native AOT and Trimming
- License
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.0andnet10.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>
- 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, adynamicoption 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 asNullable<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.
- Uses AES via
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 —
virtualis not required (as of 2.2.0); opt a property out with[IgnoreAutosave] - Requires package
nucs.JsonSettings.Autosaveand 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.jsonversioned1.0.0.5will be renamed tomyfile.1.0.0.5-0.jsonif it fails on parsing (the trailing-0is 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.jsonversioned1.0.0.5will be renamed tomyfile.1.0.0.5-0.jsonif it fails on parsing (the trailing-0is 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.
- Pass the version when calling
WithVersioning. - Add
[EnforcedVersion("1.0.0.0")]attribute to yourIVersionable.Versionproperty 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:
virtualis 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 ofvirtual, 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] = valuedoes not autosave — an indexer is not a serializable property. Use a normal property or callSave(). - Reentrancy is safe. Writing a monitored property from inside an
AfterSavehandler does not trigger another save (it would otherwise recurse); the value is kept in memory and persists on the next save. SuspendAutosavenests. 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.Versionis 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 namedVersionon a class that does not implementIVersionableis ordinary user data and is monitored.)
Requirements
- Install
nucs.JsonSettings.Autosavenuget package - Mark the settings class
[Autosave] - Call
mySettings.EnableAutosave()extension after callingLoad
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 PropertyChangedcalls- All properties that implement
INotifyPropertyChangedwill bind to theirevent PropertyChanged - All properties that implement
INotifyCollectionChangedsuch asObservableCollection<T>will bind to theirevent CollectionChanged - All other properties save through their woven setter (
virtualis 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 derivingNotifiyingJsonSettings) - Mark the settings class
[Autosave] - Install
nucs.JsonSettings.Autosavenuget package - Call
mySettings.EnableAutosave()extension after callingLoad
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 callsOnPropertyChanged()would notify twice. Framework-neutral: depends only onSystem.ComponentModel, not on WPF. NotificationGuardcontrols 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); frameworkFileName/Modulation/Versionnever 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]injectsINotifyPropertyChangedfor you (per-instance; best for a single class — a hierarchy should useNotifiyingJsonSettings+[NotifyChanges]). - Also raises
INotifyPropertyChangingbefore the change (onNotifiyingJsonSettings, 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 viaEnableNotificationMarshaling().
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 aJsonSettings-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 afterDispose()throwsObjectDisposedException.
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");
}
settingsparameter 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.this.OverrideSerializerSettingsis a property in every class inheritingJsonSettingsallowing personalized settings per object. TheOverrideSerializerSettingsproperty andResolveConfigurationmethod are bothvirtualand can be overriden to redirect the resolving to where ever you see fit or with what-ever predefined value.static JsonSettings.SerializationSettingsis the default for allJsonSettingsobjects.static JsonConvert.DefaultSettingsis 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; }. ModuleSocketstores all modules attached to thisJsonSettingsobject.- 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 | Versions 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. |
-
.NETFramework 4.8
- Newtonsoft.Json (>= 13.0.3)
-
.NETStandard 2.0
- Newtonsoft.Json (>= 13.0.3)
-
net10.0
- Newtonsoft.Json (>= 13.0.3)
-
net6.0
- Newtonsoft.Json (>= 13.0.3)
-
net8.0
- Newtonsoft.Json (>= 13.0.3)
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 |
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