diff --git a/OpenNest.Data/UserSettingsFile.cs b/OpenNest.Data/UserSettingsFile.cs
new file mode 100644
index 00000000..a4d86f37
--- /dev/null
+++ b/OpenNest.Data/UserSettingsFile.cs
@@ -0,0 +1,79 @@
+using System.Text.Json;
+
+namespace OpenNest.Data;
+
+///
+/// The desktop apps' per-user settings file, %APPDATA%\OpenNest\user-settings.json: one JSON
+/// object of setting names to their text values. The WinForms app writes it through
+/// AppDataSettingsProvider (values in System.Configuration's invariant text form, e.g.
+/// "1936, 1048" for a size); the Avalonia app reads and writes the same names, so both apps
+/// share one file on Windows.
+///
+public static class UserSettingsFile
+{
+ private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+
+ /// %APPDATA%\OpenNest\user-settings.json (~/.config/OpenNest on Linux).
+ public static string DefaultPath => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "OpenNest", "user-settings.json");
+
+ ///
+ /// The stored values. A missing, unreadable or corrupt file reads as empty, and an entry
+ /// whose value is not text is skipped; reading never changes the file.
+ ///
+ public static Dictionary Read(string path)
+ {
+ var values = new Dictionary();
+ if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
+ return values;
+
+ try
+ {
+ using var document = JsonDocument.Parse(File.ReadAllText(path));
+ if (document.RootElement.ValueKind != JsonValueKind.Object)
+ return values;
+
+ foreach (var property in document.RootElement.EnumerateObject())
+ {
+ if (property.Value.ValueKind == JsonValueKind.String)
+ values[property.Name] = property.Value.GetString()!;
+ }
+ }
+ catch (Exception ex) when (SettingsFile.IsUnreadableFile(ex))
+ {
+ values.Clear();
+ }
+
+ return values;
+ }
+
+ ///
+ /// Replaces the file with through a temporary file. Settings are a
+ /// convenience: a failed write returns false and leaves the previous file in place.
+ ///
+ public static bool Write(string path, IReadOnlyDictionary values)
+ {
+ try
+ {
+ SettingsFile.Write(path, JsonSerializer.Serialize(values, JsonOptions));
+ return true;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Re-reads the file, applies and writes it back, so entries this
+ /// build does not know (from a newer copy, or the other desktop app) are kept.
+ ///
+ public static bool Update(string path, Action> change)
+ {
+ ArgumentNullException.ThrowIfNull(change);
+ var values = Read(path);
+ change(values);
+ return Write(path, values);
+ }
+}
diff --git a/OpenNest.Tests/Data/UserSettingsFileTests.cs b/OpenNest.Tests/Data/UserSettingsFileTests.cs
new file mode 100644
index 00000000..7680078a
--- /dev/null
+++ b/OpenNest.Tests/Data/UserSettingsFileTests.cs
@@ -0,0 +1,122 @@
+using OpenNest.Data;
+
+namespace OpenNest.Tests.Data;
+
+public class UserSettingsFileTests : IDisposable
+{
+ private readonly string _directory = Path.Combine(
+ Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString());
+ private readonly string _path;
+
+ public UserSettingsFileTests()
+ {
+ Directory.CreateDirectory(_directory);
+ _path = Path.Combine(_directory, "user-settings.json");
+ }
+
+ public void Dispose() => Directory.Delete(_directory, recursive: true);
+
+ [Fact]
+ public void DefaultPath_IsTheWinFormsSettingsFile()
+ {
+ Assert.Equal(
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "OpenNest", "user-settings.json"),
+ UserSettingsFile.DefaultPath);
+ }
+
+ [Fact]
+ public void Read_MissingFile_IsEmptyAndCreatesNothing()
+ {
+ Assert.Empty(UserSettingsFile.Read(_path));
+ Assert.False(File.Exists(_path));
+ }
+
+ [Fact]
+ public void Read_ValuesTheWinFormsAppWrote()
+ {
+ File.WriteAllText(_path, """
+ {
+ "MainWindowSize": "1936, 1048",
+ "MainWindowState": "Maximized",
+ "LastFeedrate": "100"
+ }
+ """);
+
+ var values = UserSettingsFile.Read(_path);
+
+ Assert.Equal("1936, 1048", values["MainWindowSize"]);
+ Assert.Equal("Maximized", values["MainWindowState"]);
+ Assert.Equal("100", values["LastFeedrate"]);
+ }
+
+ [Theory]
+ [InlineData("{ not json")]
+ [InlineData("[\"a\"]")]
+ [InlineData("")]
+ public void Read_BrokenFile_IsEmptyAndLeavesTheFileAlone(string content)
+ {
+ File.WriteAllText(_path, content);
+
+ Assert.Empty(UserSettingsFile.Read(_path));
+ Assert.Equal(content, File.ReadAllText(_path));
+ }
+
+ [Fact]
+ public void Read_SkipsEntriesThatAreNotText()
+ {
+ File.WriteAllText(_path, """{ "Kept": "x", "Number": 5, "Nested": { "a": "b" } }""");
+
+ var values = UserSettingsFile.Read(_path);
+
+ Assert.Equal(new Dictionary { ["Kept"] = "x" }, values);
+ }
+
+ [Fact]
+ public void Update_KeepsEntriesItDoesNotChange()
+ {
+ File.WriteAllText(_path, """{ "FromNewerBuild": "x", "MainWindowState": "Normal" }""");
+
+ var updated = UserSettingsFile.Update(
+ _path, values => values["MainWindowState"] = "Maximized");
+
+ Assert.True(updated);
+
+ var values = UserSettingsFile.Read(_path);
+ Assert.Equal("x", values["FromNewerBuild"]);
+ Assert.Equal("Maximized", values["MainWindowState"]);
+ Assert.False(File.Exists(_path + ".tmp"));
+ }
+
+ [Fact]
+ public void Update_BrokenFile_WritesAReadableFile()
+ {
+ File.WriteAllText(_path, "{ not json");
+
+ Assert.True(UserSettingsFile.Update(_path, values => values["MainWindowState"] = "Normal"));
+
+ Assert.Equal("Normal", UserSettingsFile.Read(_path)["MainWindowState"]);
+ }
+
+ [Fact]
+ public void Update_CreatesTheFolder()
+ {
+ var path = Path.Combine(_directory, "new", "user-settings.json");
+
+ Assert.True(UserSettingsFile.Update(path, values => values["A"] = "1"));
+
+ Assert.Equal("1", UserSettingsFile.Read(path)["A"]);
+ }
+
+ [Fact]
+ public void Write_FailureReturnsFalseWithoutThrowing()
+ {
+ // A file where the settings folder should be makes the write fail.
+ var blocker = Path.Combine(_directory, "blocker");
+ File.WriteAllText(blocker, "");
+
+ Assert.False(UserSettingsFile.Write(
+ Path.Combine(blocker, "user-settings.json"),
+ new Dictionary { ["A"] = "1" }));
+ }
+}
diff --git a/OpenNest/Properties/AppDataSettingsProvider.cs b/OpenNest/Properties/AppDataSettingsProvider.cs
index 6026d3f2..4573374f 100644
--- a/OpenNest/Properties/AppDataSettingsProvider.cs
+++ b/OpenNest/Properties/AppDataSettingsProvider.cs
@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Collections.Specialized;
using System.Configuration;
using System.IO;
-using System.Text.Json;
+using OpenNest.Data;
namespace OpenNest.Properties
{
@@ -20,8 +20,6 @@ namespace OpenNest.Properties
///
public sealed class AppDataSettingsProvider : SettingsProvider
{
- private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
-
private readonly string _path;
private readonly Func _legacyProvider;
@@ -40,9 +38,7 @@ namespace OpenNest.Properties
internal static string PathOverride { get; set; }
/// %APPDATA%\OpenNest\user-settings.json.
- public static string DefaultPath => PathOverride ?? Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
- "OpenNest", "user-settings.json");
+ public static string DefaultPath => PathOverride ?? UserSettingsFile.DefaultPath;
public override string ApplicationName { get; set; } = "OpenNest";
@@ -91,19 +87,7 @@ namespace OpenNest.Properties
Write(stored);
}
- private Dictionary Load()
- {
- try
- {
- return File.Exists(_path)
- ? JsonSerializer.Deserialize>(File.ReadAllText(_path)) ?? new()
- : new();
- }
- catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
- {
- return new();
- }
- }
+ private Dictionary Load() => UserSettingsFile.Read(_path);
private Dictionary Migrate(SettingsContext context, SettingsPropertyCollection collection)
{
@@ -135,19 +119,8 @@ namespace OpenNest.Properties
return stored;
}
- private void Write(Dictionary stored)
- {
- try
- {
- Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(_path)));
- var temp = _path + ".tmp";
- File.WriteAllText(temp, JsonSerializer.Serialize(stored, JsonOptions));
- File.Move(temp, _path, overwrite: true);
- }
- catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
- {
- // Settings are a convenience: keep running with the in-memory values.
- }
- }
+ // Settings are a convenience: a failed write keeps running with the in-memory values.
+ private void Write(Dictionary stored) =>
+ UserSettingsFile.Write(_path, stored);
}
}