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); } }