diff --git a/CHANGELOG.md b/CHANGELOG.md index 74a6f19..dd9277d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **JSON stores moved to System.Text.Json source generation.** `JsonSnipStore` + and `JsonSettingsStore` now serialise via a generated `JsonSerializerContext` + instead of the reflection-based serializer, removing the IL2026 trim warnings. + The on-disk format is unchanged (proven byte-identical by tests, with enum + names pinned via `[JsonStringEnumMemberName]`), so existing stores keep + loading. `PublishTrimmed` stays off: a trimmed WinUI publish still trips + IL2104 on the WinAppSDK/WinRT/Jdenticon assemblies, which aren't trim-safe. + ### Added - **Markdown rendering for snip descriptions.** A snip's description is now rendered as Markdown (headings, bold/italic, inline and block code, links, diff --git a/src/Snipdeck.App/Snipdeck.App.csproj b/src/Snipdeck.App/Snipdeck.App.csproj index b8030f5..dbfa9a4 100644 --- a/src/Snipdeck.App/Snipdeck.App.csproj +++ b/src/Snipdeck.App/Snipdeck.App.csproj @@ -45,12 +45,15 @@ False True - + False diff --git a/src/Snipdeck.Core/Models/CloseBehaviour.cs b/src/Snipdeck.Core/Models/CloseBehaviour.cs index 0f9487a..7807c89 100644 --- a/src/Snipdeck.Core/Models/CloseBehaviour.cs +++ b/src/Snipdeck.Core/Models/CloseBehaviour.cs @@ -1,8 +1,13 @@ +using System.Text.Json.Serialization; + namespace Snipdeck.Core.Models { public enum CloseBehaviour { + [JsonStringEnumMemberName("hideToTray")] HideToTray = 0, + + [JsonStringEnumMemberName("exit")] Exit = 1, } } diff --git a/src/Snipdeck.Core/Models/HotkeyBinding.cs b/src/Snipdeck.Core/Models/HotkeyBinding.cs index 774a7af..5ef2d4c 100644 --- a/src/Snipdeck.Core/Models/HotkeyBinding.cs +++ b/src/Snipdeck.Core/Models/HotkeyBinding.cs @@ -1,12 +1,23 @@ +using System.Text.Json.Serialization; + namespace Snipdeck.Core.Models { [Flags] public enum HotkeyModifiers { + [JsonStringEnumMemberName("none")] None = 0, + + [JsonStringEnumMemberName("alt")] Alt = 1, + + [JsonStringEnumMemberName("control")] Control = 2, + + [JsonStringEnumMemberName("shift")] Shift = 4, + + [JsonStringEnumMemberName("windows")] Windows = 8, } diff --git a/src/Snipdeck.Core/Models/ParameterType.cs b/src/Snipdeck.Core/Models/ParameterType.cs index eaa3aeb..08ed58c 100644 --- a/src/Snipdeck.Core/Models/ParameterType.cs +++ b/src/Snipdeck.Core/Models/ParameterType.cs @@ -1,8 +1,13 @@ +using System.Text.Json.Serialization; + namespace Snipdeck.Core.Models { public enum ParameterType { + [JsonStringEnumMemberName("text")] Text = 0, + + [JsonStringEnumMemberName("choice")] Choice = 1, } } diff --git a/src/Snipdeck.Core/Models/ThemePreference.cs b/src/Snipdeck.Core/Models/ThemePreference.cs index 0e0f3ec..4565733 100644 --- a/src/Snipdeck.Core/Models/ThemePreference.cs +++ b/src/Snipdeck.Core/Models/ThemePreference.cs @@ -1,9 +1,16 @@ +using System.Text.Json.Serialization; + namespace Snipdeck.Core.Models { public enum ThemePreference { + [JsonStringEnumMemberName("system")] System = 0, + + [JsonStringEnumMemberName("light")] Light = 1, + + [JsonStringEnumMemberName("dark")] Dark = 2, } } diff --git a/src/Snipdeck.Core/Services/JsonSettingsStore.cs b/src/Snipdeck.Core/Services/JsonSettingsStore.cs index 53cc04b..c85e9be 100644 --- a/src/Snipdeck.Core/Services/JsonSettingsStore.cs +++ b/src/Snipdeck.Core/Services/JsonSettingsStore.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using System.Text.Json.Serialization; using Snipdeck.Core.Abstractions; using Snipdeck.Core.Models; @@ -8,16 +7,6 @@ namespace Snipdeck.Core.Services { public sealed class JsonSettingsStore : ISettingsStore, IDisposable { - private static readonly JsonSerializerOptions _serializerOptions = new() - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - Converters = - { - new JsonStringEnumConverter(JsonNamingPolicy.CamelCase), - }, - }; - private readonly SemaphoreSlim _gate = new(1, 1); public JsonSettingsStore(string filePath) @@ -45,7 +34,7 @@ public async Task LoadAsync(CancellationToken cancellationToken = def FileShare.Read); var config = await JsonSerializer - .DeserializeAsync(stream, _serializerOptions, cancellationToken) + .DeserializeAsync(stream, StoreJsonContext.Default.AppConfig, cancellationToken) .ConfigureAwait(false); if (config is null) @@ -92,7 +81,7 @@ public async Task SaveAsync(AppConfig config, CancellationToken cancellationToke FileShare.None)) { await JsonSerializer - .SerializeAsync(stream, config, _serializerOptions, cancellationToken) + .SerializeAsync(stream, config, StoreJsonContext.Default.AppConfig, cancellationToken) .ConfigureAwait(false); await stream.FlushAsync(cancellationToken).ConfigureAwait(false); } diff --git a/src/Snipdeck.Core/Services/JsonSnipStore.cs b/src/Snipdeck.Core/Services/JsonSnipStore.cs index 4ac79a8..dcb4527 100644 --- a/src/Snipdeck.Core/Services/JsonSnipStore.cs +++ b/src/Snipdeck.Core/Services/JsonSnipStore.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using System.Text.Json.Serialization; using Snipdeck.Core.Abstractions; using Snipdeck.Core.Models; @@ -8,13 +7,6 @@ namespace Snipdeck.Core.Services { public sealed class JsonSnipStore : ISnipStore, IDisposable { - private static readonly JsonSerializerOptions _serializerOptions = new() - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, - }; - private readonly SemaphoreSlim _gate = new(1, 1); public JsonSnipStore(string filePath) @@ -42,7 +34,7 @@ public async Task LoadAsync(CancellationToken cancellationTok FileShare.Read); var document = await JsonSerializer - .DeserializeAsync(stream, _serializerOptions, cancellationToken) + .DeserializeAsync(stream, StoreJsonContext.Default.SnipStoreDocument, cancellationToken) .ConfigureAwait(false) ?? new SnipStoreDocument(); @@ -80,7 +72,7 @@ public async Task SaveAsync(SnipStoreDocument document, CancellationToken cancel FileShare.None)) { await JsonSerializer - .SerializeAsync(stream, document, _serializerOptions, cancellationToken) + .SerializeAsync(stream, document, StoreJsonContext.Default.SnipStoreDocument, cancellationToken) .ConfigureAwait(false); await stream.FlushAsync(cancellationToken).ConfigureAwait(false); } diff --git a/src/Snipdeck.Core/Services/StoreJsonContext.cs b/src/Snipdeck.Core/Services/StoreJsonContext.cs new file mode 100644 index 0000000..13e4cb1 --- /dev/null +++ b/src/Snipdeck.Core/Services/StoreJsonContext.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.Services +{ + /// + /// System.Text.Json source-generated metadata for the persisted documents. + /// Using generated + /// instead of the reflection-based serializer keeps the JSON stores trim-safe + /// (no IL2026) so the app can be published with PublishTrimmed. + /// + /// The options here must reproduce the previous reflection serializer's wire + /// format exactly (camelCase properties, camelCase string enums) so existing + /// store/settings files keep loading — see StoreJsonCompatibilityTests. + /// + [JsonSourceGenerationOptions( + WriteIndented = true, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + UseStringEnumConverter = true)] + [JsonSerializable(typeof(SnipStoreDocument))] + [JsonSerializable(typeof(AppConfig))] + public sealed partial class StoreJsonContext : JsonSerializerContext + { + } +} diff --git a/tests/Snipdeck.Core.Tests/Services/StoreJsonCompatibilityTests.cs b/tests/Snipdeck.Core.Tests/Services/StoreJsonCompatibilityTests.cs new file mode 100644 index 0000000..a29aafc --- /dev/null +++ b/tests/Snipdeck.Core.Tests/Services/StoreJsonCompatibilityTests.cs @@ -0,0 +1,102 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using Snipdeck.Core.Models; +using Snipdeck.Core.Services; + +namespace Snipdeck.Core.Tests.Services +{ + /// + /// Proves the source-generated serializes + /// byte-for-byte identically to the previous reflection-based serializer, so + /// existing store/settings files on disk keep loading after the switch. Runs + /// entirely on Linux, which de-risks the format change that the trimmed + /// publish (PublishTrimmed) depends on. + /// + public class StoreJsonCompatibilityTests + { + // Mirrors the options the JSON stores used before source-gen. + private static readonly JsonSerializerOptions _legacyOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + }; + + private static SnipStoreDocument SampleDocument() + { + var cli = new Cli { Name = "pl-app", IconRef = "icons/pl.png" }; + return new SnipStoreDocument + { + Clis = [cli], + Snips = + [ + new Snip + { + CliId = cli.Id, + Title = "Deploy", + CommandTemplate = "pl-app deploy --env {env}", + Description = "Deploys to **{env}**.", + Parameters = + [ + new Parameter + { + Name = "env", + Type = ParameterType.Choice, + Options = ["dev", "prod"], + Default = "dev", + }, + ], + Tags = ["deploy", "ops"], + IsFavourite = true, + IsTrash = false, + UsageCount = 3, + LastUsedAt = new DateTimeOffset(2026, 5, 30, 9, 0, 0, TimeSpan.Zero), + }, + ], + }; + } + + private static AppConfig SampleConfig() => new() + { + StoragePath = @"C:\data\store.json", + BackupRetention = 25, + Theme = ThemePreference.Dark, + CloseBehaviour = CloseBehaviour.HideToTray, + Hotkey = HotkeyBinding.Default, + }; + + [Fact] + public void Document_source_gen_output_is_byte_identical_to_legacy() + { + var doc = SampleDocument(); + var legacy = JsonSerializer.Serialize(doc, _legacyOptions); + var generated = JsonSerializer.Serialize(doc, StoreJsonContext.Default.SnipStoreDocument); + Assert.Equal(legacy, generated); + } + + [Fact] + public void Config_source_gen_output_is_byte_identical_to_legacy() + { + var config = SampleConfig(); + var legacy = JsonSerializer.Serialize(config, _legacyOptions); + var generated = JsonSerializer.Serialize(config, StoreJsonContext.Default.AppConfig); + Assert.Equal(legacy, generated); + } + + [Fact] + public void Legacy_json_round_trips_through_the_source_gen_context() + { + var doc = SampleDocument(); + var legacyJson = JsonSerializer.Serialize(doc, _legacyOptions); + + var restored = JsonSerializer.Deserialize(legacyJson, StoreJsonContext.Default.SnipStoreDocument); + + Assert.NotNull(restored); + Assert.Equal("pl-app", restored!.Clis[0].Name); + Assert.Equal(ParameterType.Choice, restored.Snips[0].Parameters[0].Type); + Assert.True(restored.Snips[0].IsFavourite); + Assert.Equal(doc.Snips[0].LastUsedAt, restored.Snips[0].LastUsedAt); + } + } +}