feat: write app.json only when changed and fall back per invalid field
This commit is contained in:
1 parent
bb4f96a9c5
commit
4b56297135
5 files changed
+308
-25
No files matched your search
@@ -158,4 +158,148 @@ public class AppConfigStoreTests
|
||||
Assert.AreEqual(config.ColumnWidth.Preset, reloaded.ColumnWidth.Preset);
|
||||
Assert.AreEqual(config.Confirmations.DiscardEdit, reloaded.Confirmations.DiscardEdit);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LoadNonObjectRootBacksUpAndReturnsDefaults()
|
||||
{
|
||||
using var directory = new TempDirectory();
|
||||
string path = Path.Combine(directory.FullPath, "app.json");
|
||||
File.WriteAllText(path, "[1, 2]");
|
||||
var store = new AppConfigStore(path);
|
||||
|
||||
Assert.AreEqual(AppConfig.DefaultLanguage, store.Load().Language);
|
||||
Assert.AreEqual("[1, 2]", File.ReadAllText(path + ".bak"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("\"theme\": \"sepia\"")]
|
||||
[DataRow("\"theme\": 1")]
|
||||
[DataRow("\"theme\": {}")]
|
||||
[DataRow("\"language\": \"fr\"")]
|
||||
[DataRow("\"language\": \"zh-hans\"")]
|
||||
[DataRow("\"language\": 5")]
|
||||
[DataRow("\"sort\": { \"card\": \"created-at\" }")]
|
||||
[DataRow("\"sort\": 7")]
|
||||
[DataRow("\"column-width\": { \"preset\": \"huge\", \"custom-pixels\": 159 }")]
|
||||
[DataRow("\"column-width\": { \"custom-pixels\": 721 }")]
|
||||
[DataRow("\"column-width\": { \"custom-pixels\": \"wide\" }")]
|
||||
[DataRow("\"confirmations\": { \"delete-card\": \"nope\" }")]
|
||||
[DataRow("\"version\": \"one\"")]
|
||||
public void InvalidFieldFallsBackAloneWithoutBackup(string invalidMember)
|
||||
{
|
||||
using var directory = new TempDirectory();
|
||||
string path = Path.Combine(directory.FullPath, "app.json");
|
||||
|
||||
// The valid fields are non-defaults, so any whole-file fallback would show.
|
||||
File.WriteAllText(path, $$"""
|
||||
{
|
||||
"theme": "dark",
|
||||
"confirmations": { "discard-edit": false },
|
||||
{{invalidMember}}
|
||||
}
|
||||
""");
|
||||
var store = new AppConfigStore(path);
|
||||
|
||||
AppConfig config = store.Load();
|
||||
|
||||
Assert.IsFalse(File.Exists(path + ".bak"));
|
||||
AppConfig defaults = new();
|
||||
Assert.AreEqual(invalidMember.Contains("theme") ? defaults.Theme : ThemeOption.Dark, config.Theme);
|
||||
if (!invalidMember.Contains("confirmations"))
|
||||
{
|
||||
Assert.IsFalse(config.Confirmations.DiscardEdit);
|
||||
}
|
||||
Assert.AreEqual(defaults.Language, config.Language);
|
||||
Assert.AreEqual(defaults.Sort.Card, config.Sort.Card);
|
||||
Assert.AreEqual(defaults.ColumnWidth.Preset, config.ColumnWidth.Preset);
|
||||
Assert.AreEqual(defaults.ColumnWidth.CustomPixels, config.ColumnWidth.CustomPixels);
|
||||
Assert.AreEqual(defaults.Confirmations.DeleteCard, config.Confirmations.DeleteCard);
|
||||
Assert.AreEqual(defaults.Version, config.Version);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InvalidNestedFieldKeepsItsSiblings()
|
||||
{
|
||||
using var directory = new TempDirectory();
|
||||
string path = Path.Combine(directory.FullPath, "app.json");
|
||||
File.WriteAllText(path, """{ "column-width": { "preset": "bogus", "custom-pixels": 500 } }""");
|
||||
|
||||
AppConfig config = new AppConfigStore(path).Load();
|
||||
|
||||
Assert.AreEqual(ColumnWidthPreset.Standard, config.ColumnWidth.Preset);
|
||||
Assert.AreEqual(500, config.ColumnWidth.CustomPixels);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ColumnWidthBoundsAreInclusive()
|
||||
{
|
||||
using var directory = new TempDirectory();
|
||||
string path = Path.Combine(directory.FullPath, "app.json");
|
||||
|
||||
File.WriteAllText(path, """{ "column-width": { "custom-pixels": 160 } }""");
|
||||
Assert.AreEqual(160, new AppConfigStore(path).Load().ColumnWidth.CustomPixels);
|
||||
|
||||
File.WriteAllText(path, """{ "column-width": { "custom-pixels": 720 } }""");
|
||||
Assert.AreEqual(720, new AppConfigStore(path).Load().ColumnWidth.CustomPixels);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UnchangedConfigurationIsNotWritten()
|
||||
{
|
||||
using var directory = new TempDirectory();
|
||||
string path = Path.Combine(directory.FullPath, "app.json");
|
||||
var store = new AppConfigStore(path);
|
||||
|
||||
AppConfig config = store.Load();
|
||||
|
||||
Assert.IsFalse(store.SaveIfChanged(config));
|
||||
Assert.IsFalse(File.Exists(path));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FieldFallbackAloneDoesNotRewriteTheFile()
|
||||
{
|
||||
using var directory = new TempDirectory();
|
||||
string path = Path.Combine(directory.FullPath, "app.json");
|
||||
const string original = """{ "theme": "sepia" }""";
|
||||
File.WriteAllText(path, original);
|
||||
var store = new AppConfigStore(path);
|
||||
|
||||
AppConfig config = store.Load();
|
||||
|
||||
Assert.IsFalse(store.SaveIfChanged(config));
|
||||
Assert.AreEqual(original, File.ReadAllText(path));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ChangedConfigurationIsWrittenOnce()
|
||||
{
|
||||
using var directory = new TempDirectory();
|
||||
string path = Path.Combine(directory.FullPath, "app.json");
|
||||
var store = new AppConfigStore(path);
|
||||
AppConfig config = store.Load();
|
||||
|
||||
config.Sort.Card = CardSortOption.UpdatedAt;
|
||||
|
||||
Assert.IsTrue(store.SaveIfChanged(config));
|
||||
Assert.AreEqual(CardSortOption.UpdatedAt, new AppConfigStore(path).Load().Sort.Card);
|
||||
|
||||
// Saved state is the new baseline.
|
||||
Assert.IsFalse(store.SaveIfChanged(config));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ChangeRevertedWithinSessionIsNotWritten()
|
||||
{
|
||||
using var directory = new TempDirectory();
|
||||
string path = Path.Combine(directory.FullPath, "app.json");
|
||||
var store = new AppConfigStore(path);
|
||||
AppConfig config = store.Load();
|
||||
|
||||
config.Theme = ThemeOption.Dark;
|
||||
config.Theme = ThemeOption.FollowSystem;
|
||||
|
||||
Assert.IsFalse(store.SaveIfChanged(config));
|
||||
Assert.IsFalse(File.Exists(path));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace YKanBan.Services;
|
||||
/// <summary>
|
||||
/// Composition root for the whole process: owns the loaded configuration and
|
||||
/// the single open workspace session. The configuration is persisted once at
|
||||
/// shutdown; the session (lock plus connection) lives until disposal.
|
||||
/// shutdown, and only when this session changed it; the session (lock plus connection) lives until disposal.
|
||||
/// </summary>
|
||||
public sealed class AppServices : IDisposable
|
||||
{
|
||||
@@ -49,9 +49,9 @@ public sealed class AppServices : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists the configuration; called once at shutdown.
|
||||
/// Persists the configuration when this session changed a setting; called once at shutdown.
|
||||
/// </summary>
|
||||
public void SaveConfiguration() => ConfigStore.Save(Config);
|
||||
public void SaveConfiguration() => ConfigStore.SaveIfChanged(Config);
|
||||
|
||||
/// <summary>
|
||||
/// Releases the workspace session, if any.
|
||||
|
||||
@@ -141,6 +141,26 @@ public sealed class AppConfig
|
||||
/// </summary>
|
||||
public const string DefaultLanguage = "en";
|
||||
|
||||
/// <summary>
|
||||
/// Simplified Chinese UI language code.
|
||||
/// </summary>
|
||||
public const string SimplifiedChineseLanguage = "zh-Hans";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the supported UI language codes; any other stored value falls back to <see cref="DefaultLanguage"/>.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> SupportedLanguages { get; } = [DefaultLanguage, SimplifiedChineseLanguage];
|
||||
|
||||
/// <summary>
|
||||
/// Smallest accepted custom column width in pixels.
|
||||
/// </summary>
|
||||
public const int MinColumnPixels = 160;
|
||||
|
||||
/// <summary>
|
||||
/// Largest accepted custom column width in pixels.
|
||||
/// </summary>
|
||||
public const int MaxColumnPixels = 720;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the configuration format version of this file.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace YKanBan.Storage.AppData;
|
||||
|
||||
/// <summary>
|
||||
/// Loads and saves app.json with graceful degradation: a missing file yields
|
||||
/// defaults; a corrupt file is backed up to app.json.bak and then defaults are
|
||||
/// used. Saving writes the file directly (no atomic replace) — losing the last
|
||||
/// session's settings to a crash is an accepted trade-off.
|
||||
/// defaults; a file that is not a JSON object is backed up to app.json.bak and
|
||||
/// then defaults are used; an individual invalid field (unknown enum value or
|
||||
/// language, out-of-range width, wrong type) falls back to its own default while
|
||||
/// the other fields are kept. Saving happens only when the configuration differs
|
||||
/// from what was loaded, and writes the file directly (no atomic replace) —
|
||||
/// losing the last session's settings to a crash is an accepted trade-off.
|
||||
/// </summary>
|
||||
public sealed class AppConfigStore
|
||||
{
|
||||
@@ -16,10 +20,24 @@ public sealed class AppConfigStore
|
||||
Formatting = Formatting.Indented,
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
|
||||
// Enums round-trip through the kebab-case strings declared via [EnumMember].
|
||||
Converters = { new StringEnumConverter() },
|
||||
// Enums round-trip through the kebab-case strings declared via [EnumMember];
|
||||
// numeric enum values are rejected like any other unknown value.
|
||||
Converters = { new StringEnumConverter { AllowIntegerValues = false } },
|
||||
};
|
||||
|
||||
private static readonly JsonSerializer FieldTolerantSerializer = JsonSerializer.Create(new JsonSerializerSettings
|
||||
{
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
Converters = { new StringEnumConverter { AllowIntegerValues = false } },
|
||||
|
||||
// The JSON text is already known to be well formed here, so every error is a
|
||||
// single member that fails to convert: skip it and keep that member's default.
|
||||
Error = (_, args) => args.ErrorContext.Handled = true,
|
||||
});
|
||||
|
||||
/// <summary>The serialized form of the configuration as last loaded or saved.</summary>
|
||||
private string _persistedSnapshot = Serialize(new AppConfig());
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a store bound to a specific app.json path.
|
||||
/// </summary>
|
||||
@@ -32,12 +50,57 @@ public sealed class AppConfigStore
|
||||
public string FilePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Loads the configuration. A missing file yields defaults; a corrupt file
|
||||
/// is backed up to app.json.bak and then defaults are used.
|
||||
/// Loads the configuration and records it as the persisted baseline for
|
||||
/// <see cref="SaveIfChanged"/>. A missing file yields defaults; a file that
|
||||
/// is not a JSON object is backed up to app.json.bak and then defaults are
|
||||
/// used; invalid fields fall back to their defaults individually.
|
||||
/// </summary>
|
||||
/// <returns>The loaded configuration, or defaults when none could be read.</returns>
|
||||
/// <exception cref="IOException">The file exists but cannot be read.</exception>
|
||||
public AppConfig Load()
|
||||
{
|
||||
AppConfig config = Read();
|
||||
|
||||
// The baseline is the effective configuration, so field fallbacks alone never trigger a write.
|
||||
_persistedSnapshot = Serialize(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the configuration only when it differs from the loaded or last
|
||||
/// saved one, i.e. when this session changed a setting.
|
||||
/// </summary>
|
||||
/// <param name="config">The live configuration.</param>
|
||||
/// <returns><see langword="true"/> when the file was written.</returns>
|
||||
public bool SaveIfChanged(AppConfig config)
|
||||
{
|
||||
string json = Serialize(config);
|
||||
if (json == _persistedSnapshot)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Write(json);
|
||||
_persistedSnapshot = json;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the configuration directly to disk (no atomic replace), unconditionally.
|
||||
/// </summary>
|
||||
/// <param name="config">The configuration to serialize.</param>
|
||||
public void Save(AppConfig config)
|
||||
{
|
||||
string json = Serialize(config);
|
||||
Write(json);
|
||||
_persistedSnapshot = json;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads and sanitizes the file.
|
||||
/// </summary>
|
||||
/// <returns>The effective configuration.</returns>
|
||||
private AppConfig Read()
|
||||
{
|
||||
// Missing file is a normal first-run state: run with defaults.
|
||||
if (!File.Exists(FilePath))
|
||||
@@ -46,42 +109,98 @@ public sealed class AppConfigStore
|
||||
}
|
||||
|
||||
string json = File.ReadAllText(FilePath);
|
||||
JToken root;
|
||||
try
|
||||
{
|
||||
// A JSON null literal deserializes to null; treat it like defaults.
|
||||
AppConfig? config = JsonConvert.DeserializeObject<AppConfig>(json, SerializerSettings);
|
||||
if (config is null)
|
||||
root = JToken.Parse(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return BackUpAndUseDefaults();
|
||||
}
|
||||
|
||||
// A JSON null literal carries no settings at all; treat it like a missing file.
|
||||
if (root.Type == JTokenType.Null)
|
||||
{
|
||||
return new AppConfig();
|
||||
}
|
||||
|
||||
// Well-formed JSON of the wrong shape has no field to keep: handle it like corrupt text.
|
||||
if (root is not JObject rootObject)
|
||||
{
|
||||
return BackUpAndUseDefaults();
|
||||
}
|
||||
|
||||
AppConfig config = rootObject.ToObject<AppConfig>(FieldTolerantSerializer) ?? new AppConfig();
|
||||
Sanitize(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps a copy of an unusable file for inspection, then continues with defaults.
|
||||
/// </summary>
|
||||
/// <returns>The default configuration.</returns>
|
||||
private AppConfig BackUpAndUseDefaults()
|
||||
{
|
||||
File.Copy(FilePath, FilePath + ".bak", overwrite: true);
|
||||
return new AppConfig();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces every value that converted but is outside its domain with the field default.
|
||||
/// </summary>
|
||||
/// <param name="config">The freshly deserialized configuration.</param>
|
||||
private static void Sanitize(AppConfig config)
|
||||
{
|
||||
var defaults = new AppConfig();
|
||||
|
||||
// Older or hand-edited files may miss whole sections; each falls back to its defaults.
|
||||
config.Sort ??= new SortSettings();
|
||||
config.ColumnWidth ??= new ColumnWidthSettings();
|
||||
config.Confirmations ??= new ConfirmationSettings();
|
||||
return config;
|
||||
}
|
||||
catch (JsonException)
|
||||
|
||||
if (config.Language is null || !AppConfig.SupportedLanguages.Contains(config.Language, StringComparer.Ordinal))
|
||||
{
|
||||
// Corrupt: keep a copy for inspection, then continue with defaults.
|
||||
File.Copy(FilePath, FilePath + ".bak", overwrite: true);
|
||||
return new AppConfig();
|
||||
config.Language = defaults.Language;
|
||||
}
|
||||
if (!Enum.IsDefined(config.Theme))
|
||||
{
|
||||
config.Theme = defaults.Theme;
|
||||
}
|
||||
if (!Enum.IsDefined(config.Sort.Card))
|
||||
{
|
||||
config.Sort.Card = defaults.Sort.Card;
|
||||
}
|
||||
if (!Enum.IsDefined(config.ColumnWidth.Preset))
|
||||
{
|
||||
config.ColumnWidth.Preset = defaults.ColumnWidth.Preset;
|
||||
}
|
||||
if (config.ColumnWidth.CustomPixels is < AppConfig.MinColumnPixels or > AppConfig.MaxColumnPixels)
|
||||
{
|
||||
config.ColumnWidth.CustomPixels = defaults.ColumnWidth.CustomPixels;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the configuration directly to disk (no atomic replace).
|
||||
/// Writes the serialized configuration, creating the parent folder on demand.
|
||||
/// </summary>
|
||||
/// <param name="config">The configuration to serialize.</param>
|
||||
public void Save(AppConfig config)
|
||||
/// <param name="json">The serialized configuration.</param>
|
||||
private void Write(string json)
|
||||
{
|
||||
// Create the parent folder on demand; normally it already exists.
|
||||
// Normally the folder already exists.
|
||||
string? directory = Path.GetDirectoryName(FilePath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.WriteAllText(FilePath, JsonConvert.SerializeObject(config, SerializerSettings));
|
||||
File.WriteAllText(FilePath, json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a configuration in the on-disk format.
|
||||
/// </summary>
|
||||
/// <param name="config">The configuration.</param>
|
||||
/// <returns>The JSON text.</returns>
|
||||
private static string Serialize(AppConfig config) => JsonConvert.SerializeObject(config, SerializerSettings);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ public sealed partial class SettingsDialogViewModel : ViewModelBase
|
||||
Languages =
|
||||
[
|
||||
new OptionItem(AppConfig.DefaultLanguage, Resources.Settings_LanguageEnglish),
|
||||
new OptionItem("zh-Hans", Resources.Settings_LanguageChinese),
|
||||
new OptionItem(AppConfig.SimplifiedChineseLanguage, Resources.Settings_LanguageChinese),
|
||||
];
|
||||
selectedLanguage = Languages.FirstOrDefault(option => (string)option.Value == _config.Language)
|
||||
?? Languages[0];
|
||||
|
||||
Reference in new issue
Block a user