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